From cdfc40dc2d93b7a91bca335e15b72254ffdbbf58 Mon Sep 17 00:00:00 2001 From: Marc Littlemore Date: Fri, 20 Dec 2024 16:42:55 +0000 Subject: [PATCH] feat: add ability to add users to projects via API --- .../v1/handlers/projects/projects.handler.ts | 40 +++++ .../spec/paths/projects.projectId.users.yml | 49 +++++ packages/cli/src/public-api/v1/openapi.yml | 2 + packages/cli/src/requests.ts | 5 + .../integration/public-api/projects.test.ts | 169 +++++++++++++++++- 5 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.yml diff --git a/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts b/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts index 303949a615..8ef1272da7 100644 --- a/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts @@ -14,6 +14,7 @@ type Update = ProjectRequest.Update; type Delete = ProjectRequest.Delete; type DeleteUser = ProjectRequest.DeleteUser; type GetAll = PaginatedRequest; +type AddUsers = ProjectRequest.AddUsers; export = { createProject: [ @@ -87,4 +88,43 @@ export = { return res.status(204).send(); }, ], + addUsersToProject: [ + isLicensed('feat:projectRole:admin'), + globalScope('project:update'), + async (req: AddUsers, res: Response) => { + const { projectId } = req.params; + const { users } = req.body; + + const project = await Container.get(ProjectRepository).findOne({ + where: { id: projectId }, + relations: { projectRelations: true }, + }); + + if (!project) { + return res.status(404).send({ message: 'Not found' }); + } + + const existingUsers = project.projectRelations.map((relation) => ({ + userId: relation.userId, + role: relation.role, + })); + + // TODO: + // - What happens when the user is already in the project? + // - What happens when the user is not found on the instance? + + try { + await Container.get(ProjectController).syncProjectRelations(projectId, [ + ...existingUsers, + ...users, + ]); + } catch (error) { + return res + .status(400) + .send({ message: error instanceof Error ? error.message : 'Bad request' }); + } + + return res.status(201).send(); + }, + ], }; diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.yml new file mode 100644 index 0000000000..8114c829dd --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.yml @@ -0,0 +1,49 @@ +post: + x-eov-operation-id: addUsersToProject + x-eov-operation-handler: v1/handlers/projects/projects.handler + tags: + - Projects + summary: Add one or more users to a project + description: Add one or more users to a project from your instance. + parameters: + - name: projectId + in: path + description: The ID of the project. + required: true + schema: + type: string + requestBody: + description: Payload containing an array of one or more users to add to the project. + content: + application/json: + schema: + type: object + properties: + users: + type: array + description: A list of users and roles to add to the project. + items: + type: object + properties: + userId: + type: string + description: The unique identifier of the user. + example: '91765f0d-3b29-45df-adb9-35b23937eb92' + role: + type: string + description: The role assigned to the user in the project. + example: 'project:viewer' + required: + - userId + - role + required: + - users + responses: + '201': + description: Operation successful. + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '403': + $ref: '../../../../shared/spec/responses/forbidden.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index ec575fc556..3f646dc2fb 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -82,6 +82,8 @@ paths: $ref: './handlers/projects/spec/paths/projects.yml' /projects/{projectId}: $ref: './handlers/projects/spec/paths/projects.projectId.yml' + /projects/{projectId}/users: + $ref: './handlers/projects/spec/paths/projects.projectId.users.yml' /projects/{projectId}/users/{id}: $ref: './handlers/projects/spec/paths/projects.projectId.users.id.yml' components: diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 14e12bed42..3d27484fe7 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -564,6 +564,11 @@ export declare namespace ProjectRequest { >; type Delete = AuthenticatedRequest<{ projectId: string }, {}, {}, { transferId?: string }>; type DeleteUser = AuthenticatedRequest<{ projectId: string; id: string }, {}, {}, {}>; + type AddUsers = AuthenticatedRequest< + { projectId: string }, + {}, + { users: ProjectRelationPayload[] } + >; } // ---------------------------------- diff --git a/packages/cli/test/integration/public-api/projects.test.ts b/packages/cli/test/integration/public-api/projects.test.ts index 49af371101..49c7ed541d 100644 --- a/packages/cli/test/integration/public-api/projects.test.ts +++ b/packages/cli/test/integration/public-api/projects.test.ts @@ -475,7 +475,7 @@ describe('Projects in Public API', () => { expect(projectBefore[1].userId).toEqual(member.id); expect(projectAfter.length).toEqual(1); - expect(projectBefore[0].userId).toEqual(owner.id); + expect(projectAfter[0].userId).toEqual(owner.id); }); it('should reject with 404 if no project found', async () => { @@ -511,7 +511,174 @@ describe('Projects in Public API', () => { expect(projectBefore[0].userId).toEqual(owner.id); expect(projectAfter.length).toEqual(1); + expect(projectAfter[0].userId).toEqual(owner.id); + }); + }); + }); + + describe('POST /projects/:id/users', () => { + it('if not authenticated, should reject with 401', async () => { + const project = await createTeamProject(); + + const response = await testServer + .publicApiAgentWithoutApiKey() + .post(`/projects/${project.id}/users`); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required"); + }); + + it('if not licensed, should reject with a 403', async () => { + const owner = await createOwnerWithApiKey(); + const project = await createTeamProject(); + const member = await createMember(); + + const payload = { + users: [ + { + userId: member.id, + role: 'project:viewer', + }, + ], + }; + + const response = await testServer + .publicApiAgentFor(owner) + .post(`/projects/${project.id}/users`) + .send(payload); + + expect(response.status).toBe(403); + expect(response.body).toHaveProperty( + 'message', + new FeatureNotLicensedError('feat:projectRole:admin').message, + ); + }); + + it('if missing scope, should reject with 403', async () => { + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const member = await createMemberWithApiKey(); + const project = await createTeamProject(); + + const payload = { + users: [ + { + userId: member.id, + role: 'project:viewer', + }, + ], + }; + + const response = await testServer + .publicApiAgentFor(member) + .post(`/projects/${project.id}/users`) + .send(payload); + + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + + describe('when user has correct license', () => { + beforeEach(() => { + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + }); + + it('should reject with 404 if no project found', async () => { + const owner = await createOwnerWithApiKey(); + const member = await createMember(); + + const payload = { + users: [ + { + userId: member.id, + role: 'project:viewer', + }, + ], + }; + + const response = await testServer + .publicApiAgentFor(owner) + .post('/projects/123456/users/') + .send(payload); + + expect(response.status).toBe(404); + expect(response.body).toHaveProperty('message', 'Not found'); + }); + + it('should add expected users to project', async () => { + testServer.license.enable('feat:projectRole:viewer'); + testServer.license.enable('feat:projectRole:editor'); + const owner = await createOwnerWithApiKey(); + const project = await createTeamProject('shared-project', owner); + const member = await createMember(); + const member2 = await createMember(); + const projectBefore = await getAllProjectRelations({ + projectId: project.id, + }); + + const payload = { + users: [ + { + userId: member.id, + role: 'project:viewer', + }, + { + userId: member2.id, + role: 'project:editor', + }, + ], + }; + + const response = await testServer + .publicApiAgentFor(owner) + .post(`/projects/${project.id}/users`) + .send(payload); + + const projectAfter = await getAllProjectRelations({ + projectId: project.id, + }); + + expect(response.status).toBe(201); + expect(projectBefore.length).toEqual(1); expect(projectBefore[0].userId).toEqual(owner.id); + + expect(projectAfter.length).toEqual(3); + expect(projectAfter[0]).toEqual( + expect.objectContaining({ userId: owner.id, role: 'project:admin' }), + ); + expect(projectAfter[1]).toEqual( + expect.objectContaining({ userId: member.id, role: 'project:viewer' }), + ); + expect(projectAfter[2]).toEqual( + expect.objectContaining({ userId: member2.id, role: 'project:editor' }), + ); + }); + + it('should reject with 400 if license does not include user role', async () => { + const owner = await createOwnerWithApiKey(); + const project = await createTeamProject('shared-project', owner); + const member = await createMember(); + + const payload = { + users: [ + { + userId: member.id, + role: 'project:viewer', + }, + ], + }; + + const response = await testServer + .publicApiAgentFor(owner) + .post(`/projects/${project.id}/users`) + .send(payload); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty( + 'message', + 'Your instance is not licensed to use role "project:viewer".', + ); }); }); });