From 3075b605766a73bee35d07b3afab02050c4e4a49 Mon Sep 17 00:00:00 2001 From: Sam Wooler Date: Tue, 13 Oct 2026 12:03:20 +0000 Subject: [PATCH] fix(API): Apply projectId filter to executions for member API keys (#40887) Co-authored-by: Claude Haiku 5.5 --- .../workflow-sharing.service.test.ts | 11 +-- .../src/services/workflow-sharing.service.ts | 11 +-- .../shared-workflow.repository.test.ts | 36 ++++++-- .../shared-workflow.repository.ts | 17 ++-- .../integration/public-api/executions.test.ts | 88 ++++++++++++------- .../workflow-sharing.service.test.ts | 27 ++++++ 6 files changed, 136 insertions(+), 54 deletions(-) diff --git a/packages/@n8n/backend-services/src/services/__tests__/workflow-sharing.service.test.ts b/packages/@n8n/backend-services/src/services/__tests__/workflow-sharing.service.test.ts index 7a188e01113..870232ccddf 100644 --- a/packages/@n8n/backend-services/src/services/__tests__/workflow-sharing.service.test.ts +++ b/packages/@n8n/backend-services/src/services/__tests__/workflow-sharing.service.test.ts @@ -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']); }); }); diff --git a/packages/@n8n/backend-services/src/services/workflow-sharing.service.ts b/packages/@n8n/backend-services/src/services/workflow-sharing.service.ts index f499c4b6a2e..f502da11a22 100644 --- a/packages/@n8n/backend-services/src/services/workflow-sharing.service.ts +++ b/packages/@n8n/backend-services/src/services/workflow-sharing.service.ts @@ -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, + }); } /** diff --git a/packages/@n8n/db/src/repositories/__tests__/shared-workflow.repository.test.ts b/packages/@n8n/db/src/repositories/__tests__/shared-workflow.repository.test.ts index a87b2f036f8..6908b2a4d1d 100644 --- a/packages/@n8n/db/src/repositories/__tests__/shared-workflow.repository.test.ts +++ b/packages/@n8n/db/src/repositories/__tests__/shared-workflow.repository.test.ts @@ -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); diff --git a/packages/@n8n/db/src/repositories/shared-workflow.repository.ts b/packages/@n8n/db/src/repositories/shared-workflow.repository.ts index cbc79236e12..41be247b750 100644 --- a/packages/@n8n/db/src/repositories/shared-workflow.repository.ts +++ b/packages/@n8n/db/src/repositories/shared-workflow.repository.ts @@ -41,15 +41,22 @@ export class SharedWorkflowRepository extends BaseRepository { return rows.map(({ workflowId }) => workflowId); } - async findWorkflowIdsAccessibleToUser( - userId: string, - workflowRoleSlugs: string[], - projectRoleSlugs: string[], - ): Promise { + async findWorkflowIdsAccessibleToUser({ + userId, + workflowRoleSlugs, + projectRoleSlugs, + projectId, + }: { + userId: string; + workflowRoleSlugs: string[]; + projectRoleSlugs: string[]; + projectId?: string; + }): Promise { const rows = await this.find({ where: { role: In(workflowRoleSlugs), project: { + id: projectId ?? undefined, projectRelations: { userId, role: { slug: In(projectRoleSlugs) }, diff --git a/packages/cli/test/integration/public-api/executions.test.ts b/packages/cli/test/integration/public-api/executions.test.ts index a3995de8fec..71e4eab4bf0 100644 --- a/packages/cli/test/integration/public-api/executions.test.ts +++ b/packages/cli/test/integration/public-api/executions.test.ts @@ -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 () => { diff --git a/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts b/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts index aa3ba26e63c..e864869de05 100644 --- a/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts @@ -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', () => {