Skip to content

Commit a3fb5f7

Browse files
committed
feat(core): support system actor direct FGA calls
1 parent 985cb50 commit a3fb5f7

8 files changed

Lines changed: 125 additions & 3 deletions

File tree

.changeset/crisp-llamas-happen.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/core': minor
3+
---
4+
5+
Added direct workflow, tool, and memory FGA support for trusted system actor invocations.

packages/core/src/memory/memory-config.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,34 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { describe, expect, it, vi } from 'vitest';
22

3+
import { MastraFGAPermissions } from '../auth/ee';
4+
import { RequestContext } from '../request-context';
35
import { InMemoryStore } from '../storage';
46

57
import { MockMemory } from './mock';
68

9+
describe('MastraMemory FGA', () => {
10+
it('bypasses thread membership resolution for a tenant-scoped system actor', async () => {
11+
const fgaProvider = {
12+
require: vi.fn().mockResolvedValue(undefined),
13+
};
14+
const requestContext = new RequestContext();
15+
requestContext.set('organizationId', 'org-1');
16+
17+
await MockMemory.checkThreadFGA({
18+
mastra: {
19+
getServer: () => ({ fga: fgaProvider }),
20+
} as any,
21+
threadId: 'thread-1',
22+
resourceId: 'tenant-1',
23+
requestContext,
24+
permission: MastraFGAPermissions.MEMORY_READ,
25+
systemActor: { actorKind: 'system', sourceWorkflow: 'nightly-workflow' },
26+
});
27+
28+
expect(fgaProvider.require).not.toHaveBeenCalled();
29+
});
30+
});
31+
732
describe('MastraMemory config serialization', () => {
833
it('should serialize observational memory retrieval config for thread scope', () => {
934
const memory = new MockMemory({

packages/core/src/memory/memory.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { AssistantContent, UserContent, CoreMessage } from '@internal/ai-sdk-v4';
22
import type { MastraDBMessage } from '../agent/message-list';
33
import { MastraFGAPermissions } from '../auth/ee';
4-
import type { MastraFGAPermissionInput } from '../auth/ee';
4+
import type { MastraFGAPermissionInput, SystemActorSignal } from '../auth/ee';
55
import { MastraBase } from '../base';
66
import { ErrorDomain, MastraError } from '../error';
77
import { ModelRouterEmbeddingModel } from '../llm/model';
@@ -591,11 +591,12 @@ https://mastra.ai/en/docs/memory/overview`,
591591
*/
592592
static async checkThreadFGA(options: {
593593
mastra?: Mastra;
594-
user: Record<string, unknown>;
594+
user?: Record<string, unknown>;
595595
threadId: string;
596596
resourceId?: string;
597597
requestContext?: RequestContext;
598598
permission?: MastraFGAPermissionInput;
599+
systemActor?: SystemActorSignal;
599600
}): Promise<void> {
600601
const {
601602
mastra,
@@ -604,6 +605,7 @@ https://mastra.ai/en/docs/memory/overview`,
604605
resourceId,
605606
requestContext,
606607
permission = MastraFGAPermissions.MEMORY_READ,
608+
systemActor,
607609
} = options;
608610
const fgaProvider = mastra?.getServer()?.fga;
609611
if (!fgaProvider) return;
@@ -615,6 +617,7 @@ https://mastra.ai/en/docs/memory/overview`,
615617
resource: { type: 'thread', id: threadId },
616618
permission,
617619
requestContext,
620+
systemActor,
618621
context:
619622
resourceId || requestContext
620623
? {

packages/core/src/tools/tool-builder/builder.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,51 @@ describe('CoreToolBuilder FGA', () => {
146146
expect(fgaProvider.require).not.toHaveBeenCalled();
147147
expect(execute).not.toHaveBeenCalled();
148148
});
149+
150+
it('bypasses membership resolution for a tenant-scoped system actor', async () => {
151+
const execute = vi.fn().mockResolvedValue({ result: 'ok' });
152+
const testTool = createTool({
153+
id: 'search',
154+
description: 'Search',
155+
inputSchema: z.object({ query: z.string() }),
156+
execute,
157+
});
158+
const requestContext = new RequestContext();
159+
requestContext.set('organizationId', 'org-1');
160+
const fgaProvider = {
161+
require: vi.fn().mockResolvedValue(undefined),
162+
};
163+
164+
const builder = new CoreToolBuilder({
165+
originalTool: testTool,
166+
options: {
167+
name: 'search',
168+
logger: {
169+
debug: vi.fn(),
170+
warn: vi.fn(),
171+
error: vi.fn(),
172+
trackException: vi.fn(),
173+
} as any,
174+
requestContext,
175+
mastra: {
176+
getServer: () => ({ fga: fgaProvider }),
177+
} as any,
178+
},
179+
});
180+
181+
const builtTool = builder.build();
182+
await builtTool.execute!(
183+
{ query: 'docs' },
184+
{
185+
toolCallId: 'call-1',
186+
messages: [],
187+
systemActor: { actorKind: 'system', sourceWorkflow: 'nightly-workflow' },
188+
},
189+
);
190+
191+
expect(fgaProvider.require).not.toHaveBeenCalled();
192+
expect(execute).toHaveBeenCalled();
193+
});
149194
});
150195

151196
describe('MCP Tool Tracing', () => {

packages/core/src/tools/tool-builder/builder.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,7 @@ export class CoreToolBuilder extends MastraBase {
743743
resource: { type: 'tool', id: toolResourceId },
744744
permission: MastraFGAPermissions.TOOLS_EXECUTE,
745745
requestContext: toolRequestContext,
746+
systemActor: execOptions?.systemActor,
746747
context: {
747748
resourceId: options.resourceId,
748749
},

packages/core/src/tools/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { ElicitRequest, ElicitResult } from '@modelcontextprotocol/sdk/type
1212

1313
import type { MastraPrimitives, MastraUnion } from '../action';
1414
export type { MastraPrimitives, MastraUnion };
15+
import type { SystemActorSignal } from '../auth/ee';
1516
import type { ToolBackgroundConfig } from '../background-tasks';
1617
import type { MastraBrowser } from '../browser/browser';
1718
import type { Mastra } from '../mastra';
@@ -240,6 +241,8 @@ export type MastraToolInvocationOptions = ToolInvocationOptions &
240241
* their requestContext (e.g., authenticated API clients, feature flags) to tools.
241242
*/
242243
requestContext?: RequestContext;
244+
/** Trusted server-side signal for this tool FGA check. */
245+
systemActor?: SystemActorSignal;
243246
/**
244247
* Flushes the parent stream's pending messages to persistent storage.
245248
*

packages/core/src/workflows/workflow.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,42 @@ describe('Workflow (Default Engine Specifics)', () => {
782782
).rejects.toThrow('authenticated user is required');
783783
expect(fgaProvider.require).not.toHaveBeenCalled();
784784
});
785+
786+
it('bypasses membership resolution for a tenant-scoped system actor', async () => {
787+
const fgaProvider = {
788+
require: vi.fn().mockResolvedValue(undefined),
789+
check: vi.fn(),
790+
filterAccessible: vi.fn(),
791+
};
792+
const workflow = createFGAWorkflow();
793+
const mastra = new Mastra({
794+
logger: false,
795+
server: { fga: fgaProvider },
796+
});
797+
workflow.__registerMastra(mastra);
798+
799+
const requestContext = new RequestContext();
800+
requestContext.set('organizationId', 'org-1');
801+
802+
const result = await (workflow as any).execute({
803+
runId: 'run-3',
804+
inputData: { value: 'ok' },
805+
state: {},
806+
setState: vi.fn(),
807+
suspend: vi.fn(),
808+
[PUBSUB_SYMBOL]: new EventEmitterPubSub(),
809+
mastra,
810+
requestContext,
811+
systemActor: { actorKind: 'system', sourceWorkflow: 'nightly-workflow' },
812+
abort: vi.fn(),
813+
abortSignal: new AbortController().signal,
814+
engine: 'default',
815+
bail: vi.fn(),
816+
});
817+
818+
expect(result).toEqual({ value: 'ok' });
819+
expect(fgaProvider.require).not.toHaveBeenCalled();
820+
});
785821
});
786822

787823
describe('Nested workflow abort listener cleanup (issue #16125)', () => {

packages/core/src/workflows/workflow.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { SubAgent } from '../agent/subagent';
1212
import { TripWire } from '../agent/trip-wire';
1313
import type { AgentStreamOptions } from '../agent/types';
1414
import { MastraFGAPermissions } from '../auth/ee';
15+
import type { SystemActorSignal } from '../auth/ee';
1516
import { MastraBase } from '../base';
1617
import { RequestContext } from '../di';
1718
import { ErrorCategory, ErrorDomain, MastraError } from '../error';
@@ -2536,6 +2537,7 @@ export class Workflow<
25362537
outputWriter,
25372538
validateInputs,
25382539
perStep,
2540+
systemActor,
25392541
engine: _engine,
25402542
bail: _bail,
25412543
...rest
@@ -2572,6 +2574,7 @@ export class Workflow<
25722574
outputWriter?: OutputWriter;
25732575
validateInputs?: boolean;
25742576
perStep?: boolean;
2577+
systemActor?: SystemActorSignal;
25752578
} & Partial<ObservabilityContext>): Promise<TOutput | undefined> {
25762579
const observabilityContext = resolveObservabilityContext(rest);
25772580
this.__registerMastra(mastra);
@@ -2587,6 +2590,7 @@ export class Workflow<
25872590
resource: { type: 'workflow', id: getWorkflowFGAResourceId(this.id) },
25882591
permission: MastraFGAPermissions.WORKFLOWS_EXECUTE,
25892592
requestContext,
2593+
systemActor,
25902594
context: {
25912595
resourceId,
25922596
},

0 commit comments

Comments
 (0)