Skip to content

Commit e68a2ad

Browse files
os-zhuangclaude
andcommitted
fix(security): single-source the request authorization resolver (REST dropped sys_user_role)
The REST server (@objectstack/rest) and the runtime dispatcher (@objectstack/runtime) each carried their own copy of the request -> ExecutionContext identity/role resolver. They drifted on a security path: the REST copy silently omitted sys_user_role (so custom roles granted via the ADR-0057 D4 platform-RBAC path did not apply over REST), sys_role_permission_set, the owner->org_owner membership normalization, the platform-admin derivation, and the ai_seat synthesis. Fail-closed (legitimate access denied), not an escalation. Root-fix (finishes the extraction the API-key verifier already modelled): a single shared resolver `resolveAuthzContext` in @objectstack/core/security — the lowest common dependency of both rest and runtime. Both entry points now delegate; no parallel copies to keep in sync. - core: resolveAuthzContext + resolveLocalizationContext (complete role/permission aggregation) + a contract test asserting every authorization source is honored. - runtime: resolveExecutionContext delegates (469 -> 138 lines). - rest: rest-server's identity resolver delegates (drops its divergent ~180-line copy). - scripts/check-single-authz-resolver.mjs + a lint CI step: guard against a future duplicate resolver or a dropped delegation. Verified: contract test 7/7; suites green (runtime 444/444, rest 130/130, core green); full turbo build green; ESLint clean; dogfooded — a user granted `contributor` via sys_user_role now resolves that role on the REST data path (previously roles []). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent baf689c commit e68a2ad

9 files changed

Lines changed: 637 additions & 565 deletions

File tree

.changeset/unify-authz-resolver.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/rest": patch
3+
"@objectstack/runtime": patch
4+
"@objectstack/core": patch
5+
---
6+
7+
fix(security): single-source the request authorization resolver — REST no longer drops sys_user_role
8+
9+
The REST server and the runtime dispatcher each carried their own copy of the request → ExecutionContext identity/role resolver, and they drifted on a security path. The REST copy silently omitted `sys_user_role` (so custom roles granted via the ADR-0057 D4 platform-RBAC path did not apply over REST), `sys_role_permission_set`, the `owner→org_owner` membership normalization, the platform-admin derivation, and the `ai_seat` synthesis — fail-closed (legitimate access denied), not an escalation.
10+
11+
Both entry points now delegate to a single shared resolver, `resolveAuthzContext` in `@objectstack/core/security` (joining the API-key verifier that already lived there). A contract test locks every authorization source and a lint gate (`check:authz-resolver`) prevents a future duplicate resolver or a dropped delegation.

.github/workflows/lint.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ jobs:
5858
- name: Doc/skill authoring guard
5959
run: pnpm check:doc-authoring
6060

61+
# Authorization resolution must stay single-sourced (resolveAuthzContext,
62+
# @objectstack/core). Guards against a duplicate resolver copy drifting on a
63+
# security path (the REST-vs-dispatcher sys_user_role drift) and against an
64+
# entry point silently dropping the delegation.
65+
- name: Single authz resolver guard
66+
run: pnpm check:authz-resolver
67+
6168
typecheck:
6269
name: TypeScript Type Check
6370
runs-on: ubuntu-latest

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
"objectui:refresh": "bash scripts/bump-objectui.sh && bash scripts/build-console.sh",
2626
"objectui:clean": "rm -rf packages/console/dist .cache/objectui-*",
2727
"lint": "eslint . --no-inline-config",
28-
"check:doc-authoring": "node scripts/check-doc-authoring.mjs"
28+
"check:doc-authoring": "node scripts/check-doc-authoring.mjs",
29+
"check:authz-resolver": "node scripts/check-single-authz-resolver.mjs"
2930
},
3031
"keywords": [
3132
"objectstack",

packages/core/src/security/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,11 @@ export {
7979
type GeneratedApiKey,
8080
type ApiKeyPrincipal,
8181
} from './api-key.js';
82+
83+
export {
84+
resolveAuthzContext,
85+
resolveLocalizationContext,
86+
type ResolvedAuthzContext,
87+
type ResolveAuthzInput,
88+
type ResolveLocalizationInput,
89+
} from './resolve-authz-context.js';
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { resolveAuthzContext } from './resolve-authz-context.js';
5+
6+
/**
7+
* Contract test for the SINGLE authorization resolver. Every authorization
8+
* source MUST be honored here — this is the regression net that would have
9+
* caught the REST-vs-dispatcher drift (the REST copy had silently dropped
10+
* sys_user_role / sys_role_permission_set / platform_admin / ai_seat).
11+
*/
12+
13+
// Minimal in-memory ObjectQL: find(object, { where }) with `===` + `$in` match.
14+
function makeQl(tables: Record<string, any[]>) {
15+
return {
16+
async find(object: string, opts: any) {
17+
const rows = tables[object] ?? [];
18+
const where = opts?.where ?? {};
19+
return rows.filter((r) =>
20+
Object.entries(where).every(([k, v]) => {
21+
if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(r[k]);
22+
return r[k] === v;
23+
}),
24+
);
25+
},
26+
};
27+
}
28+
const session = (userId: string, opts: { email?: string; org?: string } = {}) =>
29+
async () => ({ user: { id: userId, email: opts.email }, session: { activeOrganizationId: opts.org ?? null } });
30+
const H = () => new Headers();
31+
32+
describe('resolveAuthzContext — single source of truth', () => {
33+
it('resolves a custom role granted via sys_user_role (the REST-drift bug)', async () => {
34+
const ql = makeQl({
35+
sys_user: [{ id: 'u1', email: 'ada@x.com' }],
36+
sys_member: [],
37+
sys_user_role: [{ user_id: 'u1', role: 'contributor', organization_id: null }],
38+
sys_user_permission_set: [],
39+
});
40+
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') });
41+
expect(ctx.roles).toContain('contributor');
42+
});
43+
44+
it('normalizes sys_member org roles (owner -> org_owner)', async () => {
45+
const ql = makeQl({
46+
sys_user: [{ id: 'u1' }],
47+
sys_member: [{ user_id: 'u1', role: 'owner', organization_id: 'o1' }],
48+
sys_user_role: [],
49+
sys_user_permission_set: [],
50+
});
51+
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }) });
52+
expect(ctx.roles).toContain('org_owner');
53+
});
54+
55+
it('resolves role-bound permission sets (sys_role_permission_set)', async () => {
56+
const ql = makeQl({
57+
sys_user: [{ id: 'u1' }],
58+
sys_member: [],
59+
sys_user_role: [{ user_id: 'u1', role: 'contributor', organization_id: null }],
60+
sys_user_permission_set: [],
61+
sys_role: [{ id: 'r1', name: 'contributor' }],
62+
sys_role_permission_set: [{ role_id: 'r1', permission_set_id: 'ps1' }],
63+
sys_permission_set: [{ id: 'ps1', name: 'contributor_ps', system_permissions: ['cap_x'] }],
64+
});
65+
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') });
66+
expect(ctx.permissions).toContain('contributor_ps');
67+
expect(ctx.systemPermissions).toContain('cap_x');
68+
});
69+
70+
it('derives platform_admin from an UNSCOPED admin_full_access user grant', async () => {
71+
const ql = makeQl({
72+
sys_user: [{ id: 'u1' }],
73+
sys_member: [],
74+
sys_user_role: [],
75+
sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', organization_id: null }],
76+
sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }],
77+
});
78+
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') });
79+
expect(ctx.roles).toContain('platform_admin');
80+
});
81+
82+
it('does NOT derive platform_admin from an ORG-scoped admin_full_access grant', async () => {
83+
const ql = makeQl({
84+
sys_user: [{ id: 'u1' }],
85+
sys_member: [],
86+
sys_user_role: [],
87+
sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', organization_id: 'o1' }],
88+
sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }],
89+
});
90+
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }) });
91+
expect(ctx.roles).not.toContain('platform_admin');
92+
});
93+
94+
it('synthesizes ai_seat from sys_user.ai_access (sqlite integer 1)', async () => {
95+
const ql = makeQl({
96+
sys_user: [{ id: 'u1', ai_access: 1 }],
97+
sys_member: [],
98+
sys_user_role: [],
99+
sys_user_permission_set: [],
100+
});
101+
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') });
102+
expect(ctx.permissions).toContain('ai_seat');
103+
});
104+
105+
it('anonymous (no session, no api key) → empty context', async () => {
106+
const ctx = await resolveAuthzContext({ ql: makeQl({}), headers: H(), getSession: async () => undefined });
107+
expect(ctx.userId).toBeUndefined();
108+
expect(ctx.roles).toEqual([]);
109+
expect(ctx.permissions).toEqual([]);
110+
});
111+
});

0 commit comments

Comments
 (0)