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
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@
// registered after boot runs last, right before the driver), and what the
// caller is told (status, envelope, and that no byte of the row comes back).
//
// Flow start admission's third question (#29365) runs before the executor: a
// start input the definition reads as a record id is judged as the caller, for
// every runAs, before the run exists. The guest is a caller like any other
// (`isSystem: false`), so a guest start naming a row it cannot read is refused
// `404 RECORD_NOT_FOUND` and the executor is never asked; the executor pin
// below uses a start that names no row.
//
// The fixture is inline and throwaway on purpose, like the policy fixture: it
// exists only to be probed. Every anonymous endpoint carries the armed rate
// limit ADR-0121 D6 makes its publish obligation, so booting at all is a
Expand Down Expand Up @@ -282,10 +289,12 @@ describe('[#22147] object_operation at authRequired:false — the anonymous requ
describe('[#22147] flow at authRequired:false — the flow executor is handed the guest', () => {
it('carries the guest position with no user and no elevation, and the run\'s data call never reaches the engine principal-less', async () => {
resetProbes();
// A start that names no row: flow start admission has nothing to load, so
// the executor is reached and its own identity rule is what answers.
const res = await stack.api('/apps/anonguest/read', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ params: { noteId: seededId } }),
body: JSON.stringify({ params: {} }),
});
const text = await res.text();

Expand All @@ -305,4 +314,27 @@ describe('[#22147] flow at authRequired:false — the flow executor is handed th
expect(engineCalls).toEqual([]);
expect(driverReached).toEqual([]);
});

it('a guest start naming a row the guest cannot read is refused at the door as the guest — 404, no run, nothing disclosed', async () => {
resetProbes();
const res = await stack.api('/apps/anonguest/read', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ params: { noteId: seededId } }),
});
const text = await res.text();
expect(res.status, text).toBe(404);
const body = JSON.parse(text) as { success?: boolean; data?: unknown; error?: { code?: string } };
expect(body.success).toBe(false);
expect(body.error?.code).toBe('RECORD_NOT_FOUND');
expect(body).not.toHaveProperty('data');
expect(text).not.toContain(SEEDED_NAME);
// Refused before the run exists: the executor was never asked.
expect(flowContexts).toEqual([]);
// The door's load of the row ran in the GUEST's scope — never principal-less,
// never the system principal — and no security layer let it reach the driver.
expect(engineCalls.length).toBeGreaterThan(0);
for (const call of engineCalls) expectGuest(call.context);
expect(driverReached).toEqual([]);
});
});
70 changes: 66 additions & 4 deletions packages/qa/dogfood/test/fixtures/flow-runas-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@
// 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.
//
// Flow start admission's third question (#29365) judges every start input a
// flow's definition reads as a record id (a CRUD node's `filter.id`, handed on
// through `subflow`) in the CALLER's own scope, for every `runAs`, before the
// run exists: an input the caller cannot read is refused 404 RECORD_NOT_FOUND.
// So the proofs pick rows the rule admits:
// • WRITE — an admin note marked `status: 'shared'`, which the member may
// READ (a select-only policy) but still not WRITE (the owner policy): the
// id passes the door, and the write is where the two `runAs` part ways.
// • READ — `runas_<mode>_read_sealed`, whose definition fixes the row by a
// literal (`SEALED_NOTE_NAME`), with no start input at all: the elevated
// read sees an admin note the member cannot, the user-mode read does not.

import { defineStack } from '@objectstack/spec';
import { ObjectSchema, Field } from '@objectstack/spec/data';
Expand Down Expand Up @@ -117,10 +129,48 @@ function readFlow(name: string, runAs: 'system' | 'user'): Flow {
};
}

/** The `name` of the admin note the sealed read flows fix by a literal. */
export const SEALED_NOTE_NAME = 'runas-sealed-note';

/** The `status` that makes an admin note readable (never writable) to a member. */
export const SHARED_STATUS = 'shared';

/**
* `runas_<mode>_read_sealed` — start → get_record → end, where the DEFINITION
* names the row (a literal `name` filter) and the start hands it nothing. The
* read is the run's own choice of row, so it shows what the run's identity can
* see, with no caller input for flow start admission to judge.
*/
function sealedReadFlow(name: string, runAs: 'system' | 'user'): Flow {
return {
name,
label: `RunAs ${runAs} sealed read`,
type: 'autolaunched',
runAs,
variables: [{ name: 'found', type: 'text', isOutput: true }],
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{
id: 'get',
type: 'get_record',
label: 'Get the sealed note',
config: { objectName: 'runas_note', filter: { name: SEALED_NOTE_NAME }, 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');
export const runasSystemReadSealed = sealedReadFlow('runas_system_read_sealed', 'system');
export const runasUserReadSealed = sealedReadFlow('runas_user_read_sealed', 'user');

/**
* `<child>_via_parent` — start → subflow(child) → end, under `runAs: 'user'`:
Expand All @@ -129,14 +179,14 @@ export const runasUserRead = readFlow('runas_user_read', 'user');
* 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 {
function viaParent(child: Flow, withOutput: boolean, handsNoteId = true): 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 },
...(handsNoteId ? [{ name: 'noteId', type: 'text', isInput: true }] : []),
...(withOutput ? [{ name: 'found', type: 'object', isOutput: true }] : []),
],
nodes: [
Expand All @@ -148,7 +198,7 @@ function viaParent(child: Flow, withOutput: boolean): Flow {
config: {
flowName: child.name,
// `input.*` is a value slot (#19939): the parent's own input, as a CEL envelope.
input: { noteId: { dialect: 'cel', source: 'noteId' } },
input: handsNoteId ? { noteId: { dialect: 'cel', source: 'noteId' } } : {},
...(withOutput ? { outputVariable: 'found' } : {}),
},
},
Expand All @@ -163,6 +213,7 @@ function viaParent(child: Flow, withOutput: boolean): Flow {

export const runasSystemTouchViaParent = viaParent(runasSystemTouch, false);
export const runasSystemReadViaParent = viaParent(runasSystemRead, true);
export const runasSystemReadSealedViaParent = viaParent(runasSystemReadSealed, true, false);

/** A minimal, self-contained app config the dogfood harness can boot. */
export const runasFixtureStack = defineStack({
Expand All @@ -182,6 +233,9 @@ export const runasFixtureStack = defineStack({
runasUserRead,
runasSystemTouchViaParent,
runasSystemReadViaParent,
runasSystemReadSealed,
runasUserReadSealed,
runasSystemReadSealedViaParent,
],
});

Expand All @@ -191,6 +245,11 @@ export const runasFixtureStack = defineStack({
* 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.
*
* One select-only policy widens READS alone: an admin note whose `status` is
* `SHARED_STATUS` is readable to the member (read policies OR-combine), and
* stays unwritable (no write policy admits it). That is the row the write
* proof hands a flow: one the caller can show it reads, and cannot touch.
*/
const FIXTURE_MEMBER_SET = 'runas_fixture_member';

Expand All @@ -200,7 +259,10 @@ export const runasMemberSet: PermissionSet = PermissionSetSchema.parse({
objects: {
runas_note: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
},
rowLevelSecurity: [RLS.ownerPolicy('runas_note', 'created_by')],
rowLevelSecurity: [
RLS.ownerPolicy('runas_note', 'created_by'),
{ name: 'runas_note_shared_read', object: 'runas_note', operation: 'select', using: `status == '${SHARED_STATUS}'` },
],
});

/**
Expand Down
Loading
Loading