From e68a2ad0d792e4cf148efb075485272a7398302f Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Fri, 26 Jun 2026 17:19:26 +0800 Subject: [PATCH] fix(security): single-source the request authorization resolver (REST dropped sys_user_role) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The REST server (@objectstack/rest) and the runtime dispatcher (@objectstack/runtime) each carried their own copy of the request -> ExecutionContext identity/role resolver. They drifted on a security path: the REST copy silently omitted sys_user_role (so custom roles granted via the ADR-0057 D4 platform-RBAC path did not apply over REST), sys_role_permission_set, the owner->org_owner membership normalization, the platform-admin derivation, and the ai_seat synthesis. Fail-closed (legitimate access denied), not an escalation. Root-fix (finishes the extraction the API-key verifier already modelled): a single shared resolver `resolveAuthzContext` in @objectstack/core/security — the lowest common dependency of both rest and runtime. Both entry points now delegate; no parallel copies to keep in sync. - core: resolveAuthzContext + resolveLocalizationContext (complete role/permission aggregation) + a contract test asserting every authorization source is honored. - runtime: resolveExecutionContext delegates (469 -> 138 lines). - rest: rest-server's identity resolver delegates (drops its divergent ~180-line copy). - scripts/check-single-authz-resolver.mjs + a lint CI step: guard against a future duplicate resolver or a dropped delegation. Verified: contract test 7/7; suites green (runtime 444/444, rest 130/130, core green); full turbo build green; ESLint clean; dogfooded — a user granted `contributor` via sys_user_role now resolves that role on the REST data path (previously roles []). Co-Authored-By: Claude Opus 4.8 --- .changeset/unify-authz-resolver.md | 11 + .github/workflows/lint.yml | 7 + package.json | 3 +- packages/core/src/security/index.ts | 8 + .../security/resolve-authz-context.test.ts | 111 +++++ .../src/security/resolve-authz-context.ts | 315 ++++++++++++ packages/rest/src/rest-server.ts | 212 ++------- .../src/security/resolve-execution-context.ts | 450 +++--------------- scripts/check-single-authz-resolver.mjs | 85 ++++ 9 files changed, 637 insertions(+), 565 deletions(-) create mode 100644 .changeset/unify-authz-resolver.md create mode 100644 packages/core/src/security/resolve-authz-context.test.ts create mode 100644 packages/core/src/security/resolve-authz-context.ts create mode 100644 scripts/check-single-authz-resolver.mjs diff --git a/.changeset/unify-authz-resolver.md b/.changeset/unify-authz-resolver.md new file mode 100644 index 0000000000..1a2aabb62d --- /dev/null +++ b/.changeset/unify-authz-resolver.md @@ -0,0 +1,11 @@ +--- +"@objectstack/rest": patch +"@objectstack/runtime": patch +"@objectstack/core": patch +--- + +fix(security): single-source the request authorization resolver — REST no longer drops sys_user_role + +The REST server and the runtime dispatcher each carried their own copy of the request → ExecutionContext identity/role resolver, and they drifted on a security path. The REST copy silently omitted `sys_user_role` (so custom roles granted via the ADR-0057 D4 platform-RBAC path did not apply over REST), `sys_role_permission_set`, the `owner→org_owner` membership normalization, the platform-admin derivation, and the `ai_seat` synthesis — fail-closed (legitimate access denied), not an escalation. + +Both entry points now delegate to a single shared resolver, `resolveAuthzContext` in `@objectstack/core/security` (joining the API-key verifier that already lived there). A contract test locks every authorization source and a lint gate (`check:authz-resolver`) prevents a future duplicate resolver or a dropped delegation. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index edecf70d75..0d0884d29b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -58,6 +58,13 @@ jobs: - name: Doc/skill authoring guard run: pnpm check:doc-authoring + # Authorization resolution must stay single-sourced (resolveAuthzContext, + # @objectstack/core). Guards against a duplicate resolver copy drifting on a + # security path (the REST-vs-dispatcher sys_user_role drift) and against an + # entry point silently dropping the delegation. + - name: Single authz resolver guard + run: pnpm check:authz-resolver + typecheck: name: TypeScript Type Check runs-on: ubuntu-latest diff --git a/package.json b/package.json index 73dd8cbe8e..b8c87dfede 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "objectui:refresh": "bash scripts/bump-objectui.sh && bash scripts/build-console.sh", "objectui:clean": "rm -rf packages/console/dist .cache/objectui-*", "lint": "eslint . --no-inline-config", - "check:doc-authoring": "node scripts/check-doc-authoring.mjs" + "check:doc-authoring": "node scripts/check-doc-authoring.mjs", + "check:authz-resolver": "node scripts/check-single-authz-resolver.mjs" }, "keywords": [ "objectstack", diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 5a4abd9738..b1f30b161c 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -79,3 +79,11 @@ export { type GeneratedApiKey, type ApiKeyPrincipal, } from './api-key.js'; + +export { + resolveAuthzContext, + resolveLocalizationContext, + type ResolvedAuthzContext, + type ResolveAuthzInput, + type ResolveLocalizationInput, +} from './resolve-authz-context.js'; diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts new file mode 100644 index 0000000000..e035be0dc7 --- /dev/null +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { resolveAuthzContext } from './resolve-authz-context.js'; + +/** + * Contract test for the SINGLE authorization resolver. Every authorization + * source MUST be honored here — this is the regression net that would have + * caught the REST-vs-dispatcher drift (the REST copy had silently dropped + * sys_user_role / sys_role_permission_set / platform_admin / ai_seat). + */ + +// Minimal in-memory ObjectQL: find(object, { where }) with `===` + `$in` match. +function makeQl(tables: Record) { + return { + async find(object: string, opts: any) { + const rows = tables[object] ?? []; + const where = opts?.where ?? {}; + return rows.filter((r) => + Object.entries(where).every(([k, v]) => { + if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(r[k]); + return r[k] === v; + }), + ); + }, + }; +} +const session = (userId: string, opts: { email?: string; org?: string } = {}) => + async () => ({ user: { id: userId, email: opts.email }, session: { activeOrganizationId: opts.org ?? null } }); +const H = () => new Headers(); + +describe('resolveAuthzContext — single source of truth', () => { + it('resolves a custom role granted via sys_user_role (the REST-drift bug)', async () => { + const ql = makeQl({ + sys_user: [{ id: 'u1', email: 'ada@x.com' }], + sys_member: [], + sys_user_role: [{ user_id: 'u1', role: 'contributor', organization_id: null }], + sys_user_permission_set: [], + }); + const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); + expect(ctx.roles).toContain('contributor'); + }); + + it('normalizes sys_member org roles (owner -> org_owner)', async () => { + const ql = makeQl({ + sys_user: [{ id: 'u1' }], + sys_member: [{ user_id: 'u1', role: 'owner', organization_id: 'o1' }], + sys_user_role: [], + sys_user_permission_set: [], + }); + const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); + expect(ctx.roles).toContain('org_owner'); + }); + + it('resolves role-bound permission sets (sys_role_permission_set)', async () => { + const ql = makeQl({ + sys_user: [{ id: 'u1' }], + sys_member: [], + sys_user_role: [{ user_id: 'u1', role: 'contributor', organization_id: null }], + sys_user_permission_set: [], + sys_role: [{ id: 'r1', name: 'contributor' }], + sys_role_permission_set: [{ role_id: 'r1', permission_set_id: 'ps1' }], + sys_permission_set: [{ id: 'ps1', name: 'contributor_ps', system_permissions: ['cap_x'] }], + }); + const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); + expect(ctx.permissions).toContain('contributor_ps'); + expect(ctx.systemPermissions).toContain('cap_x'); + }); + + it('derives platform_admin from an UNSCOPED admin_full_access user grant', async () => { + const ql = makeQl({ + sys_user: [{ id: 'u1' }], + sys_member: [], + sys_user_role: [], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', organization_id: null }], + sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], + }); + const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); + expect(ctx.roles).toContain('platform_admin'); + }); + + it('does NOT derive platform_admin from an ORG-scoped admin_full_access grant', async () => { + const ql = makeQl({ + sys_user: [{ id: 'u1' }], + sys_member: [], + sys_user_role: [], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', organization_id: 'o1' }], + sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], + }); + const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); + expect(ctx.roles).not.toContain('platform_admin'); + }); + + it('synthesizes ai_seat from sys_user.ai_access (sqlite integer 1)', async () => { + const ql = makeQl({ + sys_user: [{ id: 'u1', ai_access: 1 }], + sys_member: [], + sys_user_role: [], + sys_user_permission_set: [], + }); + const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); + expect(ctx.permissions).toContain('ai_seat'); + }); + + it('anonymous (no session, no api key) → empty context', async () => { + const ctx = await resolveAuthzContext({ ql: makeQl({}), headers: H(), getSession: async () => undefined }); + expect(ctx.userId).toBeUndefined(); + expect(ctx.roles).toEqual([]); + expect(ctx.permissions).toEqual([]); + }); +}); diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts new file mode 100644 index 0000000000..b4798f3934 --- /dev/null +++ b/packages/core/src/security/resolve-authz-context.ts @@ -0,0 +1,315 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * resolveAuthzContext — the SINGLE source of truth for resolving an inbound + * request's identity + authorization context (roles, permissions, RLS scoping). + * + * Every HTTP entry point (REST server, runtime dispatcher, MCP, any future + * transport) MUST resolve authorization through this function — never by + * re-reading `sys_member` / `sys_user_role` / `sys_*_permission_set` itself. + * + * Why this exists: authorization resolution used to be DUPLICATED across the + * REST server (`@objectstack/rest`) and the runtime dispatcher + * (`@objectstack/runtime`). On a security path, duplicated logic drifts and the + * drift is silent: the REST copy had quietly omitted `sys_user_role` (so custom + * roles granted via the ADR-0057 D4 platform-RBAC path didn't apply over REST), + * `sys_role_permission_set`, `mapMembershipRole` normalization, the + * platform-admin derivation, and the `ai_seat` synthesis. The API-key half was + * already shared here (`resolveApiKeyPrincipal`); this completes the extraction + * by bringing session + role/permission aggregation home too. There is now ONE + * implementation; both entry points are thin adapters that supply `ql` / + * `getSession` their own way and delegate here. + * + * Fail-closed: every read is defensive. Missing services / tables yield a + * partial context (even `{ roles: [], permissions: [] }`) — enforcement is the + * SecurityPlugin's job, never this resolver's. + */ + +import { + mapMembershipRole, + BUILTIN_ROLE_PLATFORM_ADMIN, + ADMIN_FULL_ACCESS, +} from '@objectstack/spec'; + +import { resolveApiKeyPrincipal } from './api-key.js'; + +/** The transport-agnostic authorization envelope produced from a request. */ +export interface ResolvedAuthzContext { + userId?: string; + tenantId?: string; + email?: string; + accessToken?: string; + roles: string[]; + permissions: string[]; + systemPermissions: string[]; + tabPermissions?: Record; + /** Fellow-org user IDs for RLS scoping of identity tables (`id IN (...)`). */ + org_user_ids: string[]; +} + +export interface ResolveAuthzInput { + /** Data engine (ObjectQL) exposing `find(object, { where, limit, context })`. */ + ql: any; + /** Inbound request headers (Web `Headers` or a plain record). */ + headers: any; + /** + * Resolve a better-auth session from `headers`, returning `{ user?, session? }` + * (or undefined). Optional — when omitted or throwing, only the API-key path + * runs and anonymous requests resolve to an empty context. + */ + getSession?: (headers: any) => Promise | any; + /** Clock injection for API-key expiry (tests). */ + nowMs?: number; +} + +function safeJsonParse(s: string, fallback: T): T { + try { return JSON.parse(s) as T; } catch { return fallback; } +} + +async function tryFind(ql: any, object: string, where: any, limit = 100): Promise { + if (!ql || typeof ql.find !== 'function') return []; + try { + let rows = await ql.find(object, { where, limit, context: { isSystem: true } } as any); + if (rows && (rows as any).value) rows = (rows as any).value; + return Array.isArray(rows) ? rows : []; + } catch { + return []; + } +} + +/** + * Resolve the authorization context for an inbound request. Always resolves — + * never throws. Anonymous requests yield `{ roles: [], permissions: [], ... }`. + */ +export async function resolveAuthzContext(input: ResolveAuthzInput): Promise { + const { ql, headers } = input; + const ctx: ResolvedAuthzContext = { + roles: [], + permissions: [], + systemPermissions: [], + org_user_ids: [], + }; + + let userId: string | undefined; + let tenantId: string | undefined; + + // 1. API key (explicit opt-in via header) takes precedence over session. + const keyPrincipal = await resolveApiKeyPrincipal(ql, headers, input.nowMs); + if (keyPrincipal) { + userId = keyPrincipal.userId; + tenantId = keyPrincipal.tenantId; + for (const scope of keyPrincipal.scopes) { + if (!ctx.permissions.includes(scope)) ctx.permissions.push(scope); + } + } + + // 2. Session / Bearer path — fall back when no API key resolved a user. + if (!userId && typeof input.getSession === 'function') { + try { + const sessionData = await input.getSession(headers); + userId = sessionData?.user?.id ?? sessionData?.session?.userId; + tenantId = tenantId ?? sessionData?.session?.activeOrganizationId; + ctx.accessToken = sessionData?.session?.token ?? ctx.accessToken; + if (sessionData?.user?.email) ctx.email = String(sessionData.user.email); + } catch { + // no auth configured / bad session → anonymous + } + } + + if (!userId) return ctx; + ctx.userId = userId; + if (tenantId) ctx.tenantId = tenantId; + if (!ql || typeof ql.find !== 'function') return ctx; + + // Resolve the caller's unique email for `current_user.email` RLS owner + // policies when the session path didn't supply it (e.g. API-key auth). + if (!ctx.email) { + const userRows = await tryFind(ql, 'sys_user', { id: userId }, 1); + if (userRows[0]?.email) ctx.email = String(userRows[0].email); + } + + // 3. Organization-administration roles via sys_member (better-auth), normalized + // to the canonical built-in names (owner→org_owner, admin→org_admin, …). + const memberWhere: any = tenantId + ? { user_id: userId, organization_id: tenantId } + : { user_id: userId }; + const members = await tryFind(ql, 'sys_member', memberWhere, 50); + for (const m of members) { + if (m.role && typeof m.role === 'string') { + for (const raw of m.role.split(',').map((s: string) => s.trim()).filter(Boolean)) { + const r = mapMembershipRole(raw); + if (!ctx.roles.includes(r)) ctx.roles.push(r); + } + } + } + + // 4. [ADR-0057 D4] Platform-owned RBAC role assignments (sys_user_role) — the + // source of truth for custom roles, decoupled from sys_member.role. + // `organization_id = null` = global (cross-tenant); else match active org. + const userRoleRows = await tryFind(ql, 'sys_user_role', { user_id: userId }, 200); + for (const ur of userRoleRows) { + const org = ur.organization_id ?? null; + if (org && tenantId && org !== tenantId) continue; + const r = ur.role; + if (typeof r === 'string' && r && !ctx.roles.includes(r)) ctx.roles.push(r); + } + + // 5. Fellow-org user IDs so RLS can scope identity tables to collaborators. + if (tenantId) { + const orgMembers = await tryFind(ql, 'sys_member', { organization_id: tenantId }, 1000); + const ids = new Set( + orgMembers + .map((m) => m.user_id ?? m.userId) + .filter((v): v is string => typeof v === 'string' && v.length > 0), + ); + ids.add(userId); + ctx.org_user_ids = Array.from(ids); + } else { + ctx.org_user_ids = [userId]; + } + + // 6. Permission sets — user-scoped grants (null org = global, else active org). + const upsRows = await tryFind(ql, 'sys_user_permission_set', { user_id: userId }, 100); + const psIds = new Set( + upsRows + .filter((r) => { + const org = (r.organization_id ?? r.organizationId) ?? null; + return !(org && tenantId && org !== tenantId); + }) + .map((r) => r.permission_set_id ?? r.permissionSetId) + .filter(Boolean), + ); + // platform_admin (ADR-0068 D2) is DERIVED from an UNSCOPED admin_full_access + // USER grant — the single source of truth (no trusted stored boolean). + const unscopedUserPsIds = new Set( + upsRows + .filter((r) => ((r.organization_id ?? r.organizationId) ?? null) === null) + .map((r) => r.permission_set_id ?? r.permissionSetId) + .filter(Boolean), + ); + let hasPlatformAdminGrant = false; + + // 6a. Role-bound permission sets (sys_role_permission_set): a role carries its + // permission sets. + if (ctx.roles.length > 0) { + const roleRows = await tryFind(ql, 'sys_role', { name: { $in: ctx.roles } }, 100); + const roleIds = roleRows.map((r) => r.id).filter(Boolean); + if (roleIds.length > 0) { + const rpsRows = await tryFind(ql, 'sys_role_permission_set', { role_id: { $in: roleIds } }, 500); + for (const r of rpsRows) { + const id = r.permission_set_id ?? r.permissionSetId; + if (id) psIds.add(id); + } + } + } + + // 6b. Resolve permission-set details (names → ctx.permissions; system_permissions; + // tab_permissions merged by highest visibility). + if (psIds.size > 0) { + const psRows = await tryFind(ql, 'sys_permission_set', { id: { $in: Array.from(psIds) } }, 500); + const tabRank: Record = { hidden: 0, default_off: 1, default_on: 2, visible: 3 }; + const mergedTabs: Record = {}; + for (const ps of psRows) { + if (ps.name && !ctx.permissions.includes(ps.name)) ctx.permissions.push(ps.name); + if (ps.name === ADMIN_FULL_ACCESS && unscopedUserPsIds.has(ps.id)) hasPlatformAdminGrant = true; + const sysPerms = typeof ps.system_permissions === 'string' + ? safeJsonParse(ps.system_permissions, []) + : (ps.system_permissions ?? ps.systemPermissions); + if (Array.isArray(sysPerms)) { + for (const p of sysPerms) { + if (typeof p === 'string' && !ctx.systemPermissions.includes(p)) ctx.systemPermissions.push(p); + } + } + const tabs = typeof ps.tab_permissions === 'string' + ? safeJsonParse(ps.tab_permissions, {}) + : (ps.tab_permissions ?? ps.tabPermissions); + if (tabs && typeof tabs === 'object') { + for (const [app, val] of Object.entries(tabs as Record)) { + if (typeof val !== 'string' || !(val in tabRank)) continue; + const cur = mergedTabs[app]; + if (!cur || tabRank[val] > tabRank[cur]) { + mergedTabs[app] = val as 'visible' | 'hidden' | 'default_on' | 'default_off'; + } + } + } + } + if (Object.keys(mergedTabs).length > 0) ctx.tabPermissions = mergedTabs; + } + + // 6c. Project the derived platform_admin built-in role (leads the list). + if (hasPlatformAdminGrant && !ctx.roles.includes(BUILTIN_ROLE_PLATFORM_ADMIN)) { + ctx.roles.unshift(BUILTIN_ROLE_PLATFORM_ADMIN); + } + + // 7. [ADR-0024] Env-side AI seat: synthesize the `ai_seat` capability from the + // boolean sys_user.ai_access (sqlite returns 1/0; memory returns boolean). + if (!ctx.permissions.includes('ai_seat')) { + const seatRows = await tryFind(ql, 'sys_user', { id: userId }, 1); + const aiAccess = (seatRows?.[0] as { ai_access?: unknown } | undefined)?.ai_access; + if (aiAccess === true || aiAccess === 1 || aiAccess === '1') ctx.permissions.push('ai_seat'); + } + + return ctx; +} + +// ── Localization (ADR-0053 Phase 2) ───────────────────────────────────────── + +function isValidTimeZone(tz: string): boolean { + try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; } +} +function coerceTimeZone(value: unknown): string | undefined { + const s = typeof value === 'string' ? value.trim() : value != null ? String(value).trim() : ''; + return s && isValidTimeZone(s) ? s : undefined; +} +function coerceLocale(value: unknown): string | undefined { + const s = typeof value === 'string' ? value.trim() : value != null ? String(value).trim() : ''; + return s || undefined; +} +function coerceCurrency(value: unknown): string | undefined { + const s = typeof value === 'string' ? value.trim().toUpperCase() : ''; + return /^[A-Z]{3}$/.test(s) ? s : undefined; +} + +export interface ResolveLocalizationInput { + ql: any; + /** Settings service exposing `get(namespace, key, { tenantId, userId })`. */ + settings?: any; + tenantId?: string; + userId?: string; +} + +/** + * Resolve workspace localization defaults (reference `timezone` / `locale` / + * `currency`). Canonical path is the `localization` SettingsManifest (cascade: + * platform default → global → tenant); falls back to direct tenant-scoped + * `sys_setting` rows, then the built-ins `UTC` / `en-US`. Never throws. + */ +export async function resolveLocalizationContext( + input: ResolveLocalizationInput, +): Promise<{ timezone: string; locale: string; currency?: string }> { + const { ql, settings, tenantId, userId } = input; + try { + if (settings && typeof settings.get === 'function') { + const sctx = { tenantId, userId } as any; + const [tzRes, localeRes, currencyRes] = await Promise.all([ + settings.get('localization', 'timezone', sctx).catch(() => undefined), + settings.get('localization', 'locale', sctx).catch(() => undefined), + settings.get('localization', 'currency', sctx).catch(() => undefined), + ]); + const tz = coerceTimeZone(tzRes?.value); + const locale = coerceLocale(localeRes?.value); + const currency = coerceCurrency(currencyRes?.value); + if (tz || locale || currency) return { timezone: tz ?? 'UTC', locale: locale ?? 'en-US', currency }; + } + } catch { + // settings service unavailable → direct read + } + const tzRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'timezone', scope: 'tenant' }, 1); + const localeRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'locale', scope: 'tenant' }, 1); + const currencyRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'currency', scope: 'tenant' }, 1); + return { + timezone: coerceTimeZone(tzRows[0]?.value) ?? 'UTC', + locale: coerceLocale(localeRows[0]?.value) ?? 'en-US', + currency: coerceCurrency(currencyRows[0]?.value), + }; +} diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index a416cd6b62..19753f78ad 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { IHttpServer, resolveApiKeyPrincipal } from '@objectstack/core'; +import { IHttpServer, resolveAuthzContext, resolveLocalizationContext } from '@objectstack/core'; import { RouteManager } from './route-manager.js'; import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpointsConfig, BatchEndpointsConfig, RouteGenerationConfig } from '@objectstack/spec/api'; import { ObjectStackProtocol } from '@objectstack/spec/api'; @@ -917,182 +917,46 @@ export class RestServer { return undefined; } - const permissions: string[] = []; - const systemPermissions: string[] = []; - const roles: string[] = []; - - // Resolve the data engine once — needed by the API-key verifier and - // reused by the role/permission lookups below. - let identityQl: any; - if (kernel) identityQl = await kernel.getServiceAsync('objectql').catch(() => undefined); - if (!identityQl && this.objectQLProvider) { - identityQl = await this.objectQLProvider(environmentId).catch(() => undefined); - } + // Resolve the data engine for this scope (shared by the resolver below). + const ql: any = kernel + ? await kernel.getServiceAsync('objectql').catch(() => undefined) + : (this.objectQLProvider ? await this.objectQLProvider(environmentId).catch(() => undefined) : undefined); + + // Delegate ALL identity + role/permission/RLS aggregation to the SINGLE + // shared resolver (`resolveAuthzContext`, @objectstack/core) — the same one + // the runtime dispatcher uses, so the REST and dispatcher entry points can + // never drift on authorization. (This path previously kept its own copy that + // silently omitted sys_user_role / sys_role_permission_set / platform_admin / + // ai_seat — see the resolver's module doc.) + const getSession = async (h: any) => { + try { return await api.getSession({ headers: h }); } catch { return undefined; } + }; + const authz = await resolveAuthzContext({ ql, headers, getSession }); + if (!authz.userId) return undefined; + + const settings = this.settingsServiceProvider + ? await this.settingsServiceProvider(environmentId).catch(() => undefined) + : undefined; + const localization = await resolveLocalizationContext({ + ql, + settings, + tenantId: authz.tenantId, + userId: authz.userId, + }); - // ── Identity: API key (sys_api_key) takes precedence, then session. - // Verified by the SAME `resolveApiKeyPrincipal` (@objectstack/core) - // the dispatcher/MCP path uses, so REST + MCP never drift on how a - // key authenticates. Anonymous (neither) → undefined → 401. - let userId: string; - let tenantId: string | undefined; - let email: string | undefined; - const keyPrincipal = await resolveApiKeyPrincipal(identityQl, headers).catch(() => undefined); - if (keyPrincipal) { - userId = keyPrincipal.userId; - tenantId = keyPrincipal.tenantId; - for (const s of keyPrincipal.scopes) if (!permissions.includes(s)) permissions.push(s); - } else { - const session = await api.getSession({ headers }); - if (!session?.user?.id) return undefined; - userId = session.user.id; - tenantId = session.session?.activeOrganizationId ?? undefined; - if (session.user?.email) email = String(session.user.email); - } - // Resolve the caller's UNIQUE email for `current_user.email` RLS owner - // policies (e.g. `owner = current_user.email`). Session-supplied when - // present, else a bounded sys_user lookup (the API-key path). Email is - // the unique owner anchor — display `name` is never exposed to RLS - // (names collide, and a collision on an ownership predicate leaks access). - if (!email && identityQl && typeof identityQl.find === 'function') { - const urows = await identityQl.find('sys_user', { where: { id: userId }, limit: 1, context: { isSystem: true } } as any).catch(() => []); - if ((urows as any)?.[0]?.email) email = String((urows as any)[0].email); - } - // Look up the link tables to surface roles + permission set names. - // Skipping this lookup would silently ignore admin/role grants — - // including the platform-admin promotion seeded by - // `bootstrapPlatformAdmin` — and force every authenticated user - // through the `member_default` fallback path. - try { - let ql: any; - if (kernel) { - ql = await kernel.getServiceAsync('objectql').catch(() => undefined); - } - if (!ql && this.objectQLProvider) { - ql = await this.objectQLProvider(environmentId).catch(() => undefined); - } - if (ql && typeof ql.find === 'function') { - const sysOpts = { context: { isSystem: true } }; - const memberRows = await ql.find('sys_member', { - where: tenantId ? { user_id: userId, organization_id: tenantId } : { user_id: userId }, - limit: 50, - ...sysOpts, - } as any).catch(() => []); - for (const m of (memberRows ?? []) as any[]) { - if (typeof m.role === 'string') { - for (const r of m.role.split(',').map((s: string) => s.trim()).filter(Boolean)) { - if (!roles.includes(r)) roles.push(r); - } - } - } - const upsRows = await ql.find('sys_user_permission_set', { - where: { user_id: userId }, - limit: 100, - ...sysOpts, - } as any).catch(() => []); - const psIds = new Set(); - for (const r of (upsRows ?? []) as any[]) { - const orgScope = r.organization_id ?? null; - if (!orgScope || (tenantId && orgScope === tenantId)) { - const pid = r.permission_set_id ?? r.permissionSetId; - if (pid) psIds.add(pid); - } - } - if (psIds.size > 0) { - const psRows = await ql.find('sys_permission_set', { - where: { id: { $in: Array.from(psIds) } }, - limit: 500, - ...sysOpts, - } as any).catch(() => []); - for (const ps of (psRows ?? []) as any[]) { - if (ps.name && !permissions.includes(ps.name)) permissions.push(ps.name); - // System permissions may be stored as a JSON string - // (driver-sql round-trips array columns as text). - // Mirrors runtime/src/security/resolve-execution-context.ts. - const rawSys = typeof ps.system_permissions === 'string' - ? (() => { try { return JSON.parse(ps.system_permissions); } catch { return []; } })() - : (ps.system_permissions ?? ps.systemPermissions); - if (Array.isArray(rawSys)) { - for (const sp of rawSys) { - if (typeof sp === 'string' && !systemPermissions.includes(sp)) { - systemPermissions.push(sp); - } - } - } - } - } - } - } catch { /* fall through with empty perms */ } - // Pre-resolve fellow-org user IDs so RLS can scope identity - // tables (sys_user) to org collaborators. Cap at 1000. See - // `@objectstack/runtime/security/resolve-execution-context.ts` - // for the mirror implementation in the dispatcher path. - let org_user_ids: string[] = [userId]; - if (tenantId) { - try { - let ql: any; - if (kernel) { - ql = await kernel.getServiceAsync('objectql').catch(() => undefined); - } - if (!ql && this.objectQLProvider) { - ql = await this.objectQLProvider(environmentId).catch(() => undefined); - } - if (ql && typeof ql.find === 'function') { - const sysOpts = { context: { isSystem: true } }; - const memberRows = await ql.find('sys_member', { - where: { organization_id: tenantId }, - limit: 1000, - ...sysOpts, - } as any).catch(() => []); - const ids = new Set([userId]); - for (const m of (memberRows ?? []) as any[]) { - const uid = m.user_id ?? m.userId; - if (typeof uid === 'string' && uid.length > 0) ids.add(uid); - } - org_user_ids = Array.from(ids); - } - } catch { /* fall back to self-only */ } - } - // Reference timezone + locale for date rendering and analytics date - // bucketing (ADR-0053 Phase 2 / localization manifest #2006). Resolved - // through the `settings` service so the 4-tier cascade — including the - // env override `OS_LOCALIZATION_TIMEZONE` — is honoured; `sys_setting` - // rows alone would miss the env/global tiers. Best-effort: any failure - // leaves the engine's UTC default. Mirrors the dispatcher path's - // `resolveLocalization` (runtime/security/resolve-execution-context.ts). - let timezone: string | undefined; - let locale: string | undefined; - let currency: string | undefined; - try { - const settings = this.settingsServiceProvider - ? await this.settingsServiceProvider(environmentId).catch(() => undefined) - : undefined; - if (settings && typeof settings.get === 'function') { - const sctx = { tenantId, userId }; - const [tzRes, localeRes, currencyRes] = await Promise.all([ - settings.get('localization', 'timezone', sctx).catch(() => undefined), - settings.get('localization', 'locale', sctx).catch(() => undefined), - settings.get('localization', 'currency', sctx).catch(() => undefined), - ]); - const tzVal = tzRes?.value; - const localeVal = localeRes?.value; - const currencyVal = currencyRes?.value; - if (typeof tzVal === 'string' && tzVal.trim()) timezone = tzVal.trim(); - if (typeof localeVal === 'string' && localeVal.trim()) locale = localeVal.trim(); - if (typeof currencyVal === 'string' && currencyVal.trim()) currency = currencyVal.trim().toUpperCase(); - } - } catch { /* best-effort — fall back to engine UTC default */ } return { - userId, - tenantId, - email, - roles, - permissions, - systemPermissions, + userId: authz.userId, + tenantId: authz.tenantId, + email: authz.email, + roles: authz.roles, + permissions: authz.permissions, + systemPermissions: authz.systemPermissions, + ...(authz.tabPermissions ? { tabPermissions: authz.tabPermissions } : {}), isSystem: false, - org_user_ids, - ...(timezone ? { timezone } : {}), - ...(locale ? { locale } : {}), - ...(currency ? { currency } : {}), + org_user_ids: authz.org_user_ids, + ...(localization.timezone ? { timezone: localization.timezone } : {}), + ...(localization.locale ? { locale: localization.locale } : {}), + ...(localization.currency ? { currency: localization.currency } : {}), // Internal: resolved kernel so the nav-serving path can probe // requiresService capability gates (ADR-0057 D10). NOT an // authorization input — never read by RLS/permission logic. diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index 0ea1decdcd..e0ab752066 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -1,32 +1,30 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * resolveExecutionContext — REST entry-point identity resolver. + * resolveExecutionContext — REST/dispatcher entry-point identity resolver. * - * Builds an {@link ExecutionContext} from an incoming HTTP request by combining: - * - better-auth Bearer/Session cookies (`authService.api.getSession`) - * - API Key headers (`X-API-Key` / `Authorization: ApiKey `) — a - * hand-rolled check that hashes the inbound key and looks it up against the - * `sys_api_key` system object by its at-rest hash, rejecting revoked or - * expired keys. (better-auth 1.6.x ships no apiKey plugin.) - * - `sys_member` lookup for `(userId, activeOrganizationId)` to populate - * organization-scoped roles, plus any extra permission sets bound through - * the `sys_user_permission_set` / `sys_role_permission_set` link tables. + * Thin adapter over the SINGLE shared authorization resolver + * (`resolveAuthzContext` in `@objectstack/core/security`). This function only + * does the transport-specific plumbing — pull `ql` and the better-auth session + * getter out of the active kernel/scope — then delegates ALL identity + + * role/permission/RLS aggregation to the shared resolver, and layers the + * reference localization (timezone/locale) on top. * - * The resolver is intentionally non-fatal: when auth is not wired up or any - * of the dependent services are unavailable, it returns the partial context - * that can be reconstructed (even an empty `{ isSystem: false, roles: [], - * permissions: [] }`). Permission enforcement is the SecurityPlugin's job. + * The actual table reads (`sys_member` / `sys_user_role` / + * `sys_*_permission_set`), the platform-admin derivation and the `ai_seat` + * synthesis live in ONE place now (`@objectstack/core`), shared with the REST + * server, so the two entry points can never drift on authorization again. + * + * Always resolves — never throws. Anonymous requests yield + * `{ isSystem: false, roles: [], permissions: [] }`. */ import type { ExecutionContext } from '@objectstack/spec/kernel'; -import { resolveApiKeyPrincipal } from './api-key.js'; import { - mapMembershipRole, - BUILTIN_ROLE_PLATFORM_ADMIN, - ADMIN_FULL_ACCESS, -} from '@objectstack/spec'; + resolveAuthzContext, + resolveLocalizationContext, +} from '@objectstack/core'; interface ResolveOptions { /** Function returning a service from the active kernel (or undefined). */ @@ -38,9 +36,9 @@ interface ResolveOptions { } /** - * Convert the dispatcher's plain `Record` headers map into - * a Web `Headers` instance so libraries like better-auth (which reads via - * `headers.get('cookie')`) work uniformly. + * Convert the dispatcher's plain `Record` headers map into a Web + * `Headers` instance so better-auth (which reads via `headers.get('cookie')`) + * works uniformly. */ function toHeaders(input: any): any { if (!input) return new Headers(); @@ -58,380 +56,54 @@ function toHeaders(input: any): any { return h; } -function safeJsonParse(s: string, fallback: T): T { - try { return JSON.parse(s) as T; } catch { return fallback; } -} - -async function tryFind(ql: any, object: string, where: any, limit = 100): Promise { - if (!ql || typeof ql.find !== 'function') return []; - try { - let rows = await ql.find(object, { where, limit, context: { isSystem: true } } as any); - if (rows && (rows as any).value) rows = (rows as any).value; - return Array.isArray(rows) ? rows : []; - } catch { - return []; - } -} - -/** True for a valid IANA timezone name (e.g. `America/New_York`, `UTC`). */ -function isValidTimeZone(tz: string): boolean { - try { - new Intl.DateTimeFormat('en-US', { timeZone: tz }); - return true; - } catch { - return false; - } -} - -/** Coerce a stored preference/setting value to a valid IANA zone, or undefined. */ -function coerceTimeZone(value: unknown): string | undefined { - const s = typeof value === 'string' ? value.trim() : value != null ? String(value).trim() : ''; - return s && isValidTimeZone(s) ? s : undefined; -} - -/** Coerce a stored locale value to a non-empty BCP-47-ish string, or undefined. */ -function coerceLocale(value: unknown): string | undefined { - const s = typeof value === 'string' ? value.trim() : value != null ? String(value).trim() : ''; - return s || undefined; -} - -/** Coerce a stored currency value to an ISO 4217 (3-letter) code, or undefined. */ -function coerceCurrency(value: unknown): string | undefined { - const s = typeof value === 'string' ? value.trim().toUpperCase() : ''; - return /^[A-Z]{3}$/.test(s) ? s : undefined; -} - -/** - * Resolve the workspace localization defaults onto the ExecutionContext - * (ADR-0053 Phase 2): reference `timezone` and `locale`. - * - * Canonical path is the `localization` SettingsManifest via the `settings` - * service, whose cascade is platform default → global → tenant (ADR-0002: one - * org per physical tenant; per-user overrides are intentionally out of scope - * for v1). When the settings service or its namespace is unavailable (e.g. a - * minimal deployment), fall back to a direct tenant-scoped `sys_setting` read, - * then to the built-ins `UTC` / `en-US`. - * - * Every read is defensive — localization never blocks auth, and an invalid - * zone falls through to the built-in. - */ -async function resolveLocalization( - opts: ResolveOptions, - ql: any, - sctx: { tenantId?: string; userId?: string }, -): Promise<{ timezone: string; locale: string; currency?: string }> { - // 1. Canonical — the `localization` manifest via the settings service. - try { - const settings: any = await opts.getService('settings'); - if (settings && typeof settings.get === 'function') { - const [tzRes, localeRes, currencyRes] = await Promise.all([ - settings.get('localization', 'timezone', sctx).catch(() => undefined), - settings.get('localization', 'locale', sctx).catch(() => undefined), - settings.get('localization', 'currency', sctx).catch(() => undefined), - ]); - const tz = coerceTimeZone(tzRes?.value); - const locale = coerceLocale(localeRes?.value); - const currency = coerceCurrency(currencyRes?.value); - // A resolved value (incl. the manifest default) means the namespace is - // live — trust it and skip the legacy direct read. - if (tz || locale || currency) return { timezone: tz ?? 'UTC', locale: locale ?? 'en-US', currency }; - } - } catch { - // settings service unavailable → fall through to the direct read - } - - // 2. Fallback — direct tenant-scoped `sys_setting` rows (no settings service - // registered, or namespace not loaded). - const tzRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'timezone', scope: 'tenant' }, 1); - const localeRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'locale', scope: 'tenant' }, 1); - const currencyRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'currency', scope: 'tenant' }, 1); - return { - timezone: coerceTimeZone(tzRows[0]?.value) ?? 'UTC', - locale: coerceLocale(localeRows[0]?.value) ?? 'en-US', - currency: coerceCurrency(currencyRows[0]?.value), - }; -} - -/** - * Resolve the {@link ExecutionContext} for an inbound request. - * - * Always resolves — never throws. Anonymous requests yield - * `{ isSystem: false, roles: [], permissions: [] }`. - */ export async function resolveExecutionContext(opts: ResolveOptions): Promise { - const headers = opts.request?.headers; - const ctx: ExecutionContext = { - roles: [], - permissions: [], - systemPermissions: [], - isSystem: false, - }; - - let userId: string | undefined; - let tenantId: string | undefined; - - // 1. API Key path — takes precedence over session, since callers explicitly - // opt in to API-key auth via the header. - // - // better-auth 1.6.x ships no apiKey plugin, so this is a hand-rolled - // check: hash the inbound key, look it up against `sys_api_key` by the - // at-rest hash, and reject revoked or expired keys. The raw key is never - // stored or logged. Once resolved, the principal flows through the exact - // same role/permission/RLS resolution as the session path below. - // Verification is delegated to the shared `resolveApiKeyPrincipal` - // (@objectstack/core), the SAME verifier the REST data API uses, so the - // two surfaces never drift. - const keyPrincipal = await resolveApiKeyPrincipal(await opts.getQl(), headers); - if (keyPrincipal) { - userId = keyPrincipal.userId; - tenantId = keyPrincipal.tenantId; - for (const scope of keyPrincipal.scopes) { - if (!ctx.permissions!.includes(scope)) ctx.permissions!.push(scope); - } - } + const headers = toHeaders(opts.request?.headers); + const ql = await opts.getQl(); - // 2. Session / Bearer path — fall back when API key did not resolve a user. - if (!userId) { + // The auth service surfaces better-auth either as `.api` (legacy direct mount) + // or via `await getApi()` (lazy plugin). Build a session getter that tolerates + // both, and degrades to anonymous when auth isn't wired up. + const getSession = async (h: any) => { try { const authService: any = await opts.getService('auth'); - // The auth service surfaces its better-auth API either as `.api` - // (legacy direct mount) or via `await getApi()` (lazy plugin). - // Try both so we don't silently degrade to anonymous when the - // shape differs across plugin versions. let api: any = authService?.api; - if (!api && typeof authService?.getApi === 'function') { - api = await authService.getApi(); - } - const headersInstance = toHeaders(headers); - const sessionData = await api?.getSession?.({ headers: headersInstance }); - userId = sessionData?.user?.id ?? sessionData?.session?.userId; - tenantId = tenantId ?? sessionData?.session?.activeOrganizationId; - ctx.accessToken = sessionData?.session?.token ?? ctx.accessToken; - // Session carries the user's email for free → use it for RLS owner-by-email. - if (sessionData?.user?.email) ctx.email = String(sessionData.user.email); + if (!api && typeof authService?.getApi === 'function') api = await authService.getApi(); + return await api?.getSession?.({ headers: h }); } catch { - // no auth configured — return anonymous context - } - } - - if (userId) ctx.userId = userId; - if (tenantId) ctx.tenantId = tenantId; - - if (!userId) return ctx; - - // 3. Resolve organization-scoped roles via sys_member, then merge any - // permission sets bound via the link tables. All lookups go through - // ObjectQL with `isSystem: true` to avoid recursion through the - // SecurityPlugin middleware. - const ql = await opts.getQl(); - if (!ql) return ctx; - - // Resolve the caller's unique email for `current_user.email` RLS policies when - // the session path didn't supply it (e.g. API-key auth). One bounded lookup; - // email is the auth-unique owner anchor (display name is never used — it - // collides, and a collision on an ownership predicate leaks access). - if (!ctx.email) { - const userRows = await tryFind(ql, 'sys_user', { id: userId }, 1); - if (userRows[0]?.email) ctx.email = String(userRows[0].email); - } - - const memberWhere: any = tenantId - ? { user_id: userId, organization_id: tenantId } - : { user_id: userId }; - const members = await tryFind(ql, 'sys_member', memberWhere, 50); - for (const m of members) { - if (m.role && typeof m.role === 'string') { - // better-auth stores comma-separated roles for multi-role membership. - for (const raw of m.role.split(',').map((s: string) => s.trim()).filter(Boolean)) { - // ADR-0068 D2: normalize better-auth owner/admin/member to the canonical - // org_owner/org_admin/org_member built-in role names. - const r = mapMembershipRole(raw); - if (!ctx.roles!.includes(r)) ctx.roles!.push(r); - } + return undefined; } - } - - // [ADR-0057 D4] Platform-owned RBAC role assignments — the source of truth, - // decoupled from better-auth's `sys_member.role` (which is being reframed to - // org-administration: owner/admin/member). Merged alongside membership roles - // during the transition window. `organization_id = null` = a global (cross- - // tenant) assignment; tenant-scoped assignments match the active org. - const userRoleRows = await tryFind(ql, 'sys_user_role', { user_id: userId }, 200); - for (const ur of userRoleRows) { - const org = ur.organization_id ?? null; - if (org && tenantId && org !== tenantId) continue; - const r = ur.role; - if (typeof r === 'string' && r && !ctx.roles!.includes(r)) ctx.roles!.push(r); - } - - // 3a. Resolve fellow-organization user IDs so RLS can scope identity - // tables (`sys_user`) to collaborators in the active org via - // `id IN (current_user.org_user_ids)`. Without this, the default - // `id = current_user.id` policy on sys_user makes @-mention pickers, - // owner/assignee lookups and reviewer selectors all return just the - // current user. Hard-capped at 1000 members per request — large - // enterprises should plug in a cache or directory adapter. - if (tenantId) { - const orgMembers = await tryFind( - ql, - 'sys_member', - { organization_id: tenantId }, - 1000, - ); - const orgUserIds = Array.from( - new Set( - orgMembers - .map((m) => m.user_id ?? m.userId) - .filter((v): v is string => typeof v === 'string' && v.length > 0), - ), - ); - // Always include self even if the sys_member lookup misfires (e.g. - // API key auth where the user is recognised but not in sys_member). - if (!orgUserIds.includes(userId)) orgUserIds.push(userId); - (ctx as any).org_user_ids = orgUserIds; - } else { - // No active org → at minimum the user can see themselves. - (ctx as any).org_user_ids = [userId]; - } + }; - // Resolve user-scoped permission sets. - // - // [ADR-0066] A platform-scoped grant (`organization_id IS NULL`) is GLOBAL and - // must apply regardless of the caller's active org. Querying with - // `organization_id = tenantId` dropped null-org grants the moment a user had - // an active org — so a platform admin who also owns an org silently lost - // `admin_full_access` (and its `systemPermissions`), which then locked them - // out of a `requiredPermissions`-gated control-plane object (e.g. sys_license). - // Mirror the role-binding logic above (null org = global, cross-org): fetch the - // user's grants and keep null-org + active-org ones, dropping only grants - // scoped to a DIFFERENT org. - const upsRows = await tryFind(ql, 'sys_user_permission_set', { user_id: userId }, 100); - const psIds = new Set( - upsRows - .filter((r) => { - const org = (r.organization_id ?? r.organizationId) ?? null; - return !(org && tenantId && org !== tenantId); - }) - .map((r) => r.permission_set_id ?? r.permissionSetId) - .filter(Boolean), - ); + const authz = await resolveAuthzContext({ ql, headers, getSession }); - // ADR-0068 D2: platform_admin is DERIVED from the UNSCOPED (org_id = null) - // admin_full_access grant — the single source of truth. Track which permission - // sets came from a null-org USER grant so we can project the built-in role - // without trusting any stored boolean flag. - const unscopedUserPsIds = new Set( - upsRows - .filter((r) => ((r.organization_id ?? r.organizationId) ?? null) === null) - .map((r) => r.permission_set_id ?? r.permissionSetId) - .filter(Boolean), - ); - let hasPlatformAdminGrant = false; - - // Resolve role-bound permission sets. - if (ctx.roles!.length > 0) { - const roleRows = await tryFind(ql, 'sys_role', { name: { $in: ctx.roles } }, 100); - const roleIds = roleRows.map((r) => r.id).filter(Boolean); - if (roleIds.length > 0) { - const rpsRows = await tryFind( - ql, - 'sys_role_permission_set', - { role_id: { $in: roleIds } }, - 500, - ); - for (const r of rpsRows) { - const id = r.permission_set_id ?? r.permissionSetId; - if (id) psIds.add(id); - } - } - } - - if (psIds.size > 0) { - // Surface permission set names through ctx.permissions so downstream - // SecurityPlugin can look them up. We store the canonical `name` field. - const psRows = await tryFind( + const ctx: ExecutionContext = { + roles: authz.roles, + permissions: authz.permissions, + systemPermissions: authz.systemPermissions, + isSystem: false, + }; + if (authz.userId) ctx.userId = authz.userId; + if (authz.tenantId) ctx.tenantId = authz.tenantId; + if (authz.email) ctx.email = authz.email; + if (authz.accessToken) ctx.accessToken = authz.accessToken; + if (authz.tabPermissions) ctx.tabPermissions = authz.tabPermissions; + (ctx as any).org_user_ids = authz.org_user_ids; + + // Anonymous → skip localization (no scope to resolve against); keep the engine + // default. Authenticated → resolve reference timezone/locale/currency. + if (authz.userId) { + const settings = await Promise.resolve(opts.getService('settings')).catch(() => undefined); + const localization = await resolveLocalizationContext({ ql, - 'sys_permission_set', - { id: { $in: Array.from(psIds) } }, - 500, - ); - const tabRank: Record = { - hidden: 0, - default_off: 1, - default_on: 2, - visible: 3, - }; - const mergedTabs: Record = {}; - for (const ps of psRows) { - if (ps.name && !ctx.permissions!.includes(ps.name)) { - ctx.permissions!.push(ps.name); - } - // ADR-0068 D2: an UNSCOPED admin_full_access grant => platform admin. - if (ps.name === ADMIN_FULL_ACCESS && unscopedUserPsIds.has(ps.id)) { - hasPlatformAdminGrant = true; - } - // System permissions may be stored as JSON string in DB rows. - const sysPerms = typeof ps.system_permissions === 'string' - ? safeJsonParse(ps.system_permissions, []) - : (ps.system_permissions ?? ps.systemPermissions); - if (Array.isArray(sysPerms)) { - for (const p of sysPerms) { - if (typeof p === 'string' && !ctx.systemPermissions!.includes(p)) { - ctx.systemPermissions!.push(p); - } - } - } - const tabs = typeof ps.tab_permissions === 'string' - ? safeJsonParse(ps.tab_permissions, {}) - : (ps.tab_permissions ?? ps.tabPermissions); - if (tabs && typeof tabs === 'object') { - for (const [app, val] of Object.entries(tabs as Record)) { - if (typeof val !== 'string' || !(val in tabRank)) continue; - const cur = mergedTabs[app]; - if (!cur || tabRank[val] > tabRank[cur]) { - mergedTabs[app] = val as 'visible' | 'hidden' | 'default_on' | 'default_off'; - } - } - } - } - if (Object.keys(mergedTabs).length > 0) { - ctx.tabPermissions = mergedTabs; - } - } - - // ADR-0068 D2: project the derived platform_admin built-in role (unshift so it - // leads the list). roles[] is canonical; isPlatformAdmin is a derived alias. - if (hasPlatformAdminGrant && !ctx.roles!.includes(BUILTIN_ROLE_PLATFORM_ADMIN)) { - ctx.roles!.unshift(BUILTIN_ROLE_PLATFORM_ADMIN); + settings, + tenantId: authz.tenantId, + userId: authz.userId, + }); + ctx.timezone = localization.timezone; + ctx.locale = localization.locale; + if (localization.currency) ctx.currency = localization.currency; } - // [ADR-0024] Env-side AI seat. The per-agent gate (evaluateAgentAccess → - // requires the `ai_seat` capability) reads ctx.permissions. Synthesize it from - // the boolean `sys_user.ai_access`, read via the SAME scope-correct `ql` used - // for the permission sets above — so it resolves on single-env local AND on the - // sharded multi-tenant runtime, where only the per-request scope selects the - // right engine (a no-arg / wrong-scope lookup returns empty and loses the seat). - // Guarded (tryFind swallows errors) → can only ever ADD access, never break auth. - if (userId && !ctx.permissions!.includes('ai_seat')) { - const seatRows = await tryFind(ql, 'sys_user', { id: userId }, 1); - // Turso/libSQL returns sqlite booleans as integer 1/0; the memory driver - // returns a JS boolean. Accept both (+ the stringified form) so the seat - // resolves regardless of the env's data driver. - const aiAccess = (seatRows?.[0] as { ai_access?: unknown } | undefined)?.ai_access; - if (aiAccess === true || aiAccess === 1 || aiAccess === '1') { - ctx.permissions!.push('ai_seat'); - } - } - - // 4. Localization (ADR-0053 Phase 2) — reference timezone + locale resolved - // once per request from the `localization` settings and carried on the - // context. Consumers: formula today()/datetime, analytics date buckets, - // email rendering. Absent config → UTC / en-US keeps current behavior. - const localization = await resolveLocalization(opts, ql, { tenantId, userId }); - ctx.timezone = localization.timezone; - ctx.locale = localization.locale; - if (localization.currency) ctx.currency = localization.currency; - return ctx; } @@ -439,12 +111,10 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise