fix(core): Agents called from workflows use the workflows owner/user ID for calling further workflows through the agent (#30242)

This commit is contained in:
Michael Drury 2026-05-11 15:51:59 +01:00 committed by GitHub
parent 1749801508
commit 9072ee3beb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 122 additions and 14 deletions

View File

@ -1,6 +1,6 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import type { WorkflowEntity } from '@n8n/db';
import type { WorkflowEntity, User, Project } from '@n8n/db';
import { ExecutionRepository, WorkflowPublishHistoryRepository, WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
@ -26,11 +26,14 @@ import {
SubworkflowPolicyChecker,
} from '@/executions/pre-execution-checks';
import { ExternalHooks } from '@/external-hooks';
import { AgentsService } from '@/modules/agents/agents.service';
import { DataTableProxyService } from '@/modules/data-table/data-table-proxy.service';
import { OwnershipService } from '@/services/ownership.service';
import { UrlService } from '@/services/url.service';
import { WorkflowStatisticsService } from '@/services/workflow-statistics.service';
import { Telemetry } from '@/telemetry';
import {
executeAgent,
executeWorkflow,
getBase,
getRunData,
@ -759,4 +762,99 @@ describe('WorkflowExecuteAdditionalData', () => {
expect(additionalData.workflowSettings).toBe(workflowSettings);
});
});
describe('executeAgent', () => {
const ownershipService = mockInstance(OwnershipService);
const agentsService = mockInstance(AgentsService);
const AGENT_ID = 'agent-id';
const MESSAGE = 'hello';
const EXEC_ID = 'exec-id';
const THREAD_ID = 'thread-id';
beforeEach(() => {
jest.clearAllMocks();
agentsService.executeForWorkflow.mockResolvedValue(
mock<Awaited<ReturnType<typeof agentsService.executeForWorkflow>>>(),
);
});
it('uses userId and projectId from additionalData when both are present', async () => {
const additionalData = mock<IWorkflowExecuteAdditionalData>({
userId: 'user-1',
projectId: 'project-1',
workflowId: 'workflow-1',
});
await executeAgent(AGENT_ID, MESSAGE, EXEC_ID, THREAD_ID, additionalData);
expect(ownershipService.getWorkflowProjectCached).not.toHaveBeenCalled();
expect(ownershipService.getPersonalProjectOwnerCached).not.toHaveBeenCalled();
expect(agentsService.executeForWorkflow).toHaveBeenCalledWith(
AGENT_ID,
MESSAGE,
EXEC_ID,
THREAD_ID,
'user-1',
'project-1',
);
});
it('backfills userId and projectId from the workflow owner when both are missing', async () => {
const additionalData = mock<IWorkflowExecuteAdditionalData>({
userId: undefined,
projectId: undefined,
workflowId: 'workflow-1',
});
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(
mock<Project>({ id: 'project-1' }),
);
ownershipService.getPersonalProjectOwnerCached.mockResolvedValueOnce(
mock<User>({ id: 'owner-1' }),
);
await executeAgent(AGENT_ID, MESSAGE, EXEC_ID, THREAD_ID, additionalData);
expect(ownershipService.getWorkflowProjectCached).toHaveBeenCalledWith('workflow-1');
expect(ownershipService.getPersonalProjectOwnerCached).toHaveBeenCalledWith('project-1');
expect(agentsService.executeForWorkflow).toHaveBeenCalledWith(
AGENT_ID,
MESSAGE,
EXEC_ID,
THREAD_ID,
'owner-1',
'project-1',
);
});
it('throws when userId is missing and the workflow has no personal-project owner', async () => {
const additionalData = mock<IWorkflowExecuteAdditionalData>({
userId: undefined,
projectId: undefined,
workflowId: 'workflow-1',
});
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(
mock<Project>({ id: 'project-1' }),
);
ownershipService.getPersonalProjectOwnerCached.mockResolvedValueOnce(null);
await expect(
executeAgent(AGENT_ID, MESSAGE, EXEC_ID, THREAD_ID, additionalData),
).rejects.toThrow('Cannot execute agent without a userId in additional data');
expect(agentsService.executeForWorkflow).not.toHaveBeenCalled();
});
it('throws when userId is missing and no workflowId is available to resolve ownership', async () => {
const additionalData = mock<IWorkflowExecuteAdditionalData>({
userId: undefined,
projectId: undefined,
workflowId: undefined,
});
await expect(
executeAgent(AGENT_ID, MESSAGE, EXEC_ID, THREAD_ID, additionalData),
).rejects.toThrow('Cannot execute agent without a userId in additional data');
expect(ownershipService.getWorkflowProjectCached).not.toHaveBeenCalled();
});
});
});

View File

@ -261,22 +261,32 @@ export async function executeAgent(
threadId: string,
additionalData: IWorkflowExecuteAdditionalData,
): Promise<ExecuteAgentData> {
if (!additionalData.userId) {
throw new UnexpectedError('Cannot execute agent without a userId in additional data');
let userId = additionalData.userId;
let projectId = additionalData.projectId;
// Trigger-fired and webhook executions build `additionalData` without a
// `userId` (see `getBase` callers in `active-workflow-manager`,
// `webhooks/*`, `scaling/job-processor`). Resolve the workflow's owning
// project to derive both `userId` and `projectId` so the agent runs under
// the workflow owner's identity, mirroring the projectId backfill below.
if ((!userId || !projectId) && additionalData.workflowId) {
const { OwnershipService } = await import('@/services/ownership.service');
const ownershipService = Container.get(OwnershipService);
const project = await ownershipService.getWorkflowProjectCached(additionalData.workflowId);
projectId = projectId ?? project.id;
if (!userId) {
const owner = await ownershipService.getPersonalProjectOwnerCached(project.id);
userId = owner?.id;
}
}
let projectId = additionalData.projectId;
if (!userId) {
throw new UnexpectedError('Cannot execute agent without a userId in additional data');
}
if (!projectId) {
if (!additionalData.workflowId) {
throw new UnexpectedError(
'Cannot execute agent without a projectId or workflowId in additional data',
);
}
const { OwnershipService } = await import('@/services/ownership.service');
const project = await Container.get(OwnershipService).getWorkflowProjectCached(
additionalData.workflowId,
throw new UnexpectedError(
'Cannot execute agent without a projectId or workflowId in additional data',
);
projectId = project.id;
}
const { AgentsService } = await import('@/modules/agents/agents.service');
@ -287,7 +297,7 @@ export async function executeAgent(
message,
executionId,
threadId,
additionalData.userId,
userId,
projectId,
);
}