diff --git a/packages/cli/src/PublicApi/types.ts b/packages/cli/src/PublicApi/types.ts index 831dd03ebf..631a8c09d7 100644 --- a/packages/cli/src/PublicApi/types.ts +++ b/packages/cli/src/PublicApi/types.ts @@ -29,6 +29,7 @@ export declare namespace ExecutionRequest { includeData?: boolean; workflowId?: string; lastId?: string; + projectId?: string; } >; diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts index 16ca8093cb..ed078b52d5 100644 --- a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts @@ -95,9 +95,10 @@ export = { status = undefined, includeData = false, workflowId = undefined, + projectId, } = req.query; - const sharedWorkflowsIds = await getSharedWorkflowIds(req.user, ['workflow:read']); + const sharedWorkflowsIds = await getSharedWorkflowIds(req.user, ['workflow:read'], projectId); // user does not have workflows hence no executions // or the execution they are trying to access belongs to a workflow they do not own diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/spec/paths/executions.yml b/packages/cli/src/PublicApi/v1/handlers/executions/spec/paths/executions.yml index 8d26492ec3..6fcdaf356e 100644 --- a/packages/cli/src/PublicApi/v1/handlers/executions/spec/paths/executions.yml +++ b/packages/cli/src/PublicApi/v1/handlers/executions/spec/paths/executions.yml @@ -21,6 +21,14 @@ get: schema: type: string example: '1000' + - name: projectId + in: query + required: false + explode: false + allowReserved: true + schema: + type: string + example: VmwOO9HeTEj20kxM - $ref: '../../../../shared/spec/parameters/limit.yml' - $ref: '../../../../shared/spec/parameters/cursor.yml' responses: diff --git a/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.yml b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.yml index 7778a08e35..e767ab33cc 100644 --- a/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.yml +++ b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.yml @@ -9,6 +9,14 @@ get: - $ref: '../../../../shared/spec/parameters/limit.yml' - $ref: '../../../../shared/spec/parameters/cursor.yml' - $ref: '../schemas/parameters/includeRole.yml' + - name: projectId + in: query + required: false + explode: false + allowReserved: true + schema: + type: string + example: VmwOO9HeTEj20kxM responses: '200': description: Operation successful. diff --git a/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts b/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts index cbfc43c0d1..53c568ee69 100644 --- a/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts +++ b/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts @@ -12,6 +12,7 @@ import { } from '../../shared/middlewares/global.middleware'; import type { UserRequest } from '@/requests'; import { InternalHooks } from '@/InternalHooks'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; import type { Response } from 'express'; import { InvitationController } from '@/controllers/invitation.controller'; import { UsersController } from '@/controllers/users.controller'; @@ -51,12 +52,17 @@ export = { validCursor, globalScope(['user:list', 'user:read']), async (req: UserRequest.Get, res: express.Response) => { - const { offset = 0, limit = 100, includeRole = false } = req.query; + const { offset = 0, limit = 100, includeRole = false, projectId } = req.query; + + const _in = projectId + ? await Container.get(ProjectRelationRepository).findUserIdsByProjectId(projectId) + : undefined; const [users, count] = await getAllUsersAndCount({ includeRole, limit, offset, + in: _in, }); const telemetryData = { diff --git a/packages/cli/src/PublicApi/v1/handlers/users/users.service.ee.ts b/packages/cli/src/PublicApi/v1/handlers/users/users.service.ee.ts index f7bf661816..e62c946574 100644 --- a/packages/cli/src/PublicApi/v1/handlers/users/users.service.ee.ts +++ b/packages/cli/src/PublicApi/v1/handlers/users/users.service.ee.ts @@ -3,6 +3,8 @@ import { UserRepository } from '@db/repositories/user.repository'; import type { User } from '@db/entities/User'; import pick from 'lodash/pick'; import { validate as uuidValidate } from 'uuid'; +// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import +import { In } from '@n8n/typeorm'; export async function getUser(data: { withIdentifier: string; @@ -25,9 +27,12 @@ export async function getAllUsersAndCount(data: { includeRole?: boolean; limit?: number; offset?: number; + in?: string[]; }): Promise<[User[], number]> { + const { in: _in } = data; + const users = await Container.get(UserRepository).find({ - where: {}, + where: { ...(_in && { id: In(_in) }) }, skip: data.offset, take: data.limit, }); diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts index d301e61c93..3e3afc078e 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts @@ -17,15 +17,21 @@ function insertIf(condition: boolean, elements: string[]): string[] { return condition ? elements : []; } -export async function getSharedWorkflowIds(user: User, scopes: Scope[]): Promise { +export async function getSharedWorkflowIds( + user: User, + scopes: Scope[], + projectId?: string, +): Promise { if (Container.get(License).isSharingEnabled()) { return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, { scopes, + projectId, }); } else { return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, { workflowRoles: ['workflow:owner'], projectRoles: ['project:personalOwner'], + projectId, }); } } diff --git a/packages/cli/src/databases/repositories/projectRelation.repository.ts b/packages/cli/src/databases/repositories/projectRelation.repository.ts index bddfd6e38d..00fc4de34a 100644 --- a/packages/cli/src/databases/repositories/projectRelation.repository.ts +++ b/packages/cli/src/databases/repositories/projectRelation.repository.ts @@ -52,4 +52,13 @@ export class ProjectRelationRepository extends Repository { {} as Record, ); } + + async findUserIdsByProjectId(projectId: string): Promise { + const rows = await this.find({ + select: ['userId'], + where: { projectId }, + }); + + return [...new Set(rows.map((r) => r.userId))]; + } } diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 1ea265f239..4fe369857e 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -306,7 +306,7 @@ export declare namespace UserRequest { { id: string; email: string; identifier: string }, {}, {}, - { limit?: number; offset?: number; cursor?: string; includeRole?: boolean } + { limit?: number; offset?: number; cursor?: string; includeRole?: boolean; projectId?: string } >; export type PasswordResetLink = AuthenticatedRequest<{ id: string }, {}, {}, {}>; diff --git a/packages/cli/src/workflows/workflowSharing.service.ts b/packages/cli/src/workflows/workflowSharing.service.ts index 93ef76438f..add5bb31b8 100644 --- a/packages/cli/src/workflows/workflowSharing.service.ts +++ b/packages/cli/src/workflows/workflowSharing.service.ts @@ -27,11 +27,16 @@ export class WorkflowSharingService { async getSharedWorkflowIds( user: User, options: - | { scopes: Scope[] } - | { projectRoles: ProjectRole[]; workflowRoles: WorkflowSharingRole[] }, + | { scopes: Scope[]; projectId?: string } + | { projectRoles: ProjectRole[]; workflowRoles: WorkflowSharingRole[]; projectId?: string }, ): Promise { + const { projectId } = options; + if (user.hasGlobalScope('workflow:read')) { - const sharedWorkflows = await this.sharedWorkflowRepository.find({ select: ['workflowId'] }); + const sharedWorkflows = await this.sharedWorkflowRepository.find({ + select: ['workflowId'], + ...(projectId && { where: { projectId } }), + }); return sharedWorkflows.map(({ workflowId }) => workflowId); } diff --git a/packages/cli/test/integration/publicApi/executions.test.ts b/packages/cli/test/integration/publicApi/executions.test.ts index 9961dac545..8dac97fc22 100644 --- a/packages/cli/test/integration/publicApi/executions.test.ts +++ b/packages/cli/test/integration/publicApi/executions.test.ts @@ -20,6 +20,8 @@ import { import type { SuperAgentTest } from '../shared/types'; import { mockInstance } from '@test/mocking'; import { Telemetry } from '@/telemetry'; +import { createTeamProject } from '@test-integration/db/projects'; +import type { ExecutionEntity } from '@/databases/entities/ExecutionEntity'; let owner: User; let user1: User; @@ -447,6 +449,42 @@ 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), + ]); + + /** + * Act + */ + const response = await authOwnerAgent.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]), + ); + }); + test('owner should retrieve all executions regardless of ownership', async () => { const [firstWorkflowForUser1, secondWorkflowForUser1] = await createManyWorkflows(2, {}, user1); await createManyExecutions(2, firstWorkflowForUser1, createSuccessfulExecution); diff --git a/packages/cli/test/integration/publicApi/users.ee.test.ts b/packages/cli/test/integration/publicApi/users.ee.test.ts index 6e57a629d7..be23d8f45a 100644 --- a/packages/cli/test/integration/publicApi/users.ee.test.ts +++ b/packages/cli/test/integration/publicApi/users.ee.test.ts @@ -7,8 +7,10 @@ import { mockInstance } from '../../shared/mocking'; import { randomApiKey } from '../shared/random'; import * as utils from '../shared/utils/'; import * as testDb from '../shared/testDb'; -import { createUser, createUserShell } from '../shared/db/users'; +import { createOwner, createUser, createUserShell } from '../shared/db/users'; import type { SuperAgentTest } from '../shared/types'; +import { createTeamProject, linkUserToProject } from '@test-integration/db/projects'; +import type { User } from '@/databases/entities/User'; mockInstance(License, { getUsersLimit: jest.fn().mockReturnValue(-1), @@ -84,6 +86,46 @@ describe('With license unlimited quota:users', () => { expect(updatedAt).toBeDefined(); } }); + + it('should return users filtered by project ID', async () => { + /** + * Arrange + */ + const [owner, firstMember, secondMember, thirdMember] = await Promise.all([ + createOwner({ withApiKey: true }), + createUser({ role: 'global:member' }), + createUser({ role: 'global:member' }), + createUser({ role: 'global:member' }), + ]); + + const [firstProject, secondProject] = await Promise.all([ + createTeamProject(), + createTeamProject(), + ]); + + await Promise.all([ + linkUserToProject(firstMember, firstProject, 'project:admin'), + linkUserToProject(secondMember, firstProject, 'project:viewer'), + linkUserToProject(thirdMember, secondProject, 'project:admin'), + ]); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).get('/users').query({ + projectId: firstProject.id, + }); + + /** + * Assert + */ + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(2); + expect(response.body.nextCursor).toBeNull(); + expect(response.body.data.map((user: User) => user.id)).toEqual( + expect.arrayContaining([firstMember.id, secondMember.id]), + ); + }); }); describe('GET /users/:id', () => {