diff --git a/.changeset/23217-core-request-execution-context.md b/.changeset/23217-core-request-execution-context.md new file mode 100644 index 00000000000..c8383443ad6 --- /dev/null +++ b/.changeset/23217-core-request-execution-context.md @@ -0,0 +1,13 @@ +--- +"@objectstack/core": minor +--- + +feat(core): export `resolveRequestExecutionContext`, the one composition that turns an inbound request into the caller's `ExecutionContext` + +Clause-②: yes (widening) + +- **New export from `@objectstack/core`'s `.` entry.** `resolveRequestExecutionContext(input)` reads the session through the auth service (the in-process rule: a request carrying a session cookie reads without renewal), classifies the tenancy posture with `classifyAdmissionTenancyPosture`, resolves the principal and its grants with `resolveAuthzContext`, resolves the reference localization, decides the ADR-0069 authentication-policy gate and assembles the envelope with `assembleExecutionContext` or `assembleExecutionContextOrGuest`. Its input type `RequestExecutionContextInput` and the types `RequestAuthService`, `EstablishedRequestSession` and `RequestAdmission` are exported with it. +- **Two choices, made once.** `anonymous: 'fail-closed'` yields no context for a request that resolves no principal (the door answers 401); `anonymous: 'guest'` serves it as the guest principal with the guest anchor's grants. `authGate: 'withhold' | 'resolved' | 'reread-when-active'` says whether the gate's posture rides the envelope, and how it is read. +- **Every other difference between doors is a named, required parameter:** `credentials`, `sessionApi`, `session`, `tenancy`, `localization`, `requestLocale`, `accessToken`, `oauth` and `onAdmission`. A door with no value passes `undefined` or the withholding literal, so no door can drop an input by omitting it. +- **First caller.** The runtime dispatcher's identity door (`@objectstack/runtime`'s `resolveExecutionContext`) now calls it. What that door answers is unchanged: the same guest envelope, the same 503 for a tenancy service that failed to build, the same OAuth agent principal on `/mcp`. +- **Faults propagate.** It catches nothing around the resolver: an unreadable permission store or tenancy service (`AuthzStoreUnavailableError`, 503) and an API key its own lifecycle refuses (`ApiKeyLifecycleRefusalError`, 403) reach the caller, which answers them at its own net. diff --git a/packages/core/src/security/authz-store-unavailable.test.ts b/packages/core/src/security/authz-store-unavailable.test.ts index 5d21e693db8..b1264f47310 100644 --- a/packages/core/src/security/authz-store-unavailable.test.ts +++ b/packages/core/src/security/authz-store-unavailable.test.ts @@ -180,7 +180,11 @@ const REPO_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)); */ const TRANSPORT_LEDGER: Record = { 'packages/rest/src/rest-server.ts': 'guarded', - 'packages/runtime/src/security/resolve-execution-context.ts': 'propagates', + // The one request-to-ExecutionContext composition (ruling record 6227928430, + // letter A). It catches nothing around the resolver: every door that calls + // it — the runtime dispatcher's `resolve-execution-context.ts` first — + // receives the outage and answers it at its own net. + 'packages/core/src/security/request-execution-context.ts': 'propagates', 'packages/mcp/src/plugin.ts': 'propagates', 'packages/services/service-datasource/src/admin-routes.ts': 'guarded', 'packages/services/service-settings/src/settings-service-plugin.ts': 'guarded', diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index f3d8ad480a2..f7d119734d3 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -160,6 +160,19 @@ export { type TenancyServiceResolver, } from './admission-tenancy-posture.js'; +// [ruling record 6227928430, letter A] The ONE request-to-ExecutionContext +// composition the platform's doors call: the session read, the posture +// classification above, `resolveAuthzContext`, the localization, the ADR-0069 +// gate and the assembly below, with the anonymous entry and the gate decided +// once and every door's intentional difference a named parameter. +export { + resolveRequestExecutionContext, + type RequestExecutionContextInput, + type RequestAuthService, + type EstablishedRequestSession, + type RequestAdmission, +} from './request-execution-context.js'; + // [commit 6a180e42d] The LOUD failure an unreachable permission store raises, and the // brand predicate a fail-closed `catch` uses to re-raise it instead of // degrading an outage into a capability denial. Ruled 2026-08-30. diff --git a/packages/core/src/security/request-execution-context.test.ts b/packages/core/src/security/request-execution-context.test.ts new file mode 100644 index 00000000000..96251a2161d --- /dev/null +++ b/packages/core/src/security/request-execution-context.test.ts @@ -0,0 +1,393 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `resolveRequestExecutionContext` (ruling record 6227928430, letter A): the + * one request-to-ExecutionContext composition. One battery per choice and per + * named parameter. Every reading is paired with the control that separates it + * from the parameter's other value, so a composition that ignored the + * parameter fails here rather than passing on both sides. + * + * The engine double answers `find` from tables and counts every read; the + * auth service double counts every session read it serves. + */ + +import { describe, it, expect } from 'vitest'; + +import { ObjectKernel } from '../kernel.js'; + +import { hashApiKey } from './api-key.js'; +import type { OAuthTokenProvenance } from './assemble-execution-context.js'; +import { isAuthzStoreUnavailableError } from './authz-store-unavailable.js'; +import { + resolveRequestExecutionContext, + type RequestAdmission, + type RequestAuthService, + type RequestExecutionContextInput, +} from './request-execution-context.js'; +import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit.js'; + +const ORG = 'org_only'; +const GUEST_SET = 'storefront_reader'; +const KEY = 'osk_request_ctx_key'; +const EX_MEMBER_KEY = 'osk_request_ctx_ex_member'; +const REVOKED_KEY = 'osk_request_ctx_revoked'; +const COOKIE = 'better-auth.session_token=tok3n.c2ln'; +const FUTURE = '2999-01-01T00:00:00Z'; + +/** Equality and `$in` per field; a combinator it does not implement is refused. */ +function matchesWhere(row: Record, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) throw new Error('fakeEngine.find: unsupported logical operator ' + k); + const hit = v && typeof v === 'object' && '$in' in v ? (v as { $in: unknown[] }).$in.includes(row[k]) : row[k] === v; + if (!hit) return false; + } + return true; +} + +function fakeEngine() { + const reads: string[] = []; + const tables: Record>> = { + sys_organization: [{ id: ORG }], + sys_metadata_activation: [], + sys_api_key: [ + { id: 'k_live', key: hashApiKey(KEY), revoked: false, user_id: 'u_key', active_organization_id: ORG, expires_at: FUTURE }, + // Stamped with ORG; its owner is a member of no organization. + { id: 'k_ex', key: hashApiKey(EX_MEMBER_KEY), revoked: false, user_id: 'u_ex', active_organization_id: ORG, expires_at: FUTURE }, + { id: 'k_revoked', key: hashApiKey(REVOKED_KEY), revoked: true, user_id: 'u_key', expires_at: FUTURE }, + ], + sys_member: [ + { user_id: 'u_key', organization_id: ORG, role: 'member' }, + { user_id: 'u_ses', organization_id: ORG, role: 'member' }, + ], + sys_user_position: [], + sys_user_permission_set: [], + }; + const registered = new Set(Object.keys(tables)); + const engine = { + registry: { getObject: (name: string) => (registered.has(name) ? { name } : undefined) }, + async find(object: string, q: { where?: Record; limit?: number } | undefined) { + reads.push(object); + const rows = (tables[object] ?? []).filter((r) => matchesWhere(r, q?.where ?? {})); + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + }; + bindStaticSecurityCatalog(engine, { + positions: [{ name: 'guest', permissionSets: [GUEST_SET] }, { name: 'everyone', permissionSets: ['member_default'] }], + permissions: [{ name: GUEST_SET }, { name: 'member_default' }], + }); + return { engine, reads }; +} + +interface SessionUser { id: string; email?: string; authGate?: unknown } + +/** An auth service double whose session read answers `user` and counts every read. */ +function fakeAuth(user: SessionUser | undefined, opts: { gateActive?: boolean | 'throws'; rereadFails?: boolean; lazy?: boolean; ownerGates?: unknown[] } = {}) { + const sessionReads: unknown[] = []; + const api = { + async getSession(input: { headers: unknown; query?: { disableRefresh?: boolean } }) { + sessionReads.push(input); + if (opts.rereadFails && sessionReads.length > 1) throw new Error('session store down'); + if (!user) return undefined; + return { user, session: { id: 'sess_1', userId: user.id, token: 'sess_tok', activeOrganizationId: ORG } }; + }, + }; + const service: RequestAuthService = { + ...(opts.lazy ? { getApi: async () => api } : { api }), + isAuthGateActive: () => { + if (opts.gateActive === 'throws') throw new Error('probe faulted'); + return opts.gateActive === true; + }, + ...(opts.ownerGates ? { resolveUserAuthGates: async () => opts.ownerGates as never } : {}), + }; + return { service, sessionReads }; +} + +/** The runtime / MCP dispatcher's parameter set, the baseline every case edits one key of. */ +function input(over: Partial & { headers?: Headers }): RequestExecutionContextInput { + return { + headers: new Headers(), + ql: undefined, + authService: undefined, + tenancy: undefined, + anonymous: 'fail-closed', + authGate: 'withhold', + credentials: 'api-key-and-session', + sessionApi: 'optional', + session: 'request', + localization: 'withhold', + requestLocale: undefined, + accessToken: 'withhold', + oauth: undefined, + onAdmission: undefined, + ...over, + }; +} + +const sessionHeaders = () => new Headers({ cookie: COOKIE }); +const keyHeaders = (raw = KEY) => new Headers({ 'x-api-key': raw }); +const rejectionOf = (p: Promise) => p.then(() => undefined, (e: unknown) => e as any); + +describe('choice 1 — the anonymous entry', () => { + it("'fail-closed': a request with no credential yields no context; 'guest' yields the guest envelope with the guest anchor's grants", async () => { + const { engine } = fakeEngine(); + const failClosed = await resolveRequestExecutionContext(input({ ql: engine })); + expect(failClosed).toBeUndefined(); + + const guest = await resolveRequestExecutionContext(input({ ql: engine, anonymous: 'guest' })); + expect(guest).toEqual({ + positions: ['guest'], + permissions: [GUEST_SET], + systemPermissions: [], + isSystem: false, + principalKind: 'guest', + tenantId: ORG, + org_user_ids: [], + accessible_org_ids: [], + }); + }); + + it('an authenticated request is the same human principal under either entry', async () => { + const { engine } = fakeEngine(); + const failClosed = await resolveRequestExecutionContext(input({ ql: engine, headers: keyHeaders() })); + const guest = await resolveRequestExecutionContext(input({ ql: engine, headers: keyHeaders(), anonymous: 'guest' })); + expect(failClosed).toMatchObject({ userId: 'u_key', principalKind: 'human' }); + expect(guest).toEqual(failClosed); + }); +}); + +describe('the session read', () => { + it("'request' reads the auth service's session under the in-process rule (a session cookie reads without renewal), through `.api` or `getApi()`", async () => { + for (const lazy of [false, true]) { + const { engine } = fakeEngine(); + const auth = fakeAuth({ id: 'u_ses' }, { lazy }); + const ctx = await resolveRequestExecutionContext(input({ ql: engine, authService: auth.service, headers: sessionHeaders() })); + expect(ctx?.userId).toBe('u_ses'); + expect(auth.sessionReads).toHaveLength(1); + expect(auth.sessionReads[0]).toMatchObject({ query: { disableRefresh: true } }); + } + }); + + it("'none' answers no session — the cookie beside the failed credential included", async () => { + const { engine } = fakeEngine(); + const auth = fakeAuth({ id: 'u_ses' }); + const ctx = await resolveRequestExecutionContext(input({ ql: engine, authService: auth.service, headers: sessionHeaders(), session: 'none' })); + expect(ctx).toBeUndefined(); + expect(auth.sessionReads).toHaveLength(0); + }); + + it('{ established } resolves the principal the door established, its organization vetted like a session\'s', async () => { + const { engine } = fakeEngine(); + const auth = fakeAuth({ id: 'u_cookie' }); + const established = (userId: string) => ({ established: { user: { id: userId }, session: { userId, activeOrganizationId: ORG } } }); + const tenancy = () => ({ posture: 'isolated' as const }); + + const member = await resolveRequestExecutionContext(input({ ql: engine, authService: auth.service, headers: sessionHeaders(), session: established('u_ses'), tenancy })); + expect(member).toMatchObject({ userId: 'u_ses', tenantId: ORG }); + // The organization is a claim: a user no membership backs keeps no organization. + const stranger = await resolveRequestExecutionContext(input({ ql: engine, authService: auth.service, headers: sessionHeaders(), session: established('u_stranger'), tenancy })); + expect(stranger?.userId).toBe('u_stranger'); + expect(stranger?.tenantId).toBeUndefined(); + // The cookie beside it never answered. + expect(auth.sessionReads).toHaveLength(0); + }); +}); + +describe('credentials and sessionApi', () => { + it("'api-key-and-session' admits a key; 'session-only' never reads it", async () => { + const { engine } = fakeEngine(); + const admitted = await resolveRequestExecutionContext(input({ ql: engine, headers: keyHeaders() })); + expect(admitted?.userId).toBe('u_key'); + const sessionOnly = await resolveRequestExecutionContext(input({ ql: engine, headers: keyHeaders(), credentials: 'session-only' })); + expect(sessionOnly).toBeUndefined(); + + // 'session-only' still reads the session, over the request's own headers. + const auth = fakeAuth({ id: 'u_ses' }); + const headers = new Headers({ cookie: COOKIE, 'x-api-key': KEY }); + const session = await resolveRequestExecutionContext(input({ ql: engine, authService: auth.service, headers, credentials: 'session-only' })); + expect(session?.userId).toBe('u_ses'); + expect((auth.sessionReads[0] as { headers: unknown }).headers).toBe(headers); + }); + + it("'required': no session API authenticates nobody, the API-key door included, and reads nothing; 'optional' still admits the key", async () => { + const optional = fakeEngine(); + const admitted = await resolveRequestExecutionContext(input({ ql: optional.engine, headers: keyHeaders() })); + expect(admitted?.userId).toBe('u_key'); + + const required = fakeEngine(); + const tenancyReads: string[] = []; + const refused = await resolveRequestExecutionContext(input({ + ql: required.engine, + headers: keyHeaders(), + sessionApi: 'required', + tenancy: () => { tenancyReads.push('tenancy'); return { posture: 'isolated' as const }; }, + })); + expect(refused).toBeUndefined(); + expect(required.reads).toEqual([]); + expect(tenancyReads).toEqual([]); + + // An auth service exposing no session read is the same as none. + const noRead = await resolveRequestExecutionContext(input({ ql: required.engine, headers: keyHeaders(), sessionApi: 'required', authService: {} })); + expect(noRead).toBeUndefined(); + }); + + it("'required' with a session API admits a key and a session as 'optional' does", async () => { + const { engine } = fakeEngine(); + const auth = fakeAuth({ id: 'u_ses' }, { lazy: true }); + const key = await resolveRequestExecutionContext(input({ ql: engine, authService: auth.service, headers: keyHeaders(), sessionApi: 'required' })); + expect(key?.userId).toBe('u_key'); + const session = await resolveRequestExecutionContext(input({ ql: engine, authService: auth.service, headers: sessionHeaders(), sessionApi: 'required' })); + expect(session?.userId).toBe('u_ses'); + }); +}); + +describe('tenancy — the classification of the door\'s own resolution', () => { + it('undefined reads no posture; a walled posture refuses the key its owner no longer backs', async () => { + const { engine } = fakeEngine(); + const noPosture = await resolveRequestExecutionContext(input({ ql: engine, headers: keyHeaders(EX_MEMBER_KEY) })); + expect(noPosture).toMatchObject({ userId: 'u_ex', tenantId: ORG }); + const walled = await resolveRequestExecutionContext(input({ ql: engine, headers: keyHeaders(EX_MEMBER_KEY), tenancy: () => ({ posture: 'isolated' as const }) })); + expect(walled).toBeUndefined(); + }); + + it('a service never registered is quiet; one that failed to build is the 503 outage', async () => { + const kernel = new ObjectKernel({ logger: { level: 'error' }, gracefulShutdown: false, skipSystemValidation: true }); + kernel.registerServiceFactory('tenancy', () => { throw new Error('tenancy backend unavailable'); }); + const { engine } = fakeEngine(); + + const unregistered = new ObjectKernel({ logger: { level: 'error' }, gracefulShutdown: false, skipSystemValidation: true }); + const quiet = await resolveRequestExecutionContext(input({ ql: engine, headers: keyHeaders(EX_MEMBER_KEY), tenancy: () => unregistered.getServiceAsync('tenancy') as never })); + expect(quiet?.userId).toBe('u_ex'); + + const err = await rejectionOf(resolveRequestExecutionContext(input({ ql: engine, headers: keyHeaders(EX_MEMBER_KEY), tenancy: () => kernel.getServiceAsync('tenancy') as never }))); + expect(isAuthzStoreUnavailableError(err)).toBe(true); + expect(err?.code).toBe('SERVICE_UNAVAILABLE'); + expect(err?.status).toBe(503); + expect(err?.object).toBe('tenancy'); + }); +}); + +describe('choice 2 — the ADR-0069 gate', () => { + const GATED = { id: 'u_ses', authGate: { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' } }; + const POSTURE = { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' }; + + it("'withhold' never carries the posture; 'resolved' carries the admitting read's, with no second read", async () => { + const { engine } = fakeEngine(); + const withheld = fakeAuth(GATED); + const w = await resolveRequestExecutionContext(input({ ql: engine, authService: withheld.service, headers: sessionHeaders() })); + expect(w?.userId).toBe('u_ses'); + expect(w?.authGate).toBeUndefined(); + + const resolved = fakeAuth(GATED); + const r = await resolveRequestExecutionContext(input({ ql: engine, authService: resolved.service, headers: sessionHeaders(), authGate: 'resolved' })); + expect(r?.authGate).toEqual(POSTURE); + expect(resolved.sessionReads).toHaveLength(1); + }); + + it("'reread-when-active': an inactive gate (or a probe that throws) reads once and carries none; an active one reads again and carries it", async () => { + const { engine } = fakeEngine(); + for (const gateActive of [false, 'throws'] as const) { + const auth = fakeAuth(GATED, { gateActive }); + const ctx = await resolveRequestExecutionContext(input({ ql: engine, authService: auth.service, headers: sessionHeaders(), authGate: 'reread-when-active' })); + expect(ctx?.authGate).toBeUndefined(); + expect(auth.sessionReads).toHaveLength(1); + } + const active = fakeAuth(GATED, { gateActive: true }); + const ctx = await resolveRequestExecutionContext(input({ ql: engine, authService: active.service, headers: sessionHeaders(), authGate: 'reread-when-active' })); + expect(ctx?.authGate).toEqual(POSTURE); + expect(active.sessionReads).toHaveLength(2); + }); + + it("'reread-when-active': a declared-active gate whose re-read fails is the 503 outage, never a silent admission", async () => { + const { engine } = fakeEngine(); + const auth = fakeAuth(GATED, { gateActive: true, rereadFails: true }); + const err = await rejectionOf(resolveRequestExecutionContext(input({ ql: engine, authService: auth.service, headers: sessionHeaders(), authGate: 'reread-when-active' }))); + expect(isAuthzStoreUnavailableError(err)).toBe(true); + expect(err?.code).toBe('SERVICE_UNAVAILABLE'); + expect(err?.status).toBe(503); + expect(err?.object).toBe('auth_gate'); + }); + + it("an API key is judged by its owner's posture from the admission, never by the session beside it", async () => { + const { engine } = fakeEngine(); + const auth = fakeAuth(GATED, { gateActive: true, ownerGates: [{ code: 'MFA_REQUIRED', message: 'Set up MFA.' }] }); + const headers = new Headers({ cookie: COOKIE, 'x-api-key': KEY }); + const ctx = await resolveRequestExecutionContext(input({ ql: engine, authService: auth.service, headers, authGate: 'reread-when-active' })); + expect(ctx?.userId).toBe('u_key'); + expect(ctx?.authGate).toEqual({ code: 'MFA_REQUIRED', message: 'Set up MFA.' }); + expect(auth.sessionReads).toHaveLength(0); + }); + + it('onAdmission is told the credential and the posture; never for an anonymous request', async () => { + const { engine } = fakeEngine(); + const seen: RequestAdmission[] = []; + const onAdmission = (a: RequestAdmission) => { seen.push(a); }; + await resolveRequestExecutionContext(input({ ql: engine, authService: fakeAuth(GATED).service, headers: sessionHeaders(), onAdmission })); + await resolveRequestExecutionContext(input({ ql: engine, headers: keyHeaders(), onAdmission })); + await resolveRequestExecutionContext(input({ ql: engine, onAdmission, anonymous: 'guest' })); + expect(seen).toEqual([ + { credential: 'session', authGate: POSTURE }, + { credential: 'api_key', authGate: undefined }, + ]); + }); +}); + +describe('localization, the request locale and the session bearer', () => { + const settingsService = { + async get(_ns: string, key: string) { + return { value: ({ timezone: 'Europe/Paris', locale: 'fr-FR', currency: 'EUR' } as Record)[key], source: 'tenant' }; + }, + }; + + it('the settings lookup localizes a resolved principal; the request locale wins; withheld or anonymous reads nothing', async () => { + const { engine } = fakeEngine(); + let lookups = 0; + const localization = { settings: () => { lookups += 1; return settingsService; } }; + + const ctx = await resolveRequestExecutionContext(input({ ql: engine, headers: keyHeaders(), localization })); + expect(ctx).toMatchObject({ timezone: 'Europe/Paris', locale: 'fr-FR', currency: 'EUR' }); + const preferred = await resolveRequestExecutionContext(input({ ql: engine, headers: keyHeaders(), localization, requestLocale: 'zh-CN' })); + expect(preferred?.locale).toBe('zh-CN'); + expect(lookups).toBe(2); + + const withheld = await resolveRequestExecutionContext(input({ ql: engine, headers: keyHeaders() })); + expect(withheld?.timezone).toBeUndefined(); + const anonymous = await resolveRequestExecutionContext(input({ ql: engine, localization, anonymous: 'guest' })); + expect(anonymous?.timezone).toBeUndefined(); + expect(lookups).toBe(2); + }); + + it('a settings lookup that rejects reads as no service: the defaults stand', async () => { + const { engine } = fakeEngine(); + const ctx = await resolveRequestExecutionContext(input({ ql: engine, headers: keyHeaders(), localization: { settings: () => Promise.reject(new Error('down')) } })); + expect(ctx).toMatchObject({ timezone: 'UTC', locale: 'en-US' }); + }); + + it("'carry' puts the session bearer on the envelope; 'withhold' does not", async () => { + const { engine } = fakeEngine(); + const carried = await resolveRequestExecutionContext(input({ ql: engine, authService: fakeAuth({ id: 'u_ses' }).service, headers: sessionHeaders(), accessToken: 'carry' })); + expect(carried?.accessToken).toBe('sess_tok'); + const withheld = await resolveRequestExecutionContext(input({ ql: engine, authService: fakeAuth({ id: 'u_ses' }).service, headers: sessionHeaders() })); + expect(withheld?.userId).toBe('u_ses'); + expect(withheld?.accessToken).toBeUndefined(); + }); +}); + +describe('OAuth provenance and fault propagation', () => { + it('an OAuth provenance naming a client makes the principal an agent on behalf of the user', async () => { + const { engine } = fakeEngine(); + const oauth: OAuthTokenProvenance = { userId: 'u_ses', scopes: ['data:read'], clientId: 'agent-1', scopePermissions: ['agent_read'], delegatesActions: false }; + const session = { established: { user: { id: 'u_ses' }, session: { userId: 'u_ses' } } }; + const ctx = await resolveRequestExecutionContext(input({ ql: engine, session, oauth })); + expect(ctx).toMatchObject({ principalKind: 'agent', userId: 'u_ses', permissions: ['agent_read'], oauthScopes: ['data:read'] }); + expect(ctx?.onBehalfOf).toEqual({ userId: 'u_ses', principalKind: 'human' }); + const human = await resolveRequestExecutionContext(input({ ql: engine, session })); + expect(human?.principalKind).toBe('human'); + }); + + it('a key its own lifecycle refuses rejects with its declared code and status; the composition never swallows it', async () => { + const { engine } = fakeEngine(); + const err = await rejectionOf(resolveRequestExecutionContext(input({ ql: engine, headers: keyHeaders(REVOKED_KEY), anonymous: 'guest' }))); + expect(err?.code).toBe('API_KEY_REVOKED'); + expect(err?.status).toBe(403); + }); +}); diff --git a/packages/core/src/security/request-execution-context.ts b/packages/core/src/security/request-execution-context.ts new file mode 100644 index 00000000000..e3a17adddf4 --- /dev/null +++ b/packages/core/src/security/request-execution-context.ts @@ -0,0 +1,288 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * resolveRequestExecutionContext — the ONE composition that turns an inbound + * request into the caller's {@link ExecutionContext}. + * + * ## Why it exists (ruling record 6227928430, letter A) + * + * Five platform doors each hand-rolled the same steps around the shared + * pieces: read the session through the auth service, classify the tenancy + * posture, call `resolveAuthzContext`, pick the anonymous entry, resolve the + * localization, decide the ADR-0069 gate, and assemble the envelope. Each copy + * was repaired separately when it omitted one input another copy had (the + * tenancy posture, the in-process session read, six envelope fields). This + * function is those steps, once. A door supplies the pieces it holds and + * states its intentional differences as the named parameters below; ⛔ a door + * never re-composes the steps beside it. + * + * ## The two choices, made here + * + * 1. **The anonymous entry** ({@link RequestExecutionContextInput.anonymous}). + * `'fail-closed'` is the default entry: a request that resolves no + * principal yields NO context, and the door answers 401 + * (`assembleExecutionContext`). `'guest'` serves the request as the + * first-class guest principal and resolves the guest anchor's grants + * (`assembleExecutionContextOrGuest`, `resolveAuthzContext`'s + * `guestEntry`). A door takes `'guest'` only when serving anonymous + * principals is its product semantics (ADR-0138 D2 lists those doors). + * 2. **The ADR-0069 authentication-policy gate** + * ({@link RequestExecutionContextInput.authGate}). The posture is decided + * for whichever credential admitted the caller, by the shared key-admission + * seam for an API key and from the session read for a session. A door either + * carries it on the envelope for a consumer that reads it there, or + * withholds it because it enforces the gate at its own seam. + * + * ## The named parameters, and the door each one exists for + * + * Every key is REQUIRED, the way `ExecutionContextAssemblyInput`'s are: a door + * that has no value passes `undefined` or the withholding literal on the + * record, so a door cannot drop an input by omission. + * + * | parameter | values, and the door that takes each | + * |:--|:--| + * | `anonymous` | `'guest'`: the runtime / MCP dispatcher. `'fail-closed'`: REST, settings, storage, the `/auth/me` endpoints | + * | `authGate` | `'withhold'`: the dispatcher (its `enforceAuthGate` re-reads the session; a key's answer leaves through `onAdmission`) and the `/auth/me` endpoints (allow-listed for a gated user). `'resolved'`: settings. `'reread-when-active'`: REST and the storage family door | + * | `credentials` | `'session-only'`: the `/auth/me` endpoints, which never opened an API-key door. `'api-key-and-session'`: every other door | + * | `sessionApi` | `'required'`: REST and the `/auth/me` endpoints, which authenticate nobody without a session API. `'optional'`: the dispatcher, settings and storage, which still admit an API key | + * | `session` | `'request'`: every door. `{ established }` / `'none'`: the dispatcher's `/mcp` face, for a verified / an unverifiable OAuth access token | + * | `tenancy` | the door's own way to reach the `tenancy` service (#16013: the resolution stays at the door), or `undefined` where the door reads no posture (the `/auth/me` endpoints) | + * | `localization` | the door's settings-service lookup (the dispatcher, REST), or `'withhold'` (the `/auth/me` endpoints read the cascade per endpoint) | + * | `requestLocale` | the door's own parse: `Accept-Language` (the dispatcher), the header or `?locale` (REST), none (the `/auth/me` endpoints) | + * | `accessToken` | `'carry'`: the dispatcher. `'withhold'`: REST and the `/auth/me` endpoints | + * | `oauth` | the dispatcher's `/mcp` face, which alone speaks the OAuth scope vocabulary; `undefined` elsewhere | + * | `onAdmission` | the dispatcher, whose gate needs the credential the envelope does not carry; `undefined` elsewhere | + * + * ## What stays at the door + * + * How the door reaches its services (a kernel per environment, a plugin + * context, a registry slice), how it turns its transport's headers into a Web + * `Headers`, how it answers a fault (this function PROPAGATES every + * rejection: an unreadable permission store, an unreadable posture or gate, an + * API key its own lifecycle refuses), and what it does with the context + * afterwards. Those are transport facts, not the composition. + */ + +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { AuthSessionApi, IAuthService } from '@objectstack/spec/contracts'; +import { inProcessSessionReadInput } from '@objectstack/types'; + +import { classifyAdmissionTenancyPosture, type TenancyServiceResolver } from './admission-tenancy-posture.js'; +import { apiKeyLifecyclePolicySourceOf } from './api-key.js'; +import { + assembleExecutionContext, + assembleExecutionContextOrGuest, + type EntryLocalization, + type ExecutionContextAssemblyInput, + type OAuthTokenProvenance, +} from './assemble-execution-context.js'; +import { normalizeAuthGate, userAuthGateSourceOf, type AuthGate } from './auth-gate.js'; +import { AuthzStoreUnavailableError } from './authz-store-unavailable.js'; +import { resolveAuthzContext, resolveLocalizationContext, type ResolvedAuthzContext } from './resolve-authz-context.js'; + +/** The slice of the kernel's `auth` service this composition reads. */ +export type RequestAuthService = Pick< + IAuthService, + 'api' | 'getApi' | 'isAuthGateActive' | 'resolveUserAuthGates' | 'resolveApiKeyLifecyclePolicy' +>; + +/** + * A session a door has already established from a credential other than the + * request's own session: the dispatcher's verified OAuth access token. The + * organization is the one the token's consent bound, and the session arm vets + * it as it vets a browser session's active organization (ADR-0090 D10). + */ +export interface EstablishedRequestSession { + user: { id: string }; + session: { userId: string; activeOrganizationId?: string }; +} + +/** Which credential admitted the principal, and the ADR-0069 posture decided for it. */ +export interface RequestAdmission { + credential: 'api_key' | 'session'; + authGate: AuthGate | undefined; +} + +/** Everything the composition reads. Every key is required; see the module docblock. */ +export interface RequestExecutionContextInput { + /** The request's headers, as a Web `Headers`. */ + headers: Headers; + /** The data engine (ObjectQL), or `undefined` when the door has none. */ + ql: unknown; + /** The kernel's `auth` service, or `undefined` when none is reachable. */ + authService: RequestAuthService | undefined; + /** + * How this door reaches the `tenancy` service, handed to + * `classifyAdmissionTenancyPosture` (never registered: no posture; failed: + * the 503 outage). `undefined`: this door reads no posture. + */ + tenancy: TenancyServiceResolver | undefined; + /** Choice 1: the anonymous entry. */ + anonymous: 'fail-closed' | 'guest'; + /** + * Choice 2: the ADR-0069 gate on the envelope. + * + * - `'withhold'`: never on the envelope; the door enforces the gate itself. + * - `'resolved'`: the posture `resolveAuthzContext` decided for whichever + * credential admitted the caller, from the read that admitted it. + * - `'reread-when-active'`: an API key's posture as `'resolved'`; for the + * request's own session, read again only while the auth service says a + * gate is active (`isAuthGateActive()`, a probe that throws reads as + * inactive). A re-read that fails is `AuthzStoreUnavailableError('auth_gate')`: + * a gate the deployment declared active is never dropped silently. + */ + authGate: 'withhold' | 'resolved' | 'reread-when-active'; + /** Which credential doors this door opens. `'session-only'` reads no API key. */ + credentials: 'api-key-and-session' | 'session-only'; + /** + * `'required'`: with no auth service, or one exposing no session read, this + * door authenticates nobody, the API-key door included. `'optional'`: the + * API-key door still admits. + */ + sessionApi: 'optional' | 'required'; + /** + * Where the session arm reads its session. `'request'`: the auth service's + * read of this request, under the in-process rule (a request carrying a + * session cookie reads without renewal). `{ established }`: a session the + * door established from another credential. `'none'`: the door's own + * credential failed, and no session answers for it, ⛔ the cookie beside it + * included. + */ + session: 'request' | 'none' | { readonly established: EstablishedRequestSession }; + /** + * For an authenticated principal, the door's settings-service lookup for the + * reference localization (a lookup that rejects reads as no service), or + * `'withhold'` to resolve none. + */ + localization: 'withhold' | { readonly settings: () => unknown }; + /** The request's own locale preference, parsed by the door; wins over the workspace default. */ + requestLocale: string | undefined; + /** Whether the session bearer rides the envelope to hooks (`session.accessToken`). */ + accessToken: 'carry' | 'withhold'; + /** OAuth access-token provenance, derived by the one door that accepts one. */ + oauth: OAuthTokenProvenance | undefined; + /** Told which credential admitted the principal; not called for an anonymous request. */ + onAdmission: ((admission: RequestAdmission) => void) | undefined; +} + +/** `.api` (the legacy direct mount) or `getApi()` (the lazy plugin). */ +async function sessionApiOf(authService: RequestAuthService | undefined): Promise { + let api = authService?.api; + if (!api && typeof authService?.getApi === 'function') api = await authService.getApi(); + return api; +} + +/** Choice 2, for a resolved principal. */ +async function decideAuthGate( + input: RequestExecutionContextInput, + authz: ResolvedAuthzContext, + sessionApi: () => Promise, +): Promise { + if (input.authGate === 'withhold') return undefined; + if (input.authGate === 'resolved') return authz.authGate; + // 'reread-when-active'. A key, or a session the door established from another + // credential, is judged by the answer the admission already decided. + if (authz.credential !== 'session' || input.session !== 'request') return authz.authGate; + let active = false; + try { + active = input.authService?.isAuthGateActive?.() === true; + } catch { + // A probe that faults never declared a gate. + active = false; + } + if (!active) return undefined; + let gatedSession: Awaited>>; + try { + const api = await sessionApi(); + gatedSession = await api!.getSession!(inProcessSessionReadInput(input.headers)); + } catch (err) { + throw new AuthzStoreUnavailableError('auth_gate', err); + } + return normalizeAuthGate(gatedSession?.user) ?? undefined; +} + +/** + * Resolve the request's {@link ExecutionContext}: the guest entry always + * yields one; the fail-closed entry yields `undefined` for a request that + * resolves no principal. + * + * @throws {AuthzStoreUnavailableError} an authorization input that exists and + * could not be read (the permission store, the `tenancy` service, an active + * gate's re-read). + * @throws {ApiKeyLifecycleRefusalError} an API key its own lifecycle refuses. + */ +export function resolveRequestExecutionContext( + input: RequestExecutionContextInput & { anonymous: 'guest' }, +): Promise; +export function resolveRequestExecutionContext( + input: RequestExecutionContextInput, +): Promise; +export async function resolveRequestExecutionContext( + input: RequestExecutionContextInput, +): Promise { + const { headers, ql, authService } = input; + + // A door that requires a session API asks for it before any read, and + // without one authenticates nobody. A door that does not asks lazily, from + // the session read itself, so an API-key request never builds the auth + // instance. + let requiredApi: AuthSessionApi | undefined; + let credentialsOpen = true; + if (input.sessionApi === 'required') { + requiredApi = await sessionApiOf(authService); + if (typeof requiredApi?.getSession !== 'function') { + if (input.anonymous === 'fail-closed') return undefined; + credentialsOpen = false; + } + } + const sessionApi = () => (input.sessionApi === 'required' ? Promise.resolve(requiredApi) : sessionApiOf(authService)); + + const tenancyPosture = input.tenancy ? await classifyAdmissionTenancyPosture(input.tenancy) : undefined; + + const session = input.session; + const readSession = async () => { + if (session === 'none') return undefined; + if (typeof session === 'object') return session.established; + try { + const api = await sessionApi(); + return await api?.getSession?.(inProcessSessionReadInput(headers)); + } catch { + return undefined; + } + }; + + const authz = await resolveAuthzContext({ + ql, + headers: credentialsOpen && input.credentials === 'api-key-and-session' ? headers : undefined, + getSession: credentialsOpen ? readSession : undefined, + tenancyPosture, + guestEntry: input.anonymous === 'guest' ? true : undefined, + userAuthGates: userAuthGateSourceOf(authService), + apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService), + }); + if (authz.userId && authz.credential) { + input.onAdmission?.({ credential: authz.credential, authGate: authz.authGate }); + } + + // An anonymous request has no scope to localize against and no session for a + // gate to attach to: both are read only for a resolved principal. + let localization: EntryLocalization | undefined; + let authGate: AuthGate | undefined; + if (authz.userId) { + if (input.localization !== 'withhold') { + const settings = await Promise.resolve(input.localization.settings()).catch(() => undefined); + localization = await resolveLocalizationContext({ ql, settings, tenantId: authz.tenantId, userId: authz.userId }); + } + authGate = await decideAuthGate(input, authz, sessionApi); + } + + const assembly: ExecutionContextAssemblyInput = { + authz, + oauth: input.oauth, + localization, + requestLocale: input.requestLocale, + accessToken: input.accessToken === 'carry' ? authz.accessToken : undefined, + authGate, + }; + return input.anonymous === 'guest' ? assembleExecutionContextOrGuest(assembly) : assembleExecutionContext(assembly); +} diff --git a/packages/runtime/src/security/request-execution-context.conformance.test.ts b/packages/runtime/src/security/request-execution-context.conformance.test.ts new file mode 100644 index 00000000000..a1edecab199 --- /dev/null +++ b/packages/runtime/src/security/request-execution-context.conformance.test.ts @@ -0,0 +1,229 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The conformance test of ruling record 6227928430, item 4: one request driven + * through `resolveRequestExecutionContext` (`@objectstack/core`) and through + * each door converted onto it must give one identical context. + * + * ## The shape + * + * Each converted door is one row of {@link DOORS}: how a request is driven + * through the door's OWN entry, and the named parameters the door declares to + * the function. Every request in {@link REQUESTS} goes through both, and the + * two contexts must be equal. A later slice converts its door and adds it as + * one more row; nothing else here changes. + * + * Across rows, a request that resolves a principal must resolve the SAME + * principal at every door ({@link IDENTITY}): the named parameters decide what + * a door withholds or adds around the identity, never who the caller is. + * + * ## Why the requests are these + * + * Each named parameter changes the answer for at least one request below, so a + * row whose declared parameters drift from what its door really passes fails + * here instead of agreeing by accident: + * + * | request | the parameters it tells apart | + * |:--|:--| + * | no credential | `anonymous` | + * | a session cookie, a gated user, a session bearer, `Accept-Language` | `session`, `authGate`, `accessToken`, `requestLocale`, `localization` | + * | an API key | `credentials` | + * | an ex-member's organization-stamped key under a walled posture | `tenancy` | + * | an API key on a host with no auth service | `sessionApi` | + * + * The runtime door's `/mcp` face (an OAuth access token) is not a row: its + * verification and scope interpretation are the door's own, ahead of the call, + * and its pins are `resolve-execution-context.oauth-organization.test.ts` and + * the agent-principal cases of `resolve-execution-context.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; + +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { preferredLocaleFromHeader } from '@objectstack/spec/system'; +import { resolveRequestExecutionContext, type RequestExecutionContextInput } from '@objectstack/core'; + +import { hashApiKey } from './api-key.js'; +import { resolveExecutionContext } from './resolve-execution-context.js'; +import { bindCatalogFromTables } from './security-catalog.testkit.js'; + +const ORG = 'org_alpha'; +const COOKIE = 'better-auth.session_token=tok3n.c2ln'; +const KEY = 'osk_conformance_key'; +const EX_MEMBER_KEY = 'osk_conformance_ex_member'; +const FUTURE = '2999-01-01T00:00:00Z'; + +/** One deployment every row is driven against: its engine and its kernel services. */ +interface World { + ql: unknown; + services: Record; +} + +function world(opts: { auth: boolean }): World { + const tables: Record>> = { + sys_api_key: [ + { id: 'k_live', key: hashApiKey(KEY), revoked: false, user_id: 'u_key', active_organization_id: ORG, expires_at: FUTURE }, + { id: 'k_ex', key: hashApiKey(EX_MEMBER_KEY), revoked: false, user_id: 'u_ex', active_organization_id: ORG, expires_at: FUTURE }, + ], + sys_member: [ + { user_id: 'u_key', organization_id: ORG, role: 'member' }, + { user_id: 'u_ses', organization_id: ORG, role: 'admin' }, + ], + sys_user_position: [{ user_id: 'u_ses', position: 'sales_rep', organization_id: ORG }], + sys_user_permission_set: [{ user_id: 'u_key', permission_set: 'contact_reader', organization_id: null }], + sys_permission_set: [ + { id: 'ps_sales', name: 'sales_user', object_permissions: '{}' }, + { id: 'ps_contact', name: 'contact_reader', system_permissions: '["export_reports"]', object_permissions: '{}' }, + ], + sys_position: [{ id: 'pos_sales', name: 'sales_rep', organization_id: null }], + sys_position_permission_set: [{ position_id: 'pos_sales', permission_set_id: 'ps_sales' }], + }; + const ql = bindCatalogFromTables({ + async find(object: string, q: { where?: Record; limit?: number } | undefined) { + const rows = (tables[object] ?? []).filter((row) => Object.entries(q?.where ?? {}).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fixture: unsupported combinator ${k}`); + if (v !== null && typeof v === 'object') { + return Array.isArray((v as { $in?: unknown[] }).$in) ? (v as { $in: unknown[] }).$in.includes(row[k]) : true; + } + return (v ?? null) === (row[k] ?? null); + })); + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + }, tables); + + const services: Record = { + tenancy: { posture: 'isolated' }, + settings: { + async get(namespace: string, key: string) { + const values: Record = { 'localization.timezone': 'Europe/Paris', 'localization.locale': 'fr-FR' }; + return { value: values[`${namespace}.${key}`], source: 'tenant' }; + }, + }, + }; + if (opts.auth) { + services.auth = { + api: { + async getSession(input: { headers: Headers }) { + if (input.headers.get('cookie') !== COOKIE) return undefined; + return { + user: { id: 'u_ses', email: 'ses@example.com', authGate: { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' } }, + session: { id: 'sess_1', userId: 'u_ses', token: 'sess_bearer', activeOrganizationId: ORG }, + }; + }, + }, + isAuthGateActive: () => true, + resolveUserAuthGates: async () => [], + }; + } + return { ql, services }; +} + +interface Request { + name: string; + headers: Record; + auth: boolean; + /** Who the request resolves to at every door, or `undefined` for none. */ + principal: string | undefined; +} + +const REQUESTS: Request[] = [ + { name: 'no credential', headers: {}, auth: true, principal: undefined }, + { + name: 'a session cookie for a gated user, with a language preference', + headers: { cookie: COOKIE, 'accept-language': 'zh-CN,zh;q=0.9' }, + auth: true, + principal: 'u_ses', + }, + { name: 'an API key', headers: { 'x-api-key': KEY }, auth: true, principal: 'u_key' }, + { name: "an ex-member's organization-stamped key under the walled posture", headers: { 'x-api-key': EX_MEMBER_KEY }, auth: true, principal: undefined }, + { name: 'an API key on a host with no auth service', headers: { 'x-api-key': KEY }, auth: false, principal: 'u_key' }, +]; + +/** A converted door: its own entry, and the named parameters it declares to the function. */ +interface DoorRow { + door: string; + drive(w: World, headers: Record): Promise; + parameters(w: World, headers: Headers): Omit; +} + +const DOORS: DoorRow[] = [ + { + door: 'packages/runtime/src/security/resolve-execution-context.ts', + drive: (w, headers) => resolveExecutionContext({ + getService: async (name: string) => w.services[name], + getQl: async () => w.ql, + request: { headers }, + }), + parameters: (w, headers) => ({ + tenancy: () => w.services.tenancy as never, + anonymous: 'guest', + authGate: 'withhold', + credentials: 'api-key-and-session', + sessionApi: 'optional', + session: 'request', + localization: { settings: () => w.services.settings }, + requestLocale: preferredLocaleFromHeader(headers.get('accept-language')), + accessToken: 'carry', + oauth: undefined, + onAdmission: undefined, + }), + }, +]; + +/** The facts that say WHO the caller is; equal at every door that resolves one. */ +const IDENTITY = ['userId', 'tenantId', 'email', 'positions', 'permissions', 'systemPermissions', 'posture', 'org_user_ids', 'accessible_org_ids', 'principalKind'] as const; + +describe('[ruling 6227928430 item 4] one request, the function and every converted door, one context', () => { + for (const request of REQUESTS) { + it(`${request.name}: each door's context IS the function's under the door's declared parameters`, async () => { + for (const row of DOORS) { + const w = world({ auth: request.auth }); + const viaDoor = await row.drive(w, request.headers); + const headers = new Headers(request.headers); + const viaFunction = await resolveRequestExecutionContext({ + headers, + ql: w.ql, + authService: w.services.auth as RequestExecutionContextInput['authService'], + ...row.parameters(w, headers), + }); + expect(viaDoor, row.door).toEqual(viaFunction); + expect(viaDoor?.userId, row.door).toBe(request.principal); + } + }); + } + + it('a principal is the same principal at every door', async () => { + for (const request of REQUESTS.filter((r) => r.principal)) { + const identities = await Promise.all(DOORS.map(async (row) => { + const ctx = await row.drive(world({ auth: request.auth }), request.headers); + return Object.fromEntries(IDENTITY.map((k) => [k, ctx?.[k]])); + })); + for (const identity of identities) expect(identity, request.name).toEqual(identities[0]); + } + }); + + it('CONTROL: the requests tell the runtime door\'s named parameters apart', async () => { + // Without this, a fixture in which every parameter answered alike would + // make the equality above hold for any declared set. Each reading below is + // a parameter the session request or the anonymous one discriminates. + const w = world({ auth: true }); + const session = await DOORS[0]!.drive(w, REQUESTS[1]!.headers); + expect(session).toMatchObject({ userId: 'u_ses', principalKind: 'human', accessToken: 'sess_bearer', locale: 'zh-CN', timezone: 'Europe/Paris', tenantId: ORG }); + // `authGate: 'withhold'`: the gated user's posture is not on the envelope. + expect(session?.authGate).toBeUndefined(); + const anonymous = await DOORS[0]!.drive(w, REQUESTS[0]!.headers); + expect(anonymous?.principalKind).toBe('guest'); + // And the function answers differently when a parameter is flipped. + const headers = new Headers(REQUESTS[1]!.headers); + const flipped = await resolveRequestExecutionContext({ + headers, + ql: w.ql, + authService: w.services.auth as RequestExecutionContextInput['authService'], + ...DOORS[0]!.parameters(w, headers), + authGate: 'resolved', + accessToken: 'withhold', + }); + expect(flipped?.authGate).toEqual({ code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' }); + expect(flipped?.accessToken).toBeUndefined(); + }); +}); diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index bb3e258874d..c1e8c5ba34c 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -1,19 +1,15 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * resolveExecutionContext — REST/dispatcher entry-point identity resolver. + * resolveExecutionContext — the runtime / MCP dispatcher's identity door. * - * 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 + - * position/permission/RLS aggregation to the shared resolver, and layers the - * reference localization (timezone/locale) on top. - * - * The actual table reads (`sys_member` / `sys_user_position` / - * `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. + * [ruling record 6227928430, letter A] The composition — the session read, + * the tenancy-posture classification, the shared authorization resolver + * (`resolveAuthzContext`), the localization and the ExecutionContext assembly — + * is `resolveRequestExecutionContext` in `@objectstack/core`, the one function + * every platform door calls. This door only does what is its own: pull `ql` + * and the services out of the active kernel/scope, verify an OAuth access token + * on the `/mcp` face, and state its named parameters to that function. * * Resolves for every request it can ANSWER — anonymous requests yield the * guest envelope (`principalKind: 'guest'`, `positions: ['guest']`, @@ -30,34 +26,15 @@ import type { ExecutionContext } from '@objectstack/spec/kernel'; import type { IAuthService, ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts'; import { scopesToAgentPermissionSets, MCP_OAUTH_SCOPE_ACTIONS } from '@objectstack/spec/ai'; import { preferredLocaleFromHeader } from '@objectstack/spec/system'; -// [#22258] The in-process session-read rule — see the session getter below. -import { inProcessSessionReadInput } from '@objectstack/types'; -import { - resolveAuthzContext, - resolveLocalizationContext, - assembleExecutionContextOrGuest, - type EntryLocalization, - // [#13906 decision 1 A / #16013] The ONE classification this door applies to - // the `tenancy` service's rejection: the REGISTRY's own "never registered" - // brand absorbs the supported no-tenancy composition (quiet `undefined`) - // while every other rejection becomes the loud `AuthzStoreUnavailableError` - // for an authorization input that exists and could not be read. Never - // message text (#13905). ⛔ The RESOLUTION stays here — see the call site. - classifyAdmissionTenancyPosture, - // [#23287, ruling C′ item 3] The auth service's per-user posture - // computation, bound for the shared key-admission seam. - userAuthGateSourceOf, - apiKeyLifecyclePolicySourceOf, - type AuthGate, -} from '@objectstack/core'; +import { resolveRequestExecutionContext, type RequestAdmission } from '@objectstack/core'; /** * [#4127 batch 4] The lookup facade, typed by slot like the ones on * `DomainHandlerDeps` and `ActionExecutionDeps`. This was the THIRD copy still - * returning `any`, and the one that mattered most: both of its `'auth'` callers - * read members — `verifyMcpAccessToken`, `api`, `getApi` — that nothing - * declared, on the path that decides who a request is. + * returning `any`, and the one that mattered most: its `'auth'` lookup reads + * members — `verifyMcpAccessToken`, `api`, `getApi` — that nothing declared, + * on the path that decides who a request is. */ interface KernelServiceLookup { (name: K): Promise | undefined>; @@ -104,7 +81,7 @@ interface ResolveOptions { * not carry either fact (its `authGate` stays withheld on this face, see * below), so this is the channel. Not called for an anonymous request. */ - onAdmission?: (admission: { credential: 'api_key' | 'session'; authGate: AuthGate | undefined }) => void; + onAdmission?: (admission: RequestAdmission) => void; } /** @@ -144,13 +121,19 @@ function toHeaders(input: any): any { export async function resolveExecutionContext(opts: ResolveOptions): Promise { const headers = toHeaders(opts.request?.headers); const ql = await opts.getQl(); + // The `auth` service, looked up once for every reader below: the OAuth + // verifier, the session read and the key-admission seam's posture and + // lifecycle sources. A lookup that rejects is a host with no auth service. + const authService = await Promise.resolve(opts.getService('auth')).catch(() => undefined); // ── OAuth 2.1 access-token provenance (MCP surface only, #2698) ── // Verified BEFORE session resolution so the presented credential decides // the outcome. Verification lives in the auth service (it owns the AS + - // JWKS); the *authorization* resolution below still flows through the - // single shared resolver — OAuth is a second provenance for the - // principal, never a second authz model. + // JWKS); the *authorization* resolution still flows through the one shared + // composition — OAuth is a second provenance for the principal, never a + // second authz model. It stays at this door because the scope vocabulary + // it is read in (`@objectstack/spec/ai`) is MCP-domain knowledge the core + // package deliberately does not depend on. let oauthPrincipal: VerifiedMcpAccessToken | undefined; let oauthBearerPresented = false; if (opts.acceptOAuthAccessToken) { @@ -161,7 +144,6 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise { - try { - // [#4127 FINDING, batch 5] - // Reaches BOTH undeclared shapes in two lines — `.api` and the `getApi()` - // fallback — which is the clearest statement of the gap in the codebase. - const authService = await opts.getService('auth'); - let api: any = authService?.api; - if (!api && typeof authService?.getApi === 'function') api = await authService.getApi(); - // [#22258] A request carrying a session cookie reads without renewal: - // this door's response never carries a renewed cookie, so a renewal - // here would leave the browser's cookie to die before its session. - return await api?.getSession?.(inProcessSessionReadInput(h)); - } catch { - return undefined; - } - }; - - // Session getter by provenance: - // - verified OAuth token → synthetic session for the token's principal - // (roles/permissions/RLS still aggregate through resolveAuthzContext); - // - JWT bearer presented but NOT verified → hard anonymous (no cookie - // fallback — a dead token must yield 401, not ambient session access); - // - otherwise → the regular better-auth session path. - // - // [ADR-0090 D10 · ruling record 6232069920, letter A] The synthetic session - // is built IN the one organization the user's consent bound — the verified - // result's `organizationId`, carried where a session carries its active - // organization. So `resolveAuthzContext`'s session arm vets it on every - // request by the rule it applies to a browser session - // (`vetOrganizationClaim`): kept while a current membership backs it under a - // walled posture; dropped — no organization, only global grants, so tenant - // reads come back empty and tenant writes are refused — once none does. The - // D10 delegator is then resolved in that organization, so the user's - // organization-scoped grants count for their agent as they do for them. - // ⛔ Never the union of the user's organizations, never the organization - // their browser session has selected now, never a widened global grant. A - // token minted before consents bound one carries none: `undefined` here, - // the principal resolves with no organization, exactly as before. - const getSessionForProvenance = oauthPrincipal - ? async () => ({ - user: { id: oauthPrincipal!.userId }, - session: { userId: oauthPrincipal!.userId, activeOrganizationId: oauthPrincipal!.organizationId }, - }) - : oauthBearerPresented - ? async () => undefined - : getSession; - - // [#8287] The EFFECTIVE tenancy posture, from the kernel's `tenancy` service — - // the same source `plugin-security` reconciles before handing a posture to the - // Layer 0 wall, so admission and the wall can never disagree. Deliberately not - // `OS_TENANCY_POSTURE`: that is what the operator ASKED for, and under - // ADR-0093 D4/D5 a requested-but-unenforceable wall resolves to `single`. - // - // [#13906 decision 1 A, at this door] `undefined` is not a neutral value - // here: `resolveAuthzContext` gates BOTH posture-conditional API-key - // refusals (`organization_required`, `organization_membership_ended`) on a - // PRESENT posture, so "no posture" means "no wall". The seam used to resolve - // EVERY rejection to that — and a `tenancy` service that was registered and - // FAILED to build read as a deployment with no tenancy at all. Two facts, - // told apart by the registry's own brand, the way `rest-server.ts` already - // does on both of its wirings: - // - // - never registered → branded → `undefined`, quiet. The supported - // no-tenancy composition: no wall exists, and refusing there would break - // every single-organization embedder. - // - registered and FAILED to build → unbranded → re-raised as the same loud - // answer a failed permission-store read gives (commit 6a180e42d): the posture is an - // authorization INPUT, so admission was never decided. A posture that - // could not be READ is not a posture that is ABSENT. - // - // A facade that resolves `undefined` instead of rejecting (the dispatcher's - // capability PROBE, `resolveService`) still reads as absent — which is why - // `HttpDispatcher.resolveRequestScope` hands THIS read the classified - // rejection rather than the probe's collapsed answer. - // - // [commit 4af758d47] The CLASSIFICATION above is `classifyAdmissionTenancyPosture`'s, - // not a hand-written copy of it: this seam was one of the two that #16013 - // left behind, and a copy of this decision is by construction the stale one. - // ⛔ The RESOLUTION is still this door's own and must stay so — `opts.getService` - // is this facade's lookup (the dispatcher hands it the registry's own async - // accessor; other callers hand it a probe), so it is handed in as the thunk - // and the helper never learns how this seam reaches the service. - const tenancyPosture = await classifyAdmissionTenancyPosture( - () => opts.getService('tenancy'), - ); - - // [#23287, ruling C′ item 3] The per-user posture computation the shared - // key-admission seam asks for a key's owner. A probe that faults is a host - // with no posture to ask, exactly as for the session getter above. - const authServiceForGates = await Promise.resolve(opts.getService('auth')).catch(() => undefined); - - const authz = await resolveAuthzContext({ - ql, + return resolveRequestExecutionContext({ headers, - getSession: getSessionForProvenance, - tenancyPosture, - // [ADR-0138 D3/D4] This face takes the explicit guest entry below, so a - // request that resolves no principal is served the guest's grants: the - // sets bound to the `guest` anchor, in the organization D4 derives. - guestEntry: true, - userAuthGates: userAuthGateSourceOf(authServiceForGates as any), - // [#23367, ruling A] The API-key lifecycle policy, from the same service. - // A key its own lifecycle refuses raises the branded - // `ApiKeyLifecycleRefusalError` out of this call, and this door hands it - // up unchanged: whoever catches the resolution decides how loud it is. - apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authServiceForGates as any), - }); - if (authz.userId && authz.credential) { - opts.onAdmission?.({ credential: authz.credential, authGate: authz.authGate }); - } - - // [commit f586f1a89 — maintainer ruling 2026-08-08, Option A] The ExecutionContext - // ASSEMBLY now lives in ONE place too (`assembleExecutionContext*`, - // @objectstack/core), shared with the REST face. Everything above is - // transport-specific plumbing; the field set is closed by type over there, so - // a new `ExecutionContext` field can no longer reach one face and miss - // another (the #6071 drift class), and the per-face divergences are named - // inputs rather than silent omissions. - // - // This face takes the EXPLICIT GUEST entry: a sessionless request on the - // runtime / MCP door is a first-class guest principal, and enforcement - // consumers read it (`plugin-security/explain-engine.ts`: guest ⇒ `EXTERNAL` - // posture). The REST face takes the fail-closed default entry instead - // (no session → no ctx → 401), unchanged. - - // Anonymous → skip localization (no scope to resolve against); keep the engine - // default. Authenticated → resolve reference timezone/locale/currency. - let localization: EntryLocalization | undefined; - if (authz.userId) { - const settings = await Promise.resolve(opts.getService('settings')).catch(() => undefined); - localization = await resolveLocalizationContext({ - ql, - settings, - tenantId: authz.tenantId, - userId: authz.userId, - }); - } - - return assembleExecutionContextOrGuest({ - authz, + ql, + authService, + // [#8287 · #13906 decision 1 A · #16013] The EFFECTIVE tenancy posture, + // from the kernel's `tenancy` service, classified by the shared helper. + // ⛔ The RESOLUTION is still this door's own: `opts.getService` is this + // facade's lookup (the dispatcher hands it the registry's own async + // accessor for `tenancy`; other callers hand it a probe), so it is handed + // in as the thunk and the shared function never learns how this door + // reaches the service. + tenancy: () => opts.getService('tenancy'), + // [commit f586f1a89 · ADR-0138 D3/D4] The EXPLICIT GUEST entry: a sessionless + // request on the runtime / MCP door is a first-class guest principal, + // served the sets bound to the `guest` anchor, and enforcement consumers + // read it (`plugin-security/explain-engine.ts`: guest ⇒ `EXTERNAL`). + anonymous: 'guest', + // [ADR-0069 / #7280] WITHHELD, on the record. This face enforces the + // authentication-policy gate at its OWN seam: `HttpDispatcher.enforceAuthGate` + // re-reads the session and calls `evaluateAuthGate(session.user, path)` + // there, and nothing on this path ever reads `context.authGate`. [#23287] + // The key-admission seam's answer reaches that gate through + // `opts.onAdmission`, not through this field. + authGate: 'withhold', + credentials: 'api-key-and-session', + // This door admits an API key whether or not an auth service is wired. + sessionApi: 'optional', + // Session by provenance: + // - verified OAuth token → a session established for the token's + // principal (positions/permissions/RLS still aggregate in the resolver); + // - JWT bearer presented but NOT verified → no session at all (no cookie + // fallback — a dead token must yield 401, not ambient session access); + // - otherwise → the auth service's read of this request. + // + // [ADR-0090 D10 · ruling record 6232069920, letter A] The established + // session is IN the one organization the user's consent bound — the + // verified result's `organizationId`, carried where a session carries its + // active organization. So the session arm vets it on every request by the + // rule it applies to a browser session (`vetOrganizationClaim`): kept while + // a current membership backs it under a walled posture; dropped — no + // organization, only global grants — once none does. The D10 delegator is + // then resolved in that organization. ⛔ Never the union of the user's + // organizations, never the organization their browser session has selected + // now, never a widened global grant. A token minted before consents bound + // one carries none: the principal resolves with no organization. + session: oauthPrincipal + ? { + established: { + user: { id: oauthPrincipal.userId }, + session: { userId: oauthPrincipal.userId, activeOrganizationId: oauthPrincipal.organizationId }, + }, + } + : oauthBearerPresented ? 'none' : 'request', + // Authenticated → resolve reference timezone/locale/currency; a settings + // lookup that rejects reads as no settings service. + localization: { settings: () => opts.getService('settings') }, + // [#3957] The request's OWN language preference wins over the workspace + // default, so a rejection message is not rendered in English beside the + // Chinese label of the very field it names. + requestLocale: preferredLocaleFromHeader(headers.get('accept-language')), + // A NAMED divergence (commit f586f1a89): this face has always carried the + // session bearer down to hooks (`session.accessToken`); the REST face never has. + accessToken: 'carry', // The OAuth SCOPE VOCABULARY is interpreted here, at the only door that // speaks it (`acceptOAuthAccessToken` is set solely by the `/mcp` path - // match), and the shared assembler receives the already-derived grant. It - // decides what that ceiling REPLACES on the envelope — the part that - // drifted — without `@objectstack/core` taking a dependency on the AI - // subdomain. See `OAuthTokenProvenance`. + // match), and the shared assembler receives the already-derived grant. oauth: oauthPrincipal && { ...oauthPrincipal, // [ADR-0090 D10] `data:read` → read-only, `data:write` → CRUD, neither → @@ -330,29 +229,7 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise