diff --git a/.changeset/27610-adr-0066-d4-api-actions-door.md b/.changeset/27610-adr-0066-d4-api-actions-door.md new file mode 100644 index 00000000000..056c6baaca1 --- /dev/null +++ b/.changeset/27610-adr-0066-d4-api-actions-door.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': minor +'@objectstack/platform-objects': patch +--- + +spec(ui): `requiredPermissions` on a `type: 'api'` action — a same-origin api action is gated at the platform action door (ADR-0066 D4 amended 2026-10-29) + +Clause-②: no +Metadata-protocol: semantic + +The documented meaning of `requiredPermissions` on a `type: 'api'` action changes; no key, accepted value or default moves, and the schema shape is unchanged. + +- **Before.** The `requiredPermissions` docblock and `.describe()` (and the generated references under `content/docs/references/`), and the Studio action form's `requiredPermissions` help text (with its `en` / `zh-CN` / `ja-JP` / `es-ES` bundles in `@objectstack/platform-objects`), said a `type: 'api'` action pointed at a custom endpoint is called by the browser directly, never passes through the platform action route, and that the endpoint must re-check the capability itself — the 2026-07 scope note of ADR-0066 D4. +- **Now.** Per ADR-0066 D4 as amended on 2026-10-29, 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 — not `//`, nor `/` followed by a backslash) is dispatched by the platform action door — `POST /actions/:object/:action` and the MCP `run_action` bridge — which runs the D4 capability gate, the anonymous baseline and the ADR-0126 activation consult before forwarding the call in-process to the declared `target` as the caller. MCP keeps its `ai.exposed` opt-in and its fail-closed refusal of actions on `sys_*` objects, so those (`sys_user.ban_user` and the like) reach an agent through REST `POST /actions/:object/:action`, not MCP. The door gates the calls it dispatches, and only those: the target endpoint stays directly reachable, so it must still enforce the declared capability itself, the door's gate being the added check (defence in depth, not a replacement). The `.describe()` and the help text say "on every call the door dispatches", so they stay true before and after the server half lands. An api action whose `target` is not a same-origin platform path stays browser-only and ungoverned: the door does not dispatch it, MCP does not list it, and the activation switch refuses to flip it. The `operation: 'update'` refusal beside a `type: 'api'` executor now says `api` "calls the endpoint it names" rather than that the browser calls it. The description and the help text also drop `modal` from the list of server-dispatched types: the door dispatches `script`, `flow` and the declarative `operation: 'update'` write, and answers a `modal` action 400 as a client-side type. +- **Landing.** ADR-0066 records the decision; the runtime that implements the door's api forwarding ships separately (ADR-0066 D4's amendment note names the server card). Until it lands the door still refuses `type: 'api'` and the console fetches `target` directly. Nothing an author writes today is refused or re-read differently by this release. diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index befb82d0d88..774d19c575a 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -502,7 +502,7 @@ const result = ApiMethod.parse(data); | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | | **disabledReason** | `string \| Record` | optional | Display-only reason a greyed action shows (its hover title and accessible description, or a menu item's second line) while `disabled` evaluates TRUE — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | -| **requiredPermissions** | `string[]` | optional | [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. | +| **requiredPermissions** | `string[]` | optional | [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. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | | **recordIdField** | `string` | optional | Row field whose value seeds recordIdParam. Defaults to "id". | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 730931fea1f..851a7e3cbfa 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -347,7 +347,7 @@ const result = MetadataBulkResultSchema.parse(data); | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | | **disabledReason** | `string \| Record` | optional | Display-only reason a greyed action shows (its hover title and accessible description, or a menu item's second line) while `disabled` evaluates TRUE — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | -| **requiredPermissions** | `string[]` | optional | [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. | +| **requiredPermissions** | `string[]` | optional | [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. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | | **recordIdField** | `string` | optional | Row field whose value seeds recordIdParam. Defaults to "id". | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 44d208afd02..94749085a6d 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -57,7 +57,7 @@ const result = ActionSchema.parse(data); | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| 'transfer_ownership' \| 'remove_member' \| 'create_team' \| 'update_team' \| 'remove_team' \| … +4 more>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | | **disabledReason** | `string \| Record` | optional | Display-only reason a greyed action shows (its hover title and accessible description, or a menu item's second line) while `disabled` evaluates TRUE — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | -| **requiredPermissions** | `string[]` | optional | [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. | +| **requiredPermissions** | `string[]` | optional | [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. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | | **recordIdField** | `string` | optional | Row field whose value seeds recordIdParam. Defaults to "id". | diff --git a/docs/adr/0066-unified-authorization-model.md b/docs/adr/0066-unified-authorization-model.md index 543cc47cc29..6ffdc025df0 100644 --- a/docs/adr/0066-unified-authorization-model.md +++ b/docs/adr/0066-unified-authorization-model.md @@ -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 ⑨. +**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//` 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. **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`) @@ -71,7 +71,9 @@ Extend `requiredPermissions` (today only on `App`/nav, **[existing]**) to **Obje ### D4 — Dual-surface action gates [new] 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. -**Scope of the server half (clarified 2026-07, framework#3923).** "The server rejects the call" means the PLATFORM ACTION ROUTE — `POST /api/v1/actions//` 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. +**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//` 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 the server card's `isSameOriginApiTarget` judges it (PR #27649; once the symbol is on `main`, card #27694 rewrites this citation into a symbol anchor on `isSameOriginApiTarget` in `packages/runtime/src/action-execution.ts`; 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//`, `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//` — 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. ### 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. diff --git a/docs/adr/0126-packaged-metadata-customization-model.md b/docs/adr/0126-packaged-metadata-customization-model.md index e73fbe99f71..b297d6a68a7 100644 --- a/docs/adr/0126-packaged-metadata-customization-model.md +++ b/docs/adr/0126-packaged-metadata-customization-model.md @@ -429,7 +429,16 @@ callers first, or don't), and preserves "packaged code calls what it names". 的,因为有的 action 我不想启用。」, the first-party pull on record): same `sys_metadata_activation` ledger, same §5 write authority, a consult point at action dispatch, a surface row. ⛔ The action-**clone** half is not chartered — it stays on §3's pre-chart - discipline until real pull appears. + discipline until real pull appears. The consult point is the `/actions` door — + `POST /actions/:object/:action` and the MCP `run_action` bridge, both through + `packages/runtime/src/action-execution.ts#disabledActionRefusal` — and per ADR-0066 D4 as + amended on 2026-10-29 (ruling of record 6499781492 on #27506) it covers `type: 'api'` too: a + same-origin api action is dispatched through that same door, so the one consult at action + dispatch is its consult (§4: each consumer documents its consult point), while an api action + whose `target` is off-origin stays browser-only and the activation write door refuses to flip + it. The switch stops the action's dispatch, not a direct call to its `target` endpoint: the + door's half lands under #27506, and the console's click meets the consult only from step 3 + (#27611), which sends it to the door instead of fetching `target`. 3. **Permission sets converge in a later card — and the amendment made it simpler.** The landed #11513 machinery (lock, clone action, row-state `active`, drift detection of the enforced copy) **stays valid meanwhile** — it is the regime's first instance, not a violation of it, diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 4416ef64977..b62315a4bea 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -1877,7 +1877,7 @@ export const enMetadataForms: NonNullable = { }, requiredPermissions: { label: "Required Permissions", - helpText: "Capabilities (permission-set systemPermissions) a caller must hold — every one listed — to invoke this action (ADR-0066 D4). The platform action route refuses anyone else with 403 (script, flow and modal actions, and the MCP/AI path), and the button is hidden from them. A type api action calls its endpoint directly, so that endpoint must re-check them." + helpText: "Capabilities (permission-set systemPermissions) a caller must hold — every one listed — to invoke this action (ADR-0066 D4). The platform action door refuses anyone else with 403 on every call it dispatches, the MCP/AI path included, and the button is hidden from them. A type api action's endpoint stays directly reachable, so it must still check these capabilities itself; for an api action with an off-origin target the platform checks nothing server-side." }, ai: { label: "Ai", diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index c0056418ef8..e11be0b8168 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -1877,7 +1877,7 @@ export const esESMetadataForms: NonNullable = }, requiredPermissions: { label: "Permisos requeridos", - helpText: "Capacidades (systemPermissions de conjuntos de permisos) que quien llama debe tener, todas las enumeradas, para invocar esta acción (ADR-0066 D4). La ruta de acciones de la plataforma rechaza a cualquier otro con 403 (acciones script, flow y modal, y la vía MCP/IA), y se le oculta el botón. Una acción de type api llama directamente a su endpoint, así que ese endpoint debe volver a comprobarlas." + helpText: "Capacidades (systemPermissions de conjuntos de permisos) que quien llama debe tener, todas las enumeradas, para invocar esta acción (ADR-0066 D4). La puerta de acciones de la plataforma rechaza a cualquier otro con 403 en cada llamada que despacha, incluida la vía MCP/IA, y se le oculta el botón. El endpoint de una acción de type api sigue siendo accesible directamente, así que debe seguir comprobándolas por sí mismo; en una acción api con un target de otro origen, la plataforma no comprueba nada en el servidor." }, ai: { label: "Exposición a IA", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index c2cf601fb12..bb1a2770392 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -1877,7 +1877,7 @@ export const jaJPMetadataForms: NonNullable = }, requiredPermissions: { label: "必要な権限", - helpText: "このアクションを実行するために呼び出し元が保持すべき機能(権限セットの systemPermissions)。列挙したすべてが必要です(ADR-0066 D4)。それ以外の呼び出し元はプラットフォームのアクションルートで 403 として拒否され(script、flow、modal アクションと MCP/AI 経路)、ボタンも表示されません。type が api のアクションはエンドポイントを直接呼び出すため、そのエンドポイントで改めてチェックする必要があります。" + helpText: "このアクションを実行するために呼び出し元が保持すべき機能(権限セットの systemPermissions)。列挙したすべてが必要です(ADR-0066 D4)。プラットフォームのアクションドアは、自らがディスパッチするすべての呼び出しで、それ以外の呼び出し元を 403 として拒否し(MCP/AI 経路を含む)、ボタンも表示されません。type が api のアクションのエンドポイントは引き続き直接呼び出せるため、そのエンドポイント自身でもこれらの機能をチェックする必要があります。target が別オリジンの api アクションについては、プラットフォームはサーバー側で何もチェックしません。" }, ai: { label: "AI 公開", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 6ed55dee301..f82866361da 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -1877,7 +1877,7 @@ export const zhCNMetadataForms: NonNullable = }, requiredPermissions: { label: "所需权限", - helpText: "调用此动作必须持有的能力(权限集的 systemPermissions),列出的每一项都必须持有(ADR-0066 D4)。平台动作路由会以 403 拒绝其他调用方(script、flow 和 modal 动作,以及 MCP/AI 路径),并对他们隐藏按钮。type 为 api 的动作由浏览器直接调用其端点,因此该端点必须自行再次检查。" + helpText: "调用此动作必须持有的能力(权限集的 systemPermissions),列出的每一项都必须持有(ADR-0066 D4)。平台动作门对它分派的每一次调用都以 403 拒绝其他调用方(包括 MCP/AI 路径),并对他们隐藏按钮。type 为 api 的动作,其端点仍可被直接调用,因此该端点仍须自行检查这些能力;target 不同源的 api 动作,平台在服务端不做任何检查。" }, ai: { label: "AI 暴露", diff --git a/packages/spec/src/ui/action-declarative-update.ts b/packages/spec/src/ui/action-declarative-update.ts index 92ed6457ac8..5c613c715ea 100644 --- a/packages/spec/src/ui/action-declarative-update.ts +++ b/packages/spec/src/ui/action-declarative-update.ts @@ -121,7 +121,7 @@ function updateOperationExecutorRefusal(surface: DeclarativeUpdateSurface, execu : `\`${surface.executorKey}\` names that route or nothing`; return "`operation: 'update'` is performed by the platform action route — the route " + `${aSpelled(surface, 'script')} action posts to — so ${keep}; leave it off. ` - + `${ASpelled(surface, executor)} action dispatches on \`target\` (\`api\` makes the browser call the endpoint itself) ` + + `${ASpelled(surface, executor)} action dispatches on \`target\` (\`api\` calls the endpoint it names) ` + 'and never performs the write.'; } diff --git a/packages/spec/src/ui/action.form.ts b/packages/spec/src/ui/action.form.ts index 31444239aa9..924e7234cca 100644 --- a/packages/spec/src/ui/action.form.ts +++ b/packages/spec/src/ui/action.form.ts @@ -175,7 +175,7 @@ export const actionForm = defineForm({ { field: 'requiresMembershipReach', helpText: 'The organization endpoint this action calls, so it is offered only to members whose grade reaches that endpoint. It is lowered into the `visible` predicate at parse time and stripped from the output, so no downstream consumer ever sees the key.' }, // #19332 G1b — the RBAC gate, beside the feature gate. `string[]`, so the // `app.form.ts` `requiredPermissions` face (`string-tags`). - { field: 'requiredPermissions', widget: 'string-tags', helpText: 'Capabilities (permission-set systemPermissions) a caller must hold — every one listed — to invoke this action (ADR-0066 D4). The platform action route refuses anyone else with 403 (script, flow and modal actions, and the MCP/AI path), and the button is hidden from them. A type api action calls its endpoint directly, so that endpoint must re-check them.' }, + { field: 'requiredPermissions', widget: 'string-tags', helpText: 'Capabilities (permission-set systemPermissions) a caller must hold — every one listed — to invoke this action (ADR-0066 D4). The platform action door refuses anyone else with 403 on every call it dispatches, the MCP/AI path included, and the button is hidden from them. A type api action\'s endpoint stays directly reachable, so it must still check these capabilities itself; for an api action with an off-origin target the platform checks nothing server-side.' }, // `shortcut` input removed with the key (#3896 close-out) — a form // input for an unenforced capability is the UI half of false compliance. ], diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 4da86ee5311..71931a098ea 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -1587,22 +1587,38 @@ const actionObject = () => strictObject({ /** * [ADR-0066 D4] System capabilities required to INVOKE this action — a - * dual-surface gate from ONE declaration: the PLATFORM ACTION ROUTE rejects + * dual-surface gate from ONE declaration: the PLATFORM ACTION DOOR rejects * the call with 403 when the caller's systemPermissions don't cover these (the * source of truth), and the objectui action surfaces hide the button using the * same requirement. Independent of `visible` (CEL): this is the RBAC * capability contract, mirroring `App.requiredPermissions`. * - * **Server enforcement covers the platform's own invocation paths only** — - * `POST /api/v1/actions//` (and the MCP/AI path), which is - * where `type: 'script' | 'flow' | 'modal'` actions land. A `type: 'api'` - * action whose `target` is a self-authored endpoint is called by the browser - * DIRECTLY: the platform never sees the request, so nothing checks this - * declaration server-side. Such an endpoint MUST re-check the capability - * itself (framework#3923) — treat the UI gate there as a courtesy, not a - * boundary. + * **Server enforcement is the platform action door** — + * `POST /api/v1/actions//` and the MCP `run_action` bridge, + * which dispatch `type: 'script'`, `type: 'flow'`, the declarative + * `operation: 'update'` write and — per ADR-0066 D4 as amended 2026-10-29 — + * 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 — not `//`, nor `/` and a backslash). For that api action + * the door runs this gate (beside the anonymous baseline and the ADR-0126 + * activation consult) and then forwards the call in-process to the declared + * `target` as the caller. The door gates the calls it dispatches, and only + * those: the `target` endpoint stays directly reachable, so it MUST still + * enforce this capability itself, the door's gate being the added check + * (defence in depth, not a replacement). A `type: 'api'` + * action whose `target` is NOT a same-origin platform path stays browser-only + * 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 + * is the ruling's step 2 (#27506). Until it lands the door still refuses + * `type: 'api'` (`isHeadlessInvokableAction` admits `script`, `flow` and the + * declarative update only) and the console fetches `target` directly. Once + * the server half (#27649) lands, card #27694 removes this paragraph's door + * clause; its console clause goes when step 3 (#27611) lands. */ - 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.'), + 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.'), /** Keyboard Shortcut */