Skip to content

Commit 82da264

Browse files
os-zhuangclaude
andauthored
feat(spec,core,rest,runtime): declare ExecutionContext.authGate so the ADR-0069 gate joins the closed field set (#7280) (#7434)
The authentication-policy gate rode the execution context undeclared: REST's `computeExecCtx` spread it on with `...(authGate ? { authGate } : {})` behind an `as any`, and `enforceAuth` read it back ten lines later. #6216's closed entry field set is derived from `keyof ExecutionContext`, so a field living only inside an `as any` is outside every closure gate by construction — the exact blind spot that gate exists to remove. Measured as ENTRY-decided, not a mid-request mutation: it is resolved from the request's own session inside `computeExecCtx`, immediately before assembly, and no handler writes it. So it joins the closed set rather than the non-entry partition, and `ExecutionContextAssemblyInput` gains a REQUIRED `authGate` input on the `accessToken` template — every face decides on the record. REST carries it (its consumer reads it off the envelope); the runtime/MCP dispatcher passes `undefined` because it enforces the same gate at its own seam (`HttpDispatcher.enforceAuthGate`) and never reads `context.authGate`. `normalizeAuthGate` completes a session user's loose gate into the declared shape at the one producer, so 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. No runtime behaviour change: the assembler omits undefined-valued keys, so the key is present exactly when it was before. Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 354b00f commit 82da264

14 files changed

Lines changed: 509 additions & 28 deletions
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/core": minor
4+
"@objectstack/rest": patch
5+
"@objectstack/runtime": patch
6+
---
7+
8+
feat: declare `ExecutionContext.authGate`, so the ADR-0069 gate sits inside the closed field set (#7280)
9+
10+
The ADR-0069 authentication-policy gate (expired password, enforced MFA) rode
11+
the execution context **undeclared**: REST's `computeExecCtx` spread it onto the
12+
assembled envelope with `...(authGate ? { authGate } : {})` behind an `as any`,
13+
and its `enforceAuth` read it back ten lines later. Nothing was broken — but the
14+
closed entry field set shipped in #6216 is derived from `keyof ExecutionContext`,
15+
so a field that exists only inside an `as any` is **outside every closure gate by
16+
construction**: `ENTRY_EXECUTION_CONTEXT_FIELDS` could not list it,
17+
`ExecutionContextEntryFields` could not demand it, and the runtime pin that
18+
reconciles the closed set against `ExecutionContextSchema.shape` could not see
19+
it. It was the exact blind spot that gate exists to remove, sitting one `as any`
20+
outside it.
21+
22+
**@objectstack/spec** declares the field:
23+
24+
```ts
25+
authGate: z.object({ code: z.string(), message: z.string() }).optional()
26+
```
27+
28+
Both inner keys are required, matching the sole producer
29+
(`AuthManager.computeAuthGate`, which sets both on every return branch) — `code`
30+
is the stable machine code a client branches on, `message` is what the blocked
31+
user reads, and the transport seam renders both as the `403` body.
32+
33+
**@objectstack/core** picks it up as an ENTRY-decided field — it is resolved from
34+
the request's own session at the transport entry point, never written mid-request
35+
— so `ExecutionContextAssemblyInput` gains a **required** `authGate` input on the
36+
same footing as `accessToken`: every face states its decision instead of omitting
37+
it. A guest principal never carries one (no authenticated session for a policy
38+
gate to attach to). Also exported: `normalizeAuthGate`, which completes a session
39+
user's loose `authGate` into the declared shape at the one producer rather than
40+
tolerating a partial shape downstream — a gate naming a `code` but no `message`
41+
no longer renders a `403` body with `message: undefined`. `AuthGate` is now
42+
derived from the schema instead of being a second hand-written declaration.
43+
44+
**@objectstack/rest** passes the resolved gate as an assembler input and drops the
45+
post-assembly spread; the remaining `as any` covers `__kernel` alone.
46+
**@objectstack/runtime** (the runtime / MCP dispatcher) passes `authGate:
47+
undefined` on the record: it enforces the same gate at its own seam
48+
(`HttpDispatcher.enforceAuthGate` re-reads the session and calls
49+
`evaluateAuthGate`) and never reads `context.authGate`, so carrying it there
50+
would be a second copy no consumer reads.
51+
52+
**No runtime behaviour change on either surface.** The shared assembler omits
53+
`undefined`-valued keys, so the key is present exactly when it was before. The one
54+
new behaviour is the normalization above, on a shape the sole producer never
55+
emits today.

content/docs/references/kernel/execution-context.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const result = ExecutionContextSchema.parse(data);
5353
| **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | |
5454
| **audience** | `Enum<'internal' \| 'external'>` | optional | |
5555
| **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. |
56+
| **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. |
5657
| **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | |
5758
| **permissions** | `string[]` || |
5859
| **systemPermissions** | `string[]` | optional | |

docs/audits/2026-07-unknown-key-strictness-ledger.counts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ directory rather than per file.
268268
| `cloud/` | 83 |
269269
| `identity/` | 33 |
270270
| `integration/` | 10 |
271-
| `kernel/` | 295 |
271+
| `kernel/` | 296 |
272272
| `qa/` | 6 |
273273
| `shared/` | 20 |
274274
| `system/` | 362 |

packages/core/src/security/assemble-execution-context.test.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,13 @@ function legacyDispatcherAssembly(
8989

9090
/**
9191
* REST `computeExecCtx` assembly, verbatim, pre-#6216. FROZEN — see header.
92-
* `authGate` / `__kernel` are deliberately outside: neither is an
93-
* `ExecutionContext` field, and the REST face still adds them after assembly.
92+
* `authGate` / `__kernel` are outside: at the time this was frozen neither was
93+
* an `ExecutionContext` field, and the REST face added both after assembly.
94+
*
95+
* `authGate` has since been DECLARED and joined the closed entry set (#7280),
96+
* so the parity probes below pass `authGate: undefined` — the value that keeps
97+
* them comparable with this frozen transcription. The gate's own carriage is
98+
* pinned separately (see "#7280 — the ADR-0069 gate is an ENTRY-decided field").
9499
*/
95100
function legacyRestAssembly(
96101
authz: ResolvedAuthzContext,
@@ -227,6 +232,7 @@ describe('#6216 — runtime/dispatcher face: byte-for-byte parity with the pre-#
227232
localization,
228233
requestLocale,
229234
accessToken: authz.accessToken,
235+
authGate: undefined,
230236
});
231237
const before = legacyDispatcherAssembly(authz, oauth, localization, requestLocale);
232238
expect(observable(now)).toEqual(observable(before));
@@ -256,6 +262,7 @@ describe('#6216 — REST face: byte-for-byte parity with the pre-#6216 assembly'
256262
// The named per-face divergence: REST has never carried the
257263
// session bearer, and #6216 preserves that.
258264
accessToken: undefined,
265+
authGate: undefined,
259266
});
260267
const before = legacyRestAssembly(authz, localization ?? {}, requestLocale);
261268
if (before === undefined) {
@@ -279,6 +286,7 @@ describe('#6216 — the anonymous face, in BOTH directions', () => {
279286
localization: { timezone: 'Asia/Shanghai', locale: 'zh-CN', currency: 'CNY' },
280287
requestLocale: 'en-US',
281288
accessToken: 'sess_token_abc',
289+
authGate: undefined,
282290
}),
283291
).toBeUndefined();
284292
});
@@ -290,6 +298,7 @@ describe('#6216 — the anonymous face, in BOTH directions', () => {
290298
localization: undefined,
291299
requestLocale: undefined,
292300
accessToken: undefined,
301+
authGate: undefined,
293302
});
294303
// The exact envelope, key set included — `explain-engine.ts` reads
295304
// `principalKind === 'guest'` for its EXTERNAL posture floor, and the
@@ -314,6 +323,7 @@ describe('#6216 — the anonymous face, in BOTH directions', () => {
314323
localization: { timezone: 'Asia/Shanghai', locale: 'zh-CN' },
315324
requestLocale: undefined,
316325
accessToken: HUMAN_FULL.accessToken,
326+
authGate: undefined,
317327
} as const;
318328
expect(assembleExecutionContextOrGuest(input)).toEqual(assembleExecutionContext(input));
319329
});
@@ -327,6 +337,7 @@ describe('#6216 — the named per-face divergences are values, not switches', ()
327337
localization: undefined,
328338
requestLocale: undefined,
329339
accessToken: undefined,
340+
authGate: undefined,
330341
})!;
331342
expect(Object.keys(ctx)).not.toContain('accessToken');
332343
});
@@ -338,6 +349,7 @@ describe('#6216 — the named per-face divergences are values, not switches', ()
338349
localization: undefined,
339350
requestLocale: undefined,
340351
accessToken: HUMAN_FULL.accessToken,
352+
authGate: undefined,
341353
})!;
342354
expect(ctx.accessToken).toBe('sess_token_abc');
343355
});
@@ -349,13 +361,66 @@ describe('#6216 — the named per-face divergences are values, not switches', ()
349361
localization: undefined,
350362
requestLocale: undefined,
351363
accessToken: undefined,
364+
authGate: undefined,
352365
})!;
353366
expect(ctx.principalKind).toBe('human');
354367
expect(Object.keys(ctx)).not.toContain('onBehalfOf');
355368
expect(Object.keys(ctx)).not.toContain('oauthScopes');
356369
});
357370
});
358371

372+
describe('#7280 — the ADR-0069 gate is an ENTRY-decided field', () => {
373+
const GATE = { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' };
374+
375+
it('a face that resolves a gate carries it on the envelope verbatim', () => {
376+
const ctx = assembleExecutionContext({
377+
authz: HUMAN_FULL,
378+
oauth: undefined,
379+
localization: undefined,
380+
requestLocale: undefined,
381+
accessToken: undefined,
382+
authGate: GATE,
383+
})!;
384+
expect(ctx.authGate).toEqual(GATE);
385+
});
386+
387+
it('a face that resolves none emits NO authGate key — not a key spelled undefined', () => {
388+
const ctx = assembleExecutionContext({
389+
authz: HUMAN_FULL,
390+
oauth: undefined,
391+
localization: undefined,
392+
requestLocale: undefined,
393+
accessToken: undefined,
394+
authGate: undefined,
395+
})!;
396+
// Behaviour preserved: the pre-#7280 REST face spread
397+
// `...(authGate ? { authGate } : {})` AFTER assembly, so the key was absent
398+
// for exactly these inputs too. Only the declaration moved.
399+
expect(Object.keys(ctx)).not.toContain('authGate');
400+
expect('authGate' in ctx).toBe(false);
401+
});
402+
403+
it('a GUEST principal never carries a gate, even when a face passes one', () => {
404+
const ctx = assembleExecutionContextOrGuest({
405+
authz: ANONYMOUS,
406+
oauth: undefined,
407+
localization: undefined,
408+
requestLocale: undefined,
409+
accessToken: undefined,
410+
authGate: GATE,
411+
});
412+
// An anonymous request has no authenticated session for an
413+
// authentication-policy gate to attach to, so "gated guest" is not a state
414+
// this entry can emit.
415+
expect(ctx.principalKind).toBe('guest');
416+
expect(Object.keys(ctx)).not.toContain('authGate');
417+
});
418+
419+
it('the gate rides the SAME closed set as every other entry field', () => {
420+
expect(ENTRY_EXECUTION_CONTEXT_FIELDS).toContain('authGate');
421+
});
422+
});
423+
359424
describe('#6216 — the field set is CLOSED', () => {
360425
/**
361426
* The non-entry partition, spelled again here on purpose: the module's
@@ -400,6 +465,7 @@ describe('#6216 — the field set is CLOSED', () => {
400465
localization: { timezone: 'Asia/Shanghai', locale: 'zh-CN', currency: 'CNY' },
401466
requestLocale: 'en-US',
402467
accessToken: 'sess_token_abc',
468+
authGate: undefined,
403469
});
404470
for (const key of Object.keys(ctx)) {
405471
expect(ENTRY_EXECUTION_CONTEXT_FIELDS).toContain(key);
@@ -423,6 +489,7 @@ describe('#6216 — the measured residual: keys that were present-with-undefined
423489
localization: undefined,
424490
requestLocale: undefined,
425491
accessToken: undefined,
492+
authGate: undefined,
426493
} as const;
427494
const before = legacyDispatcherAssembly(HUMAN_MINIMAL, undefined, undefined, undefined);
428495
const now = assembleExecutionContextOrGuest(input);
@@ -444,6 +511,7 @@ describe('#6216 — the measured residual: keys that were present-with-undefined
444511
localization: {},
445512
requestLocale: undefined,
446513
accessToken: undefined,
514+
authGate: undefined,
447515
})!;
448516

449517
expect(Object.keys(before)).toContain('tenantId');

packages/core/src/security/assemble-execution-context.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858

5959
import type { ExecutionContext } from '@objectstack/spec/kernel';
6060

61+
import type { AuthGate } from './auth-gate.js';
6162
import type { ResolvedAuthzContext } from './resolve-authz-context.js';
6263

6364
/**
@@ -127,6 +128,7 @@ export const ENTRY_EXECUTION_CONTEXT_FIELDS = [
127128
'accessToken',
128129
'tabPermissions',
129130
'posture',
131+
'authGate',
130132
'org_user_ids',
131133
'accessible_org_ids',
132134
'oauthScopes',
@@ -235,6 +237,27 @@ export interface ExecutionContextAssemblyInput {
235237
* instead of being an omission nobody can see.
236238
*/
237239
accessToken: string | undefined;
240+
/**
241+
* [ADR-0069] The AUTHENTICATION-policy gate posture resolved for this
242+
* request's session (expired password / enforced MFA), or `undefined` when
243+
* the face resolves none — normalize a session user through
244+
* `normalizeAuthGate` rather than copying its `authGate` verbatim.
245+
*
246+
* A NAMED per-face divergence, on the same footing as {@link accessToken}
247+
* (#7280):
248+
*
249+
* - the **REST** face lifts it onto the envelope, because that is where its
250+
* consumer reads it (`RestServer.enforceAuth` → `403 { code, message }`);
251+
* - the **runtime / MCP dispatcher** passes `undefined`, because it enforces
252+
* the same ADR-0069 gate at its OWN seam (`HttpDispatcher.enforceAuthGate`
253+
* re-reads the session and calls `evaluateAuthGate` there) and never reads
254+
* `context.authGate` — carrying it would be a second, unread copy.
255+
*
256+
* Until #7280 declared it, this posture reached the envelope through an
257+
* `as any` spread AFTER assembly, which put it outside this closed set
258+
* entirely — the blind spot the set exists to remove.
259+
*/
260+
authGate: AuthGate | undefined;
238261
}
239262

240263
/** Drop `undefined`-valued keys, emitting in the closed set's declared order. */
@@ -256,7 +279,7 @@ function entryFields(
256279
input: ExecutionContextAssemblyInput,
257280
anonymous: boolean,
258281
): ExecutionContextEntryFields {
259-
const { authz, oauth, localization, requestLocale, accessToken } = input;
282+
const { authz, oauth, localization, requestLocale, accessToken, authGate } = input;
260283

261284
// [ADR-0090 D10 — agent principal] An OAuth access token naming an authorized
262285
// client (`azp`) is an AI agent acting ON BEHALF OF the human `sub`. The
@@ -307,6 +330,12 @@ function entryFields(
307330
// transport presents enforcement the SAME value. Present only for an
308331
// authenticated principal (guest → absent).
309332
posture: authz.posture,
333+
// [ADR-0069 / #7280] The AUTHENTICATION-policy gate, carried for the seam
334+
// that reads it off the envelope (REST's `enforceAuth`). Anonymous → never:
335+
// a guest has no authenticated session for a policy gate to attach to, so
336+
// "gated guest" is not a state this entry can emit even if a face passed
337+
// one.
338+
authGate: anonymous ? undefined : authGate,
310339
/** Fellow-org user IDs for RLS scoping of identity tables. */
311340
org_user_ids: authz.org_user_ids,
312341
// [ADR-0105 D2] The caller's org access set — the `group` posture's Layer 0

packages/core/src/security/auth-gate.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22
import { describe, it, expect } from 'vitest';
3-
import { isAuthGateAllowlisted, evaluateAuthGate } from './auth-gate';
3+
import { isAuthGateAllowlisted, evaluateAuthGate, normalizeAuthGate } from './auth-gate';
44

55
describe('auth-gate (ADR-0069 session gate)', () => {
66
describe('isAuthGateAllowlisted', () => {
@@ -48,4 +48,47 @@ describe('auth-gate (ADR-0069 session gate)', () => {
4848
expect(typeof g?.message).toBe('string');
4949
});
5050
});
51+
52+
// #7280 — `ExecutionContext.authGate` is now DECLARED (`{ code, message }`,
53+
// both required), and the session user it is lifted from crosses an external
54+
// boundary as `any`. This is the one place that turns the loose thing into
55+
// the declared thing, for BOTH consumers: `evaluateAuthGate` (the seams that
56+
// decide per path) and REST's `computeExecCtx` (the seam that puts the
57+
// posture on the envelope). A test here is what stops the two from
58+
// re-deriving it differently.
59+
describe('normalizeAuthGate (#7280)', () => {
60+
it('returns null for a user with no gate, and for no user at all', () => {
61+
expect(normalizeAuthGate({ id: 'u1' })).toBeNull();
62+
expect(normalizeAuthGate(undefined)).toBeNull();
63+
expect(normalizeAuthGate(null)).toBeNull();
64+
});
65+
66+
it('returns null when the gate names no string code — that is not a gate', () => {
67+
expect(normalizeAuthGate({ authGate: {} })).toBeNull();
68+
expect(normalizeAuthGate({ authGate: { code: 403 } })).toBeNull();
69+
});
70+
71+
it('passes a well-formed gate through verbatim', () => {
72+
expect(normalizeAuthGate({ authGate: { code: 'PASSWORD_EXPIRED', message: 'change it' } }))
73+
.toEqual({ code: 'PASSWORD_EXPIRED', message: 'change it' });
74+
});
75+
76+
it('fills a missing or blank message, so the declared shape is always met', () => {
77+
// Without this the envelope would carry `message: undefined` into a 403
78+
// body — the loose shape the declaration exists to rule out.
79+
for (const gate of [{ code: 'MFA_REQUIRED' }, { code: 'MFA_REQUIRED', message: '' }]) {
80+
const g = normalizeAuthGate({ authGate: gate });
81+
expect(g?.code).toBe('MFA_REQUIRED');
82+
expect(typeof g?.message).toBe('string');
83+
expect(g?.message.length).toBeGreaterThan(0);
84+
}
85+
});
86+
87+
it('drops any key the declaration does not name', () => {
88+
const g = normalizeAuthGate({
89+
authGate: { code: 'PASSWORD_EXPIRED', message: 'm', redirectTo: '/change-password' },
90+
});
91+
expect(Object.keys(g ?? {}).sort()).toEqual(['code', 'message']);
92+
});
93+
});
5194
});

0 commit comments

Comments
 (0)