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
32 changes: 32 additions & 0 deletions .changeset/29050-flow-declared-record-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@objectstack/runtime': patch
---

fix(runtime): a start input a flow's definition reads as a record id is loaded in the caller's own scope from the object the flow reads it from, and must be an id, before the run exists (#29050)

Clause-②: no
Metadata-protocol: none

**The rule.** Where a flow's own definition reads a start input as the id of a record of a fixed object, every door that starts the flow by name loads that input in the caller's own scope from THAT object before the run exists, and refuses a value the caller cannot read there with the action door's own `404 RECORD_NOT_FOUND` (ADR-0112 envelope). A record the caller cannot read and an id that names nothing get the same answer. The input must name one record by its id (a string or a number), or a list of records by their ids where the definition reads it as a list; any other value is refused `400 VALIDATION_FAILED` naming the input. Nothing runs on a refusal: no run record, no node, no side effect. This holds whatever object the start names, or none, and whatever the flow runs as.

**Where a definition reads an input as a record id.** Across every region of the flow (loop bodies, parallel branches, try and catch regions):

- a `get_record`, `update_record` or `delete_record` node with a literal `objectName` whose `filter.id` or `filter.id.$eq` is the input (one id), or whose `filter.id.$in` is (a list of ids);
- a `screen` node with a literal `objectName` whose `recordId` is the input;
- a `subflow` node with a literal `flowName` that hands the input to a callee input the callee reads so, and a `map` node that does the same with the input itself or with each item of it (a `map` iterating the input reads it as a list of ids).

"Is the input" means a CEL value whose whole source is the input's name (`caseId` or `vars.caseId`).

**Which doors.** `POST /api/v1/automation/:name/trigger` and the legacy `POST /api/v1/automation/trigger/:name`; a declared endpoint of `type: 'flow'`; and an action of `type: 'flow'` (the REST action route and the MCP `run_action` tool), for each key of the caller's own `params` and for the routed record id under the row slots, so the routed record is judged against the object the flow reads as well as the action's own object.

**The REST action route's routed record is one id.** A body `recordId` that is not one id (a string or a number) is refused `400 VALIDATION_FAILED` naming `recordId`, before the declared param contract, the record load and dispatch, for every action type. A path segment is always one id; an absent, `null` or empty `recordId` is the record-less call, as before.

**What stays as it was.**

- A start whose caller can read the record in the object the flow reads it from.
- An input the definition does not read as a record id, including a value the flow computes from an input or derives elsewhere.
- A name a `loop` or `map` node binds as its item or index variable, which no longer holds the start's value once that node runs.
- The system principal (an in-process caller, such as a job).
- At the action door, the subject record's own fields, and the row slots of an action whose `recordIdField` names a field other than `id`.

**If a start now answers 404 or 400.** Hand the input the id of a record the caller can read in the object the flow reads it from, in the shape the flow reads (one id, or a list of ids).
2 changes: 1 addition & 1 deletion content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2482,7 +2482,7 @@ door's own admission check, made before anything is dispatched:
| `404` | — | No flow by that name in this deployment | Check the name; nothing was dispatched |
| `403` | `PERMISSION_DENIED` | The caller is not the system principal, and the flow is declared `runAs: 'system'` with a self-triggered `type` (`autolaunched`, `record_change`, `schedule`). The same answer at every door that starts a flow by name: this route, a `type: 'flow'` action (REST and MCP `run_action`) and a declared `type: 'flow'` endpoint | Let it run on its own trigger, or call it from a parent flow's `subflow` node. If members are meant to start it, declare it `type: 'screen'` (or `'api'` for a signed inbound hook) |
| `403` | `PERMISSION_DENIED` | The flow declares `requiredPermissions` and the caller, not the system principal, does not hold every capability it names. Checked after the row above, at the same doors; the message names the declared and the missing capabilities | Grant the capability through a permission set's `systemPermissions`, or start the flow as someone who holds it |
| `404` | `RECORD_NOT_FOUND` | The start names a record (`recordId`, or the object's id alias such as `crmLeadId`) that the caller, not the system principal, cannot read in its own scope, or that does not exist: the two get one answer, the one the action door gives. Checked after the two rows above, at the same doors. A record key that is not one id, or a record named with no `objectName` for a flow declared `runAs: 'system'`, is refused `400` `VALIDATION_FAILED` naming the key | Start the flow on a record the caller can read, and name the record's object (`objectName`) beside `recordId` |
| `404` | `RECORD_NOT_FOUND` | The start names a record (`recordId`, or the object's id alias such as `crmLeadId`) that the caller, not the system principal, cannot read in its own scope, or that does not exist: the two get one answer, the one the action door gives. Checked after the two rows above, at the same doors. A record key that is not one id, or a record named with no `objectName` for a flow declared `runAs: 'system'`, is refused `400` `VALIDATION_FAILED` naming the key. Where the flow's own definition reads a start input as the id of a record of a fixed object (a `get_record`, `update_record` or `delete_record` node with a literal `objectName` whose `filter.id` or `filter.id.$eq` is the input, or whose `filter.id.$in` is; a `screen` node with a literal `objectName` whose `recordId` is the input; a `subflow` or `map` node that hands the input to a callee input the callee reads so), that input is also loaded in the caller's own scope from THAT object, whatever object the start names, or none, and whatever the flow runs as: a value the caller cannot read there gets this `404`, and a value that is not one id (or a list of ids, where the definition reads a list) is refused `400` `VALIDATION_FAILED` naming the input | Start the flow on a record the caller can read, and name the record's object (`objectName`) beside `recordId`. Hand an input the flow reads as a record id the id of a record the caller can read in the object the flow reads it from, in the shape the flow reads (one id, or a list of ids) |
| `409` | `FLOW_DISABLED` | The flow exists but is switched off | Enable it — the identical request then succeeds |
| `422` | `FLOW_NO_START_NODE` | The stored definition has no `start` node | Fix the flow; retrying cannot help |
| `422` | `FLOW_INPUT_SCHEMA_INVALID` | A node's `config` violates the `inputSchema` its own definition declares | Fix the flow; the refusal is non-retryable — even under `errorHandling.strategy: 'retry'` the engine refuses once and never enters the retry loop |
Expand Down
5 changes: 5 additions & 0 deletions content/docs/ui/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,11 @@ the server resolves your declaration by name and derives the handler key from
it. Rename the underlying function freely; as long as the declaration's
`target` follows, the public URL is unchanged.

A body `recordId` names one record by its id, a string or a number. Any other
value is refused `400 VALIDATION_FAILED` naming `recordId`, before the declared
param contract, the record load and dispatch, for every action type. An absent,
`null` or empty `recordId` is the record-less call.

Failures speak HTTP — the status code is the signal (#3962):

- **400** — the action ran and **rejected** (a business rule said no, or
Expand Down
139 changes: 113 additions & 26 deletions packages/runtime/src/action-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ import {
// Who may START a flow by name — the one rule the trigger door, this door and
// the declared endpoint share (the maintainer's rulings, letters B and A, and
// the flow's own `requiredPermissions`).
import { flowRunsElevated, flowStartRefusal } from './flow-start-admission.js';
import { flowDeclaredRecordReads, flowRunsElevated, flowStartRefusal } from './flow-start-admission.js';
import { validationFailure } from './validation-failure.js';
// [ADR-0066 D4] The capability arithmetic the action gate and the flow start
// gate share.
Expand Down Expand Up @@ -2383,6 +2383,33 @@ function flowActionCallerRecordKeys(
return keys;
}

/**
* The inputs this door hands a run from the REQUEST, each with the value the
* run receives under it ({@link seedFlowActionParams}'s bag), as flow start
* admission's third question judges them against the object the flow's own
* definition reads them from ({@link refuseUnreadableFlowStartRecords}): every
* key of the caller's own `params`, and each row slot carrying the routed
* record id. Two kinds of key are left out, because their values come from a
* row the caller has already shown it can read, not from the request:
*
* - the subject row's own fields, which the bag carries under their names;
* - the row slots of an action whose `recordIdField` names a field other than
* `id`, which carry that field's value.
*/
function flowActionStartInputs(
action: any,
objectName: string,
params: Record<string, unknown>,
runParams: Record<string, unknown>,
): Array<readonly [string, unknown]> {
const keys = new Set<string>(Object.keys(params));
const idField: string = typeof action?.recordIdField === 'string' && action.recordIdField ? action.recordIdField : 'id';
if (idField === 'id') {
for (const key of flowActionRowIdKeys(action, objectName)) keys.add(key);
}
return [...keys].map((key) => [key, runParams[key]] as const);
}

/**
* Brand for {@link FlowActionRefusal} — `Symbol.for`, so recognition keeps
* working even if two module instances of this file ever coexist (src vs
Expand Down Expand Up @@ -2607,10 +2634,21 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps,
// (the request's own driver, environment and identity, as both entrances
// load the routed row), and refused with that load's 404 before the run
// exists. Thrown with `code` + `status`, like the refusals above.
//
// The same question then follows every value this door hands the run's
// inputs that came from the request (the caller's params, and the routed
// id under the row slots) to the object the flow's own definition reads it
// from, where it declares one: the routed row was judged against the
// action's object, which is not always the object the flow reads.
const runParams = seedFlowActionParams(deps, action, { objectName, record, params, recordId });
await refuseUnreadableFlowStartRecords(
automation,
action.target,
{ objectName, recordKeys: flowActionCallerRecordKeys(action, objectName, params, recordId, subject) },
{
objectName,
recordKeys: flowActionCallerRecordKeys(action, objectName, params, recordId, subject),
inputs: flowActionStartInputs(action, objectName, params, runParams),
},
ec,
(object, id) => callData(deps, requestContext, 'get', { object, id }, requestContext?.dataDriver, envId, ec),
);
Expand Down Expand Up @@ -2640,7 +2678,7 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps,
// twin (`buildAutomationContext`): a refused run's message renders in
// it. See `AutomationContext.locale`.
...(typeof ec?.locale === 'string' && ec.locale.length > 0 ? { locale: ec.locale } : {}),
params: seedFlowActionParams(deps, action, { objectName, record, params, recordId }),
params: runParams,
// [#19846] Which of those keys the CALLER supplied, stated here where it
// is known rather than inferred downstream from the merged bag.
callerParamKeys: flowActionCallerParamKeys(action, objectName, params),
Expand Down Expand Up @@ -3733,48 +3771,97 @@ export type FlowStartRecordKeys = ReadonlyArray<readonly [key: string, value: un
* loaded and is refused `400 VALIDATION_FAILED` naming the key: only an id
* names one row.
*
* ## The object the flow reads a start input from
*
* The object the start names is not always the object the run reads. A flow's
* own definition can state that it reads a start input as the id of a row of
* a fixed object ({@link flowDeclaredRecordReads}: a CRUD node's `filter.id`,
* a screen edit form's `recordId`, handed on through `subflow` and `map`
* inputs). Every such input the start hands the run (`start.inputs`, the
* `params` the run's input variables are bound from, row slots included) is
* judged against THAT object too, by the same load and the same refusals: the
* value must be one record id (a list of ids where the flow reads a list), or
* the start is refused `400 VALIDATION_FAILED` naming the input, and each id
* is loaded in the caller's own scope from the object the flow reads it from,
* or the start is refused `404 RECORD_NOT_FOUND` for that object. This holds
* whether or not the start names an object, and whatever object it names.
*
* ## What stays as it was
*
* - **The system principal** (`isSystem`, never set on inbound HTTP) starts
* every flow on every row, as the two admission rules let it.
* - **A start that names no record key** loads nothing.
* - **A key the door already loaded** is not passed here: the action door's
* subject load judged the routed id, and its tests stay as they are.
* - **A start that names no record key**, to a flow that declares no record
* read of what it was handed, loads nothing.
* - **A key the door already loaded** is not passed in `recordKeys`: the
* action door's subject load judged the routed id against the action's
* object, and its tests stay as they are.
*
* `readRecord` is the door's own caller-scope read, the `callData('get', …)`
* its subject load uses, so there is no second loader.
*/
export async function refuseUnreadableFlowStartRecords(
automation: unknown,
flowName: string,
start: { objectName: unknown; recordKeys: FlowStartRecordKeys },
start: { objectName: unknown; recordKeys: FlowStartRecordKeys; inputs?: FlowStartRecordKeys },
executionContext: { isSystem?: boolean } | null | undefined,
readRecord: (objectName: string, recordId: string) => Promise<unknown>,
): Promise<void> {
if (executionContext?.isSystem === true) return;
const named = start.recordKeys.filter(([, value]) => value !== undefined && value !== null && value !== '');
if (named.length === 0) return;
const objectName = typeof start.objectName === 'string' && start.objectName !== '' && !isObjectLessActionKey(start.objectName)
? start.objectName
: undefined;
if (objectName === undefined) {
if (!(await flowRunsElevated(automation, flowName))) return;
const message = 'A start that names a record must also name the record\'s object (`objectName`), '
+ 'so the caller\'s access to that record can be checked before the flow starts.';
throw validationFailure(message, [{ field: 'objectName', code: 'required', message }]);
}
const loaded = new Set<string>();
for (const [key, value] of named) {
if (typeof value !== 'string' && typeof value !== 'number') {
const message = `The record key \`${key}\` must name one record by its id.`;
throw validationFailure(message, [{ field: key, code: 'invalid_type', message }]);
}
const recordId = String(value);
if (loaded.has(recordId)) continue;
const loadOrRefuse = async (objectName: string, recordId: string): Promise<void> => {
const once = `${objectName}\u0000${recordId}`;
if (loaded.has(once)) return;
const subject = await loadActionSubjectRecord(objectName, recordId, () => readRecord(objectName, recordId));
refuseDeniedSubjectLoad(objectName, recordId, subject);
loaded.add(recordId);
loaded.add(once);
};
const named = start.recordKeys.filter(([, value]) => isNamedStartValue(value));
if (named.length > 0) {
const objectName = typeof start.objectName === 'string' && start.objectName !== '' && !isObjectLessActionKey(start.objectName)
? start.objectName
: undefined;
if (objectName === undefined) {
if (await flowRunsElevated(automation, flowName)) {
const message = 'A start that names a record must also name the record\'s object (`objectName`), '
+ 'so the caller\'s access to that record can be checked before the flow starts.';
throw validationFailure(message, [{ field: 'objectName', code: 'required', message }]);
}
} else {
for (const [key, value] of named) {
await loadOrRefuse(objectName, oneRecordIdOrRefuse(key, value));
}
}
}
const inputs = (start.inputs ?? []).filter(([, value]) => isNamedStartValue(value));
if (inputs.length === 0) return;
const reads = await flowDeclaredRecordReads(automation, flowName);
for (const [key, value] of inputs) {
for (const read of reads.get(key) ?? []) {
const ids = read.many ? recordIdListOrRefuse(key, value) : [oneRecordIdOrRefuse(key, value)];
for (const recordId of ids) await loadOrRefuse(read.object, recordId);
}
}
}

/** Whether a start names something under a key: absent, `null` and `''` name nothing. */
function isNamedStartValue(value: unknown): boolean {
return value !== undefined && value !== null && value !== '';
}

/** One record id (a string or a number), or the `400` naming the key: only an id names one row. */
function oneRecordIdOrRefuse(key: string, value: unknown): string {
if (typeof value === 'string' || typeof value === 'number') return String(value);
const message = `The record key \`${key}\` must name one record by its id.`;
throw validationFailure(message, [{ field: key, code: 'invalid_type', message }]);
}

/** A list of record ids, or the `400` naming the key: a flow that reads a list of ids reads nothing else. */
function recordIdListOrRefuse(key: string, value: unknown): string[] {
if (Array.isArray(value) && value.every((id) => typeof id === 'string' || typeof id === 'number')) {
return value.map(String);
}
const message = `The record key \`${key}\` must name a list of records by their ids.`;
throw validationFailure(message, [{ field: key, code: 'invalid_type', message }]);
}

/**
Expand Down
12 changes: 12 additions & 0 deletions packages/runtime/src/domains/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,18 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string
const paramsFailure = objectValueFailure('params', reqBody.params);
if (paramsFailure) return invalidBody(`Invalid action body — \`params\` ${paramsFailure.message}`, paramsFailure);
const recordId = recordIdFromPath ?? reqBody.recordId;
// The routed record is named by ONE id, a string or a number: the subject
// load below reads the row whose `id` equals it, and every branch hands it
// on as the row's id. Any other value names no one row, so it is refused
// here, before the param contract, the load and dispatch, with the `400`
// flow start admission answers for a record key that is not one id at the
// other doors (`refuseUnreadableFlowStartRecords`). A path segment is
// always a string; absent, `null` and `''` stay the record-less call.
if (recordId !== undefined && recordId !== null && recordId !== ''
&& typeof recordId !== 'string' && typeof recordId !== 'number') {
const message = 'The record key `recordId` must name one record by its id.';
return invalidBody(`Invalid action body — ${message}`, { field: 'recordId', code: 'invalid_type', message });
}
const reqParams: Record<string, unknown> = reqBody.params ?? {};

// [ADR-0104 D2] Enforce the declared param contract before the handler
Expand Down
Loading
Loading