From 3328170a3e8286a957445f121cae155a21683137 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 09:44:06 +0000 Subject: [PATCH] fix(authz): carry derived posture rung on ExecutionContext (#2947) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADR-0095 D2 posture ladder is derived once by the shared authz resolver from capability grants, but both entry points that build the ExecutionContext (rest-server, runtime resolveExecutionContext) dropped it — so any enforcement-side reader of context.posture always saw undefined (the same drop that forced the explain layer to re-derive it, #2949). - spec: ExecutionContextSchema gains an optional `posture` field (reuses AuthzPostureSchema; no api-surface change — schema tracked as a const). - rest + runtime: plumb authz.posture through when present. - tests: assert PLATFORM_ADMIN / MEMBER are carried and guest stays unset. Additive and behavior-preserving: no enforcement decision consumes posture yet. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs --- .changeset/authz-2947-posture-plumb.md | 20 +++++++ packages/rest/src/rest-server.ts | 4 ++ .../resolve-execution-context.test.ts | 57 +++++++++++++++++++ .../src/security/resolve-execution-context.ts | 4 ++ .../spec/src/kernel/execution-context.zod.ts | 14 +++++ 5 files changed, 99 insertions(+) create mode 100644 .changeset/authz-2947-posture-plumb.md diff --git a/.changeset/authz-2947-posture-plumb.md b/.changeset/authz-2947-posture-plumb.md new file mode 100644 index 0000000000..3999d21770 --- /dev/null +++ b/.changeset/authz-2947-posture-plumb.md @@ -0,0 +1,20 @@ +--- +'@objectstack/spec': minor +'@objectstack/runtime': patch +'@objectstack/rest': patch +--- + +fix(authz): carry the derived posture rung on ExecutionContext (#2947) + +The ADR-0095 D2 posture ladder (`PLATFORM_ADMIN > TENANT_ADMIN > MEMBER > +EXTERNAL`) is derived once by the shared authz resolver from capability grants, +but both HTTP/MCP entry points that build the `ExecutionContext` dropped it — +so any enforcement-side reader of `context.posture` always saw `undefined` +(the same drop that forced the explain layer to re-derive it, #2949). + +`ExecutionContextSchema` now carries an optional `posture` field, and both +`rest-server` and the runtime `resolveExecutionContext` plumb the resolver's +value through. Additive and **behavior-preserving**: no enforcement decision +consumes `posture` yet — whether the hot path evaluates *by* posture remains a +larger ADR-level decision — this only stops the already-computed value from +being discarded, so enforcement and explain read the same derived rung. diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index a47761c765..5d92a21252 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1202,6 +1202,10 @@ export class RestServer { permissions: authz.permissions, systemPermissions: authz.systemPermissions, ...(authz.tabPermissions ? { tabPermissions: authz.tabPermissions } : {}), + // [ADR-0095 D2 / #2947] Carry the derived posture rung so the + // enforcement side reads the SAME value the resolver computed, + // instead of dropping it here (the boundary this issue closes). + ...(authz.posture ? { posture: authz.posture } : {}), isSystem: false, org_user_ids: authz.org_user_ids, ...(authGate ? { authGate } : {}), diff --git a/packages/runtime/src/security/resolve-execution-context.test.ts b/packages/runtime/src/security/resolve-execution-context.test.ts index eeaa363356..2173642c19 100644 --- a/packages/runtime/src/security/resolve-execution-context.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.test.ts @@ -323,6 +323,9 @@ describe('resolveExecutionContext — platform-scoped (null-org) grants (ADR-006 expect(ctx.tenantId).toBe('orgA'); expect(ctx.permissions).toContain('admin_full_access'); expect(ctx.systemPermissions).toContain('manage_platform_settings'); + // [#2947] the derived posture rung is now CARRIED on the ctx (previously + // dropped at this entry). An unscoped admin_full_access grant → PLATFORM_ADMIN. + expect(ctx.posture).toBe('PLATFORM_ADMIN'); }); it('still drops a grant scoped to a DIFFERENT org', async () => { @@ -334,6 +337,60 @@ describe('resolveExecutionContext — platform-scoped (null-org) grants (ADR-006 }); }); +// --------------------------------------------------------------------------- +// [#2947 / ADR-0095 D2] The derived posture rung must be CARRIED down the HTTP / +// MCP entry, not dropped. The rung itself is derived from CAPABILITY grants by +// the shared authz resolver (asserted exhaustively in the posture-ladder tests); +// here we only prove the value survives the ExecutionContext construction — i.e. +// the enforcement side reads the SAME value the resolver computed. +// --------------------------------------------------------------------------- +describe('resolveExecutionContext — posture plumbing (#2947)', () => { + const RAW = 'osk_posture'; + function makeQlFor(permissionSets: any[], userPermSets: any[]) { + const tables: Record = { + sys_api_key: [{ id: 'k1', key: hashApiKey(RAW), revoked: false, user_id: 'u1', organization_id: 'orgA', expires_at: FUTURE }], + sys_member: [{ user_id: 'u1', organization_id: 'orgA', role: 'member' }], + sys_user_permission_set: userPermSets, + sys_permission_set: permissionSets, + sys_position: [], sys_position_permission_set: [], sys_user_position: [], + }; + return { + async find(object: string, opts: any) { + const rows = tables[object] ?? []; + const where = opts?.where ?? {}; + return rows.filter((row) => { + for (const [k, v] of Object.entries(where)) { + if (v !== null && typeof v === 'object') { + if (Array.isArray((v as any).$in) && !(v as any).$in.includes(row[k])) return false; + continue; + } + if ((v ?? null) !== (row[k] ?? null)) return false; + } + return true; + }); + }, + }; + } + const opts = (ql: any) => ({ getService: async () => undefined, getQl: async () => ql, request: { headers: { 'x-api-key': RAW } } }); + + it('carries MEMBER for an ordinary principal (no admin / org-admin capability)', async () => { + const ql = makeQlFor( + [{ id: 'ps_basic', name: 'sales_rep', system_permissions: '[]', object_permissions: '{}' }], + [{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_basic', organization_id: null }], + ); + const ctx = await resolveExecutionContext(opts(ql)); + expect(ctx.userId).toBe('u1'); + expect(ctx.permissions).not.toContain('admin_full_access'); + expect(ctx.posture).toBe('MEMBER'); + }); + + it('leaves posture UNSET for an anonymous (guest) request — no principal, no rung', async () => { + const ctx = await resolveExecutionContext(makeOpts([], {})); + expect(ctx.userId).toBeUndefined(); + expect(ctx.posture).toBeUndefined(); + }); +}); + describe('principal taxonomy at the HTTP entry (ADR-0090 D9/D10)', () => { it('a sessionless request resolves as a guest principal holding only the guest position', async () => { const ctx = await resolveExecutionContext(makeOpts([], {})); diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index 9abc39a737..ee389858ab 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -203,6 +203,10 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise z.object({ /** Current user ID (resolved from session) */ userId: z.string().optional(), @@ -92,6 +93,19 @@ export const ExecutionContextSchema = lazySchema(() => z.object({ */ audience: z.enum(['internal', 'external']).optional(), + /** + * [ADR-0095 D2/B2 — posture ladder] The monotonic authorization rung the + * principal evaluated at (`PLATFORM_ADMIN > TENANT_ADMIN > MEMBER > EXTERNAL`), + * derived once by the shared authz resolver from capability grants — never a + * better-auth role. Carried here so enforcement/explain read the SAME derived + * value instead of each re-deriving it (the divergence #2949 patched at the + * explain boundary; #2947 closes the enforcement-boundary drop). Additive and + * behavior-preserving: no enforcement decision consumes it yet — whether the + * hot path evaluates BY posture is a larger ADR-level decision. Absent for + * contexts resolved before the ladder existed or where no principal resolved. + */ + posture: AuthzPostureSchema.optional(), + /** * [ADR-0090 D10 — P1 shape] Delegation link for agent/service principals * acting on behalf of a user. Agent effective permission = the agent's own