Skip to content

Commit c11e24b

Browse files
os-zhuangclaude
andauthored
fix(authz): carry derived posture rung on ExecutionContext (#2947) (#2956)
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. Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs Co-authored-by: Claude <noreply@anthropic.com>
1 parent df26c91 commit c11e24b

5 files changed

Lines changed: 99 additions & 0 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/runtime': patch
4+
'@objectstack/rest': patch
5+
---
6+
7+
fix(authz): carry the derived posture rung on ExecutionContext (#2947)
8+
9+
The ADR-0095 D2 posture ladder (`PLATFORM_ADMIN > TENANT_ADMIN > MEMBER >
10+
EXTERNAL`) is derived once by the shared authz resolver from capability grants,
11+
but both HTTP/MCP entry points that build the `ExecutionContext` dropped it —
12+
so any enforcement-side reader of `context.posture` always saw `undefined`
13+
(the same drop that forced the explain layer to re-derive it, #2949).
14+
15+
`ExecutionContextSchema` now carries an optional `posture` field, and both
16+
`rest-server` and the runtime `resolveExecutionContext` plumb the resolver's
17+
value through. Additive and **behavior-preserving**: no enforcement decision
18+
consumes `posture` yet — whether the hot path evaluates *by* posture remains a
19+
larger ADR-level decision — this only stops the already-computed value from
20+
being discarded, so enforcement and explain read the same derived rung.

packages/rest/src/rest-server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,6 +1202,10 @@ export class RestServer {
12021202
permissions: authz.permissions,
12031203
systemPermissions: authz.systemPermissions,
12041204
...(authz.tabPermissions ? { tabPermissions: authz.tabPermissions } : {}),
1205+
// [ADR-0095 D2 / #2947] Carry the derived posture rung so the
1206+
// enforcement side reads the SAME value the resolver computed,
1207+
// instead of dropping it here (the boundary this issue closes).
1208+
...(authz.posture ? { posture: authz.posture } : {}),
12051209
isSystem: false,
12061210
org_user_ids: authz.org_user_ids,
12071211
...(authGate ? { authGate } : {}),

packages/runtime/src/security/resolve-execution-context.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,9 @@ describe('resolveExecutionContext — platform-scoped (null-org) grants (ADR-006
323323
expect(ctx.tenantId).toBe('orgA');
324324
expect(ctx.permissions).toContain('admin_full_access');
325325
expect(ctx.systemPermissions).toContain('manage_platform_settings');
326+
// [#2947] the derived posture rung is now CARRIED on the ctx (previously
327+
// dropped at this entry). An unscoped admin_full_access grant → PLATFORM_ADMIN.
328+
expect(ctx.posture).toBe('PLATFORM_ADMIN');
326329
});
327330

328331
it('still drops a grant scoped to a DIFFERENT org', async () => {
@@ -334,6 +337,60 @@ describe('resolveExecutionContext — platform-scoped (null-org) grants (ADR-006
334337
});
335338
});
336339

340+
// ---------------------------------------------------------------------------
341+
// [#2947 / ADR-0095 D2] The derived posture rung must be CARRIED down the HTTP /
342+
// MCP entry, not dropped. The rung itself is derived from CAPABILITY grants by
343+
// the shared authz resolver (asserted exhaustively in the posture-ladder tests);
344+
// here we only prove the value survives the ExecutionContext construction — i.e.
345+
// the enforcement side reads the SAME value the resolver computed.
346+
// ---------------------------------------------------------------------------
347+
describe('resolveExecutionContext — posture plumbing (#2947)', () => {
348+
const RAW = 'osk_posture';
349+
function makeQlFor(permissionSets: any[], userPermSets: any[]) {
350+
const tables: Record<string, any[]> = {
351+
sys_api_key: [{ id: 'k1', key: hashApiKey(RAW), revoked: false, user_id: 'u1', organization_id: 'orgA', expires_at: FUTURE }],
352+
sys_member: [{ user_id: 'u1', organization_id: 'orgA', role: 'member' }],
353+
sys_user_permission_set: userPermSets,
354+
sys_permission_set: permissionSets,
355+
sys_position: [], sys_position_permission_set: [], sys_user_position: [],
356+
};
357+
return {
358+
async find(object: string, opts: any) {
359+
const rows = tables[object] ?? [];
360+
const where = opts?.where ?? {};
361+
return rows.filter((row) => {
362+
for (const [k, v] of Object.entries(where)) {
363+
if (v !== null && typeof v === 'object') {
364+
if (Array.isArray((v as any).$in) && !(v as any).$in.includes(row[k])) return false;
365+
continue;
366+
}
367+
if ((v ?? null) !== (row[k] ?? null)) return false;
368+
}
369+
return true;
370+
});
371+
},
372+
};
373+
}
374+
const opts = (ql: any) => ({ getService: async () => undefined, getQl: async () => ql, request: { headers: { 'x-api-key': RAW } } });
375+
376+
it('carries MEMBER for an ordinary principal (no admin / org-admin capability)', async () => {
377+
const ql = makeQlFor(
378+
[{ id: 'ps_basic', name: 'sales_rep', system_permissions: '[]', object_permissions: '{}' }],
379+
[{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_basic', organization_id: null }],
380+
);
381+
const ctx = await resolveExecutionContext(opts(ql));
382+
expect(ctx.userId).toBe('u1');
383+
expect(ctx.permissions).not.toContain('admin_full_access');
384+
expect(ctx.posture).toBe('MEMBER');
385+
});
386+
387+
it('leaves posture UNSET for an anonymous (guest) request — no principal, no rung', async () => {
388+
const ctx = await resolveExecutionContext(makeOpts([], {}));
389+
expect(ctx.userId).toBeUndefined();
390+
expect(ctx.posture).toBeUndefined();
391+
});
392+
});
393+
337394
describe('principal taxonomy at the HTTP entry (ADR-0090 D9/D10)', () => {
338395
it('a sessionless request resolves as a guest principal holding only the guest position', async () => {
339396
const ctx = await resolveExecutionContext(makeOpts([], {}));

packages/runtime/src/security/resolve-execution-context.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,10 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise<Exe
203203
if (authz.email) ctx.email = authz.email;
204204
if (authz.accessToken) ctx.accessToken = authz.accessToken;
205205
if (authz.tabPermissions) ctx.tabPermissions = authz.tabPermissions;
206+
// [ADR-0095 D2 / #2947] Carry the derived posture rung down the runtime /
207+
// MCP entry too, so this path and the REST path present enforcement the same
208+
// value. Present only for an authenticated principal (guest → absent).
209+
if (authz.posture) ctx.posture = authz.posture;
206210
(ctx as any).org_user_ids = authz.org_user_ids;
207211

208212
// OAuth provenance: surface the token's granted scopes so the MCP

packages/spec/src/kernel/execution-context.zod.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { z } from 'zod';
1818
* engine.find('account', { context: { userId: '...', tenantId: '...' } })
1919
*/
2020
import { lazySchema } from '../shared/lazy-schema';
21+
import { AuthzPostureSchema } from '../security/explain.zod';
2122
export const ExecutionContextSchema = lazySchema(() => z.object({
2223
/** Current user ID (resolved from session) */
2324
userId: z.string().optional(),
@@ -92,6 +93,19 @@ export const ExecutionContextSchema = lazySchema(() => z.object({
9293
*/
9394
audience: z.enum(['internal', 'external']).optional(),
9495

96+
/**
97+
* [ADR-0095 D2/B2 — posture ladder] The monotonic authorization rung the
98+
* principal evaluated at (`PLATFORM_ADMIN > TENANT_ADMIN > MEMBER > EXTERNAL`),
99+
* derived once by the shared authz resolver from capability grants — never a
100+
* better-auth role. Carried here so enforcement/explain read the SAME derived
101+
* value instead of each re-deriving it (the divergence #2949 patched at the
102+
* explain boundary; #2947 closes the enforcement-boundary drop). Additive and
103+
* behavior-preserving: no enforcement decision consumes it yet — whether the
104+
* hot path evaluates BY posture is a larger ADR-level decision. Absent for
105+
* contexts resolved before the ladder existed or where no principal resolved.
106+
*/
107+
posture: AuthzPostureSchema.optional(),
108+
95109
/**
96110
* [ADR-0090 D10 — P1 shape] Delegation link for agent/service principals
97111
* acting on behalf of a user. Agent effective permission = the agent's own

0 commit comments

Comments
 (0)