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
4 changes: 3 additions & 1 deletion docs/adr/0066-unified-authorization-model.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR-0066: Unified authorization model — capability registry, secure-by-default posture, resource→capability contracts, dual-surface gates

**Status**: Accepted (2026-06-23) — D1–D5 + refinements ⑤/⑨ implemented (capability registry + `defineCapability` seeding, `access.default` posture with posture-gated superuser bypass, `requiredPermissions` AND-gates on object/field/action, `crudBucketForOperation`, `validateCapabilityReferences` lint); D4's UI half enforced in objectui ActionRunner. · **Amended** (2026-10-14, [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) D2/D3/D13): the stored records this record names — `sys_permission_set` and `sys_capability` — are retired as row objects by #15204 stage 8 (`66949b7729`, 2026-10-13), and the seeders that filled them are retired under ADR-0131 D2; permission sets and capabilities are metadata-registry definitions, and the `sys_user_permission_set` assignment stays. The three-way separation stands; see the notes under the principle, D1, D5 and ⑨. · **Amended** (2026-10-29, ruling of record 6499781492 on #27506 — maintainer, verbatim: 「我们的平台设计目标是 agent 和人类使用同一套系统」「同意」): the browser-only half of D4's 2026-07 api clause is superseded — a `type: 'api'` action whose `target` is a same-origin platform path is dispatched by the platform action door (`POST /api/v1/actions/<object>/<action>` and MCP `run_action`), which runs the D4 gate before forwarding; the `target` endpoint stays directly reachable and must still enforce the declared capability itself, the door's gate being the added check; an off-origin api action stays browser-only. The server half lands under #27506; see the note under D4.
**Status**: Accepted (2026-06-23) — D1–D5 + refinements ⑤/⑨ implemented (capability registry + `defineCapability` seeding, `access.default` posture with posture-gated superuser bypass, `requiredPermissions` AND-gates on object/field/action, `crudBucketForOperation`, `validateCapabilityReferences` lint); D4's UI half enforced in objectui ActionRunner. · **Amended** (2026-10-14, [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) D2/D3/D13): the stored records this record names — `sys_permission_set` and `sys_capability` — are retired as row objects by #15204 stage 8 (`66949b7729`, 2026-10-13), and the seeders that filled them are retired under ADR-0131 D2; permission sets and capabilities are metadata-registry definitions, and the `sys_user_permission_set` assignment stays. The three-way separation stands; see the notes under the principle, D1, D5 and ⑨. · **Amended** (2026-10-29, ruling of record 6499781492 on #27506 — maintainer, verbatim: 「我们的平台设计目标是 agent 和人类使用同一套系统」「同意」): the browser-only half of D4's 2026-07 api clause is superseded — a `type: 'api'` action whose `target` is a same-origin platform path is dispatched by the platform action door (`POST /api/v1/actions/<object>/<action>` and MCP `run_action`), which runs the D4 gate before forwarding; the `target` endpoint stays directly reachable and must still enforce the declared capability itself, the door's gate being the added check; an off-origin api action stays browser-only. The server half lands under #27506; see the note under D4. Landed: the server half by PR #27649 (2026-10-29); step 3, the console, by PR #27793 for #27611 (2026-10-30); the `sys_*` MCP follow-up by PR #27940 for #27681 (2026-10-30, ruling 6513869839) — see the 2026-10-30 note under D4.
**Deciders**: ObjectStack Protocol Architects
**Builds on**: [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (`readScope`/`writeScope` depth: own/unit/unit_and_below/org), [ADR-0058](./0058-expression-and-predicate-surface.md) (CEL predicate surface used by RLS); relates to cloud ADR-0016 (authz open/paid boundary)
**Consumers**: `@objectstack/spec` (object/app/permission schemas), `@objectstack/plugin-security` (RLS/FLS compiler + enforcement), `../objectui` (ActionRunner + app/nav gating), `../cloud` (control-plane objects, e.g. `sys_license`)
Expand Down Expand Up @@ -74,6 +74,8 @@ An action declaring `requiredPermissions` is enforced in **one place, two surfac
**Scope of the server half (clarified 2026-07, framework#3923; api clause amended 2026-10-29, Ruling-ref: 6499781492).** "The server rejects the call" means the PLATFORM ACTION DOOR — `POST /api/v1/actions/<object>/<action>` and the MCP `run_action` bridge, which enforce one gate (`packages/runtime/src/action-execution.ts#actionPermissionError`) and dispatch `type: 'script'`, `type: 'flow'` and the declarative `operation: 'update'` write. A `type: 'api'` action whose `target` is a same-origin platform path — the declared `target`, before interpolation, starts with exactly one `/` and goes on past it (`/` alone, `//host` and `/\host` are not), as `packages/runtime/src/action-execution.ts#isSameOriginApiTarget` judges it (PR #27649; the door holds the interpolated path to the same test before it forwards) — is dispatched by that door too: the route and the bridge run the anonymous baseline, this D4 gate and the ADR-0126 §8 activation consult, then forward the call in-process to the declared `target` as the caller — the request assembled from the declaration (`method`, `recordIdParam`, `bodyShape`, `bodyExtra`, the placeholders in `target`), the caller's cookies and auth headers carried. The target's `Set-Cookie` is returned on the REST route's response; the MCP bridge relays only the bearer-lane rotation headers (`set-auth-token`, `set-admin-session-token`), and no cookie value enters a tool result. The door gates the calls it dispatches, and only those: the `target` endpoint stays directly reachable — nothing routes a direct call to it through the door — so it MUST still enforce the declared capability itself, and the door's gate is the added check: defence in depth, not a replacement. An api action whose `target` is not a same-origin platform path stays browser-only and ungoverned: the door does not dispatch it, the MCP bridge does not list it, and the activation write door (`POST /api/v1/actions/_activation/<object>/<action>`, `packages/runtime/src/domains/actions.ts#handleActionActivationWrite`) refuses to flip it, with a reason, rather than record a switch nothing consults. 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.

> **Amended (2026-10-29) — the api clause above reverses the browser-only half of the 2026-07 clarification (ruling of record 6499781492 on #27506; Prime Directive #13: a recorded decision is reversed by an ADR edit, never by a changeset).** As clarified on 2026-07 (framework#3923) this paragraph read: "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." Its first half — fetched by the browser directly, never passes through that route, no server-side consumer — recorded the gap as the rule and is superseded, not deleted. Its last clause stands: the endpoint must still re-check the capability itself, now beside the door's gate (the paragraph above). Under the maintainer's design goal, a capability a human reaches by clicking and an agent cannot reach through the action surface is a defect, and the unhonoured ADR-0126 switch on a `type: 'api'` action is one symptom of it. The maintainer, verbatim and untranslated: 「我们的平台设计目标是 agent 和人类使用同一套系统」 / 「同意」. The paragraph above also drops `modal` from the dispatched types: the door answers a `modal` action 400 as a client-side type (`packages/runtime/src/action-execution.ts#headlessActionTypeError`), and it has since gained the declarative update. **Landing, in three steps.** This record is step 1. Step 2 is the server card #27506: the door and the bridge accept a declared same-origin `type: 'api'` action, `packages/runtime/src/action-execution.ts#isHeadlessInvokableAction` admits it (and the `list_actions` filter in `packages/runtime/src/domains/mcp.ts#buildMcpBridge` is widened beside it), and the activation write door refuses an off-origin one. That does not put every api action in front of an agent through MCP: `list_actions` and `run_action` keep the `ai.exposed` opt-in and the fail-closed `sys_*` guard (`packages/runtime/src/domains/mcp.ts#buildMcpBridge` for the listing, `packages/runtime/src/action-execution.ts#invokeBusinessAction` for the call, both through `packages/runtime/src/action-execution.ts#isSystemObjectName`), so `sys_user.ban_user` and the other api actions on platform `sys_*` objects stay off MCP and reach an agent through REST `POST /api/v1/actions/<object>/<action>` — a gap this amendment records and does not close; card #27681 carries it. Step 3 is the ui card #27611: the console's api handlers send a same-origin click to the door instead of fetching `target`. Until step 2 lands the door still refuses `type: 'api'` — `packages/runtime/src/action-execution.ts#isHeadlessInvokableAction` admits `script`, `flow` and the declarative update only, `packages/runtime/src/action-execution.ts#SERVER_DISPATCHED_ACTION_TYPES` is `script`, `flow`, and `packages/runtime/src/action-execution.ts#headlessActionTypeError` answers an api action 400 — so no agent can invoke a `type: 'api'` action today, and the console still fetches `target` directly.
>
> **Note (2026-10-30, #27956, #27694) — all three steps have landed, and the `sys_*` gap is closed.** The amendment above reads as of its date; this note records what landed after it and changes no decision. Its closing sentence ("Until step 2 lands the door still refuses `type: 'api'` … and the console still fetches `target` directly") and its "stay off MCP … a gap this amendment records and does not close; card #27681 carries it" are superseded by this note, not deleted. (a) **The `sys_*` MCP gap is closed** by PR #27940 (#27681, maintainer ruling of record 6513869839, letter B). The rule is now per action: an action on a `sys_*` object is listed and run over MCP only when the action itself declares `ai.exposed: true` (ADR-0011); no deployment switch opens one. One predicate, `packages/runtime/src/action-execution.ts#systemObjectActionExposureError`, answers for the three MCP sites — `run_action` (`packages/runtime/src/action-execution.ts#invokeBusinessAction`), the `list_actions` filter (`packages/runtime/src/domains/mcp.ts#buildMcpBridge`) and the `resume_run` admission (`packages/runtime/src/domains/mcp.ts#resumeAdmissionRefusal`) — and the MCP tool layer's fail-closed filter, with its `allowSystemObjects` option at the default off, admits a `sys_*` action only on the bridge's attestation of that opt-in (`packages/mcp/src/mcp-http-tools.ts#isListableAction`). `packages/runtime/src/action-execution.ts#isSystemObjectName` still decides which objects the rule applies to. The stored-metadata body family (`sys_metadata`, `sys_metadata_history`, `packages/spec/src/kernel/stored-metadata-body-objects.ts#isStoredMetadataBodyObject`) is refused before the flag is read, so no declaration opens it. Every gate after admission is unchanged: `packages/runtime/src/action-execution.ts#actionAiExposureError`, this D4 gate (`packages/runtime/src/action-execution.ts#actionPermissionError`), the ADR-0126 §8 activation consult, the param contract, the `ai.requiresConfirmation` gate and the subject load under RLS. (b) **Step 2 landed** by PR #27649 (2026-10-29): the door and the bridge dispatch a declared same-origin `type: 'api'` action, as the D4 paragraph above states. (c) **Step 3 landed** by PR #27793 for #27611 (2026-10-30): the console sends a click on a declared same-origin `type: 'api'` action to `POST /api/v1/actions/<object>/<action>` instead of fetching `target`. A click whose `target` is off-origin, and one that resolves to no declared action in the object metadata the console holds (an inline page button that names none, an object-less action), still fetches `target` directly.

### D5 — Package-seeded, admin-maintainable policies [existing mechanism, fill the gap]
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.
Expand Down
5 changes: 0 additions & 5 deletions packages/spec/src/ui/action.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1610,11 +1610,6 @@ const actionObject = () => strictObject({
* and ungoverned — the door does not dispatch it, MCP does not list it, the
* activation switch refuses to flip it, and nothing checks this declaration
* server-side.
*
* Landing, as of 2026-10-29: ADR-0066 records the decision; the server half,
* the ruling's step 2 (#27506), is on `main` (#27649). Until step 3 (#27611)
* lands the console fetches `target` directly; card #27694 removes this
* paragraph when it does.
*/
requiredPermissions: z.array(z.string()).optional().describe('[ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on every call the platform action door (`POST /actions/:object/:action` and MCP `run_action`) dispatches, and mirrored as a UI hide. ADR-0066 D4, as amended 2026-10-29, brings a `type: api` action whose declared `target` is a same-origin platform path (exactly one leading `/`, followed by neither `/` nor a backslash, with a path after it) under that door; its `target` endpoint stays directly reachable, so it MUST still enforce this capability itself, the door\'s gate being the added check. A `type: api` action with an off-origin `target` is browser-only and not gated server-side.'),

Expand Down
Loading