Skip to content

Commit df8bbf9

Browse files
committed
docs: a flow's requiredPermissions is a second 403 at every start door
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HEB7eZUjj5begLB9coGhFs
1 parent 1516b97 commit df8bbf9

3 files changed

Lines changed: 14 additions & 8 deletions

File tree

‎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/ui/actions.mdx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -407,9 +407,13 @@ is a spec proposal for a properly named key, not a values map under this one.
407407
- **`requiredPermissions: ['can_close_tickets']`** is a **dual-surface gate**
408408
(one declaration, two enforcement points): the server rejects unauthorized
409409
calls with 403, and the UI hides or disables the button for the same users.
410-
Unset means no gate beyond object CRUD permissions — except that a `flow`
411-
action whose target is an elevated self-triggered flow is refused to every
412-
caller but the system principal either way (the `flow` row below). Referenced
410+
Unset means no gate beyond object CRUD permissions — except at a `flow`
411+
action, whose target flow is admitted by its own rules either way (the
412+
`flow` row below): an elevated self-triggered target is refused to every
413+
caller but the system principal, and a target that declares its own
414+
`requiredPermissions` is refused to a caller who does not hold them. The
415+
action inherits the flow's list; when both declare one, the caller needs
416+
both. Referenced
413417
capabilities must exist — `os lint` checks that.
414418
- **`visible`** is a CEL predicate evaluated **fail-closed**: an expression
415419
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
546550
| `type` | Over REST |
547551
|:---|:---|
548552
| `script` | Runs the registered handler / inline body. |
549-
| `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. |
553+
| `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. |
550554
| `api` | **400** — it dispatches on `target`; call that endpoint directly. |
551555
| `url` / `modal` / `form` | **400** — client-side navigation; there is nothing for the server to run. |
552556

0 commit comments

Comments
 (0)