Skip to content

Commit f1ddace

Browse files
feat(automation): flow-level requiredPermissions, checked at every flow start door (#25643)
Fixes #25580 Ruling-ref: 6377848497 Clause-②: yes (widening) Metadata-protocol: added ## What A flow can now declare who may START it: `FlowSchema.requiredPermissions: string[]`, the same value shape as an action's `requiredPermissions` (ADR-0066 D4). Ruling A's order holds inside this one PR (retriage answer 6382852102): the spec adds the key, and the server checks it in the one shared check every start door asks. ### The shared check — `packages/runtime/src/flow-start-admission.ts#flowStartRefusal` The module already existed as "the ONE definition asked by every door that starts a flow by name" (the elevated self-triggered rule). Its door-facing predicate is replaced by one function, `flowStartRefusal(automation, flowName, executionContext)`, which returns the ADR-0112 refusal or `null`: 1. **Elevated self-triggered rule** (unchanged): a non-system caller may not start a `runAs: 'system'` flow of type `autolaunched` / `record_change` / `schedule`. Answers first, because no capability opens it. 2. **The flow's `requiredPermissions`** (new): a non-system caller must hold every named capability, or `403 PERMISSION_DENIED`, nothing dispatched. The capability arithmetic is `packages/runtime/src/required-capabilities.ts#missingCapabilities`, which the action gate `actionPermissionError` now asks too: one spelling of "holds X" for both keys. The system principal (`isSystem`) is admitted, the action gate's bypass; the census row on `content/docs/permissions/system-context.mdx` is re-anchored to the new symbol. ### The doors (each one call to the check, nothing else) | Door | Call site | |---|---| | Trigger route `POST /automation/:name/trigger` (and the legacy spelling) | `packages/runtime/src/domains/automation.ts#respondToFlowTrigger` | | Action of `type: 'flow'`: REST `POST /actions/:object/:action` and MCP `run_action` | `packages/runtime/src/action-execution.ts#dispatchFlowAction` (both entrances reach it) | | Declared endpoint of `type: 'flow'` | `packages/runtime/src/endpoint-executor.ts#executeFlow` | `packages/mcp` needed no change: `run_action` reaches `dispatchFlowAction` through the runtime bridge. ### Decisions recorded - **The action door inherits the flow's list.** An action that starts a gated flow is gated by it even when the action declares none. - **When both declare, the caller needs both lists.** The action's own gate answers first (upstream, unchanged). The flow's gate then answers at `dispatchFlowAction`. - **Sub-flows are not doors.** A parent's `subflow` node starts the child through `engine.execute`, which never reads the key. Pinned on the real engine. - **The declared endpoint is gated too.** The ruling names three doors. The existing shared module already treats `type: 'flow'` endpoints as a fourth door that starts a flow by name, so leaving it out would leave a bypass. - **The refusal names the declared and the missing capabilities** (as the action gate's does) and nothing else about the flow. - **`runAs: 'user'` flows are gated too.** The key gates the start, not the elevation. ### Authoring surfaces - `os validate`: `capability-reference-unknown` now judges `flows[i].requiredPermissions` through the same code that judges actions (`packages/lint/src/validate-capability-references.ts`), and `os explain` names the position. - Studio flow form: offers the key (`string-tags`, as on the action form). The translation bundles are regenerated; zh-CN, ja-JP and es-ES are translated by hand. - Generated: authorable-surface shards, references docs, liveness row (`live`) and state counts. ## Tests - `packages/runtime/src/flow-start-required-permissions.test.ts` covers all four doors (trigger, REST `/actions`, MCP `run_action`, endpoint). Each case is pinned on `code` + `status` + never dispatched: - without X → `403 PERMISSION_DENIED`; with X → starts - EVERY capability required; system principal admitted; absent or empty list admits - the elevated self-triggered rule answers first - identical envelope at all four doors - inheritance with no action list; both-declared in each direction - `packages/services/service-automation/src/subflow-required-permissions.test.ts`: a gated child runs from its parent for a member holding nothing, on the real engine. - `packages/spec/src/automation/flow-required-permissions.test.ts`: full parse green and kept on the parsed definition; wrong value shapes refused at the same paths the action key refuses. - `packages/lint/src/validate-capability-references.test.ts`: a typo at `flows[1].requiredPermissions` warns; a granted one passes. Readings are on head `3ddf0535` (and as noted per line) (local subset; CI runs the farm): - `@objectstack/runtime` full suite (pre-merge head `a2fd696f`; the merge brought no runtime change): Test Files 441 passed (441), Tests 7080 passed / 19 skipped. On the merged head `86714f38`: the three door pin files, 97 passed. - `@objectstack/spec` full suite on `86714f38`: Test Files 716 passed (716), Tests 21712 passed / 1 todo. The first run, pre-merge, was 2 failed (the translation-face and form-reconciliation pins named the new key); both were fixed in this PR by offering the key in the flow form and adding a translation-face row. - `@objectstack/lint` full suite on `86714f38`: Test Files 159 passed (159), Tests 7245 passed. - `@objectstack/service-automation` full suite (pre-merge): Test Files 200 passed (200), Tests 2630 passed. The sub-flow pin on `86714f38`: 2 passed. - `@objectstack/platform-objects` full suite on `3ddf0535`: Test Files 94 passed (94), Tests 1343 passed. The catalog count pin moved 678 to 679 for the new form row. - typecheck on the merged head: spec, runtime, service-automation, lint, rest and platform-objects all `Done`, exit 0. Ablation (one-off, no permanent file). Mutating `if (missing.length > 0) return requiredPermissionsRefusal(` to `if (false) …` through `scripts/ablation-replace.mjs` reds `flow-start-required-permissions.test.ts`: 21 failed / 24 passed of 45. The file was restored byte-identical (blob `9f81b3b1570b` before = after; `git diff HEAD` empty). The runtime tests import `src` relatively, so no `dist/` leg was needed. ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, which gives 122 commands. - All 122 ran on head `86714f38` (after the `origin/main` merge), each exit code captured before any pipe: 122 exit 0. - `check-plugin-teardown-shape --self-test` first answered 3 (shallow clone). After fetching its pinned fixture commit it answered 0. - The last commit (`3ddf0535`) only touches a platform-objects test file. It re-derives the same 122, and the ratchet families on that head answered 0: test-source-alias, cross-package-test-inputs, i18n, i18n-stale-fill, type-check-debt, type-check-coverage. - `--ran` reconciliation: 122 derived, 122 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. - Also local: `check-system-context-census` OK; `check-platform-checklist` OK, and `--changed-from origin/main` OK with 0 anchors lost; `check:dev-prereqs` OK; `check-nul-bytes` OK; spec `check:generated` "All 14 generated artifacts are up to date" after the merge. - Not run locally: full dogfood. There is no `!` in the title and no `(narrowing)` arm. CI runs the farm. ## Producers Measured at `7265b33e77`: 8 flows in `examples/**` declare `runAs: 'system'` (showcase 6, todo 2). 2 of them are `screen` / `api`: `showcase_inquiry_purge` (screen) and `showcase_inbound_task_webhook` (api). None declares `requiredPermissions`, so none changes behaviour. `packages/**` holds no flow definition that declares `runAs: 'system'` outside tests. ## Acceptance notes - **Listing faces do not mirror the inherited gate.** Two hide faces use only the action's own `requiredPermissions`: MCP `list_actions` (`packages/runtime/src/domains/mcp.ts`, the `actionPermissionError` filter) and the `/meta` action strip. So a member can still see an action whose target flow refuses them, and running it answers 403. These are UI conveniences, not access boundaries, and the doors enforce. Not filed: it is an observation, not one of the three filing classes. - **Not start doors:** `resume_run` / the screen-resume route continue a run that a door already admitted, so they are not checked again. - **Pre-existing, unchanged:** at the MCP bridge, the action's OWN `requiredPermissions` refusal is thrown as a bare sentence with no `code`/`status`, as the existing elevated-door pins already record. The new flow-level refusal carries `PERMISSION_DENIED`/403 at both entrances. - **Files beyond the claim's declared surface:** each is either a door itself or a gate the new key forces. - Doors: `runtime/src/domains/automation.ts` (the trigger door lives there, not in `action-execution.ts`) and `runtime/src/endpoint-executor.ts`. - New runtime files: `runtime/src/required-capabilities.ts` and the door pin. - Forced by gates: the `service-automation` sub-flow pin; `spec/src/automation/flow.form.ts` and `spec/src/system/flows-translation-face.test.ts` (form reconciliation and translation-face pins); `platform-objects` translation bundles; `spec/liveness/flow.json`; `content/docs/permissions/system-context.mdx` (census); `docs/qa/platform-checklist/areas/automation.json` (the anchor followed the renamed symbol). - Comment-only re-points to the new symbol name: `rest/src/import-prepare.ts` and `spec/src/api/endpoint-publish-gate.ts`. - **hotsrm** can drop its `current_user.positions` gate in `srm_bank_verify` once this ships. The four-eyes half (the creator may not verify) is out of scope per the ruling. Session: `session_01HEB7eZUjj5begLB9coGhFs` 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01HEB7eZUjj5begLB9coGhFs --- _Generated by [Claude Code](https://claude.ai/code/session_01HEB7eZUjj5begLB9coGhFs)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 061cc94 commit f1ddace

34 files changed

Lines changed: 753 additions & 74 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/runtime': minor
4+
'@objectstack/lint': minor
5+
'@objectstack/platform-objects': minor
6+
---
7+
8+
feat(automation): a flow declares who may start it — `requiredPermissions` on the flow, checked at every start door
9+
10+
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.
11+
12+
- **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.
13+
- **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.
14+
- **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`.
15+
- **Studio's flow form** offers the key (a tag list beside `runAs`).
16+
- **`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`).
17+
18+
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.
19+
20+
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.
21+
22+
Metadata-protocol: added (`FlowSchema.requiredPermissions`)

‎content/docs/api/declarative-endpoints.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ declaration to shadow a built-in one. Match → policy chain (`rateLimit` → `a
101101
| `type` | Delegates to | Request shape |
102102
|:---|:---|:---|
103103
| `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` |
104-
| `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 |
104+
| `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 |
105105

106106
The frozen vocabulary has **no path-template syntax**, so an endpoint cannot express
107107
`/leads/{id}` — a record id travels as `?id=…`. A method that no declaration claims

‎content/docs/automation/flows.mdx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ const approvalFlow = {
7474
| `status` | `enum` | optional | `'draft'`, `'active'`, `'obsolete'`, `'invalid'` (defaults to `'draft'`) |
7575
| `type` | `FlowType` | ✅ | Flow trigger type (see below) |
7676
| `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 |
77+
| `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 |
7778
| `variables` | `FlowVariable[]` | optional | Input/output variables |
7879
| `nodes` | `FlowNode[]` | ✅ | Flow nodes |
7980
| `edges` | `FlowEdge[]` | ✅ | Connections between nodes |
@@ -2291,20 +2292,21 @@ curl -b cookies.txt -X POST \
22912292
| `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 |
22922293

22932294
**Failures answer real HTTP status codes.** A flow that does not run is not a
2294-
`200`. The route classifies six outcomes. Five of them it reads off the engine's
2295-
own verdict — never the message text; the `403` is the door's own admission
2295+
`200`. The route classifies seven outcomes. Five of them it reads off the engine's
2296+
own verdict — never the message text; the two `403`s are the door's own admission
22962297
check, made before anything is dispatched:
22972298

22982299
| Status | `error.code` | What happened | What to do |
22992300
|:---|:---|:---|:---|
23002301
| `404` | — | No flow by that name in this deployment | Check the name; nothing was dispatched |
23012302
| `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) |
2303+
| `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 |
23022304
| `409` | `FLOW_DISABLED` | The flow exists but is switched off | Enable it — the identical request then succeeds |
23032305
| `422` | `FLOW_NO_START_NODE` | The stored definition has no `start` node | Fix the flow; retrying cannot help |
23042306
| `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 |
23052307
| `400` | `FLOW_FAILED` | The flow **ran** and was rejected | Read `error.details.summary` for the failing node |
23062308

2307-
The first five never dispatched anything: no node executed, no record was
2309+
The first six never dispatched anything: no node executed, no record was
23082310
written, and there is no run to look up. Only `400` describes a run. The
23092311
`FLOW_INPUT_SCHEMA_INVALID` verdict is a pure function of the flow definition —
23102312
re-running the guard cannot change the answer — so a `retry` strategy does not

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ One of the largest consumers of the flag;
193193
| 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` |
194194
| 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` |
195195
| 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` |
196-
| 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` |
196+
| 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` |
197197
| 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` |
198198
| 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` |
199199
| 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` |

0 commit comments

Comments
 (0)