Skip to content

Commit f911e1d

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(authz): ADR-0066 D4 — action dual-surface gate (server source of truth) (#2238)
- spec: ActionSchema gains `requiredPermissions: string[]` (mirrors App/Object). - runtime: http-dispatcher.handleActions enforces it BEFORE executing — resolves the action def from the object schema and rejects (403) when the caller's systemPermissions don't cover requiredPermissions. isSystem bypasses; an unauthenticated caller (no caps) is denied. One declaration gates both surfaces (objectui ActionRunner derives the UI hide/disable from the same field — separate PR), removing the "UI-gated but server-open" footgun. - liveness ledger: classify action.requiredPermissions (live). api-surface unchanged. - Tests: spec (+2) + full-dispatcher gate (+5: deny/allow/system-bypass/ungated/anon). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4035b31 commit f911e1d

5 files changed

Lines changed: 126 additions & 1 deletion

File tree

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1845,4 +1845,58 @@ describe('HttpDispatcher', () => {
18451845
expect(memberQL.find).not.toHaveBeenCalled();
18461846
});
18471847
});
1848-
});
1848+
});
1849+
1850+
1851+
describe('HttpDispatcher — ADR-0066 D4 action requiredPermissions gate', () => {
1852+
const gated = { name: 'issue_and_sign', label: 'Issue', type: 'api', requiredPermissions: ['manage_platform_settings'] };
1853+
const make = (actionDef: any, execCtx: any) => {
1854+
const executeAction = vi.fn().mockResolvedValue({ ran: true });
1855+
const schemaOf = (name: string) => ({ name, actions: actionDef ? [actionDef] : [] });
1856+
const ql: any = {
1857+
executeAction,
1858+
getSchema: schemaOf,
1859+
// getObjectQLService only returns a service when `svc.registry` is truthy.
1860+
registry: { getObject: schemaOf },
1861+
find: vi.fn().mockResolvedValue([]),
1862+
insert: vi.fn(), update: vi.fn(), delete: vi.fn(),
1863+
};
1864+
const kernel: any = { context: { getService: (n: string) => (n === 'objectql' ? ql : null) } };
1865+
const dispatcher = new HttpDispatcher(kernel);
1866+
const ctx: any = { request: {}, environmentId: 'platform', executionContext: execCtx };
1867+
return { dispatcher, executeAction, ctx };
1868+
};
1869+
1870+
it('rejects (403) when the caller lacks the action capability', async () => {
1871+
const { dispatcher, executeAction, ctx } = make(gated, { userId: 'u1', systemPermissions: [] });
1872+
const res = await dispatcher.handleActions('/sys_license/issue_and_sign', 'POST', {}, ctx);
1873+
expect(res.response.status).toBe(403);
1874+
expect(executeAction).not.toHaveBeenCalled();
1875+
});
1876+
1877+
it('allows when the caller holds the capability', async () => {
1878+
const { dispatcher, executeAction, ctx } = make(gated, { userId: 'u1', systemPermissions: ['manage_platform_settings'] });
1879+
const res = await dispatcher.handleActions('/sys_license/issue_and_sign', 'POST', {}, ctx);
1880+
expect(executeAction).toHaveBeenCalledTimes(1);
1881+
expect(res.response.status).not.toBe(403);
1882+
});
1883+
1884+
it('bypasses the gate for a system context', async () => {
1885+
const { dispatcher, executeAction, ctx } = make(gated, { isSystem: true });
1886+
await dispatcher.handleActions('/sys_license/issue_and_sign', 'POST', {}, ctx);
1887+
expect(executeAction).toHaveBeenCalledTimes(1);
1888+
});
1889+
1890+
it('does not gate an action without requiredPermissions', async () => {
1891+
const { dispatcher, executeAction, ctx } = make({ name: 'mark_done', label: 'Mark', type: 'script', execute: 'true' }, { userId: 'u1', systemPermissions: [] });
1892+
await dispatcher.handleActions('/task/mark_done', 'POST', {}, ctx);
1893+
expect(executeAction).toHaveBeenCalledTimes(1);
1894+
});
1895+
1896+
it('denies an unauthenticated caller for a gated action', async () => {
1897+
const { dispatcher, executeAction, ctx } = make(gated, undefined);
1898+
const res = await dispatcher.handleActions('/sys_license/issue_and_sign', 'POST', {}, ctx);
1899+
expect(res.response.status).toBe(403);
1900+
expect(executeAction).not.toHaveBeenCalled();
1901+
});
1902+
});

packages/runtime/src/http-dispatcher.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2590,6 +2590,45 @@ export class HttpDispatcher {
25902590
return { handled: true, response: this.error('Data engine not available', 503) };
25912591
}
25922592

2593+
// [ADR-0066 D4] Dual-surface action gate — the server is the source of
2594+
// truth. Resolve the action's declared `requiredPermissions` from the
2595+
// object schema and reject (403) when the caller's systemPermissions
2596+
// don't cover them. The objectui ActionRunner hides/disables the same
2597+
// action from the identical declaration, so a UI-hidden action is also
2598+
// server-closed (and the inverse footgun is removed). System/engine
2599+
// self-invocation (isSystem) bypasses; an unauthenticated caller holds
2600+
// no capabilities and is therefore denied for a gated action.
2601+
try {
2602+
const actionSchema: any =
2603+
(typeof ql.getSchema === 'function' ? ql.getSchema(objectName) : undefined) ??
2604+
ql.registry?.getObject?.(objectName);
2605+
const actionDef: any = Array.isArray(actionSchema?.actions)
2606+
? actionSchema.actions.find((a: any) => a?.name === actionName)
2607+
: undefined;
2608+
const required: string[] = Array.isArray(actionDef?.requiredPermissions)
2609+
? actionDef.requiredPermissions
2610+
: [];
2611+
if (required.length > 0) {
2612+
const execCtx: any = _context?.executionContext;
2613+
if (!execCtx?.isSystem) {
2614+
const held = new Set<string>(execCtx?.systemPermissions ?? []);
2615+
const missing = required.filter((perm) => !held.has(perm));
2616+
if (missing.length > 0) {
2617+
return {
2618+
handled: true,
2619+
response: this.error(
2620+
`Action '${actionName}' on '${objectName}' requires capability ` +
2621+
`[${required.join(', ')}] — caller is missing [${missing.join(', ')}]`,
2622+
403,
2623+
),
2624+
};
2625+
}
2626+
}
2627+
}
2628+
} catch {
2629+
/* schema unresolved → no declared gate to enforce (handler-only action) */
2630+
}
2631+
25932632
// Resolve the handler — fall back to wildcard '*' if the object-specific key is missing.
25942633
// Since engine.executeAction throws when the key is unknown, we probe via the internal
25952634
// map by attempting the call inside a try/catch and rotating to '*'.

packages/spec/liveness/action.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@
8484
"status": "live",
8585
"note": "objectui — CEL, fail-closed."
8686
},
87+
"requiredPermissions": {
88+
"status": "live",
89+
"evidence": "packages/runtime/src/http-dispatcher.ts",
90+
"note": "ADR-0066 D4 dual-surface action gate. Server is source of truth: handleActions rejects (403) when the caller's systemPermissions don't cover action.requiredPermissions; objectui ActionRunner derives the same UI hide/disable. Unit-proven in packages/runtime/src/http-dispatcher.test.ts + packages/spec/src/ui/action.test.ts."
91+
},
8792
"disabled": {
8893
"status": "live",
8994
"evidence": "objectui: packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx:169",

packages/spec/src/ui/action.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -909,4 +909,21 @@ describe('ACTION_LOCATIONS — canonical source of truth', () => {
909909
})
910910
).toThrow();
911911
});
912+
913+
914+
it('[ADR-0066 D4] ActionSchema accepts requiredPermissions', () => {
915+
const action = ActionSchema.parse({
916+
name: 'issue_and_sign',
917+
label: 'Issue & Sign',
918+
type: 'api',
919+
target: '/api/v1/cloud/licenses/issue',
920+
requiredPermissions: ['manage_platform_settings'],
921+
});
922+
expect(action.requiredPermissions).toEqual(['manage_platform_settings']);
923+
});
924+
925+
it('[ADR-0066 D4] requiredPermissions is optional (absent ⇒ undefined)', () => {
926+
const action = ActionSchema.parse({ name: 'mark_done', label: 'Mark Done', type: 'script', execute: 'true' });
927+
expect(action.requiredPermissions).toBeUndefined();
928+
});
912929
});

packages/spec/src/ui/action.zod.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,16 @@ export const ActionSchema = lazySchema(() => z.object({
373373
visible: ExpressionInputSchema.optional().describe('Visibility predicate (CEL).'),
374374
disabled: z.union([z.boolean(), ExpressionInputSchema]).optional().describe('Boolean or predicate (CEL) — action is disabled when TRUE.'),
375375

376+
/**
377+
* [ADR-0066 D4] System capabilities required to INVOKE this action — a
378+
* dual-surface gate from ONE declaration: the server (action route) rejects
379+
* the call with 403 when the caller's systemPermissions don't cover these (the
380+
* source of truth), and the objectui ActionRunner hides/disables the button
381+
* using the same requirement. Independent of `visible` (CEL): this is the RBAC
382+
* capability contract, mirroring `App.requiredPermissions`.
383+
*/
384+
requiredPermissions: z.array(z.string()).optional().describe('[ADR-0066 D4] Capabilities required to invoke this action (server-enforced 403 + UI hide/disable).'),
385+
376386
/** Keyboard Shortcut */
377387
shortcut: z.string().optional().describe('Keyboard shortcut to trigger this action (e.g., "Ctrl+S")'),
378388

0 commit comments

Comments
 (0)