Skip to content

Commit d552ee9

Browse files
os-zhuangclaude
andcommitted
feat(automation): opt-in single-hop lookup expansion for record-change flow templates (#3475)
A record-change flow can declare `expand: ['<lookup>', …]` on its start-node config so node templates resolve `{record.<lookup>.<field>}` (e.g. `{record.account.name}` — the #3426 lookup gap). The engine re-reads the declared relations AFTER identity resolution, as the run's OWN principal (`resolveRunDataContext` honors `runAs`), and grafts the expanded objects onto the run record. So a `runAs:'user'` run reads the referenced object as the triggering USER — RLS/FLS enforced — not system- elevated, which is exactly what made expansion unsafe in the trigger's re-read (no resolved grants there). New `AutomationEngine.setRecordExpander`, bridged by the plugin to the same data engine the CRUD nodes use. Only declared keys are grafted, so bare lookup ids, `multiple` lookup arrays (#1872), and trigger-hydrated formula fields are untouched. Opt-in ⇒ zero cost otherwise; best-effort ⇒ a re-read failure leaves the record unexpanded and never breaks the flow. The `flow-template-lookup-traversal` lint warning (#3472) is suppressed once a relation is declared in `config.expand`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7aea626 commit d552ee9

6 files changed

Lines changed: 364 additions & 4 deletions

File tree

.changeset/flow-lookup-expand.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
"@objectstack/lint": patch
4+
---
5+
6+
feat(automation): opt-in single-hop lookup expansion for record-change flow templates (#3475)
7+
8+
A record-change flow can now declare `expand: ['<lookup_field>', …]` on its start
9+
node config so node templates resolve `{record.<lookup>.<field>}` (e.g.
10+
`{record.account.name}` in a notify title, closing the #3426 gap for lookups).
11+
12+
The engine re-reads the declared relations AFTER identity resolution, as the
13+
run's OWN principal — `resolveRunDataContext` honors `runAs`, so a `runAs:'user'`
14+
run reads the referenced object as the **triggering user** (its RLS/FLS enforced)
15+
rather than system-elevated. This is what made expansion unsafe to do in the
16+
trigger's re-read (which has no resolved grants) and is why it lives in the
17+
engine (new `AutomationEngine.setRecordExpander`, bridged by the plugin to the
18+
same data engine the CRUD nodes use).
19+
20+
Only the declared relation keys are grafted onto the run record, so bare lookup
21+
ids and `multiple` lookup arrays (#1872) on other relations — and the formula
22+
fields the trigger already hydrated — are untouched. Opt-in ⇒ zero cost when
23+
unused; best-effort ⇒ a re-read failure leaves the record unexpanded and never
24+
breaks the flow.
25+
26+
The `os validate` lint rule `flow-template-lookup-traversal` (#3426/#3472) is now
27+
suppressed for a relation once the flow declares it in `config.expand`.

packages/lint/src/validate-flow-template-paths.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,49 @@ describe('validateFlowTemplatePaths', () => {
186186
expect(findings[0].rule).toBe(FLOW_TEMPLATE_UNKNOWN_FIELD);
187187
});
188188

189+
it('does NOT flag a lookup traversal when the start config declares expand (#3475)', () => {
190+
const findings = validateFlowTemplatePaths({
191+
objects: [LEAD_OBJECT],
192+
flows: [
193+
{
194+
name: 'expand_ok',
195+
type: 'record_change',
196+
nodes: [
197+
{
198+
id: 'start',
199+
type: 'start',
200+
config: { objectName: 'crm_lead', triggerType: 'record-created', expand: ['crm_account'] },
201+
},
202+
{ id: 'n1', type: 'notify', notify: { title: 'From {record.crm_account.name}', body: 'x' } },
203+
],
204+
},
205+
],
206+
});
207+
expect(findings).toHaveLength(0);
208+
});
209+
210+
it('still flags a lookup traversal NOT covered by the declared expand (#3475)', () => {
211+
const findings = validateFlowTemplatePaths({
212+
objects: [LEAD_OBJECT],
213+
flows: [
214+
{
215+
name: 'expand_partial',
216+
type: 'record_change',
217+
nodes: [
218+
{
219+
id: 'start',
220+
type: 'start',
221+
config: { objectName: 'crm_lead', triggerType: 'record-created', expand: ['target_channels'] },
222+
},
223+
{ id: 'n1', type: 'notify', notify: { title: 'From {record.crm_account.name}', body: 'x' } },
224+
],
225+
},
226+
],
227+
});
228+
expect(findings).toHaveLength(1);
229+
expect(findings[0].rule).toBe(FLOW_TEMPLATE_LOOKUP_TRAVERSAL);
230+
});
231+
189232
it('returns empty when there are no flows', () => {
190233
expect(validateFlowTemplatePaths({ objects: [LEAD_OBJECT] })).toHaveLength(0);
191234
});

packages/lint/src/validate-flow-template-paths.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,22 @@ function boundObjectOf(flow: AnyRec): string | undefined {
189189
return fromConfig ?? fromTyped;
190190
}
191191

192+
/**
193+
* The lookup relations a record-change flow opted IN to expand, from the start
194+
* node's `config.expand` (#3475). A `{record.<rel>.<field>}` hop through one of
195+
* these IS resolved at run time — the engine re-reads it as the run's identity —
196+
* so the traversal warning is suppressed for those relations. Accepts a `string`
197+
* or `string[]`; anything else yields the empty set.
198+
*/
199+
function declaredExpandOf(flow: AnyRec): Set<string> {
200+
const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : [];
201+
const start = nodes.find((n) => n?.type === 'start');
202+
const raw = ((start?.config ?? {}) as AnyRec).expand;
203+
if (typeof raw === 'string') return new Set(raw ? [raw] : []);
204+
if (Array.isArray(raw)) return new Set(raw.filter((r): r is string => typeof r === 'string' && r.length > 0));
205+
return new Set();
206+
}
207+
192208
/**
193209
* Validate `{record.<path>}` template references across every record-change
194210
* flow. Pure and dependency-free; safe on pre- or post-parse stacks.
@@ -218,6 +234,7 @@ export function validateFlowTemplatePaths(stack: AnyRec): FlowTemplatePathFindin
218234
if (!obj) return;
219235

220236
const fieldTypes = fieldTypesOf(obj);
237+
const expandSet = declaredExpandOf(flow);
221238

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

265282
if (nextIsIdentifier) {
266283
const headType = fieldTypes.get(head) ?? '';
267-
if (RELATION_TYPES.has(headType)) {
284+
if (RELATION_TYPES.has(headType) && !expandSet.has(head)) {
268285
const key = rest.join('.');
269286
if (seenTraversal.has(key)) continue;
270287
seenTraversal.add(key);
@@ -278,9 +295,9 @@ export function validateFlowTemplatePaths(stack: AnyRec): FlowTemplatePathFindin
278295
`'${head}' — the flow record carries '${head}' as a scalar id, not an expanded object, so ` +
279296
`this resolves to an empty string at runtime (silently).`,
280297
hint:
281-
`Single-hop lookup traversal in templates is not resolved yet (tracked on #3426). ` +
282-
`Reference the foreign-key id directly ('{record.${head}}'), or add a formula field on ` +
283-
`'${objectName}' that projects the related value and reference that instead.`,
298+
`Opt in to resolve it: add '${head}' to the start node's config.expand (#3475) and the ` +
299+
`engine re-reads it as the run's identity. Otherwise reference the foreign-key id directly ` +
300+
`('{record.${head}}'), or project the value via a formula field on '${objectName}'.`,
284301
});
285302
}
286303
// STRUCTURED_TYPES + any other scalar `.sub` access is left alone:

packages/services/service-automation/src/engine.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,24 @@ export type FlowUserGrantsResolver = (
289289
tenantId: string | undefined,
290290
) => Promise<FlowUserGrants | undefined> | FlowUserGrants | undefined;
291291

292+
/**
293+
* Re-reads the specified single-hop lookup relations of a record-change flow's
294+
* triggering record so `{record.<lookup>.<field>}` templates can traverse them
295+
* (#3475). Injected by the host — the automation plugin bridges it to a
296+
* data-engine `findOne(..., { expand, context })` scoped by the run's identity
297+
* ({@link resolveRunDataContext}), so the referenced object's RLS/FLS are
298+
* enforced as the RUN (never system-elevated for a `runAs:'user'` run). Returns
299+
* the re-read record (with the requested relations expanded to objects) or
300+
* `undefined`; only the declared relation keys are grafted onto the run record.
301+
* When unwired, lookup traversal stays unresolved (the pre-#3475 behavior).
302+
*/
303+
export type FlowRecordExpander = (
304+
objectName: string,
305+
id: unknown,
306+
expandFields: readonly string[],
307+
runContext: AutomationContext,
308+
) => Promise<Record<string, unknown> | undefined> | Record<string, unknown> | undefined;
309+
292310
/**
293311
* A designer-facing view of one connector action — identity + its JSON-Schema
294312
* input/output. The runtime handler is intentionally omitted; this is metadata.
@@ -667,6 +685,9 @@ export class AutomationEngine implements IAutomationService {
667685
/** Bridge to the host authz resolver so a `runAs:'user'` run enforces the
668686
* triggering user's real grants (#3356), if wired. See {@link FlowUserGrantsResolver}. */
669687
private userGrantsResolver: FlowUserGrantsResolver | null = null;
688+
/** Bridge to a host data read that expands declared lookup relations on the
689+
* run record (#3475), if wired. See {@link FlowRecordExpander}. */
690+
private recordExpander: FlowRecordExpander | null = null;
670691
private executionLogs: ExecutionLogEntry[] = [];
671692
private readonly maxLogSize: number;
672693
private logger: Logger;
@@ -1136,6 +1157,16 @@ export class AutomationEngine implements IAutomationService {
11361157
this.userGrantsResolver = resolver;
11371158
}
11381159

1160+
/**
1161+
* Wire the record lookup-expander (#3475). The automation plugin bridges it
1162+
* to a `findOne(..., { expand, context })` on the same data engine the CRUD
1163+
* nodes use, scoped by the run's identity. Passing `null` detaches it (lookup
1164+
* traversal in templates then stays unresolved).
1165+
*/
1166+
setRecordExpander(expander: FlowRecordExpander | null): void {
1167+
this.recordExpander = expander;
1168+
}
1169+
11391170
/**
11401171
* Resolve a named function for a `script` node. Returns `undefined` when no
11411172
* resolver is wired or the name is unregistered — the node then fails the
@@ -1570,9 +1601,69 @@ export class AutomationEngine implements IAutomationService {
15701601
`(ADR-0049, #1888).`,
15711602
);
15721603
}
1604+
1605+
// #3475 — opt-in single-hop lookup expansion, AFTER identity resolution so
1606+
// the re-read runs as this run's own principal (see helper).
1607+
await this.expandDeclaredLookups(flow, runContext);
15731608
return runContext;
15741609
}
15751610

1611+
/**
1612+
* Enrich `runContext.record` with opt-in single-hop lookup expansions the
1613+
* start node declares as `config.expand: string[]` (#3475). Re-reads just
1614+
* those relations through the injected {@link setRecordExpander} — which the
1615+
* host wires to a data-engine read scoped by {@link resolveRunDataContext},
1616+
* so the referenced object's RLS/FLS are enforced as the RUN's identity (the
1617+
* triggering user for `runAs:'user'`, elevated for `runAs:'system'`). Grafts
1618+
* ONLY the declared relation keys, and only when the re-read actually returned
1619+
* an object/array, so bare lookup ids and #1872 multi-lookup arrays on other
1620+
* relations — and the formula fields the trigger already hydrated — stay
1621+
* untouched. Mutates `record` in place (the same object the run's variable map
1622+
* already references). Best-effort: any failure leaves `record` unexpanded
1623+
* (the template then renders the scalar id) — expansion must never break the
1624+
* flow it feeds.
1625+
*/
1626+
private async expandDeclaredLookups(flow: FlowParsed, runContext: AutomationContext): Promise<void> {
1627+
if (!this.recordExpander) return;
1628+
const record = runContext.record as Record<string, unknown> | undefined;
1629+
const id = record?.id;
1630+
const object = runContext.object;
1631+
if (!record || id == null || id === '' || !object) return;
1632+
1633+
const startNode = flow.nodes.find((n) => n.type === 'start');
1634+
const raw = (startNode?.config as { expand?: unknown } | undefined)?.expand;
1635+
const expandFields =
1636+
typeof raw === 'string'
1637+
? raw
1638+
? [raw]
1639+
: []
1640+
: Array.isArray(raw)
1641+
? raw.filter((f): f is string => typeof f === 'string' && f.length > 0)
1642+
: [];
1643+
if (expandFields.length === 0) return;
1644+
1645+
try {
1646+
const full = await this.recordExpander(object, id, expandFields, runContext);
1647+
if (full && typeof full === 'object') {
1648+
for (const field of expandFields) {
1649+
const expanded = (full as Record<string, unknown>)[field];
1650+
// Graft only a genuinely expanded relation (object/array); a
1651+
// scalar means the field is not a resolvable lookup — leave the
1652+
// raw id in place rather than overwrite it.
1653+
if (expanded !== null && typeof expanded === 'object') {
1654+
record[field] = expanded;
1655+
}
1656+
}
1657+
}
1658+
} catch (err) {
1659+
this.logger.warn(
1660+
`[expand] flow '${flow.name}' could not expand lookups [${expandFields.join(', ')}] on ` +
1661+
`'${object}#${String(id)}': ${(err as Error)?.message ?? String(err)}. Templates referencing ` +
1662+
`these relations resolve to the scalar id.`,
1663+
);
1664+
}
1665+
}
1666+
15761667
async execute(flowName: string, context?: AutomationContext): Promise<AutomationResult> {
15771668
const startTime = Date.now();
15781669
const flow = this.flows.get(flowName);

packages/services/service-automation/src/plugin.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
import { isConnectorUpstreamUnavailable } from '@objectstack/spec/integration';
1313
import { AutomationEngine } from './engine.js';
1414
import { installBuiltinNodes, rearmSuspendedWaitTimers } from './builtin/index.js';
15+
import { resolveRunDataContext } from './runtime-identity.js';
1516
import { SysAutomationRun } from './sys-automation-run.object.js';
1617
import {
1718
ObjectStoreSuspendedRunStore,
@@ -520,6 +521,34 @@ export class AutomationServicePlugin implements Plugin {
520521
};
521522
});
522523
ctx.logger.debug('[Automation] runAs:user grant resolver bridged to @objectstack/core resolveUserAuthzGrants (#3356)');
524+
525+
// #3475 — bridge the lookup expander for record-change flow
526+
// templates. Re-reads the relations a flow declares in its start
527+
// node's `config.expand` via the run's own identity
528+
// (`resolveRunDataContext` honors runAs), so `{record.<lookup>.<field>}`
529+
// resolves WITHOUT bypassing the triggering user's RLS/FLS on the
530+
// referenced object. Same engine the CRUD nodes and grant resolver
531+
// use; a no-op when it exposes no `findOne`.
532+
const expandQl = engineQl as {
533+
findOne?: (
534+
obj: string,
535+
q: Record<string, unknown>,
536+
) => Promise<Record<string, unknown> | null | undefined>;
537+
};
538+
if (typeof expandQl.findOne === 'function') {
539+
this.engine.setRecordExpander(async (objectName, id, expandFields, runContext) => {
540+
const dataCtx = resolveRunDataContext(runContext);
541+
const expand: Record<string, unknown> = {};
542+
for (const f of expandFields) expand[f] = {};
543+
const query: Record<string, unknown> = { where: { id }, expand };
544+
// Omit `context` for the identity-less case (matches the CRUD
545+
// nodes), so the data engine applies its no-identity default.
546+
if (dataCtx) query.context = dataCtx;
547+
const full = await expandQl.findOne!(objectName, query);
548+
return full && typeof full === 'object' ? (full as Record<string, unknown>) : undefined;
549+
});
550+
ctx.logger.debug('[Automation] record-change lookup expander bridged (#3475)');
551+
}
523552
} else {
524553
ctx.logger.debug('[Automation] objectql not present — runAs:user runs keep the trigger-supplied identity');
525554
}

0 commit comments

Comments
 (0)