diff --git a/.changeset/25580-flow-required-permissions.md b/.changeset/25580-flow-required-permissions.md new file mode 100644 index 00000000000..aec4d4165c0 --- /dev/null +++ b/.changeset/25580-flow-required-permissions.md @@ -0,0 +1,22 @@ +--- +'@objectstack/spec': minor +'@objectstack/runtime': minor +'@objectstack/lint': minor +'@objectstack/platform-objects': minor +--- + +feat(automation): a flow declares who may start it — `requiredPermissions` on the flow, checked at every start door + +A flow can now carry `requiredPermissions: ['capability', …]`, the same value shape as an action's `requiredPermissions` (ADR-0066 D4). Every door that starts a flow by name checks it in one shared check: the trigger route (`POST /automation/:name/trigger`), an action of `type: 'flow'` (REST `POST /actions/:object/:action` and the MCP `run_action` bridge), and a declared endpoint of `type: 'flow'`. A caller that is not the system principal must hold every named capability (granted through a permission set's `systemPermissions`), or the door answers `403 PERMISSION_DENIED` and nothing runs. + +- **The action door inherits the flow's list.** An action that starts a gated flow is gated by the flow even when the action declares nothing, so the app states it once. An action that declares its own list keeps its own gate, which answers first, so a caller at that door needs both lists. +- **A sub-flow is not a door.** A parent flow's `subflow` node starts its child through the engine and is not checked again; the parent's own start was. +- **The elevated self-triggered rule still answers first.** Holding the capabilities does not let a member start a `runAs: 'system'` flow of type `autolaunched`, `record_change` or `schedule`. +- **Studio's flow form** offers the key (a tag list beside `runAs`). +- **`os validate`** judges the key's values the way it judges an action's: a capability no built-in, `defineCapability` or `systemPermissions` grant provides is reported as `capability-reference-unknown` at `flows[i].requiredPermissions` (`os explain capability-reference-unknown`). + +This is how to say who may start an elevated (`runAs: 'system'`) `screen` or `api` flow. An app that gates such a flow with a `decision` node on `current_user.positions` can move that gate to the flow's `requiredPermissions`; the positions array is not an authorization input. A flow that declares no `requiredPermissions` starts exactly as before. + +Clause-②: yes (widening) — a flow may declare a new key, `requiredPermissions`; a flow that does not declare it is admitted at every door exactly as before. + +Metadata-protocol: added (`FlowSchema.requiredPermissions`) diff --git a/content/docs/api/declarative-endpoints.mdx b/content/docs/api/declarative-endpoints.mdx index c04765211db..9e673b65990 100644 --- a/content/docs/api/declarative-endpoints.mdx +++ b/content/docs/api/declarative-endpoints.mdx @@ -101,7 +101,7 @@ declaration to shadow a built-in one. Match → policy chain (`rateLimit` → `a | `type` | Delegates to | Request shape | |:---|:---|:---| | `object_operation` | the same `callData` binding that serves `/api/v1/data/{object}` | `find` reads its criteria from the query string; `get` / `update` / `delete` take the record id from `query.id`; `create` / `update` take the body. `create` answers `201`, the rest `200` | -| `flow` | the same automation pipeline as `POST /api/v1/automation/{name}/trigger` | the request body is the flow input. A refused or failed run answers a real status: `404` unknown flow, `409` `FLOW_DISABLED`, `422` `FLOW_NO_START_NODE`, `422` `FLOW_INPUT_SCHEMA_INVALID`, `400` `FLOW_FAILED`, and `403` `PERMISSION_DENIED`, nothing run, when the target is declared `runAs: 'system'` with a self-triggered type (`autolaunched`, `record_change`, `schedule`), exactly as the trigger door answers | +| `flow` | the same automation pipeline as `POST /api/v1/automation/{name}/trigger` | the request body is the flow input. A refused or failed run answers a real status: `404` unknown flow, `409` `FLOW_DISABLED`, `422` `FLOW_NO_START_NODE`, `422` `FLOW_INPUT_SCHEMA_INVALID`, `400` `FLOW_FAILED`, and `403` `PERMISSION_DENIED`, nothing run, when the target is declared `runAs: 'system'` with a self-triggered type (`autolaunched`, `record_change`, `schedule`) or declares `requiredPermissions` the caller does not hold, exactly as the trigger door answers | The frozen vocabulary has **no path-template syntax**, so an endpoint cannot express `/leads/{id}` — a record id travels as `?id=…`. A method that no declaration claims diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 045fec7b2a8..ec95565e2e2 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -74,6 +74,7 @@ const approvalFlow = { | `status` | `enum` | optional | `'draft'`, `'active'`, `'obsolete'`, `'invalid'` (defaults to `'draft'`) | | `type` | `FlowType` | ✅ | Flow trigger type (see below) | | `runAs` | `enum` | optional | `'system'` (elevated, bypasses RLS; after an approval decision its attribution user is the decider) or `'user'` (the default, RLS-respecting: the trigger user until a human decision, the decider after it, with the decider's permissions). A `'user'` run that resolved **no** trigger user has nothing to scope to, so its data operations are refused — declare `'system'` for schedule / time-relative / API triggers and for record-change flows fired by a write that carried no user | +| `requiredPermissions` | `string[]` | optional | Capabilities a caller must hold, every one listed, to START the flow. Checked at every door that starts a flow by name (the trigger route, a `type: 'flow'` action over REST and MCP `run_action`, a declared `type: 'flow'` endpoint): anyone else but the system principal is refused `403` `PERMISSION_DENIED` and nothing runs. An action that starts the flow inherits the list. A parent flow's `subflow` node is not a door and is not checked again. This is how to say who may start an elevated `screen` or `api` flow; `current_user.positions` is not an authorization input | | `variables` | `FlowVariable[]` | optional | Input/output variables | | `nodes` | `FlowNode[]` | ✅ | Flow nodes | | `edges` | `FlowEdge[]` | ✅ | Connections between nodes | @@ -2291,20 +2292,21 @@ curl -b cookies.txt -X POST \ | `POST /api/v1/automation/:name/runs/:runId/restore-suspension` | Re-arm a run stranded by a failed resume — and, for a nested run, every ancestor whose pause that strand's cascade consumed, deepest first, from whichever member you name. Same gate as `cancel`; body `{ reason? }`. `501 NOT_IMPLEMENTED` when the service does not declare the verb | **Failures answer real HTTP status codes.** A flow that does not run is not a -`200`. The route classifies six outcomes. Five of them it reads off the engine's -own verdict — never the message text; the `403` is the door's own admission +`200`. The route classifies seven outcomes. Five of them it reads off the engine's +own verdict — never the message text; the two `403`s are the door's own admission check, made before anything is dispatched: | Status | `error.code` | What happened | What to do | |:---|:---|:---|:---| | `404` | — | No flow by that name in this deployment | Check the name; nothing was dispatched | | `403` | `PERMISSION_DENIED` | The caller is not the system principal, and the flow is declared `runAs: 'system'` with a self-triggered `type` (`autolaunched`, `record_change`, `schedule`). The same answer at every door that starts a flow by name: this route, a `type: 'flow'` action (REST and MCP `run_action`) and a declared `type: 'flow'` endpoint | Let it run on its own trigger, or call it from a parent flow's `subflow` node. If members are meant to start it, declare it `type: 'screen'` (or `'api'` for a signed inbound hook) | +| `403` | `PERMISSION_DENIED` | The flow declares `requiredPermissions` and the caller, not the system principal, does not hold every capability it names. Checked after the row above, at the same doors; the message names the declared and the missing capabilities | Grant the capability through a permission set's `systemPermissions`, or start the flow as someone who holds it | | `409` | `FLOW_DISABLED` | The flow exists but is switched off | Enable it — the identical request then succeeds | | `422` | `FLOW_NO_START_NODE` | The stored definition has no `start` node | Fix the flow; retrying cannot help | | `422` | `FLOW_INPUT_SCHEMA_INVALID` | A node's `config` violates the `inputSchema` its own definition declares | Fix the flow; the refusal is non-retryable — even under `errorHandling.strategy: 'retry'` the engine refuses once and never enters the retry loop | | `400` | `FLOW_FAILED` | The flow **ran** and was rejected | Read `error.details.summary` for the failing node | -The first five never dispatched anything: no node executed, no record was +The first six never dispatched anything: no node executed, no record was written, and there is no run to look up. Only `400` describes a run. The `FLOW_INPUT_SCHEMA_INVALID` verdict is a pure function of the flow definition — re-running the guard cannot change the answer — so a `retry` strategy does not diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 103943007a3..8c901f14aa1 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -193,7 +193,7 @@ One of the largest consumers of the flag; | 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability` | | 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite`, `#refuseUngrantedActivationAuthoring` | | 55b | Walled environment-metadata refusal does not fire | metadata-core, rest, runtime | Get: under `isolated` / `group`, a write of the environment's metadata, of any type, at the `/meta` write doors (save, reset, publish, rollback) and `POST /meta/_migrate-stored` on both transports, at every `/packages` write door (create, the manifest `PATCH`, enable, disable, publish, `publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`, delete), at the `/automation` definition writes (create, update, delete, clone), and at the permission-set clone door. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3, D12 items 7–8, D11), because one organization's write would change what every organization resolves. The seeders, the package door and migrations write the environment's metadata this way. Under `single` the refusal is inert for every caller | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict`, `packages/rest/src/rest-server.ts#walledCatalogRefusal`, `packages/runtime/src/domains/packages.ts#refuseWalledCatalogPackageWrite`, `packages/runtime/src/domains/automation.ts#refuseWalledFlowDefinitionWrite` | -| 57 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A). The three doors ask one predicate, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#isRunStarterOrRunStateReader`, `#refuseUngrantedRunLifecycleWrite`, `packages/runtime/src/flow-start-admission.ts#refusesElevatedSelfTriggeredStart` | +| 57 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A) — and starting, through the same doors, a flow whose `requiredPermissions` the caller does not hold (ruling record 6377848497, letter A). The three doors ask one check, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#isRunStarterOrRunStateReader`, `#refuseUngrantedRunLifecycleWrite`, `packages/runtime/src/flow-start-admission.ts#flowStartRefusal` | | 58 | Audience-binding suggestion authority gate passes | plugin-security | Get: a system caller lists, accepts and dismisses suggestions without `manage_metadata` and, under a wall, without the platform-operator rung | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertDeploymentAuthority` | | 59 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6); the plugin itself no longer writes the table (templates resolve from the registry), so the v18 migration ceremony's promotion is the one remaining writer. Lose (`sys_webhook`): the row is not marked as an admin customization, and the declared-name guard does not run (a new row or a rename taking a declared webhook's name, 409) — the system writers are the platform's machinery (the legacy-secret sweep, the ownership ceremony, migrations) | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp`, `packages/plugins/plugin-webhooks/src/declared-webhooks.ts#bindDeclaredWebhookNameGuard` | | 60 | **Automation flow data nodes re-add the `owner_id` stamp** (the one place row 2's gap is compensated inline) | service-automation | Get: a flow-authored INSERT under system elevation still lands owned, when the run resolved a user. Fill-only — flow-authored values win | `packages/services/service-automation/src/runtime-identity.ts#stampSystemInsertOwner`, called from `packages/services/service-automation/src/builtin/crud-nodes.ts#registerCrudNodes` | diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 0975406e959..b39d884bd6f 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -121,6 +121,7 @@ const result = AutomationApiErrorCode.parse(data); | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to START this flow, the flow-level twin of an action's requiredPermissions. Checked at every door that starts a flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow): a caller that is not the system principal must hold every named capability, or the door answers 403 PERMISSION_DENIED and nothing runs. An action that starts this flow inherits the list even when it declares none; one that declares its own needs both. A parent flow's subflow node is not a door and is not checked again. Use this, not current_user.positions inside the flow, to say who may start an elevated (runAs: system) screen or api flow. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -245,6 +246,7 @@ const result = AutomationApiErrorCode.parse(data); | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to START this flow, the flow-level twin of an action's requiredPermissions. Checked at every door that starts a flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow): a caller that is not the system principal must hold every named capability, or the door answers 403 PERMISSION_DENIED and nothing runs. An action that starts this flow inherits the list even when it declares none; one that declares its own needs both. A parent flow's subflow node is not a door and is not checked again. Use this, not current_user.positions inside the flow, to say who may start an elevated (runAs: system) screen or api flow. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -773,6 +775,7 @@ const result = AutomationApiErrorCode.parse(data); | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to START this flow, the flow-level twin of an action's requiredPermissions. Checked at every door that starts a flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow): a caller that is not the system principal must hold every named capability, or the door answers 403 PERMISSION_DENIED and nothing runs. An action that starts this flow inherits the list even when it declares none; one that declares its own needs both. A parent flow's subflow node is not a door and is not checked again. Use this, not current_user.positions inside the flow, to say who may start an elevated (runAs: system) screen or api flow. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -1083,6 +1086,7 @@ const result = AutomationApiErrorCode.parse(data); | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to START this flow, the flow-level twin of an action's requiredPermissions. Checked at every door that starts a flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow): a caller that is not the system principal must hold every named capability, or the door answers 403 PERMISSION_DENIED and nothing runs. An action that starts this flow inherits the list even when it declares none; one that declares its own needs both. A parent flow's subflow node is not a door and is not checked again. Use this, not current_user.positions inside the flow, to say who may start an elevated (runAs: system) screen or api flow. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -1148,6 +1152,7 @@ const result = AutomationApiErrorCode.parse(data); | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to START this flow, the flow-level twin of an action's requiredPermissions. Checked at every door that starts a flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow): a caller that is not the system principal must hold every named capability, or the door answers 403 PERMISSION_DENIED and nothing runs. An action that starts this flow inherits the list even when it declares none; one that declares its own needs both. A parent flow's subflow node is not a door and is not checked again. Use this, not current_user.positions inside the flow, to say who may start an elevated (runAs: system) screen or api flow. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | diff --git a/content/docs/references/automation/flow.mdx b/content/docs/references/automation/flow.mdx index 7a28aafd6ff..13ddfba3c41 100644 --- a/content/docs/references/automation/flow.mdx +++ b/content/docs/references/automation/flow.mdx @@ -42,6 +42,7 @@ const result = FlowSchema.parse(data); | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to START this flow, the flow-level twin of an action's requiredPermissions. Checked at every door that starts a flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow): a caller that is not the system principal must hold every named capability, or the door answers 403 PERMISSION_DENIED and nothing runs. An action that starts this flow inherits the list even when it declares none; one that declares its own needs both. A parent flow's subflow node is not a door and is not checked again. Use this, not current_user.positions inside the flow, to say who may start an elevated (runAs: system) screen or api flow. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -263,6 +264,7 @@ const result = FlowSchema.parse(data); | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to START this flow, the flow-level twin of an action's requiredPermissions. Checked at every door that starts a flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow): a caller that is not the system principal must hold every named capability, or the door answers 403 PERMISSION_DENIED and nothing runs. An action that starts this flow inherits the list even when it declares none; one that declares its own needs both. A parent flow's subflow node is not a door and is not checked again. Use this, not current_user.positions inside the flow, to say who may start an elevated (runAs: system) screen or api flow. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | diff --git a/content/docs/ui/actions.mdx b/content/docs/ui/actions.mdx index 7bd958ae58a..8568909d2c8 100644 --- a/content/docs/ui/actions.mdx +++ b/content/docs/ui/actions.mdx @@ -407,9 +407,13 @@ is a spec proposal for a properly named key, not a values map under this one. - **`requiredPermissions: ['can_close_tickets']`** is a **dual-surface gate** (one declaration, two enforcement points): the server rejects unauthorized calls with 403, and the UI hides or disables the button for the same users. - Unset means no gate beyond object CRUD permissions — except that a `flow` - action whose target is an elevated self-triggered flow is refused to every - caller but the system principal either way (the `flow` row below). Referenced + Unset means no gate beyond object CRUD permissions — except at a `flow` + action, whose target flow is admitted by its own rules either way (the + `flow` row below): an elevated self-triggered target is refused to every + caller but the system principal, and a target that declares its own + `requiredPermissions` is refused to a caller who does not hold them. The + action inherits the flow's list; when both declare one, the caller needs + both. Referenced capabilities must exist — `os lint` checks that. - **`visible`** is a CEL predicate evaluated **fail-closed**: an expression that throws hides the action silently. Two rules save real debugging time: @@ -546,7 +550,7 @@ The endpoint dispatches on the **declared `type`**, exactly like the MCP | `type` | Over REST | |:---|:---| | `script` | Runs the registered handler / inline body. | -| `flow` | Runs `target` on the automation engine, with your identity forwarded (a `runAs: 'user'` flow enforces RLS as you). Dispatches the same flow as `POST /api/v1/automation/:target/trigger`, without having to know the flow name — **and answers the same way**: a run that ran and was rejected is **400** `FLOW_FAILED`, while a dispatch that never happened is separated out (**404** unknown flow / **409** `FLOW_DISABLED` / **422** `FLOW_NO_START_NODE` / **422** `FLOW_INPUT_SCHEMA_INVALID`). A target declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) is refused **403** `PERMISSION_DENIED` to every caller but the system principal, exactly as at the trigger door, and nothing runs; holding the action's `requiredPermissions` does not open it. See [Run a flow via API](/docs/automation/flows#run-a-flow-via-api) for the full table — it is one table, read by both doors. | +| `flow` | Runs `target` on the automation engine, with your identity forwarded (a `runAs: 'user'` flow enforces RLS as you). Dispatches the same flow as `POST /api/v1/automation/:target/trigger`, without having to know the flow name — **and answers the same way**: a run that ran and was rejected is **400** `FLOW_FAILED`, while a dispatch that never happened is separated out (**404** unknown flow / **409** `FLOW_DISABLED` / **422** `FLOW_NO_START_NODE` / **422** `FLOW_INPUT_SCHEMA_INVALID`). A target declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) is refused **403** `PERMISSION_DENIED` to every caller but the system principal, exactly as at the trigger door, and nothing runs; holding the action's `requiredPermissions` does not open it. A target that declares its own `requiredPermissions` is refused **403** `PERMISSION_DENIED` to a caller who does not hold every capability it names, even when the action declares none. See [Run a flow via API](/docs/automation/flows#run-a-flow-via-api) for the full table — it is one table, read by both doors. | | `api` | **400** — it dispatches on `target`; call that endpoint directly. | | `url` / `modal` / `form` | **400** — client-side navigation; there is nothing for the server to run. | diff --git a/docs/qa/platform-checklist/areas/automation.json b/docs/qa/platform-checklist/areas/automation.json index 1a5793f940e..dcc8cfa9e43 100644 --- a/docs/qa/platform-checklist/areas/automation.json +++ b/docs/qa/platform-checklist/areas/automation.json @@ -2333,7 +2333,7 @@ "ref": "packages/services/service-automation/src/flow-cel-current-user.test.ts + packages/services/service-automation/src/flow-cel-record-binding.test.ts + packages/runtime/src/action-flow-elevated-door.test.ts + packages/runtime/src/endpoint-flow-elevated-door.test.ts + packages/services/service-automation/src/builtin/decision-overlapping-edge-conditions.pin.test.ts" }, "source": [ - "packages/runtime/src/flow-start-admission.ts#refusesElevatedSelfTriggeredStart", + "packages/runtime/src/flow-start-admission.ts#flowStartRefusal", "packages/services/service-automation/src/engine.ts", "v18 changesets (.changeset/pre/, consumed into 18.0.0-next.*): 22310-elevated-flow-trigger-door, 22407-flow-door-elevated-start, 19939-flow-value-slot-run-user-refused" ], diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index adeccb4afd5..a6d3a1b91fd 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -3706,7 +3706,7 @@ const CAPABILITY_REFERENCE_UNKNOWN_EXPLANATION: RuleExplanation = { // The positions are held to the rule by its test, each fired. 'Judged at every position the key is declared: an object\'s `requiredPermissions` (the array ' + 'form, or each operation of the per-operation map), each field\'s, each object action\'s and ' + - 'each top-level action\'s, an app\'s and each of its nav entries (through `navigation`, ' + + 'each top-level action\'s, each flow\'s, an app\'s and each of its nav entries (through `navigation`, ' + '`areas`, `tabs`, `children` and `items`), a view\'s (its own, its `config`\'s, its default ' + '`list`\'s and each `listViews` entry\'s, on views and on objects) and a dashboard\'s.', 'A warning, not an error: one package\'s lint cannot see the capabilities other installed ' + diff --git a/packages/lint/src/validate-capability-references.test.ts b/packages/lint/src/validate-capability-references.test.ts index 6ec257ae7ae..10979ef6361 100644 --- a/packages/lint/src/validate-capability-references.test.ts +++ b/packages/lint/src/validate-capability-references.test.ts @@ -96,6 +96,25 @@ describe('validateCapabilityReferences (ADR-0066 ⑨)', () => { expect(findings.every((f) => f.severity === 'warning')).toBe(true); }); + // A flow's `requiredPermissions` is who may START it, checked at every start + // door with the action gate's arithmetic — so the same judgement here: a typo + // refuses every member at every door, silently. + it('warns on a flow requiredPermissions typo and passes a granted one', () => { + const findings = validateCapabilityReferences({ + permissions: [{ name: 'srm_ap', systemPermissions: ['srm_verify_bank_account'] }], + flows: [ + { name: 'srm_bank_verify', type: 'screen', runAs: 'system', requiredPermissions: ['srm_verify_bank_account'] }, + { name: 'srm_bank_reject', type: 'screen', runAs: 'system', requiredPermissions: ['srm_verfy_bank_account'] }, + { name: 'srm_open', type: 'screen' }, + ], + }); + expect(findings.map((f) => [f.where, f.path])).toEqual([ + ['flow "srm_bank_reject"', 'flows[1].requiredPermissions'], + ]); + expect(findings[0]).toMatchObject({ severity: 'warning', rule: CAPABILITY_REFERENCE_UNKNOWN }); + expect(findings[0].message).toContain('srm_verfy_bank_account'); + }); + // [#22639] The `/meta` read gate serves a list view or a dashboard only to a // caller holding EVERY capability its `requiredPermissions` names, so a typo // there hides it from everyone. Every door the key is declared at resolves. @@ -203,6 +222,7 @@ describe('[#22161] one-line verdicts — capability-reference-unknown', () => { 'per-operation map', '`navigation`, `areas`, `tabs`, `children` and `items`', 'each `listViews` entry', + 'each flow\'s', 'a dashboard', 'other installed packages', ]) { diff --git a/packages/lint/src/validate-capability-references.ts b/packages/lint/src/validate-capability-references.ts index b83e681f474..fda7ef427e9 100644 --- a/packages/lint/src/validate-capability-references.ts +++ b/packages/lint/src/validate-capability-references.ts @@ -3,8 +3,8 @@ /** * [ADR-0066 ⑨] Authoring-time validation for capability references. * - * `requiredPermissions` (on objects, fields, apps, actions, list views and - * dashboards) and `systemPermissions` (on permission sets) are free capability + * `requiredPermissions` (on objects, fields, apps, actions, flows, list views + * and dashboards) and `systemPermissions` (on permission sets) are free capability * strings. A typo * — `mange_users` for `manage_users` — is Zod-valid and fails CLOSED at runtime * (the caller is denied), which is the safe direction but UNDISCOVERABLE: nothing @@ -177,6 +177,16 @@ export function validateCapabilityReferences(stack: AnyRec): CapabilityRefFindin } } + // ── Flows: who may START the flow (ruling record 6377848497, letter A) — + // the flow-level twin of an action's list, so the same judgement. A + // misspelled capability here refuses every member at every start door. ── + for (const [i, flow] of recordsOf(stack.flows).entries()) { + const fName = typeof flow.name === 'string' ? flow.name : `(flow ${i})`; + for (const cap of asCapArray(flow.requiredPermissions)) { + flag(cap, `flow "${fName}"`, `flows[${i}].requiredPermissions`); + } + } + // ── Apps: requiredPermissions can appear at the app and nav-item // (recursively through groups) levels. Walk each app subtree. `areas` is // still traversed, but only to REACH the nav items nested inside it: the diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index 43d1781f7d7..d1c3b130754 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -323,10 +323,12 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no * `dashboard` moved 21 → 22 the same way when it gained * `requiredPermissions` (#22611, the audience gate). `position` moved * 12 → 13 the same way when it gained `permissionSets` (ADR-0131 D3/D4, - * the sets a position distributes). + * the sets a position distributes). `flow` moved 23 → 24 the same way + * when it gained `requiredPermissions` (who may start the flow, checked at + * every start door). */ const CARD_PROPERTY_COUNTS: Record = { - agent: 26, app: 30, dashboard: 22, dataset: 16, field: 80, flow: 23, + agent: 26, app: 30, dashboard: 22, dataset: 16, field: 80, flow: 24, hook: 22, object: 47, page: 26, position: 13, report: 21, skill: 17, tool: 14, }; 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 8f36470a4ad..3a1aea12bda 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 @@ -2195,6 +2195,10 @@ export const enMetadataForms: NonNullable = { label: "Run As", helpText: "Execute as system (admin) or user (current user permissions)" }, + requiredPermissions: { + label: "Required Permissions", + helpText: "Capabilities (permission-set systemPermissions) a caller must hold — every one listed — to start this flow. Every start door (the trigger route, a flow action over REST and MCP, a flow endpoint) refuses anyone else with 403, and an action that starts this flow inherits the list. A parent flow's subflow step is not checked again." + }, errorHandling: { label: "Error Handling", helpText: "What to do when a node fails (fail, retry, continue)" 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 b7065bdde9f..80fca2de789 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 @@ -2195,6 +2195,10 @@ export const esESMetadataForms: NonNullable = label: "Ejecutar como", helpText: "Ejecutar como system (admin) o user (permisos del usuario actual)" }, + requiredPermissions: { + label: "Permisos requeridos", + helpText: "Capacidades (systemPermissions de conjuntos de permisos) que quien llama debe tener —todas las enumeradas— para iniciar este flujo. Cada puerta de inicio (la ruta de disparo, una acción de flujo por REST y MCP, un endpoint de flujo) rechaza a cualquier otro con 403, y una acción que inicia este flujo hereda la lista. El paso de subflujo de un flujo padre no se vuelve a comprobar." + }, errorHandling: { label: "Manejo de errores", helpText: "Qué hacer cuando falla un nodo (fail, retry, continue)" 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 c6141147be8..52ae3927fcd 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 @@ -2195,6 +2195,10 @@ export const jaJPMetadataForms: NonNullable = label: "実行主体", helpText: "system(admin)または user(現在のユーザー権限)として実行" }, + requiredPermissions: { + label: "必要な権限", + helpText: "このフローを開始するために呼び出し元が保持すべき機能(権限セットの systemPermissions)。列挙したすべてが必要です。すべての開始口(トリガールート、REST と MCP 経由のフローアクション、フローエンドポイント)はそれ以外の呼び出し元を 403 で拒否し、このフローを開始するアクションはこのリストを継承します。親フローのサブフローステップでは再チェックしません。" + }, errorHandling: { label: "エラー処理", helpText: "ノード失敗時の処理(fail, retry, continue)" diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 0dfe36cfa70..74d267a263f 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1215,7 +1215,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 678 since #25330: the dataset form's dimensions repeater offers // `nullLabel` once its liveness verdict went live — one row label, // authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(678); + // 679 since the flow form offers `requiredPermissions` (who may start the + // flow) — one row label, authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(679); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the 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 f125a76cf7a..80387ad938f 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 @@ -2195,6 +2195,10 @@ export const zhCNMetadataForms: NonNullable = label: "运行身份", helpText: "以系统(管理员)或当前用户权限执行" }, + requiredPermissions: { + label: "所需权限", + helpText: "调用方启动本流程必须持有的能力(权限集的 systemPermissions),列出的每一项都要具备。每个启动入口(触发路由、经 REST 与 MCP 的流程动作、流程端点)对其他调用方一律返回 403;启动本流程的动作继承这份列表。父流程的子流程步骤不再重复检查。" + }, errorHandling: { label: "错误处理", helpText: "节点失败时的处理方式(fail / retry / continue)" diff --git a/packages/rest/src/import-prepare.ts b/packages/rest/src/import-prepare.ts index c170dc38857..63a33431bb1 100644 --- a/packages/rest/src/import-prepare.ts +++ b/packages/rest/src/import-prepare.ts @@ -379,7 +379,7 @@ export interface ImportControlOptOutRefusal { * caller the platform already lets past those controls: the system principal, * `ExecutionContext.isSystem === true` — the predicate every door that starts * an elevated self-triggered flow asks - * (`refusesElevatedSelfTriggeredStart`, `@objectstack/runtime`), and the only + * (`flowStartRefusal`, `@objectstack/runtime`), and the only * exemption the create-side read-only strip and the approval record lock * admit. ⛔ No permission, capability or administrator standing is read here: * none of them lets a caller past those controls anywhere else either (an diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index e4eebd83e05..ea61abd650e 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -50,8 +50,12 @@ import { type FlowRefusalCode, } from './flow-dispatch-status.js'; // Who may START a flow by name — the one rule the trigger door, this door and -// the declared endpoint share (the maintainer's ruling, letters B and A). -import { ELEVATED_START_REFUSAL, refusesElevatedSelfTriggeredStart } from './flow-start-admission.js'; +// the declared endpoint share (the maintainer's rulings, letters B and A, and +// the flow's own `requiredPermissions`). +import { flowStartRefusal } from './flow-start-admission.js'; +// [ADR-0066 D4] The capability arithmetic the action gate and the flow start +// gate share. +import { missingCapabilities } from './required-capabilities.js'; import type { FlowFailureDetails } from '@objectstack/spec/api'; // [#5138] The ONE 404 envelope a single-record path answers. Imported rather // than re-spelled so `callData`'s ObjectQL fallback and the protocol service it @@ -828,8 +832,7 @@ export function actionPermissionError(_deps: ActionExecutionDeps, actionDef: any : []; if (required.length === 0) return null; if (ec?.isSystem) return null; - const held = new Set(ec?.systemPermissions ?? []); - const missing = required.filter((perm) => !held.has(perm)); + const missing = missingCapabilities(required, ec); if (missing.length === 0) return null; const on = objectName ? ` on '${objectName}'` : ''; return ( @@ -1386,10 +1389,17 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps, // `code` + `status` so the ADR-0112 envelope survives both entrances, as // the 409 / 422 rows below do; the words are the trigger door's own // (`flow-start-admission.ts`). - if (await refusesElevatedSelfTriggeredStart(automation, action.target, ec)) { - throw Object.assign(new Error(ELEVATED_START_REFUSAL.message), { - status: ELEVATED_START_REFUSAL.status, - code: ELEVATED_START_REFUSAL.code, + // + // [ruling record 6377848497, letter A] The same check then asks the + // target flow's own `requiredPermissions`, so this door INHERITS the + // flow's declaration: an action that names a gated flow is gated by it + // even when the action declares nothing. An action that declares its own + // list has been answered upstream, so a caller here needs both lists. + const startRefusal = await flowStartRefusal(automation, action.target, ec); + if (startRefusal) { + throw Object.assign(new Error(startRefusal.message), { + status: startRefusal.status, + code: startRefusal.code, }); } // Pass a proper AutomationContext (the engine never read the former diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 63a9318bb64..19de7049992 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -56,7 +56,7 @@ import { } from '../flow-dispatch-status.js'; // Who may START a flow by name — the one rule the trigger door, the action door // and the declared endpoint share (the maintainer's ruling, letters B and A). -import { ELEVATED_START_REFUSAL, refusesElevatedSelfTriggeredStart } from '../flow-start-admission.js'; +import { flowStartRefusal } from '../flow-start-admission.js'; // [#12156] ADR-0126 §7.1 clone — the whole-definition copy, the keys it must // not carry forward, the same-name refusal and the references notice. import { @@ -2019,12 +2019,14 @@ async function respondToFlowTrigger( // and BEFORE dispatch, so a refused start runs nothing — no run record, no // node, no side effect. The ONE rule every door that starts a flow by name // asks (`../flow-start-admission.ts`): the action door and the declared - // endpoint answer the same refusal for the same declaration. - if (await refusesElevatedSelfTriggeredStart(automationService, flowName, context?.executionContext)) { + // endpoint answer the same refusal for the same declaration — the elevated + // self-triggered rule, then the flow's own `requiredPermissions`. + const startRefusal = await flowStartRefusal(automationService, flowName, context?.executionContext); + if (startRefusal) { return { handled: true, - response: deps.error(ELEVATED_START_REFUSAL.message, ELEVATED_START_REFUSAL.status, { - code: ELEVATED_START_REFUSAL.code, + response: deps.error(startRefusal.message, startRefusal.status, { + code: startRefusal.code, }), }; } @@ -2428,9 +2430,14 @@ export async function classifyResumeResult( * type (`autolaunched`, `record_change`, * `schedule`) — the system principal only; * anyone else → 403 `PERMISSION_DENIED`, never - * dispatched (`refusesElevatedSelfTriggeredStart`, + * dispatched (`flowStartRefusal`, * `../flow-start-admission.ts`, which the action * door and the declared endpoint ask too) + * ⚑ a flow declaring `requiredPermissions` — a + * caller holding every named capability (or the + * system principal) only; anyone else → 403 + * `PERMISSION_DENIED`, never dispatched (the + * same check) * POST /:name/toggle → toggleFlow (unknown name → 404, #7535). Switches * PACKAGED flows only — it writes the ADR-0126 §7.2 * activation ledger. A flow no package ships → 409 diff --git a/packages/runtime/src/endpoint-executor.ts b/packages/runtime/src/endpoint-executor.ts index 2e203eb14bd..f7a5ba2133f 100644 --- a/packages/runtime/src/endpoint-executor.ts +++ b/packages/runtime/src/endpoint-executor.ts @@ -64,7 +64,7 @@ import { } from './flow-dispatch-status.js'; // Who may START a flow by name — the one rule the trigger door, the action door // and this door share (the maintainer's ruling, letters B and A). -import { ELEVATED_START_REFUSAL, refusesElevatedSelfTriggeredStart } from './flow-start-admission.js'; +import { flowStartRefusal } from './flow-start-admission.js'; import type { HttpProtocolContext } from './http-dispatcher.js'; // ============================================================================ @@ -563,11 +563,16 @@ async function executeFlow( // not the system one. The same code, status and words the trigger door // answers (`flow-start-admission.ts`), so #5040 §4's "same operation, same // answer" holds for this refusal too. - if (await refusesElevatedSelfTriggeredStart(service, plan.flow, ctx.executionContext)) { + // + // [ruling record 6377848497, letter A] The same check then asks the flow's + // own `requiredPermissions`: an endpoint is a door that starts a flow by + // name, so the flow's declaration binds here as at the other doors. + const startRefusal = await flowStartRefusal(service, plan.flow, ctx.executionContext); + if (startRefusal) { return apiErrorResponse({ - message: ELEVATED_START_REFUSAL.message, - httpStatus: ELEVATED_START_REFUSAL.status, - code: ELEVATED_START_REFUSAL.code, + message: startRefusal.message, + httpStatus: startRefusal.status, + code: startRefusal.code, }); } diff --git a/packages/runtime/src/flow-start-admission.ts b/packages/runtime/src/flow-start-admission.ts index d0f69698501..9f81b3b1570 100644 --- a/packages/runtime/src/flow-start-admission.ts +++ b/packages/runtime/src/flow-start-admission.ts @@ -14,30 +14,46 @@ * (`action-execution.ts`); * - a declared endpoint of `type: 'flow'` (`endpoint-executor.ts`). * - * The rule (the maintainer's ruling, letter B at the trigger door, extended to - * every door that starts a flow by name by letter A): a caller that is not the - * system principal may not start a flow declared `runAs: 'system'` whose - * `type` is self-triggered — `autolaunched`, `record_change` or `schedule`. - * Those types run on their own trigger, or as a sub-flow from a parent flow's - * `subflow` node, and are never an entry, so a button, an action or an - * endpoint that happens to name one is wiring, not a design. `screen` and - * `api` flows stay the doors their author designed, elevated or not. + * Two rules, asked in this order through ONE entry, {@link flowStartRefusal}: + * + * 1. **The elevated self-triggered rule** (the maintainer's ruling, letter B at + * the trigger door, extended to every door that starts a flow by name by + * letter A): a caller that is not the system principal may not start a flow + * declared `runAs: 'system'` whose `type` is self-triggered — + * `autolaunched`, `record_change` or `schedule`. Those types run on their + * own trigger, or as a sub-flow from a parent flow's `subflow` node, and are + * never an entry, so a button, an action or an endpoint that happens to name + * one is wiring, not a design. No capability opens it, which is why it + * answers first. + * 2. **The flow's own `requiredPermissions`** (ruling record 6377848497, + * letter A): `screen` and `api` flows stay the doors their author designed, + * elevated or not, and the flow says who may walk through them. A caller + * that is not the system principal must hold EVERY capability the flow + * names — the action gate's arithmetic (ADR-0066 D4, + * `required-capabilities.ts`), so an action and a flow declaring the same + * list admit the same callers. Because the action door asks this too, an + * action that starts the flow inherits the flow's declaration: the app + * states it once, on the thing it protects, and an action that forgets to + * repeat it is no hole. An action that declares its OWN list keeps its own + * gate, which answers first (`actionPermissionError`), so a caller at that + * door needs both lists. * * ## Why a module, and why every door asks it * * The same reason `flow-dispatch-status.ts` is one: a rule each door spells * for itself is a rule that drifts, and a door left out is the defect, not a - * gap. The type set, the refusal's code and status, and its words live here - * once, so a caller refused at one door is refused alike at the other two — + * gap. The type set, the capability check, the refusals' code and status, and + * their words live here once, so a caller refused at one door is refused alike + * at the other two — * an MCP agent and a person are judged at the same door by the same rule. * ⛔ A second copy of the type set or the refusal, at any door, is the defect * by construction. * * ## What the predicate does NOT decide * - * Admission only. Elevation stays the engine's: the predicate reads two - * declared keys off the definition the service serves and never re-implements - * the engine's run-as policy. A door's own gates are untouched and keep their + * Admission only. Elevation stays the engine's: the check reads three + * declared keys (`type`, `runAs`, `requiredPermissions`) off the definition the + * service serves and never re-implements the engine's run-as policy. A door's own gates are untouched and keep their * place: the action's declared `requiredPermissions` (ADR-0066 D4) still * answers first at the action door, and a declared endpoint's policy chain * (`authRequired`, `rateLimit`) still runs before its executor. This check @@ -47,6 +63,8 @@ import type { FlowParsed } from '@objectstack/spec/automation'; +import { missingCapabilities } from './required-capabilities.js'; + /** * The three flow types that start on their OWN trigger — or as a sub-flow * called from a parent flow's `subflow` node — and never as an entry: an @@ -82,22 +100,60 @@ export const ELEVATED_START_REFUSAL = Object.freeze({ } as const); /** - * Whether a door refuses this caller starting `flowName`: the caller is not - * the system principal, AND the flow is declared `runAs: 'system'`, AND its - * `type` is one of {@link SELF_TRIGGERED_FLOW_TYPES}. + * The refusal a door answers when the caller lacks a capability the flow's + * `requiredPermissions` names — the same code and status as + * {@link ELEVATED_START_REFUSAL} and as the action gate's 403 (ADR-0112). ⛔ No + * new code is minted here. * - * What stays exactly as it was, and why each is outside this predicate: + * The message names the declared and the missing capabilities, as the action + * gate's does — the remedy is a grant, and a caller cannot ask for one it + * cannot name — and nothing else about the flow: not its name, its type or its + * run-as declaration. + */ +export function requiredPermissionsRefusal(required: readonly string[], missing: readonly string[]): FlowStartRefusal { + return { + code: 'PERMISSION_DENIED', + status: 403, + message: + `Starting this flow requires capability [${required.join(', ')}] — ` + + `caller is missing [${missing.join(', ')}]`, + }; +} + +/** The ADR-0112 refusal a door answers for a start it does not admit. */ +export interface FlowStartRefusal { + code: 'PERMISSION_DENIED'; + status: 403; + message: string; +} + +/** + * Whether a flow definition is elevated AND self-triggered — rule 1's subject, + * judged on the declaration alone (the caller is {@link flowStartRefusal}'s). + */ +export function isElevatedSelfTriggered(flow: { type?: FlowParsed['type']; runAs?: unknown }): boolean { + return flow.runAs === 'system' && flow.type !== undefined && SELF_TRIGGERED_FLOW_TYPES.has(flow.type); +} + +/** + * THE CHECK every door that starts a flow by name asks: the refusal it + * answers for this caller starting `flowName`, or `null` when the start is + * admitted. Rule 1 ({@link isElevatedSelfTriggered}) answers first, then rule 2 + * (the flow's `requiredPermissions`). + * + * What stays exactly as it was, and why each is outside this check: * * - **The system principal** (`executionContext.isSystem`, never set on - * inbound HTTP; an in-process caller such as a job) still starts every flow. - * The guest principal an `authRequired: false` endpoint admits carries - * `isSystem: false` and is refused like any other caller. + * inbound HTTP; an in-process caller such as a job) still starts every flow, + * whatever it declares — the same bypass the action gate grants. The guest + * principal an `authRequired: false` endpoint admits carries + * `isSystem: false` and is judged like any other caller. * - **A parent flow's `subflow` node** starts its child through the engine - * (`engine.execute`), never through a door, so an elevated sub-flow called - * from its parent is untouched — the check lives at the doors precisely so - * that path stays open. - * - **`screen` and `api` flows**, elevated or not, and every flow that does - * not declare `runAs: 'system'`. + * (`engine.execute`), never through a door, so a sub-flow called from its + * parent is untouched by both rules — the check lives at the doors + * precisely so that path stays open. The parent's own start was the door. + * - **A flow that declares no `requiredPermissions`** (or an empty list) and is + * not elevated-and-self-triggered: admitted, as before. * * Reads the flow through the automation service's own `getFlow` — the probe * `flowIsUnknown` (`flow-dispatch-status.ts`) uses, which serves the same @@ -106,15 +162,21 @@ export const ELEVATED_START_REFUSAL = Object.freeze({ * asked, and the door dispatches as before, exactly as the existence check * does. */ -export async function refusesElevatedSelfTriggeredStart( +export async function flowStartRefusal( automation: unknown, flowName: string, - executionContext: { isSystem?: boolean } | null | undefined, -): Promise { - if (executionContext?.isSystem === true) return false; + executionContext: { isSystem?: boolean; systemPermissions?: readonly string[] } | null | undefined, +): Promise { + if (executionContext?.isSystem === true) return null; const svc = automation as { getFlow?: (name: string) => Promise } | null | undefined; - if (typeof svc?.getFlow !== 'function') return false; - const flow = (await svc.getFlow(flowName)) as { type?: FlowParsed['type']; runAs?: unknown } | null | undefined; - if (!flow) return false; - return flow.runAs === 'system' && flow.type !== undefined && SELF_TRIGGERED_FLOW_TYPES.has(flow.type); + if (typeof svc?.getFlow !== 'function') return null; + const flow = (await svc.getFlow(flowName)) as + | { type?: FlowParsed['type']; runAs?: unknown; requiredPermissions?: unknown } + | null + | undefined; + if (!flow) return null; + if (isElevatedSelfTriggered(flow)) return ELEVATED_START_REFUSAL; + const missing = missingCapabilities(flow.requiredPermissions, executionContext); + if (missing.length > 0) return requiredPermissionsRefusal(flow.requiredPermissions as string[], missing); + return null; } diff --git a/packages/runtime/src/flow-start-required-permissions.test.ts b/packages/runtime/src/flow-start-required-permissions.test.ts new file mode 100644 index 00000000000..5b93eefded1 --- /dev/null +++ b/packages/runtime/src/flow-start-required-permissions.test.ts @@ -0,0 +1,291 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A flow's own `requiredPermissions` × every door that starts a flow by name + * (ruling record 6377848497, letter A): the trigger route, an action of + * `type: 'flow'` through both of its entrances (REST `POST /actions/:object/:action` + * and the MCP `run_action` bridge), and a declared endpoint of `type: 'flow'`. + * Each door asks the one check, `flowStartRefusal` (`flow-start-admission.ts`), + * so a caller refused at one door is refused alike at every other. + * + * Driven with a scripted automation service, as the elevated-door pins are + * (`action-flow-elevated-door.test.ts`, `endpoint-flow-elevated-door.test.ts`): + * `getFlow` serves the declaration the door reads, and `execute` is a spy, so + * "never dispatched" is a fact about the engine call rather than an inference + * from a status code. That a parent flow's `subflow` node is NOT a door, and + * starts a gated child for a member who holds nothing, is pinned on the real + * engine: `service-automation`'s `subflow-required-permissions.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { resolveThrownHttpError } from '@objectstack/types'; +import { ApiEndpointSchema } from '@objectstack/spec/api'; +import type { ApiEndpointMatch } from '@objectstack/spec/contracts'; + +import { HttpDispatcher } from './http-dispatcher.js'; +import { callData, invokeBusinessAction } from './action-execution.js'; +import { buildEndpointExecutionContext, executeEndpointTarget } from './endpoint-executor.js'; +import { ELEVATED_START_REFUSAL } from './flow-start-admission.js'; + +const OBJECT = 'frp_vendor'; +const FLOW = 'frp_bank_verify'; +const ACTION = 'verify_bank'; +const CAP = 'frp_verify_bank_account'; +const ACTION_CAP = 'frp_run_actions'; + +/** A signed-in, non-system caller holding no capability. */ +const MEMBER_EC = { userId: 'user_1', systemPermissions: [] as string[] }; +const HOLDER_EC = { userId: 'user_2', systemPermissions: [CAP] }; +/** The system principal: the in-process caller a job or an internal dispatch is. */ +const SYSTEM_EC = { isSystem: true }; + +interface Declared { + type: string; + runAs?: string; + requiredPermissions?: string[]; +} + +interface Answer { status: number; code?: string; message?: string } + +/** One automation service serving `flow` under {@link FLOW}, and every door over it. */ +function makeDoors(flow: Declared, opts: { actionRequiredPermissions?: string[] } = {}) { + const execute = vi.fn(async (_name: string, _context?: unknown) => ({ success: true, output: {} })); + const getFlow = vi.fn(async (name: string) => (name === FLOW ? { name, ...flow } : null)); + const automation: Record = { execute, getFlow }; + + const action = { + name: ACTION, + label: 'Verify bank account', + objectName: OBJECT, + type: 'flow', + target: FLOW, + ai: { exposed: true, description: 'Starts the bank-account verification flow for this test.' }, + ...(opts.actionRequiredPermissions ? { requiredPermissions: opts.actionRequiredPermissions } : {}), + }; + const objectDef = { name: OBJECT, actions: [action] }; + const schemaOf = (n: string) => (n === OBJECT ? objectDef : undefined); + const ql: any = { + executeAction: vi.fn(async () => ({ ran: 'script' })), + getSchema: schemaOf, + registry: { getObject: schemaOf, getItem: () => undefined }, + find: vi.fn(async () => []), + insert: vi.fn(), update: vi.fn(), delete: vi.fn(), + }; + const metadata: any = { + load: vi.fn(async () => null), + loadDiagnosed: vi.fn(async () => ({ data: null, degraded: false, errors: [] })), + listObjects: vi.fn(async () => [objectDef]), + getObject: vi.fn(async (n: string) => schemaOf(n)), + }; + const resolve = (n: string) => + n === 'objectql' || n === 'data' ? ql : n === 'metadata' ? metadata : n === 'automation' ? automation : null; + const kernel: any = { getService: resolve, getServiceAsync: async (n: string) => resolve(n), context: { getService: resolve } }; + const dispatcher = new HttpDispatcher(kernel); + + const trigger = async (ec: unknown): Promise => { + const res: any = await dispatcher.handleAutomation(`/${FLOW}/trigger`, 'POST', {}, { + request: {}, executionContext: ec, + } as any); + const error = res.response?.body?.error; + return { status: res.response?.status, code: error?.code, message: error?.message }; + }; + + const rest = async (ec: unknown): Promise => { + const res: any = await dispatcher.handleActions(`/${OBJECT}/${ACTION}`, 'POST', {}, { + request: {}, environmentId: 'platform', executionContext: ec, + } as any); + const error = res.response?.body?.error; + return { status: res.response?.status, code: error?.code, message: error?.message }; + }; + + const mcp = async (ec: unknown): Promise => { + const request: any = { request: {}, environmentId: 'platform', executionContext: ec }; + const deps: any = { + resolveService: async (_ctx: any, name: string) => (name === 'automation' ? automation : undefined), + getObjectQL: async () => ql, + }; + try { + await invokeBusinessAction(deps, request, ACTION, { objectName: OBJECT } as any, { + driver: undefined, + envId: 'platform', + ec, + getMeta: () => ({ listObjects: async () => [objectDef] }), + callData: (a, params, dataDriver, scopeId, execCtx) => + callData(deps, request, a, params, dataDriver, scopeId, execCtx), + }); + return { status: 200 }; + } catch (err) { + const thrown = resolveThrownHttpError(err, 500); + return { status: thrown.status, code: thrown.code, message: thrown.message }; + } + }; + + const endpoint = async (ec: unknown): Promise => { + const match: ApiEndpointMatch = { + endpoint: ApiEndpointSchema.parse({ + name: 'frp_start', path: '/api/v1/apps/frp/start', method: 'POST', type: 'flow', target: FLOW, authRequired: true, + }), + params: {}, + }; + const ctx = buildEndpointExecutionContext({ + request: { method: 'POST', path: '/api/v1/apps/frp/start', query: {}, headers: {}, body: {} }, + match, + executionContext: ec as any, + environmentId: 'env-1', + }); + const answer = await executeEndpointTarget(ctx, { callData: vi.fn().mockResolvedValue({ ok: true }), automationService: automation }); + const error = (answer.body as any)?.error; + return { status: answer.status, code: error?.code, message: error?.message }; + }; + + return { doors: { trigger, 'REST /actions': rest, 'MCP run_action': mcp, endpoint }, execute, getFlow }; +} + +const DOORS = ['trigger', 'REST /actions', 'MCP run_action', 'endpoint'] as const; +const ACTION_ENTRANCES = ['REST /actions', 'MCP run_action'] as const; +const ENTRY_TYPES = ['screen', 'api'] as const; + +describe("a flow's requiredPermissions is checked at every door that starts it by name", () => { + for (const door of DOORS) { + for (const type of ENTRY_TYPES) { + it(`${door}: an elevated '${type}' flow — a member without the capability → 403 PERMISSION_DENIED, never dispatched`, async () => { + const { doors, execute } = makeDoors({ type, runAs: 'system', requiredPermissions: [CAP] }); + + const answer = await doors[door](MEMBER_EC); + + expect(answer.status).toBe(403); + expect(answer.code).toBe('PERMISSION_DENIED'); + // The remedy is a grant, so the refusal names the capability — + // and nothing of the flow itself. + expect(answer.message).toContain(CAP); + expect(answer.message).not.toContain(FLOW); + expect(execute).not.toHaveBeenCalled(); + }); + + it(`${door}: an elevated '${type}' flow — a member holding the capability starts it`, async () => { + const { doors, execute } = makeDoors({ type, runAs: 'system', requiredPermissions: [CAP] }); + + const answer = await doors[door](HOLDER_EC); + + expect(answer.status).toBe(200); + expect(execute).toHaveBeenCalledTimes(1); + expect(execute.mock.calls[0]?.[0]).toBe(FLOW); + }); + } + + it(`${door}: the declaration binds a flow that runs as the user too — it gates the start, not the elevation`, async () => { + const { doors, execute } = makeDoors({ type: 'screen', runAs: 'user', requiredPermissions: [CAP] }); + + expect((await doors[door](MEMBER_EC)).status).toBe(403); + expect(execute).not.toHaveBeenCalled(); + expect((await doors[door](HOLDER_EC)).status).toBe(200); + expect(execute).toHaveBeenCalledTimes(1); + }); + + it(`${door}: EVERY named capability is required — holding one of two is refused`, async () => { + const { doors, execute } = makeDoors({ type: 'screen', runAs: 'system', requiredPermissions: [CAP, 'frp_second'] }); + + const answer = await doors[door](HOLDER_EC); + + expect(answer.status).toBe(403); + expect(answer.code).toBe('PERMISSION_DENIED'); + expect(answer.message).toContain('frp_second'); + expect(execute).not.toHaveBeenCalled(); + }); + + it(`${door}: the system principal starts a gated flow — the action gate's bypass`, async () => { + const { doors, execute } = makeDoors({ type: 'screen', runAs: 'system', requiredPermissions: [CAP] }); + + expect((await doors[door](SYSTEM_EC)).status).toBe(200); + expect(execute).toHaveBeenCalledTimes(1); + }); + + it(`${door}: a flow declaring none (absent or empty) admits a member, as before`, async () => { + const absent = makeDoors({ type: 'screen', runAs: 'system' }); + const empty = makeDoors({ type: 'screen', runAs: 'system', requiredPermissions: [] }); + + expect((await absent.doors[door](MEMBER_EC)).status).toBe(200); + expect((await empty.doors[door](MEMBER_EC)).status).toBe(200); + expect(absent.execute).toHaveBeenCalledTimes(1); + expect(empty.execute).toHaveBeenCalledTimes(1); + }); + + it(`${door}: holding the capability does not open an elevated self-triggered flow — that rule answers first`, async () => { + const { doors, execute } = makeDoors({ type: 'autolaunched', runAs: 'system', requiredPermissions: [CAP] }); + + const answer = await doors[door](HOLDER_EC); + + expect(answer).toMatchObject({ status: ELEVATED_START_REFUSAL.status, code: ELEVATED_START_REFUSAL.code }); + expect(answer.message).toBe(ELEVATED_START_REFUSAL.message); + expect(execute).not.toHaveBeenCalled(); + }); + } + + it('one refusal at every door: the same status, code and words for the same declaration and caller', async () => { + const answers: Answer[] = []; + for (const door of DOORS) { + const { doors } = makeDoors({ type: 'screen', runAs: 'system', requiredPermissions: [CAP] }); + answers.push(await doors[door](MEMBER_EC)); + } + expect(answers[0]).toMatchObject({ status: 403, code: 'PERMISSION_DENIED' }); + for (const answer of answers.slice(1)) expect(answer).toEqual(answers[0]); + }); +}); + +describe("the action door inherits the flow's declaration", () => { + for (const entrance of ACTION_ENTRANCES) { + it(`${entrance}: an action that declares NO requiredPermissions is gated by its target flow's`, async () => { + const { doors, execute } = makeDoors({ type: 'screen', runAs: 'system', requiredPermissions: [CAP] }); + + const refused = await doors[entrance](MEMBER_EC); + expect(refused).toMatchObject({ status: 403, code: 'PERMISSION_DENIED' }); + expect(execute).not.toHaveBeenCalled(); + + expect((await doors[entrance](HOLDER_EC)).status).toBe(200); + expect(execute).toHaveBeenCalledTimes(1); + }); + + describe(`${entrance}: when the action AND the flow both declare, the caller needs both lists`, () => { + const both = () => makeDoors( + { type: 'screen', runAs: 'system', requiredPermissions: [CAP] }, + { actionRequiredPermissions: [ACTION_CAP] }, + ); + + it("holding only the action's capability → refused by the flow's check, never dispatched", async () => { + const { doors, execute, getFlow } = both(); + + const answer = await doors[entrance]({ userId: 'u', systemPermissions: [ACTION_CAP] }); + + expect(answer).toMatchObject({ status: 403, code: 'PERMISSION_DENIED' }); + expect(answer.message).toContain(CAP); + // The action gate admitted; the flow's declaration was read. + expect(getFlow).toHaveBeenCalled(); + expect(execute).not.toHaveBeenCalled(); + }); + + it("holding only the flow's capability → refused by the action's own gate first, the flow never read", async () => { + const { doors, execute, getFlow } = both(); + + const answer = await doors[entrance](HOLDER_EC); + + // The action gate's refusal (ADR-0066 D4) answers upstream, as + // it did before the flow check existed: REST answers it 403, + // the MCP bridge throws its sentence bare. + if (entrance === 'REST /actions') expect(answer.status).toBe(403); + expect(answer.status).not.toBe(200); + expect(answer.message).toContain(ACTION_CAP); + expect(getFlow).not.toHaveBeenCalled(); + expect(execute).not.toHaveBeenCalled(); + }); + + it('holding both → starts', async () => { + const { doors, execute } = both(); + + const answer = await doors[entrance]({ userId: 'u', systemPermissions: [ACTION_CAP, CAP] }); + + expect(answer.status).toBe(200); + expect(execute).toHaveBeenCalledTimes(1); + }); + }); + } +}); diff --git a/packages/runtime/src/required-capabilities.ts b/packages/runtime/src/required-capabilities.ts new file mode 100644 index 00000000000..7b67f39a4aa --- /dev/null +++ b/packages/runtime/src/required-capabilities.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0066 D4] The capability arithmetic behind every declared + * `requiredPermissions` gate that admits a caller to an execution surface — + * ONE definition, asked by the action gate (`actionPermissionError`, + * `action-execution.ts`) and the flow start gate (`flowStartRefusal`, + * `flow-start-admission.ts`). + * + * A declaration is a list of capability names; the caller holds the + * capabilities its permission sets grant (`ExecutionContext.systemPermissions`). + * The gate admits a caller that holds EVERY named capability. The two gates + * share this line so an action and a flow that declare the same list admit + * the same callers: two spellings of "holds X" would be two answers to one + * question. + * + * The system principal is NOT decided here: each gate reads + * `ExecutionContext.isSystem` itself, before it asks, so the bypass stays at + * the gate that grants it and the census page + * (`content/docs/permissions/system-context.mdx`) anchors it there. + */ + +/** + * The capabilities in `required` the caller does not hold, in declaration + * order. Empty when nothing is declared (an ungated surface) or when the + * caller holds all of them. A value that is not an array declares nothing. + */ +export function missingCapabilities( + required: unknown, + executionContext: { systemPermissions?: readonly string[] } | null | undefined, +): string[] { + if (!Array.isArray(required) || required.length === 0) return []; + const held = new Set(executionContext?.systemPermissions ?? []); + return (required as string[]).filter((perm) => !held.has(perm)); +} diff --git a/packages/services/service-automation/src/subflow-required-permissions.test.ts b/packages/services/service-automation/src/subflow-required-permissions.test.ts new file mode 100644 index 00000000000..21d0a01eb8b --- /dev/null +++ b/packages/services/service-automation/src/subflow-required-permissions.test.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// A flow's `requiredPermissions` says who may START it at a door — the trigger +// route, an action of `type: 'flow'`, a declared endpoint (ruling record +// 6377848497, letter A; the check is the runtime's `flowStartRefusal`). A +// parent flow's `subflow` node is NOT a door: it starts its child through the +// engine, and the parent's own start was the admission. So the engine itself +// never reads the key, and a gated child called from its parent runs for a +// caller who holds nothing. The door half is the runtime's +// `flow-start-required-permissions.test.ts`. + +import { describe, it, expect, vi } from 'vitest'; +import { AutomationEngine } from './engine.js'; +import { registerSubflowNode } from './builtin/subflow-node.js'; + +function createTestLogger(): any { + const l: any = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + l.child = () => l; + return l; +} + +const CAP = 'srp_verify_bank_account'; + +const CHILD = { + name: 'srp_child', + label: 'Gated child', + type: 'autolaunched', + runAs: 'system', + requiredPermissions: [CAP], + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: {} }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], +}; + +const PARENT = { + name: 'srp_parent', + label: 'Parent', + type: 'screen', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: {} }, + { id: 'call', type: 'subflow', label: 'Call', config: { flowName: 'srp_child' } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'call' }, + { id: 'e2', source: 'call', target: 'end' }, + ], +}; + +describe("a parent flow's subflow node is not a start door", () => { + it('the engine keeps the declaration on the definition the doors read', async () => { + const engine = new AutomationEngine(createTestLogger()); + engine.registerFlow(CHILD.name, CHILD); + + expect((await engine.getFlow(CHILD.name))?.requiredPermissions).toEqual([CAP]); + }); + + it('a gated child runs from its parent for a member holding no capability', async () => { + const engine = new AutomationEngine(createTestLogger()); + engine.registerFlow(CHILD.name, CHILD); + engine.registerFlow(PARENT.name, PARENT); + registerSubflowNode(engine, { logger: createTestLogger(), getService: () => undefined } as any); + const execute = vi.spyOn(engine, 'execute'); + + const result = await engine.execute(PARENT.name, { userId: 'member_1', permissions: [] } as any); + + expect(result, JSON.stringify(result)).toMatchObject({ success: true }); + // The child was started by the parent's subflow node, and it ran. + const childCall = execute.mock.calls.findIndex(([name]) => name === CHILD.name); + expect(childCall).toBeGreaterThan(-1); + const childResult = await execute.mock.results[childCall]!.value; + expect(childResult.success).toBe(true); + }); +}); diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 50721d89bc2..9a0d74cfdca 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -361,6 +361,7 @@ "api/CreateFlowRequest:name", "api/CreateFlowRequest:nodes", "api/CreateFlowRequest:protection", + "api/CreateFlowRequest:requiredPermissions", "api/CreateFlowRequest:runAs", "api/CreateFlowRequest:status", "api/CreateFlowRequest:successMessage", diff --git a/packages/spec/authorable-surface/automation.json b/packages/spec/authorable-surface/automation.json index b9ed1d8d28f..4301d468ef1 100644 --- a/packages/spec/authorable-surface/automation.json +++ b/packages/spec/authorable-surface/automation.json @@ -165,6 +165,7 @@ "automation/Flow:name", "automation/Flow:nodes", "automation/Flow:protection", + "automation/Flow:requiredPermissions", "automation/Flow:runAs", "automation/Flow:status", "automation/Flow:successMessage", diff --git a/packages/spec/liveness/flow.json b/packages/spec/liveness/flow.json index 20114a17729..56bae3db21e 100644 --- a/packages/spec/liveness/flow.json +++ b/packages/spec/liveness/flow.json @@ -46,6 +46,13 @@ "evidence": "packages/services/service-automation/src/builtin/crud-nodes.ts: CRUD nodes pass the run identity to ObjectQL per flow.runAs; the engine sets context.runAs at run setup; resolveRunDataContext maps system->isSystem / user->trigger user (#1888)", "note": "ENFORCED 2026-06-24 (#1888, ADR-0049): was marker-driven experimental; the engine now switches the data-layer execution identity and restores the caller context. Proof-backed by the dogfood gate." }, + "requiredPermissions": { + "status": "live", + "verifiedAt": "2026-10-24", + "evidenceScope": "in-repo", + "evidence": "packages/runtime/src/flow-start-admission.ts#flowStartRefusal (THE start check: reads flow.requiredPermissions off the definition getFlow serves and answers 403 PERMISSION_DENIED when a non-system caller's systemPermissions do not cover it); packages/runtime/src/required-capabilities.ts#missingCapabilities (the capability arithmetic, shared with the action gate actionPermissionError); packages/runtime/src/domains/automation.ts#respondToFlowTrigger (the trigger door); packages/runtime/src/action-execution.ts#dispatchFlowAction (the action door, REST /actions and MCP run_action — inherits the flow's list); packages/runtime/src/endpoint-executor.ts#executeFlow (a declared endpoint of type flow)", + "note": "Flow-level twin of action.requiredPermissions (ADR-0066 D4), ruling record 6377848497 letter A: one shared check at every door that starts a flow by name; a parent flow's subflow node is not a door and is not checked again. Door pins: packages/runtime/src/flow-start-required-permissions.test.ts; sub-flow pin: packages/services/service-automation/src/subflow-required-permissions.test.ts." + }, "type": { "status": "live", "verifiedAt": "2026-08-28", diff --git a/packages/spec/liveness/state-counts/flow.md b/packages/spec/liveness/state-counts/flow.md index 08faba4d15d..3840cade61f 100644 --- a/packages/spec/liveness/state-counts/flow.md +++ b/packages/spec/liveness/state-counts/flow.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `flow` | 35 | 0 | 0 | 5 | 0 | 40 | +| `flow` | 36 | 0 | 0 | 5 | 0 | 41 | diff --git a/packages/spec/src/api/endpoint-publish-gate.ts b/packages/spec/src/api/endpoint-publish-gate.ts index f04f8cff33b..2da931d6dc0 100644 --- a/packages/spec/src/api/endpoint-publish-gate.ts +++ b/packages/spec/src/api/endpoint-publish-gate.ts @@ -608,10 +608,11 @@ function policyGate( // `runAs: 'system'` reads the anonymous endpoints that target it. // // ⛔ Not a mirror of a runtime refusal, unlike the gates above. The endpoint -// executor's `refusesElevatedSelfTriggeredStart` refuses a non-system caller -// only for a SELF-TRIGGERED `system` flow (`autolaunched`, `record_change`, -// `schedule`); a `system` flow of type `api` or `screen` behind an anonymous -// endpoint executes, elevated. The record places D2b at publish, and this is +// executor's `flowStartRefusal` refuses a non-system caller +// for a SELF-TRIGGERED `system` flow (`autolaunched`, `record_change`, +// `schedule`) and for a flow whose `requiredPermissions` it does not hold; a +// `system` flow of type `api` or `screen` declaring none, behind an anonymous +// endpoint, executes, elevated. The record places D2b at publish, and this is // that refusal. // // The prescription is the record's, scenario by scenario: an authenticated diff --git a/packages/spec/src/automation/flow-required-permissions.test.ts b/packages/spec/src/automation/flow-required-permissions.test.ts new file mode 100644 index 00000000000..4fd854781a0 --- /dev/null +++ b/packages/spec/src/automation/flow-required-permissions.test.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `FlowSchema.requiredPermissions` — who may START a flow (ruling record + * 6377848497, letter A), the flow-level twin of an action's + * `requiredPermissions` (ADR-0066 D4): the same value shape, a list of + * capability names. The runtime checks it at every start door + * (`packages/runtime/src/flow-start-admission.ts`); these pins hold the + * authoring half: the key is a real authorable key, its value is the action's + * shape, and it is kept on the parsed definition the doors read. + */ +import { describe, expect, it } from 'vitest'; + +import { ActionSchema } from '../ui/action.zod'; +import { FlowSchema } from './flow.zod'; + +const GATED = { + name: 'srm_bank_verify', + label: 'Verify bank account', + type: 'screen', + runAs: 'system', + requiredPermissions: ['srm_verify_bank_account'], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], +}; + +describe('FlowSchema.requiredPermissions', () => { + it('parses whole, and the parsed definition keeps the list the doors read', () => { + const parsed = FlowSchema.safeParse(GATED); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + expect(parsed.data?.requiredPermissions).toEqual(['srm_verify_bank_account']); + }); + + it('is optional: a flow that declares none parses with the key absent', () => { + const { requiredPermissions: _omit, ...bare } = GATED; + const parsed = FlowSchema.safeParse(bare); + expect(parsed.success).toBe(true); + expect(parsed.data && 'requiredPermissions' in parsed.data).toBe(false); + }); + + it("takes the action's value shape — a list of names — and refuses another", () => { + for (const value of ['srm_verify_bank_account', { srm_verify_bank_account: true }, [1]]) { + const parsed = FlowSchema.safeParse({ ...GATED, requiredPermissions: value }); + expect(parsed.success, JSON.stringify(value)).toBe(false); + expect(parsed.error?.issues.some((i) => i.path[0] === 'requiredPermissions')).toBe(true); + // The action key refuses the same values: one shape, two positions. + const action = ActionSchema.safeParse({ + name: 'verify_bank', label: 'Verify', type: 'flow', target: 'srm_bank_verify', requiredPermissions: value, + }); + expect(action.success, `action ${JSON.stringify(value)}`).toBe(false); + } + }); +}); diff --git a/packages/spec/src/automation/flow.form.ts b/packages/spec/src/automation/flow.form.ts index bb03fbf1bc8..390b340eb08 100644 --- a/packages/spec/src/automation/flow.form.ts +++ b/packages/spec/src/automation/flow.form.ts @@ -100,6 +100,9 @@ export const flowForm = defineForm({ { field: 'status', required: true, colSpan: 1, helpText: 'Deployment status: draft → active → obsolete' }, { field: 'version', colSpan: 1, helpText: 'Version number (auto-incremented)' }, { field: 'runAs', colSpan: 1, helpText: 'Execute as system (admin) or user (current user permissions)' }, + // Who may START the flow — the action form's `requiredPermissions` face + // (`string-tags`), since it is the same key and the same check. + { field: 'requiredPermissions', widget: 'string-tags', colSpan: 2, helpText: 'Capabilities (permission-set systemPermissions) a caller must hold — every one listed — to start this flow. Every start door (the trigger route, a flow action over REST and MCP, a flow endpoint) refuses anyone else with 403, and an action that starts this flow inherits the list. A parent flow\'s subflow step is not checked again.' }, { field: 'errorHandling', type: 'composite', colSpan: 2, helpText: 'What to do when a node fails (fail, retry, continue)' }, // #19331 — both messages are declared by FlowSchema and were offered by // no control. The copy states the reach the runtime actually gives them: diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 17e0ba62006..44760d6c9f1 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -1191,6 +1191,44 @@ export const FlowSchema = lazySchema(() => strictObject( "through an authenticated endpoint, the flow's signed inbound hook, or a public form.", ), + /** + * Who may START this flow — the flow-level twin of an action's + * `requiredPermissions` (ADR-0066 D4), same value shape, same check + * (ruling record 6377848497, letter A). + * + * Checked by every door that starts a flow by name — the trigger route + * (`POST /automation/:name/trigger`), an action of `type: 'flow'` (REST + * `/actions` and the MCP `run_action` bridge) and a declared endpoint of + * `type: 'flow'` — in the one shared start check + * (`packages/runtime/src/flow-start-admission.ts`). A caller that is not the + * system principal must hold EVERY named capability, or the door answers + * `403 PERMISSION_DENIED` (ADR-0112) and nothing runs. + * + * The action door INHERITS it: an action that starts this flow is gated by + * this list even when the action declares none, so the app states it once, + * on the thing it protects. An action that declares its own list keeps it, + * and a caller at that door needs both. + * + * A parent flow's `subflow` node is not a door: it starts this flow through + * the engine and is not checked again — the parent's own start was. + * + * This is the platform's answer to "who may start an elevated `screen` / + * `api` flow" — gate on it rather than on `current_user.positions` inside + * the flow, which is not an authorization input. + */ + requiredPermissions: z + .array(z.string()) + .optional() + .describe( + '[ADR-0066 D4] Capabilities required to START this flow, the flow-level twin of an action\'s ' + + 'requiredPermissions. Checked at every door that starts a flow by name (the trigger route, an action of ' + + 'type flow over REST and MCP run_action, a declared endpoint of type flow): a caller that is not the system ' + + 'principal must hold every named capability, or the door answers 403 PERMISSION_DENIED and nothing runs. ' + + 'An action that starts this flow inherits the list even when it declares none; one that declares its own ' + + "needs both. A parent flow's subflow node is not a door and is not checked again. Use this, not " + + 'current_user.positions inside the flow, to say who may start an elevated (runAs: system) screen or api flow.', + ), + /** * Error Handling Strategy. * diff --git a/packages/spec/src/system/flows-translation-face.test.ts b/packages/spec/src/system/flows-translation-face.test.ts index 54deeeb67e3..246eb0722fe 100644 --- a/packages/spec/src/system/flows-translation-face.test.ts +++ b/packages/spec/src/system/flows-translation-face.test.ts @@ -102,6 +102,7 @@ const RUNNER_SLOTS: Readonly> = { ), 'flow.successMessage': keyed('successMessage', 'the completion toast'), 'flow.errorMessage': keyed('errorMessage', 'the failure toast'), + 'flow.requiredPermissions[]': noReader('a capability name the start doors check, never drawn as text'), 'flow.variables[].name': noReader('a variable name — the binding a screen field or node writes to'), 'flow.variables[].type': noReader('a data-type token'), 'flow.variables[].defaultValue': noReader('a value the run binds, not text it shows'),