From cd46ffa4f100f7717b9cfeead65d9bd7683df918 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:39:46 +0000 Subject: [PATCH 1/6] test(verify): measure the trigger door against runAs-system flows, per 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 --- .../automation-trigger-elevated-door.test.ts | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 packages/verify/src/automation-trigger-elevated-door.test.ts 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..6184376485b --- /dev/null +++ b/packages/verify/src/automation-trigger-elevated-door.test.ts @@ -0,0 +1,268 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The trigger door × a flow declared `runAs: 'system'`, measured 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`). + * + * MEASURE-FIRST STAGE. This file is committed before any fix, and the table + * below is what the door answers TODAY. It is the before-table the fix is + * judged against: every row is a measurement through the real kernel — + * auth, security middleware, the real automation engine — not a scripted + * service. + * + * 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. + */ + +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'; + +// 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); + }); +}); + +/** + * The before-table: caller × flow → what the door answered and what it caused. + * Measured on this branch before the door check existed. + */ +const MEASURED: Array<{ caller: Caller; flow: string; rowsOf?: string; outcome: Outcome }> = [ + // A signed-in member, per type, against `runAs: 'system'` writers. + { caller: 'member', flow: AUTO_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, + { caller: 'member', flow: CHANGE_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, + { caller: 'member', flow: SCHED_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, + { caller: 'member', flow: SCREEN_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, + { caller: 'member', flow: API_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, + // The non-elevated control: the member's own identity is refused the write. + { caller: 'member', flow: AUTO_USER, outcome: { answer: 400, code: 'FLOW_FAILED', rows: 0, runs: 1 } }, + // The sub-flow path: the elevated child writes its own name. + { caller: 'member', flow: PARENT_USER, rowsOf: AUTO_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, + // The platform admin is a signed-in user too, not the system principal. + { caller: 'admin', flow: AUTO_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, + { caller: 'admin', flow: CHANGE_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, + { caller: 'admin', flow: SCHED_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, + // The system principal. + { caller: 'system', flow: AUTO_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, + { caller: 'system', flow: CHANGE_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, + { caller: 'system', flow: SCHED_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, +]; + +describe('the trigger door × runAs: system, per type and caller (measured)', () => { + for (const row of MEASURED) { + it(`${row.caller} starts ${row.flow}`, async () => { + expect(await startAs(row.caller, row.flow, row.rowsOf)).toEqual(row.outcome); + }); + } +}); From 5fc3b2533205c484668b7b91e92dc3a7f3c08560 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:40:51 +0000 Subject: [PATCH 2/6] fix(runtime): the trigger door refuses a self-triggered system flow to 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 --- .../22310-elevated-flow-trigger-door.md | 20 +++ .../automation-trigger-elevated-door.test.ts | 163 ++++++++++++++++++ packages/runtime/src/domains/automation.ts | 101 ++++++++++- .../automation-trigger-elevated-door.test.ts | 107 ++++++++---- 4 files changed, 358 insertions(+), 33 deletions(-) create mode 100644 .changeset/22310-elevated-flow-trigger-door.md create mode 100644 packages/runtime/src/domains/automation-trigger-elevated-door.test.ts 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/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..a080a02086c --- /dev/null +++ b/packages/runtime/src/domains/automation-trigger-elevated-door.test.ts @@ -0,0 +1,163 @@ +// 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 () => ({ 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); + const message = String(res.response?.body?.error?.message ?? ''); + // 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 index 6184376485b..5fcc55307c2 100644 --- a/packages/verify/src/automation-trigger-elevated-door.test.ts +++ b/packages/verify/src/automation-trigger-elevated-door.test.ts @@ -1,16 +1,27 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The trigger door × a flow declared `runAs: 'system'`, measured per flow type - * and per caller, through `flows.run` — the in-process twin of + * 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`). * - * MEASURE-FIRST STAGE. This file is committed before any fix, and the table - * below is what the door answers TODAY. It is the before-table the fix is - * judged against: every row is a measurement through the real kernel — - * auth, security middleware, the real automation engine — not a scripted - * service. + * 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 @@ -18,6 +29,10 @@ * 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'; @@ -28,6 +43,7 @@ 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; @@ -234,35 +250,64 @@ describe('control: the member cannot write the ledger directly', () => { }); }); +const REFUSED = { answer: 403, code: 'PERMISSION_DENIED', rows: 0, runs: 0 } as const; +const RAN = { answer: 200, rows: 1, runs: 1 } as const; + /** - * The before-table: caller × flow → what the door answered and what it caused. - * Measured on this branch before the door check existed. + * 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 MEASURED: Array<{ caller: Caller; flow: string; rowsOf?: string; outcome: Outcome }> = [ - // A signed-in member, per type, against `runAs: 'system'` writers. - { caller: 'member', flow: AUTO_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, - { caller: 'member', flow: CHANGE_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, - { caller: 'member', flow: SCHED_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, - { caller: 'member', flow: SCREEN_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, - { caller: 'member', flow: API_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, - // The non-elevated control: the member's own identity is refused the write. - { caller: 'member', flow: AUTO_USER, outcome: { answer: 400, code: 'FLOW_FAILED', rows: 0, runs: 1 } }, - // The sub-flow path: the elevated child writes its own name. - { caller: 'member', flow: PARENT_USER, rowsOf: AUTO_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, - // The platform admin is a signed-in user too, not the system principal. - { caller: 'admin', flow: AUTO_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, - { caller: 'admin', flow: CHANGE_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, - { caller: 'admin', flow: SCHED_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, - // The system principal. - { caller: 'system', flow: AUTO_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, - { caller: 'system', flow: CHANGE_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, - { caller: 'system', flow: SCHED_SYS, outcome: { answer: 200, rows: 1, runs: 1 } }, +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 (measured)', () => { - for (const row of MEASURED) { - it(`${row.caller} starts ${row.flow}`, async () => { +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'/); + }); +}); From 12f88829793e8023693cc5b8f5c3c881de20a266 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:44:49 +0000 Subject: [PATCH 3/6] test(runtime): the disclosure pin asserts the refusal before its absences Under ablation of the door check it passed over an empty message. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- .../src/domains/automation-trigger-elevated-door.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/runtime/src/domains/automation-trigger-elevated-door.test.ts b/packages/runtime/src/domains/automation-trigger-elevated-door.test.ts index a080a02086c..55b87ffcb66 100644 --- a/packages/runtime/src/domains/automation-trigger-elevated-door.test.ts +++ b/packages/runtime/src/domains/automation-trigger-elevated-door.test.ts @@ -84,7 +84,11 @@ describe('a non-system caller is refused an elevated self-triggered flow at the 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); From 0e92dbe44a8ef91e81503d9bb39ca764251ba35e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:14:40 +0000 Subject: [PATCH 4/6] test(runtime): type the execute spy's parameters so its calls index under tsc Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- .../src/domains/automation-trigger-elevated-door.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/domains/automation-trigger-elevated-door.test.ts b/packages/runtime/src/domains/automation-trigger-elevated-door.test.ts index 55b87ffcb66..cad8ed476c3 100644 --- a/packages/runtime/src/domains/automation-trigger-elevated-door.test.ts +++ b/packages/runtime/src/domains/automation-trigger-elevated-door.test.ts @@ -46,7 +46,7 @@ const DOOR_TYPES = ['screen', 'api'] as const; */ 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 () => ({ success: true, output: {} })); + 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 }; From 50ccae4e6986e88533dfd6d994cda81f1174612a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 03:17:53 +0000 Subject: [PATCH 5/6] docs(permissions): the isSystem census anchors the trigger door's elevated 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 --- content/docs/permissions/system-context.mdx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 From 799c28789fff6644d8f57f9dce3cb7e63bd56f9e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 03:17:54 +0000 Subject: [PATCH 6/6] test(dogfood): the pins that started a runAs-system self-triggered flow 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 --- .../test/fixtures/flow-runas-fixture.ts | 58 +++++++++++++- .../dogfood/test/flow-runas.dogfood.test.ts | 59 +++++++++++++- ...hedule-acting-organization.dogfood.test.ts | 78 ++++++++++++++++--- 3 files changed, 180 insertions(+), 15 deletions(-) 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`,