Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/execution-context-auth-gate-declared.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions content/docs/references/kernel/execution-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
72 changes: 70 additions & 2 deletions packages/core/src/security/assemble-execution-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
});
Expand All @@ -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
Expand All @@ -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));
});
Expand All @@ -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');
});
Expand All @@ -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');
});
Expand All @@ -349,13 +361,66 @@ 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');
expect(Object.keys(ctx)).not.toContain('oauthScopes');
});
});

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
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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');
Expand Down
31 changes: 30 additions & 1 deletion packages/core/src/security/assemble-execution-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -127,6 +128,7 @@ export const ENTRY_EXECUTION_CONTEXT_FIELDS = [
'accessToken',
'tabPermissions',
'posture',
'authGate',
'org_user_ids',
'accessible_org_ids',
'oauthScopes',
Expand Down Expand Up @@ -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. */
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
45 changes: 44 additions & 1 deletion packages/core/src/security/auth-gate.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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']);
});
});
});
Loading
Loading