Skip to content

Commit 06772eb

Browse files
baozhoutaoclaude
authored andcommitted
docs(spec): state what the action requiredPermissions server gate does NOT cover (#3923)
`ActionSchema.requiredPermissions` documented itself as a dual-surface gate whose server half is "the source of truth". An app author reasonably reads that as "any action I declare this on is enforced somewhere", and it isn't. Server enforcement lives on the platform action route — POST /api/v1/actions/<object>/<action> and the MCP/AI path — which is where `type: 'script' | 'flow' | 'modal'` actions execute. A `type: 'api'` action pointed at a self-authored endpoint is fetched by the browser directly; that request never reaches the platform, so nothing checks the declaration server-side and the endpoint must re-check the capability itself. The doc comment, the `describe()` string (what surfaces in generated schema docs and editor tooltips) and ADR-0066 D4 now say so. Behaviour is unchanged. The UI half's gaps were separate and are fixed in objectui. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent cd6b9f2 commit 06772eb

3 files changed

Lines changed: 37 additions & 4 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): state what the action `requiredPermissions` server gate does NOT cover (#3923)
6+
7+
`ActionSchema.requiredPermissions` documented itself as a dual-surface gate whose
8+
server half is "the source of truth": declare once, get a 403 on the server and a
9+
hidden button in the UI. An app author reasonably read that as "any action I
10+
declare this on is enforced somewhere", and it isn't.
11+
12+
Server enforcement lives on the PLATFORM ACTION ROUTE — `POST
13+
/api/v1/actions/<object>/<action>` and the MCP/AI path — which is where `type:
14+
'script' | 'flow' | 'modal'` actions execute. A `type: 'api'` action pointed at a
15+
self-authored endpoint is fetched by the browser directly; that request never
16+
reaches the platform, so nothing checks the declaration server-side and the
17+
endpoint has to re-check the capability itself. The doc comment, the `describe()`
18+
string (which is what surfaces in generated schema docs and editor tooltips), and
19+
ADR-0066 D4 now say so.
20+
21+
Behaviour is unchanged — this is the contract being honest about its edges. The
22+
UI half's gaps were separate and are fixed in objectui.

docs/adr/0066-unified-authorization-model.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ Extend `requiredPermissions` (today only on `App`/nav, **[existing]**) to **Obje
5757
### D4 — Dual-surface action gates [new]
5858
An action declaring `requiredPermissions` is enforced in **one place, two surfaces**: the ActionRunner hides/disables it in the UI **and** the server rejects the call when the caller lacks the capability. Removes the "UI-gated but server-open" footgun (and the inverse). Server enforcement is the source of truth; UI gating is derived from the same declaration.
5959

60+
**Scope of the server half (clarified 2026-07, framework#3923).** "The server rejects the call" means the PLATFORM ACTION ROUTE — `POST /api/v1/actions/<object>/<action>` and the MCP/AI path, i.e. where `type: 'script' | 'flow' | 'modal'` actions execute. A `type: 'api'` action whose `target` is a self-authored endpoint is fetched by the browser directly and never passes through that route, so the declaration has no server-side consumer there; that endpoint must re-check the capability itself. The UI half is correspondingly *every* action surface, not just the engine: `record_header`/`record_more` (`page:header`) and `list_item` (the grid row menu) filter their own action lists and were fixed to apply the same gate (objectui), alongside `ActionEngine.getActionsForLocation`. All of them fail OPEN when the client does not know the caller's capabilities — unknown is not denied.
61+
6062
### D5 — Package-seeded, admin-maintainable policies [existing mechanism, fill the gap]
6163
A package may seed permission-set policies (incl. per-object grants for **its own** objects) via `stack.permissions` **[existing]**; these land as `sys_permission_set` records **admins can edit in Setup** **[existing]**. The gap to close: make a package's per-object secure default (D2 + an admin-only grant) expressible + seedable so a sensitive package object is locked on install and tunable thereafter.
6264

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -580,13 +580,22 @@ export const ActionSchema = lazySchema(() => z.object({
580580

581581
/**
582582
* [ADR-0066 D4] System capabilities required to INVOKE this action — a
583-
* dual-surface gate from ONE declaration: the server (action route) rejects
583+
* dual-surface gate from ONE declaration: the PLATFORM ACTION ROUTE rejects
584584
* the call with 403 when the caller's systemPermissions don't cover these (the
585-
* source of truth), and the objectui ActionRunner hides/disables the button
586-
* using the same requirement. Independent of `visible` (CEL): this is the RBAC
585+
* source of truth), and the objectui action surfaces hide the button using the
586+
* same requirement. Independent of `visible` (CEL): this is the RBAC
587587
* capability contract, mirroring `App.requiredPermissions`.
588+
*
589+
* **Server enforcement covers the platform's own invocation paths only** —
590+
* `POST /api/v1/actions/<object>/<action>` (and the MCP/AI path), which is
591+
* where `type: 'script' | 'flow' | 'modal'` actions land. A `type: 'api'`
592+
* action whose `target` is a self-authored endpoint is called by the browser
593+
* DIRECTLY: the platform never sees the request, so nothing checks this
594+
* declaration server-side. Such an endpoint MUST re-check the capability
595+
* itself (framework#3923) — treat the UI gate there as a courtesy, not a
596+
* boundary.
588597
*/
589-
requiredPermissions: z.array(z.string()).optional().describe('[ADR-0066 D4] Capabilities required to invoke this action (server-enforced 403 + UI hide/disable).'),
598+
requiredPermissions: z.array(z.string()).optional().describe('[ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there.'),
590599

591600
/** Keyboard Shortcut */
592601
shortcut: z.string().optional().describe('Keyboard shortcut to trigger this action (e.g., "Ctrl+S")'),

0 commit comments

Comments
 (0)