Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/flow-runas-identity-enforcement.md
Original file line number Diff line number Diff line change
@@ -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`).
6 changes: 3 additions & 3 deletions docs/audits/2026-06-flowschema-property-liveness.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
156 changes: 156 additions & 0 deletions packages/dogfood/test/fixtures/flow-runas-fixture.ts
Original file line number Diff line number Diff line change
@@ -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_<mode>_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_<mode>_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,
});
}
127 changes: 127 additions & 0 deletions packages/dogfood/test/flow-runas.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<unknown> {
const res = await stack.apiAs(adminToken, 'GET', `/data/runas_note/${id}`);
expect(res.status).toBe(200);
const j = (await res.json()) as { record?: Record<string, unknown> } & Record<string, unknown>;
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<string, unknown> } & Record<string, unknown>;
const rec = (j.record ?? j) as Record<string, unknown>;
// 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();
});
});
Loading