Skip to content

Commit ca135dc

Browse files
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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): the trigger door refuses a self-triggered flow declared to run as system to any caller but the system principal
6+
7+
Clause-②: no
8+
9+
A self-triggered flow declared to run as system could be started by any signed-in member through the trigger door. `POST /api/v1/automation/:name/trigger` (and the legacy `POST /api/v1/automation/trigger/:name`, and `@objectstack/verify`'s `flows.run`, which answer through the same function) asked only whether the caller was anonymous, so a flow meant to run on its own trigger, or as a sub-flow, also ran elevated for anyone who named it.
10+
11+
**What changes.** A caller that is not the system principal now gets `403 PERMISSION_DENIED` (ADR-0112 envelope) when it starts a flow declared `runAs: 'system'` whose `type` is `autolaunched`, `record_change` or `schedule`. Nothing runs: no run record, no node, no side effect. The refusal names no part of the flow. This includes a platform admin's session, which is a signed-in user, not the system principal.
12+
13+
**What stays as it was.**
14+
15+
- `screen` and `api` flows, elevated or not: doors the author designed (ADR-0073 D2).
16+
- Every flow that does not declare `runAs: 'system'`.
17+
- A parent flow's `subflow` node calling an elevated flow: the child starts through the engine, never through the door.
18+
- The system principal (an in-process caller, such as a job), which starts every flow.
19+
20+
**If a call now answers 403.** That flow runs on its own trigger. To start its work on a user's request, call it from a parent flow's `subflow` node, or, if it is meant to be a door, declare it `type: 'screen'` (or `type: 'api'` for a signed inbound hook) so the elevation is a reviewable choice.

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
1010
service self-write, a migration.
1111

1212
This page is **the authority** for what that flag actually does. It exists
13-
because the flag is not one concept: it is a single boolean read at **118
13+
because the flag is not one concept: it is a single boolean read at **119
1414
distinct sites across 20 packages**, and knowing three of those behaviours gives
1515
no hint that the other hundred-and-four exist. Every documented app-side bug
1616
traced to `isSystem` had the same shape — the metadata was complete and correct,
@@ -141,7 +141,7 @@ that silently does not happen.
141141

142142
### 3. Sharing (`plugin-sharing`)
143143

144-
The largest single consumer — **17 of the 118 sites**.
144+
The largest single consumer — **17 of the 119 sites**.
145145

146146
| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
147147
|:--|:---|:---|:---|
@@ -180,7 +180,7 @@ The largest single consumer — **17 of the 118 sites**.
180180
| 53 | Package REST route capability gate bypassed | rest | Get: a marketplace publish over REST (`POST /packages/publish`, the one route the REST registrar mounts since #14503) without `manage_metadata`; the package read cohort (`studio.access` / `setup.access`) is enforced by the dispatcher `/packages` domain's own read gate, where the reads are served | `packages/rest/src/package-routes.ts#refusePackageRequest` |
181181
| 54 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability` |
182182
| 55 | 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` |
183-
| 56 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume) and the two operator run-lifecycle writes 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 | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#isRunStarterOrRunStateReader`, `#refuseUngrantedRunLifecycleWrite` |
183+
| 56 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the trigger door's elevated self-triggered start 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, through the trigger door, a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`), which every other caller is refused `403 PERMISSION_DENIED` (the maintainer's ruling, letter B). That bypass 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`, `#refusesElevatedSelfTriggeredStart` |
184184
| 57 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertTenantAdmin` |
185185
| 58 | 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), so the seeds, the boot sweep and the projection of a Studio save are the only writers. Lose (`sys_webhook`): the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` |
186186
| 59 | **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` |
@@ -284,7 +284,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
284284
independent decisions, and a seed loader plausibly wants the first two but not
285285
the third. The concept is nevertheless **staying as one boolean**:
286286

287-
- **Shipped semantics.** `isSystem` is a published contract with 118 read sites
287+
- **Shipped semantics.** `isSystem` is a published contract with 119 read sites
288288
in 20 packages. Splitting it is a breaking contract change across all of them.
289289
(The ruling was taken when the census read 80 sites in 18 packages; the count
290290
has grown, which strengthens rather than weakens the argument.)
@@ -358,16 +358,16 @@ still holds equal to the census on every pull request:
358358
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
359359
| — parsed as a declaration | 27 | ✅ |
360360
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
361-
| — parsed as a property **read** | 124 | ✅ |
361+
| — parsed as a property **read** | 125 | ✅ |
362362
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
363363
| — the remainder: text inside comments and string literals | 358 | — |
364364
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
365-
| Of those reads: reads of `ExecutionContext.isSystem` | **118** | ✅ |
366-
| — behaviour-bearing (rows 1–61 above) | 115 | ✅ |
365+
| Of those reads: reads of `ExecutionContext.isSystem` | **119** | ✅ |
366+
| — behaviour-bearing (rows 1–61 above) | 116 | ✅ |
367367
| — carry the flag onward only (rows 62–64 above) | 3 | ✅ |
368368
| Packages containing at least one elevation read | **20** | ✅ |
369369
| Files containing at least one elevation read | 55 | ✅ |
370-
| — the distinct symbols those reads live in — what this page anchors | 101 | ✅ |
370+
| — the distinct symbols those reads live in — what this page anchors | 102 | ✅ |
371371
| — of those files, the ones holding more than one read in one symbol | 8 | ✅ |
372372

373373
The six rows marked — are a **dated decomposition, not a live claim**: they were

‎packages/qa/dogfood/test/fixtures/flow-runas-fixture.ts‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@
2626
//
2727
// Before the fix the user-mode flows wrongly succeed (security skipped) → RED.
2828
// After the fix they are correctly denied while system-mode still succeeds → GREEN.
29+
//
30+
// The two system flows are `autolaunched`, and since the maintainer's ruling on
31+
// the trigger door (letter B) a caller that is not the system principal may not
32+
// start a `runAs: 'system'` flow of a self-triggered type through that door. So
33+
// the member reaches them the way the platform keeps open for an elevated write:
34+
// through a `runAs: 'user'` PARENT whose `subflow` node calls the elevated child
35+
// (`runas_system_touch_via_parent` / `runas_system_read_via_parent`). The
36+
// elevation the proof is about still belongs to the child; the parent adds none.
2937

3038
import { defineStack } from '@objectstack/spec';
3139
import { ObjectSchema, Field } from '@objectstack/spec/data';
@@ -114,6 +122,47 @@ export const runasUserTouch = touchFlow('runas_user_touch', 'user', 'touched-use
114122
export const runasSystemRead = readFlow('runas_system_read', 'system');
115123
export const runasUserRead = readFlow('runas_user_read', 'user');
116124

125+
/**
126+
* `<child>_via_parent` — start → subflow(child) → end, under `runAs: 'user'`:
127+
* the non-elevated parent a member may start at the trigger door, which hands
128+
* `noteId` to the elevated child. With `outputVariable`, the child's outputs
129+
* land on the parent's `found` output (so a read child's own `found` arrives as
130+
* `output.found.found` on the trigger response).
131+
*/
132+
function viaParent(child: Flow, withOutput: boolean): Flow {
133+
return {
134+
name: `${child.name}_via_parent`,
135+
label: `${child.label} (via a user-mode parent)`,
136+
type: 'autolaunched',
137+
runAs: 'user',
138+
variables: [
139+
{ name: 'noteId', type: 'text', isInput: true },
140+
...(withOutput ? [{ name: 'found', type: 'object', isOutput: true }] : []),
141+
],
142+
nodes: [
143+
{ id: 'start', type: 'start', label: 'Start' },
144+
{
145+
id: 'call',
146+
type: 'subflow',
147+
label: 'Call the elevated child',
148+
config: {
149+
flowName: child.name,
150+
input: { noteId: '{noteId}' },
151+
...(withOutput ? { outputVariable: 'found' } : {}),
152+
},
153+
},
154+
{ id: 'end', type: 'end', label: 'End' },
155+
],
156+
edges: [
157+
{ id: 'e1', source: 'start', target: 'call' },
158+
{ id: 'e2', source: 'call', target: 'end' },
159+
],
160+
};
161+
}
162+
163+
export const runasSystemTouchViaParent = viaParent(runasSystemTouch, false);
164+
export const runasSystemReadViaParent = viaParent(runasSystemRead, true);
165+
117166
/** A minimal, self-contained app config the dogfood harness can boot. */
118167
export const runasFixtureStack = defineStack({
119168
manifest: {
@@ -125,7 +174,14 @@ export const runasFixtureStack = defineStack({
125174
description: 'Owner-isolated single-object app exercising flow.runAs identity enforcement.',
126175
},
127176
objects: [RunAsNote],
128-
flows: [runasSystemTouch, runasUserTouch, runasSystemRead, runasUserRead],
177+
flows: [
178+
runasSystemTouch,
179+
runasUserTouch,
180+
runasSystemRead,
181+
runasUserRead,
182+
runasSystemTouchViaParent,
183+
runasSystemReadViaParent,
184+
],
129185
});
130186

131187
/**

‎packages/qa/dogfood/test/flow-runas.dogfood.test.ts‎

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@
2828
// different bug and must not pass here.
2929
// Before the #1888 fix the user flows wrongly succeed (CRUD nodes passed no
3030
// identity → security skipped) → this file is RED; after the fix → GREEN.
31+
//
32+
// The trigger door's own rule (the maintainer's ruling, letter B): a caller that
33+
// is not the system principal may not start a `runAs: 'system'` flow of a
34+
// self-triggered type — and both system flows here are `autolaunched` — through
35+
// `POST /automation/:name/trigger`. So the elevation legs reach each system flow
36+
// the way the platform keeps open for an elevated write: the member starts a
37+
// `runAs: 'user'` parent whose `subflow` node calls it. The proof's subject is
38+
// unchanged — what the CHILD's data nodes run as — and a separate case pins the
39+
// door's half: the member's DIRECT start of either system flow is refused
40+
// `403 PERMISSION_DENIED`, the note is untouched and no run is recorded.
3141

3242
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
3343
import { bootStack, type VerifyStack } from '@objectstack/verify';
@@ -134,6 +144,35 @@ describe('objectstack verify FLOW: runAs identity enforcement (#flow-runas)', ()
134144
expect(failed?.status, `no failure entry for node '${failingNodeId}' in ${text}`).toBe('failure');
135145
}
136146

147+
/** Run-log entries the engine holds for `flow` — the "nothing ran" witness. */
148+
async function runCount(flow: string): Promise<number> {
149+
const automation = stack.kernel.getService('automation') as { listRuns(name: string): Promise<unknown[]> };
150+
return (await automation.listRuns(flow)).length;
151+
}
152+
153+
/**
154+
* Start a flow DIRECTLY as the restricted member and require the trigger door
155+
* to refuse it: `403 PERMISSION_DENIED` in the ADR-0112 envelope, no inner
156+
* `data`, and no run recorded for the flow — the door refuses before
157+
* dispatch, so the engine is never asked.
158+
*/
159+
async function memberDirectTriggerExpectingDoorRefusal(flow: string, noteId: string) {
160+
const runsBefore = await runCount(flow);
161+
const res = await stack.apiAs(memberToken, 'POST', `/automation/${flow}/trigger`, { params: { noteId } });
162+
const text = await res.clone().text();
163+
expect(res.status, `direct trigger of ${flow} should be refused 403: ${res.status} ${text}`).toBe(403);
164+
const body = (await res.json()) as {
165+
success?: boolean;
166+
data?: unknown;
167+
error?: { code?: string; httpStatus?: number };
168+
};
169+
expect(body.success).toBe(false);
170+
expect(body.error?.code, `expected PERMISSION_DENIED: ${text}`).toBe('PERMISSION_DENIED');
171+
expect(body.error?.httpStatus).toBe(403);
172+
expect(body.data).toBeUndefined();
173+
expect(await runCount(flow), `a refused start of ${flow} still recorded a run`).toBe(runsBefore);
174+
}
175+
137176
it('precondition: the automation service is wired and a flow is registered', async () => {
138177
const res = await stack.apiAs(memberToken, 'GET', '/automation/runas_system_touch');
139178
expect(res.status, `automation service not wired: ${res.status}`).toBe(200);
@@ -158,12 +197,22 @@ describe('objectstack verify FLOW: runAs identity enforcement (#flow-runas)', ()
158197

159198
it("runAs:'system' ELEVATES — member-triggered system flow WRITES a record the member cannot", async () => {
160199
const id = await adminCreateNote('sys-touch');
161-
const result = await memberTrigger('runas_system_touch', id);
200+
// Through the user-mode parent: the parent itself is RLS-bound as the
201+
// member, so the write landing is the elevated CHILD's doing.
202+
const result = await memberTrigger('runas_system_touch_via_parent', id);
162203
expect(result.success, `system flow run not successful: ${JSON.stringify(result)}`).toBe(true);
163204
// The elevated run bypassed RLS and stamped the admin's note.
164205
expect(await adminStatusOf(id)).toBe('touched-system');
165206
});
166207

208+
it("the trigger door refuses the member a DIRECT start of either runAs:'system' flow — 403, nothing written, no run", async () => {
209+
const id = await adminCreateNote('sys-direct');
210+
await memberDirectTriggerExpectingDoorRefusal('runas_system_touch', id);
211+
// Refused before dispatch: the elevated write never happened.
212+
expect(await adminStatusOf(id)).toBe('new');
213+
await memberDirectTriggerExpectingDoorRefusal('runas_system_read', id);
214+
});
215+
167216
it("runAs:'user' DE-ELEVATES — member-triggered user flow is RLS-DENIED on the same record", async () => {
168217
const id = await adminCreateNote('user-touch');
169218
// The de-elevated run reaches the record layer as the MEMBER and the write
@@ -188,8 +237,12 @@ describe('objectstack verify FLOW: runAs identity enforcement (#flow-runas)', ()
188237
it("runAs:'system' READS a record the member cannot; runAs:'user' cannot", async () => {
189238
const id = await adminCreateNote('read-check');
190239

191-
const sys = await memberTrigger('runas_system_read', id);
192-
expect(sys.output?.found, 'system flow could not read the record it should see (elevation broken)').toBeTruthy();
240+
// Through the user-mode parent, whose `found` output carries the elevated
241+
// child's outputs — so the child's own `found` is `output.found.found`.
242+
const sys = await memberTrigger('runas_system_read_via_parent', id);
243+
const sysFound = (sys.output?.found as { found?: { id?: unknown } } | undefined)?.found;
244+
expect(sysFound, 'system flow could not read the record it should see (elevation broken)').toBeTruthy();
245+
expect(sysFound?.id, 'the elevated read returned some row, not the admin note it was asked for').toBe(id);
193246

194247
const usr = await memberTrigger('runas_user_read', id);
195248
const found = usr.output?.found;

0 commit comments

Comments
 (0)