Skip to content

Commit 0c2eb00

Browse files
tiffanybalcclaude
andcommitted
feat(inngest): forward FGA actor signal through execution engine
The @mastra/inngest execution engine now accepts a trusted `actor` (ActorSignal) on its run/start path and re-threads it across durable step and nested-workflow boundaries, so trusted background workflows on the Inngest engine get the same FGA membership bypass the default engine already has. - run.ts: accept `actor` on start/startAsync/resume/resumeAsync/stream/ streamLegacy/timeTravel and include it in each Inngest event payload. - workflow.ts: read `actor` from event.data in the function handler and pass it to engine.execute(). - execution-engine.ts: forward `actor` into every nested inngestStep.invoke() so nested workflows re-thread it across the serialization boundary. Mirrors core PR mastra-ai#17487 (default engine) for the durable runtime. Refs mastra-ai#17216, mastra-ai#17487. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 705ba98 commit 0c2eb00

6 files changed

Lines changed: 349 additions & 2 deletions

File tree

.changeset/early-spoons-joke.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@mastra/inngest': minor
3+
---
4+
5+
Added support for the fine-grained authorization (FGA) `actor` signal on the Inngest execution engine.
6+
7+
Workflows running on the Inngest engine can now pass a trusted `actor` through `run.start()`, `startAsync()`, `resume()`, `stream()`, and `timeTravel()`. The signal is re-threaded across durable step and nested-workflow boundaries, so every nested agent, tool, and memory FGA check sees the same actor. Previously `actor` was only threaded through the default engine, so trusted background workflows on Inngest lost the membership bypass at each step re-entry.
8+
9+
**Usage**
10+
11+
```ts
12+
const run = await workflow.createRun();
13+
await run.start({
14+
inputData,
15+
requestContext, // includes organizationId / tenant scope
16+
actor: { actorKind: 'system', sourceWorkflow: 'nightly-sync' },
17+
});
18+
```
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import type { ActorSignal } from '@mastra/core/auth/ee';
2+
import { Mastra } from '@mastra/core/mastra';
3+
import { MockStore } from '@mastra/core/storage';
4+
import { Inngest } from 'inngest';
5+
import { describe, expect, it, vi } from 'vitest';
6+
import { z } from 'zod';
7+
8+
import { InngestExecutionEngine } from './execution-engine';
9+
import { init } from './index';
10+
11+
/**
12+
* Hermetic tests for FGA `actor` signal threading through @mastra/inngest.
13+
*
14+
* These do NOT require an Inngest dev server. They exercise the two seams the
15+
* engine owns directly:
16+
* 1. The run/start path serializes `actor` into the Inngest event payload.
17+
* 2. `executeWorkflowStep` forwards `actor` across the nested-workflow
18+
* `step.invoke()` serialization boundary so the nested run re-threads it.
19+
*/
20+
describe('@mastra/inngest actor signal threading (hermetic)', () => {
21+
const actor: ActorSignal = { actorKind: 'system', sourceWorkflow: 'nightly-workflow' };
22+
23+
it('forwards actor into the nested-workflow invoke payload (durable step boundary)', async () => {
24+
const inngest = new Inngest({ id: 'mastra-test' });
25+
const { createWorkflow, createStep } = init(inngest);
26+
27+
const nestedStep = createStep({
28+
id: 'nested-step',
29+
inputSchema: z.object({ value: z.string() }),
30+
outputSchema: z.object({ value: z.string() }),
31+
execute: async ({ inputData }) => inputData,
32+
});
33+
34+
const nestedWorkflow = createWorkflow({
35+
id: 'nested-workflow',
36+
inputSchema: z.object({ value: z.string() }),
37+
outputSchema: z.object({ value: z.string() }),
38+
steps: [nestedStep],
39+
})
40+
.then(nestedStep)
41+
.commit();
42+
43+
// Capture the data handed to inngestStep.invoke (the serialization boundary).
44+
const invokeData: any[] = [];
45+
const fakeStep: any = {
46+
run: async (_id: string, fn: () => Promise<any>) => fn(),
47+
invoke: async (_id: string, opts: { function: any; data: any }) => {
48+
invokeData.push(opts.data);
49+
return { result: { status: 'success', result: { value: 'ok' }, state: {} }, runId: 'nested-run' };
50+
},
51+
sleep: async () => {},
52+
sleepUntil: async () => {},
53+
};
54+
55+
const engine = new InngestExecutionEngine({} as Mastra, fakeStep, 0, {});
56+
const pubsub: any = { publish: vi.fn().mockResolvedValue(undefined) };
57+
58+
const result = await engine.executeWorkflowStep({
59+
step: nestedWorkflow as any,
60+
stepResults: {},
61+
executionContext: {
62+
workflowId: 'parent-workflow',
63+
runId: 'parent-run',
64+
executionPath: [0],
65+
suspendedPaths: {},
66+
state: {},
67+
} as any,
68+
prevOutput: {},
69+
inputData: { value: 'ok' },
70+
pubsub,
71+
startedAt: Date.now(),
72+
actor,
73+
} as any);
74+
75+
expect(result?.status).toBe('success');
76+
expect(invokeData).toHaveLength(1);
77+
expect(invokeData[0].actor).toEqual(actor);
78+
});
79+
80+
it('serializes actor into the Inngest event payload on the start path', async () => {
81+
const inngest = new Inngest({ id: 'mastra-test' });
82+
const { createWorkflow, createStep } = init(inngest);
83+
84+
const step = createStep({
85+
id: 'step',
86+
inputSchema: z.object({ value: z.string() }),
87+
outputSchema: z.object({ value: z.string() }),
88+
execute: async ({ inputData }) => inputData,
89+
});
90+
91+
const workflow = createWorkflow({
92+
id: 'actor-start-workflow',
93+
inputSchema: z.object({ value: z.string() }),
94+
outputSchema: z.object({ value: z.string() }),
95+
steps: [step],
96+
})
97+
.then(step)
98+
.commit();
99+
100+
const mastra = new Mastra({
101+
logger: false,
102+
storage: new MockStore(),
103+
workflows: { 'actor-start-workflow': workflow as any },
104+
});
105+
workflow.__registerMastra(mastra);
106+
107+
const sendSpy = vi.spyOn(inngest, 'send').mockResolvedValue({ ids: ['evt-1'] } as any);
108+
109+
const run = await workflow.createRun();
110+
await run.startAsync({ inputData: { value: 'ok' }, actor });
111+
112+
expect(sendSpy).toHaveBeenCalledTimes(1);
113+
const sentData = (sendSpy.mock.calls[0]![0] as any).data;
114+
expect(sentData.actor).toEqual(actor);
115+
});
116+
117+
it('forwards a re-supplied actor through the resume event payload (per-call contract)', async () => {
118+
// `actor` is intentionally NOT rehydrated from the snapshot (matching the default
119+
// engine — see packages/core/src/workflows/workflow.ts `_resume`). A trusted resumer
120+
// re-supplies it on each resume; this locks in that per-call contract.
121+
const inngest = new Inngest({ id: 'mastra-test' });
122+
const { createWorkflow, createStep } = init(inngest);
123+
124+
const step = createStep({
125+
id: 'suspendable-step',
126+
inputSchema: z.object({ value: z.string() }),
127+
outputSchema: z.object({ value: z.string() }),
128+
execute: async ({ inputData }) => inputData,
129+
});
130+
131+
const workflow = createWorkflow({
132+
id: 'actor-resume-workflow',
133+
inputSchema: z.object({ value: z.string() }),
134+
outputSchema: z.object({ value: z.string() }),
135+
steps: [step],
136+
})
137+
.then(step)
138+
.commit();
139+
140+
const mastra = new Mastra({
141+
logger: false,
142+
storage: new MockStore(),
143+
workflows: { 'actor-resume-workflow': workflow as any },
144+
});
145+
workflow.__registerMastra(mastra);
146+
147+
const run = await workflow.createRun();
148+
149+
// Persist a suspended snapshot so the resume path has something to load.
150+
const workflowsStore = await mastra.getStorage()!.getStore('workflows');
151+
await workflowsStore!.persistWorkflowSnapshot({
152+
workflowName: 'actor-resume-workflow',
153+
runId: run.runId,
154+
snapshot: {
155+
runId: run.runId,
156+
serializedStepGraph: [],
157+
status: 'suspended',
158+
value: {},
159+
context: { input: { value: 'ok' } },
160+
activePaths: [],
161+
suspendedPaths: { 'suspendable-step': [0] },
162+
activeStepsPath: {},
163+
resumeLabels: {},
164+
waitingPaths: {},
165+
timestamp: Date.now(),
166+
} as any,
167+
});
168+
169+
const sendSpy = vi.spyOn(inngest, 'send').mockResolvedValue({ ids: ['evt-resume'] } as any);
170+
171+
await run.resumeAsync({ resumeData: { value: 'ok' }, step: 'suspendable-step', actor });
172+
173+
expect(sendSpy).toHaveBeenCalledTimes(1);
174+
const sentData = (sendSpy.mock.calls[0]![0] as any).data;
175+
expect(sentData.actor).toEqual(actor);
176+
});
177+
});

workflows/inngest/src/execution-engine.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { randomUUID } from 'node:crypto';
2+
import type { ActorSignal } from '@mastra/core/auth/ee';
23
import type { RequestContext } from '@mastra/core/di';
34
import { getErrorFromUnknown } from '@mastra/core/error';
45
import type { SerializedError } from '@mastra/core/error';
@@ -424,6 +425,7 @@ export class InngestExecutionEngine extends DefaultExecutionEngine {
424425
startedAt: number;
425426
perStep?: boolean;
426427
stepSpan?: any;
428+
actor?: ActorSignal;
427429
}): Promise<StepResult<any, any, any, any> | null> {
428430
// Only handle InngestWorkflow instances
429431
if (!(params.step instanceof InngestWorkflow)) {
@@ -442,6 +444,7 @@ export class InngestExecutionEngine extends DefaultExecutionEngine {
442444
startedAt,
443445
perStep,
444446
stepSpan,
447+
actor,
445448
} = params;
446449

447450
// Build trace context to propagate to nested workflow
@@ -483,6 +486,7 @@ export class InngestExecutionEngine extends DefaultExecutionEngine {
483486
outputOptions: { includeState: true },
484487
perStep,
485488
tracingOptions: nestedTracingContext,
489+
actor,
486490
},
487491
})) as any;
488492
result = invokeResp.result;
@@ -512,6 +516,7 @@ export class InngestExecutionEngine extends DefaultExecutionEngine {
512516
outputOptions: { includeState: true },
513517
perStep,
514518
tracingOptions: nestedTracingContext,
519+
actor,
515520
},
516521
})) as any;
517522
result = invokeResp.result;
@@ -526,6 +531,7 @@ export class InngestExecutionEngine extends DefaultExecutionEngine {
526531
outputOptions: { includeState: true },
527532
perStep,
528533
tracingOptions: nestedTracingContext,
534+
actor,
529535
},
530536
})) as any;
531537
result = invokeResp.result;

workflows/inngest/src/index.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,119 @@ describe('MastraInngestWorkflow', () => {
237237
}
238238
});
239239

240+
describe.sequential('FGA actor signal', () => {
241+
it('bypasses membership resolution for a trusted system actor across a nested-workflow step boundary', async ctx => {
242+
const inngest = new Inngest({
243+
id: 'mastra',
244+
baseUrl: `http://localhost:${(ctx as any).inngestPort}`,
245+
});
246+
247+
const { createWorkflow, createStep } = init(inngest);
248+
249+
const fgaProvider = {
250+
require: vi.fn().mockResolvedValue(undefined),
251+
check: vi.fn(),
252+
filterAccessible: vi.fn(),
253+
};
254+
255+
const agent = new Agent({
256+
id: 'membership-agent',
257+
name: 'Membership Agent',
258+
instructions: 'Say ok',
259+
model: new MockLanguageModelV2({
260+
doGenerate: async () => ({
261+
rawCall: { rawPrompt: null, rawSettings: {} },
262+
finishReason: 'stop',
263+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
264+
content: [{ type: 'text', text: 'ok' }],
265+
warnings: [],
266+
}),
267+
}),
268+
});
269+
270+
// Inside a durable step, forward the per-call actor + tenant-scoped requestContext
271+
// to a nested agent FGA check, exactly as a trusted background workflow would.
272+
const callAgentStep = createStep({
273+
id: 'call-agent',
274+
inputSchema: z.object({}),
275+
outputSchema: z.object({ text: z.string() }),
276+
execute: async ({ actor, requestContext, mastra }) => {
277+
const res = await mastra!.getAgent('membership-agent').generate('hello', { actor, requestContext });
278+
return { text: res.text };
279+
},
280+
});
281+
282+
const nestedWorkflow = createWorkflow({
283+
id: 'nested-actor-workflow',
284+
inputSchema: z.object({}),
285+
outputSchema: z.object({ text: z.string() }),
286+
steps: [callAgentStep],
287+
})
288+
.then(callAgentStep)
289+
.commit();
290+
291+
const workflow = createWorkflow({
292+
id: 'actor-parent-workflow',
293+
inputSchema: z.object({}),
294+
outputSchema: z.object({ text: z.string() }),
295+
steps: [nestedWorkflow],
296+
})
297+
.then(nestedWorkflow)
298+
.commit();
299+
300+
const mastra = new Mastra({
301+
logger: false,
302+
storage: new DefaultStorage({
303+
id: 'test-storage',
304+
url: ':memory:',
305+
}),
306+
agents: { 'membership-agent': agent },
307+
workflows: {
308+
'actor-parent-workflow': workflow,
309+
},
310+
server: {
311+
fga: fgaProvider,
312+
apiRoutes: [
313+
{
314+
path: '/inngest/api',
315+
method: 'ALL',
316+
createHandler: async ({ mastra }) => inngestServe({ mastra, inngest, ...getDockerRegisterOptions() }),
317+
},
318+
],
319+
},
320+
});
321+
322+
const app = await createHonoServer(mastra);
323+
324+
const srv = (globServer = serve({
325+
fetch: app.fetch,
326+
port: (ctx as any).handlerPort,
327+
}));
328+
await resetInngest();
329+
330+
const requestContext = new RequestContext();
331+
requestContext.set('organizationId', 'org-1');
332+
333+
const run = await workflow.createRun();
334+
const result = await run.start({
335+
inputData: {},
336+
requestContext,
337+
actor: { actorKind: 'system', sourceWorkflow: 'nightly-workflow' },
338+
});
339+
340+
srv.close();
341+
342+
// The trusted actor bypasses membership resolution at the nested agent FGA check,
343+
// which only happens if `actor` survived the parent -> nested-workflow step boundary.
344+
expect(fgaProvider.require).not.toHaveBeenCalled();
345+
expect(result.status).toBe('success');
346+
expect(result.steps['nested-actor-workflow']).toMatchObject({
347+
status: 'success',
348+
output: { text: 'ok' },
349+
});
350+
});
351+
});
352+
240353
describe.sequential('Basic Workflow Execution', () => {
241354
it('should be able to bail workflow execution', async ctx => {
242355
const t0 = Date.now();

0 commit comments

Comments
 (0)