From 6144aea23ea66cdc1fa7e29080e349d299154933 Mon Sep 17 00:00:00 2001 From: rajdip-b Date: Mon, 5 Feb 2024 20:49:16 +0530 Subject: [PATCH] feat(api)!: update workspace role mechanism and added functionality to create custom roles --- apps/api/project.json | 3 +- apps/api/src/api-key/api-key.e2e.spec.ts | 158 +++ .../api-key/controller/api-key.controller.ts | 25 +- .../dto/create.api-key/create.api-key.ts | 11 +- .../src/api-key/service/api-key.service.ts | 238 ++--- apps/api/src/app/app.module.ts | 4 +- apps/api/src/app/e2e.setup.ts | 1 - apps/api/src/auth/service/auth.service.ts | 43 +- apps/api/src/common/api-key-roles.ts | 27 - apps/api/src/common/create-user.ts | 22 + .../get-collective-project-authorities.ts | 52 + .../get-collective-workspace-authorities.ts | 40 + .../common/get-environment-with-authority.ts | 44 + apps/api/src/common/get-permitted.roles.ts | 18 - .../src/common/get-project-with-authority.ts | 42 + .../common/get-workspace-with-authority.ts | 39 + apps/api/src/common/mock-data/workspaces.ts | 3 - .../service/environment.service.ts | 220 ++-- .../src/mail/services/interface.service.ts | 4 - apps/api/src/mail/services/mail.service.ts | 12 +- apps/api/src/mail/services/mock.service.ts | 7 +- .../20240205042913_migrate_role/migration.sql | 95 ++ .../migrations/20240205044521_/migration.sql | 8 + .../migrations/20240205044826_/migration.sql | 26 + .../migrations/20240205100104_/migration.sql | 1 + .../migrations/20240205101804_/migration.sql | 2 + apps/api/src/prisma/schema.prisma | 122 ++- .../controller/project.controller.spec.ts | 4 +- apps/api/src/project/project.module.ts | 3 +- .../project/service/project.service.spec.ts | 4 +- .../src/project/service/project.service.ts | 227 ++--- apps/api/src/secret/service/secret.service.ts | 171 +--- apps/api/src/user/service/user.service.ts | 52 +- apps/api/src/user/user.e2e.spec.ts | 53 +- .../workspace-role.controller.spec.ts | 27 + .../controller/workspace-role.controller.ts | 124 +++ .../create-workspace-role.spec.ts | 7 + .../create-workspace-role.ts | 23 + .../update-workspace-role.spec.ts | 7 + .../update-workspace-role.ts | 4 + .../service/workspace-role.service.spec.ts | 25 + .../service/workspace-role.service.ts | 315 ++++++ .../workspace-role/workspace-role.e2e.spec.ts | 941 ++++++++++++++++++ .../workspace-role/workspace-role.module.ts | 9 + .../controller/workspace.controller.spec.ts | 2 - .../controller/workspace.controller.ts | 33 +- .../dto/create.workspace/create.workspace.ts | 8 +- .../misc/workspace.permission.spec.ts | 19 - .../workspace/misc/workspace.permission.ts | 90 -- .../service/workspace.service.spec.ts | 193 +--- .../workspace/service/workspace.service.ts | 476 +++++---- apps/api/src/workspace/workspace.module.ts | 6 +- package.json | 4 +- pnpm-lock.yaml | 10 + 54 files changed, 2749 insertions(+), 1355 deletions(-) create mode 100644 apps/api/src/api-key/api-key.e2e.spec.ts delete mode 100644 apps/api/src/common/api-key-roles.ts create mode 100644 apps/api/src/common/create-user.ts create mode 100644 apps/api/src/common/get-collective-project-authorities.ts create mode 100644 apps/api/src/common/get-collective-workspace-authorities.ts create mode 100644 apps/api/src/common/get-environment-with-authority.ts delete mode 100644 apps/api/src/common/get-permitted.roles.ts create mode 100644 apps/api/src/common/get-project-with-authority.ts create mode 100644 apps/api/src/common/get-workspace-with-authority.ts create mode 100644 apps/api/src/prisma/migrations/20240205042913_migrate_role/migration.sql create mode 100644 apps/api/src/prisma/migrations/20240205044521_/migration.sql create mode 100644 apps/api/src/prisma/migrations/20240205044826_/migration.sql create mode 100644 apps/api/src/prisma/migrations/20240205100104_/migration.sql create mode 100644 apps/api/src/prisma/migrations/20240205101804_/migration.sql create mode 100644 apps/api/src/workspace-role/controller/workspace-role.controller.spec.ts create mode 100644 apps/api/src/workspace-role/controller/workspace-role.controller.ts create mode 100644 apps/api/src/workspace-role/dto/create-workspace-role/create-workspace-role.spec.ts create mode 100644 apps/api/src/workspace-role/dto/create-workspace-role/create-workspace-role.ts create mode 100644 apps/api/src/workspace-role/dto/update-workspace-role/update-workspace-role.spec.ts create mode 100644 apps/api/src/workspace-role/dto/update-workspace-role/update-workspace-role.ts create mode 100644 apps/api/src/workspace-role/service/workspace-role.service.spec.ts create mode 100644 apps/api/src/workspace-role/service/workspace-role.service.ts create mode 100644 apps/api/src/workspace-role/workspace-role.e2e.spec.ts create mode 100644 apps/api/src/workspace-role/workspace-role.module.ts delete mode 100644 apps/api/src/workspace/misc/workspace.permission.spec.ts delete mode 100644 apps/api/src/workspace/misc/workspace.permission.ts diff --git a/apps/api/project.json b/apps/api/project.json index d0ba8363..8b53f765 100644 --- a/apps/api/project.json +++ b/apps/api/project.json @@ -65,7 +65,8 @@ "outputs": ["{workspaceRoot}/coverage-e2e/{projectRoot}"], "options": { "devServerTarget": "api:serve", - "jestConfig": "apps/api/jest.e2e-config.ts" + "jestConfig": "apps/api/jest.e2e-config.ts", + "runInBand": true }, "configurations": { "development": { diff --git a/apps/api/src/api-key/api-key.e2e.spec.ts b/apps/api/src/api-key/api-key.e2e.spec.ts new file mode 100644 index 00000000..218fdfb6 --- /dev/null +++ b/apps/api/src/api-key/api-key.e2e.spec.ts @@ -0,0 +1,158 @@ +import { + FastifyAdapter, + NestFastifyApplication +} from '@nestjs/platform-fastify' +import { PrismaService } from '../prisma/prisma.service' +import { ApiKeyModule } from './api-key.module' +import { MAIL_SERVICE } from '../mail/services/interface.service' +import { MockMailService } from '../mail/services/mock.service' +import { AppModule } from '../app/app.module' +import { Test } from '@nestjs/testing' +import { ApiKey, User } from '@prisma/client' + +describe('Api Key Role Controller Tests', () => { + let app: NestFastifyApplication + let prisma: PrismaService + let user: User + let apiKey: ApiKey + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule, ApiKeyModule] + }) + .overrideProvider(MAIL_SERVICE) + .useClass(MockMailService) + .compile() + app = moduleRef.createNestApplication( + new FastifyAdapter() + ) + prisma = moduleRef.get(PrismaService) + + await app.init() + await app.getHttpAdapter().getInstance().ready() + + await prisma.apiKey.deleteMany() + await prisma.user.deleteMany() + + user = await prisma.user.create({ + data: { + email: 'john@keyshade.xyz', + name: 'John', + isActive: true, + isAdmin: false, + isOnboardingFinished: true + } + }) + }) + + it('should be able to create api key', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api-key', + payload: { + name: 'Test Key', + expiresAfter: '24' + }, + headers: { + 'x-e2e-user-email': user.email + } + }) + + expect(response.statusCode).toBe(201) + expect(response.json()).toEqual({ + id: expect.any(String), + name: 'Test Key', + value: expect.stringMatching(/^ks_*/), + expiresAt: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String) + }) + + apiKey = response.json() + }) + + it('should be able to update the api key', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/api-key/${apiKey.id}`, + payload: { + name: 'Updated Test Key', + expiresAfter: '168' + }, + headers: { + 'x-e2e-user-email': user.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + id: apiKey.id, + name: 'Updated Test Key', + expiresAt: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String) + }) + + apiKey = response.json() + }) + + it('should be able to get the api key', async () => { + const response = await app.inject({ + method: 'GET', + url: `/api-key/${apiKey.id}`, + headers: { + 'x-e2e-user-email': user.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + id: apiKey.id, + name: 'Updated Test Key', + expiresAt: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String) + }) + }) + + it('should be able to get all the api keys of the user', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api-key/all/as-user', + headers: { + 'x-e2e-user-email': user.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual([ + { + id: apiKey.id, + name: 'Updated Test Key', + expiresAt: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String) + } + ]) + }) + + it('should be able to delete the api key', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/api-key/${apiKey.id}`, + headers: { + 'x-e2e-user-email': user.email + } + }) + + expect(response.statusCode).toBe(200) + }) + + afterAll(async () => { + await prisma.apiKey.deleteMany() + await prisma.user.deleteMany() + + await prisma.$disconnect() + await app.close() + }) +}) diff --git a/apps/api/src/api-key/controller/api-key.controller.ts b/apps/api/src/api-key/controller/api-key.controller.ts index cc4ac67e..7fd314e1 100644 --- a/apps/api/src/api-key/controller/api-key.controller.ts +++ b/apps/api/src/api-key/controller/api-key.controller.ts @@ -6,23 +6,20 @@ import { Param, Post, Put, - Query + Query, + UseGuards } from '@nestjs/common' import { ApiKeyService } from '../service/api-key.service' import { User } from '@prisma/client' import { CurrentUser } from '../../decorators/user.decorator' import { CreateApiKey } from '../dto/create.api-key/create.api-key' import { UpdateApiKey } from '../dto/update.api-key/update.api-key' +import { AdminGuard } from '../../auth/guard/admin.guard' @Controller('api-key') export class ApiKeyController { constructor(private readonly apiKeyService: ApiKeyService) {} - @Get('permissable-scopes-of-workspaces') - async getPermissableScopesOfWorkspaces(@CurrentUser() user: User) { - return this.apiKeyService.getPermissableScopesOfWorkspaces(user) - } - @Post() async createApiKey(@CurrentUser() user: User, @Body() dto: CreateApiKey) { return this.apiKeyService.createApiKey(user, dto) @@ -47,8 +44,8 @@ export class ApiKeyController { return this.apiKeyService.getApiKeyById(user, id) } - @Get() - async getApiKeys( + @Get('all/as-user') + async getApiKeysOfUser( @CurrentUser() user: User, @Query('page') page: number = 1, @Query('limit') limit: number = 10, @@ -65,4 +62,16 @@ export class ApiKeyController { search ) } + + @Get('all/as-admin') + @UseGuards(AdminGuard) + async getApiKeys( + @Query('page') page: number = 1, + @Query('limit') limit: number = 10, + @Query('sort') sort: string = 'name', + @Query('order') order: string = 'asc', + @Query('search') search: string = '' + ) { + return this.apiKeyService.getAllApiKeys(page, limit, sort, order, search) + } } diff --git a/apps/api/src/api-key/dto/create.api-key/create.api-key.ts b/apps/api/src/api-key/dto/create.api-key/create.api-key.ts index c16630a9..aa15f132 100644 --- a/apps/api/src/api-key/dto/create.api-key/create.api-key.ts +++ b/apps/api/src/api-key/dto/create.api-key/create.api-key.ts @@ -1,5 +1,5 @@ -import { ApiKey, ApiKeyGeneralRole, ApiKeyWorkspaceScope } from '@prisma/client' -import { IsArray, IsOptional, IsString } from 'class-validator' +import { ApiKey } from '@prisma/client' +import { IsString } from 'class-validator' export class CreateApiKey { @IsString() @@ -7,11 +7,4 @@ export class CreateApiKey { @IsString() expiresAfter: '24' | '168' | '720' | '8760' | 'never' = 'never' - - @IsArray() - generalRoles: ApiKeyGeneralRole[] - - @IsArray() - @IsOptional() - scopes: ApiKeyWorkspaceScope[] } diff --git a/apps/api/src/api-key/service/api-key.service.ts b/apps/api/src/api-key/service/api-key.service.ts index 730e284c..ca0abc0c 100644 --- a/apps/api/src/api-key/service/api-key.service.ts +++ b/apps/api/src/api-key/service/api-key.service.ts @@ -1,17 +1,11 @@ -import { - Injectable, - Logger, - NotFoundException, - UnauthorizedException -} from '@nestjs/common' -import { ApiKeyWorkspaceRole, User, WorkspaceRole } from '@prisma/client' +import { Injectable, Logger, NotFoundException } from '@nestjs/common' +import { User } from '@prisma/client' import { PrismaService } from '../../prisma/prisma.service' import { CreateApiKey } from '../dto/create.api-key/create.api-key' import { addHoursToDate } from '../../common/add-hours-to-date' import { generateApiKey } from '../../common/api-key-generator' import { toSHA256 } from '../../common/to-sha256' import { UpdateApiKey } from '../dto/update.api-key/update.api-key' -import { ApiKeyWorkspaceRoles } from '../../common/api-key-roles' @Injectable() export class ApiKeyService { @@ -19,83 +13,28 @@ export class ApiKeyService { constructor(private readonly prisma: PrismaService) {} - async getPermissableScopesOfWorkspaces(user: User) { - const workspaces = await this.prisma.workspaceMember.findMany({ - where: { - userId: user.id - }, - select: { - workspace: true, - role: true - } - }) - - function getWorkspaceScopes(role: WorkspaceRole) { - switch (role) { - case WorkspaceRole.VIEWER: - return [...ApiKeyWorkspaceRoles.VIEWER] - case WorkspaceRole.MAINTAINER: - return [ - ...ApiKeyWorkspaceRoles.VIEWER, - ...ApiKeyWorkspaceRoles.MAINTAINER - ] - case WorkspaceRole.OWNER: - return [ - ...ApiKeyWorkspaceRoles.VIEWER, - ...ApiKeyWorkspaceRoles.MAINTAINER, - ...ApiKeyWorkspaceRoles.OWNER - ] - default: - throw new Error(`Unknown user role ${role}`) - } - } - - return workspaces.map((workspace) => ({ - workspaceId: workspace.workspace.id, - roles: getWorkspaceScopes(workspace.role) - })) - } - async createApiKey(user: User, dto: CreateApiKey) { - // For each workspace scope, check if the user has the required roles to perform the action. - if (dto.scopes) { - await this.checkPermissionsOfWorkspaceScopes(user, dto.scopes) - } - const plainTextApiKey = generateApiKey() const hashedApiKey = toSHA256(plainTextApiKey) - const apiKey = dto.scopes - ? await this.prisma.apiKey.create({ - data: { - name: dto.name, - value: hashedApiKey, - expiresAt: addHoursToDate(dto.expiresAfter), - generalRoles: dto.generalRoles, - workspaceScopes: { - createMany: { - data: dto.scopes - } - }, - user: { - connect: { - id: user.id - } - } + const apiKey = await this.prisma.apiKey.create({ + data: { + name: dto.name, + value: hashedApiKey, + expiresAt: addHoursToDate(dto.expiresAfter), + user: { + connect: { + id: user.id } - }) - : await this.prisma.apiKey.create({ - data: { - name: dto.name, - value: hashedApiKey, - expiresAt: addHoursToDate(dto.expiresAfter), - generalRoles: dto.generalRoles, - user: { - connect: { - id: user.id - } - } - } - }) + } + }, + select: { + id: true, + expiresAt: true, + name: true, + createdAt: true, + updatedAt: true + } + }) this.logger.log(`User ${user.id} created API key ${apiKey.id}`) @@ -106,65 +45,26 @@ export class ApiKeyService { } async updateApiKey(user: User, apiKeyId: string, dto: UpdateApiKey) { - const apiKey = await this.prisma.apiKey.findUnique({ + const updatedApiKey = await this.prisma.apiKey.update({ where: { id: apiKeyId, userId: user.id }, - include: { - workspaceScopes: true + data: { + name: dto.name, + expiresAt: dto.expiresAfter + ? addHoursToDate(dto.expiresAfter) + : undefined + }, + select: { + id: true, + expiresAt: true, + name: true, + createdAt: true, + updatedAt: true } }) - if (!apiKey) { - throw new NotFoundException( - `User ${user.id} is not authorized to update API key ${apiKeyId}` - ) - } - - if (apiKey.workspaceScopes) { - await this.checkPermissionsOfWorkspaceScopes(user, apiKey.workspaceScopes) - } - - const updatedApiKey = apiKey.workspaceScopes - ? await this.prisma.apiKey.update({ - where: { - id: apiKeyId - }, - data: { - name: dto.name, - expiresAt: addHoursToDate(dto.expiresAfter), - generalRoles: dto.generalRoles, - workspaceScopes: { - deleteMany: { - workspaceId: { - in: dto.scopes.map((scope) => scope.workspaceId) - } - }, - createMany: { - data: dto.scopes - } - } - } - }) - : await this.prisma.apiKey.update({ - where: { - id: apiKeyId - }, - data: { - name: dto.name, - expiresAt: addHoursToDate(dto.expiresAfter), - generalRoles: dto.generalRoles - }, - select: { - id: true, - expiresAt: true, - name: true, - generalRoles: true, - workspaceScopes: true - } - }) - this.logger.log(`User ${user.id} updated API key ${apiKeyId}`) return updatedApiKey @@ -189,8 +89,8 @@ export class ApiKeyService { id: true, expiresAt: true, name: true, - generalRoles: true, - workspaceScopes: true + createdAt: true, + updatedAt: true } }) @@ -226,54 +126,38 @@ export class ApiKeyService { select: { id: true, expiresAt: true, - name: true + name: true, + createdAt: true, + updatedAt: true } }) } - private async checkPermissionsOfWorkspaceScopes( - user: User, - workspaceScopes: { workspaceId: string; roles: ApiKeyWorkspaceRole[] }[] + async getAllApiKeys( + page: number, + limit: number, + sort: string, + order: string, + search: string ) { - for (const apiKeyScope of workspaceScopes) { - const membership = await this.prisma.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - workspaceId: apiKeyScope.workspaceId, - userId: user.id - } - } - }) - - if (!membership) { - throw new UnauthorizedException( - `User ${user.id} is not a member of workspace ${apiKeyScope.workspaceId}` - ) - } - - if ( - ApiKeyWorkspaceRoles.OWNER.some( - (role) => !membership.role.includes(role) - ) - ) { - if (membership.role !== WorkspaceRole.OWNER) { - throw new UnauthorizedException( - `User ${user.id} is not an owner of workspace ${apiKeyScope.workspaceId}` - ) - } - } - - if ( - ApiKeyWorkspaceRoles.MAINTAINER.some( - (role) => !membership.role.includes(role) - ) - ) { - if (membership.role !== WorkspaceRole.MAINTAINER) { - throw new UnauthorizedException( - `User ${user.id} is not a maintainer of workspace ${apiKeyScope.workspaceId}` - ) + return await this.prisma.apiKey.findMany({ + where: { + name: { + contains: search } + }, + skip: (page - 1) * limit, + take: limit, + orderBy: { + [sort]: order + }, + select: { + id: true, + expiresAt: true, + name: true, + createdAt: true, + updatedAt: true } - } + }) } } diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 6bc04ad4..5f7bb5a4 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -14,6 +14,7 @@ import { ProjectModule } from '../project/project.module' import { EnvironmentModule } from '../environment/environment.module' import { ApiKeyModule } from '../api-key/api-key.module' import { WorkspaceModule } from '../workspace/workspace.module' +import { WorkspaceRoleModule } from '../workspace-role/workspace-role.module' @Module({ controllers: [AppController], @@ -31,7 +32,8 @@ import { WorkspaceModule } from '../workspace/workspace.module' UserModule, ProjectModule, EnvironmentModule, - WorkspaceModule + WorkspaceModule, + WorkspaceRoleModule ], providers: [ { diff --git a/apps/api/src/app/e2e.setup.ts b/apps/api/src/app/e2e.setup.ts index fcc6bbc5..5b797c9b 100644 --- a/apps/api/src/app/e2e.setup.ts +++ b/apps/api/src/app/e2e.setup.ts @@ -44,7 +44,6 @@ export class E2ESetup implements OnModuleInit { data: { name: `My Workspace`, description: 'My default workspace', - isDefault: true, ownerId: regularUser.id, lastUpdatedBy: { connect: { diff --git a/apps/api/src/auth/service/auth.service.ts b/apps/api/src/auth/service/auth.service.ts index 3f812fa0..9a25d471 100644 --- a/apps/api/src/auth/service/auth.service.ts +++ b/apps/api/src/auth/service/auth.service.ts @@ -15,7 +15,7 @@ import { MAIL_SERVICE } from '../../mail/services/interface.service' import { PrismaService } from '../../prisma/prisma.service' -import { WorkspaceRole } from '@prisma/client' +import createUser from '../../common/create-user' @Injectable() export class AuthService { @@ -153,11 +153,10 @@ export class AuthService { name?: string, profilePictureUrl?: string ) { - const user = await this.findUserByEmail(email) - + let user = await this.findUserByEmail(email) // We need to create the user if it doesn't exist yet if (!user) { - await this.createUser(email, name, profilePictureUrl) + user = await this.createUser(email, name, profilePictureUrl) } return user } @@ -168,39 +167,15 @@ export class AuthService { profilePictureUrl: string ) { // Create the user - const user = await this.prisma.user.create({ - data: { + const user = await createUser( + { email, name, profilePictureUrl - } - }) - - // Create the user's default workspace - await this.prisma.workspace.create({ - data: { - name: `My Workspace`, - description: 'My default workspace', - isDefault: true, - ownerId: user.id, - lastUpdatedBy: { - connect: { - id: user.id - } - }, - members: { - create: { - role: WorkspaceRole.OWNER, - invitationAccepted: true, - user: { - connect: { - id: user.id - } - } - } - } - } - }) + }, + this.prisma + ) + this.logger.log(`User created: ${email}`) return user } diff --git a/apps/api/src/common/api-key-roles.ts b/apps/api/src/common/api-key-roles.ts deleted file mode 100644 index e7f8d091..00000000 --- a/apps/api/src/common/api-key-roles.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ApiKeyWorkspaceRole, WorkspaceRole } from '@prisma/client' - -export const ApiKeyWorkspaceRoles: { - [key in WorkspaceRole]: ApiKeyWorkspaceRole[] -} = { - [WorkspaceRole.VIEWER]: [ - ApiKeyWorkspaceRole.READ_PROJECT, - ApiKeyWorkspaceRole.READ_SECRET, - ApiKeyWorkspaceRole.READ_ENVIRONMENT, - ApiKeyWorkspaceRole.READ_USERS - ], - [WorkspaceRole.MAINTAINER]: [ - ApiKeyWorkspaceRole.CREATE_SECRET, - ApiKeyWorkspaceRole.UPDATE_SECRET, - ApiKeyWorkspaceRole.DELETE_SECRET, - ApiKeyWorkspaceRole.CREATE_ENVIRONMENT, - ApiKeyWorkspaceRole.UPDATE_ENVIRONMENT, - ApiKeyWorkspaceRole.DELETE_ENVIRONMENT - ], - [WorkspaceRole.OWNER]: [ - ApiKeyWorkspaceRole.UPDATE_PROJECT, - ApiKeyWorkspaceRole.DELETE_PROJECT, - ApiKeyWorkspaceRole.ADD_USER, - ApiKeyWorkspaceRole.REMOVE_USER, - ApiKeyWorkspaceRole.UPDATE_USER_ROLE - ] -} diff --git a/apps/api/src/common/create-user.ts b/apps/api/src/common/create-user.ts new file mode 100644 index 00000000..c73b4fd5 --- /dev/null +++ b/apps/api/src/common/create-user.ts @@ -0,0 +1,22 @@ +import { User } from '@prisma/client' +import { PrismaService } from '../prisma/prisma.service' +import { CreateUserDto } from '../user/dto/create.user/create.user' + +const createUser = async ( + dto: Partial, + prisma: PrismaService +): Promise => { + // Create the user + return await prisma.user.create({ + data: { + email: dto.email, + name: dto.name, + profilePictureUrl: dto.profilePictureUrl, + isActive: dto.isActive ?? true, + isAdmin: dto.isAdmin ?? false, + isOnboardingFinished: dto.isOnboardingFinished ?? false + } + }) +} + +export default createUser diff --git a/apps/api/src/common/get-collective-project-authorities.ts b/apps/api/src/common/get-collective-project-authorities.ts new file mode 100644 index 00000000..c79ad6aa --- /dev/null +++ b/apps/api/src/common/get-collective-project-authorities.ts @@ -0,0 +1,52 @@ +import { Authority, PrismaClient, Project, User } from '@prisma/client' + +/** + * Given the userId and project, this function returns the set of authorities + * that are formed by accumulating a set of all the authorities across all the + * roles that the user has in the workspace, adding an extra layer of filtering + * by the project. + * @param userId The id of the user + * @param project The project + * @param prisma The prisma client + * @returns + */ +export default async function getCollectiveProjectAuthorities( + userId: User['id'], + project: Project, + prisma: PrismaClient +): Promise> { + const authorities = new Set() + + const roleAssociations = await prisma.workspaceMemberRoleAssociation.findMany( + { + where: { + workspaceMember: { + userId, + workspaceId: project.workspaceId + }, + role: { + projects: { + some: { + id: project.id + } + } + } + }, + include: { + role: { + select: { + authorities: true + } + } + } + } + ) + + roleAssociations.forEach((roleAssociation) => { + roleAssociation.role.authorities.forEach((authority) => { + authorities.add(authority) + }) + }) + + return authorities +} diff --git a/apps/api/src/common/get-collective-workspace-authorities.ts b/apps/api/src/common/get-collective-workspace-authorities.ts new file mode 100644 index 00000000..50ee908e --- /dev/null +++ b/apps/api/src/common/get-collective-workspace-authorities.ts @@ -0,0 +1,40 @@ +import { Authority, PrismaClient, User, Workspace } from '@prisma/client' + +/** + * Given the userId and workspaceId, this function returns the set of authorities + * that are formed by accumulating a set of all the authorities across all the + * roles that the user has in the workspace. + * @param workspaceId The id of the workspace + * @param userId The id of the user + * @param prisma The prisma client + */ +const getCollectiveWorkspaceAuthorities = async ( + workspaceId: Workspace['id'], + userId: User['id'], + prisma: PrismaClient +): Promise> => { + const authorities = new Set() + + const roleAssociations = await prisma.workspaceMemberRoleAssociation.findMany( + { + where: { + workspaceMember: { + userId, + workspaceId + } + }, + include: { + role: true + } + } + ) + roleAssociations.forEach((roleAssociation) => { + roleAssociation.role.authorities.forEach((authority) => { + authorities.add(authority) + }) + }) + + return authorities +} + +export default getCollectiveWorkspaceAuthorities diff --git a/apps/api/src/common/get-environment-with-authority.ts b/apps/api/src/common/get-environment-with-authority.ts new file mode 100644 index 00000000..ab057314 --- /dev/null +++ b/apps/api/src/common/get-environment-with-authority.ts @@ -0,0 +1,44 @@ +import { ConflictException, NotFoundException } from '@nestjs/common' +import { Authority, Environment, PrismaClient, User } from '@prisma/client' +import getCollectiveProjectAuthorities from './get-collective-project-authorities' + +export default async function getEnvironmentWithAuthority( + userId: User['id'], + environmentId: Environment['id'], + authority: Authority, + prisma: PrismaClient +): Promise { + // Fetch the environment + const environment = await prisma.environment.findUnique({ + where: { + id: environmentId + }, + include: { + project: true + } + }) + + if (!environment) { + throw new NotFoundException( + `Environment with id ${environmentId} not found` + ) + } + + const permittedAuthorities = await getCollectiveProjectAuthorities( + userId, + environment.project, + prisma + ) + + // Check if the user has the required authorities + if ( + !permittedAuthorities.has(authority) && + !permittedAuthorities.has(Authority.WORKSPACE_ADMIN) + ) { + throw new ConflictException( + `User ${userId} does not have the required authorities` + ) + } + + return environment +} diff --git a/apps/api/src/common/get-permitted.roles.ts b/apps/api/src/common/get-permitted.roles.ts deleted file mode 100644 index 8ed28fd4..00000000 --- a/apps/api/src/common/get-permitted.roles.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { WorkspaceRole } from '@prisma/client' - -const permittedRoles = (role: WorkspaceRole) => { - switch (role) { - case WorkspaceRole.OWNER: - return [ - WorkspaceRole.OWNER, - WorkspaceRole.MAINTAINER, - WorkspaceRole.VIEWER - ] - case WorkspaceRole.MAINTAINER: - return [WorkspaceRole.MAINTAINER, WorkspaceRole.VIEWER] - case WorkspaceRole.VIEWER: - return [WorkspaceRole.VIEWER] - } -} - -export default permittedRoles diff --git a/apps/api/src/common/get-project-with-authority.ts b/apps/api/src/common/get-project-with-authority.ts new file mode 100644 index 00000000..be79e03f --- /dev/null +++ b/apps/api/src/common/get-project-with-authority.ts @@ -0,0 +1,42 @@ +import { NotFoundException } from '@nestjs/common' +import { Authority, PrismaClient, Project, User } from '@prisma/client' +import getCollectiveProjectAuthorities from './get-collective-project-authorities' +import { ProjectWithSecrets } from '../project/project.types' + +export default async function getProjectWithAuthority( + userId: User['id'], + projectId: Project['id'], + authority: Authority, + prisma: PrismaClient +): Promise { + // Fetch the project + const project = await prisma.project.findUnique({ + where: { + id: projectId + }, + include: { + secrets: true + } + }) + + if (!project) { + throw new NotFoundException(`Project with id ${projectId} not found`) + } + + const permittedAuthorities = await getCollectiveProjectAuthorities( + userId, + project, + prisma + ) + + if ( + !permittedAuthorities.has(authority) && + !permittedAuthorities.has(Authority.WORKSPACE_ADMIN) + ) { + throw new NotFoundException( + `User with id ${userId} does not have the authority ${authority} in the project with id ${projectId}` + ) + } + + return project +} diff --git a/apps/api/src/common/get-workspace-with-authority.ts b/apps/api/src/common/get-workspace-with-authority.ts new file mode 100644 index 00000000..8b71302a --- /dev/null +++ b/apps/api/src/common/get-workspace-with-authority.ts @@ -0,0 +1,39 @@ +import { Authority, PrismaClient, User, Workspace } from '@prisma/client' +import getCollectiveWorkspaceAuthorities from './get-collective-workspace-authorities' +import { NotFoundException, UnauthorizedException } from '@nestjs/common' + +export default async function getWorkspaceWithAuthority( + userId: User['id'], + workspaceId: Workspace['id'], + authority: Authority, + prisma: PrismaClient +): Promise { + const workspace = await prisma.workspace.findUnique({ + where: { + id: workspaceId + } + }) + + // Check if the workspace exists or not + if (!workspace) { + throw new NotFoundException(`Workspace with id ${workspaceId} not found`) + } + + const permittedAuthorities = await getCollectiveWorkspaceAuthorities( + workspaceId, + userId, + prisma + ) + + // Check if the user has the authority to perform the action + if ( + !permittedAuthorities.has(authority) && + !permittedAuthorities.has(Authority.WORKSPACE_ADMIN) + ) { + throw new UnauthorizedException( + `User ${userId} does not have the required authorities to perform the action` + ) + } + + return workspace +} diff --git a/apps/api/src/common/mock-data/workspaces.ts b/apps/api/src/common/mock-data/workspaces.ts index 5084d45b..45d08e8a 100644 --- a/apps/api/src/common/mock-data/workspaces.ts +++ b/apps/api/src/common/mock-data/workspaces.ts @@ -6,7 +6,6 @@ export const workspaces: Workspace[] = [ name: 'Workspace 1', description: 'This is workspace 1', isFreeTier: true, - isDefault: false, createdAt: new Date('2022-01-01T00:00:00Z'), updatedAt: new Date('2022-01-01T00:00:00Z'), lastUpdatedById: '1', @@ -17,7 +16,6 @@ export const workspaces: Workspace[] = [ name: 'Workspace 2', description: null, isFreeTier: false, - isDefault: true, createdAt: new Date('2022-01-02T00:00:00Z'), updatedAt: new Date('2022-01-02T00:00:00Z'), lastUpdatedById: '1', @@ -28,7 +26,6 @@ export const workspaces: Workspace[] = [ name: 'Workspace 3', description: 'This is workspace 3', isFreeTier: true, - isDefault: false, createdAt: new Date('2022-01-03T00:00:00Z'), updatedAt: new Date('2022-01-03T00:00:00Z'), lastUpdatedById: '1', diff --git a/apps/api/src/environment/service/environment.service.ts b/apps/api/src/environment/service/environment.service.ts index 382ae79d..bd38a3bf 100644 --- a/apps/api/src/environment/service/environment.service.ts +++ b/apps/api/src/environment/service/environment.service.ts @@ -1,13 +1,10 @@ -import { - ConflictException, - Injectable, - NotFoundException, - UnauthorizedException -} from '@nestjs/common' -import { Environment, Project, User, WorkspaceRole } from '@prisma/client' +import { ConflictException, Injectable } from '@nestjs/common' +import { Authority, Environment, Project, User } from '@prisma/client' import { CreateEnvironment } from '../dto/create.environment/create.environment' import { UpdateEnvironment } from '../dto/update.environment/update.environment' import { PrismaService } from '../../prisma/prisma.service' +import getProjectWithAuthority from '../../common/get-project-with-authority' +import getEnvironmentWithAuthority from '../../common/get-environment-with-authority' @Injectable() export class EnvironmentService { @@ -19,7 +16,12 @@ export class EnvironmentService { projectId: Project['id'] ) { // Check if the user has the required role to create an environment - await this.getProjectWithRole(user.id, projectId, WorkspaceRole.MAINTAINER) + await getProjectWithAuthority( + user.id, + projectId, + Authority.CREATE_ENVIRONMENT, + this.prisma + ) // Check if an environment with the same name already exists if (await this.environmentExists(dto.name, projectId)) { @@ -28,28 +30,35 @@ export class EnvironmentService { // If the current environment needs to be the default one, we will // need to update the existing default environment to be a regular one + const ops = [] + if (dto.isDefault) { - await this.makeAllNonDefault(projectId) + ops.push(this.makeAllNonDefault(projectId)) } // Create the environment - return await this.prisma.environment.create({ - data: { - name: dto.name, - description: dto.description, - isDefault: dto.isDefault, - project: { - connect: { - id: projectId - } - }, - lastUpdatedBy: { - connect: { - id: user.id + ops.unshift( + this.prisma.environment.create({ + data: { + name: dto.name, + description: dto.description, + isDefault: dto.isDefault, + project: { + connect: { + id: projectId + } + }, + lastUpdatedBy: { + connect: { + id: user.id + } } } - } - }) + }) + ) + + const result = await this.prisma.$transaction(ops) + return result[0] } async updateEnvironment( @@ -57,10 +66,11 @@ export class EnvironmentService { dto: UpdateEnvironment, environmentId: Environment['id'] ) { - const environment = await this.getEnvironmentWithRole( + const environment = await getEnvironmentWithAuthority( user.id, environmentId, - WorkspaceRole.MAINTAINER + Authority.UPDATE_ENVIRONMENT, + this.prisma ) // Check if an environment with the same name already exists @@ -72,35 +82,43 @@ export class EnvironmentService { throw new ConflictException('Environment already exists') } + const ops = [] + // If the current environment needs to be the default one, we will // need to update the existing default environment to be a regular one if (dto.isDefault) { - await this.makeAllNonDefault(environment.projectId) + ops.push(this.makeAllNonDefault(environment.projectId)) } // Update the environment - return await this.prisma.environment.update({ - where: { - id: environmentId - }, - data: { - name: dto.name, - description: dto.description, - isDefault: dto.isDefault, - lastUpdatedById: user.id - }, - include: { - secrets: true, - lastUpdatedBy: true - } - }) + ops.unshift( + this.prisma.environment.update({ + where: { + id: environmentId + }, + data: { + name: dto.name, + description: dto.description, + isDefault: dto.isDefault, + lastUpdatedById: user.id + }, + include: { + secrets: true, + lastUpdatedBy: true + } + }) + ) + + const result = await this.prisma.$transaction(ops) + return result[0] } async getEnvironment(user: User, environmentId: Environment['id']) { - const environment = await this.getEnvironmentWithRole( + const environment = await getEnvironmentWithAuthority( user.id, environmentId, - WorkspaceRole.VIEWER + Authority.READ_ENVIRONMENT, + this.prisma ) return environment @@ -115,7 +133,12 @@ export class EnvironmentService { order: string, search: string ) { - await this.getProjectWithRole(user.id, projectId, WorkspaceRole.VIEWER) + await getProjectWithAuthority( + user.id, + projectId, + Authority.READ_ENVIRONMENT, + this.prisma + ) // Get the environments return await this.prisma.environment.findMany({ @@ -162,10 +185,11 @@ export class EnvironmentService { } async deleteEnvironment(user: User, environmentId: Environment['id']) { - const environment = await this.getEnvironmentWithRole( + const environment = await getEnvironmentWithAuthority( user.id, environmentId, - WorkspaceRole.MAINTAINER + Authority.DELETE_ENVIRONMENT, + this.prisma ) const projectId = environment.projectId @@ -205,24 +229,8 @@ export class EnvironmentService { }) } - private async getByProjectIdAndId( - projectId: Project['id'], - environmentId: Environment['id'] - ) { - return await this.prisma.environment.findFirst({ - where: { - id: environmentId, - projectId - }, - include: { - secrets: true, - lastUpdatedBy: true - } - }) - } - private async makeAllNonDefault(projectId: Project['id']): Promise { - await this.prisma.environment.updateMany({ + this.prisma.environment.updateMany({ where: { projectId }, @@ -231,88 +239,4 @@ export class EnvironmentService { } }) } - - private async getEnvironmentWithRole( - userId: User['id'], - environmentId: Environment['id'], - role: WorkspaceRole - ): Promise { - // Fetch the environment - const environment = await this.prisma.environment.findUnique({ - where: { - id: environmentId - }, - include: { - project: { - include: { - workspace: { - include: { - members: true - } - } - } - } - } - }) - - if (!environment) { - throw new NotFoundException( - `Environment with id ${environmentId} not found` - ) - } - - // Check for the required membership role - if ( - !environment.project.workspace.members.some( - (member) => member.userId === userId && member.role === role - ) - ) - throw new UnauthorizedException( - `You don't have the required role to access this environment` - ) - - // Remove the workspace from the environment - environment.project.workspace = undefined - - return environment - } - - private async getProjectWithRole( - userId: User['id'], - projectId: Project['id'], - role: WorkspaceRole - ): Promise { - // Fetch the project - const project = await this.prisma.project.findUnique({ - where: { - id: projectId - }, - include: { - workspace: { - include: { - members: true - } - } - } - }) - - if (!project) { - throw new NotFoundException(`Project with id ${projectId} not found`) - } - - // Check for the required membership role - if ( - !project.workspace.members.some( - (member) => member.userId === userId && member.role === role - ) - ) - throw new UnauthorizedException( - `You don't have the required role to access this project` - ) - - // Remove the workspace from the project - project.workspace = undefined - - return project - } } diff --git a/apps/api/src/mail/services/interface.service.ts b/apps/api/src/mail/services/interface.service.ts index c952e838..11414444 100644 --- a/apps/api/src/mail/services/interface.service.ts +++ b/apps/api/src/mail/services/interface.service.ts @@ -1,5 +1,3 @@ -import { WorkspaceRole } from '@prisma/client' - export const MAIL_SERVICE = 'MAIL_SERVICE' export interface IMailService { @@ -10,11 +8,9 @@ export interface IMailService { workspace: string, actionUrl: string, invitedBy: string, - role: WorkspaceRole, forRegisteredUser: boolean ): Promise - accountLoginEmail(email: string): Promise adminUserCreateEmail(email: string): Promise diff --git a/apps/api/src/mail/services/mail.service.ts b/apps/api/src/mail/services/mail.service.ts index 5cda462f..b3664062 100644 --- a/apps/api/src/mail/services/mail.service.ts +++ b/apps/api/src/mail/services/mail.service.ts @@ -1,6 +1,5 @@ import { Injectable, Logger } from '@nestjs/common' import { IMailService } from './interface.service' -import { WorkspaceRole } from '@prisma/client' import { Transporter, createTransport } from 'nodemailer' @Injectable() @@ -24,13 +23,13 @@ export class MailService implements IMailService { workspace: string, actionUrl: string, invitee: string, - role: WorkspaceRole, forRegisteredUser: boolean ): Promise { const subject = `You have been invited to a ${workspace}` - const intro = forRegisteredUser? `Hello again! You've been invited to join a new workspace.`:`Hello there! We're excited to welcome you to Keyshade.` - const body = - ` + const intro = forRegisteredUser + ? `Hello again! You've been invited to join a new workspace.` + : `Hello there! We're excited to welcome you to Keyshade.` + const body = ` Workspace Invitation @@ -38,7 +37,7 @@ export class MailService implements IMailService {

Welcome to keyshade!

${intro}

-

You have been invited to join the workspace ${workspace} by ${invitee} as ${role.toString()}.

+

You have been invited to join the workspace ${workspace} by ${invitee}.

Please click on the link below to accept the invitation.

Accept Invitation

Thank you for choosing us.

@@ -49,7 +48,6 @@ export class MailService implements IMailService { await this.sendEmail(email, subject, body) } - async sendOtp(email: string, otp: string): Promise { const subject = 'Your Login OTP' const body = ` diff --git a/apps/api/src/mail/services/mock.service.ts b/apps/api/src/mail/services/mock.service.ts index 49878e09..122847ee 100644 --- a/apps/api/src/mail/services/mock.service.ts +++ b/apps/api/src/mail/services/mock.service.ts @@ -11,16 +11,15 @@ export class MockMailService implements IMailService { workspace: string, actionUrl: string, invitee: string, - role: WorkspaceRole, forRegisteredUser: boolean ): Promise { this.log.log( - forRegisteredUser? - `Workspace Invitation Mail for Registered User: ${email}, ${workspace}, ${actionUrl}, ${invitee}, ${role}`:`Workspace Invitation Mail for Non Registered User: ${email}, ${workspace}, ${actionUrl}, ${invitee}, ${role}` + forRegisteredUser + ? `Workspace Invitation Mail for Registered User: ${email}, ${workspace}, ${actionUrl}, ${invitee}` + : `Workspace Invitation Mail for Non Registered User: ${email}, ${workspace}, ${actionUrl}, ${invitee}` ) } - async adminUserCreateEmail(email: string): Promise { this.log.log(`Create pAdmin User Email: ${email}`) } diff --git a/apps/api/src/prisma/migrations/20240205042913_migrate_role/migration.sql b/apps/api/src/prisma/migrations/20240205042913_migrate_role/migration.sql new file mode 100644 index 00000000..adcfed8e --- /dev/null +++ b/apps/api/src/prisma/migrations/20240205042913_migrate_role/migration.sql @@ -0,0 +1,95 @@ +/* + Warnings: + + - You are about to drop the column `generalRoles` on the `ApiKey` table. All the data in the column will be lost. + - You are about to drop the column `isDefault` on the `Workspace` table. All the data in the column will be lost. + - You are about to drop the column `role` on the `WorkspaceMember` table. All the data in the column will be lost. + - You are about to drop the `ApiKeyWorkspaceScope` table. If the table is not empty, all the data it contains will be lost. + - A unique constraint covering the columns `[name,ownerId]` on the table `Workspace` will be added. If there are existing duplicate values, this will fail. + - Added the required column `updatedAt` to the `ApiKey` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "Authority" AS ENUM ('CREATE_PROJECT', 'READ_USERS', 'ADD_USER', 'REMOVE_USER', 'UPDATE_USER_ROLE', 'READ_WORKSPACE', 'UPDATE_WORKSPACE', 'DELETE_WORKSPACE', 'TRANSFER_OWNERSHIP', 'CREATE_WORKSPACE_ROLE', 'READ_WORKSPACE_ROLE', 'UPDATE_WORKSPACE_ROLE', 'DELETE_WORKSPACE_ROLE', 'WORKSPACE_ADMIN', 'READ_PROJECT', 'UPDATE_PROJECT', 'DELETE_PROJECT', 'CREATE_SECRET', 'READ_SECRET', 'UPDATE_SECRET', 'DELETE_SECRET', 'CREATE_ENVIRONMENT', 'READ_ENVIRONMENT', 'UPDATE_ENVIRONMENT', 'DELETE_ENVIRONMENT', 'CREATE_WORKSPACE', 'CREATE_API_KEY', 'READ_API_KEY', 'UPDATE_API_KEY', 'DELETE_API_KEY', 'UPDATE_PROFILE'); + +-- DropForeignKey +ALTER TABLE "ApiKeyWorkspaceScope" DROP CONSTRAINT "ApiKeyWorkspaceScope_apiKeyId_fkey"; + +-- DropForeignKey +ALTER TABLE "ApiKeyWorkspaceScope" DROP CONSTRAINT "ApiKeyWorkspaceScope_workspaceId_fkey"; + +-- DropIndex +DROP INDEX "Workspace_isDefault_ownerId_key"; + +-- AlterTable +ALTER TABLE "ApiKey" DROP COLUMN "generalRoles", +ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "apiKeyWorkspaceAuthorityId" TEXT, +ADD COLUMN "isPublic" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "workspaceRoleId" TEXT; + +-- AlterTable +ALTER TABLE "Workspace" DROP COLUMN "isDefault"; + +-- AlterTable +ALTER TABLE "WorkspaceMember" DROP COLUMN "role"; + +-- DropTable +DROP TABLE "ApiKeyWorkspaceScope"; + +-- DropEnum +DROP TYPE "ApiKeyGeneralRole"; + +-- DropEnum +DROP TYPE "ApiKeyWorkspaceRole"; + +-- DropEnum +DROP TYPE "WorkspaceRole"; + +-- CreateTable +CREATE TABLE "WorkspaceRole" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "colorCode" TEXT, + "hasAdminAuthority" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "authorities" "Authority"[], + "workspaceId" TEXT NOT NULL, + + CONSTRAINT "WorkspaceRole_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WorkspaceMemberRoleAssociation" ( + "id" TEXT NOT NULL, + "roleId" TEXT NOT NULL, + "workspaceMemberId" TEXT NOT NULL, + + CONSTRAINT "WorkspaceMemberRoleAssociation_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "WorkspaceRole_workspaceId_name_key" ON "WorkspaceRole"("workspaceId", "name"); + +-- CreateIndex +CREATE UNIQUE INDEX "WorkspaceMemberRoleAssociation_roleId_workspaceMemberId_key" ON "WorkspaceMemberRoleAssociation"("roleId", "workspaceMemberId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Workspace_name_ownerId_key" ON "Workspace"("name", "ownerId"); + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_workspaceRoleId_fkey" FOREIGN KEY ("workspaceRoleId") REFERENCES "WorkspaceRole"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WorkspaceRole" ADD CONSTRAINT "WorkspaceRole_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WorkspaceMemberRoleAssociation" ADD CONSTRAINT "WorkspaceMemberRoleAssociation_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "WorkspaceRole"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WorkspaceMemberRoleAssociation" ADD CONSTRAINT "WorkspaceMemberRoleAssociation_workspaceMemberId_fkey" FOREIGN KEY ("workspaceMemberId") REFERENCES "WorkspaceMember"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/src/prisma/migrations/20240205044521_/migration.sql b/apps/api/src/prisma/migrations/20240205044521_/migration.sql new file mode 100644 index 00000000..b5dcdbb8 --- /dev/null +++ b/apps/api/src/prisma/migrations/20240205044521_/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[workspaceId,hasAdminAuthority]` on the table `WorkspaceRole` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "WorkspaceRole_workspaceId_hasAdminAuthority_key" ON "WorkspaceRole"("workspaceId", "hasAdminAuthority"); diff --git a/apps/api/src/prisma/migrations/20240205044826_/migration.sql b/apps/api/src/prisma/migrations/20240205044826_/migration.sql new file mode 100644 index 00000000..df46517f --- /dev/null +++ b/apps/api/src/prisma/migrations/20240205044826_/migration.sql @@ -0,0 +1,26 @@ +-- DropForeignKey +ALTER TABLE "Project" DROP CONSTRAINT "Project_lastUpdatedById_fkey"; + +-- DropForeignKey +ALTER TABLE "Secret" DROP CONSTRAINT "Secret_lastUpdatedById_fkey"; + +-- DropForeignKey +ALTER TABLE "SecretVersion" DROP CONSTRAINT "SecretVersion_createdById_fkey"; + +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "lastUpdatedById" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "Secret" ALTER COLUMN "lastUpdatedById" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "SecretVersion" ALTER COLUMN "createdById" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_lastUpdatedById_fkey" FOREIGN KEY ("lastUpdatedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SecretVersion" ADD CONSTRAINT "SecretVersion_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Secret" ADD CONSTRAINT "Secret_lastUpdatedById_fkey" FOREIGN KEY ("lastUpdatedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/src/prisma/migrations/20240205100104_/migration.sql b/apps/api/src/prisma/migrations/20240205100104_/migration.sql new file mode 100644 index 00000000..af5102c8 --- /dev/null +++ b/apps/api/src/prisma/migrations/20240205100104_/migration.sql @@ -0,0 +1 @@ +-- This is an empty migration. \ No newline at end of file diff --git a/apps/api/src/prisma/migrations/20240205101804_/migration.sql b/apps/api/src/prisma/migrations/20240205101804_/migration.sql new file mode 100644 index 00000000..0365ee8b --- /dev/null +++ b/apps/api/src/prisma/migrations/20240205101804_/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX "WorkspaceRole_workspaceId_hasAdminAuthority_key"; diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma index c4a76057..7c0b1d50 100644 --- a/apps/api/src/prisma/schema.prisma +++ b/apps/api/src/prisma/schema.prisma @@ -7,14 +7,24 @@ datasource db { url = env("DATABASE_URL") } -enum WorkspaceRole { - OWNER // Can do everything - MAINTAINER // Can do everything except deleting the workspace - VIEWER // Can only view the workspace, projects in it and use its secrets -} - -enum ApiKeyWorkspaceRole { +enum Authority { + // Workspace authorities CREATE_PROJECT + READ_USERS + ADD_USER + REMOVE_USER + UPDATE_USER_ROLE + READ_WORKSPACE + UPDATE_WORKSPACE + DELETE_WORKSPACE + TRANSFER_OWNERSHIP + CREATE_WORKSPACE_ROLE + READ_WORKSPACE_ROLE + UPDATE_WORKSPACE_ROLE + DELETE_WORKSPACE_ROLE + WORKSPACE_ADMIN + + // Project authorities READ_PROJECT UPDATE_PROJECT DELETE_PROJECT @@ -22,21 +32,13 @@ enum ApiKeyWorkspaceRole { READ_SECRET UPDATE_SECRET DELETE_SECRET - READ_USERS - ADD_USER - REMOVE_USER - UPDATE_USER_ROLE CREATE_ENVIRONMENT READ_ENVIRONMENT UPDATE_ENVIRONMENT DELETE_ENVIRONMENT -} -enum ApiKeyGeneralRole { + // User authorities CREATE_WORKSPACE - READ_WORKSPACE - UPDATE_WORKSPACE - DELETE_WORKSPACE CREATE_API_KEY READ_API_KEY UPDATE_API_KEY @@ -130,38 +132,64 @@ model Project { privateKey String? // We store this only if the user wants us to do so! storePrivateKey Boolean @default(false) isDisabled Boolean @default(false) // This is set to true when the user stops his subscription and still has premium features in use + isPublic Boolean @default(false) - lastUpdatedBy User? @relation(fields: [lastUpdatedById], references: [id], onUpdate: Cascade) - lastUpdatedById String + lastUpdatedBy User? @relation(fields: [lastUpdatedById], references: [id], onUpdate: Cascade, onDelete: SetNull) + lastUpdatedById String? workspaceId String workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) - secrets Secret[] - environments Environment[] + secrets Secret[] + environments Environment[] + workspaceRole WorkspaceRole? @relation(fields: [workspaceRoleId], references: [id], onDelete: SetNull, onUpdate: Cascade) + workspaceRoleId String? + apiKeyWorkspaceAuthorityId String? } -model WorkspaceMember { - id String @id @default(cuid()) - role WorkspaceRole - user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) - userId String - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) - workspaceId String - invitationAccepted Boolean @default(false) +model WorkspaceRole { + id String @id @default(cuid()) + name String + description String? + colorCode String? + hasAdminAuthority Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - @@unique([workspaceId, userId]) -} - -model ApiKeyWorkspaceScope { - id String @id @default(cuid()) - roles ApiKeyWorkspaceRole[] + authorities Authority[] + workspaceMembers WorkspaceMemberRoleAssociation[] - apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade, onUpdate: Cascade) - apiKeyId String + projects Project[] workspaceId String workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + + @@unique([workspaceId, name]) +} + +model WorkspaceMemberRoleAssociation { + id String @id @default(cuid()) + + role WorkspaceRole @relation(fields: [roleId], references: [id], onDelete: Cascade, onUpdate: Cascade) + roleId String + + workspaceMember WorkspaceMember @relation(fields: [workspaceMemberId], references: [id], onDelete: Cascade, onUpdate: Cascade) + workspaceMemberId String + + @@unique([roleId, workspaceMemberId]) +} + +// This model stores the membership of a workspace-user and their roles. +model WorkspaceMember { + id String @id @default(cuid()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userId String + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + workspaceId String + invitationAccepted Boolean @default(false) + roles WorkspaceMemberRoleAssociation[] + + @@unique([workspaceId, userId]) } model SecretVersion { @@ -173,8 +201,8 @@ model SecretVersion { secret Secret @relation(fields: [secretId], references: [id], onDelete: Cascade, onUpdate: Cascade) createdOn DateTime @default(now()) - createdBy User? @relation(fields: [createdById], references: [id], onUpdate: Cascade) - createdById String + createdBy User? @relation(fields: [createdById], references: [id], onUpdate: Cascade, onDelete: SetNull) + createdById String? } model Secret { @@ -185,8 +213,8 @@ model Secret { updatedAt DateTime @updatedAt rotateAt DateTime? - lastUpdatedBy User @relation(fields: [lastUpdatedById], references: [id]) - lastUpdatedById String + lastUpdatedBy User? @relation(fields: [lastUpdatedById], references: [id], onUpdate: Cascade, onDelete: SetNull) + lastUpdatedById String? projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) @@ -200,9 +228,8 @@ model ApiKey { name String value String @unique expiresAt DateTime? - - generalRoles ApiKeyGeneralRole[] - workspaceScopes ApiKeyWorkspaceScope[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) userId String @@ -224,7 +251,6 @@ model Workspace { name String description String? isFreeTier Boolean @default(true) - isDefault Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt ownerId String @@ -232,9 +258,9 @@ model Workspace { lastUpdatedBy User? @relation(fields: [lastUpdatedById], references: [id], onUpdate: Cascade, onDelete: SetNull) lastUpdatedById String? - projects Project[] - members WorkspaceMember[] - apiKeyWorkspaceScope ApiKeyWorkspaceScope[] + projects Project[] + members WorkspaceMember[] + roles WorkspaceRole[] - @@unique([isDefault, ownerId], name: "defaultWorkspace") + @@unique([name, ownerId]) } diff --git a/apps/api/src/project/controller/project.controller.spec.ts b/apps/api/src/project/controller/project.controller.spec.ts index 263c06d6..85ee649f 100644 --- a/apps/api/src/project/controller/project.controller.spec.ts +++ b/apps/api/src/project/controller/project.controller.spec.ts @@ -5,7 +5,6 @@ import { MAIL_SERVICE } from '../../mail/services/interface.service' import { MockMailService } from '../../mail/services/mock.service' import { PrismaService } from '../../prisma/prisma.service' import { mockDeep } from 'jest-mock-extended' -import { WorkspacePermission } from '../../workspace/misc/workspace.permission' describe('ProjectController', () => { let controller: ProjectController @@ -16,8 +15,7 @@ describe('ProjectController', () => { providers: [ ProjectService, PrismaService, - { provide: MAIL_SERVICE, useClass: MockMailService }, - WorkspacePermission + { provide: MAIL_SERVICE, useClass: MockMailService } ] }) .overrideProvider(PrismaService) diff --git a/apps/api/src/project/project.module.ts b/apps/api/src/project/project.module.ts index 481d6db0..4737691a 100644 --- a/apps/api/src/project/project.module.ts +++ b/apps/api/src/project/project.module.ts @@ -4,10 +4,9 @@ import { ProjectController } from './controller/project.controller' import { EnvironmentModule } from '../environment/environment.module' import { UserModule } from '../user/user.module' import { SecretModule } from '../secret/secret.module' -import { WorkspacePermission } from '../workspace/misc/workspace.permission' @Module({ - providers: [ProjectService, WorkspacePermission], + providers: [ProjectService], controllers: [ProjectController], imports: [UserModule, EnvironmentModule, SecretModule] }) diff --git a/apps/api/src/project/service/project.service.spec.ts b/apps/api/src/project/service/project.service.spec.ts index 10721e30..fd3d08b0 100644 --- a/apps/api/src/project/service/project.service.spec.ts +++ b/apps/api/src/project/service/project.service.spec.ts @@ -4,7 +4,6 @@ import { MockMailService } from '../../mail/services/mock.service' import { MAIL_SERVICE } from '../../mail/services/interface.service' import { PrismaService } from '../../prisma/prisma.service' import { mockDeep } from 'jest-mock-extended' -import { WorkspacePermission } from '../../workspace/misc/workspace.permission' describe('ProjectService', () => { let service: ProjectService @@ -14,8 +13,7 @@ describe('ProjectService', () => { providers: [ ProjectService, PrismaService, - { provide: MAIL_SERVICE, useClass: MockMailService }, - WorkspacePermission + { provide: MAIL_SERVICE, useClass: MockMailService } ] }) .overrideProvider(PrismaService) diff --git a/apps/api/src/project/service/project.service.ts b/apps/api/src/project/service/project.service.ts index ad616d8d..bf870415 100644 --- a/apps/api/src/project/service/project.service.ts +++ b/apps/api/src/project/service/project.service.ts @@ -1,36 +1,27 @@ +import { ConflictException, Injectable, Logger } from '@nestjs/common' import { - ConflictException, - Injectable, - Logger, - NotFoundException, - UnauthorizedException -} from '@nestjs/common' -import { + Authority, Project, SecretVersion, User, - Workspace, - WorkspaceRole + Workspace } from '@prisma/client' import { CreateProject } from '../dto/create.project/create.project' import { UpdateProject } from '../dto/update.project/update.project' import { createKeyPair } from '../../common/create-key-pair' import { excludeFields } from '../../common/exclude-fields' -import { ProjectWithSecrets } from '../project.types' import { PrismaService } from '../../prisma/prisma.service' import { decrypt } from '../../common/decrypt' import { encrypt } from '../../common/encrypt' -import { WorkspacePermission } from '../../workspace/misc/workspace.permission' -import permittedRoles from '../../common/get-permitted.roles' +import getWorkspaceWithAuthority from '../../common/get-workspace-with-authority' +import getProjectWithAuthority from '../../common/get-project-with-authority' +import { v4 } from 'uuid' @Injectable() export class ProjectService { private readonly log: Logger = new Logger(ProjectService.name) - constructor( - private readonly prisma: PrismaService, - private readonly permission: WorkspacePermission - ) {} + constructor(private readonly prisma: PrismaService) {} async createProject( user: User, @@ -38,13 +29,12 @@ export class ProjectService { dto: CreateProject ): Promise { // Check if the workspace exists or not - const workspace = await this.getWorkspaceByUserIdAndId(user.id, workspaceId) - - if (!workspace) - throw new NotFoundException(`Workspace with id ${workspaceId} not found`) - - // Check if the user has the permission to create a project - await this.permission.isWorkspaceMaintainer(user, workspaceId) + await getWorkspaceWithAuthority( + user.id, + workspaceId, + Authority.CREATE_SECRET, + this.prisma + ) // Check if project with this name already exists for the user if (await this.projectExists(dto.name, workspaceId)) @@ -70,9 +60,19 @@ export class ProjectService { const userId = user.id + const newProjectId = v4() + + const adminRole = await this.prisma.workspaceRole.findFirst({ + where: { + workspaceId: workspaceId, + hasAdminAuthority: true + } + }) + // Create and return the project - const newProject = await this.prisma.project.create({ + const createNewProject = this.prisma.project.create({ data: { + id: newProjectId, name: data.name, description: data.description, publicKey: data.publicKey, @@ -91,6 +91,22 @@ export class ProjectService { } }) + const addProjectToAdminRoleOfItsWorkspace = + this.prisma.workspaceRole.update({ + where: { + id: adminRole.id + }, + data: { + projects: { + connect: { + id: newProjectId + } + } + } + }) + + const createEnvironmentOps = [] + // Create and assign the environments provided in the request, if any // or create a default environment if (dto.environments && dto.environments.length > 0) { @@ -101,40 +117,44 @@ export class ProjectService { 'will create default env: ', defaultEnvironmentExists === false ? environment.isDefault : false ) - const env = await this.prisma.environment.create({ - data: { - name: environment.name, - description: environment.description, - isDefault: - defaultEnvironmentExists === false - ? environment.isDefault - : false, - projectId: newProject.id, - lastUpdatedById: user.id - } - }) + createEnvironmentOps.push( + this.prisma.environment.create({ + data: { + name: environment.name, + description: environment.description, + isDefault: + defaultEnvironmentExists === false + ? environment.isDefault + : false, + projectId: newProjectId, + lastUpdatedById: user.id + } + }) + ) defaultEnvironmentExists = defaultEnvironmentExists || environment.isDefault - - this.log.debug(`Created environment ${env} for project ${newProject}`) } } else { - const defaultEnvironment = await this.prisma.environment.create({ - data: { - name: 'Default', - description: 'Default environment for the project', - isDefault: true, - projectId: newProject.id, - lastUpdatedById: user.id - } - }) - - this.log.debug( - `Created default environment ${defaultEnvironment} for project ${newProject}` + createEnvironmentOps.push( + this.prisma.environment.create({ + data: { + name: 'Default', + description: 'Default environment for the project', + isDefault: true, + projectId: newProjectId, + lastUpdatedById: user.id + } + }) ) } + const [newProject] = await this.prisma.$transaction([ + createNewProject, + addProjectToAdminRoleOfItsWorkspace, + ...createEnvironmentOps + ]) + this.log.debug(`Created project ${newProject}`) // It is important that we log before the private key is set // in order to not log the private key @@ -148,10 +168,11 @@ export class ProjectService { projectId: Project['id'], dto: UpdateProject ): Promise { - const project = await this.getProjectWithRole( + const project = await getProjectWithAuthority( user.id, projectId, - WorkspaceRole.MAINTAINER + Authority.UPDATE_PROJECT, + this.prisma ) // Check if project with this name already exists for the user @@ -170,6 +191,8 @@ export class ProjectService { privateKey: dto.storePrivateKey ? project.privateKey : null } + const versionUpdateOps = [] + let privateKey = undefined, publicKey = undefined // A new key pair can be generated only if: @@ -206,20 +229,22 @@ export class ProjectService { } for (const version of updatedVersions) { - await this.prisma.secretVersion.update({ - where: { - id: version.id - }, - data: { - value: version.value - } - }) + versionUpdateOps.push( + this.prisma.secretVersion.update({ + where: { + id: version.id + }, + data: { + value: version.value + } + }) + ) } } } // Update and return the project - const updatedProject = await this.prisma.project.update({ + const updateProjectOp = this.prisma.project.update({ where: { id: projectId }, @@ -229,6 +254,11 @@ export class ProjectService { } }) + const [updatedProject] = await this.prisma.$transaction([ + updateProjectOp, + ...versionUpdateOps + ]) + this.log.debug(`Updated project ${updatedProject.id}`) return { ...updatedProject, @@ -237,10 +267,11 @@ export class ProjectService { } async deleteProject(user: User, projectId: Project['id']): Promise { - const project = await this.getProjectWithRole( + const project = await getProjectWithAuthority( user.id, projectId, - WorkspaceRole.MAINTAINER + Authority.DELETE_PROJECT, + this.prisma ) // Delete the project @@ -254,20 +285,22 @@ export class ProjectService { } async getProjectByUserAndId(user: User, projectId: Project['id']) { - const project = await this.getProjectWithRole( + const project = await getProjectWithAuthority( user.id, projectId, - WorkspaceRole.VIEWER + Authority.READ_PROJECT, + this.prisma ) return project } async getProjectById(projectId: Project['id']) { - const project = await this.getProjectWithRole( + const project = await getProjectWithAuthority( null, projectId, - WorkspaceRole.VIEWER + Authority.READ_PROJECT, + this.prisma ) return project @@ -366,64 +399,4 @@ export class ProjectService { })) > 0 ) } - - private async getWorkspaceByUserIdAndId( - userId: User['id'], - workspaceId: Workspace['id'] - ): Promise { - return await this.prisma.workspace.findFirst({ - where: { - id: workspaceId, - members: { - some: { - userId - } - } - }, - include: { - members: true - } - }) - } - - private async getProjectWithRole( - userId: User['id'], - projectId: Project['id'], - role: WorkspaceRole - ): Promise { - // Fetch the project - const project = await this.prisma.project.findUnique({ - where: { - id: projectId - }, - include: { - workspace: { - include: { - members: true - } - }, - secrets: true - } - }) - - if (!project) { - throw new NotFoundException(`Project with id ${projectId} not found`) - } - - // Check for the required membership role - if ( - !project.workspace.members.some( - (member) => - member.userId === userId && permittedRoles(role).includes(role) - ) - ) - throw new UnauthorizedException( - `You don't have the required role to access this project` - ) - - // Remove the workspace from the project - project.workspace = undefined - - return project - } } diff --git a/apps/api/src/secret/service/secret.service.ts b/apps/api/src/secret/service/secret.service.ts index 1e68421d..7f94796f 100644 --- a/apps/api/src/secret/service/secret.service.ts +++ b/apps/api/src/secret/service/secret.service.ts @@ -2,16 +2,15 @@ import { BadRequestException, ConflictException, Injectable, - NotFoundException, - UnauthorizedException + NotFoundException } from '@nestjs/common' import { + Authority, Environment, Project, Secret, SecretVersion, - User, - WorkspaceRole + User } from '@prisma/client' import { CreateSecret } from '../dto/create.secret/create.secret' import { UpdateSecret } from '../dto/update.secret/update.secret' @@ -20,7 +19,9 @@ import { SecretWithProjectAndVersion, SecretWithVersion } from '../secret.types' import { PrismaService } from '../../prisma/prisma.service' import { addHoursToDate } from '../../common/add-hours-to-date' import { encrypt } from '../../common/encrypt' -import permittedRoles from '../../common/get-permitted.roles' +import getCollectiveProjectAuthorities from '../../common/get-collective-project-authorities' +import getProjectWithAuthority from '../../common/get-project-with-authority' +import getEnvironmentWithAuthority from '../../common/get-environment-with-authority' @Injectable() export class SecretService { @@ -29,24 +30,22 @@ export class SecretService { async createSecret(user: User, dto: CreateSecret, projectId: Project['id']) { const environmentId = dto.environmentId // Fetch the project - const project = await this.getProjectWithRole( + const project = await getProjectWithAuthority( user.id, projectId, - WorkspaceRole.MAINTAINER + Authority.CREATE_SECRET, + this.prisma ) // Check if the environment exists let environment: Environment | null = null if (environmentId) { - environment = await this.getEnvironmentByProjectIdAndId( - projectId, - environmentId + environment = await getEnvironmentWithAuthority( + user.id, + environmentId, + Authority.READ_ENVIRONMENT, + this.prisma ) - if (!environment) { - throw new NotFoundException( - `Environment not found: ${environmentId} in project ${projectId}` - ) - } } if (!environment) { environment = await this.getDefaultEnvironmentOfProject(projectId) @@ -98,10 +97,10 @@ export class SecretService { } async updateSecret(user: User, secretId: Secret['id'], dto: UpdateSecret) { - const secret = await this.getSecretWithRole( + const secret = await this.getSecretWithAuthority( user.id, secretId, - WorkspaceRole.MAINTAINER + Authority.UPDATE_SECRET ) // Check if the secret already exists in the environment @@ -165,22 +164,19 @@ export class SecretService { secretId: Secret['id'], environmentId: Environment['id'] ) { - const secret = await this.getSecretWithRole( + const secret = await this.getSecretWithAuthority( user.id, secretId, - WorkspaceRole.MAINTAINER + Authority.UPDATE_SECRET ) // Check if the environment exists - const environment = await this.getEnvironmentByProjectIdAndId( + const environment = await getEnvironmentWithAuthority( secret.projectId, - environmentId + environmentId, + Authority.READ_ENVIRONMENT, + this.prisma ) - if (!environment) { - throw new NotFoundException( - `Environment not found: ${environmentId} in project ${secret.projectId}` - ) - } // Check if the secret already exists in the environment if ( @@ -209,10 +205,10 @@ export class SecretService { rollbackVersion: SecretVersion['version'] ) { // Fetch the secret - const secret = await this.getSecretWithRole( + const secret = await this.getSecretWithAuthority( user.id, secretId, - WorkspaceRole.MAINTAINER + Authority.UPDATE_SECRET ) // Check if the rollback version is valid @@ -235,7 +231,11 @@ export class SecretService { async deleteSecret(user: User, secretId: Secret['id']) { // Check if the user has the required role - await this.getSecretWithRole(user.id, secretId, WorkspaceRole.MAINTAINER) + await this.getSecretWithAuthority( + user.id, + secretId, + Authority.DELETE_SECRET + ) // Delete the secret return await this.prisma.secret.delete({ @@ -251,10 +251,10 @@ export class SecretService { decryptValue: boolean ) { // Fetch the secret - const secret = await this.getSecretWithRole( + const secret = await this.getSecretWithAuthority( user.id, secretId, - WorkspaceRole.VIEWER + Authority.READ_SECRET ) const project = secret.project @@ -289,10 +289,10 @@ export class SecretService { async getAllVersionsOfSecret(user: User, secretId: Secret['id']) { // Fetch the secret - const secret = await this.getSecretWithRole( + const secret = await this.getSecretWithAuthority( user.id, secretId, - WorkspaceRole.MAINTAINER + Authority.READ_SECRET ) return secret.versions @@ -309,10 +309,11 @@ export class SecretService { search: string ) { // Fetch the project - const project = await this.getProjectWithRole( + const project = await getProjectWithAuthority( user.id, projectId, - WorkspaceRole.VIEWER + Authority.READ_SECRET, + this.prisma ) // Check if the project is allowed to store the private key @@ -410,18 +411,6 @@ export class SecretService { }) } - private async getEnvironmentByProjectIdAndId( - projectId: Project['id'], - environmentId: Environment['id'] - ) { - return await this.prisma.environment.findFirst({ - where: { - projectId, - id: environmentId - } - }) - } - private async getDefaultEnvironmentOfProject( projectId: Project['id'] ): Promise { @@ -449,32 +438,10 @@ export class SecretService { ) } - private async getSecret( - secretId: Secret['id'], - projectId: Project['id'] - ): Promise { - return await this.prisma.secret.findUnique({ - where: { - id: secretId, - projectId - }, - include: { - versions: { - orderBy: { - version: 'desc' - }, - take: 1 - }, - lastUpdatedBy: true, - environment: true - } - }) - } - - private async getSecretWithRole( + private async getSecretWithAuthority( userId: User['id'], secretId: Secret['id'], - role: WorkspaceRole + authority: Authority ): Promise { // Fetch the secret const secret = await this.prisma.secret.findUnique({ @@ -499,60 +466,26 @@ export class SecretService { throw new NotFoundException(`Secret with id ${secretId} not found`) } - // Check for the required membership role - if ( - !secret.project.workspace.members.some( - (member) => - member.userId === userId && permittedRoles(role).includes(role) - ) + // Check if the user has the project in their workspace role list + const permittedAuthorities = await getCollectiveProjectAuthorities( + userId, + secret.project, + this.prisma ) - throw new UnauthorizedException( - `You don't have the required role to access this secret` + + // Check if the user has the required authorities + if ( + !permittedAuthorities.has(authority) && + !permittedAuthorities.has(Authority.WORKSPACE_ADMIN) + ) { + throw new ConflictException( + `User ${userId} does not have the required authorities` ) + } // Remove the workspace from the secret secret.project.workspace = undefined return secret } - - private async getProjectWithRole( - userId: User['id'], - projectId: Project['id'], - role: WorkspaceRole - ): Promise { - // Fetch the project - const project = await this.prisma.project.findUnique({ - where: { - id: projectId - }, - include: { - workspace: { - include: { - members: true - } - } - } - }) - - if (!project) { - throw new NotFoundException(`Project with id ${projectId} not found`) - } - - // Check for the required membership role - if ( - !project.workspace.members.some( - (member) => - member.userId === userId && permittedRoles(role).includes(role) - ) - ) - throw new UnauthorizedException( - `You don't have the required role to access this project` - ) - - // Remove the workspace from the project - project.workspace = undefined - - return project - } } diff --git a/apps/api/src/user/service/user.service.ts b/apps/api/src/user/service/user.service.ts index c7170025..127f738d 100644 --- a/apps/api/src/user/service/user.service.ts +++ b/apps/api/src/user/service/user.service.ts @@ -1,12 +1,13 @@ import { ConflictException, Inject, Injectable, Logger } from '@nestjs/common' import { UpdateUserDto } from '../dto/update.user/update.user' -import { User, WorkspaceRole } from '@prisma/client' +import { User } from '@prisma/client' import { PrismaService } from '../../prisma/prisma.service' import { CreateUserDto } from '../dto/create.user/create.user' import { IMailService, MAIL_SERVICE } from '../../mail/services/interface.service' +import createUser from '../../common/create-user' @Injectable() export class UserService { @@ -104,16 +105,6 @@ export class UserService { } private async deleteUserById(userId: User['id']) { - // Delete the user's default workspace - await this.prisma.workspace.delete({ - where: { - defaultWorkspace: { - ownerId: userId, - isDefault: true - } - } - }) - // Delete the user await this.prisma.user.delete({ where: { @@ -136,44 +127,9 @@ export class UserService { throw new ConflictException('User already exists with this email') } - // Create the user - const newUser = await this.prisma.user.create({ - data: { - name: user.name, - email: user.email, - profilePictureUrl: user.profilePictureUrl, - isActive: user.isActive ?? true, - isOnboardingFinished: user.isOnboardingFinished ?? true, - isAdmin: user.isAdmin ?? false - } - }) - this.log.log(`Created user with email ${user.email}`) - // Create the user's default workspace - await this.prisma.workspace.create({ - data: { - name: 'Default', - isDefault: true, - ownerId: newUser.id, - lastUpdatedBy: { - connect: { - id: newUser.id - } - }, - members: { - create: { - role: WorkspaceRole.OWNER, - invitationAccepted: true, - user: { - connect: { - id: newUser.id - } - } - } - } - } - }) - this.log.log(`Created user's default workspace`) + const newUser = await createUser(user, this.prisma) + this.log.log(`Created user with email ${user.email}`) await this.mailService.accountLoginEmail(newUser.email) this.log.log(`Sent login email to ${user.email}`) diff --git a/apps/api/src/user/user.e2e.spec.ts b/apps/api/src/user/user.e2e.spec.ts index 7168720a..776ae424 100644 --- a/apps/api/src/user/user.e2e.spec.ts +++ b/apps/api/src/user/user.e2e.spec.ts @@ -54,56 +54,10 @@ describe('User Controller Tests', () => { isOnboardingFinished: false } }) + }) - // Regular user's workspace - await prisma.workspace.create({ - data: { - name: 'Default', - isDefault: true, - ownerId: regularUser.id, - lastUpdatedBy: { - connect: { - id: regularUser.id - } - }, - members: { - create: { - role: 'OWNER', - invitationAccepted: true, - user: { - connect: { - id: regularUser.id - } - } - } - } - } - }) - - // Admin user's workspace - await prisma.workspace.create({ - data: { - name: 'Default', - isDefault: true, - ownerId: adminUser.id, - lastUpdatedBy: { - connect: { - id: adminUser.id - } - }, - members: { - create: { - role: 'OWNER', - invitationAccepted: true, - user: { - connect: { - id: adminUser.id - } - } - } - } - } - }) + it('should be defined', () => { + expect(app).toBeDefined() }) it(`should be able to get self as admin`, async () => { @@ -317,6 +271,7 @@ describe('User Controller Tests', () => { }) afterAll(async () => { + await prisma.user.deleteMany() await prisma.$disconnect() await app.close() }) diff --git a/apps/api/src/workspace-role/controller/workspace-role.controller.spec.ts b/apps/api/src/workspace-role/controller/workspace-role.controller.spec.ts new file mode 100644 index 00000000..e879a6cd --- /dev/null +++ b/apps/api/src/workspace-role/controller/workspace-role.controller.spec.ts @@ -0,0 +1,27 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { WorkspaceRoleController } from './workspace-role.controller' +import { MockMailService } from '../../mail/services/mock.service' +import { MAIL_SERVICE } from '../../mail/services/interface.service' +import { PrismaService } from '../../prisma/prisma.service' +import { WorkspaceRoleService } from '../service/workspace-role.service' + +describe('WorkspaceRoleController', () => { + let controller: WorkspaceRoleController + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WorkspaceRoleService, + PrismaService, + { provide: MAIL_SERVICE, useClass: MockMailService } + ], + controllers: [WorkspaceRoleController] + }).compile() + + controller = module.get(WorkspaceRoleController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) +}) diff --git a/apps/api/src/workspace-role/controller/workspace-role.controller.ts b/apps/api/src/workspace-role/controller/workspace-role.controller.ts new file mode 100644 index 00000000..3de2cdf6 --- /dev/null +++ b/apps/api/src/workspace-role/controller/workspace-role.controller.ts @@ -0,0 +1,124 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + UseGuards +} from '@nestjs/common' +import { WorkspaceRoleService } from '../service/workspace-role.service' +import { CurrentUser } from '../../decorators/user.decorator' +import { User, Workspace, WorkspaceRole } from '@prisma/client' +import { CreateWorkspaceRole } from '../dto/create-workspace-role/create-workspace-role' +import { AdminGuard } from '../../auth/guard/admin.guard' +import { UpdateWorkspaceRole } from '../dto/update-workspace-role/update-workspace-role' + +@Controller('workspace-role') +export class WorkspaceRoleController { + constructor(private readonly workspaceRoleService: WorkspaceRoleService) {} + + @Post(':workspaceId') + async createWorkspaceRole( + @CurrentUser() user: User, + @Param('workspaceId') workspaceId: Workspace['id'], + @Body() dto: CreateWorkspaceRole + ) { + return await this.workspaceRoleService.createWorkspaceRole( + user, + workspaceId, + dto + ) + } + + @Put(':workspaceRoleId') + async updateWorkspaceRole( + @CurrentUser() user: User, + @Param('workspaceRoleId') workspaceRoleId: WorkspaceRole['id'], + @Body() dto: UpdateWorkspaceRole + ) { + return await this.workspaceRoleService.updateWorkspaceRole( + user, + workspaceRoleId, + dto + ) + } + + @Delete(':workspaceRoleId') + async deleteWorkspaceRole( + @CurrentUser() user: User, + @Param('workspaceRoleId') workspaceRoleId: WorkspaceRole['id'] + ) { + return await this.workspaceRoleService.deleteWorkspaceRole( + user, + workspaceRoleId + ) + } + + @Get(':workspaceId/exists/:workspaceRoleName') + async checkWorkspaceRoleExists( + @CurrentUser() user: User, + @Param('workspaceId') workspaceId: Workspace['id'], + @Param('workspaceRoleName') name: WorkspaceRole['name'] + ) { + return { + exists: await this.workspaceRoleService.checkWorkspaceRoleExists( + user, + workspaceId, + name + ) + } + } + + @Get(':workspaceRoleId') + async getWorkspaceRole( + @CurrentUser() user: User, + @Param('workspaceRoleId') workspaceRoleId: WorkspaceRole['id'] + ) { + return await this.workspaceRoleService.getWorkspaceRole( + user, + workspaceRoleId + ) + } + + @Get(':workspaceId/all') + async getAllWorkspaceRolesOfWorkspace( + @CurrentUser() user: User, + @Param('workspaceId') workspaceId: Workspace['id'], + @Query('page') page: number = 1, + @Query('limit') limit: number = 10, + @Query('sort') sort: string = 'name', + @Query('order') order: string = 'asc', + @Query('search') search: string = '' + ) { + return await this.workspaceRoleService.getWorkspaceRolesOfWorkspace( + user, + workspaceId, + page, + limit, + sort, + order, + search + ) + } + + @Get() + @UseGuards(AdminGuard) + async getAllWorkspaceRoles( + @Query('page') page: number = 1, + @Query('limit') limit: number = 10, + @Query('sort') sort: string = 'name', + @Query('order') order: string = 'asc', + @Query('search') search: string = '' + ) { + return await this.workspaceRoleService.getWorkspaceRoles( + page, + limit, + sort, + order, + search + ) + } +} diff --git a/apps/api/src/workspace-role/dto/create-workspace-role/create-workspace-role.spec.ts b/apps/api/src/workspace-role/dto/create-workspace-role/create-workspace-role.spec.ts new file mode 100644 index 00000000..c51d7e08 --- /dev/null +++ b/apps/api/src/workspace-role/dto/create-workspace-role/create-workspace-role.spec.ts @@ -0,0 +1,7 @@ +import { CreateWorkspaceRole } from './create-workspace-role'; + +describe('CreateWorkspaceRole', () => { + it('should be defined', () => { + expect(new CreateWorkspaceRole()).toBeDefined(); + }); +}); diff --git a/apps/api/src/workspace-role/dto/create-workspace-role/create-workspace-role.ts b/apps/api/src/workspace-role/dto/create-workspace-role/create-workspace-role.ts new file mode 100644 index 00000000..d0d20f77 --- /dev/null +++ b/apps/api/src/workspace-role/dto/create-workspace-role/create-workspace-role.ts @@ -0,0 +1,23 @@ +import { Authority } from '@prisma/client' +import { IsArray, IsOptional, IsString } from 'class-validator' + +export class CreateWorkspaceRole { + @IsString() + readonly name: string + + @IsString() + @IsOptional() + readonly description: string + + @IsString() + @IsOptional() + readonly colorCode: string + + @IsArray() + @IsOptional() + readonly authorities: Authority[] + + @IsArray() + @IsOptional() + readonly projectIds: string[] +} diff --git a/apps/api/src/workspace-role/dto/update-workspace-role/update-workspace-role.spec.ts b/apps/api/src/workspace-role/dto/update-workspace-role/update-workspace-role.spec.ts new file mode 100644 index 00000000..5f0a2eae --- /dev/null +++ b/apps/api/src/workspace-role/dto/update-workspace-role/update-workspace-role.spec.ts @@ -0,0 +1,7 @@ +import { UpdateWorkspaceRole } from './update-workspace-role'; + +describe('UpdateWorkspaceRole', () => { + it('should be defined', () => { + expect(new UpdateWorkspaceRole()).toBeDefined(); + }); +}); diff --git a/apps/api/src/workspace-role/dto/update-workspace-role/update-workspace-role.ts b/apps/api/src/workspace-role/dto/update-workspace-role/update-workspace-role.ts new file mode 100644 index 00000000..04b2aa0e --- /dev/null +++ b/apps/api/src/workspace-role/dto/update-workspace-role/update-workspace-role.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger' +import { CreateWorkspaceRole } from '../create-workspace-role/create-workspace-role' + +export class UpdateWorkspaceRole extends PartialType(CreateWorkspaceRole) {} diff --git a/apps/api/src/workspace-role/service/workspace-role.service.spec.ts b/apps/api/src/workspace-role/service/workspace-role.service.spec.ts new file mode 100644 index 00000000..518c24d4 --- /dev/null +++ b/apps/api/src/workspace-role/service/workspace-role.service.spec.ts @@ -0,0 +1,25 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { WorkspaceRoleService } from './workspace-role.service' +import { PrismaService } from '../../prisma/prisma.service' +import { MAIL_SERVICE } from '../../mail/services/interface.service' +import { MockMailService } from '../../mail/services/mock.service' + +describe('WorkspaceRoleService', () => { + let service: WorkspaceRoleService + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WorkspaceRoleService, + PrismaService, + { provide: MAIL_SERVICE, useClass: MockMailService } + ] + }).compile() + + service = module.get(WorkspaceRoleService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) +}) diff --git a/apps/api/src/workspace-role/service/workspace-role.service.ts b/apps/api/src/workspace-role/service/workspace-role.service.ts new file mode 100644 index 00000000..0b38e06c --- /dev/null +++ b/apps/api/src/workspace-role/service/workspace-role.service.ts @@ -0,0 +1,315 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, + UnauthorizedException +} from '@nestjs/common' +import { Authority, User, Workspace, WorkspaceRole } from '@prisma/client' +import { CreateWorkspaceRole } from '../dto/create-workspace-role/create-workspace-role' +import getWorkspaceWithAuthority from '../../common/get-workspace-with-authority' +import getCollectiveWorkspaceAuthorities from '../../common/get-collective-workspace-authorities' +import { UpdateWorkspaceRole } from '../dto/update-workspace-role/update-workspace-role' +import { PrismaService } from '../../prisma/prisma.service' + +@Injectable() +export class WorkspaceRoleService { + private readonly logger: Logger = new Logger(WorkspaceRoleService.name) + + constructor(private readonly prisma: PrismaService) {} + + async createWorkspaceRole( + user: User, + workspaceId: Workspace['id'], + dto: CreateWorkspaceRole + ) { + if ( + dto.authorities && + dto.authorities.includes(Authority.WORKSPACE_ADMIN) + ) { + throw new BadRequestException( + 'You can not explicitly assign workspace admin authority to a role' + ) + } + + await getWorkspaceWithAuthority( + user.id, + workspaceId, + Authority.CREATE_WORKSPACE_ROLE, + this.prisma + ) + + if (await this.checkWorkspaceRoleExists(user, workspaceId, dto.name)) { + throw new ConflictException( + 'Workspace role with the same name already exists' + ) + } + + const workspaceRole = await this.prisma.workspaceRole.create({ + data: { + name: dto.name, + description: dto.description, + colorCode: dto.colorCode, + authorities: dto.authorities ?? [], + hasAdminAuthority: false, + projects: { + connect: dto.projectIds?.map((id) => ({ id })) + }, + workspace: { + connect: { + id: workspaceId + } + } + }, + include: { + projects: { + select: { + id: true + } + } + } + }) + + this.logger.log( + `User with id ${user.id} created workspace role with id ${workspaceRole.id}` + ) + + return workspaceRole + } + + async updateWorkspaceRole( + user: User, + workspaceRoleId: WorkspaceRole['id'], + dto: UpdateWorkspaceRole + ) { + if ( + dto.authorities && + dto.authorities.includes(Authority.WORKSPACE_ADMIN) + ) { + throw new BadRequestException( + 'You can not explicitly assign workspace admin authority to a role' + ) + } + + let workspaceRole = await this.getWorkspaceRoleWithAuthority( + user.id, + workspaceRoleId, + Authority.UPDATE_WORKSPACE_ROLE + ) + + if ( + dto.name && + ((await this.checkWorkspaceRoleExists( + user, + workspaceRole.workspaceId, + dto.name + )) || + dto.name === workspaceRole.name) + ) { + throw new ConflictException( + 'Workspace role with the same name already exists' + ) + } + + workspaceRole = workspaceRole.hasAdminAuthority + ? await this.prisma.workspaceRole.update({ + where: { + id: workspaceRoleId + }, + data: { + name: dto.name, + description: dto.description, + colorCode: dto.colorCode, + projects: { + connect: dto.projectIds?.map((id) => ({ id })) + } + }, + include: { + projects: { + select: { + id: true + } + } + } + }) + : await this.prisma.workspaceRole.update({ + where: { + id: workspaceRoleId + }, + data: { + name: dto.name, + description: dto.description, + colorCode: dto.colorCode, + authorities: dto.authorities ?? [], + projects: { + connect: dto.projectIds?.map((id) => ({ id })) + } + }, + include: { + projects: { + select: { + id: true + } + } + } + }) + + this.logger.log( + `User with id ${user.id} updated workspace role with id ${workspaceRoleId}` + ) + + return workspaceRole + } + + async deleteWorkspaceRole(user: User, workspaceRoleId: WorkspaceRole['id']) { + const workspaceRole = await this.getWorkspaceRoleWithAuthority( + user.id, + workspaceRoleId, + Authority.DELETE_WORKSPACE_ROLE + ) + + if (workspaceRole.hasAdminAuthority) { + throw new UnauthorizedException( + 'Cannot delete workspace role with administrative authority' + ) + } + + await this.prisma.workspaceRole.delete({ + where: { + id: workspaceRoleId + } + }) + + this.logger.log( + `User with id ${user.id} deleted workspace role with id ${workspaceRoleId}` + ) + } + + async checkWorkspaceRoleExists( + user: User, + workspaceId: Workspace['id'], + name: string + ) { + await getWorkspaceWithAuthority( + user.id, + workspaceId, + Authority.READ_WORKSPACE_ROLE, + this.prisma + ) + + return ( + (await this.prisma.workspaceRole.count({ + where: { + workspaceId, + name + } + })) > 0 + ) + } + + async getWorkspaceRole( + user: User, + workspaceRoleId: WorkspaceRole['id'] + ): Promise { + return await this.getWorkspaceRoleWithAuthority( + user.id, + workspaceRoleId, + Authority.READ_WORKSPACE_ROLE + ) + } + + async getWorkspaceRolesOfWorkspace( + user: User, + workspaceId: Workspace['id'], + page: number, + limit: number, + sort: string, + order: string, + search: string + ): Promise { + await getWorkspaceWithAuthority( + user.id, + workspaceId, + Authority.READ_WORKSPACE_ROLE, + this.prisma + ) + + return await this.prisma.workspaceRole.findMany({ + where: { + workspaceId, + name: { + contains: search + } + }, + skip: (page - 1) * limit, + take: limit, + orderBy: { + [sort]: order + } + }) + } + + async getWorkspaceRoles( + page: number, + limit: number, + sort: string, + order: string, + search: string + ): Promise { + return await this.prisma.workspaceRole.findMany({ + where: { + name: { + contains: search + } + }, + skip: (page - 1) * limit, + take: limit, + orderBy: { + [sort]: order + } + }) + } + + private async getWorkspaceRoleWithAuthority( + userId: User['id'], + workspaceRoleId: Workspace['id'], + authority: Authority + ) { + const workspaceRole = await this.prisma.workspaceRole.findUnique({ + where: { + id: workspaceRoleId + }, + include: { + projects: { + select: { + id: true + } + } + } + }) + + if (!workspaceRole) { + throw new NotFoundException( + `Workspace role with id ${workspaceRoleId} not found` + ) + } + + const permittedAuthorities = await getCollectiveWorkspaceAuthorities( + workspaceRole.workspaceId, + userId, + this.prisma + ) + + if ( + !permittedAuthorities.has(authority) && + !permittedAuthorities.has(Authority.WORKSPACE_ADMIN) + ) { + throw new UnauthorizedException( + `User ${userId} does not have the required authorities to perform the action` + ) + } + + return workspaceRole + } +} diff --git a/apps/api/src/workspace-role/workspace-role.e2e.spec.ts b/apps/api/src/workspace-role/workspace-role.e2e.spec.ts new file mode 100644 index 00000000..fca835b2 --- /dev/null +++ b/apps/api/src/workspace-role/workspace-role.e2e.spec.ts @@ -0,0 +1,941 @@ +import { + FastifyAdapter, + NestFastifyApplication +} from '@nestjs/platform-fastify' +import { PrismaService } from '../prisma/prisma.service' +import { + Authority, + Project, + User, + Workspace, + WorkspaceRole +} from '@prisma/client' +import { AppModule } from '../app/app.module' +import { WorkspaceRoleModule } from './workspace-role.module' +import { MAIL_SERVICE } from '../mail/services/interface.service' +import { MockMailService } from '../mail/services/mock.service' +import { Test } from '@nestjs/testing' +import { v4 } from 'uuid' + +describe('Workspace Role Controller Tests', () => { + let app: NestFastifyApplication + let prisma: PrismaService + + let alice: User + let bob: User + let charlie: User + let workspaceAlice: Workspace + let workspaceBob: Workspace + let adminRole1: WorkspaceRole + let adminRole2: WorkspaceRole + let projects: Project[] + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule, WorkspaceRoleModule] + }) + .overrideProvider(MAIL_SERVICE) + .useClass(MockMailService) + .compile() + app = moduleRef.createNestApplication( + new FastifyAdapter() + ) + prisma = moduleRef.get(PrismaService) + + await app.init() + await app.getHttpAdapter().getInstance().ready() + + await prisma.$transaction([ + prisma.user.deleteMany(), + prisma.project.deleteMany(), + prisma.workspace.deleteMany(), + prisma.workspaceRole.deleteMany(), + prisma.workspaceMember.deleteMany() + ]) + + const aliceId = v4() + const bobId = v4() + const charlieId = v4() + const workspaceAliceId = v4() + const workspaceBobId = v4() + + // Create the users + const createAlice = prisma.user.create({ + data: { + id: aliceId, + email: 'alice@keyshade.xyz', + name: 'Alice', + isActive: true, + isAdmin: false, + isOnboardingFinished: true + } + }) + + const createBob = prisma.user.create({ + data: { + id: bobId, + email: 'bob@keyshade.xyz', + name: 'Bob', + isActive: true, + isAdmin: false, + isOnboardingFinished: true + } + }) + + const createCharlie = prisma.user.create({ + data: { + id: charlieId, + email: 'charlie@keyshade.xyz', + name: 'Charlie', + isActive: true, + isAdmin: false, + isOnboardingFinished: true + } + }) + + // Create the workspaces + const createWorkspaceAlice = prisma.workspace.create({ + data: { + id: workspaceAliceId, + name: 'Test Workspace for Alice', + description: 'Test Workspace Description', + isFreeTier: true, + ownerId: workspaceBobId, + roles: { + createMany: { + data: [ + { + name: 'Admin', + authorities: [Authority.WORKSPACE_ADMIN], + hasAdminAuthority: true, + colorCode: '#FF0000' + }, + { + name: 'Viewer', + authorities: [Authority.READ_WORKSPACE_ROLE] + } + ] + } + } + } + }) + + const createWorkspaceBob = prisma.workspace.create({ + data: { + id: workspaceBobId, + name: 'Test Workspace for Bob', + description: 'Test Workspace Description', + isFreeTier: true, + ownerId: bobId, + roles: { + createMany: { + data: [ + { + name: 'Admin', + authorities: [Authority.WORKSPACE_ADMIN], + hasAdminAuthority: true, + colorCode: '#FF0000' + } + ] + } + } + } + }) + + // Add the owners to the workspaces + const assignOwnershipForAlice = prisma.workspaceMember.create({ + data: { + workspace: { + connect: { + id: workspaceAliceId + } + }, + user: { + connect: { + id: aliceId + } + }, + invitationAccepted: true, + roles: { + create: { + role: { + connect: { + workspaceId_name: { + workspaceId: workspaceAliceId, + name: 'Admin' + } + } + } + } + } + } + }) + + const assignOwnershipForBob = prisma.workspaceMember.create({ + data: { + workspace: { + connect: { + id: workspaceBobId + } + }, + user: { + connect: { + id: bobId + } + }, + invitationAccepted: true, + roles: { + create: { + role: { + connect: { + workspaceId_name: { + workspaceId: workspaceBobId, + name: 'Admin' + } + } + } + } + } + } + }) + + const assignRoleForCharlie = prisma.workspaceMember.create({ + data: { + workspace: { + connect: { + id: workspaceAliceId + } + }, + user: { + connect: { + id: charlieId + } + }, + invitationAccepted: true, + roles: { + create: { + role: { + connect: { + workspaceId_name: { + workspaceId: workspaceAliceId, + name: 'Viewer' + } + } + } + } + } + } + }) + + const result = await prisma.$transaction([ + createAlice, + createBob, + createCharlie, + createWorkspaceAlice, + createWorkspaceBob, + assignOwnershipForAlice, + assignOwnershipForBob, + assignRoleForCharlie + ]) + + alice = result[0] + bob = result[1] + charlie = result[2] + workspaceAlice = result[3] + workspaceBob = result[4] + + adminRole1 = await prisma.workspaceRole.findFirst({ + where: { + workspaceId: workspaceAlice.id, + hasAdminAuthority: true + } + }) + + adminRole2 = await prisma.workspaceRole.findFirst({ + where: { + workspaceId: workspaceBob.id, + hasAdminAuthority: true + } + }) + + projects = await prisma.$transaction([ + prisma.project.create({ + data: { + name: 'Project 1', + description: 'Project 1 Description', + workspaceId: workspaceAlice.id, + publicKey: v4() + } + }), + prisma.project.create({ + data: { + name: 'Project 2', + description: 'Project 2 Description', + workspaceId: workspaceAlice.id, + publicKey: v4() + } + }) + ]) + }) + + it('should be defined', () => { + expect(app).toBeDefined() + }) + + it('should be able to get the auto generated admin role', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': alice.email + }, + url: `/workspace-role/${adminRole1.id}` + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + id: expect.any(String), + name: 'Admin', + description: null, + colorCode: '#FF0000', + hasAdminAuthority: true, + createdAt: expect.any(String), + updatedAt: expect.any(String), + authorities: [Authority.WORKSPACE_ADMIN], + workspaceId: workspaceAlice.id, + projects: [] + }) + }) + + it('should not be able to get the auto generated admin role of other workspace', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': alice.email + }, + url: `/workspace-role/${adminRole2.id}` + }) + + expect(response.statusCode).toBe(401) + }) + + it('should be able to create workspace role', async () => { + const response = await app.inject({ + method: 'POST', + url: `/workspace-role/${workspaceAlice.id}`, + payload: { + name: 'Test Role', + description: 'Test Role Description', + colorCode: '#0000FF', + authorities: [Authority.CREATE_SECRET, Authority.CREATE_WORKSPACE_ROLE] + }, + headers: { + 'x-e2e-user-email': alice.email + } + }) + + expect(response.statusCode).toBe(201) + expect(response.json()).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: 'Test Role', + description: 'Test Role Description', + colorCode: '#0000FF', + authorities: [Authority.CREATE_SECRET, Authority.CREATE_WORKSPACE_ROLE], + workspaceId: workspaceAlice.id, + projects: [] + }) + ) + }) + + it('should not be able to create a workspace role for other workspace', async () => { + const response = await app.inject({ + method: 'POST', + url: `/workspace-role/${workspaceBob.id}`, + payload: { + name: 'Test Role', + description: 'Test Role Description', + colorCode: '#0000FF', + authorities: [Authority.CREATE_SECRET, Authority.CREATE_WORKSPACE_ROLE] + }, + headers: { + 'x-e2e-user-email': alice.email + } + }) + + expect(response.statusCode).toBe(401) + }) + + it('should not be able to create a workspace role with WORKSPACE_ADMIN authority', async () => { + const response = await app.inject({ + method: 'POST', + url: `/workspace-role/${workspaceAlice.id}`, + payload: { + name: 'Test Role', + description: 'Test Role Description', + colorCode: '#0000FF', + authorities: [Authority.WORKSPACE_ADMIN] + }, + headers: { + 'x-e2e-user-email': alice.email + } + }) + + expect(response.statusCode).toBe(400) + }) + + it('should not be able to create a workspace role with the same name', async () => { + const response = await app.inject({ + method: 'POST', + url: `/workspace-role/${workspaceAlice.id}`, + payload: { + name: 'Test Role', + description: 'Test Role Description', + colorCode: '#0000FF', + authorities: [Authority.CREATE_SECRET, Authority.CREATE_WORKSPACE_ROLE] + }, + headers: { + 'x-e2e-user-email': alice.email + } + }) + + expect(response.statusCode).toBe(409) + }) + + it('should be able to read workspace role with READ_WORKSPACE_ROLE authority', async () => { + const response = await app.inject({ + method: 'GET', + url: `/workspace-role/${adminRole1.id}`, + headers: { + 'x-e2e-user-email': charlie.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + id: expect.any(String), + name: 'Admin', + description: null, + colorCode: '#FF0000', + hasAdminAuthority: true, + createdAt: expect.any(String), + updatedAt: expect.any(String), + authorities: [Authority.WORKSPACE_ADMIN], + workspaceId: workspaceAlice.id, + projects: [] + }) + }) + + it('should not be able to create workspace role with READ_WORKSPACE_ROLE authority', async () => { + const response = await app.inject({ + method: 'POST', + url: `/workspace-role/${workspaceAlice.id}`, + payload: { + name: 'Test Role 2', + description: 'Test Role Description', + colorCode: '#0000FF', + authorities: [Authority.READ_WORKSPACE_ROLE] + }, + headers: { + 'x-e2e-user-email': charlie.email + } + }) + + expect(response.statusCode).toBe(401) + }) + + it('should only be able to update color code, name, description of admin authority role', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/workspace-role/${adminRole1.id}`, + payload: { + name: 'Updated Admin', + description: 'Updated Description', + colorCode: '#00FF00' + }, + headers: { + 'x-e2e-user-email': alice.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + id: adminRole1.id, + name: 'Updated Admin', + description: 'Updated Description', + colorCode: '#00FF00', + authorities: [Authority.WORKSPACE_ADMIN], + workspaceId: workspaceAlice.id, + createdAt: expect.any(String), + updatedAt: expect.any(String), + hasAdminAuthority: true, + projects: [] + }) + + adminRole1 = response.json() + }) + + it('should not be able to update workspace role of other workspace', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/workspace-role/${adminRole2.id}`, + payload: { + name: 'Updated Admin', + description: 'Updated Description', + colorCode: '#00FF00', + authorities: [Authority.CREATE_SECRET, Authority.CREATE_WORKSPACE_ROLE] + }, + headers: { + 'x-e2e-user-email': alice.email + } + }) + + expect(response.statusCode).toBe(401) + }) + + it('should not be able to update workspace role with the same name', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/workspace-role/${adminRole1.id}`, + payload: { + name: 'Updated Admin', + description: 'Updated Description', + colorCode: '#00FF00', + authorities: [Authority.CREATE_SECRET, Authority.CREATE_WORKSPACE_ROLE] + }, + headers: { + 'x-e2e-user-email': alice.email + } + }) + + expect(response.statusCode).toBe(409) + }) + + it('should not be able to update the workspace role with READ_WORKSPACE_ROLE authority', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/workspace-role/${adminRole1.id}`, + payload: { + name: 'Updated Admin', + description: 'Updated Description', + colorCode: '#00FF00', + authorities: [Authority.CREATE_SECRET, Authority.CREATE_WORKSPACE_ROLE] + }, + headers: { + 'x-e2e-user-email': charlie.email + } + }) + + expect(response.statusCode).toBe(401) + }) + + it('should be able to update the workspace role with UPDATE_WORKSPACE_ROLE authority', async () => { + await prisma.workspaceRole.update({ + where: { + workspaceId_name: { + workspaceId: workspaceAlice.id, + name: 'Viewer' + } + }, + data: { + authorities: { + set: [Authority.UPDATE_WORKSPACE_ROLE, Authority.READ_WORKSPACE_ROLE] + } + } + }) + + const dummyRole = await prisma.workspaceRole.create({ + data: { + name: 'Dummy Role', + workspaceId: workspaceAlice.id, + authorities: [Authority.CREATE_API_KEY, Authority.CREATE_SECRET] + } + }) + + const response = await app.inject({ + method: 'PUT', + url: `/workspace-role/${dummyRole.id}`, + payload: { + name: 'Updated Dummy Role', + description: 'Updated Description', + colorCode: '#00FF00', + authorities: [Authority.CREATE_SECRET, Authority.CREATE_WORKSPACE_ROLE] + }, + headers: { + 'x-e2e-user-email': charlie.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual( + expect.objectContaining({ + id: dummyRole.id, + name: 'Updated Dummy Role', + description: 'Updated Description', + colorCode: '#00FF00', + authorities: [Authority.CREATE_SECRET, Authority.CREATE_WORKSPACE_ROLE], + workspaceId: workspaceAlice.id, + projects: [] + }) + ) + + await prisma.workspaceRole.delete({ + where: { + id: dummyRole.id + } + }) + }) + + it('should not be able to delete the workspace role with READ_WORKSPACE_ROLE authority', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/workspace-role/${adminRole1.id}`, + headers: { + 'x-e2e-user-email': charlie.email + } + }) + + expect(response.statusCode).toBe(401) + }) + + it('should be able to delete the workspace role with DELETE_WORKSPACE_ROLE authority', async () => { + await prisma.workspaceRole.update({ + where: { + workspaceId_name: { + workspaceId: workspaceAlice.id, + name: 'Viewer' + } + }, + data: { + authorities: { + set: [Authority.DELETE_WORKSPACE_ROLE, Authority.READ_WORKSPACE_ROLE] + } + } + }) + + const dummyRole = await prisma.workspaceRole.create({ + data: { + name: 'Dummy Role', + workspaceId: workspaceAlice.id, + authorities: [Authority.CREATE_API_KEY, Authority.CREATE_SECRET] + } + }) + + const response = await app.inject({ + method: 'DELETE', + url: `/workspace-role/${dummyRole.id}`, + headers: { + 'x-e2e-user-email': charlie.email + } + }) + + expect(response.statusCode).toBe(200) + }) + + it('should be able to delete workspace role with WORKSPACE_ADMIN authority', async () => { + const dummyRole = await prisma.workspaceRole.create({ + data: { + name: 'Dummy Role', + workspaceId: workspaceAlice.id, + authorities: [Authority.CREATE_API_KEY, Authority.CREATE_SECRET] + } + }) + + const response = await app.inject({ + method: 'DELETE', + url: `/workspace-role/${dummyRole.id}`, + headers: { + 'x-e2e-user-email': alice.email + } + }) + + expect(response.statusCode).toBe(200) + }) + + it('should not be able to delete the auto generated admin role', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/workspace-role/${adminRole1.id}`, + headers: { + 'x-e2e-user-email': alice.email + } + }) + + expect(response.statusCode).toBe(401) + }) + + it('should not be able to delete role of other workspace', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/workspace-role/${adminRole2.id}`, + headers: { + 'x-e2e-user-email': alice.email + } + }) + + expect(response.statusCode).toBe(401) + }) + + it('should be able to check if the workspace role exists', async () => { + const response = await app.inject({ + method: 'GET', + url: `/workspace-role/${workspaceAlice.id}/exists/Viewer`, + headers: { + 'x-e2e-user-email': charlie.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + exists: true + }) + }) + + it('should be able to check if the workspace role exists(2)', async () => { + const response = await app.inject({ + method: 'GET', + url: `/workspace-role/${workspaceAlice.id}/exists/new-stuff`, + headers: { + 'x-e2e-user-email': charlie.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + exists: false + }) + }) + + it('should not be able to check if the workspace role exists for other workspace', async () => { + const response = await app.inject({ + method: 'GET', + url: `/workspace-role/${workspaceBob.id}/exists/Viewer`, + headers: { + 'x-e2e-user-email': charlie.email + } + }) + + expect(response.statusCode).toBe(401) + }) + + it('should be able to fetch all the roles of a workspace with WORKSPACE_ADMIN role', async () => { + const roles = await prisma.workspaceRole + .findMany({ + where: { + workspaceId: workspaceAlice.id + } + }) + .then((roles) => + roles.map((role) => ({ + ...role, + createdAt: role.createdAt.toISOString(), + updatedAt: role.updatedAt.toISOString() + })) + ) + + const response = await app.inject({ + method: 'GET', + url: `/workspace-role/${workspaceAlice.id}/all`, + headers: { + 'x-e2e-user-email': charlie.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual(roles) + }) + + it('should be able to fetch all the roles of a workspace with READ_WORKSPACE_ROLE role', async () => { + await prisma.workspaceRole.update({ + where: { + workspaceId_name: { + workspaceId: workspaceAlice.id, + name: 'Viewer' + } + }, + data: { + authorities: { + set: [Authority.READ_WORKSPACE_ROLE] + } + } + }) + + const roles = await prisma.workspaceRole + .findMany({ + where: { + workspaceId: workspaceAlice.id + } + }) + .then((roles) => + roles.map((role) => ({ + ...role, + createdAt: role.createdAt.toISOString(), + updatedAt: role.updatedAt.toISOString() + })) + ) + + const response = await app.inject({ + method: 'GET', + url: `/workspace-role/${workspaceAlice.id}/all`, + headers: { + 'x-e2e-user-email': charlie.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual(roles) + }) + + it('should not be able to fetch all the roles of a workspace without READ_WORKSPACE_ROLE role', async () => { + await prisma.workspaceRole.update({ + where: { + workspaceId_name: { + workspaceId: workspaceAlice.id, + name: 'Viewer' + } + }, + data: { + authorities: { + set: [Authority.CREATE_WORKSPACE_ROLE] + } + } + }) + + const response = await app.inject({ + method: 'GET', + url: `/workspace/${workspaceAlice.id}`, + headers: { + 'x-e2e-user-email': bob.email + } + }) + + expect(response.statusCode).toBe(401) + }) + + it('should be able to add projects to the role', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/workspace-role/${adminRole1.id}`, + payload: { + projectIds: projects.map((project) => project.id) + }, + headers: { + 'x-e2e-user-email': alice.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + id: adminRole1.id, + name: 'Updated Admin', + description: 'Updated Description', + colorCode: '#00FF00', + authorities: [Authority.WORKSPACE_ADMIN], + workspaceId: workspaceAlice.id, + createdAt: expect.any(String), + updatedAt: expect.any(String), + hasAdminAuthority: true, + projects: expect.arrayContaining([ + { + id: projects[0].id + }, + { + id: projects[1].id + } + ]) + }) + }) + + it('should be able to add projects to the role with UPDATE_WORKSPACE_ROLE and READ_PROJECT authorities', async () => { + await prisma.workspaceRole.update({ + where: { + workspaceId_name: { + workspaceId: workspaceAlice.id, + name: 'Viewer' + } + }, + data: { + authorities: { + set: [ + Authority.UPDATE_WORKSPACE_ROLE, + Authority.READ_PROJECT, + Authority.READ_WORKSPACE_ROLE + ] + } + } + }) + + const response = await app.inject({ + method: 'PUT', + url: `/workspace-role/${adminRole1.id}`, + payload: { + projectIds: projects.map((project) => project.id) + }, + headers: { + 'x-e2e-user-email': charlie.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + id: adminRole1.id, + name: 'Updated Admin', + description: 'Updated Description', + colorCode: '#00FF00', + authorities: [Authority.WORKSPACE_ADMIN], + workspaceId: workspaceAlice.id, + createdAt: expect.any(String), + updatedAt: expect.any(String), + hasAdminAuthority: true, + projects: expect.arrayContaining([ + { + id: projects[0].id + }, + { + id: projects[1].id + } + ]) + }) + }) + + it('should not be able to add projects to the role without UPDATE_WORKSPACE_ROLE and READ_PROJECT authorities', async () => { + await prisma.workspaceRole.update({ + where: { + workspaceId_name: { + workspaceId: workspaceAlice.id, + name: 'Viewer' + } + }, + data: { + authorities: { + set: [Authority.READ_WORKSPACE_ROLE] + } + } + }) + + const response = await app.inject({ + method: 'PUT', + url: `/workspace-role/${adminRole1.id}`, + payload: { + projectIds: projects.map((project) => project.id) + }, + headers: { + 'x-e2e-user-email': charlie.email + } + }) + + expect(response.statusCode).toBe(401) + }) + + afterAll(async () => { + await prisma.$transaction([ + prisma.user.deleteMany(), + prisma.project.deleteMany(), + prisma.workspace.deleteMany(), + prisma.workspaceRole.deleteMany(), + prisma.workspaceMember.deleteMany() + ]) + + await prisma.$disconnect() + await app.close() + }) +}) diff --git a/apps/api/src/workspace-role/workspace-role.module.ts b/apps/api/src/workspace-role/workspace-role.module.ts new file mode 100644 index 00000000..09abe1e3 --- /dev/null +++ b/apps/api/src/workspace-role/workspace-role.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' +import { WorkspaceRoleService } from './service/workspace-role.service' +import { WorkspaceRoleController } from './controller/workspace-role.controller' + +@Module({ + providers: [WorkspaceRoleService], + controllers: [WorkspaceRoleController] +}) +export class WorkspaceRoleModule {} diff --git a/apps/api/src/workspace/controller/workspace.controller.spec.ts b/apps/api/src/workspace/controller/workspace.controller.spec.ts index febac361..38ea6d9a 100644 --- a/apps/api/src/workspace/controller/workspace.controller.spec.ts +++ b/apps/api/src/workspace/controller/workspace.controller.spec.ts @@ -1,7 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing' import { WorkspaceController } from './workspace.controller' import { WorkspaceService } from '../service/workspace.service' -import { WorkspacePermission } from '../misc/workspace.permission' import { PrismaService } from '../../prisma/prisma.service' import { MAIL_SERVICE } from '../../mail/services/interface.service' import { MockMailService } from '../../mail/services/mock.service' @@ -14,7 +13,6 @@ describe('WorkspaceController', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ WorkspaceService, - WorkspacePermission, PrismaService, { provide: MAIL_SERVICE, diff --git a/apps/api/src/workspace/controller/workspace.controller.ts b/apps/api/src/workspace/controller/workspace.controller.ts index 50626573..4098710c 100644 --- a/apps/api/src/workspace/controller/workspace.controller.ts +++ b/apps/api/src/workspace/controller/workspace.controller.ts @@ -77,17 +77,17 @@ export class WorkspaceController { } @Put(':workspaceId/update-member-role/:userId') - async updateMemberRole( + async updateMemberRoles( @CurrentUser() user: User, @Param('workspaceId') workspaceId: Workspace['id'], @Param('userId') userId: User['id'], - @Query('role') role: WorkspaceRole + @Query('roles') roleIds: WorkspaceRole['id'][] ) { - return this.workspaceService.updateMemberRole( + return this.workspaceService.updateMemberRoles( user, workspaceId, userId, - role + roleIds ) } @@ -137,6 +137,27 @@ export class WorkspaceController { ) } + @Get(':workspaceId/members') + async getMembers( + @CurrentUser() user: User, + @Param('workspaceId') workspaceId: Workspace['id'], + @Query('page') page: number = 0, + @Query('limit') limit: number = 10, + @Query('sort') sort: string = 'name', + @Query('order') order: string = 'asc', + @Query('search') search: string = '' + ) { + return this.workspaceService.getAllMembersOfWorkspace( + user, + workspaceId, + page, + limit, + sort, + order, + search + ) + } + @Get(':workspaceId') async getWorkspace( @CurrentUser() user: User, @@ -145,7 +166,7 @@ export class WorkspaceController { return this.workspaceService.getWorkspaceById(user, workspaceId) } - @Get('/all') + @Get('/all/as-user') async getAllWorkspacesOfUser( @CurrentUser() user: User, @Query('page') page: number = 0, @@ -165,7 +186,7 @@ export class WorkspaceController { } @UseGuards(AdminGuard) - @Get() + @Get('/all/as-admin') async getAllWorkspaces( @Query('page') page: number = 0, @Query('limit') limit: number = 10, diff --git a/apps/api/src/workspace/dto/create.workspace/create.workspace.ts b/apps/api/src/workspace/dto/create.workspace/create.workspace.ts index bc09d9ee..81358b72 100644 --- a/apps/api/src/workspace/dto/create.workspace/create.workspace.ts +++ b/apps/api/src/workspace/dto/create.workspace/create.workspace.ts @@ -1,5 +1,5 @@ import { WorkspaceRole } from '@prisma/client' -import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator' +import { IsNotEmpty, IsOptional, IsString } from 'class-validator' export class CreateWorkspace { @IsString() @@ -9,13 +9,9 @@ export class CreateWorkspace { @IsString() @IsOptional() description: string - - @IsArray() - @IsOptional() - members: WorkspaceMemberDTO[] } export interface WorkspaceMemberDTO { email: string - role: WorkspaceRole + roleIds: WorkspaceRole['id'][] } diff --git a/apps/api/src/workspace/misc/workspace.permission.spec.ts b/apps/api/src/workspace/misc/workspace.permission.spec.ts deleted file mode 100644 index 99b4c8e3..00000000 --- a/apps/api/src/workspace/misc/workspace.permission.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing' -import { WorkspacePermission } from './workspace.permission' -import { PrismaService } from '../../prisma/prisma.service' - -describe('WorkspacePermission', () => { - let service: WorkspacePermission - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [WorkspacePermission, PrismaService] - }).compile() - - service = module.get(WorkspacePermission) - }) - - it('should be defined', () => { - expect(service).toBeDefined() - }) -}) diff --git a/apps/api/src/workspace/misc/workspace.permission.ts b/apps/api/src/workspace/misc/workspace.permission.ts deleted file mode 100644 index 84307dd8..00000000 --- a/apps/api/src/workspace/misc/workspace.permission.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common' -import { Workspace, WorkspaceRole, User } from '@prisma/client' -import { PrismaService } from '../../prisma/prisma.service' - -@Injectable() -export class WorkspacePermission { - constructor(private readonly prisma: PrismaService) {} - - async isWorkspaceAdmin( - user: User, - workspaceId: Workspace['id'] - ): Promise { - // Admins can do everything - if (user.isAdmin) Promise.resolve() - - // Else, check if the user is a workspace admin - const membership = await this.getMembership(workspaceId, user.id) - - if (membership.role !== WorkspaceRole.OWNER) { - throw new UnauthorizedException('Atleast OWNER role is required') - } - } - - async isWorkspaceMaintainer( - user: User, - workspaceId: Workspace['id'] - ): Promise { - // Admins can do everything - if (user.isAdmin) Promise.resolve() - - // Else, check if the user is a workspace admin - // const memberships = await this.resolveWorkspacesOfUser(user) - const membership = await this.getMembership(workspaceId, user.id) - - if ( - membership.role !== WorkspaceRole.OWNER && - membership.role !== WorkspaceRole.MAINTAINER - ) { - throw new UnauthorizedException('Atleast MAINTAINER role is required') - } - } - - async isWorkspaceMember( - user: User, - workspaceId: Workspace['id'] - ): Promise { - // Admins can do everything - if (user.isAdmin) Promise.resolve() - - // Else, check if the user is a workspace admin - this.getMembership(workspaceId, user.id) - } - - private async getMembership( - workspaceId: Workspace['id'], - userId: User['id'] - ) { - const membership = await this.prisma.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - workspaceId, - userId - } - } - }) - - if (!membership) { - throw new UnauthorizedException('User is not a member of the workspace') - } - - return membership - } - - // private async resolveWorkspacesOfUser( - // user: User - // ): Promise<{ workspaceId: Workspace['id']; role: WorkspaceRole }[]> { - // // const memberships = await this.repository.getWorkspaceMembershipsOfUser( - // // user.id - // // ) - // const memberships = await this.prisma.workspaceMember.findMany({ - // where: { - // userId: user.id - // } - // }) - // return memberships.map((membership) => ({ - // workspaceId: membership.workspaceId, - // role: membership.role - // })) - // } -} diff --git a/apps/api/src/workspace/service/workspace.service.spec.ts b/apps/api/src/workspace/service/workspace.service.spec.ts index c01d9caf..acfa1362 100644 --- a/apps/api/src/workspace/service/workspace.service.spec.ts +++ b/apps/api/src/workspace/service/workspace.service.spec.ts @@ -1,89 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing' import { WorkspaceService } from './workspace.service' -import { WorkspacePermission } from '../misc/workspace.permission' import { PrismaService } from '../../prisma/prisma.service' import { MAIL_SERVICE } from '../../mail/services/interface.service' import { MockMailService } from '../../mail/services/mock.service' import { JwtService } from '@nestjs/jwt' -import { workspaces } from '../../common/mock-data/workspaces' -import { users } from '../../common/mock-data/users' -import { WorkspaceRole } from '@prisma/client' - -const mockPrisma = { - user: { - findUnique: jest.fn().mockImplementation((args) => { - const user = users.find((u) => u.id === args.where.id) - if (!user) { - throw new Error('User not found') - } - return user - }) - }, - apiKeyWorkspaceScope: { - deleteMany: jest.fn() - }, - workspace: { - create: jest.fn().mockImplementation((args) => { - return { - id: '4', - name: args.data.name, - description: args.data.description, - isFreeTier: args.data.isFreeTier, - isDefault: args.data.isDefault, - createdAt: new Date('2022-01-04T00:00:00Z'), - updatedAt: new Date('2022-01-04T00:00:00Z'), - lastUpdatedById: '1', - ownerId: args.data.ownerId - } - }), - update: jest.fn().mockImplementation((args) => { - const workspace = workspaces[0] - return { - ...workspace, - name: args.data.name ?? workspace.name, - description: args.data.description ?? workspace.description, - isFreeTier: args.data.isFreeTier ?? workspace.isFreeTier, - isDefault: args.data.isDefault ?? workspace.isDefault, - updatedAt: new Date('2022-01-05T00:00:00Z'), - lastUpdatedById: '1' - } - }), - findMany: jest.fn().mockImplementation(() => workspaces), - findUnique: jest.fn().mockImplementation((args) => { - const workspace = workspaces.find((w) => w.id === args.where.id) - if (!workspace) { - throw new Error('Workspace not found') - } - // @ts-expect-error - Mocking - workspace.members = users - .filter((u) => u.id === workspace.ownerId) - .map((u) => ({ - userId: u.id, - role: 'OWNER' - })) - return workspace - }), - delete: jest.fn().mockImplementation((args) => { - const workspace = workspaces.find((w) => w.id === args.where.id) - if (!workspace) { - throw new Error('Workspace not found') - } - return workspace - }), - count: jest.fn().mockImplementation( - (args: { - where: { - name: string - } - }) => { - const filtered = workspaces.filter((w) => { - return w.name.includes(args.where.name) - }) - return filtered.length - } - ) - } -} describe('WorkspaceService', () => { let service: WorkspaceService @@ -92,7 +12,6 @@ describe('WorkspaceService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ WorkspaceService, - WorkspacePermission, PrismaService, { provide: MAIL_SERVICE, @@ -100,10 +19,7 @@ describe('WorkspaceService', () => { }, JwtService ] - }) - .overrideProvider(PrismaService) - .useValue(mockPrisma) - .compile() + }).compile() service = module.get(WorkspaceService) }) @@ -111,111 +27,4 @@ describe('WorkspaceService', () => { it('should be defined', () => { expect(service).toBeDefined() }) - - it('should be able to create a workspace', async () => { - const user = users[0] - const workspace = await service.createWorkspace(user, { - name: 'Workspace 4', - description: 'This is workspace 4', - members: [] - }) - expect(workspace.id).toEqual('4') - expect(workspace.name).toEqual('Workspace 4') - expect(workspace.description).toEqual('This is workspace 4') - expect(workspace.isFreeTier).toEqual(true) - expect(workspace.isDefault).toEqual(false) - expect(workspace.ownerId).toEqual('1') - }) - - it('should be able to update an existing workspace', async () => { - const user = users[0] - const workspace = await service.updateWorkspace(user, '1', { - name: 'Workspace 1 Updated', - description: 'This is workspace 1 updated' - }) - expect(workspace.id).toEqual('1') - expect(workspace.name).toEqual('Workspace 1 Updated') - expect(workspace.description).toEqual('This is workspace 1 updated') - expect(workspace.isFreeTier).toEqual(true) - expect(workspace.isDefault).toEqual(false) - }) - - it('should not be able to update a workspace that does not belong to the user', async () => { - const user = users[0] - await expect( - service.updateWorkspace(user, '2', { - name: 'Workspace 2 Updated', - description: 'This is workspace 2 updated' - }) - ).rejects.toThrow(`User ${user.id} is not a member of workspace 2`) - }) - - it('should not be able to update a workspace with duplicate name', async () => { - const user = users[0] - await expect( - service.updateWorkspace(user, '1', { - name: 'Workspace 3', - description: 'This is workspace 3' - }) - ).rejects.toThrow('Workspace already exists') - }) - - it('should throw an error while updating a non-existing workspace', async () => { - const user = users[0] - await expect( - service.updateWorkspace(user, '4', { - name: 'Workspace 4', - description: 'This is workspace 4' - }) - ).rejects.toThrow('Workspace not found') - }) - - it('should be able to delete an existing workspace', async () => { - const user = users[0] - expect(await service.deleteWorkspace(user, '1')).toBeUndefined() - }) - - it('should not be able to delete a workspace that does not belong to the user', async () => { - const user = users[0] - await expect(service.deleteWorkspace(user, '2')).rejects.toThrow( - `User ${user.id} is not a member of workspace 2` - ) - }) - - it('should throw an error while deleting a non-existing workspace', async () => { - const user = users[0] - await expect(service.deleteWorkspace(user, '4')).rejects.toThrow( - 'Workspace not found' - ) - }) - - it('should be able to get a workspace by its id', async () => { - const user = users[0] - const workspace = await service.getWorkspaceById(user, '1') - expect(workspace.id).toEqual('1') - expect(workspace.name).toEqual('Workspace 1') - expect(workspace.description).toEqual('This is workspace 1') - expect(workspace.isFreeTier).toEqual(true) - expect(workspace.isDefault).toEqual(false) - expect(workspace.ownerId).toEqual('1') - }) - - it('should not be able to get a workspace that does not belong to the user', async () => { - const user = users[0] - await expect(service.getWorkspaceById(user, '2')).rejects.toThrow( - `User ${user.id} is not a member of workspace 2` - ) - }) - - it('should throw an error while getting a non-existing workspace', async () => { - const user = users[0] - await expect(service.getWorkspaceById(user, '4')).rejects.toThrow( - 'Workspace not found' - ) - }) - - it('should be able to get all workspaces', async () => { - const workspaces = await service.getWorkspaces(0, 3, 'name', 'asc', '') - expect(workspaces.length).toEqual(3) - }) }) diff --git a/apps/api/src/workspace/service/workspace.service.ts b/apps/api/src/workspace/service/workspace.service.ts index 958a053d..dc7fdfc0 100644 --- a/apps/api/src/workspace/service/workspace.service.ts +++ b/apps/api/src/workspace/service/workspace.service.ts @@ -8,20 +8,25 @@ import { UnauthorizedException } from '@nestjs/common' import { PrismaService } from '../../prisma/prisma.service' -import { User, Workspace, WorkspaceMember, WorkspaceRole } from '@prisma/client' +import { + Authority, + User, + Workspace, + WorkspaceMember, + WorkspaceRole +} from '@prisma/client' import { CreateWorkspace, WorkspaceMemberDTO } from '../dto/create.workspace/create.workspace' -import { WorkspacePermission } from '../misc/workspace.permission' -import { ApiKeyWorkspaceRoles } from '../../common/api-key-roles' import { IMailService, MAIL_SERVICE } from '../../mail/services/interface.service' import { JwtService } from '@nestjs/jwt' import { UpdateWorkspace } from '../dto/update.workspace/update.workspace' -import permittedRoles from '../../common/get-permitted.roles' +import getWorkspaceWithAuthority from '../../common/get-workspace-with-authority' +import { v4 } from 'uuid' @Injectable() export class WorkspaceService { @@ -29,7 +34,6 @@ export class WorkspaceService { constructor( private readonly prisma: PrismaService, - private readonly permission: WorkspacePermission, private readonly jwt: JwtService, @Inject(MAIL_SERVICE) private readonly mailService: IMailService ) {} @@ -39,37 +43,67 @@ export class WorkspaceService { throw new ConflictException('Workspace already exists') } - const newWorkspace = await this.prisma.workspace.create({ + const workspaceId = v4() + + const createNewWorkspace = this.prisma.workspace.create({ data: { + id: workspaceId, name: dto.name, description: dto.description, - isDefault: false, isFreeTier: true, ownerId: user.id, - members: { + roles: { + createMany: { + data: [ + { + name: 'Admin', + authorities: [Authority.WORKSPACE_ADMIN], + hasAdminAuthority: true, + colorCode: '#FF0000' + } + ] + } + } + } + }) + + // Add the owner to the workspace + const assignOwnership = this.prisma.workspaceMember.create({ + data: { + workspace: { + connect: { + id: workspaceId + } + }, + user: { + connect: { + id: user.id + } + }, + invitationAccepted: true, + roles: { create: { - user: { + role: { connect: { - id: user.id + workspaceId_name: { + workspaceId: workspaceId, + name: 'Admin' + } } - }, - role: WorkspaceRole.OWNER, - invitationAccepted: true + } } } } }) - // Add users to the project if any - if (dto.members && dto.members.length > 0) { - this.addMembersToWorkspace(newWorkspace, user, dto.members) - } + const result = await this.prisma.$transaction([ + createNewWorkspace, + assignOwnership + ]) - this.log.debug( - `Created workspace ${newWorkspace.name} (${newWorkspace.id})` - ) + this.log.debug(`Created workspace ${dto.name} (${workspaceId})`) - return newWorkspace + return result[0] } async updateWorkspace( @@ -78,10 +112,11 @@ export class WorkspaceService { dto: UpdateWorkspace ) { // Fetch the workspace - const workspace = await this.getWorkspaceWithRole( + const workspace = await getWorkspaceWithAuthority( user.id, workspaceId, - WorkspaceRole.OWNER + Authority.UPDATE_WORKSPACE, + this.prisma ) // Check if a same named workspace already exists @@ -99,7 +134,12 @@ export class WorkspaceService { }, data: { name: dto.name, - description: dto.description + description: dto.description, + lastUpdatedBy: { + connect: { + id: user.id + } + } } }) } @@ -109,33 +149,82 @@ export class WorkspaceService { workspaceId: Workspace['id'], userId: User['id'] ): Promise { - const workspace = await this.getWorkspaceWithRole( + const workspace = await getWorkspaceWithAuthority( user.id, workspaceId, - WorkspaceRole.OWNER + Authority.TRANSFER_OWNERSHIP, + this.prisma + ) + + if (userId === user.id) { + throw new BadRequestException( + `You are already the owner of the workspace ${workspace.name} (${workspace.id})` + ) + } + + const workspaceMembership = await this.getWorkspaceMembership( + workspaceId, + userId ) // Check if the user is a member of the workspace - if (!(await this.memberExistsInWorkspace(workspaceId, userId))) + if (!workspaceMembership) { throw new NotFoundException( `User ${userId} is not a member of workspace ${workspace.name} (${workspace.id})` ) + } - // Update the role of the user - await this.updateMembership(workspaceId, userId, { - role: WorkspaceRole.OWNER + // Get the admin ownership role + const adminOwnershipRole = await this.prisma.workspaceRole.findFirst({ + where: { + workspaceId, + hasAdminAuthority: true + } + }) + + // Assign this role to the new owner + const assignRole = this.prisma.workspaceMemberRoleAssociation.upsert({ + where: { + roleId_workspaceMemberId: { + roleId: adminOwnershipRole.id, + workspaceMemberId: workspaceMembership.id + } + }, + create: { + role: { + connect: { + workspaceId_name: { + name: adminOwnershipRole.name, + workspaceId + } + } + }, + workspaceMember: { + connect: { + id: workspaceMembership.id + } + } + }, + update: {} }) // Update the owner of the workspace - await this.prisma.workspace.update({ + const updateUser = this.prisma.workspace.update({ where: { id: workspaceId }, data: { - ownerId: userId + ownerId: userId, + lastUpdatedBy: { + connect: { + id: user.id + } + } } }) + await this.prisma.$transaction([assignRole, updateUser]) + this.log.debug( `Transferred ownership of workspace ${workspace.name} (${workspace.id}) to user ${userId}` ) @@ -145,15 +234,13 @@ export class WorkspaceService { user: User, workspaceId: Workspace['id'] ): Promise { - const workspace = await this.getWorkspaceWithRole( + const workspace = await getWorkspaceWithAuthority( user.id, workspaceId, - WorkspaceRole.OWNER + Authority.DELETE_WORKSPACE, + this.prisma ) - // Delete the API key scopes associated with this workspace - await this.deleteApiKeyScopesOfWorkspace(workspaceId) - // Delete the workspace await this.prisma.workspace.delete({ where: { @@ -169,10 +256,11 @@ export class WorkspaceService { workspaceId: Workspace['id'], members: WorkspaceMemberDTO[] ): Promise { - const workspace = await this.getWorkspaceWithRole( + const workspace = await getWorkspaceWithAuthority( user.id, workspaceId, - WorkspaceRole.OWNER + Authority.ADD_USER, + this.prisma ) // Add users to the workspace if any @@ -186,10 +274,11 @@ export class WorkspaceService { workspaceId: Workspace['id'], userIds: User['id'][] ): Promise { - const workspace = await this.getWorkspaceWithRole( + const workspace = await getWorkspaceWithAuthority( user.id, workspaceId, - WorkspaceRole.OWNER + Authority.REMOVE_USER, + this.prisma ) // Check if the user is already a member of the workspace @@ -199,6 +288,7 @@ export class WorkspaceService { ) // Remove users from the workspace if any + const ops = [] if (userIds && userIds.length > 0) { for (const userId of userIds) { if (userId === user.id) @@ -207,35 +297,33 @@ export class WorkspaceService { ) // Delete the membership - await this.prisma.workspaceMember.delete({ - where: { - workspaceId_userId: { - workspaceId, - userId + ops.push( + this.prisma.workspaceMember.delete({ + where: { + workspaceId_userId: { + workspaceId, + userId + } } - } - }) - - // Delete the API key scopes associated with this workspace - await this.deleteApiKeyScopesOfWorkspace(workspaceId, userId) - - this.log.debug( - `Removed user ${userId} from workspace ${workspace.name} (${workspace.id})` + }) ) } } + + await this.prisma.$transaction(ops) } - async updateMemberRole( + async updateMemberRoles( user: User, workspaceId: Workspace['id'], userId: User['id'], - role: WorkspaceRole + roleIds: WorkspaceRole['id'][] ): Promise { - const workspace = await this.getWorkspaceWithRole( + const workspace = await getWorkspaceWithAuthority( user.id, workspaceId, - WorkspaceRole.OWNER + Authority.UPDATE_USER_ROLE, + this.prisma ) // Check if the member in concern is a part of the workspace or not @@ -244,69 +332,91 @@ export class WorkspaceService { `User ${userId} is not a member of workspace ${workspace.name} (${workspace.id})` ) - // Check if the user has the permission to update the role of the user - this.permission.isWorkspaceAdmin(user, workspaceId) - - // Fetch the current role of the user in concern - const currentRole = await this.prisma.workspaceMember.findUnique({ + // Update the role of the user + await this.prisma.workspaceMember.update({ where: { workspaceId_userId: { workspaceId, userId } }, - select: { - role: true + data: { + roles: { + set: roleIds.map((id) => ({ id })) + } } }) - // We only want to reduce the roles of the API key if the user's role has been downgraded - if ( - (currentRole.role === WorkspaceRole.OWNER && - role !== WorkspaceRole.OWNER) || - (currentRole.role === WorkspaceRole.MAINTAINER && - role == WorkspaceRole.VIEWER) - ) { - const previousAPIKeyScopes = - await this.prisma.apiKeyWorkspaceScope.findFirst({ - where: { - workspaceId, - apiKey: { - userId - } - }, - select: { - roles: true - } - }) + this.log.debug( + `Updated role of user ${userId} in workspace ${workspace.name} (${workspace.id})` + ) + } - // Filtering out the old roles that are now not allowed - const updatedRoles = previousAPIKeyScopes.roles.filter((scope) => - ApiKeyWorkspaceRoles[currentRole.role].includes(scope) - ) + async getAllMembersOfWorkspace( + user: User, + workspaceId: Workspace['id'], + page: number, + limit: number, + sort: string, + order: string, + search: string + ) { + await getWorkspaceWithAuthority( + user.id, + workspaceId, + Authority.READ_USERS, + this.prisma + ) - // Update the API key scopes associated with this workspace - await this.prisma.apiKeyWorkspaceScope.updateMany({ - where: { - workspaceId, - apiKey: { - userId + return await this.prisma.workspaceMember.findMany({ + skip: (page - 1) * limit, + take: limit, + orderBy: { + workspace: { + [sort]: order + } + }, + where: { + workspaceId, + user: { + OR: [ + { + name: { + contains: search + } + }, + { + email: { + contains: search + } + } + ] + } + }, + select: { + id: true, + user: true, + roles: { + select: { + id: true, + role: { + select: { + id: true, + name: true, + description: true, + colorCode: true, + authorities: true, + projects: { + select: { + id: true + } + } + } + } } - }, - data: { - roles: updatedRoles } - }) - } - - // Update the role of the user - await this.updateMembership(workspaceId, userId, { - role + } }) - - this.log.debug( - `Updated role of user ${userId} to ${role} in workspace ${workspace.name} (${workspace.id})` - ) } async acceptInvitation( @@ -320,8 +430,16 @@ export class WorkspaceService { ) // Update the membership - await this.updateMembership(workspaceId, user.id, { - invitationAccepted: true + await this.prisma.workspaceMember.update({ + where: { + workspaceId_userId: { + workspaceId, + userId: user.id + } + }, + data: { + invitationAccepted: true + } }) this.log.debug( @@ -334,8 +452,12 @@ export class WorkspaceService { workspaceId: Workspace['id'], inviteeId: User['id'] ): Promise { - // Check if the user has permission to decline the invitation - this.permission.isWorkspaceAdmin(user, workspaceId) + await getWorkspaceWithAuthority( + user.id, + workspaceId, + Authority.REMOVE_USER, + this.prisma + ) // Check if the user has a pending invitation to the workspace if (!(await this.invitationPending(workspaceId, inviteeId))) @@ -419,9 +541,6 @@ export class WorkspaceService { // Delete the membership await this.deleteMembership(workspaceId, user.id) - // Delete the API key scopes associated with this workspace - await this.deleteApiKeyScopesOfWorkspace(workspaceId, user.id) - this.log.debug( `User ${user.name} (${user.id}) left workspace ${workspaceId}` ) @@ -436,7 +555,12 @@ export class WorkspaceService { order: string, search: string ) { - await this.getWorkspaceWithRole(user.id, workspaceId, WorkspaceRole.VIEWER) + await getWorkspaceWithAuthority( + user.id, + workspaceId, + Authority.READ_USERS, + this.prisma + ) return await this.prisma.workspaceMember.findMany({ skip: (page - 1) * limit, @@ -474,7 +598,12 @@ export class WorkspaceService { workspaceId: Workspace['id'], otherUserId: User['id'] ): Promise { - await this.getWorkspaceWithRole(user.id, workspaceId, WorkspaceRole.VIEWER) + await getWorkspaceWithAuthority( + user.id, + workspaceId, + Authority.READ_USERS, + this.prisma + ) return await this.memberExistsInWorkspace(workspaceId, otherUserId) } @@ -483,10 +612,11 @@ export class WorkspaceService { user: User, workspaceId: Workspace['id'] ): Promise { - return await this.getWorkspaceWithRole( + return await getWorkspaceWithAuthority( user.id, workspaceId, - WorkspaceRole.VIEWER + Authority.READ_USERS, + this.prisma ) } @@ -576,7 +706,7 @@ export class WorkspaceService { members: WorkspaceMemberDTO[] ) { for (const member of members) { - let memberUser: User | null = await this.prisma.user.findUnique({ + const memberUser: User | null = await this.prisma.user.findUnique({ where: { email: member.email } @@ -587,15 +717,31 @@ export class WorkspaceService { memberUser && (await this.memberExistsInWorkspace(workspace.id, memberUser.id)) ) - continue + throw new ConflictException( + `User ${memberUser.name} (${memberUser.id}) is already a member of workspace ${workspace.name} (${workspace.id})` + ) + + // Create the workspace membership + const createMembership = this.prisma.workspaceMember.create({ + data: { + workspaceId: workspace.id, + userId: memberUser.id, + roles: { + connect: member.roleIds.map((r) => ({ id: r })) + } + } + }) + + let txResult if (memberUser) { + txResult = await this.prisma.$transaction([createMembership]) + this.mailService.workspaceInvitationMailForUsers( member.email, workspace.name, `${process.env.WORKSPACE_FRONTEND_URL}/workspace/${workspace.id}/join`, currentUser.name, - member.role, true ) @@ -603,12 +749,17 @@ export class WorkspaceService { `Sent workspace invitation mail to registered user ${memberUser}` ) } else { - memberUser = await this.prisma.user.create({ + const createMember = this.prisma.user.create({ data: { email: member.email } }) + txResult = await this.prisma.$transaction([ + createMember, + createMembership + ]) + this.log.debug(`Created non-registered user ${memberUser}`) this.mailService.workspaceInvitationMailForUsers( @@ -616,11 +767,10 @@ export class WorkspaceService { workspace.name, `${process.env.WORKSPACE_FRONTEND_URL}/workspace/${ workspace.id - }/join?token=${await await this.jwt.signAsync({ + }/join?token=${await this.jwt.signAsync({ id: memberUser.id })}`, currentUser.name, - member.role, false ) @@ -629,17 +779,8 @@ export class WorkspaceService { ) } - // Create the workspace membership - const membership = await this.prisma.workspaceMember.create({ - data: { - workspaceId: workspace.id, - userId: memberUser.id, - role: member.role - } - }) - this.log.debug( - `Added user ${memberUser} as ${member.role} to workspace ${workspace.name}. Membership: ${membership.id}` + `Added user ${memberUser} to workspace ${workspace.name}. Membership: ${txResult[0].membership.id}` ) } } @@ -648,31 +789,26 @@ export class WorkspaceService { workspaceId: string, userId: string ): Promise { - return await this.prisma.workspaceMember - .count({ + return ( + (await this.prisma.workspaceMember.count({ where: { workspaceId, userId } - }) - .then((count) => count > 0) + })) > 0 + ) } - private async updateMembership( + private async getWorkspaceMembership( workspaceId: Workspace['id'], - userId: User['id'], - data: Partial> + userId: User['id'] ): Promise { - return await this.prisma.workspaceMember.update({ + return await this.prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId } - }, - data: { - role: data.role, - invitationAccepted: data.invitationAccepted } }) } @@ -705,52 +841,4 @@ export class WorkspaceService { }) .then((count) => count > 0) } - - private async deleteApiKeyScopesOfWorkspace( - workspaceId: Workspace['id'], - userId?: User['id'] - ) { - await this.prisma.apiKeyWorkspaceScope.deleteMany({ - where: { - workspaceId, - apiKey: { - userId - } - } - }) - } - - private async getWorkspaceWithRole( - userId: User['id'], - workspaceId: Workspace['id'], - role: WorkspaceRole - ): Promise { - const workspace = await this.prisma.workspace.findUnique({ - where: { - id: workspaceId - }, - include: { - members: true - } - }) - - // Check if the workspace exists or not - if (!workspace) { - throw new NotFoundException(`Workspace with id ${workspaceId} not found`) - } - - // Check if the user is a member of the workspace - if ( - !workspace.members.some( - (member) => - member.userId === userId && permittedRoles(role).includes(role) - ) - ) { - throw new UnauthorizedException( - `User ${userId} is not a member of workspace ${workspaceId}` - ) - } - - return workspace - } } diff --git a/apps/api/src/workspace/workspace.module.ts b/apps/api/src/workspace/workspace.module.ts index f8519474..ab9a368a 100644 --- a/apps/api/src/workspace/workspace.module.ts +++ b/apps/api/src/workspace/workspace.module.ts @@ -1,11 +1,9 @@ import { Module } from '@nestjs/common' import { WorkspaceService } from './service/workspace.service' import { WorkspaceController } from './controller/workspace.controller' -import { WorkspacePermission } from './misc/workspace.permission' @Module({ - providers: [WorkspaceService, WorkspacePermission], - controllers: [WorkspaceController], - exports: [WorkspacePermission] + providers: [WorkspaceService], + controllers: [WorkspaceController] }) export class WorkspaceModule {} diff --git a/package.json b/package.json index 2af08139..b326fd91 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,7 @@ "@types/passport-jwt": "^3.0.13", "@types/react": "18.2.33", "@types/react-dom": "18.2.14", + "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^6.18.0", "@typescript-eslint/parser": "^6.18.0", "autoprefixer": "^10.4.16", @@ -208,6 +209,7 @@ "reflect-metadata": "^0.1.14", "rxjs": "^7.8.1", "semantic-release": "^23.0.0", - "tslib": "^2.6.2" + "tslib": "^2.6.2", + "uuid": "^9.0.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55224c32..793d9c02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: tslib: specifier: ^2.6.2 version: 2.6.2 + uuid: + specifier: ^9.0.1 + version: 9.0.1 devDependencies: '@nestjs/schematics': specifier: ^10.0.3 @@ -186,6 +189,9 @@ importers: '@types/react-dom': specifier: 18.2.14 version: 18.2.14 + '@types/uuid': + specifier: ^9.0.8 + version: 9.0.8 '@typescript-eslint/eslint-plugin': specifier: ^6.18.0 version: 6.18.0(@typescript-eslint/parser@6.18.0)(eslint@8.48.0)(typescript@5.2.2) @@ -4636,6 +4642,10 @@ packages: resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} dev: true + /@types/uuid@9.0.8: + resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + dev: true + /@types/validator@13.11.7: resolution: {integrity: sha512-q0JomTsJ2I5Mv7dhHhQLGjMvX0JJm5dyZ1DXQySIUzU1UlwzB8bt+R6+LODUbz0UDIOvEzGc28tk27gBJw2N8Q==}