diff --git a/.changeset/execution-context-auth-gate-declared.md b/.changeset/execution-context-auth-gate-declared.md new file mode 100644 index 0000000000..a86fff2d49 --- /dev/null +++ b/.changeset/execution-context-auth-gate-declared.md @@ -0,0 +1,55 @@ +--- +"@objectstack/spec": minor +"@objectstack/core": minor +"@objectstack/rest": patch +"@objectstack/runtime": patch +--- + +feat: declare `ExecutionContext.authGate`, so the ADR-0069 gate sits inside the closed field set (#7280) + +The ADR-0069 authentication-policy gate (expired password, enforced MFA) rode +the execution context **undeclared**: REST's `computeExecCtx` spread it onto the +assembled envelope with `...(authGate ? { authGate } : {})` behind an `as any`, +and its `enforceAuth` read it back ten lines later. Nothing was broken — but the +closed entry field set shipped in #6216 is derived from `keyof ExecutionContext`, +so a field that exists only inside an `as any` is **outside every closure gate by +construction**: `ENTRY_EXECUTION_CONTEXT_FIELDS` could not list it, +`ExecutionContextEntryFields` could not demand it, and the runtime pin that +reconciles the closed set against `ExecutionContextSchema.shape` could not see +it. It was the exact blind spot that gate exists to remove, sitting one `as any` +outside it. + +**@objectstack/spec** declares the field: + +```ts +authGate: z.object({ code: z.string(), message: z.string() }).optional() +``` + +Both inner keys are required, matching the sole producer +(`AuthManager.computeAuthGate`, which sets both on every return branch) — `code` +is the stable machine code a client branches on, `message` is what the blocked +user reads, and the transport seam renders both as the `403` body. + +**@objectstack/core** picks it up as an ENTRY-decided field — it is resolved from +the request's own session at the transport entry point, never written mid-request +— so `ExecutionContextAssemblyInput` gains a **required** `authGate` input on the +same footing as `accessToken`: every face states its decision instead of omitting +it. A guest principal never carries one (no authenticated session for a policy +gate to attach to). Also exported: `normalizeAuthGate`, which completes a session +user's loose `authGate` into the declared shape at the one producer rather than +tolerating a partial shape downstream — a gate naming a `code` but no `message` +no longer renders a `403` body with `message: undefined`. `AuthGate` is now +derived from the schema instead of being a second hand-written declaration. + +**@objectstack/rest** passes the resolved gate as an assembler input and drops the +post-assembly spread; the remaining `as any` covers `__kernel` alone. +**@objectstack/runtime** (the runtime / MCP dispatcher) passes `authGate: +undefined` on the record: it enforces the same gate at its own seam +(`HttpDispatcher.enforceAuthGate` re-reads the session and calls +`evaluateAuthGate`) and never reads `context.authGate`, so carrying it there +would be a second copy no consumer reads. + +**No runtime behaviour change on either surface.** The shared assembler omits +`undefined`-valued keys, so the key is present exactly when it was before. The one +new behaviour is the normalization above, on a shape the sole producer never +emits today. diff --git a/content/docs/references/kernel/execution-context.mdx b/content/docs/references/kernel/execution-context.mdx index 6df06ded77..00b7f31c57 100644 --- a/content/docs/references/kernel/execution-context.mdx +++ b/content/docs/references/kernel/execution-context.mdx @@ -53,6 +53,7 @@ const result = ExecutionContextSchema.parse(data); | **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | | | **audience** | `Enum<'internal' \| 'external'>` | optional | | | **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows. | +| **authGate** | `{ code: string; message: string }` | optional | ADR-0069 authentication-policy gate: present only while the principal is blocked from protected resources until they remediate (expired password, enforced MFA), absent for every healthy session. `code` is the stable machine code the client branches on (PASSWORD_EXPIRED / MFA_REQUIRED) and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. AUTHENTICATION, not authorization — it suspends access entirely rather than narrowing it, and nothing in the permission/RLS path reads it, while the allow-listed remediation endpoints stay reachable. Server-constructed only, never client-supplied; a guest/anonymous principal never carries one. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **permissions** | `string[]` | ✅ | | | **systemPermissions** | `string[]` | optional | | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 9b00491a9b..f318ef352d 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -268,7 +268,7 @@ directory rather than per file. | `cloud/` | 83 | | `identity/` | 33 | | `integration/` | 10 | -| `kernel/` | 295 | +| `kernel/` | 296 | | `qa/` | 6 | | `shared/` | 20 | | `system/` | 362 | diff --git a/packages/core/src/security/assemble-execution-context.test.ts b/packages/core/src/security/assemble-execution-context.test.ts index 688272120d..94aed7bcd5 100644 --- a/packages/core/src/security/assemble-execution-context.test.ts +++ b/packages/core/src/security/assemble-execution-context.test.ts @@ -89,8 +89,13 @@ function legacyDispatcherAssembly( /** * REST `computeExecCtx` assembly, verbatim, pre-#6216. FROZEN — see header. - * `authGate` / `__kernel` are deliberately outside: neither is an - * `ExecutionContext` field, and the REST face still adds them after assembly. + * `authGate` / `__kernel` are outside: at the time this was frozen neither was + * an `ExecutionContext` field, and the REST face added both after assembly. + * + * `authGate` has since been DECLARED and joined the closed entry set (#7280), + * so the parity probes below pass `authGate: undefined` — the value that keeps + * them comparable with this frozen transcription. The gate's own carriage is + * pinned separately (see "#7280 — the ADR-0069 gate is an ENTRY-decided field"). */ function legacyRestAssembly( authz: ResolvedAuthzContext, @@ -227,6 +232,7 @@ describe('#6216 — runtime/dispatcher face: byte-for-byte parity with the pre-# localization, requestLocale, accessToken: authz.accessToken, + authGate: undefined, }); const before = legacyDispatcherAssembly(authz, oauth, localization, requestLocale); expect(observable(now)).toEqual(observable(before)); @@ -256,6 +262,7 @@ describe('#6216 — REST face: byte-for-byte parity with the pre-#6216 assembly' // The named per-face divergence: REST has never carried the // session bearer, and #6216 preserves that. accessToken: undefined, + authGate: undefined, }); const before = legacyRestAssembly(authz, localization ?? {}, requestLocale); if (before === undefined) { @@ -279,6 +286,7 @@ describe('#6216 — the anonymous face, in BOTH directions', () => { localization: { timezone: 'Asia/Shanghai', locale: 'zh-CN', currency: 'CNY' }, requestLocale: 'en-US', accessToken: 'sess_token_abc', + authGate: undefined, }), ).toBeUndefined(); }); @@ -290,6 +298,7 @@ describe('#6216 — the anonymous face, in BOTH directions', () => { localization: undefined, requestLocale: undefined, accessToken: undefined, + authGate: undefined, }); // The exact envelope, key set included — `explain-engine.ts` reads // `principalKind === 'guest'` for its EXTERNAL posture floor, and the @@ -314,6 +323,7 @@ describe('#6216 — the anonymous face, in BOTH directions', () => { localization: { timezone: 'Asia/Shanghai', locale: 'zh-CN' }, requestLocale: undefined, accessToken: HUMAN_FULL.accessToken, + authGate: undefined, } as const; expect(assembleExecutionContextOrGuest(input)).toEqual(assembleExecutionContext(input)); }); @@ -327,6 +337,7 @@ describe('#6216 — the named per-face divergences are values, not switches', () localization: undefined, requestLocale: undefined, accessToken: undefined, + authGate: undefined, })!; expect(Object.keys(ctx)).not.toContain('accessToken'); }); @@ -338,6 +349,7 @@ describe('#6216 — the named per-face divergences are values, not switches', () localization: undefined, requestLocale: undefined, accessToken: HUMAN_FULL.accessToken, + authGate: undefined, })!; expect(ctx.accessToken).toBe('sess_token_abc'); }); @@ -349,6 +361,7 @@ describe('#6216 — the named per-face divergences are values, not switches', () localization: undefined, requestLocale: undefined, accessToken: undefined, + authGate: undefined, })!; expect(ctx.principalKind).toBe('human'); expect(Object.keys(ctx)).not.toContain('onBehalfOf'); @@ -356,6 +369,58 @@ describe('#6216 — the named per-face divergences are values, not switches', () }); }); +describe('#7280 — the ADR-0069 gate is an ENTRY-decided field', () => { + const GATE = { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' }; + + it('a face that resolves a gate carries it on the envelope verbatim', () => { + const ctx = assembleExecutionContext({ + authz: HUMAN_FULL, + oauth: undefined, + localization: undefined, + requestLocale: undefined, + accessToken: undefined, + authGate: GATE, + })!; + expect(ctx.authGate).toEqual(GATE); + }); + + it('a face that resolves none emits NO authGate key — not a key spelled undefined', () => { + const ctx = assembleExecutionContext({ + authz: HUMAN_FULL, + oauth: undefined, + localization: undefined, + requestLocale: undefined, + accessToken: undefined, + authGate: undefined, + })!; + // Behaviour preserved: the pre-#7280 REST face spread + // `...(authGate ? { authGate } : {})` AFTER assembly, so the key was absent + // for exactly these inputs too. Only the declaration moved. + expect(Object.keys(ctx)).not.toContain('authGate'); + expect('authGate' in ctx).toBe(false); + }); + + it('a GUEST principal never carries a gate, even when a face passes one', () => { + const ctx = assembleExecutionContextOrGuest({ + authz: ANONYMOUS, + oauth: undefined, + localization: undefined, + requestLocale: undefined, + accessToken: undefined, + authGate: GATE, + }); + // An anonymous request has no authenticated session for an + // authentication-policy gate to attach to, so "gated guest" is not a state + // this entry can emit. + expect(ctx.principalKind).toBe('guest'); + expect(Object.keys(ctx)).not.toContain('authGate'); + }); + + it('the gate rides the SAME closed set as every other entry field', () => { + expect(ENTRY_EXECUTION_CONTEXT_FIELDS).toContain('authGate'); + }); +}); + describe('#6216 — the field set is CLOSED', () => { /** * The non-entry partition, spelled again here on purpose: the module's @@ -400,6 +465,7 @@ describe('#6216 — the field set is CLOSED', () => { localization: { timezone: 'Asia/Shanghai', locale: 'zh-CN', currency: 'CNY' }, requestLocale: 'en-US', accessToken: 'sess_token_abc', + authGate: undefined, }); for (const key of Object.keys(ctx)) { expect(ENTRY_EXECUTION_CONTEXT_FIELDS).toContain(key); @@ -423,6 +489,7 @@ describe('#6216 — the measured residual: keys that were present-with-undefined localization: undefined, requestLocale: undefined, accessToken: undefined, + authGate: undefined, } as const; const before = legacyDispatcherAssembly(HUMAN_MINIMAL, undefined, undefined, undefined); const now = assembleExecutionContextOrGuest(input); @@ -444,6 +511,7 @@ describe('#6216 — the measured residual: keys that were present-with-undefined localization: {}, requestLocale: undefined, accessToken: undefined, + authGate: undefined, })!; expect(Object.keys(before)).toContain('tenantId'); diff --git a/packages/core/src/security/assemble-execution-context.ts b/packages/core/src/security/assemble-execution-context.ts index b85887375e..28994af91c 100644 --- a/packages/core/src/security/assemble-execution-context.ts +++ b/packages/core/src/security/assemble-execution-context.ts @@ -58,6 +58,7 @@ import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { AuthGate } from './auth-gate.js'; import type { ResolvedAuthzContext } from './resolve-authz-context.js'; /** @@ -127,6 +128,7 @@ export const ENTRY_EXECUTION_CONTEXT_FIELDS = [ 'accessToken', 'tabPermissions', 'posture', + 'authGate', 'org_user_ids', 'accessible_org_ids', 'oauthScopes', @@ -235,6 +237,27 @@ export interface ExecutionContextAssemblyInput { * instead of being an omission nobody can see. */ accessToken: string | undefined; + /** + * [ADR-0069] The AUTHENTICATION-policy gate posture resolved for this + * request's session (expired password / enforced MFA), or `undefined` when + * the face resolves none — normalize a session user through + * `normalizeAuthGate` rather than copying its `authGate` verbatim. + * + * A NAMED per-face divergence, on the same footing as {@link accessToken} + * (#7280): + * + * - the **REST** face lifts it onto the envelope, because that is where its + * consumer reads it (`RestServer.enforceAuth` → `403 { code, message }`); + * - the **runtime / MCP dispatcher** passes `undefined`, because it enforces + * the same ADR-0069 gate at its OWN seam (`HttpDispatcher.enforceAuthGate` + * re-reads the session and calls `evaluateAuthGate` there) and never reads + * `context.authGate` — carrying it would be a second, unread copy. + * + * Until #7280 declared it, this posture reached the envelope through an + * `as any` spread AFTER assembly, which put it outside this closed set + * entirely — the blind spot the set exists to remove. + */ + authGate: AuthGate | undefined; } /** Drop `undefined`-valued keys, emitting in the closed set's declared order. */ @@ -256,7 +279,7 @@ function entryFields( input: ExecutionContextAssemblyInput, anonymous: boolean, ): ExecutionContextEntryFields { - const { authz, oauth, localization, requestLocale, accessToken } = input; + const { authz, oauth, localization, requestLocale, accessToken, authGate } = input; // [ADR-0090 D10 — agent principal] An OAuth access token naming an authorized // client (`azp`) is an AI agent acting ON BEHALF OF the human `sub`. The @@ -307,6 +330,12 @@ function entryFields( // transport presents enforcement the SAME value. Present only for an // authenticated principal (guest → absent). posture: authz.posture, + // [ADR-0069 / #7280] The AUTHENTICATION-policy gate, carried for the seam + // that reads it off the envelope (REST's `enforceAuth`). Anonymous → never: + // a guest has no authenticated session for a policy gate to attach to, so + // "gated guest" is not a state this entry can emit even if a face passed + // one. + authGate: anonymous ? undefined : authGate, /** Fellow-org user IDs for RLS scoping of identity tables. */ org_user_ids: authz.org_user_ids, // [ADR-0105 D2] The caller's org access set — the `group` posture's Layer 0 diff --git a/packages/core/src/security/auth-gate.test.ts b/packages/core/src/security/auth-gate.test.ts index 9f7f7f784e..9ce174c486 100644 --- a/packages/core/src/security/auth-gate.test.ts +++ b/packages/core/src/security/auth-gate.test.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { isAuthGateAllowlisted, evaluateAuthGate } from './auth-gate'; +import { isAuthGateAllowlisted, evaluateAuthGate, normalizeAuthGate } from './auth-gate'; describe('auth-gate (ADR-0069 session gate)', () => { describe('isAuthGateAllowlisted', () => { @@ -48,4 +48,47 @@ describe('auth-gate (ADR-0069 session gate)', () => { expect(typeof g?.message).toBe('string'); }); }); + + // #7280 — `ExecutionContext.authGate` is now DECLARED (`{ code, message }`, + // both required), and the session user it is lifted from crosses an external + // boundary as `any`. This is the one place that turns the loose thing into + // the declared thing, for BOTH consumers: `evaluateAuthGate` (the seams that + // decide per path) and REST's `computeExecCtx` (the seam that puts the + // posture on the envelope). A test here is what stops the two from + // re-deriving it differently. + describe('normalizeAuthGate (#7280)', () => { + it('returns null for a user with no gate, and for no user at all', () => { + expect(normalizeAuthGate({ id: 'u1' })).toBeNull(); + expect(normalizeAuthGate(undefined)).toBeNull(); + expect(normalizeAuthGate(null)).toBeNull(); + }); + + it('returns null when the gate names no string code — that is not a gate', () => { + expect(normalizeAuthGate({ authGate: {} })).toBeNull(); + expect(normalizeAuthGate({ authGate: { code: 403 } })).toBeNull(); + }); + + it('passes a well-formed gate through verbatim', () => { + expect(normalizeAuthGate({ authGate: { code: 'PASSWORD_EXPIRED', message: 'change it' } })) + .toEqual({ code: 'PASSWORD_EXPIRED', message: 'change it' }); + }); + + it('fills a missing or blank message, so the declared shape is always met', () => { + // Without this the envelope would carry `message: undefined` into a 403 + // body — the loose shape the declaration exists to rule out. + for (const gate of [{ code: 'MFA_REQUIRED' }, { code: 'MFA_REQUIRED', message: '' }]) { + const g = normalizeAuthGate({ authGate: gate }); + expect(g?.code).toBe('MFA_REQUIRED'); + expect(typeof g?.message).toBe('string'); + expect(g?.message.length).toBeGreaterThan(0); + } + }); + + it('drops any key the declaration does not name', () => { + const g = normalizeAuthGate({ + authGate: { code: 'PASSWORD_EXPIRED', message: 'm', redirectTo: '/change-password' }, + }); + expect(Object.keys(g ?? {}).sort()).toEqual(['code', 'message']); + }); + }); }); diff --git a/packages/core/src/security/auth-gate.ts b/packages/core/src/security/auth-gate.ts index 55bfdcaa84..8ffa74f21e 100644 --- a/packages/core/src/security/auth-gate.ts +++ b/packages/core/src/security/auth-gate.ts @@ -16,11 +16,44 @@ * drift on what is blocked. */ -export interface AuthGate { - /** Stable machine code, e.g. `PASSWORD_EXPIRED` / `MFA_REQUIRED`. */ - code: string; - /** Human-facing message. */ - message: string; +import type { ExecutionContext } from '@objectstack/spec/kernel'; + +/** + * The gate posture, DERIVED from its declaration on `ExecutionContextSchema` + * (`packages/spec/src/kernel/execution-context.zod.ts`) rather than restated + * here (#7280). + * + * It was a hand-written interface while the envelope field was undeclared, so + * the two could have drifted with nothing to catch it — the exact class of + * defect the closed entry field set (#6216) exists to make unrepresentable. + * One declaration, one type. + */ +export type AuthGate = NonNullable; + +/** Message used when a session's gate names a `code` but no usable `message`. */ +const DEFAULT_AUTH_GATE_MESSAGE = 'Access is blocked by an authentication policy.'; + +/** + * Normalize the `authGate` a better-auth session user carries into the shape + * `ExecutionContextSchema` declares — or `null` when there is no gate. + * + * The session user crosses an external boundary as `any`, so this is where the + * declared contract is actually met: a gate naming no string `code` is not a + * gate, and a missing/blank `message` is filled with the default rather than + * riding onto the envelope (and into a `403` body) as `undefined`. Both + * consumers normalize HERE, at the one producer, instead of tolerating a loose + * shape downstream: {@link evaluateAuthGate} for the seams that decide per + * path, and REST's `computeExecCtx` for the seam that lifts the posture onto + * the execution context. + */ +export function normalizeAuthGate(sessionUser: any): AuthGate | null { + const gate = sessionUser?.authGate; + if (!gate || typeof gate.code !== 'string') return null; + return { + code: gate.code, + message: + typeof gate.message === 'string' && gate.message ? gate.message : DEFAULT_AUTH_GATE_MESSAGE, + }; } // Endpoints a gated user MUST still reach to remediate or bootstrap the @@ -56,14 +89,8 @@ export function isAuthGateAllowlisted(rawPath: string | undefined | null): boole * allow-listed paths always pass. */ export function evaluateAuthGate(sessionUser: any, path: string): AuthGate | null { - const gate = sessionUser?.authGate; - if (!gate || typeof gate.code !== 'string') return null; + const gate = normalizeAuthGate(sessionUser); + if (!gate) return null; if (isAuthGateAllowlisted(path)) return null; - return { - code: gate.code, - message: - typeof gate.message === 'string' && gate.message - ? gate.message - : 'Access is blocked by an authentication policy.', - }; + return gate; } diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 47fc70e8f9..3b1fe7bd1b 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -117,7 +117,12 @@ export { type LadderRow, type LadderPrincipal, } from './posture-ladder.js'; -export { isAuthGateAllowlisted, evaluateAuthGate, type AuthGate } from './auth-gate.js'; +export { + isAuthGateAllowlisted, + evaluateAuthGate, + normalizeAuthGate, + type AuthGate, +} from './auth-gate.js'; // #2567 — the single anonymous-deny decision shared by every HTTP seam. export { diff --git a/packages/rest/src/rest-exec-ctx-principal-kind.test.ts b/packages/rest/src/rest-exec-ctx-principal-kind.test.ts index c7578a6e8b..ff0878826a 100644 --- a/packages/rest/src/rest-exec-ctx-principal-kind.test.ts +++ b/packages/rest/src/rest-exec-ctx-principal-kind.test.ts @@ -72,6 +72,11 @@ const makeQl = () => ({ /** A fake auth service whose session is keyed off the request's `cookie` header. */ const makeAuth = () => ({ + // [#7280] The ADR-0069 gate feature is ON, so `computeExecCtx` takes its + // gate branch for every authenticated request. A session with no + // `user.authGate` still yields no gate — which is what keeps the golden key + // set below unchanged, and makes the gated cases genuinely opt-in. + isAuthGateActive: () => true, api: { getSession: async ({ headers }: { headers: any }) => { const cookie = headers?.get?.('cookie'); @@ -83,6 +88,20 @@ const makeAuth = () => ({ if (cookie === 'member-tok') { return { user: { id: 'member1' }, session: { token: 'sess_tok_rest' } }; } + // [#7280] A session blocked by an authentication policy… + if (cookie === 'member-gated') { + return { + user: { + id: 'member1', + authGate: { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' }, + }, + }; + } + // …and one whose gate names a code but no usable message, the shape + // `normalizeAuthGate` exists to complete. + if (cookie === 'member-gated-bare') { + return { user: { id: 'member1', authGate: { code: 'MFA_REQUIRED' } } }; + } return undefined; }, }, @@ -131,10 +150,32 @@ function boot() { async function request(headers: Record) { const { route, seen, protocol } = boot(); const out = makeRes(); - await route!.handler({ method: 'GET', params: { object: 'task' }, query: {}, headers } as any, out.res); + await route!.handler({ + method: 'GET', + // [#7280] A real adapter always sets `path`, and `enforceAuth`'s + // ADR-0069 branch reads it — `isAuthGateAllowlisted(undefined)` returns + // TRUE, so a request without one is treated as allow-listed and the gate + // never fires. Spelling it here keeps the wire fixture honest. + path: '/api/v1/data/task', + params: { object: 'task' }, query: {}, headers, + } as any, out.res); return { ctx: seen[0], out, protocol }; } +/** + * [#7280] The envelope `computeExecCtx` actually produces, read directly. + * + * Needed because a GATED session never reaches `findData`: `enforceAuth` answers + * 403 first, which is the whole point of the gate — so the wire-capture helper + * above cannot observe the envelope for exactly the case under test. + */ +async function execCtx(headers: Record) { + const { rest } = boot(); + return await (rest as any).computeExecCtx(undefined, { + method: 'GET', params: { object: 'task' }, query: {}, headers, + }); +} + describe('#6071 — REST-face ExecutionContext carries the ADR-0090 D9/D10 principal taxonomy', () => { it('a session-backed request reaches the data layer with principalKind:human', async () => { const { ctx, out } = await request({ cookie: 'member' }); @@ -246,8 +287,11 @@ describe('#6216 — the REST face assembles through the SHARED assembler, output 'positions', 'permissions', 'systemPermissions', 'isSystem', 'principalKind', 'userId', 'email', 'posture', 'org_user_ids', 'accessible_org_ids', 'timezone', 'locale', - // Not ExecutionContext fields — the REST face still adds these - // itself, after assembly (ADR-0069 gate posture / ADR-0057 D10). + // Not an ExecutionContext field — the REST face still adds this one + // itself, after assembly (the resolved kernel, ADR-0057 D10). The + // ADR-0069 gate posture used to sit beside it here; #7280 declared + // it, so it is an assembled field now and absent for the same reason + // every other unset field is: this session carries no gate. '__kernel', ])); }); @@ -267,3 +311,62 @@ describe('#6216 — the REST face assembles through the SHARED assembler, output expect(ctx.accessToken).toBeUndefined(); }); }); + +describe('#7280 — the ADR-0069 gate posture is an ASSEMBLED field on this face', () => { + // Before #7280 the gate reached the envelope through + // `...(authGate ? { authGate } : {})` spread on AFTER assembly, behind an + // `as any` — outside the closed entry field set (#6216) by construction. + // It is an assembler input now. These pins are on the WIRE, through the real + // `computeExecCtx` pipeline: `rest-auth-gate.test.ts` hand-builds a context + // and so proves only that `enforceAuth` reads the key, never that this face + // still puts it there. + + it('a gated session reaches enforcement with the gate on the envelope', async () => { + const ctx = await execCtx({ cookie: 'member-gated' }); + + expect(ctx?.userId).toBe('member1'); + expect(Object.keys(ctx)).toContain('authGate'); + expect(ctx.authGate).toEqual({ + code: 'PASSWORD_EXPIRED', + message: 'Your password has expired.', + }); + }); + + it('an ungated session emits NO authGate key — not a key spelled undefined', async () => { + const ctx = await execCtx({ cookie: 'member' }); + + expect(Object.keys(ctx)).not.toContain('authGate'); + expect('authGate' in ctx).toBe(false); + }); + + it('completes a gate that names a code but no message, so a 403 body is never blank', async () => { + // The declaration requires `message`; the session user crosses an + // external boundary as `any` and may carry none. `normalizeAuthGate` + // fills it at this producer rather than letting `message: undefined` + // ride into the response body. + const ctx = await execCtx({ cookie: 'member-gated-bare' }); + + expect(ctx.authGate.code).toBe('MFA_REQUIRED'); + expect(typeof ctx.authGate.message).toBe('string'); + expect(ctx.authGate.message.length).toBeGreaterThan(0); + }); + + it('end to end: a gated session is refused on a protected data route with the gate code', async () => { + // The full chain in one assertion — computeExecCtx resolves the gate, + // the shared assembler carries it, `enforceAuth` reads it off the + // envelope and refuses. Nothing pinned this seam end to end before. + const { out, protocol } = await request({ cookie: 'member-gated' }); + + expect(out.getStatus()).toBe(403); + expect(out.getJson()?.error?.code).toBe('PASSWORD_EXPIRED'); + expect(out.getJson()?.error?.message).toBe('Your password has expired.'); + expect(protocol.findData).not.toHaveBeenCalled(); + }); + + it('an ungated session on the same route is unaffected', async () => { + const { out, protocol } = await request({ cookie: 'member' }); + + expect(out.getStatus()).toBe(200); + expect(protocol.findData).toHaveBeenCalled(); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index b5ac36f55f..697ffc7692 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -2,7 +2,7 @@ import { IHttpServer, resolveAuthzContext, resolveLocalizationContext, isAuthGateAllowlisted, - assembleExecutionContext, + assembleExecutionContext, normalizeAuthGate, type AuthGate, shouldDenyAnonymous, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS, } from '@objectstack/core'; import { @@ -2585,11 +2585,18 @@ export class RestServer { // feature is active (cheap sync check) do we re-read the session for // its `user.authGate` (computed in customSession). enforceAuth() then // blocks protected resources for a gated user. Zero cost when off. - let authGate: any; + // + // [#7280] Normalized through the shared `normalizeAuthGate` instead + // of copied verbatim: the session user crosses an external boundary + // as `any`, and `ExecutionContext.authGate` now DECLARES the shape + // (`{ code, message }`), so this is where the declaration is met — + // a gate with a blank message no longer rides into a 403 body as + // `undefined`. + let authGate: AuthGate | undefined; try { if (typeof authService.isAuthGateActive === 'function' && authService.isAuthGateActive()) { const gatedSession: any = await getSession(headers).catch(() => undefined); - if (gatedSession?.user?.authGate) authGate = gatedSession.user.authGate; + authGate = normalizeAuthGate(gatedSession?.user) ?? undefined; } } catch { /* gate is best-effort — never break context resolution */ } @@ -2627,6 +2634,12 @@ export class RestServer { // Widening it to a second transport is a product decision, not // a refactor — so REST withholds it on the record. accessToken: undefined, + // [ADR-0069 / #7280] This face DOES carry the gate: its consumer + // is ten lines up (`enforceAuth` → 403 `{ code, message }`). It + // used to be spread on AFTER assembly behind an `as any`, which + // is precisely how it stayed outside the closed field set; it is + // a declared `ExecutionContext` field and an assembler input now. + authGate, }); // Unreachable: the anonymous early-return above already took this // branch. Kept because the shared entry — not this method — is the @@ -2635,10 +2648,11 @@ export class RestServer { const execCtx = { ...base, - ...(authGate ? { authGate } : {}), // 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. + // authorization input — never read by RLS/permission logic, and + // NOT an `ExecutionContext` field — hence the cast, which now + // covers this key alone. __kernel: kernel, } as any; diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index 5224f4e146..2d26966549 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -220,6 +220,16 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise { }); }); +// --------------------------------------------------------------------------- +// #7280 — `authGate` is DECLARED, so the ADR-0069 posture is inside every +// closure gate instead of one `as any` outside it. +// +// The field was written onto the envelope by REST's `computeExecCtx` and read +// by its `enforceAuth` while being declared nowhere, which put it outside the +// closed entry field set (#6216) by construction: a union derived from +// `keyof ExecutionContext` cannot name a key the schema does not have. +// +// Both inner keys are REQUIRED on purpose. The sole producer +// (`AuthManager.computeAuthGate`) sets both on every return branch, `code` is +// the stable machine code clients branch on, and `message` is what the blocked +// user reads — a gate whose message is absent renders a `403` body with +// `message: undefined`. The contract is stated here, at the producer's +// declaration, rather than tolerated at each consumer. +// --------------------------------------------------------------------------- +describe('ExecutionContextSchema.authGate — the ADR-0069 gate posture (#7280)', () => { + it('accepts a well-formed gate', () => { + const ctx = ExecutionContextSchema.parse({ + userId: 'u1', + authGate: { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' }, + }); + expect(ctx.authGate).toEqual({ + code: 'PASSWORD_EXPIRED', + message: 'Your password has expired.', + }); + }); + + it('is optional — a healthy session declares no gate at all', () => { + const ctx = ExecutionContextSchema.parse({ userId: 'u1' }); + expect(ctx.authGate).toBeUndefined(); + }); + + it('REJECTS a gate carrying a code but no message', () => { + const res = ExecutionContextSchema.safeParse({ + userId: 'u1', + authGate: { code: 'MFA_REQUIRED' }, + }); + expect(res.success).toBe(false); + // Named by PATH, not by count: what must be pinned is that the missing + // `message` is the thing rejected, not merely that something was. + const issue = res.success + ? undefined + : res.error.issues.find((i) => i.path.join('.') === 'authGate.message'); + expect(issue).toBeDefined(); + expect(issue?.code).toBe('invalid_type'); + }); + + it('REJECTS a non-string code', () => { + const res = ExecutionContextSchema.safeParse({ + userId: 'u1', + authGate: { code: 403, message: 'blocked' }, + }); + expect(res.success).toBe(false); + const issue = res.success + ? undefined + : res.error.issues.find((i) => i.path.join('.') === 'authGate.code'); + expect(issue?.code).toBe('invalid_type'); + }); + + // Same lesson as #6881 above, applied at the moment the row is ADDED rather + // than after it ships blank: `gen:docs` renders `.describe()` and NEVER the + // TSDoc, so a key declared bare lands an empty description cell in + // `content/docs/references/kernel/execution-context.mdx`. Asserted by idiom, + // not by sentence — a rewrite stays free, emptying it does not. + describe('the published description', () => { + const description = ExecutionContextSchema.shape.authGate.description ?? ''; + + it('is present and non-empty, so the generated reference row is not blank', () => { + expect(description.trim().length).toBeGreaterThan(0); + }); + + it('names the ADR and both machine codes a client branches on', () => { + expect(description).toMatch(/ADR-0069/); + expect(description).toMatch(/PASSWORD_EXPIRED/); + expect(description).toMatch(/MFA_REQUIRED/); + }); + + it('states that this is authentication, not authorization', () => { + expect(description).toMatch(/AUTHENTICATION/); + expect(description).toMatch(/not authorization/i); + }); + + it('states the server-constructed provenance', () => { + expect(description).toMatch(/server-constructed|never client-supplied/i); + }); + }); +}); + // --------------------------------------------------------------------------- // #6881 — `preserveAudit` must carry its contract in the `.describe()`, not // only in the block comment above the key. diff --git a/packages/spec/src/kernel/execution-context.zod.ts b/packages/spec/src/kernel/execution-context.zod.ts index a59b22d08a..afeb15856b 100644 --- a/packages/spec/src/kernel/execution-context.zod.ts +++ b/packages/spec/src/kernel/execution-context.zod.ts @@ -146,6 +146,42 @@ export const ExecutionContextSchema = lazySchema(() => z.object({ */ posture: AuthzPostureSchema.optional(), + /** + * [ADR-0069] The AUTHENTICATION-policy gate posture resolved for this + * request's session — present only while the principal is blocked from + * protected resources by a policy they must remediate (expired password, + * enforced MFA). Absent for every healthy session, which is the normal case. + * + * Computed ONCE per session in the auth `customSession` enrichment + * (`AuthManager.computeAuthGate` → `user.authGate`), lifted onto the envelope + * by the transport entry point, and consumed at the transport seam: + * REST's `enforceAuth` answers `403 { code, message }` on a non-allow-listed + * path. Both keys are required because the sole producer always sets both, + * and `code` is the stable machine code the client branches on + * (`PASSWORD_EXPIRED` / `MFA_REQUIRED`) while `message` is what the user reads. + * + * AUTHENTICATION, not authorization — orthogonal to {@link posture} and to + * `permissions`/`positions`: it does not narrow what the principal may do, it + * suspends their access entirely until they remediate, while the allow-listed + * remediation endpoints stay reachable (`isAuthGateAllowlisted`, + * `@objectstack/core`). Nothing in the permission/RLS path reads it. + * + * Server-constructed only, never client-supplied — exactly like + * {@link isSystem}. A guest/anonymous principal never carries one: there is + * no authenticated session for a policy gate to attach to. + * + * Declared here (#7280) because it was previously written onto the envelope + * behind an `as any` and was therefore invisible to the closed entry field set + * (#6216) — the one gate whose job is to stop a context field reaching one + * transport and missing another. + */ + authGate: z.object({ + /** Stable machine code, e.g. `PASSWORD_EXPIRED` / `MFA_REQUIRED`. */ + code: z.string(), + /** Human-facing message explaining what must be remediated. */ + message: z.string(), + }).optional().describe('ADR-0069 authentication-policy gate: present only while the principal is blocked from protected resources until they remediate (expired password, enforced MFA), absent for every healthy session. `code` is the stable machine code the client branches on (PASSWORD_EXPIRED / MFA_REQUIRED) and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. AUTHENTICATION, not authorization — it suspends access entirely rather than narrowing it, and nothing in the permission/RLS path reads it, while the allow-listed remediation endpoints stay reachable. Server-constructed only, never client-supplied; a guest/anonymous principal never carries one.'), + /** * [ADR-0090 D10 — P1 shape] Delegation link for agent/service principals * acting on behalf of a user. Agent effective permission = the agent's own