diff --git a/packages/cli/src/controllers/project.controller.ts b/packages/cli/src/controllers/project.controller.ts index 415e0e9519130..41ba02364e34f 100644 --- a/packages/cli/src/controllers/project.controller.ts +++ b/packages/cli/src/controllers/project.controller.ts @@ -196,14 +196,7 @@ export class ProjectController { await this.projectsService.updateProject(req.body.name, req.params.projectId); } if (req.body.relations) { - try { - await this.projectsService.syncProjectRelations(req.params.projectId, req.body.relations); - } catch (e) { - if (e instanceof UnlicensedProjectRoleError) { - throw new BadRequestError(e.message); - } - throw e; - } + await this.syncProjectRelations(req.params.projectId, req.body.relations); this.eventService.emit('team-project-updated', { userId: req.user.id, @@ -214,6 +207,20 @@ export class ProjectController { } } + async syncProjectRelations( + projectId: string, + relations: ProjectRequest.ProjectRelationPayload[], + ) { + try { + await this.projectsService.syncProjectRelations(projectId, relations); + } catch (e) { + if (e instanceof UnlicensedProjectRoleError) { + throw new BadRequestError(e.message); + } + throw e; + } + } + @Delete('/:projectId') @ProjectScope('project:delete') async deleteProject(req: ProjectRequest.Delete) { 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 ea7220f7880d4..8ef1272da7093 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 @@ -12,7 +12,9 @@ import { encodeNextCursor } from '../../shared/services/pagination.service'; type Create = ProjectRequest.Create; type Update = ProjectRequest.Update; type Delete = ProjectRequest.Delete; +type DeleteUser = ProjectRequest.DeleteUser; type GetAll = PaginatedRequest; +type AddUsers = ProjectRequest.AddUsers; export = { createProject: [ @@ -64,4 +66,65 @@ export = { }); }, ], + deleteUserFromProject: [ + isLicensed('feat:projectRole:admin'), + globalScope('project:update'), + async (req: DeleteUser, res: Response) => { + const { projectId, id: userId } = req.params; + + const project = await Container.get(ProjectRepository).findOne({ + where: { id: projectId }, + relations: { projectRelations: true }, + }); + + if (!project) { + return res.status(404).send({ message: 'Not found' }); + } + + const relations = project.projectRelations.filter((relation) => relation.userId !== userId); + + await Container.get(ProjectController).syncProjectRelations(projectId, relations); + + 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.id.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.id.yml new file mode 100644 index 0000000000000..d2bba47b95c09 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.id.yml @@ -0,0 +1,24 @@ +delete: + x-eov-operation-id: deleteUserFromProject + x-eov-operation-handler: v1/handlers/projects/projects.handler + tags: + - Projects + summary: Delete a user from a project + description: Delete a user from a project from your instance. + parameters: + - name: projectId + in: path + description: The ID of the project. + required: true + schema: + type: string + - $ref: '../../../users/spec/schemas/parameters/userIdentifier.yml' + responses: + '204': + 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/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 0000000000000..8114c829dd672 --- /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/handlers/projects/spec/paths/projects.projectId.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.yml index a5aab19b3d6ed..145c5e85ec7ca 100644 --- a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.yml +++ b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.yml @@ -6,7 +6,12 @@ delete: summary: Delete a project description: Delete a project from your instance. parameters: - - $ref: '../schemas/parameters/projectId.yml' + - in: path + name: projectId + description: The ID of the project. + required: true + schema: + type: string responses: '204': description: Operation successful. @@ -20,9 +25,16 @@ put: x-eov-operation-id: updateProject x-eov-operation-handler: v1/handlers/projects/projects.handler tags: - - Project + - Projects summary: Update a project description: Update a project. + parameters: + - in: path + name: projectId + description: The ID of the project. + required: true + schema: + type: string requestBody: description: Updated project object. content: diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index 2608f349808f2..3f646dc2fbb21 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -82,6 +82,10 @@ 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: schemas: $ref: './shared/spec/schemas/_index.yml' diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 7afb1e1bd3c7b..3d27484fe79e4 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -563,6 +563,12 @@ export declare namespace ProjectRequest { { name?: string; relations?: ProjectRelationPayload[] } >; 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 f815d9d07bf65..49c7ed541dc17 100644 --- a/packages/cli/test/integration/public-api/projects.test.ts +++ b/packages/cli/test/integration/public-api/projects.test.ts @@ -1,8 +1,17 @@ import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; import { Telemetry } from '@/telemetry'; import { mockInstance } from '@test/mocking'; -import { createTeamProject, getProjectByNameOrFail } from '@test-integration/db/projects'; -import { createMemberWithApiKey, createOwnerWithApiKey } from '@test-integration/db/users'; +import { + createTeamProject, + getProjectByNameOrFail, + linkUserToProject, + getAllProjectRelations, +} from '@test-integration/db/projects'; +import { + createMemberWithApiKey, + createOwnerWithApiKey, + createMember, +} from '@test-integration/db/users'; import { setupTestServer } from '@test-integration/utils'; import * as testDb from '../shared/test-db'; @@ -393,4 +402,284 @@ describe('Projects in Public API', () => { expect(response.body).toHaveProperty('message', 'Forbidden'); }); }); + + describe('DELETE /projects/:id/users/:userId', () => { + it('if not authenticated, should reject with 401', async () => { + const project = await createTeamProject(); + const member = await createMember(); + + const response = await testServer + .publicApiAgentWithoutApiKey() + .delete(`/projects/${project.id}/users/${member.id}`); + + 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 response = await testServer + .publicApiAgentFor(owner) + .delete(`/projects/${project.id}/users/${member.id}`); + + 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 response = await testServer + .publicApiAgentFor(member) + .delete(`/projects/${project.id}/users/${member.id}`); + + 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 remove given user from project', async () => { + const owner = await createOwnerWithApiKey(); + const project = await createTeamProject('shared-project', owner); + const member = await createMember(); + await linkUserToProject(member, project, 'project:viewer'); + const projectBefore = await getAllProjectRelations({ + projectId: project.id, + }); + + const response = await testServer + .publicApiAgentFor(owner) + .delete(`/projects/${project.id}/users/${member.id}`); + + const projectAfter = await getAllProjectRelations({ + projectId: project.id, + }); + + expect(response.status).toBe(204); + expect(projectBefore.length).toEqual(2); + expect(projectBefore[0].userId).toEqual(owner.id); + expect(projectBefore[1].userId).toEqual(member.id); + + expect(projectAfter.length).toEqual(1); + expect(projectAfter[0].userId).toEqual(owner.id); + }); + + it('should reject with 404 if no project found', async () => { + const owner = await createOwnerWithApiKey(); + const member = await createMember(); + + const response = await testServer + .publicApiAgentFor(owner) + .delete(`/projects/123456/users/${member.id}`); + + expect(response.status).toBe(404); + expect(response.body).toHaveProperty('message', 'Not found'); + }); + + it('should remain unchanged if user if not in project', async () => { + const owner = await createOwnerWithApiKey(); + const project = await createTeamProject('shared-project', owner); + const member = await createMember(); + const projectBefore = await getAllProjectRelations({ + projectId: project.id, + }); + + const response = await testServer + .publicApiAgentFor(owner) + .delete(`/projects/${project.id}/users/${member.id}`); + + const projectAfter = await getAllProjectRelations({ + projectId: project.id, + }); + + expect(response.status).toBe(204); + expect(projectBefore.length).toEqual(1); + 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".', + ); + }); + }); + }); }); diff --git a/packages/cli/test/integration/shared/db/projects.ts b/packages/cli/test/integration/shared/db/projects.ts index 93310d5a99b8e..2fc8b1eb5792d 100644 --- a/packages/cli/test/integration/shared/db/projects.ts +++ b/packages/cli/test/integration/shared/db/projects.ts @@ -66,3 +66,11 @@ export const getProjectRelations = async ({ where: { projectId, userId, role }, }); }; + +export const getAllProjectRelations = async ({ + projectId, +}: Partial): Promise => { + return await Container.get(ProjectRelationRepository).find({ + where: { projectId }, + }); +};