Repository navigation
fix(runtime): the trigger door refuses a self-triggered system flow to a non-system caller - #22404
objectstack-fleet[bot] wants to merge 6 commits into
Conversation
…r type and caller Measure-first stage, committed before any fix. Through `flows.run` (the in-process twin of POST /api/v1/automation/:name/trigger) on the real kernel, a signed-in member with no grant on the target object starts a flow declared runAs: 'system' of every type (autolaunched, record_change, schedule, screen, api), and the elevated write lands each time. The table also records the non-elevated control, a parent flow's subflow call, the platform admin and the system principal. This is the before-table the door check is judged against. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude <noreply@anthropic.com>
…o a non-system caller A self-triggered flow declared to run as system could be started by any signed-in member through the trigger door, which asked only whether the caller was anonymous. `respondToFlowTrigger` now refuses, after the existence check and before dispatch, a caller that is not the system principal starting a flow declared runAs: 'system' whose type is autolaunched, record_change or schedule: 403 PERMISSION_DENIED, nothing dispatched, nothing of the flow disclosed. screen and api flows, non-elevated flows, a parent flow's subflow call and the system principal are unchanged. The door reads the declaration through the automation service's own getFlow probe (the one the existence check uses), so there is no second loader and no copy of the engine's run-as policy. Pins: door-side per arm with a scripted service (runtime), and the wire half through flows.run on the real kernel (verify), whose table was measured before the check existed. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude <noreply@anthropic.com>
…nces Under ablation of the door check it passed over an empty message. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude <noreply@anthropic.com>
…nder tsc Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 33 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a637280724ccd1114b6dfff04a3cdc66a85700ac && git checkout a637280724ccd1114b6dfff04a3cdc66a85700ac
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fdfdd7e76da8fc206849a5aae7018633f5893b9e 799c28789fff6644d8f57f9dce3cb7e63bd56f9e && git checkout -B drift-repro fdfdd7e76da8fc206849a5aae7018633f5893b9e && git merge --no-ff 799c28789fff6644d8f57f9dce3cb7e63bd56f9e
node scripts/docs-audit/affected-docs.mjs --json fdfdd7e76da8fc206849a5aae7018633f5893b9e
|
…vated self-triggered start The trigger door's new caller check reads ExecutionContext.isSystem, so the automation-domain row gains its anchor and sentence, and the declared counts are regenerated (118 to 119 read sites). Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude <noreply@anthropic.com>
…ow at the trigger door assert the ruled door flow-runas: the elevation legs reach each system flow through a runAs-user parent's subflow node, and a new case pins the member's direct start of either system flow: 403 PERMISSION_DENIED, the note untouched, no run. schedule-acting-organization control B: the session drives the declaring flow's door twin (same nodes and declaration, type screen, no cadence), and the declaring flow itself is pinned refused to the session: 403 PERMISSION_DENIED, nothing delivered, no run. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude <noreply@anthropic.com>
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'whosetypeisautolaunched,record_changeorschedule. This applies atPOST /api/v1/automation/:name/trigger, at the legacyPOST /api/v1/automation/trigger/:name, and at@objectstack/verify'sflows.run, all of which answer through one function.Measure first: the before-table
All rows were measured through
flows.runon the real kernel: auth, security middleware and the real automation engine. The "before" column was taken atcd46ffa4, 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 with403 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.typecd46ffa4)runAs: 'system'autolaunchedPERMISSION_DENIED, no row, no runrunAs: 'system'record_changerunAs: 'system'schedulerunAs: 'system'screenrunAs: 'system'apirunAs: 'user'autolaunchedFLOW_FAILED, no row, runrunAs: 'user'parent whosesubflownode calls therunAs: 'system'autolaunchedwriterautolaunchedrunAs: 'system'autolaunched/record_change/schedulerunAs: 'system'autolaunched/record_change/scheduleThe card measured two
autolaunchedpaths. This table also measuresrecord_change,schedule,screenandapi: 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:respondToFlowTriggernow asksrefusesElevatedSelfTriggeredStartafter the existence check and beforeexecute. 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 declaresrunAs: 'system', and itstypeis one of the three self-triggered types. The type set is bound to the spec'sFlow.typeenum at compile time.403PERMISSION_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.getFlow, the probeflowIsUnknownalready uses, which serves the same definitionexecuteruns. There is no second loader and no copy of the engine's run-as policy in the transport (mechanism hypothesis H5 holds).getFlowis optional onIAutomationService. A service that omits it cannot be asked, so the door dispatches as before, exactly as the existence check does (see Acceptance notes).service-automation.content/docs/permissions/system-context.mdx: the automation-domain row anchors#refusesElevatedSelfTriggeredStartand names the new bypass (the in-process system principal starting an elevated self-triggered flow at the trigger door). The declared counts are regenerated bypnpm gen:system-context-census(118 to 119 read sites). No other row is touched.Mechanism hypotheses, each checked by reading the code and measuring:
execute.flows.rundispatchesPOST /automation/:name/triggerthrough the runtime'sHttpDispatcher, which reachesrespondToFlowTrigger.subflow-node.tsandmap-node.tsstart the child throughengine.execute. The subflow row above is the measured positive control.buildAutomationContextdoes not forwardisSystem. Elevation comes fromflow.runAsinside the engine. The door readsisSystemonly 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, assertingcode,statusand thatexecutewas 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 elevatedscreenandapiflows, for an unknown name still answering 404, and for a service withoutgetFlow.packages/verify/src/automation-trigger-elevated-door.test.ts(the wire, real kernel, throughflows.run): the table above, oneitper 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.tswithfixtures/flow-runas-fixture.ts, andpackages/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 throughscripts/ablation-replace.mjs, which reported anchor 1 to 0 and blob17cef819to3edb21bc):ablation-dist-preflight: present indist/index.jsanddist/index.cjs).12f88829.git diff HEADis empty, a rebuild was done,ablation-dist-preflight --absentreports the marker absent from all 6 built files, and the tree is clean.Tests and gates (head
799c28789)pnpm --filter @objectstack/runtime testat0e92dbe4(runtime source unchanged since): 342 files, 4815 passed, 19 skipped. At799c28789, the 42 runtime test files that drive the trigger door: 1026 passed.pnpm --filter @objectstack/runtime typecheckandpnpm --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 testat0e92dbe4: 20 files, 154 passed. At799c28789, the 7 verify files that drive the door: 57 passed.799c28789: every dogfood file that drives the trigger door (9) plusauthz-conformance.test.ts: 10 files, 168 passed.pnpm lint(the full run): exit 0 at799c28789.node scripts/check-system-context-census.mjs: OK, 119 read sites, 115 of 115 required symbols cited.799c28789(94 families): all 94 exit 0.pnpm check:dual-build-cjs-loadsandcheck:skill-examplesfirst answeredPREREQUISITE NOT METand 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 (
/triggerin either spelling,flows.run,handleAutomation,automation.trigger: 74 files acrosspackages/runtime,packages/verify,packages/qa/dogfood,packages/client,packages/services/service-automation,packages/spec,packages/lintandpackages/objectql, with no hits inexamples/orapps/) found exactly 3 pins whose meaning the ruling reverses: a non-system caller starting arunAs: '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 proofflow-run-as): the two elevation legs now reach each system flow through arunAs: 'user'parent whosesubflownode calls it (runas_system_touch_via_parent,runas_system_read_via_parent, added to the fixture). The write leg still stamps the admin's notetouched-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 answers403 PERMISSION_DENIED, with no innerdata, the note left atnewand 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 sameorganizationdeclaration,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'sHttpDispatcher, which@objectstack/dogfooddoes 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.jsanddist/index.cjs): exactly the 2 refusal pins went red (the direct-start case inflow-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,--absentreports 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
117d34de: no UItype: 'flow'action inexamples/targets one (the two action targets found arescreenflows declaredrunAs: 'user').type, as ruled, not on the resolved trigger kind. Anautolaunchedflow whose start node binds another trigger (a record event, a cadence, an inbound hook) is refused at this door like any otherautolaunchedone. Ascreenorapiflow is admitted whatever its start node binds.getFlow. The door can only read a declaration the automation service will serve. The platform engine always implementsgetFlow. An alternativeIAutomationServicethat omits it gets today's behaviour, the same "no evidence, dispatch as before" reading the existence check takes.type: 'flow'served atPOST /api/v1/actions/:object/:action, and a declared endpoint oftype: 'flow'withauthRequired: 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 anautolaunchedand ascheduletarget, and the endpoint door with anautolaunchedtarget. Each answered 200 and the elevated write landed. An anonymous request at the endpoint answers 401. The seat filed this as [finding] runtime(security): two other doors that start a flow by name still let any signed-in member start a self-triggered system flow, after #22310 closes the trigger door #22407, and it is not decided here.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. A403row fits best once [finding] runtime(security): two other doors that start a flow by name still let any signed-in member start a self-triggered system flow, after #22310 closes the trigger door #22407 settles the other doors. Carrier: [finding] runtime(security): two other doors that start a flow by name still let any signed-in member start a self-triggered system flow, after #22310 closes the trigger door #22407's PR.authz-conformance.matrix.ts(rowanonymous-deny-automation): itsenforcementprose 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, andauthz-conformance.test.tspasses. Carrier: the next PR to edit that matrix.Generated by Claude Code