diff --git a/.changeset/flow-runas-identity-enforcement.md b/.changeset/flow-runas-identity-enforcement.md new file mode 100644 index 0000000000..72600f5803 --- /dev/null +++ b/.changeset/flow-runas-identity-enforcement.md @@ -0,0 +1,47 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-automation': minor +'@objectstack/runtime': minor +'@objectstack/trigger-record-change': minor +--- + +fix(security): enforce flow `runAs` execution identity (#1888) + +The `service-automation` engine now honors `flow.runAs` instead of ignoring it. +Previously the CRUD nodes passed **no identity** to ObjectQL, so the security +middleware was skipped entirely — every flow ran effectively elevated regardless +of `runAs`. A `runAs:'user'` flow did **not** de-elevate (a privilege-boundary +surprise), and `runAs:'system'` did not *explicitly* elevate. + +The engine now establishes the run's data-layer identity at setup and restores +the caller's context afterward: + +- **`runAs:'system'`** → an elevated, RLS-bypassing system principal + (`{ isSystem: true }`): the run can read/write records the triggering user + cannot. +- **`runAs:'user'`** (default) → the **triggering user's** identity + (`{ userId, roles, permissions, tenantId }`): CRUD nodes' ObjectQL reads/writes + respect that user's row-level security, and the run can never exceed the + triggering user's grants. + +To keep `runAs:'user'` faithful to a direct request by that user, the REST +trigger route (`@objectstack/runtime`) and the record-change trigger +(`@objectstack/trigger-record-change`) now forward the caller's resolved +`roles`/`tenantId` into the `AutomationContext` (new optional fields), not just +`userId`. The new `resolveRunDataContext` helper is the single place that maps a +run's effective `runAs` to the ObjectQL context, shared by every data node. + +The `[EXPERIMENTAL — not enforced]` marker is removed from `FlowSchema.runAs`. + +**Behavior change / migration.** Flows that previously relied on the implicit +elevation (the default `runAs:'user'` ran unscoped) now run as the triggering +user and are subject to their RLS. **Declare `runAs:'system'` on any flow that +must read or write beyond the triggering user's access** (e.g. system +automations, cross-owner roll-ups). Schedule-triggered runs have no trigger user; +under `user` they stay unscoped (there is no identity to scope to) — declare +`system` to make elevation explicit. + +Proven both directions by the dogfood regression gate +(`flow-runas.dogfood.test.ts` — a restricted member triggers system vs user +flows against an owner-scoped record) and service-automation unit + regression +tests (`crud-runas.test.ts`). diff --git a/docs/audits/2026-06-flowschema-property-liveness.md b/docs/audits/2026-06-flowschema-property-liveness.md index 558032ea37..c912892265 100644 --- a/docs/audits/2026-06-flowschema-property-liveness.md +++ b/docs/audits/2026-06-flowschema-property-liveness.md @@ -10,15 +10,15 @@ Engine canonical type is `http` (`HTTP_TYPE`); `http_request` is a registered **deprecated alias** (`engine.ts:486`). The Studio palette/config/type-picker author **`http_request`** and never offer `http` → new Studio flows bake in the deprecated alias. ## 🟠 Execution-config props that are display-only at runtime (incl. security-relevant) -- **`runAs`** — engine **never switches execution identity** on it (`FlowPreview` shows it; execution always runs as-is). Security-relevant: a flow declaring `runAs: system` does not actually elevate/de-elevate. +- **`runAs`** — ✅ **ENFORCED 2026-06-24** (#1888, ADR-0049; was the dead security item). The engine establishes the run's data-layer identity at setup and restores the caller's context afterward: `runAs:'system'` runs elevated (a full-access, RLS-bypassing system principal); `runAs:'user'` (default) runs as the triggering user, so CRUD nodes' ObjectQL reads/writes respect that user's RLS (roles/tenant forwarded from the trigger). The `[EXPERIMENTAL — not enforced]` marker is removed from the schema. Proven both directions by the dogfood gate (`flow-runas.dogfood.test.ts`, restricted-member read+write) and service-automation unit + regression tests (`crud-runas.test.ts`). - **`status` / `active`** — engine gates on its in-memory `flowEnabled` map (`toggleFlow`), **not** on `status`/`active`. `active` is spec-flagged Deprecated and redundant with `status`. - **`errorHandling.fallbackNodeId`** — DEAD (engine uses per-node fault edges). Node **`outputSchema`** — DEAD (declared, never validated). `flow.template`, `flow.description` — no reader either layer. ## LIVE & well-wired -Top-level: `name`, `label`, `version`, `variables[]{name,isInput,isOutput}`, `nodes[]`, `edges[]{source,target,condition(CEL),label}`, `errorHandling.{strategy,maxRetries,retryDelayMs,backoffMultiplier,...}`. Node common: `id`, `type`, `label`, `config`, `connectorConfig`, `timeoutMs`, `inputSchema` (runtime-validated), `waitEventConfig`. **Executors (LIVE)**: start/end/decision/assignment/get|create|update|delete_record/script/screen/http(+alias)/notify/connector_action/wait/subflow/map/loop/parallel/try_catch/approval. +Top-level: `name`, `label`, `version`, `runAs` (identity switch — #1888), `variables[]{name,isInput,isOutput}`, `nodes[]`, `edges[]{source,target,condition(CEL),label}`, `errorHandling.{strategy,maxRetries,retryDelayMs,backoffMultiplier,...}`. Node common: `id`, `type`, `label`, `config`, `connectorConfig`, `timeoutMs`, `inputSchema` (runtime-validated), `waitEventConfig`. **Executors (LIVE)**: start/end/decision/assignment/get|create|update|delete_record/script/screen/http(+alias)/notify/connector_action/wait/subflow/map/loop/parallel/try_catch/approval. ## 🟠 `notify` invisible in the static designer Full executor + descriptor (`paradigms:['flow']`) but reaches the palette only via the server-driven `/automation/actions` overlay — absent from the hardcoded fallback palette + `flow-node-config.ts`. Against an older/offline backend it can't be authored and renders only generic JSON config. ## Recommendation -Resync `FlowNodeAction` enum with the live registry (add loop/parallel/try_catch/map/approval; remove or mark import-only the 3 gateways). Make the Studio palette author `http` (canonical). **Enforce `runAs`** (or remove — a non-enforcing identity switch is a security footgun). Collapse `status`/`active`. Prune `fallbackNodeId`/`outputSchema`/`template`. Add `notify` to the static palette. +Resync `FlowNodeAction` enum with the live registry (add loop/parallel/try_catch/map/approval; remove or mark import-only the 3 gateways). Make the Studio palette author `http` (canonical). ~~Enforce `runAs`~~ → **DONE** (#1888 — elevate/de-elevate now honored at the data layer). Collapse `status`/`active`. Prune `fallbackNodeId`/`outputSchema`/`template`. Add `notify` to the static palette. diff --git a/packages/dogfood/test/fixtures/flow-runas-fixture.ts b/packages/dogfood/test/fixtures/flow-runas-fixture.ts new file mode 100644 index 0000000000..e1c4bb7303 --- /dev/null +++ b/packages/dogfood/test/fixtures/flow-runas-fixture.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Flow `runAs` identity-enforcement fixture — the live, revert-provable #1888 gate. +// +// `flow.runAs` declares the execution identity for a run's data operations: +// • `system` → elevated, RLS-bypassing system principal (full access), +// • `user` → the triggering user (RLS-respecting; cannot exceed their grants). +// The bug (#1888): the engine IGNORED `runAs`. CRUD nodes passed no identity to +// ObjectQL at all, so the security middleware was skipped entirely — every flow +// ran effectively elevated regardless of `runAs`. A `runAs:'user'` flow did NOT +// de-elevate (a privilege-boundary surprise), and `runAs:'system'` did not +// *explicitly* elevate (it merely happened to be unscoped too). +// +// This fixture reproduces the boundary with ZERO dependence on org-scoping, +// mirroring rls-owner-fixture: one object `runas_note` whose member permission +// set carries an OWNER policy keyed on `created_by` (survives single-tenant +// stripping because the predicate references `current_user.id`). A fresh member +// therefore genuinely CANNOT read or write a note the admin created. +// +// Four flows over that object, each triggered by the RESTRICTED member, prove +// BOTH directions of the fix: +// • runas_system_touch / runas_system_read (runAs:'system') — elevate: the +// member-triggered run WRITES / READS the admin's note it cannot itself touch. +// • runas_user_touch / runas_user_read (runAs:'user') — de-elevate: the +// same run is RLS-denied on the admin's note (write lands nowhere; read empty). +// +// 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. + +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { PermissionSetSchema, RLS, type PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; + +/** The one object under test: an owner-scoped note (isolated via `created_by`). */ +export const RunAsNote = ObjectSchema.create({ + name: 'runas_note', + label: 'RunAs Note', + pluralLabel: 'RunAs Notes', + fields: { + name: Field.text({ label: 'Name', required: true }), + status: Field.text({ label: 'Status' }), + }, +}); + +/** + * `runas__touch` — start → update_record → end. Sets `status` on the note + * whose id is passed as the `noteId` input variable. The two variants differ + * ONLY in `runAs`, isolating the identity switch as the single variable. + */ +function touchFlow(name: string, runAs: 'system' | 'user', stamp: string) { + return { + name, + label: `RunAs ${runAs} touch`, + type: 'autolaunched', + runAs, + variables: [{ name: 'noteId', type: 'text', isInput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'touch', + type: 'update_record', + label: 'Touch note', + config: { objectName: 'runas_note', filter: { id: '{noteId}' }, fields: { status: stamp } }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'touch' }, + { id: 'e2', source: 'touch', target: 'end' }, + ], + }; +} + +/** + * `runas__read` — start → get_record → end. Reads the note by id into the + * `found` output variable, surfaced on the trigger response as + * `data.output.found`. Under `system` the elevated read returns the record; + * under `user` the RLS-scoped read returns null. + */ +function readFlow(name: string, runAs: 'system' | 'user') { + return { + name, + label: `RunAs ${runAs} read`, + type: 'autolaunched', + runAs, + variables: [ + { name: 'noteId', type: 'text', isInput: true }, + { name: 'found', type: 'text', isOutput: true }, + ], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'get', + type: 'get_record', + label: 'Get note', + config: { objectName: 'runas_note', filter: { id: '{noteId}' }, outputVariable: 'found' }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'get' }, + { id: 'e2', source: 'get', target: 'end' }, + ], + }; +} + +export const runasSystemTouch = touchFlow('runas_system_touch', 'system', 'touched-system'); +export const runasUserTouch = touchFlow('runas_user_touch', 'user', 'touched-user'); +export const runasSystemRead = readFlow('runas_system_read', 'system'); +export const runasUserRead = readFlow('runas_user_read', 'user'); + +/** A minimal, self-contained app config the dogfood harness can boot. */ +export const runasFixtureStack = defineStack({ + manifest: { + id: 'com.dogfood.runas_fixture', + namespace: 'runas', + version: '0.0.0', + type: 'app', + name: 'Flow runAs Fixture', + description: 'Owner-isolated single-object app exercising flow.runAs identity enforcement (#1888).', + }, + objects: [RunAsNote], + flows: [runasSystemTouch, runasUserTouch, runasSystemRead, runasUserRead], +}); + +/** + * The fallback permission set a fresh member resolves to: full CRUD on + * `runas_note` (so the request reaches the RLS layer, not an RBAC wall) plus an + * owner RLS policy on ALL operations keyed on `created_by`. A member can read + * and write their OWN notes, but neither read nor write the admin's — the exact + * cross-owner isolation the runAs proof needs on both directions. + */ +const FIXTURE_MEMBER_SET = 'runas_fixture_member'; + +export const runasMemberSet: PermissionSet = PermissionSetSchema.parse({ + name: FIXTURE_MEMBER_SET, + label: 'RunAs Fixture Member — owner-scoped (all ops)', + isProfile: true, + objects: { + runas_note: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + }, + rowLevelSecurity: [RLS.ownerPolicy('runas_note', 'created_by')], +}); + +/** + * Build a SecurityPlugin whose fallback (for a fresh, grant-less member) is the + * owner-scoped fixture set, layered on the real platform defaults so the seeded + * admin keeps `admin_full_access` (and can create the note + read everything). + */ +export function runasFixtureSecurity(): SecurityPlugin { + return new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, runasMemberSet], + fallbackPermissionSet: runasMemberSet.name, + }); +} diff --git a/packages/dogfood/test/flow-runas.dogfood.test.ts b/packages/dogfood/test/flow-runas.dogfood.test.ts new file mode 100644 index 0000000000..3936d64a66 --- /dev/null +++ b/packages/dogfood/test/flow-runas.dogfood.test.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// FLOW runAs identity-enforcement proof (#1888), exercised end-to-end through the +// real HTTP + automation + security stack. +// +// @proof: flow-runas-identity +// Security-layer instance of the "configured in the UI, silently does nothing at +// runtime" anti-pattern (sibling of the assignment/decision node fixes). A flow's +// `runAs` MUST switch the execution identity of its data nodes: +// • runAs:'system' → elevated, RLS-bypassing (the run can touch records the +// triggering user cannot), +// • runAs:'user' → the triggering user (RLS-respecting; cannot exceed grants). +// +// The proof drives both directions as a RESTRICTED member against an owner-scoped +// object the member cannot read or write directly: +// • system flows succeed on the admin's note → elevation is REAL, +// • user flows are RLS-denied on the same note → de-elevation is REAL. +// Before the #1888 fix the user flows wrongly succeed (CRUD nodes passed no +// identity → security skipped) → this file is RED; after the fix → GREEN. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { runasFixtureStack, runasFixtureSecurity } from './fixtures/flow-runas-fixture.js'; + +describe('objectstack verify FLOW: runAs identity enforcement (#flow-runas)', () => { + let stack: VerifyStack; + let adminToken: string; + let memberToken: string; + + beforeAll(async () => { + stack = await bootStack(runasFixtureStack, { automation: true, security: runasFixtureSecurity() }); + adminToken = await stack.signIn(); + // First user is the seeded dev admin, so this fresh sign-up is a plain member + // who falls back to the owner-scoped fixture permission set. + memberToken = await stack.signUp('member@runas.test'); + }, 60_000); + + afterAll(async () => { + await stack?.stop(); + }); + + /** Admin creates a note it owns; returns the new id. */ + async function adminCreateNote(name: string): Promise { + const res = await stack.apiAs(adminToken, 'POST', '/data/runas_note', { name, status: 'new' }); + expect(res.status, `admin create ${name} failed: ${res.status} ${await res.clone().text()}`).toBeLessThan(300); + const j = (await res.json()) as { id?: string; record?: { id?: string } }; + const id = j.id ?? j.record?.id; + expect(id, 'no id returned from create').toBeTruthy(); + return id as string; + } + + /** Read a note's status as the admin (who can always see every row). */ + async function adminStatusOf(id: string): Promise { + const res = await stack.apiAs(adminToken, 'GET', `/data/runas_note/${id}`); + expect(res.status).toBe(200); + const j = (await res.json()) as { record?: Record } & Record; + return (j.record ?? j).status; + } + + /** Trigger a flow as the restricted member; returns the inner AutomationResult. */ + async function memberTrigger(flow: string, noteId: string): Promise<{ success?: boolean; output?: any }> { + const res = await stack.apiAs(memberToken, 'POST', `/automation/${flow}/trigger`, { params: { noteId } }); + expect(res.status, `trigger ${flow} HTTP failed: ${res.status} ${await res.clone().text()}`).toBeLessThan(300); + const body = (await res.json()) as { success?: boolean; data?: { success?: boolean; output?: any } }; + expect(body.success).toBe(true); + return body.data ?? {}; + } + + 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); + }); + + it('precondition: the member is genuinely RLS-denied on the admin note (isolation is real)', async () => { + const id = await adminCreateNote('iso-check'); + // Admin sees it; member must NOT (owner policy keyed on created_by). + expect(await adminStatusOf(id)).toBe('new'); + const res = await stack.apiAs(memberToken, 'GET', `/data/runas_note/${id}`); + if (res.status === 200) { + const j = (await res.json()) as { record?: Record } & Record; + const rec = (j.record ?? j) as Record; + // A 200 is only acceptable if it carries NO actual row (RLS filtered it out). + expect(rec.id ?? rec.name, 'member could READ the admin note — RLS isolation is not in effect').toBeFalsy(); + } else { + expect(res.status, `unexpected status for RLS-denied read: ${res.status}`).toBe(404); + } + }); + + // ── WRITE direction ─────────────────────────────────────────────────────── + + 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); + 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("runAs:'user' DE-ELEVATES — member-triggered user flow is RLS-DENIED on the same record", async () => { + const id = await adminCreateNote('user-touch'); + await memberTrigger('runas_user_touch', id); + // The run executed as the member; the by-id write to the admin's note is + // RLS-denied, so the record is unchanged. (Before the fix it would read + // 'touched-user' — the privilege-boundary surprise this gate pins.) + const after = await adminStatusOf(id); + expect(after, 'user-mode flow wrote a record the triggering member cannot access (#1888 regression)').not.toBe( + 'touched-user', + ); + expect(after).toBe('new'); + }); + + // ── READ direction ──────────────────────────────────────────────────────── + + 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(); + + const usr = await memberTrigger('runas_user_read', id); + const found = usr.output?.found; + expect( + found && typeof found === 'object' ? (found as any).id : found, + 'user-mode flow READ a record the triggering member cannot access (#1888 regression)', + ).toBeFalsy(); + }); +}); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index d36de8c072..0d1808803f 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -2425,8 +2425,17 @@ export class HttpDispatcher { object: objectName, event: ctxBody.event ?? 'manual', }; - const userIdFromAuth = (context as any)?.user?.id ?? (context as any)?.userId; + // Forward the FULLY-RESOLVED caller identity (not just the + // user id) so a `runAs:'user'` flow enforces RLS exactly as the + // triggering user — their roles/tenant, not a member fallback + // (#1888). The engine elevates to a system principal only when + // the flow itself declares `runAs:'system'`. + const ec = (context as any)?.executionContext; + const userIdFromAuth = (context as any)?.user?.id ?? (context as any)?.userId ?? ec?.userId; if (userIdFromAuth) automationContext.userId = userIdFromAuth; + if (Array.isArray(ec?.roles) && ec.roles.length) automationContext.roles = ec.roles; + if (Array.isArray(ec?.permissions) && ec.permissions.length) automationContext.permissions = ec.permissions; + if (ec?.tenantId) automationContext.tenantId = ec.tenantId; const result = await automationService.execute(name, automationContext); return { handled: true, response: this.success(result) }; } diff --git a/packages/services/service-automation/src/builtin/crud-nodes.ts b/packages/services/service-automation/src/builtin/crud-nodes.ts index 2f7353eb0d..c2e7dd744a 100644 --- a/packages/services/service-automation/src/builtin/crud-nodes.ts +++ b/packages/services/service-automation/src/builtin/crud-nodes.ts @@ -5,6 +5,7 @@ import { defineActionDescriptor } from '@objectstack/spec/automation'; import type { IDataEngine } from '@objectstack/spec/contracts'; import type { AutomationEngine } from '../engine.js'; import { interpolate } from './template.js'; +import { resolveRunDataContext } from '../runtime-identity.js'; /** * CRUD built-in nodes — `get_record` / `create_record` / `update_record` / @@ -56,13 +57,16 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): return { success: true, output: { records: [], object: objectName } }; } + // #1888 — honor flow.runAs: read under the run's effective identity + // (system → RLS-bypassing; user → the triggering user). + const dataCtx = resolveRunDataContext(context); try { if (limit && limit > 1) { - const records = await data.find(objectName, { where: filter, fields, limit }); + const records = await data.find(objectName, { where: filter, fields, limit, context: dataCtx }); if (outputVariable) variables.set(outputVariable, records); return { success: true, output: { records, object: objectName } }; } - const record = await data.findOne(objectName, { where: filter, fields }); + const record = await data.findOne(objectName, { where: filter, fields, context: dataCtx }); if (outputVariable) variables.set(outputVariable, record); return { success: true, output: { record, id: record?.id, object: objectName } }; } catch (err) { @@ -95,8 +99,10 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): return { success: true, output: { id: mockId, object: objectName } }; } + // #1888 — honor flow.runAs (system → RLS-bypassing; user → trigger user). + const dataCtx = resolveRunDataContext(context); try { - const created = await data.insert(objectName, fields); + const created = await data.insert(objectName, fields, { context: dataCtx }); const createdRecord = Array.isArray(created) ? created[0] : created; const insertedId = createdRecord && typeof createdRecord === 'object' @@ -140,8 +146,10 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): return { success: true }; } + // #1888 — honor flow.runAs (system → RLS-bypassing; user → trigger user). + const dataCtx = resolveRunDataContext(context); try { - const result = await data.update(objectName, fields, { where: filter }); + const result = await data.update(objectName, fields, { where: filter, context: dataCtx }); return { success: true, output: { result, object: objectName } }; } catch (err) { return { success: false, error: `update_record(${objectName}) failed: ${(err as Error).message}` }; @@ -167,8 +175,10 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): const data = getData(); if (!data) return { success: true }; + // #1888 — honor flow.runAs (system → RLS-bypassing; user → trigger user). + const dataCtx = resolveRunDataContext(context); try { - const result = await data.delete(objectName, { where: filter }); + const result = await data.delete(objectName, { where: filter, context: dataCtx }); return { success: true, output: { result, object: objectName } }; } catch (err) { return { success: false, error: `delete_record(${objectName}) failed: ${(err as Error).message}` }; diff --git a/packages/services/service-automation/src/builtin/crud-runas.test.ts b/packages/services/service-automation/src/builtin/crud-runas.test.ts new file mode 100644 index 0000000000..1245659723 --- /dev/null +++ b/packages/services/service-automation/src/builtin/crud-runas.test.ts @@ -0,0 +1,195 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #1888 — the automation engine MUST switch the execution identity of a flow's + * data nodes based on `flow.runAs`, and pass it to ObjectQL as `options.context`: + * • runAs:'system' → an elevated, RLS-bypassing system principal ({ isSystem: true }), + * • runAs:'user' → the triggering user ({ userId, roles, … }) so RLS applies. + * + * Before the fix the CRUD nodes passed NO context at all (the security middleware + * was skipped), so runAs was inert in both directions. These tests capture the + * exact `context` each data op receives, proving the switch — and the regression + * test fails loudly if the threading is ever removed (so it can't go dead again). + */ +import { describe, it, expect } from 'vitest'; +import { AutomationEngine } from '../engine.js'; +import { registerCrudNodes } from './crud-nodes.js'; +import { resolveRunDataContext } from '../runtime-identity.js'; +import type { AutomationContext } from '@objectstack/spec/contracts'; + +function makeLogger(): any { + const l: any = { info() {}, warn() {}, error() {}, debug() {} }; + l.child = () => l; + return l; +} + +/** A data engine that records the `context` every op was called with. */ +function fakeData() { + const calls: Array<{ op: string; obj: string; ctx: any }> = []; + const data: any = { + async find(obj: string, q: any) { calls.push({ op: 'find', obj, ctx: q?.context }); return [{ id: 'r1' }]; }, + async findOne(obj: string, q: any) { calls.push({ op: 'findOne', obj, ctx: q?.context }); return { id: 'r1' }; }, + async insert(obj: string, _f: any, opts: any) { calls.push({ op: 'insert', obj, ctx: opts?.context }); return { id: `${obj}_1` }; }, + async update(obj: string, _f: any, opts: any) { calls.push({ op: 'update', obj, ctx: opts?.context }); return { ok: true }; }, + async delete(obj: string, opts: any) { calls.push({ op: 'delete', obj, ctx: opts?.context }); return { ok: true }; }, + }; + return { data, calls }; +} + +const ctxWith = (data: any): any => ({ + logger: makeLogger(), + getService: (n: string) => (n === 'data' ? data : undefined), +}); + +/** A flow exercising all five data ops, parameterized by runAs. */ +function allOpsFlow(name: string, runAs?: 'system' | 'user') { + return { + name, + label: name, + type: 'autolaunched', + ...(runAs ? { runAs } : {}), + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'thing', fields: { a: 1 } } }, + { id: 'up', type: 'update_record', label: 'Update', config: { objectName: 'thing', filter: { id: 'x' }, fields: { a: 2 } } }, + { id: 'g1', type: 'get_record', label: 'GetOne', config: { objectName: 'thing', filter: { id: 'x' } } }, + { id: 'gN', type: 'get_record', label: 'GetMany', config: { objectName: 'thing', filter: {}, limit: 5 } }, + { id: 'del', type: 'delete_record', label: 'Delete', config: { objectName: 'thing', filter: { id: 'x' } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'mk' }, + { id: 'e2', source: 'mk', target: 'up' }, + { id: 'e3', source: 'up', target: 'g1' }, + { id: 'e4', source: 'g1', target: 'gN' }, + { id: 'e5', source: 'gN', target: 'del' }, + { id: 'e6', source: 'del', target: 'end' }, + ], + } as any; +} + +describe('flow.runAs identity enforcement at the data layer (#1888)', () => { + it("runAs:'system' runs every data op as an elevated (isSystem) principal", async () => { + const engine = new AutomationEngine(makeLogger()); + const { data, calls } = fakeData(); + registerCrudNodes(engine, ctxWith(data)); + engine.registerFlow('sys', allOpsFlow('sys', 'system')); + + // Triggered by a normal user — `runAs:'system'` must still elevate. + const res = await engine.execute('sys', { userId: 'u1' }); + expect(res.success).toBe(true); + + expect(calls.map((c) => c.op).sort()).toEqual(['delete', 'find', 'findOne', 'insert', 'update']); + for (const c of calls) { + expect(c.ctx, `${c.op} got no context`).toBeTruthy(); + expect(c.ctx.isSystem, `${c.op} not elevated`).toBe(true); + // An elevated run is NOT attributed to the triggering user. + expect(c.ctx.userId).toBeUndefined(); + } + }); + + it("runAs:'user' runs every data op as the triggering user (RLS-respecting)", async () => { + const engine = new AutomationEngine(makeLogger()); + const { data, calls } = fakeData(); + registerCrudNodes(engine, ctxWith(data)); + engine.registerFlow('usr', allOpsFlow('usr', 'user')); + + const res = await engine.execute('usr', { userId: 'u1', roles: ['sales'], tenantId: 'org1' }); + expect(res.success).toBe(true); + + for (const c of calls) { + expect(c.ctx, `${c.op} got no context`).toBeTruthy(); + expect(c.ctx.isSystem, `${c.op} wrongly elevated`).toBe(false); + expect(c.ctx.userId, `${c.op} lost the user identity`).toBe('u1'); + expect(c.ctx.roles).toEqual(['sales']); + expect(c.ctx.tenantId).toBe('org1'); + } + }); + + it('defaults to user identity when runAs is omitted', async () => { + const engine = new AutomationEngine(makeLogger()); + const { data, calls } = fakeData(); + registerCrudNodes(engine, ctxWith(data)); + engine.registerFlow('def', allOpsFlow('def')); // no runAs → default 'user' + + await engine.execute('def', { userId: 'u2' }); + for (const c of calls) { + expect(c.ctx.userId).toBe('u2'); + expect(c.ctx.isSystem).toBe(false); + } + }); + + it('restores the caller context: execute() never mutates the passed AutomationContext', async () => { + const engine = new AutomationEngine(makeLogger()); + const { data } = fakeData(); + registerCrudNodes(engine, ctxWith(data)); + engine.registerFlow('sys2', allOpsFlow('sys2', 'system')); + + const trigger: AutomationContext = { userId: 'u1' }; + await engine.execute('sys2', trigger); + + // The run elevated internally, but the caller's object is untouched — the + // elevation is scoped to the run (no `runAs` leaked onto the trigger, userId intact). + expect(Object.prototype.hasOwnProperty.call(trigger, 'runAs')).toBe(false); + expect(trigger.userId).toBe('u1'); + }); + + it('does not leak elevation across runs (a system run then a user run)', async () => { + const engine = new AutomationEngine(makeLogger()); + const { data, calls } = fakeData(); + registerCrudNodes(engine, ctxWith(data)); + engine.registerFlow('sys3', allOpsFlow('sys3', 'system')); + engine.registerFlow('usr3', allOpsFlow('usr3', 'user')); + + await engine.execute('sys3', { userId: 'u1' }); + const afterSystem = calls.length; + await engine.execute('usr3', { userId: 'u3' }); + + for (const c of calls.slice(0, afterSystem)) expect(c.ctx.isSystem).toBe(true); + for (const c of calls.slice(afterSystem)) { + expect(c.ctx.isSystem).toBe(false); + expect(c.ctx.userId).toBe('u3'); + } + }); + + it("REGRESSION: a runAs:'system' flow must reach the data layer elevated (fails if runAs is ignored)", async () => { + const engine = new AutomationEngine(makeLogger()); + const { data, calls } = fakeData(); + registerCrudNodes(engine, ctxWith(data)); + engine.registerFlow('reg', { + name: 'reg', label: 'reg', type: 'autolaunched', runAs: 'system', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'thing', fields: { a: 1 } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'mk' }, { id: 'e2', source: 'mk', target: 'end' }], + } as any); + + // Trigger as a restricted user. If the engine ignored runAs, the insert would + // carry that user's identity (or none) instead of the elevated principal. + await engine.execute('reg', { userId: 'restricted' }); + const insert = calls.find((c) => c.op === 'insert'); + expect(insert?.ctx?.isSystem, 'runAs:system did not elevate the data op (#1888 regressed)').toBe(true); + expect(insert?.ctx?.userId).not.toBe('restricted'); + }); +}); + +describe('resolveRunDataContext (#1888 unit)', () => { + it("maps runAs:'system' to an elevated context", () => { + expect(resolveRunDataContext({ runAs: 'system', userId: 'u1' })).toEqual({ + isSystem: true, roles: [], permissions: [], + }); + }); + + it("maps runAs:'user' to the triggering user's identity", () => { + expect(resolveRunDataContext({ runAs: 'user', userId: 'u1', roles: ['r'], tenantId: 't' })).toEqual({ + isSystem: false, userId: 'u1', roles: ['r'], permissions: [], tenantId: 't', + }); + }); + + it('returns undefined for a user-mode run with no user (e.g. schedule trigger)', () => { + expect(resolveRunDataContext({ runAs: 'user' })).toBeUndefined(); + expect(resolveRunDataContext(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 0f1d094dbf..75d351845b 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -966,6 +966,13 @@ export class AutomationEngine implements IAutomationService { const startedAt = new Date().toISOString(); const steps: StepLogEntry[] = []; + // ADR-0049 / #1888 — establish the run's effective execution identity + // from flow.runAs. Thread a COPY (never mutating the caller's context) + // so the elevation is scoped to this run; the caller's identity is + // restored (unchanged) when execute() returns. Data nodes translate + // context.runAs into the ObjectQL context they pass (resolveRunDataContext). + const runContext: AutomationContext = { ...(context ?? {}), runAs: flow.runAs ?? 'user' }; + try { // Find the start node const startNode = flow.nodes.find(n => n.type === 'start'); @@ -996,7 +1003,7 @@ export class AutomationEngine implements IAutomationService { this.validateNodeInputSchemas(flow, variables); // DAG traversal execution - await this.executeNode(startNode, flow, variables, context ?? {}, steps); + await this.executeNode(startNode, flow, variables, runContext, steps); // Collect output variables const output: Record = {}; @@ -1046,7 +1053,7 @@ export class AutomationEngine implements IAutomationService { nodeId: err.nodeId, variables: Object.fromEntries(variables), steps, - context: context ?? {}, + context: runContext, startedAt, startTime, correlation: err.correlation, @@ -2218,13 +2225,17 @@ export class AutomationEngine implements IAutomationService { const startedAt = new Date().toISOString(); const steps: StepLogEntry[] = []; + // ADR-0049 / #1888 — establish the run's effective execution identity + // from flow.runAs (see execute()); threaded to node executors below. + const runContext: AutomationContext = { ...(context ?? {}), runAs: flow.runAs ?? 'user' }; + try { const startNode = flow.nodes.find(n => n.type === 'start'); if (!startNode) { return { success: false, error: 'Flow has no start node' }; } - await this.executeNode(startNode, flow, variables, context ?? {}, steps); + await this.executeNode(startNode, flow, variables, runContext, steps); const output: Record = {}; if (flow.variables) { diff --git a/packages/services/service-automation/src/index.ts b/packages/services/service-automation/src/index.ts index 7f91229630..e83c9adc42 100644 --- a/packages/services/service-automation/src/index.ts +++ b/packages/services/service-automation/src/index.ts @@ -35,6 +35,12 @@ export { SysAutomationRun } from './sys-automation-run.object.js'; export { AutomationServicePlugin } from './plugin.js'; export type { AutomationServicePluginOptions } from './plugin.js'; +// Run identity (ADR-0049 / #1888). Maps a flow run's effective `runAs` to the +// ObjectQL `context` its data nodes pass — `system` → elevated/RLS-bypassing, +// `user` → the triggering user. Exported for hosts building custom data nodes. +export { resolveRunDataContext } from './runtime-identity.js'; +export type { RunDataContext } from './runtime-identity.js'; + // Built-in node executors (ADR-0018). These are seeded by AutomationServicePlugin // and exported for advanced hosts that build a custom engine. They are functions, // not plugins — the platform's foundational nodes are built in, not installed. diff --git a/packages/services/service-automation/src/runtime-identity.ts b/packages/services/service-automation/src/runtime-identity.ts new file mode 100644 index 0000000000..b844ef71cc --- /dev/null +++ b/packages/services/service-automation/src/runtime-identity.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { AutomationContext } from '@objectstack/spec/contracts'; + +/** + * The identity envelope a flow's data nodes pass to ObjectQL as `options.context`. + * A structural subset of the kernel `ExecutionContext` — it always carries the + * three fields the security middleware keys on (`isSystem`, `roles`, + * `permissions`) so it is directly assignable to the engine's `context` option, + * plus the optional `userId`/`tenantId` of the acting user. + */ +export interface RunDataContext { + /** Elevated, RLS-bypassing system principal (full access) when true. */ + isSystem: boolean; + /** Acting user id — drives owner/role RLS for `runAs:'user'` runs. */ + userId?: string; + /** Acting user's role names (RLS parity with a direct REST request). */ + roles: string[]; + /** Acting user's explicit permission-set names. */ + permissions: string[]; + /** Acting user's tenant/org id. */ + tenantId?: string; +} + +/** + * Translate a flow run's {@link AutomationContext} into the ObjectQL `context` + * its CRUD nodes must pass, honoring `runAs` (ADR-0049 / #1888): + * + * - `runAs:'system'` → `{ isSystem: true }` — the security middleware + * short-circuits, so the run reads/writes with full access, bypassing RLS. + * - `runAs:'user'` (default) → the triggering user's identity + * (`{ userId, roles, permissions, tenantId? }`), so the security middleware + * enforces that user's row-level security. The run can never exceed the + * triggering user's grants. Empty `roles` falls back to the platform's + * baseline permission set, exactly like a fresh member's own REST request. + * + * Returns `undefined` when neither elevation nor a user identity applies (e.g. a + * schedule-triggered `user`-mode run with no user). The CRUD node then omits the + * `context` and the data engine applies its no-identity default — unchanged from + * the pre-#1888 behavior for that (identity-less) case. + * + * The engine sets {@link AutomationContext.runAs} on the run context at setup; + * this function is the single place that maps it to an ObjectQL context, shared + * by every data-touching node so the policy can't drift between node types. + */ +export function resolveRunDataContext(context: AutomationContext | undefined): RunDataContext | undefined { + if (context?.runAs === 'system') { + return { isSystem: true, roles: [], permissions: [] }; + } + if (!context?.userId) return undefined; + // `context` is now narrowed to a defined AutomationContext with a userId. + const out: RunDataContext = { + isSystem: false, + userId: context.userId, + roles: Array.isArray(context.roles) ? context.roles : [], + permissions: Array.isArray(context.permissions) ? context.permissions : [], + }; + if (context.tenantId) out.tenantId = context.tenantId; + return out; +} diff --git a/packages/spec/liveness/flow.json b/packages/spec/liveness/flow.json index 555d704128..be89b5cef4 100644 --- a/packages/spec/liveness/flow.json +++ b/packages/spec/liveness/flow.json @@ -36,6 +36,12 @@ "evidence": "objectui: packages/app-shell/src/views/metadata-admin/previews/FlowPreview.tsx:105", "note": "LIVE via objectui metadata-admin authoring UI — the 2026-06 audit missed objectui; verified by reading the file." }, + "runAs": { + "status": "live", + "proof": "packages/dogfood/test/flow-runas.dogfood.test.ts#flow-runas-identity", + "evidence": "packages/services/service-automation/src/builtin/crud-nodes.ts: CRUD nodes pass the run identity to ObjectQL per flow.runAs; the engine sets context.runAs at run setup; resolveRunDataContext maps system->isSystem / user->trigger user (#1888)", + "note": "ENFORCED 2026-06-24 (#1888, ADR-0049): was marker-driven experimental; the engine now switches the data-layer execution identity and restores the caller context. Proof-backed by the dogfood gate." + }, "template": { "status": "dead", "evidence": "no reader either layer" diff --git a/packages/spec/scripts/liveness/proof-registry.mts b/packages/spec/scripts/liveness/proof-registry.mts index 014891bd7f..24ebc04da4 100644 --- a/packages/spec/scripts/liveness/proof-registry.mts +++ b/packages/spec/scripts/liveness/proof-registry.mts @@ -107,6 +107,17 @@ export const HIGH_RISK_CLASSES: HighRiskClass[] = [ // execution + variable wiring the proof guards. ledgerBindings: [{ type: 'flow', path: 'nodes.type' }], }, + { + id: 'flow-runas', + label: 'Flow runAs identity', + summary: 'flow.runAs switches the data-layer execution identity — system elevates (RLS-bypass), user de-elevates to the triggering user (#1888).', + proofId: 'flow-runas-identity', + proofRef: 'packages/dogfood/test/flow-runas.dogfood.test.ts#flow-runas-identity', + bound: true, + // `runAs` decides whether CRUD nodes run as an elevated system principal or + // the triggering user — the security property the proof guards in both directions. + ledgerBindings: [{ type: 'flow', path: 'runAs' }], + }, { id: 'form-widget', label: 'Form layout / section / widget', diff --git a/packages/spec/scripts/liveness/proof-registry.test.ts b/packages/spec/scripts/liveness/proof-registry.test.ts index ce796dbeaa..1f6688a171 100644 --- a/packages/spec/scripts/liveness/proof-registry.test.ts +++ b/packages/spec/scripts/liveness/proof-registry.test.ts @@ -105,6 +105,7 @@ describe('registry invariants', () => { [ 'field/type', 'flow/nodes.type', + 'flow/runAs', 'permission/rowLevelSecurity.using', 'dataset/dimensions.dateGranularity', 'object/sharingModel', diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index abb264eb65..908247f706 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -262,11 +262,19 @@ export const FlowSchema = lazySchema(() => z.object({ /** Execution Config */ active: z.boolean().default(false).describe('Is active (Deprecated: use status)'), - // ⚠️ EXPERIMENTAL — NOT ENFORCED (ADR-0049, #1888). The service-automation - // engine ignores `runAs`: a flow declaring `runAs: system` does not elevate, - // and `user` does not de-elevate — steps run as the triggering context's - // identity regardless. Identity switching is tracked by #1888 (M2). - runAs: z.enum(['system', 'user']).default('user').describe('[EXPERIMENTAL — not enforced] Execution context'), + // ADR-0049 / #1888 — ENFORCED. The service-automation engine establishes the + // declared identity for the run's data operations and restores the caller's + // context afterward: `system` runs elevated (a full-access, RLS-bypassing + // system principal); `user` (default) runs as the triggering user, so CRUD + // nodes' ObjectQL reads/writes respect that user's row-level security. A + // schedule-triggered run with no user stays unscoped under `user` (there is no + // identity to scope to) — declare `system` to make elevation explicit. + runAs: z + .enum(['system', 'user']) + .default('user') + .describe( + 'Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting).', + ), /** Error Handling Strategy */ errorHandling: z.object({ diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 196152824f..ef86028b3d 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -36,6 +36,33 @@ export interface AutomationContext { event?: string; /** User who triggered the automation */ userId?: string; + /** + * Role names of the triggering identity. Forwarded by the trigger surface + * (REST route / record-change hook) so a `runAs:'user'` run enforces RLS + * exactly as that user — not a member fallback. ADR-0049 / #1888. + */ + roles?: string[]; + /** + * Explicit permission-set names of the triggering identity (parity with a + * direct REST request). Forwarded alongside {@link roles}. ADR-0049 / #1888. + */ + permissions?: string[]; + /** + * Tenant/org id of the triggering identity, carried so a `runAs:'user'` run + * stays tenant-scoped. ADR-0049 / #1888. + */ + tenantId?: string; + /** + * Effective execution identity for the run's DATA operations, established by + * the engine from {@link FlowParsed.runAs} at run setup (ADR-0049 / #1888): + * - `'system'` runs elevated — a full-access, RLS-bypassing system principal; + * - `'user'` (default) runs as {@link userId}, so CRUD nodes' ObjectQL + * reads/writes respect that user's row-level security. + * Node executors translate this into the ObjectQL `context` they pass to the + * data engine (see `resolveRunDataContext` in @objectstack/service-automation). + * Callers do NOT set this — the engine derives it from the flow definition. + */ + runAs?: 'system' | 'user'; /** Additional contextual data */ params?: Record; } diff --git a/packages/triggers/trigger-record-change/src/record-change-trigger.ts b/packages/triggers/trigger-record-change/src/record-change-trigger.ts index ef99397a43..7b337abead 100644 --- a/packages/triggers/trigger-record-change/src/record-change-trigger.ts +++ b/packages/triggers/trigger-record-change/src/record-change-trigger.ts @@ -179,7 +179,7 @@ export class RecordChangeTrigger implements FlowTrigger { { ...(inputDoc ?? {}), ...after } : inputDoc ?? (previous && typeof previous === 'object' ? previous : {}); - const session = (ctx.session ?? {}) as { userId?: string }; + const session = (ctx.session ?? {}) as { userId?: string; tenantId?: string; roles?: string[] }; return { record, @@ -187,6 +187,11 @@ export class RecordChangeTrigger implements FlowTrigger { object: binding.object ?? ctx.object, event: binding.event, userId: session.userId, + // Forward the writer's roles/tenant so a `runAs:'user'` flow enforces + // RLS exactly as the user who made the change, not a member fallback + // (#1888). The engine elevates only for `runAs:'system'`. + ...(Array.isArray(session.roles) && session.roles.length ? { roles: session.roles } : {}), + ...(session.tenantId ? { tenantId: session.tenantId } : {}), // Expose the record as params too, so flows with named `isInput` // variables matching record fields get them seeded. params: record,