Skip to content

Commit 21bbc57

Browse files
os-zhuangclaude
andcommitted
fix(security): enforce flow runAs execution identity (#1888)
The service-automation engine ignored `flow.runAs`: CRUD nodes passed no identity to ObjectQL, so the security middleware was skipped and every flow ran effectively elevated regardless of `runAs`. `runAs:'user'` did not de-elevate and `runAs:'system'` did not explicitly elevate — a privilege-boundary surprise in both directions (part of metadata-liveness audit #1878). Enforce it: the engine establishes the run's data-layer identity from `flow.runAs` at setup (a copy, so the caller's context is restored afterward) and every CRUD node threads it to ObjectQL as `options.context`: - system → { isSystem: true } (elevated, RLS-bypassing), - user (default) → the triggering user (RLS-respecting; roles/tenant forwarded by the REST trigger route + record-change trigger so it matches a direct request by that user). `resolveRunDataContext` is the single mapping point shared by all data nodes. Removes the [EXPERIMENTAL — not enforced] marker from FlowSchema.runAs, marks the audit entry live, and classifies `flow/runAs` in the spec liveness ledger as a proof-backed high-risk class (ADR-0054 prove-it-runs). Behavior change: flows that relied on the implicit elevation now run as the triggering user — declare `runAs:'system'` on flows that must exceed the trigger's access. Proven both directions by the dogfood gate (flow-runas.dogfood.test.ts — a restricted member triggering system vs user flows on an owner-scoped record, RED before / GREEN after) and service-automation unit + regression tests (crud-runas.test.ts). Refs #1888 #1878 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f1ddc98 commit 21bbc57

16 files changed

Lines changed: 697 additions & 18 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': minor
4+
'@objectstack/runtime': minor
5+
'@objectstack/trigger-record-change': minor
6+
---
7+
8+
fix(security): enforce flow `runAs` execution identity (#1888)
9+
10+
The `service-automation` engine now honors `flow.runAs` instead of ignoring it.
11+
Previously the CRUD nodes passed **no identity** to ObjectQL, so the security
12+
middleware was skipped entirely — every flow ran effectively elevated regardless
13+
of `runAs`. A `runAs:'user'` flow did **not** de-elevate (a privilege-boundary
14+
surprise), and `runAs:'system'` did not *explicitly* elevate.
15+
16+
The engine now establishes the run's data-layer identity at setup and restores
17+
the caller's context afterward:
18+
19+
- **`runAs:'system'`** → an elevated, RLS-bypassing system principal
20+
(`{ isSystem: true }`): the run can read/write records the triggering user
21+
cannot.
22+
- **`runAs:'user'`** (default) → the **triggering user's** identity
23+
(`{ userId, roles, permissions, tenantId }`): CRUD nodes' ObjectQL reads/writes
24+
respect that user's row-level security, and the run can never exceed the
25+
triggering user's grants.
26+
27+
To keep `runAs:'user'` faithful to a direct request by that user, the REST
28+
trigger route (`@objectstack/runtime`) and the record-change trigger
29+
(`@objectstack/trigger-record-change`) now forward the caller's resolved
30+
`roles`/`tenantId` into the `AutomationContext` (new optional fields), not just
31+
`userId`. The new `resolveRunDataContext` helper is the single place that maps a
32+
run's effective `runAs` to the ObjectQL context, shared by every data node.
33+
34+
The `[EXPERIMENTAL — not enforced]` marker is removed from `FlowSchema.runAs`.
35+
36+
**Behavior change / migration.** Flows that previously relied on the implicit
37+
elevation (the default `runAs:'user'` ran unscoped) now run as the triggering
38+
user and are subject to their RLS. **Declare `runAs:'system'` on any flow that
39+
must read or write beyond the triggering user's access** (e.g. system
40+
automations, cross-owner roll-ups). Schedule-triggered runs have no trigger user;
41+
under `user` they stay unscoped (there is no identity to scope to) — declare
42+
`system` to make elevation explicit.
43+
44+
Proven both directions by the dogfood regression gate
45+
(`flow-runas.dogfood.test.ts` — a restricted member triggers system vs user
46+
flows against an owner-scoped record) and service-automation unit + regression
47+
tests (`crud-runas.test.ts`).

docs/audits/2026-06-flowschema-property-liveness.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@
1010
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.
1111

1212
## 🟠 Execution-config props that are display-only at runtime (incl. security-relevant)
13-
- **`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.
13+
- **`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`).
1414
- **`status` / `active`** — engine gates on its in-memory `flowEnabled` map (`toggleFlow`), **not** on `status`/`active`. `active` is spec-flagged Deprecated and redundant with `status`.
1515
- **`errorHandling.fallbackNodeId`** — DEAD (engine uses per-node fault edges). Node **`outputSchema`** — DEAD (declared, never validated). `flow.template`, `flow.description` — no reader either layer.
1616

1717
## LIVE & well-wired
18-
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.
18+
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.
1919

2020
## 🟠 `notify` invisible in the static designer
2121
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.
2222

2323
## Recommendation
24-
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.
24+
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.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Flow `runAs` identity-enforcement fixture — the live, revert-provable #1888 gate.
4+
//
5+
// `flow.runAs` declares the execution identity for a run's data operations:
6+
// • `system` → elevated, RLS-bypassing system principal (full access),
7+
// • `user` → the triggering user (RLS-respecting; cannot exceed their grants).
8+
// The bug (#1888): the engine IGNORED `runAs`. CRUD nodes passed no identity to
9+
// ObjectQL at all, so the security middleware was skipped entirely — every flow
10+
// ran effectively elevated regardless of `runAs`. A `runAs:'user'` flow did NOT
11+
// de-elevate (a privilege-boundary surprise), and `runAs:'system'` did not
12+
// *explicitly* elevate (it merely happened to be unscoped too).
13+
//
14+
// This fixture reproduces the boundary with ZERO dependence on org-scoping,
15+
// mirroring rls-owner-fixture: one object `runas_note` whose member permission
16+
// set carries an OWNER policy keyed on `created_by` (survives single-tenant
17+
// stripping because the predicate references `current_user.id`). A fresh member
18+
// therefore genuinely CANNOT read or write a note the admin created.
19+
//
20+
// Four flows over that object, each triggered by the RESTRICTED member, prove
21+
// BOTH directions of the fix:
22+
// • runas_system_touch / runas_system_read (runAs:'system') — elevate: the
23+
// member-triggered run WRITES / READS the admin's note it cannot itself touch.
24+
// • runas_user_touch / runas_user_read (runAs:'user') — de-elevate: the
25+
// same run is RLS-denied on the admin's note (write lands nowhere; read empty).
26+
//
27+
// Before the fix the user-mode flows wrongly succeed (security skipped) → RED.
28+
// After the fix they are correctly denied while system-mode still succeeds → GREEN.
29+
30+
import { defineStack } from '@objectstack/spec';
31+
import { ObjectSchema, Field } from '@objectstack/spec/data';
32+
import { PermissionSetSchema, RLS, type PermissionSet } from '@objectstack/spec/security';
33+
import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security';
34+
35+
/** The one object under test: an owner-scoped note (isolated via `created_by`). */
36+
export const RunAsNote = ObjectSchema.create({
37+
name: 'runas_note',
38+
label: 'RunAs Note',
39+
pluralLabel: 'RunAs Notes',
40+
fields: {
41+
name: Field.text({ label: 'Name', required: true }),
42+
status: Field.text({ label: 'Status' }),
43+
},
44+
});
45+
46+
/**
47+
* `runas_<mode>_touch` — start → update_record → end. Sets `status` on the note
48+
* whose id is passed as the `noteId` input variable. The two variants differ
49+
* ONLY in `runAs`, isolating the identity switch as the single variable.
50+
*/
51+
function touchFlow(name: string, runAs: 'system' | 'user', stamp: string) {
52+
return {
53+
name,
54+
label: `RunAs ${runAs} touch`,
55+
type: 'autolaunched',
56+
runAs,
57+
variables: [{ name: 'noteId', type: 'text', isInput: true }],
58+
nodes: [
59+
{ id: 'start', type: 'start', label: 'Start' },
60+
{
61+
id: 'touch',
62+
type: 'update_record',
63+
label: 'Touch note',
64+
config: { objectName: 'runas_note', filter: { id: '{noteId}' }, fields: { status: stamp } },
65+
},
66+
{ id: 'end', type: 'end', label: 'End' },
67+
],
68+
edges: [
69+
{ id: 'e1', source: 'start', target: 'touch' },
70+
{ id: 'e2', source: 'touch', target: 'end' },
71+
],
72+
};
73+
}
74+
75+
/**
76+
* `runas_<mode>_read` — start → get_record → end. Reads the note by id into the
77+
* `found` output variable, surfaced on the trigger response as
78+
* `data.output.found`. Under `system` the elevated read returns the record;
79+
* under `user` the RLS-scoped read returns null.
80+
*/
81+
function readFlow(name: string, runAs: 'system' | 'user') {
82+
return {
83+
name,
84+
label: `RunAs ${runAs} read`,
85+
type: 'autolaunched',
86+
runAs,
87+
variables: [
88+
{ name: 'noteId', type: 'text', isInput: true },
89+
{ name: 'found', type: 'text', isOutput: true },
90+
],
91+
nodes: [
92+
{ id: 'start', type: 'start', label: 'Start' },
93+
{
94+
id: 'get',
95+
type: 'get_record',
96+
label: 'Get note',
97+
config: { objectName: 'runas_note', filter: { id: '{noteId}' }, outputVariable: 'found' },
98+
},
99+
{ id: 'end', type: 'end', label: 'End' },
100+
],
101+
edges: [
102+
{ id: 'e1', source: 'start', target: 'get' },
103+
{ id: 'e2', source: 'get', target: 'end' },
104+
],
105+
};
106+
}
107+
108+
export const runasSystemTouch = touchFlow('runas_system_touch', 'system', 'touched-system');
109+
export const runasUserTouch = touchFlow('runas_user_touch', 'user', 'touched-user');
110+
export const runasSystemRead = readFlow('runas_system_read', 'system');
111+
export const runasUserRead = readFlow('runas_user_read', 'user');
112+
113+
/** A minimal, self-contained app config the dogfood harness can boot. */
114+
export const runasFixtureStack = defineStack({
115+
manifest: {
116+
id: 'com.dogfood.runas_fixture',
117+
namespace: 'runas',
118+
version: '0.0.0',
119+
type: 'app',
120+
name: 'Flow runAs Fixture',
121+
description: 'Owner-isolated single-object app exercising flow.runAs identity enforcement (#1888).',
122+
},
123+
objects: [RunAsNote],
124+
flows: [runasSystemTouch, runasUserTouch, runasSystemRead, runasUserRead],
125+
});
126+
127+
/**
128+
* The fallback permission set a fresh member resolves to: full CRUD on
129+
* `runas_note` (so the request reaches the RLS layer, not an RBAC wall) plus an
130+
* owner RLS policy on ALL operations keyed on `created_by`. A member can read
131+
* and write their OWN notes, but neither read nor write the admin's — the exact
132+
* cross-owner isolation the runAs proof needs on both directions.
133+
*/
134+
const FIXTURE_MEMBER_SET = 'runas_fixture_member';
135+
136+
export const runasMemberSet: PermissionSet = PermissionSetSchema.parse({
137+
name: FIXTURE_MEMBER_SET,
138+
label: 'RunAs Fixture Member — owner-scoped (all ops)',
139+
isProfile: true,
140+
objects: {
141+
runas_note: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
142+
},
143+
rowLevelSecurity: [RLS.ownerPolicy('runas_note', 'created_by')],
144+
});
145+
146+
/**
147+
* Build a SecurityPlugin whose fallback (for a fresh, grant-less member) is the
148+
* owner-scoped fixture set, layered on the real platform defaults so the seeded
149+
* admin keeps `admin_full_access` (and can create the note + read everything).
150+
*/
151+
export function runasFixtureSecurity(): SecurityPlugin {
152+
return new SecurityPlugin({
153+
defaultPermissionSets: [...securityDefaultPermissionSets, runasMemberSet],
154+
fallbackPermissionSet: runasMemberSet.name,
155+
});
156+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// FLOW runAs identity-enforcement proof (#1888), exercised end-to-end through the
4+
// real HTTP + automation + security stack.
5+
//
6+
// @proof: flow-runas-identity
7+
// Security-layer instance of the "configured in the UI, silently does nothing at
8+
// runtime" anti-pattern (sibling of the assignment/decision node fixes). A flow's
9+
// `runAs` MUST switch the execution identity of its data nodes:
10+
// • runAs:'system' → elevated, RLS-bypassing (the run can touch records the
11+
// triggering user cannot),
12+
// • runAs:'user' → the triggering user (RLS-respecting; cannot exceed grants).
13+
//
14+
// The proof drives both directions as a RESTRICTED member against an owner-scoped
15+
// object the member cannot read or write directly:
16+
// • system flows succeed on the admin's note → elevation is REAL,
17+
// • user flows are RLS-denied on the same note → de-elevation is REAL.
18+
// Before the #1888 fix the user flows wrongly succeed (CRUD nodes passed no
19+
// identity → security skipped) → this file is RED; after the fix → GREEN.
20+
21+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
22+
import { bootStack, type VerifyStack } from '@objectstack/verify';
23+
import { runasFixtureStack, runasFixtureSecurity } from './fixtures/flow-runas-fixture.js';
24+
25+
describe('objectstack verify FLOW: runAs identity enforcement (#flow-runas)', () => {
26+
let stack: VerifyStack;
27+
let adminToken: string;
28+
let memberToken: string;
29+
30+
beforeAll(async () => {
31+
stack = await bootStack(runasFixtureStack, { automation: true, security: runasFixtureSecurity() });
32+
adminToken = await stack.signIn();
33+
// First user is the seeded dev admin, so this fresh sign-up is a plain member
34+
// who falls back to the owner-scoped fixture permission set.
35+
memberToken = await stack.signUp('member@runas.test');
36+
}, 60_000);
37+
38+
afterAll(async () => {
39+
await stack?.stop();
40+
});
41+
42+
/** Admin creates a note it owns; returns the new id. */
43+
async function adminCreateNote(name: string): Promise<string> {
44+
const res = await stack.apiAs(adminToken, 'POST', '/data/runas_note', { name, status: 'new' });
45+
expect(res.status, `admin create ${name} failed: ${res.status} ${await res.clone().text()}`).toBeLessThan(300);
46+
const j = (await res.json()) as { id?: string; record?: { id?: string } };
47+
const id = j.id ?? j.record?.id;
48+
expect(id, 'no id returned from create').toBeTruthy();
49+
return id as string;
50+
}
51+
52+
/** Read a note's status as the admin (who can always see every row). */
53+
async function adminStatusOf(id: string): Promise<unknown> {
54+
const res = await stack.apiAs(adminToken, 'GET', `/data/runas_note/${id}`);
55+
expect(res.status).toBe(200);
56+
const j = (await res.json()) as { record?: Record<string, unknown> } & Record<string, unknown>;
57+
return (j.record ?? j).status;
58+
}
59+
60+
/** Trigger a flow as the restricted member; returns the inner AutomationResult. */
61+
async function memberTrigger(flow: string, noteId: string): Promise<{ success?: boolean; output?: any }> {
62+
const res = await stack.apiAs(memberToken, 'POST', `/automation/${flow}/trigger`, { params: { noteId } });
63+
expect(res.status, `trigger ${flow} HTTP failed: ${res.status} ${await res.clone().text()}`).toBeLessThan(300);
64+
const body = (await res.json()) as { success?: boolean; data?: { success?: boolean; output?: any } };
65+
expect(body.success).toBe(true);
66+
return body.data ?? {};
67+
}
68+
69+
it('precondition: the automation service is wired and a flow is registered', async () => {
70+
const res = await stack.apiAs(memberToken, 'GET', '/automation/runas_system_touch');
71+
expect(res.status, `automation service not wired: ${res.status}`).toBe(200);
72+
});
73+
74+
it('precondition: the member is genuinely RLS-denied on the admin note (isolation is real)', async () => {
75+
const id = await adminCreateNote('iso-check');
76+
// Admin sees it; member must NOT (owner policy keyed on created_by).
77+
expect(await adminStatusOf(id)).toBe('new');
78+
const res = await stack.apiAs(memberToken, 'GET', `/data/runas_note/${id}`);
79+
if (res.status === 200) {
80+
const j = (await res.json()) as { record?: Record<string, unknown> } & Record<string, unknown>;
81+
const rec = (j.record ?? j) as Record<string, unknown>;
82+
// A 200 is only acceptable if it carries NO actual row (RLS filtered it out).
83+
expect(rec.id ?? rec.name, 'member could READ the admin note — RLS isolation is not in effect').toBeFalsy();
84+
} else {
85+
expect(res.status, `unexpected status for RLS-denied read: ${res.status}`).toBe(404);
86+
}
87+
});
88+
89+
// ── WRITE direction ───────────────────────────────────────────────────────
90+
91+
it("runAs:'system' ELEVATES — member-triggered system flow WRITES a record the member cannot", async () => {
92+
const id = await adminCreateNote('sys-touch');
93+
const result = await memberTrigger('runas_system_touch', id);
94+
expect(result.success, `system flow run not successful: ${JSON.stringify(result)}`).toBe(true);
95+
// The elevated run bypassed RLS and stamped the admin's note.
96+
expect(await adminStatusOf(id)).toBe('touched-system');
97+
});
98+
99+
it("runAs:'user' DE-ELEVATES — member-triggered user flow is RLS-DENIED on the same record", async () => {
100+
const id = await adminCreateNote('user-touch');
101+
await memberTrigger('runas_user_touch', id);
102+
// The run executed as the member; the by-id write to the admin's note is
103+
// RLS-denied, so the record is unchanged. (Before the fix it would read
104+
// 'touched-user' — the privilege-boundary surprise this gate pins.)
105+
const after = await adminStatusOf(id);
106+
expect(after, 'user-mode flow wrote a record the triggering member cannot access (#1888 regression)').not.toBe(
107+
'touched-user',
108+
);
109+
expect(after).toBe('new');
110+
});
111+
112+
// ── READ direction ────────────────────────────────────────────────────────
113+
114+
it("runAs:'system' READS a record the member cannot; runAs:'user' cannot", async () => {
115+
const id = await adminCreateNote('read-check');
116+
117+
const sys = await memberTrigger('runas_system_read', id);
118+
expect(sys.output?.found, 'system flow could not read the record it should see (elevation broken)').toBeTruthy();
119+
120+
const usr = await memberTrigger('runas_user_read', id);
121+
const found = usr.output?.found;
122+
expect(
123+
found && typeof found === 'object' ? (found as any).id : found,
124+
'user-mode flow READ a record the triggering member cannot access (#1888 regression)',
125+
).toBeFalsy();
126+
});
127+
});

0 commit comments

Comments
 (0)