fix(API): Apply projectId filter to executions for member API keys (#40887)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Wooler 2026-10-13 12:03:20 +00:00 committed by GitHub
parent 4fcb628fca
commit 3075b60576
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 136 additions and 54 deletions

View File

@ -62,11 +62,12 @@ describe('WorkflowSharingService', () => {
scopes: ['workflow:read'],
});
expect(sharedWorkflowRepository.findWorkflowIdsAccessibleToUser).toHaveBeenCalledWith(
member.id,
['workflow:owner'],
['project:viewer'],
);
expect(sharedWorkflowRepository.findWorkflowIdsAccessibleToUser).toHaveBeenCalledWith({
userId: member.id,
workflowRoleSlugs: ['workflow:owner'],
projectRoleSlugs: ['project:viewer'],
projectId: undefined,
});
expect(result).toEqual(['workflow-1']);
});
});

View File

@ -51,11 +51,12 @@ export class WorkflowSharingService {
? await this.roleService.rolesWithScope('workflow', options.scopes)
: options.workflowRoles;
return await this.sharedWorkflowRepository.findWorkflowIdsAccessibleToUser(
user.id,
workflowRoles,
projectRoles,
);
return await this.sharedWorkflowRepository.findWorkflowIdsAccessibleToUser({
userId: user.id,
workflowRoleSlugs: workflowRoles,
projectRoleSlugs: projectRoles,
projectId,
});
}
/**

View File

@ -66,11 +66,11 @@ describe('SharedWorkflowRepository', () => {
it('finds workflow IDs accessible through project and workflow roles', async () => {
entityManager.find.mockResolvedValue(rows);
const result = await sharedWorkflowRepository.findWorkflowIdsAccessibleToUser(
'user-1',
['workflow:owner'],
['project:viewer'],
);
const result = await sharedWorkflowRepository.findWorkflowIdsAccessibleToUser({
userId: 'user-1',
workflowRoleSlugs: ['workflow:owner'],
projectRoleSlugs: ['project:viewer'],
});
expect(entityManager.find).toHaveBeenCalledWith(SharedWorkflow, {
select: ['workflowId'],
@ -87,6 +87,32 @@ describe('SharedWorkflowRepository', () => {
expect(result).toEqual(['workflow-1', 'workflow-2']);
});
it('limits workflow IDs accessible to a user to a project', async () => {
entityManager.find.mockResolvedValue(rows);
const result = await sharedWorkflowRepository.findWorkflowIdsAccessibleToUser({
userId: 'user-1',
workflowRoleSlugs: ['workflow:owner'],
projectRoleSlugs: ['project:viewer'],
projectId: 'project-1',
});
expect(entityManager.find).toHaveBeenCalledWith(SharedWorkflow, {
select: ['workflowId'],
where: {
role: In(['workflow:owner']),
project: {
id: 'project-1',
projectRelations: {
userId: 'user-1',
role: { slug: In(['project:viewer']) },
},
},
},
});
expect(result).toEqual(['workflow-1', 'workflow-2']);
});
it('finds workflow IDs shared with a user', async () => {
entityManager.find.mockResolvedValue(rows);

View File

@ -41,15 +41,22 @@ export class SharedWorkflowRepository extends BaseRepository<SharedWorkflow> {
return rows.map(({ workflowId }) => workflowId);
}
async findWorkflowIdsAccessibleToUser(
userId: string,
workflowRoleSlugs: string[],
projectRoleSlugs: string[],
): Promise<string[]> {
async findWorkflowIdsAccessibleToUser({
userId,
workflowRoleSlugs,
projectRoleSlugs,
projectId,
}: {
userId: string;
workflowRoleSlugs: string[];
projectRoleSlugs: string[];
projectId?: string;
}): Promise<string[]> {
const rows = await this.find({
where: {
role: In(workflowRoleSlugs),
project: {
id: projectId ?? undefined,
projectRelations: {
userId,
role: { slug: In(projectRoleSlugs) },

View File

@ -8,7 +8,7 @@ import {
shareWorkflowWithUsers,
testDb,
} from '@n8n/backend-test-utils';
import type { ExecutionEntity, IExecutionResponse, User } from '@n8n/db';
import { type ExecutionEntity, type IExecutionResponse, type User } from '@n8n/db';
import { Container } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';
import { type ExecutionStatus } from 'n8n-workflow';
@ -998,41 +998,61 @@ describe('GET /executions', () => {
}
});
test('should return executions filtered by project ID', async () => {
/**
* Arrange
*/
const [firstProject, secondProject] = await Promise.all([
createTeamProject(),
createTeamProject(),
]);
const [firstWorkflow, secondWorkflow] = await Promise.all([
createWorkflow({}, firstProject),
createWorkflow({}, secondProject),
]);
const [firstExecution, secondExecution, _] = await Promise.all([
createExecution({}, firstWorkflow),
createExecution({}, firstWorkflow),
createExecution({}, secondWorkflow),
]);
test.each<{
data: () => User;
type: 'owner' | 'member';
}>([
{ type: 'owner', data: () => owner },
{ type: 'member', data: () => user1 },
])(
'should return executions filtered by project ID for $type',
async ({ data: getUser, type }) => {
testServer.license.enable('feat:sharing');
const user = getUser();
const agent = testServer.publicApiAgentFor(user);
/**
* Act
*/
const response = await authOwnerAgent.get('/executions').query({
projectId: firstProject.id,
});
/**
* Arrange
*/
const [firstProject, secondProject] = await Promise.all([
createTeamProject('first project', owner),
createTeamProject('second project', owner),
]);
if (type === 'member') {
await Promise.all([
linkUserToProject(user, firstProject, 'project:editor'),
linkUserToProject(user, secondProject, 'project:editor'),
]);
}
/**
* Assert
*/
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(2);
expect(response.body.nextCursor).toBeNull();
expect(response.body.data.map((execution: ExecutionEntity) => execution.id)).toEqual(
expect.arrayContaining([firstExecution.id, secondExecution.id]),
);
});
const [firstWorkflow, secondWorkflow] = await Promise.all([
createWorkflow({}, firstProject),
createWorkflow({}, secondProject),
]);
const [firstExecution, secondExecution, _] = await Promise.all([
createExecution({}, firstWorkflow),
createExecution({}, firstWorkflow),
createExecution({}, secondWorkflow),
]);
/**
* Act
*/
const response = await agent.get('/executions').query({
projectId: firstProject.id,
});
/**
* Assert
*/
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(2);
expect(response.body.nextCursor).toBeNull();
expect(response.body.data.map((execution: ExecutionEntity) => execution.id)).toEqual(
expect.arrayContaining([firstExecution.id, secondExecution.id]),
);
},
);
describe('with startedAfter and startedBefore filters', () => {
test('should retrieve executions started after a given time', async () => {

View File

@ -115,6 +115,33 @@ describe('WorkflowSharingService', () => {
expect(sharedWorkflowIds).toContain(workflow1.id);
expect(sharedWorkflowIds).not.toContain(workflow2.id);
});
it('should only show workflows of the given project to users', async () => {
const projectA = await projectService.createTeamProject(member, { name: 'Project A' });
const projectB = await projectService.createTeamProject(member, { name: 'Project B' });
await Promise.all([
projectService.addUser(projectA.id, {
userId: anotherMember.id,
role: 'project:admin',
}),
projectService.addUser(projectB.id, {
userId: anotherMember.id,
role: 'project:admin',
}),
]);
const workflowA = await createWorkflow(undefined, projectA);
const workflowB = await createWorkflow(undefined, projectB);
const sharedWorkflowIds = await workflowSharingService.getSharedWorkflowIds(anotherMember, {
scopes: ['workflow:read'],
projectId: projectA.id,
});
expect(sharedWorkflowIds).toEqual([workflowA.id]);
expect(sharedWorkflowIds).not.toContain(workflowB.id);
});
});
describe('getUserIdsWithAccessToWorkflow', () => {