Repository navigation
Commit ca135dc
fix(runtime): the trigger door refuses a self-triggered system flow to a non-system caller (#22404)
Fixes #22310
Clause-②: no
A self-triggered flow declared to run as system could be started by any
signed-in member through the trigger door. The door asked only whether
the caller was anonymous, so a flow meant to run on its own trigger, or
as a sub-flow, ran elevated for anyone who named it.
This implements the maintainer's ruling on the card (letter B, record
6070744023, with triage's settled reading of the carve-out, 6071477320).
A caller that is not the system principal may not start a flow declared
`runAs: 'system'` whose `type` is `autolaunched`, `record_change` or
`schedule`. This applies at `POST /api/v1/automation/:name/trigger`, at
the legacy `POST /api/v1/automation/trigger/:name`, and at
`@objectstack/verify`'s `flows.run`, all of which answer through one
function.
> **Draft until the seat lands it.** The two required checks the first
round left red are addressed on this head (patch round 1, with the
claim's file surface extended by the seat): the census row and the
ruling's pin sweep. The review of record is the seat's ACCEPT on #22310.
No contract-tier review is owed: this PR declares `Clause-②: no` and
touches neither `packages/spec` nor governed text (the pm-dispatch
contract-review reference).
## Measure first: the before-table
All rows were measured through `flows.run` on the real kernel: auth,
security middleware and the real automation engine. The "before" column
was taken at `cd46ffa4`, this branch's first commit, before any fix.
That commit carries the table as its assertion. The fixture is neutral.
It has one object a fresh member is not granted (the member's direct
create is refused with `403 PERMISSION_DENIED`, which is the control)
and one writer flow per type that creates a row in that object. In the
table, "row" means the elevated write landed and "run" means a run-log
entry was recorded.
| caller | flow declares | `type` | before (`cd46ffa4`) | after (this
head) |
|---|---|---|---|---|
| member | `runAs: 'system'` | `autolaunched` | 200, row, run | **403
`PERMISSION_DENIED`, no row, no run** |
| member | `runAs: 'system'` | `record_change` | 200, row, run | **403,
no row, no run** |
| member | `runAs: 'system'` | `schedule` | 200, row, run | **403, no
row, no run** |
| member | `runAs: 'system'` | `screen` | 200, row, run | unchanged |
| member | `runAs: 'system'` | `api` | 200, row, run | unchanged |
| member | `runAs: 'user'` | `autolaunched` | 400 `FLOW_FAILED`, no row,
run | unchanged |
| member | a `runAs: 'user'` parent whose `subflow` node calls the
`runAs: 'system'` `autolaunched` writer | `autolaunched` | 200, child's
row, run | unchanged |
| platform admin | `runAs: 'system'` | `autolaunched` / `record_change`
/ `schedule` | 200, row, run | **403, no row, no run** |
| system principal | `runAs: 'system'` | `autolaunched` /
`record_change` / `schedule` | 200, row, run | unchanged |
The card measured two `autolaunched` paths. This table also measures
`record_change`, `schedule`, `screen` and `api`: before the fix, a
member could start every one of them, and the elevated write landed each
time.
## What changed
- `packages/runtime/src/domains/automation.ts`: `respondToFlowTrigger`
now asks `refusesElevatedSelfTriggeredStart` after the existence check
and before `execute`. An unknown name keeps its 404. A refused start
dispatches nothing. The predicate refuses when all three hold: the
caller is not the system principal (`executionContext.isSystem`, the
field the domain's anonymous floor already reads, which is never set on
inbound HTTP), the flow declares `runAs: 'system'`, and its `type` is
one of the three self-triggered types. The type set is bound to the
spec's `Flow.type` enum at compile time.
- The refusal is `403` `PERMISSION_DENIED`, in the ADR-0112 envelope.
That is the code and status this domain's other permission refusals
already use, so no code is minted. The message is the same for every
refused flow. It names what admits such a flow and nothing about this
one: not its name, type, run-as declaration or definition.
- The declaration is read through the automation service's own
`getFlow`, the probe `flowIsUnknown` already uses, which serves the same
definition `execute` runs. There is no second loader and no copy of the
engine's run-as policy in the transport (mechanism hypothesis H5 holds).
`getFlow` is optional on `IAutomationService`. A service that omits it
cannot be asked, so the door dispatches as before, exactly as the
existence check does (see Acceptance notes).
- No spec key moves, and nothing changes in `service-automation`.
- `content/docs/permissions/system-context.mdx`: the automation-domain
row anchors `#refusesElevatedSelfTriggeredStart` and names the new
bypass (the in-process system principal starting an elevated
self-triggered flow at the trigger door). The declared counts are
regenerated by `pnpm gen:system-context-census` (118 to 119 read sites).
No other row is touched.
- The ruling's pin sweep, repo-wide (see **The pin sweep** below): the
only pins that asserted a non-system caller starting an elevated
self-triggered flow at the trigger door are 3 dogfood tests in 2 files,
and each now asserts the ruled door.
Mechanism hypotheses, each checked by reading the code and measuring:
- **H1 holds:** existence check, then `execute`.
- **H2 holds:** verify's `flows.run` dispatches `POST
/automation/:name/trigger` through the runtime's `HttpDispatcher`, which
reaches `respondToFlowTrigger`.
- **H3 holds:** `subflow-node.ts` and `map-node.ts` start the child
through `engine.execute`. The subflow row above is the measured positive
control.
- **H4 holds:** `buildAutomationContext` does not forward `isSystem`.
Elevation comes from `flow.runAs` inside the engine. The door reads
`isSystem` only to decide admission, and the system rows above are the
control.
## Pins
-
`packages/runtime/src/domains/automation-trigger-elevated-door.test.ts`
(door side, scripted service, both route spellings): one refusal per
refused type, asserting `code`, `status` and that `execute` was never
called; one disclosure pin (an identical message for all three types
that carries no name, type or run-as); and positive controls for the
system principal per refused type, for a non-elevated flow per type, for
elevated `screen` and `api` flows, for an unknown name still answering
404, and for a service without `getFlow`.
- `packages/verify/src/automation-trigger-elevated-door.test.ts` (the
wire, real kernel, through `flows.run`): the table above, one `it` per
row, asserting status, code, rows written and run-log entries. It also
has a direct-create control and one envelope pin.
- `packages/qa/dogfood/test/flow-runas.dogfood.test.ts` with
`fixtures/flow-runas-fixture.ts`, and
`packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts`:
the flipped pins (see The pin sweep).
**Ablation** (a mutation of the call site to `if ((globalThis as
any).__ABLATED_22310__ && await refusesElevatedSelfTriggeredStart(…))`,
landed through `scripts/ablation-replace.mjs`, which reported anchor 1
to 0 and blob `17cef819` to `3edb21bc`):
- The runtime build carried the marker (`ablation-dist-preflight`:
present in `dist/index.js` and `dist/index.cjs`).
- verify: 7 failed and 9 passed. Exactly the six refused rows and the
envelope pin went red. Every unchanged-path row stayed green.
- runtime: 8 failed and 20 passed. The six refusal pins and the two
disclosure pins went red. The disclosure pin first passed under ablation
over an empty message and was tightened in `12f88829`.
- Restore leg: the blob equals HEAD, `git diff HEAD` is empty, a rebuild
was done, `ablation-dist-preflight --absent` reports the marker absent
from all 6 built files, and the tree is clean.
## Tests and gates (head `799c28789`)
- `pnpm --filter @objectstack/runtime test` at `0e92dbe4` (runtime
source unchanged since): 342 files, 4815 passed, 19 skipped. At
`799c28789`, the 42 runtime test files that drive the trigger door: 1026
passed.
- `pnpm --filter @objectstack/runtime typecheck` and `pnpm --filter
@objectstack/verify typecheck`: exit 0. `pnpm --filter
@objectstack/dogfood typecheck`: exit 0, with the 3 edited dogfood files
in the program.
- `pnpm --filter @objectstack/verify test` at `0e92dbe4`: 20 files, 154
passed. At `799c28789`, the 7 verify files that drive the door: 57
passed.
- Dogfood at `799c28789`: every dogfood file that drives the trigger
door (9) plus `authz-conformance.test.ts`: 10 files, 168 passed.
- `pnpm lint` (the full run): exit 0 at `799c28789`.
- `node scripts/check-system-context-census.mjs`: OK, 119 read sites,
115 of 115 required symbols cited.
- The dispatch-derived gate list, re-derived with no paths at
`799c28789` (94 families): all 94 exit 0. `pnpm
check:dual-build-cjs-loads` and `check:skill-examples` first answered
`PREREQUISITE NOT MET` and went green after a full build.
`dispatch-gates --ran`: 94 derived, 94 run, 0 NOT MEASURED.
## The pin sweep
A repo-wide search over every test that reaches the trigger door
(`/trigger` in either spelling, `flows.run`, `handleAutomation`,
`automation.trigger`: 74 files across `packages/runtime`,
`packages/verify`, `packages/qa/dogfood`, `packages/client`,
`packages/services/service-automation`, `packages/spec`, `packages/lint`
and `packages/objectql`, with no hits in `examples/` or `apps/`) found
exactly 3 pins whose meaning the ruling reverses: a non-system caller
starting a `runAs: 'system'` flow of a self-triggered type through the
door. The refusal's code and message appear nowhere else. Each flipped
pin asserts the new meaning's substance, and each test keeps its
subject.
- `flow-runas.dogfood.test.ts` (the authz-matrix proof `flow-run-as`):
the two elevation legs now reach each system flow through a `runAs:
'user'` parent whose `subflow` node calls it
(`runas_system_touch_via_parent`, `runas_system_read_via_parent`, added
to the fixture). The write leg still stamps the admin's note
`touched-system`. The read leg now also asserts that the row read is the
note it asked for. A new case pins the door: the member's direct start
of either system flow answers `403 PERMISSION_DENIED`, with no inner
`data`, the note left at `new` and no run recorded.
- `schedule-acting-organization.dogfood.test.ts`, control B on
sqlite-wasm (subject: which organization a door-launched run carries,
the session's and never the declaration's): the session now drives the
declaring flow's door twin. The twin has the same nodes and the same
`organization` declaration, `type: 'screen'` and no cadence, and is
derived from the same builder. The delivered row still carries the
session's organization and never the declared one. Before that, the
control pins that the declaring flow itself is refused to the session:
`403 PERMISSION_DENIED`, no run recorded, nothing delivered. The
memory-driver branch is unchanged. The system principal could not be
used here: it is reachable only in-process through
`@objectstack/runtime`'s `HttpDispatcher`, which `@objectstack/dogfood`
does not depend on, so the session-and-twin shape keeps the subject
without a new dependency.
**Ablation of the door check against the flipped pins** (the same
call-site mutation, marker present in `dist/index.js` and
`dist/index.cjs`): exactly the 2 refusal pins went red (the direct-start
case in `flow-runas`, and control B on sqlite-wasm). The 22 other tests
in the two files stayed green, including the parent-route elevation legs
and control B's twin delivery. Restore: the blob equals HEAD, `--absent`
reports the marker gone from all 6 built files, and the tree is clean.
All 27 tests across the three files pass after the restore.
## Acceptance notes
- **Admins are refused too.** A platform admin's session is a signed-in
user, not the system principal, and the ruling refuses every caller but
the system principal. The console's flow-action buttons start flows
through this same trigger door. An action button whose target is an
elevated self-triggered flow therefore now answers 403 for everyone.
Measured in-repo corpus at `117d34de`: no UI `type: 'flow'` action in
`examples/` targets one (the two action targets found are `screen` flows
declared `runAs: 'user'`).
- **Keyed on the declared `type`, as ruled, not on the resolved trigger
kind.** An `autolaunched` flow whose start node binds another trigger (a
record event, a cadence, an inbound hook) is refused at this door like
any other `autolaunched` one. A `screen` or `api` flow is admitted
whatever its start node binds.
- **Optional `getFlow`.** The door can only read a declaration the
automation service will serve. The platform engine always implements
`getFlow`. An alternative `IAutomationService` that omits it gets
today's behaviour, the same "no evidence, dispatch as before" reading
the existence check takes.
- **Two other doors start a flow by name and were measured, not
changed** (mechanism hypothesis H6, outside this ruling): an action of
`type: 'flow'` served at `POST /api/v1/actions/:object/:action`, and a
declared endpoint of `type: 'flow'` with `authRequired: true`. On this
branch, a member with no grant on the target object still starts an
elevated self-triggered flow through both: the action door with an
`autolaunched` and a `schedule` target, and the endpoint door with an
`autolaunched` target. Each answered 200 and the elevated write landed.
An anonymous request at the endpoint answers 401. The seat filed this as
#22407, and it is not decided here.
- **Docs (seat, at ACCEPT):** `content/docs/automation/flows.mdx`'s
status table lists the engine's dispatch outcomes, and the door's caller
refusals (the anonymous floor, and now this one) sit outside it, so no
published sentence becomes false. A `403` row fits best once #22407
settles the other doors. Carrier: #22407's PR.
- **`authz-conformance.matrix.ts`** (row `anonymous-deny-automation`):
its `enforcement` prose says the execution doors sit outside every
per-route capability predicate. Since this PR, the trigger door carries
its own caller × flow check. That is prose only, and
`authz-conformance.test.ts` passes. Carrier: the next PR to edit that
matrix.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent abd2545 commit ca135dc
8 files changed
Lines changed: 787 additions & 25 deletions
File tree
- .changeset
- content/docs/permissions
- packages
- qa/dogfood/test
- fixtures
- runtime/src/domains
- verify/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
13 | | - | |
| 13 | + | |
14 | 14 | | |
15 | 15 | | |
16 | 16 | | |
| |||
141 | 141 | | |
142 | 142 | | |
143 | 143 | | |
144 | | - | |
| 144 | + | |
145 | 145 | | |
146 | 146 | | |
147 | 147 | | |
| |||
180 | 180 | | |
181 | 181 | | |
182 | 182 | | |
183 | | - | |
| 183 | + | |
184 | 184 | | |
185 | 185 | | |
186 | 186 | | |
| |||
284 | 284 | | |
285 | 285 | | |
286 | 286 | | |
287 | | - | |
| 287 | + | |
288 | 288 | | |
289 | 289 | | |
290 | 290 | | |
| |||
358 | 358 | | |
359 | 359 | | |
360 | 360 | | |
361 | | - | |
| 361 | + | |
362 | 362 | | |
363 | 363 | | |
364 | 364 | | |
365 | | - | |
366 | | - | |
| 365 | + | |
| 366 | + | |
367 | 367 | | |
368 | 368 | | |
369 | 369 | | |
370 | | - | |
| 370 | + | |
371 | 371 | | |
372 | 372 | | |
373 | 373 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
26 | 26 | | |
27 | 27 | | |
28 | 28 | | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
29 | 37 | | |
30 | 38 | | |
31 | 39 | | |
| |||
114 | 122 | | |
115 | 123 | | |
116 | 124 | | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
117 | 166 | | |
118 | 167 | | |
119 | 168 | | |
| |||
125 | 174 | | |
126 | 175 | | |
127 | 176 | | |
128 | | - | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
129 | 185 | | |
130 | 186 | | |
131 | 187 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
31 | 41 | | |
32 | 42 | | |
33 | 43 | | |
| |||
134 | 144 | | |
135 | 145 | | |
136 | 146 | | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
137 | 176 | | |
138 | 177 | | |
139 | 178 | | |
| |||
158 | 197 | | |
159 | 198 | | |
160 | 199 | | |
161 | | - | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
162 | 203 | | |
163 | 204 | | |
164 | 205 | | |
165 | 206 | | |
166 | 207 | | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
167 | 216 | | |
168 | 217 | | |
169 | 218 | | |
| |||
188 | 237 | | |
189 | 238 | | |
190 | 239 | | |
191 | | - | |
192 | | - | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
193 | 246 | | |
194 | 247 | | |
195 | 248 | | |
| |||
0 commit comments