diff --git a/.changeset/22310-elevated-flow-trigger-door.md b/.changeset/22310-elevated-flow-trigger-door.md new file mode 100644 index 00000000000..4c4a6a637c5 --- /dev/null +++ b/.changeset/22310-elevated-flow-trigger-door.md @@ -0,0 +1,20 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime): the trigger door refuses a self-triggered flow declared to run as system to any caller but the system principal + +Clause-②: no + +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. + +**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. + +**What stays as it was.** + +- `screen` and `api` flows, elevated or not: doors the author designed (ADR-0073 D2). +- Every flow that does not declare `runAs: 'system'`. +- A parent flow's `subflow` node calling an elevated flow: the child starts through the engine, never through the door. +- The system principal (an in-process caller, such as a job), which starts every flow. + +**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. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 2955a7b302c..f060800af0e 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a service self-write, a migration. This page is **the authority** for what that flag actually does. It exists -because the flag is not one concept: it is a single boolean read at **118 +because the flag is not one concept: it is a single boolean read at **119 distinct sites across 20 packages**, and knowing three of those behaviours gives no hint that the other hundred-and-four exist. Every documented app-side bug traced to `isSystem` had the same shape — the metadata was complete and correct, @@ -141,7 +141,7 @@ that silently does not happen. ### 3. Sharing (`plugin-sharing`) -The largest single consumer — **17 of the 118 sites**. +The largest single consumer — **17 of the 119 sites**. | # | Behaviour when `isSystem` | What you get / what you lose | Anchor | |:--|:---|:---|:---| @@ -180,7 +180,7 @@ The largest single consumer — **17 of the 118 sites**. | 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` | | 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` | | 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` | -| 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` | +| 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` | | 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` | | 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` | | 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 independent decisions, and a seed loader plausibly wants the first two but not the third. The concept is nevertheless **staying as one boolean**: -- **Shipped semantics.** `isSystem` is a published contract with 118 read sites +- **Shipped semantics.** `isSystem` is a published contract with 119 read sites in 20 packages. Splitting it is a breaking contract change across all of them. (The ruling was taken when the census read 80 sites in 18 packages; the count has grown, which strengthens rather than weakens the argument.) @@ -358,16 +358,16 @@ still holds equal to the census on every pull request: | Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | | — parsed as a declaration | 27 | ✅ | | — parsed as an object-literal / type key (producers and option objects) | 310 | — | -| — parsed as a property **read** | 124 | ✅ | +| — parsed as a property **read** | 125 | ✅ | | — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | | — the remainder: text inside comments and string literals | 358 | — | | Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ | -| Of those reads: reads of `ExecutionContext.isSystem` | **118** | ✅ | -| — behaviour-bearing (rows 1–61 above) | 115 | ✅ | +| Of those reads: reads of `ExecutionContext.isSystem` | **119** | ✅ | +| — behaviour-bearing (rows 1–61 above) | 116 | ✅ | | — carry the flag onward only (rows 62–64 above) | 3 | ✅ | | Packages containing at least one elevation read | **20** | ✅ | | Files containing at least one elevation read | 55 | ✅ | -| — the distinct symbols those reads live in — what this page anchors | 101 | ✅ | +| — the distinct symbols those reads live in — what this page anchors | 102 | ✅ | | — of those files, the ones holding more than one read in one symbol | 8 | ✅ | The six rows marked — are a **dated decomposition, not a live claim**: they were diff --git a/packages/qa/dogfood/test/fixtures/flow-runas-fixture.ts b/packages/qa/dogfood/test/fixtures/flow-runas-fixture.ts index d964d5602a4..7b2a8662c97 100644 --- a/packages/qa/dogfood/test/fixtures/flow-runas-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/flow-runas-fixture.ts @@ -26,6 +26,14 @@ // // Before the fix the user-mode flows wrongly succeed (security skipped) → RED. // After the fix they are correctly denied while system-mode still succeeds → GREEN. +// +// The two system flows are `autolaunched`, and since the maintainer's ruling on +// the trigger door (letter B) a caller that is not the system principal may not +// start a `runAs: 'system'` flow of a self-triggered type through that door. So +// the member reaches them the way the platform keeps open for an elevated write: +// through a `runAs: 'user'` PARENT whose `subflow` node calls the elevated child +// (`runas_system_touch_via_parent` / `runas_system_read_via_parent`). The +// elevation the proof is about still belongs to the child; the parent adds none. import { defineStack } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; @@ -114,6 +122,47 @@ export const runasUserTouch = touchFlow('runas_user_touch', 'user', 'touched-use export const runasSystemRead = readFlow('runas_system_read', 'system'); export const runasUserRead = readFlow('runas_user_read', 'user'); +/** + * `_via_parent` — start → subflow(child) → end, under `runAs: 'user'`: + * the non-elevated parent a member may start at the trigger door, which hands + * `noteId` to the elevated child. With `outputVariable`, the child's outputs + * land on the parent's `found` output (so a read child's own `found` arrives as + * `output.found.found` on the trigger response). + */ +function viaParent(child: Flow, withOutput: boolean): Flow { + return { + name: `${child.name}_via_parent`, + label: `${child.label} (via a user-mode parent)`, + type: 'autolaunched', + runAs: 'user', + variables: [ + { name: 'noteId', type: 'text', isInput: true }, + ...(withOutput ? [{ name: 'found', type: 'object', isOutput: true }] : []), + ], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'call', + type: 'subflow', + label: 'Call the elevated child', + config: { + flowName: child.name, + input: { noteId: '{noteId}' }, + ...(withOutput ? { outputVariable: 'found' } : {}), + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'call' }, + { id: 'e2', source: 'call', target: 'end' }, + ], + }; +} + +export const runasSystemTouchViaParent = viaParent(runasSystemTouch, false); +export const runasSystemReadViaParent = viaParent(runasSystemRead, true); + /** A minimal, self-contained app config the dogfood harness can boot. */ export const runasFixtureStack = defineStack({ manifest: { @@ -125,7 +174,14 @@ export const runasFixtureStack = defineStack({ description: 'Owner-isolated single-object app exercising flow.runAs identity enforcement.', }, objects: [RunAsNote], - flows: [runasSystemTouch, runasUserTouch, runasSystemRead, runasUserRead], + flows: [ + runasSystemTouch, + runasUserTouch, + runasSystemRead, + runasUserRead, + runasSystemTouchViaParent, + runasSystemReadViaParent, + ], }); /** diff --git a/packages/qa/dogfood/test/flow-runas.dogfood.test.ts b/packages/qa/dogfood/test/flow-runas.dogfood.test.ts index 7fc0dfa147d..6da378a629f 100644 --- a/packages/qa/dogfood/test/flow-runas.dogfood.test.ts +++ b/packages/qa/dogfood/test/flow-runas.dogfood.test.ts @@ -28,6 +28,16 @@ // different bug and must not pass here. // Before the #1888 fix the user flows wrongly succeed (CRUD nodes passed no // identity → security skipped) → this file is RED; after the fix → GREEN. +// +// The trigger door's own rule (the maintainer's ruling, letter B): a caller that +// is not the system principal may not start a `runAs: 'system'` flow of a +// self-triggered type — and both system flows here are `autolaunched` — through +// `POST /automation/:name/trigger`. So the elevation legs reach each system flow +// the way the platform keeps open for an elevated write: the member starts a +// `runAs: 'user'` parent whose `subflow` node calls it. The proof's subject is +// unchanged — what the CHILD's data nodes run as — and a separate case pins the +// door's half: the member's DIRECT start of either system flow is refused +// `403 PERMISSION_DENIED`, the note is untouched and no run is recorded. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { bootStack, type VerifyStack } from '@objectstack/verify'; @@ -134,6 +144,35 @@ describe('objectstack verify FLOW: runAs identity enforcement (#flow-runas)', () expect(failed?.status, `no failure entry for node '${failingNodeId}' in ${text}`).toBe('failure'); } + /** Run-log entries the engine holds for `flow` — the "nothing ran" witness. */ + async function runCount(flow: string): Promise { + const automation = stack.kernel.getService('automation') as { listRuns(name: string): Promise }; + return (await automation.listRuns(flow)).length; + } + + /** + * Start a flow DIRECTLY as the restricted member and require the trigger door + * to refuse it: `403 PERMISSION_DENIED` in the ADR-0112 envelope, no inner + * `data`, and no run recorded for the flow — the door refuses before + * dispatch, so the engine is never asked. + */ + async function memberDirectTriggerExpectingDoorRefusal(flow: string, noteId: string) { + const runsBefore = await runCount(flow); + const res = await stack.apiAs(memberToken, 'POST', `/automation/${flow}/trigger`, { params: { noteId } }); + const text = await res.clone().text(); + expect(res.status, `direct trigger of ${flow} should be refused 403: ${res.status} ${text}`).toBe(403); + const body = (await res.json()) as { + success?: boolean; + data?: unknown; + error?: { code?: string; httpStatus?: number }; + }; + expect(body.success).toBe(false); + expect(body.error?.code, `expected PERMISSION_DENIED: ${text}`).toBe('PERMISSION_DENIED'); + expect(body.error?.httpStatus).toBe(403); + expect(body.data).toBeUndefined(); + expect(await runCount(flow), `a refused start of ${flow} still recorded a run`).toBe(runsBefore); + } + it('precondition: the automation service is wired and a flow is registered', async () => { const res = await stack.apiAs(memberToken, 'GET', '/automation/runas_system_touch'); expect(res.status, `automation service not wired: ${res.status}`).toBe(200); @@ -158,12 +197,22 @@ describe('objectstack verify FLOW: runAs identity enforcement (#flow-runas)', () it("runAs:'system' ELEVATES — member-triggered system flow WRITES a record the member cannot", async () => { const id = await adminCreateNote('sys-touch'); - const result = await memberTrigger('runas_system_touch', id); + // Through the user-mode parent: the parent itself is RLS-bound as the + // member, so the write landing is the elevated CHILD's doing. + const result = await memberTrigger('runas_system_touch_via_parent', id); expect(result.success, `system flow run not successful: ${JSON.stringify(result)}`).toBe(true); // The elevated run bypassed RLS and stamped the admin's note. expect(await adminStatusOf(id)).toBe('touched-system'); }); + it("the trigger door refuses the member a DIRECT start of either runAs:'system' flow — 403, nothing written, no run", async () => { + const id = await adminCreateNote('sys-direct'); + await memberDirectTriggerExpectingDoorRefusal('runas_system_touch', id); + // Refused before dispatch: the elevated write never happened. + expect(await adminStatusOf(id)).toBe('new'); + await memberDirectTriggerExpectingDoorRefusal('runas_system_read', id); + }); + it("runAs:'user' DE-ELEVATES — member-triggered user flow is RLS-DENIED on the same record", async () => { const id = await adminCreateNote('user-touch'); // 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)', () it("runAs:'system' READS a record the member cannot; runAs:'user' cannot", async () => { const id = await adminCreateNote('read-check'); - const sys = await memberTrigger('runas_system_read', id); - expect(sys.output?.found, 'system flow could not read the record it should see (elevation broken)').toBeTruthy(); + // Through the user-mode parent, whose `found` output carries the elevated + // child's outputs — so the child's own `found` is `output.found.found`. + const sys = await memberTrigger('runas_system_read_via_parent', id); + const sysFound = (sys.output?.found as { found?: { id?: unknown } } | undefined)?.found; + expect(sysFound, 'system flow could not read the record it should see (elevation broken)').toBeTruthy(); + expect(sysFound?.id, 'the elevated read returned some row, not the admin note it was asked for').toBe(id); const usr = await memberTrigger('runas_user_read', id); const found = usr.output?.found; diff --git a/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts b/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts index 6912955a070..b452bf88d81 100644 --- a/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts +++ b/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts @@ -20,13 +20,20 @@ // ⚠️ Every assertion in this file passes vacuously if the run never happens at // all, which is why each pin also asserts a POSITIVE fact about the run // (the flow bound, the tick fired, the notification carries the declared id) and -// why the DIFFERENTIAL CONTROL below is in the same file: the same flow, on the -// same stack, through `POST /api/v1/automation/:name/trigger` under a session. -// That run reaches the identical `notify` node through the identical messaging -// chain, and its organization comes from the SESSION rather than from the -// declaration — so if the schedule pin ever goes green for a reason that has -// nothing to do with the fix, the control goes green the same way and the -// contrast that carries the proof is gone. +// why the DIFFERENTIAL CONTROL below is in the same file: the same nodes and the +// same declaration, on the same stack, through `POST /api/v1/automation/:name/trigger` +// under a session. That run reaches the identical `notify` node through the +// identical messaging chain, and its organization comes from the SESSION rather +// than from the declaration — so if the schedule pin ever goes green for a +// reason that has nothing to do with the fix, the control goes green the same +// way and the contrast that carries the proof is gone. +// +// Since the maintainer's ruling on the trigger door (letter B), no caller but +// the system principal may start the declared flow itself there — it is a +// `schedule` flow declared `runAs: 'system'` — so the session drives its DOOR +// TWIN (`sched_org_declared_door`: the same nodes and the same declaration, +// `type: 'screen'`, no cadence), and control B pins the refusal of the +// declared flow beside it. // // ## The multi-organization condition // @@ -53,6 +60,32 @@ import { const RUN_HISTORY_OBJECT = 'sys_automation_run'; const DECLARED_FLOW = 'sched_org_declared'; const UNDECLARED_FLOW = 'sched_org_undeclared'; +const DOOR_FLOW = 'sched_org_declared_door'; + +/** + * Control B's DOOR TWIN of the declaring flow: its nodes and its start node's + * `organization` declaration exactly, under its own name, as `type: 'screen'` + * and with the cadence removed — so nothing schedules it and the only way it + * runs is the trigger door. Derived from the same builder, never re-spelled, + * so the twin cannot drift into a different flow that merely looks alike. + * + * Why a twin: the declaring flow is a `schedule` flow declared + * `runAs: 'system'`, and since the maintainer's ruling on the trigger door + * (letter B) the door starts one only for the system principal — which a + * session never is. A `screen` flow is a door its author designed, so the + * session still starts the twin, and its run still reads which organization + * the DOOR hands the engine. + */ +function doorTwin(declaring: unknown): unknown { + const flow = declaring as { nodes: Array<{ id: string; config?: Record }> } & Record; + const nodes = flow.nodes.map((n) => { + if (n.id !== 'start') return n; + const config = { ...(n.config ?? {}) }; + delete config.schedule; + return { ...n, config }; + }); + return { ...flow, name: DOOR_FLOW, label: 'Digest (organization declared) — door twin', type: 'screen', nodes }; +} /** * A job service the test fires by hand. The platform's own adapter owns cron @@ -188,6 +221,7 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { automation.registerFlow(DECLARED_FLOW, declaringScheduleFlow(orgA, recipientId)); automation.registerFlow(UNDECLARED_FLOW, organizationLessScheduleFlow(recipientId)); + automation.registerFlow(DOOR_FLOW, doorTwin(declaringScheduleFlow(orgA, recipientId))); // ── [#17396] The DEPLOYMENT the pins below are about ─────────────── // @@ -514,8 +548,13 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { }); /** - * Control B — the card's own control: the same flow through - * `POST /api/v1/automation/:name/trigger` under a session. + * Control B — the card's own control: the same nodes and the same + * declaration through `POST /api/v1/automation/:name/trigger` under a + * session. The session drives the declaring flow's DOOR TWIN (see + * {@link doorTwin}); the declaring flow itself is refused to the session + * at that door (`403 PERMISSION_DENIED`, nothing delivered, no run), and + * that refusal is pinned here first, so the twin's run is the only run + * this control can observe. * * Driver-split, because the drivers genuinely differ here and the split is * pinned rather than papered over: @@ -547,7 +586,7 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { * day the driver gains isolation that goes red and whoever fixes it * enables the real control here. */ - it('control B: the same flow via POST /automation/:name/trigger under a session', async () => { + it('control B: the same nodes and declaration via POST /automation/:name/trigger under a session', async () => { if (databaseDriver === 'memory') { // Not the control — the control cannot run here. This pins the reason, // so the exemption expires by itself. @@ -575,7 +614,24 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { } const before = new Set((await rows(INBOX_OBJECT)).map((r) => String(r.id))); - const res = await stack.apiAs(memberToken, 'POST', `/automation/${DECLARED_FLOW}/trigger`, {}); + + // The declaring flow itself: refused to the session at the door, before + // dispatch — nothing delivered, no run recorded. + const runsBefore = (await automation.listRuns(DECLARED_FLOW)).length; + const refused = await stack.apiAs(memberToken, 'POST', `/automation/${DECLARED_FLOW}/trigger`, {}); + const refusedText = await refused.clone().text(); + expect( + refused.status, + `a session started the runAs-system schedule flow at the trigger door: ${refused.status} ${refusedText}`, + ).toBe(403); + expect(((await refused.json()) as { error?: { code?: string } }).error?.code, refusedText).toBe('PERMISSION_DENIED'); + expect((await automation.listRuns(DECLARED_FLOW)).length, 'the refused start still recorded a run').toBe(runsBefore); + expect( + (await rows(INBOX_OBJECT)).filter((r) => !before.has(String(r.id))), + 'the refused start still delivered — the door refused after dispatch', + ).toHaveLength(0); + + const res = await stack.apiAs(memberToken, 'POST', `/automation/${DOOR_FLOW}/trigger`, {}); expect( res.status, `the session-triggered run did not start (${res.status}) — the control cannot certify the pins above`, diff --git a/packages/runtime/src/domains/automation-trigger-elevated-door.test.ts b/packages/runtime/src/domains/automation-trigger-elevated-door.test.ts new file mode 100644 index 00000000000..cad8ed476c3 --- /dev/null +++ b/packages/runtime/src/domains/automation-trigger-elevated-door.test.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The trigger door × a flow declared `runAs: 'system'`: a caller that is not + * the system principal may not start one whose `type` is self-triggered + * (`autolaunched`, `record_change`, `schedule`). The maintainer's ruling + * (letter B) on the defect class "a self-triggered flow declared to run as + * system could be started by any signed-in member through the trigger door". + * + * These are the DOOR-side pins, driven with a scripted automation service so + * each one isolates the door's decision: `getFlow` serves the declaration the + * door reads, and `execute` is a spy, so "never dispatched" is a fact about the + * engine call rather than an inference from a status code. Both spellings of + * the door run every case — they answer through one function, and a pin that + * covered only the canonical one would let the legacy spelling (the one the SDK + * calls) drift unnoticed. + * + * The end-to-end half — the real kernel, a real member, a real engine, the + * elevated write that did or did not land, and a parent flow's `subflow` node + * still reaching its elevated child — is `@objectstack/verify`'s + * `automation-trigger-elevated-door.test.ts`, through `flows.run`. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { HttpDispatcher } from '../http-dispatcher.js'; + +/** A signed-in, non-system caller. */ +const MEMBER = { request: {}, executionContext: { userId: 'user_1' } } as any; +/** The system principal: the in-process caller a job or an internal dispatch is. */ +const SYSTEM = { request: {}, executionContext: { isSystem: true } } as any; + +/** Both spellings of the same door. `path` takes the flow name. */ +const ROUTES: Array<{ label: string; path: (flow: string) => string }> = [ + { label: 'POST /:name/trigger', path: (f) => `/${f}/trigger` }, + { label: 'legacy POST /trigger/:name', path: (f) => `/trigger/${f}` }, +]; + +const REFUSED_TYPES = ['autolaunched', 'record_change', 'schedule'] as const; +const DOOR_TYPES = ['screen', 'api'] as const; + +/** + * An automation service holding exactly the flow declarations it is given + * (`getFlow`, the probe the door reads), with `execute` as a spy answering a + * completed run. + */ +function makeDispatcher(flows: Array<{ name: string; type: string; runAs?: string }>, opts: { omitGetFlow?: boolean } = {}) { + const byName = new Map(flows.map((f) => [f.name, f])); + const execute = vi.fn(async (_name: string, _context?: unknown) => ({ success: true, output: {} })); + const getFlow = vi.fn(async (name: string) => byName.get(name) ?? null); + const automation: Record = opts.omitGetFlow ? { execute } : { execute, getFlow }; + const services: Record = { automation }; + const resolve = (name: string) => services[name]; + const kernel: any = { + getService: resolve, + getServiceAsync: async (name: string) => resolve(name), + context: { getService: resolve }, + }; + return { dispatcher: new HttpDispatcher(kernel), execute }; +} + +describe('a non-system caller is refused an elevated self-triggered flow at the trigger door', () => { + for (const route of ROUTES) { + for (const type of REFUSED_TYPES) { + it(`${route.label}: type '${type}', runAs 'system' → 403 PERMISSION_DENIED, never dispatched`, async () => { + const { dispatcher, execute } = makeDispatcher([{ name: 'elevated_flow', type, runAs: 'system' }]); + + const res = await dispatcher.handleAutomation(route.path('elevated_flow'), 'POST', {}, MEMBER); + + expect(res.handled).toBe(true); + expect(res.response?.status).toBe(403); + expect(res.response?.body?.success).toBe(false); + expect(res.response?.body?.error?.code).toBe('PERMISSION_DENIED'); + expect(res.response?.body?.error?.httpStatus).toBe(403); + // The refusal happens BEFORE dispatch: the engine was never + // asked, so no run record and no node ran. + expect(execute).not.toHaveBeenCalled(); + }); + } + + it(`${route.label}: the refusal discloses nothing of the flow — one answer for every refused type`, async () => { + const messages: string[] = []; + for (const type of REFUSED_TYPES) { + const name = `secret_${type}_flow`; + const { dispatcher } = makeDispatcher([{ name, type, runAs: 'system' }]); + const res = await dispatcher.handleAutomation(route.path(name), 'POST', {}, MEMBER); + // A refusal first — so the absence checks below cannot pass + // over an answer that carries no message at all. + expect(res.response?.status).toBe(403); + const message = String(res.response?.body?.error?.message ?? ''); + expect(message.length).toBeGreaterThan(0); + // Neither the flow's name, its type nor its run-as declaration. + expect(message).not.toContain(name); + expect(message).not.toContain(type); + expect(message).not.toMatch(/runAs|'system'/); + expect(res.response?.body?.error?.details).toBeUndefined(); + messages.push(message); + } + // Byte-identical across the three types: the answer carries no bit + // the refusal itself does not. + expect(new Set(messages).size).toBe(1); + }); + } +}); + +describe('what stays exactly as it was', () => { + for (const route of ROUTES) { + for (const type of REFUSED_TYPES) { + it(`${route.label}: the system principal still starts a '${type}' flow declared runAs 'system'`, async () => { + const { dispatcher, execute } = makeDispatcher([{ name: 'elevated_flow', type, runAs: 'system' }]); + + const res = await dispatcher.handleAutomation(route.path('elevated_flow'), 'POST', {}, SYSTEM); + + expect(res.response?.status).toBe(200); + expect(res.response?.body?.success).toBe(true); + expect(execute).toHaveBeenCalledTimes(1); + expect(execute.mock.calls[0]?.[0]).toBe('elevated_flow'); + }); + + it(`${route.label}: a member still starts a '${type}' flow that does not declare runAs 'system'`, async () => { + const { dispatcher, execute } = makeDispatcher([ + { name: 'user_flow', type, runAs: 'user' }, + // The scripted service may serve a declaration with no `runAs` + // at all (the parsed default is 'user'): not elevated either. + { name: 'bare_flow', type }, + ]); + + const asUser = await dispatcher.handleAutomation(route.path('user_flow'), 'POST', {}, MEMBER); + const bare = await dispatcher.handleAutomation(route.path('bare_flow'), 'POST', {}, MEMBER); + + expect(asUser.response?.status).toBe(200); + expect(bare.response?.status).toBe(200); + expect(execute).toHaveBeenCalledTimes(2); + }); + } + + for (const type of DOOR_TYPES) { + it(`${route.label}: a member still starts a '${type}' flow declared runAs 'system' — a door its author designed`, async () => { + const { dispatcher, execute } = makeDispatcher([{ name: 'door_flow', type, runAs: 'system' }]); + + const res = await dispatcher.handleAutomation(route.path('door_flow'), 'POST', {}, MEMBER); + + expect(res.response?.status).toBe(200); + expect(res.response?.body?.success).toBe(true); + expect(execute).toHaveBeenCalledTimes(1); + }); + } + + it(`${route.label}: an unknown name still answers 404 — existence is asked first`, async () => { + const { dispatcher, execute } = makeDispatcher([]); + + const res = await dispatcher.handleAutomation(route.path('no_such_flow'), 'POST', {}, MEMBER); + + expect(res.response?.status).toBe(404); + expect(execute).not.toHaveBeenCalled(); + }); + + it(`${route.label}: a service that cannot be asked for the declaration dispatches as before`, async () => { + const { dispatcher, execute } = makeDispatcher([], { omitGetFlow: true }); + + const res = await dispatcher.handleAutomation(route.path('any_flow'), 'POST', {}, MEMBER); + + expect(res.response?.status).toBe(200); + expect(execute).toHaveBeenCalledTimes(1); + }); + } +}); diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index da20e5fca29..17cef8197b2 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -30,7 +30,7 @@ import { isServiceServeable } from '../service-serveable.js'; import { validationFailure, validationFailureDetails, fieldsFromZodIssues, VALIDATION_FAILED_STATUS, } from '../validation-failure.js'; -import { ExecutionStatus } from '@objectstack/spec/automation'; +import { ExecutionStatus, type FlowParsed } from '@objectstack/spec/automation'; import { ListRunsRequestSchema } from '@objectstack/spec/api'; import type { ResumeFailureDetails } from '@objectstack/spec/api'; import { parseEnumParam, parseIntegerParam, parseStringParam } from '../query-param.js'; @@ -1684,6 +1684,87 @@ async function unregisterAndDeleteFlow( return undefined; } +/** + * The three flow types that start on their OWN trigger — or as a sub-flow + * called from a parent flow's `subflow` node — and never as a door: an + * `autolaunched` flow has no trigger of its own beyond a parent or an engine + * event, a `record_change` flow starts on a write, and a `schedule` flow on + * its cadence. Bound to the spec's `Flow.type` enum at compile time, so a + * member the spec drops reds this line. + * + * `screen` and `api` are deliberately NOT here: they are doors the author + * designed for users and API callers, and an elevated one is the author's + * explicit choice (ADR-0073 D2), reviewable at publish. + */ +const SELF_TRIGGERED_FLOW_TYPES: ReadonlySet = new Set( + ['autolaunched', 'record_change', 'schedule'] as const satisfies readonly FlowParsed['type'][], +); + +/** + * Refusal vocabulary for an elevated self-triggered flow at the trigger door + * (ADR-0112: code AND status) — the code and status this domain's other + * permission refusals already answer with (`RUN_READ_DENY_*`, + * `FLOW_WRITE_DENY_*`, `RUN_LIFECYCLE_DENY_*`). ⛔ No new code is minted here. + * + * The message names what admits such a flow and nothing about THIS flow: not + * its type, not its run-as declaration, not its definition. It reads the same + * for every refused flow, so it tells a caller nothing the refusal itself does + * not. + */ +const ELEVATED_TRIGGER_DENY_STATUS = 403; +const ELEVATED_TRIGGER_DENY_CODE = 'PERMISSION_DENIED'; +const ELEVATED_TRIGGER_DENY_MESSAGE = + 'This caller may not start this flow through the trigger door. A flow that runs on its own trigger ' + + "starts there, or as a sub-flow from a parent flow's `subflow` node."; + +/** + * Whether the trigger door refuses this caller starting `flowName`: the + * caller is not the system principal, AND the flow is declared + * `runAs: 'system'`, AND its `type` is one of {@link SELF_TRIGGERED_FLOW_TYPES}. + * + * Implements the maintainer's ruling (letter B) on the defect class "a + * self-triggered flow declared to run as system could be started by any + * signed-in member through the trigger door": the door used to ask only + * whether the caller was anonymous, so an elevated flow meant to run on its + * own trigger — or as a sub-flow, the platform's own pattern for an elevated + * write — was also an elevated door every signed-in user could open. Same + * direction as ADR-0073 D2 (`system` is an explicit opt-in, never a default) + * and as ADR-0138 D2b, its publish-time sibling (no anonymous flow door may + * target a `system` flow). + * + * What stays exactly as it was, and why each is outside this predicate: + * + * - **The system principal** (`executionContext.isSystem` — the same field + * the domain's anonymous floor reads, never set on inbound HTTP) still + * starts every flow. + * - **A parent flow's `subflow` node** starts its child through the engine + * (`engine.execute`), never through this door, so an elevated sub-flow + * called from its parent is untouched — the check lives at the door + * precisely so that path stays open. + * - **`screen` and `api` flows**, elevated or not, and every flow that does + * not declare `runAs: 'system'`. + * + * Reads the flow through the automation service's own `getFlow` — the probe + * {@link flowIsUnknown} uses, which serves the same definition `execute` + * runs — so there is no second loader and no second copy of the engine's + * run-as policy here: the predicate reads two declared keys and decides + * admission, while elevation stays the engine's. `getFlow` is optional on + * `IAutomationService`; an implementation that omits it cannot be asked, and + * the door dispatches as before, exactly as the existence check does. + */ +async function refusesElevatedSelfTriggeredStart( + automationService: IAutomationService, + flowName: string, + context: HttpProtocolContext, +): Promise { + const ec: any = (context as any)?.executionContext; + if (ec?.isSystem === true) return false; + if (typeof automationService.getFlow !== 'function') return false; + const flow = await automationService.getFlow(flowName); + if (!flow) return false; + return flow.runAs === 'system' && SELF_TRIGGERED_FLOW_TYPES.has(flow.type); +} + /** * [#9378] The ONE mapper both trigger doors answer through — `POST * /:name/trigger` and the legacy `POST /trigger/:name`, which @@ -1800,6 +1881,17 @@ async function respondToFlowTrigger( response: deps.error(flowNotFoundMessage(flowName), FLOW_NOT_FOUND_STATUS), }; } + // The caller × flow check: AFTER existence (an unknown name keeps its 404) + // and BEFORE dispatch, so a refused start runs nothing — no run record, no + // node, no side effect. See {@link refusesElevatedSelfTriggeredStart}. + if (await refusesElevatedSelfTriggeredStart(automationService, flowName, context)) { + return { + handled: true, + response: deps.error(ELEVATED_TRIGGER_DENY_MESSAGE, ELEVATED_TRIGGER_DENY_STATUS, { + code: ELEVATED_TRIGGER_DENY_CODE, + }), + }; + } const result = await automationService.execute(flowName, buildAutomationContext(body, context)); const refusal = classifyFlowRefusal(flowName, result); if (refusal) { @@ -2169,7 +2261,12 @@ export async function classifyResumeResult( * ran and failed → 400 `FLOW_FAILED`; #9378 + #9415; * a run that PAUSED → 200 with `runId` / `screen`, * on whichever attempt it paused — #9510) - * POST /:name/toggle → toggleFlow (unknown name → 404, #7535). Switches + * ⚑ a `runAs: 'system'` flow of a self-triggered + * type (`autolaunched`, `record_change`, + * `schedule`) — the system principal only; + * anyone else → 403 `PERMISSION_DENIED`, never + * dispatched (`refusesElevatedSelfTriggeredStart`) + * POST /:name/toggle → toggleFlow (unknown name → 404, #7535). Switches * PACKAGED flows only — it writes the ADR-0126 §7.2 * activation ledger. A flow no package ships → 409 * `RESOURCE_CONFLICT` naming that flow's own switch, diff --git a/packages/verify/src/automation-trigger-elevated-door.test.ts b/packages/verify/src/automation-trigger-elevated-door.test.ts new file mode 100644 index 00000000000..5fcc55307c2 --- /dev/null +++ b/packages/verify/src/automation-trigger-elevated-door.test.ts @@ -0,0 +1,313 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The trigger door × a flow declared `runAs: 'system'`, per flow type and per + * caller, through `flows.run` — the in-process twin of + * `POST /api/v1/automation/:name/trigger` (`handle.ts` drives the runtime's + * `HttpDispatcher`, so it answers through the same `respondToFlowTrigger`). + * + * The maintainer's ruling (letter B) on the defect class "a self-triggered flow + * declared to run as system could be started by any signed-in member through + * the trigger door": a caller that is not the system principal may not start a + * `runAs: 'system'` flow whose type is `autolaunched`, `record_change` or + * `schedule`. `screen` and `api` flows, a parent flow's `subflow` call and the + * system principal are unchanged. + * + * This is the wire half, on the real kernel — auth, security middleware, the + * real automation engine — so every row is a measurement, not a scripted + * service: the door's answer, the elevated write that did or did not land, and + * the run log. The door-side pins, one per arm with a scripted service, are + * `packages/runtime/src/domains/automation-trigger-elevated-door.test.ts`. + * + * The table was MEASURED FIRST, before the door check existed (the branch's + * first commit carries it as the assertion); each row's `before` comment is + * that reading, so the change the check makes is visible row by row. + * + * The fixture is neutral: one object no fresh member is granted (`etd_ledger`; + * a member's direct create on it is refused, which the first case pins as the + * control that makes every elevated write below meaningful), and one writer + * flow per type, each creating a ledger row named after itself. A row + * appearing is the side effect of the elevated run; the run log is the other + * witness. + * + * ⚠️ This suite resolves `@objectstack/runtime` and + * `@objectstack/service-automation` through their BUILT `dist/`. Rebuild both + * before trusting a run of this file — and especially an ablated one. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +import { HttpDispatcher } from '@objectstack/runtime'; +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import type { Flow } from '@objectstack/spec/automation'; + +import { bootStack, type VerifyStack } from './harness.js'; +import { isVerifyRefusal } from './handle.js'; + +// Booting the full in-process stack runs well past vitest's 5s default. +const BOOT_TIMEOUT = 120_000; + +const LEDGER = 'etd_ledger'; +const WATCH = 'etd_watch'; + +/** Granted to nobody but the platform admin: a member's direct create is refused. */ +const Ledger = ObjectSchema.create({ + name: LEDGER, + sharingModel: 'public_read_write', + label: 'Ledger', + pluralLabel: 'Ledgers', + fields: { name: Field.text({ label: 'Name', required: true }) }, +}); + +/** The object the record-change writer watches. Nothing in this file writes it. */ +const Watch = ObjectSchema.create({ + name: WATCH, + sharingModel: 'public_read_write', + label: 'Watch', + pluralLabel: 'Watches', + fields: { name: Field.text({ label: 'Name', required: true }) }, +}); + +/** start → create_record(etd_ledger, { name: }) → end. */ +function writer( + name: string, + type: Flow['type'], + runAs: 'system' | 'user', + startConfig?: Record, +): Flow { + return { + name, + label: name, + type, + status: 'active', + runAs, + nodes: [ + { id: 'start', type: 'start', label: 'Start', ...(startConfig ? { config: startConfig } : {}) }, + { id: 'write', type: 'create_record', label: 'Write', config: { objectName: LEDGER, fields: { name } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'write' }, + { id: 'e2', source: 'write', target: 'end' }, + ], + } as Flow; +} + +const AUTO_SYS = 'etd_auto_sys'; +const CHANGE_SYS = 'etd_change_sys'; +const SCHED_SYS = 'etd_sched_sys'; +const SCREEN_SYS = 'etd_screen_sys'; +const API_SYS = 'etd_api_sys'; +const AUTO_USER = 'etd_auto_user'; +const PARENT_USER = 'etd_parent_user'; + +const FLOWS: Flow[] = [ + writer(AUTO_SYS, 'autolaunched', 'system'), + // A cadence nobody reaches during a test run, and a watched object nobody writes: + // each of these two runs only when the door starts it. + writer(CHANGE_SYS, 'record_change', 'system', { objectName: WATCH, triggerType: 'record-after-update' }), + writer(SCHED_SYS, 'schedule', 'system', { schedule: { cron: '0 3 1 1 *' } }), + writer(SCREEN_SYS, 'screen', 'system'), + // ADR-0041: an `api` flow registers only with its per-flow secret. + writer(API_SYS, 'api', 'system', { secret: 'etd-fixture-secret' }), + writer(AUTO_USER, 'autolaunched', 'user'), + // The platform's own pattern for an elevated write: a non-elevated parent + // whose `subflow` node calls the elevated child. The child writes its own name. + { + name: PARENT_USER, + label: PARENT_USER, + type: 'autolaunched', + status: 'active', + runAs: 'user', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'sub', type: 'subflow', label: 'Elevated child', config: { flowName: AUTO_SYS } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'sub' }, + { id: 'e2', source: 'sub', target: 'end' }, + ], + } as Flow, +]; + +const fixtureStack = defineStack({ + manifest: { + id: 'com.objectstack.verify.elevated-trigger-door', + namespace: 'etd', + version: '0.0.0', + type: 'app', + name: 'Elevated Trigger Door Fixture', + description: 'One ungranted object and one elevated writer flow per flow type.', + }, + // ADR-0097: the record-change start node needs both capabilities. + requires: ['automation', 'triggers'], + objects: [Ledger, Watch], + flows: FLOWS, +} as never); + +let stack: VerifyStack; +let admin: string; +let member: string; + +beforeAll(async () => { + stack = await bootStack(fixtureStack as never, { automation: true }); + admin = await stack.signIn(); + // The first user is the seeded dev admin, so this sign-up is a plain member: + // no permission set of the app's, only the platform's fallback baseline. + member = await stack.signUp('etd-member@verify.test'); +}, BOOT_TIMEOUT); + +afterAll(async () => { + await stack?.stop().catch(() => undefined); +}); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const codeOf = (e: unknown): string | undefined => (e as any)?.code; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const statusOf = (e: unknown): number | undefined => (e as any)?.statusCode ?? (e as any)?.status; + +/** Ledger rows a writer flow named `flow` has created so far. */ +async function ledgerRows(flow: string): Promise { + return (await stack.rows(LEDGER, { name: flow })).length; +} + +/** Run-log entries the engine holds for `flow`. */ +async function runCount(flow: string): Promise { + const automation = stack.kernel.getService('automation') as { listRuns(name: string): Promise }; + return (await automation.listRuns(flow)).length; +} + +type Caller = 'member' | 'admin' | 'system'; + +interface Outcome { + /** The door's HTTP answer. */ + answer: number; + /** The ADR-0112 `error.code` on a refusal. */ + code?: string; + /** Ledger rows the request caused (the writer's side effect). */ + rows: number; + /** Run-log entries the request caused for the flow it named. */ + runs: number; +} + +/** + * Start `flow` through the door as `caller` and measure what happened. + * + * `member` and `admin` go through `flows.run` with a bearer token, exactly as a + * console or SDK reaches the door. The system principal has no token: it is + * the in-process caller a job or an internal dispatch is, so its row drives + * the SAME route (`handleAutomation` → `respondToFlowTrigger`) with the + * system execution context. + */ +async function startAs(caller: Caller, flow: string, rowsOf: string = flow): Promise { + const rowsBefore = await ledgerRows(rowsOf); + const runsBefore = await runCount(flow); + let answer: number; + let code: string | undefined; + if (caller === 'system') { + const dispatcher = new HttpDispatcher(stack.kernel); + const res = await dispatcher.handleAutomation(`/${flow}/trigger`, 'POST', { params: {} }, { + request: {}, + executionContext: { isSystem: true }, + } as never); + answer = res.response?.status ?? 0; + code = res.response?.body?.error?.code; + } else { + try { + await stack.flows.run(flow, {}, { as: caller === 'admin' ? admin : member }); + answer = 200; + } catch (e) { + answer = statusOf(e) ?? 0; + code = codeOf(e); + } + } + return { + answer, + ...(code !== undefined ? { code } : {}), + rows: (await ledgerRows(rowsOf)) - rowsBefore, + runs: (await runCount(flow)) - runsBefore, + }; +} + +describe('control: the member cannot write the ledger directly', () => { + it('a direct create is refused PERMISSION_DENIED / 403', async () => { + let err: unknown; + try { + await stack.hooks.run(LEDGER, 'insert', { name: 'direct' }, { as: member }); + } catch (e) { + err = e; + } + expect(codeOf(err)).toBe('PERMISSION_DENIED'); + expect(statusOf(err)).toBe(403); + expect(await ledgerRows('direct')).toBe(0); + }); + + it('the member is not the system principal', async () => { + const ec = await stack.contextFor(member); + expect(ec.isSystem).not.toBe(true); + }); +}); + +const REFUSED = { answer: 403, code: 'PERMISSION_DENIED', rows: 0, runs: 0 } as const; +const RAN = { answer: 200, rows: 1, runs: 1 } as const; + +/** + * Caller × flow → what the door answers and what the request caused. `before` + * is the reading taken on this branch before the door check existed. + */ +const TABLE: Array<{ caller: Caller; flow: string; rowsOf?: string; outcome: Outcome; before: string }> = [ + // REFUSED — a signed-in member, the three self-triggered types. + { caller: 'member', flow: AUTO_SYS, outcome: REFUSED, before: '200, elevated row written' }, + { caller: 'member', flow: CHANGE_SYS, outcome: REFUSED, before: '200, elevated row written' }, + { caller: 'member', flow: SCHED_SYS, outcome: REFUSED, before: '200, elevated row written' }, + // REFUSED — the platform admin is a signed-in user too, not the system principal. + { caller: 'admin', flow: AUTO_SYS, outcome: REFUSED, before: '200, row written' }, + { caller: 'admin', flow: CHANGE_SYS, outcome: REFUSED, before: '200, row written' }, + { caller: 'admin', flow: SCHED_SYS, outcome: REFUSED, before: '200, row written' }, + // UNCHANGED — the system principal starts each refused type. + { caller: 'system', flow: AUTO_SYS, outcome: RAN, before: '200, row written' }, + { caller: 'system', flow: CHANGE_SYS, outcome: RAN, before: '200, row written' }, + { caller: 'system', flow: SCHED_SYS, outcome: RAN, before: '200, row written' }, + // UNCHANGED — `screen` and `api`: doors the author designed. + { caller: 'member', flow: SCREEN_SYS, outcome: RAN, before: '200, elevated row written' }, + { caller: 'member', flow: API_SYS, outcome: RAN, before: '200, elevated row written' }, + // UNCHANGED — a non-elevated flow: the member's own identity is refused the write. + { + caller: 'member', + flow: AUTO_USER, + outcome: { answer: 400, code: 'FLOW_FAILED', rows: 0, runs: 1 }, + before: '400 FLOW_FAILED, nothing written', + }, + // UNCHANGED — the platform's pattern for an elevated write: the member starts + // the non-elevated parent, whose `subflow` node runs the elevated child that + // the door now refuses to start directly. The child writes its own name. + { caller: 'member', flow: PARENT_USER, rowsOf: AUTO_SYS, outcome: RAN, before: '200, elevated child row written' }, +]; + +describe('the trigger door × runAs: system, per type and caller', () => { + for (const row of TABLE) { + const verdict = row.outcome.answer === 403 ? 'refused 403 PERMISSION_DENIED, nothing ran' : `answers ${row.outcome.answer}`; + it(`${row.caller} starts ${row.flow}: ${verdict} (before: ${row.before})`, async () => { + expect(await startAs(row.caller, row.flow, row.rowsOf)).toEqual(row.outcome); + }); + } +}); + +describe('the refusal envelope at the wire', () => { + it('carries the ADR-0112 code and status, and nothing of the flow', async () => { + let err: unknown; + try { + await stack.flows.run(SCHED_SYS, {}, { as: member }); + } catch (e) { + err = e; + } + expect(isVerifyRefusal(err)).toBe(true); + expect(codeOf(err)).toBe('PERMISSION_DENIED'); + expect(statusOf(err)).toBe(403); + const message = (err as Error).message; + expect(message).not.toContain(SCHED_SYS); + expect(message).not.toMatch(/schedule|runAs|'system'/); + }); +});