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
27 changes: 27 additions & 0 deletions .changeset/flow-lookup-expand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@objectstack/service-automation": minor
"@objectstack/lint": patch
---

feat(automation): opt-in single-hop lookup expansion for record-change flow templates (#3475)

A record-change flow can now declare `expand: ['<lookup_field>', …]` on its start
node config so node templates resolve `{record.<lookup>.<field>}` (e.g.
`{record.account.name}` in a notify title, closing the #3426 gap for lookups).

The engine re-reads the declared relations AFTER identity resolution, as the
run's OWN principal — `resolveRunDataContext` honors `runAs`, so a `runAs:'user'`
run reads the referenced object as the **triggering user** (its RLS/FLS enforced)
rather than system-elevated. This is what made expansion unsafe to do in the
trigger's re-read (which has no resolved grants) and is why it lives in the
engine (new `AutomationEngine.setRecordExpander`, bridged by the plugin to the
same data engine the CRUD nodes use).

Only the declared relation keys are grafted onto the run record, so bare lookup
ids and `multiple` lookup arrays (#1872) on other relations — and the formula
fields the trigger already hydrated — are untouched. Opt-in ⇒ zero cost when
unused; best-effort ⇒ a re-read failure leaves the record unexpanded and never
breaks the flow.

The `os validate` lint rule `flow-template-lookup-traversal` (#3426/#3472) is now
suppressed for a relation once the flow declares it in `config.expand`.
43 changes: 43 additions & 0 deletions packages/lint/src/validate-flow-template-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,49 @@ describe('validateFlowTemplatePaths', () => {
expect(findings[0].rule).toBe(FLOW_TEMPLATE_UNKNOWN_FIELD);
});

it('does NOT flag a lookup traversal when the start config declares expand (#3475)', () => {
const findings = validateFlowTemplatePaths({
objects: [LEAD_OBJECT],
flows: [
{
name: 'expand_ok',
type: 'record_change',
nodes: [
{
id: 'start',
type: 'start',
config: { objectName: 'crm_lead', triggerType: 'record-created', expand: ['crm_account'] },
},
{ id: 'n1', type: 'notify', notify: { title: 'From {record.crm_account.name}', body: 'x' } },
],
},
],
});
expect(findings).toHaveLength(0);
});

it('still flags a lookup traversal NOT covered by the declared expand (#3475)', () => {
const findings = validateFlowTemplatePaths({
objects: [LEAD_OBJECT],
flows: [
{
name: 'expand_partial',
type: 'record_change',
nodes: [
{
id: 'start',
type: 'start',
config: { objectName: 'crm_lead', triggerType: 'record-created', expand: ['target_channels'] },
},
{ id: 'n1', type: 'notify', notify: { title: 'From {record.crm_account.name}', body: 'x' } },
],
},
],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(FLOW_TEMPLATE_LOOKUP_TRAVERSAL);
});

it('returns empty when there are no flows', () => {
expect(validateFlowTemplatePaths({ objects: [LEAD_OBJECT] })).toHaveLength(0);
});
Expand Down
25 changes: 21 additions & 4 deletions packages/lint/src/validate-flow-template-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,22 @@ function boundObjectOf(flow: AnyRec): string | undefined {
return fromConfig ?? fromTyped;
}

/**
* The lookup relations a record-change flow opted IN to expand, from the start
* node's `config.expand` (#3475). A `{record.<rel>.<field>}` hop through one of
* these IS resolved at run time — the engine re-reads it as the run's identity —
* so the traversal warning is suppressed for those relations. Accepts a `string`
* or `string[]`; anything else yields the empty set.
*/
function declaredExpandOf(flow: AnyRec): Set<string> {
const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : [];
const start = nodes.find((n) => n?.type === 'start');
const raw = ((start?.config ?? {}) as AnyRec).expand;
if (typeof raw === 'string') return new Set(raw ? [raw] : []);
if (Array.isArray(raw)) return new Set(raw.filter((r): r is string => typeof r === 'string' && r.length > 0));
return new Set();
}

/**
* Validate `{record.<path>}` template references across every record-change
* flow. Pure and dependency-free; safe on pre- or post-parse stacks.
Expand Down Expand Up @@ -218,6 +234,7 @@ export function validateFlowTemplatePaths(stack: AnyRec): FlowTemplatePathFindin
if (!obj) return;

const fieldTypes = fieldTypesOf(obj);
const expandSet = declaredExpandOf(flow);

nodes.forEach((node, nodeIndex) => {
if (typeof node !== 'object' || !node) return;
Expand Down Expand Up @@ -264,7 +281,7 @@ export function validateFlowTemplatePaths(stack: AnyRec): FlowTemplatePathFindin

if (nextIsIdentifier) {
const headType = fieldTypes.get(head) ?? '';
if (RELATION_TYPES.has(headType)) {
if (RELATION_TYPES.has(headType) && !expandSet.has(head)) {
const key = rest.join('.');
if (seenTraversal.has(key)) continue;
seenTraversal.add(key);
Expand All @@ -278,9 +295,9 @@ export function validateFlowTemplatePaths(stack: AnyRec): FlowTemplatePathFindin
`'${head}' — the flow record carries '${head}' as a scalar id, not an expanded object, so ` +
`this resolves to an empty string at runtime (silently).`,
hint:
`Single-hop lookup traversal in templates is not resolved yet (tracked on #3426). ` +
`Reference the foreign-key id directly ('{record.${head}}'), or add a formula field on ` +
`'${objectName}' that projects the related value and reference that instead.`,
`Opt in to resolve it: add '${head}' to the start node's config.expand (#3475) and the ` +
`engine re-reads it as the run's identity. Otherwise reference the foreign-key id directly ` +
`('{record.${head}}'), or project the value via a formula field on '${objectName}'.`,
});
}
// STRUCTURED_TYPES + any other scalar `.sub` access is left alone:
Expand Down
91 changes: 91 additions & 0 deletions packages/services/service-automation/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,24 @@ export type FlowUserGrantsResolver = (
tenantId: string | undefined,
) => Promise<FlowUserGrants | undefined> | FlowUserGrants | undefined;

/**
* Re-reads the specified single-hop lookup relations of a record-change flow's
* triggering record so `{record.<lookup>.<field>}` templates can traverse them
* (#3475). Injected by the host — the automation plugin bridges it to a
* data-engine `findOne(..., { expand, context })` scoped by the run's identity
* ({@link resolveRunDataContext}), so the referenced object's RLS/FLS are
* enforced as the RUN (never system-elevated for a `runAs:'user'` run). Returns
* the re-read record (with the requested relations expanded to objects) or
* `undefined`; only the declared relation keys are grafted onto the run record.
* When unwired, lookup traversal stays unresolved (the pre-#3475 behavior).
*/
export type FlowRecordExpander = (
objectName: string,
id: unknown,
expandFields: readonly string[],
runContext: AutomationContext,
) => Promise<Record<string, unknown> | undefined> | Record<string, unknown> | undefined;

/**
* A designer-facing view of one connector action — identity + its JSON-Schema
* input/output. The runtime handler is intentionally omitted; this is metadata.
Expand Down Expand Up @@ -667,6 +685,9 @@ export class AutomationEngine implements IAutomationService {
/** Bridge to the host authz resolver so a `runAs:'user'` run enforces the
* triggering user's real grants (#3356), if wired. See {@link FlowUserGrantsResolver}. */
private userGrantsResolver: FlowUserGrantsResolver | null = null;
/** Bridge to a host data read that expands declared lookup relations on the
* run record (#3475), if wired. See {@link FlowRecordExpander}. */
private recordExpander: FlowRecordExpander | null = null;
private executionLogs: ExecutionLogEntry[] = [];
private readonly maxLogSize: number;
private logger: Logger;
Expand Down Expand Up @@ -1136,6 +1157,16 @@ export class AutomationEngine implements IAutomationService {
this.userGrantsResolver = resolver;
}

/**
* Wire the record lookup-expander (#3475). The automation plugin bridges it
* to a `findOne(..., { expand, context })` on the same data engine the CRUD
* nodes use, scoped by the run's identity. Passing `null` detaches it (lookup
* traversal in templates then stays unresolved).
*/
setRecordExpander(expander: FlowRecordExpander | null): void {
this.recordExpander = expander;
}

/**
* Resolve a named function for a `script` node. Returns `undefined` when no
* resolver is wired or the name is unregistered — the node then fails the
Expand Down Expand Up @@ -1570,9 +1601,69 @@ export class AutomationEngine implements IAutomationService {
`(ADR-0049, #1888).`,
);
}

// #3475 — opt-in single-hop lookup expansion, AFTER identity resolution so
// the re-read runs as this run's own principal (see helper).
await this.expandDeclaredLookups(flow, runContext);
return runContext;
}

/**
* Enrich `runContext.record` with opt-in single-hop lookup expansions the
* start node declares as `config.expand: string[]` (#3475). Re-reads just
* those relations through the injected {@link setRecordExpander} — which the
* host wires to a data-engine read scoped by {@link resolveRunDataContext},
* so the referenced object's RLS/FLS are enforced as the RUN's identity (the
* triggering user for `runAs:'user'`, elevated for `runAs:'system'`). Grafts
* ONLY the declared relation keys, and only when the re-read actually returned
* an object/array, so bare lookup ids and #1872 multi-lookup arrays on other
* relations — and the formula fields the trigger already hydrated — stay
* untouched. Mutates `record` in place (the same object the run's variable map
* already references). Best-effort: any failure leaves `record` unexpanded
* (the template then renders the scalar id) — expansion must never break the
* flow it feeds.
*/
private async expandDeclaredLookups(flow: FlowParsed, runContext: AutomationContext): Promise<void> {
if (!this.recordExpander) return;
const record = runContext.record as Record<string, unknown> | undefined;
const id = record?.id;
const object = runContext.object;
if (!record || id == null || id === '' || !object) return;

const startNode = flow.nodes.find((n) => n.type === 'start');
const raw = (startNode?.config as { expand?: unknown } | undefined)?.expand;
const expandFields =
typeof raw === 'string'
? raw
? [raw]
: []
: Array.isArray(raw)
? raw.filter((f): f is string => typeof f === 'string' && f.length > 0)
: [];
if (expandFields.length === 0) return;

try {
const full = await this.recordExpander(object, id, expandFields, runContext);
if (full && typeof full === 'object') {
for (const field of expandFields) {
const expanded = (full as Record<string, unknown>)[field];
// Graft only a genuinely expanded relation (object/array); a
// scalar means the field is not a resolvable lookup — leave the
// raw id in place rather than overwrite it.
if (expanded !== null && typeof expanded === 'object') {
record[field] = expanded;
}
}
}
} catch (err) {
this.logger.warn(
`[expand] flow '${flow.name}' could not expand lookups [${expandFields.join(', ')}] on ` +
`'${object}#${String(id)}': ${(err as Error)?.message ?? String(err)}. Templates referencing ` +
`these relations resolve to the scalar id.`,
);
}
}

async execute(flowName: string, context?: AutomationContext): Promise<AutomationResult> {
const startTime = Date.now();
const flow = this.flows.get(flowName);
Expand Down
29 changes: 29 additions & 0 deletions packages/services/service-automation/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
import { isConnectorUpstreamUnavailable } from '@objectstack/spec/integration';
import { AutomationEngine } from './engine.js';
import { installBuiltinNodes, rearmSuspendedWaitTimers } from './builtin/index.js';
import { resolveRunDataContext } from './runtime-identity.js';
import { SysAutomationRun } from './sys-automation-run.object.js';
import {
ObjectStoreSuspendedRunStore,
Expand Down Expand Up @@ -520,6 +521,34 @@ export class AutomationServicePlugin implements Plugin {
};
});
ctx.logger.debug('[Automation] runAs:user grant resolver bridged to @objectstack/core resolveUserAuthzGrants (#3356)');

// #3475 — bridge the lookup expander for record-change flow
// templates. Re-reads the relations a flow declares in its start
// node's `config.expand` via the run's own identity
// (`resolveRunDataContext` honors runAs), so `{record.<lookup>.<field>}`
// resolves WITHOUT bypassing the triggering user's RLS/FLS on the
// referenced object. Same engine the CRUD nodes and grant resolver
// use; a no-op when it exposes no `findOne`.
const expandQl = engineQl as {
findOne?: (
obj: string,
q: Record<string, unknown>,
) => Promise<Record<string, unknown> | null | undefined>;
};
if (typeof expandQl.findOne === 'function') {
this.engine.setRecordExpander(async (objectName, id, expandFields, runContext) => {
const dataCtx = resolveRunDataContext(runContext);
const expand: Record<string, unknown> = {};
for (const f of expandFields) expand[f] = {};
const query: Record<string, unknown> = { where: { id }, expand };
// Omit `context` for the identity-less case (matches the CRUD
// nodes), so the data engine applies its no-identity default.
if (dataCtx) query.context = dataCtx;
const full = await expandQl.findOne!(objectName, query);
return full && typeof full === 'object' ? (full as Record<string, unknown>) : undefined;
});
ctx.logger.debug('[Automation] record-change lookup expander bridged (#3475)');
}
} else {
ctx.logger.debug('[Automation] objectql not present — runAs:user runs keep the trigger-supplied identity');
}
Expand Down
Loading