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
20 changes: 20 additions & 0 deletions .changeset/authz-2947-posture-plumb.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
Expand Down
57 changes: 57 additions & 0 deletions packages/runtime/src/security/resolve-execution-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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<string, any[]> = {
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([], {}));
Expand Down
4 changes: 4 additions & 0 deletions packages/runtime/src/security/resolve-execution-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,10 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise<Exe
if (authz.email) ctx.email = authz.email;
if (authz.accessToken) ctx.accessToken = authz.accessToken;
if (authz.tabPermissions) ctx.tabPermissions = authz.tabPermissions;
// [ADR-0095 D2 / #2947] Carry the derived posture rung down the runtime /
// MCP entry too, so this path and the REST path present enforcement the same
// value. Present only for an authenticated principal (guest → absent).
if (authz.posture) ctx.posture = authz.posture;
(ctx as any).org_user_ids = authz.org_user_ids;

// OAuth provenance: surface the token's granted scopes so the MCP
Expand Down
14 changes: 14 additions & 0 deletions packages/spec/src/kernel/execution-context.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { z } from 'zod';
* engine.find('account', { context: { userId: '...', tenantId: '...' } })
*/
import { lazySchema } from '../shared/lazy-schema';
import { AuthzPostureSchema } from '../security/explain.zod';
export const ExecutionContextSchema = lazySchema(() => z.object({
/** Current user ID (resolved from session) */
userId: z.string().optional(),
Expand Down Expand Up @@ -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
Expand Down