diff --git a/apps/api/package.json b/apps/api/package.json index c6ae38a9..ae4d4de6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,7 +9,6 @@ "start": "node dist/main", "dev": "cross-env NODE_ENV=dev nest start --watch", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "test": "jest", "db:generate-types": "pnpm dlx prisma generate --schema=src/prisma/schema.prisma", "db:generate-migrations": "pnpx dotenv-cli -e ../../.env -- pnpx prisma migrate dev --create-only --skip-seed --schema=src/prisma/schema.prisma", "db:deploy-migrations": "pnpx dotenv-cli -e ../../.env -- pnpx prisma migrate deploy --schema=src/prisma/schema.prisma", @@ -18,8 +17,9 @@ "db:reset": "pnpx dotenv-cli -e ../../.env -- pnpm dlx prisma migrate reset --force --schema=src/prisma/schema.prisma", "sourcemaps": "sentry-cli sourcemaps inject ./dist && sentry-cli sourcemaps upload ./dist || echo 'Failed to upload source maps to Sentry'", "e2e:prepare": "cd ../../ && docker compose down && docker compose -f docker-compose-test.yml up -d && cd apps/api && pnpm db:generate-types && cross-env NODE_ENV='e2e' DATABASE_URL='postgresql://prisma:prisma@localhost:5432/tests' pnpm run db:deploy-migrations", - "e2e": "pnpm run e2e:prepare && cross-env NODE_ENV='e2e' DATABASE_URL='postgresql://prisma:prisma@localhost:5432/tests' turbo run test --no-cache --filter=api -- --runInBand --config=jest.e2e-config.ts --coverage --coverageDirectory=../../coverage-e2e/api --coverageReporters=json && pnpm run e2e:teardown", - "e2e:teardown": "cd ../../ && docker compose -f docker-compose-test.yml down" + "e2e": "pnpm run e2e:prepare && cross-env NODE_ENV='e2e' DATABASE_URL='postgresql://prisma:prisma@localhost:5432/tests' jest --runInBand --config=jest.e2e-config.ts --coverage --coverageDirectory=../../coverage-e2e/api --coverageReporters=json && pnpm run e2e:teardown", + "e2e:teardown": "cd ../../ && docker compose -f docker-compose-test.yml down", + "unit": "pnpm db:generate-types && jest --config=jest.config.ts" }, "dependencies": { "@nestjs/common": "^10.0.0", @@ -67,6 +67,7 @@ "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", + "ajv": "^7", "dotenv-cli": "^7.4.2", "eslint": "^8.42.0", "eslint-config-prettier": "^9.0.0", @@ -80,7 +81,6 @@ "supertest": "^6.3.3", "ts-jest": "^29.1.0", "ts-loader": "^9.4.3", - "ts-node": "^10.9.1", "tsconfig-paths": "^4.2.0", "typescript": "^5.1.3" }, diff --git a/apps/api/src/common/authority-checker.service.ts b/apps/api/src/common/authority-checker.service.ts index e3e33683..e4418f83 100644 --- a/apps/api/src/common/authority-checker.service.ts +++ b/apps/api/src/common/authority-checker.service.ts @@ -150,7 +150,19 @@ export class AuthorityCheckerService { const projectAccessLevel = project.accessLevel switch (projectAccessLevel) { case ProjectAccessLevel.GLOBAL: - //everyone can access this + // We will only allow reads for the project. If the authority is READ_PROJECT, we will allow access + // For any other authority, the user needs to have the required collective authority over the workspace + // or WORKSPACE_ADMIN authority + if (authority !== Authority.READ_PROJECT) { + if ( + !permittedAuthoritiesForWorkspace.has(authority) && + !permittedAuthoritiesForWorkspace.has(Authority.WORKSPACE_ADMIN) + ) { + throw new UnauthorizedException( + `User with id ${userId} does not have the authority in the project with id ${entity?.id}` + ) + } + } break case ProjectAccessLevel.INTERNAL: // Any workspace member with the required collective authority over the workspace or diff --git a/apps/api/src/environment/dto/create.environment/create.environment.ts b/apps/api/src/environment/dto/create.environment/create.environment.ts index 4b3f721a..00631dfd 100644 --- a/apps/api/src/environment/dto/create.environment/create.environment.ts +++ b/apps/api/src/environment/dto/create.environment/create.environment.ts @@ -6,7 +6,7 @@ export class CreateEnvironment { @IsString() @IsOptional() - description: string + description?: string @IsBoolean() @IsOptional() diff --git a/apps/api/src/prisma/migrations/20240521110332_add_project_fork/migration.sql b/apps/api/src/prisma/migrations/20240521110332_add_project_fork/migration.sql new file mode 100644 index 00000000..fb58831a --- /dev/null +++ b/apps/api/src/prisma/migrations/20240521110332_add_project_fork/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "forkedFromId" TEXT, +ADD COLUMN "isForked" BOOLEAN NOT NULL DEFAULT false; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_forkedFromId_fkey" FOREIGN KEY ("forkedFromId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma index 5ef5741b..571985aa 100644 --- a/apps/api/src/prisma/schema.prisma +++ b/apps/api/src/prisma/schema.prisma @@ -300,6 +300,7 @@ model Project { isDisabled Boolean @default(false) // This is set to true when the user stops his subscription and still has premium features in use accessLevel ProjectAccessLevel @default(PRIVATE) pendingCreation Boolean @default(false) + isForked Boolean @default(false) lastUpdatedBy User? @relation(fields: [lastUpdatedById], references: [id], onUpdate: Cascade, onDelete: SetNull) lastUpdatedById String? @@ -311,6 +312,10 @@ model Project { environments Environment[] workspaceRoles ProjectWorkspaceRoleAssociation[] integrations Integration[] + forks Project[] @relation("Fork") + + forkedFromId String? + forkedFrom Project? @relation("Fork", fields: [forkedFromId], references: [id], onDelete: SetNull, onUpdate: Cascade) } model ProjectWorkspaceRoleAssociation { diff --git a/apps/api/src/project/controller/project.controller.ts b/apps/api/src/project/controller/project.controller.ts index 4cb271e5..ef928f75 100644 --- a/apps/api/src/project/controller/project.controller.ts +++ b/apps/api/src/project/controller/project.controller.ts @@ -15,6 +15,7 @@ import { CreateProject } from '../dto/create.project/create.project' import { UpdateProject } from '../dto/update.project/update.project' import { RequiredApiKeyAuthorities } from '../../decorators/required-api-key-authorities.decorator' import { AlphanumericReasonValidationPipe } from '../../common/alphanumeric-reason-pipe' +import { ForkProject } from '../dto/fork.project/fork.project' @Controller('project') export class ProjectController { @@ -61,12 +62,52 @@ export class ProjectController { return await this.service.getProjectById(user, projectId) } + @Post(':projectId/fork') + @RequiredApiKeyAuthorities(Authority.READ_PROJECT, Authority.CREATE_PROJECT) + async forkProject( + @CurrentUser() user: User, + @Param('projectId') projectId: Project['id'], + @Body() forkMetadata: ForkProject + ) { + return await this.service.forkProject(user, projectId, forkMetadata) + } + + @Put(':projectId/sync-fork') + @RequiredApiKeyAuthorities(Authority.READ_PROJECT, Authority.UPDATE_PROJECT) + async syncFork( + @CurrentUser() user: User, + @Param('projectId') projectId: Project['id'], + @Param('hardSync') hardSync: boolean = false + ) { + return await this.service.syncFork(user, projectId, hardSync) + } + + @Put(':projectId/unlink-fork') + @RequiredApiKeyAuthorities(Authority.UPDATE_PROJECT) + async unlinkFork( + @CurrentUser() user: User, + @Param('projectId') projectId: Project['id'] + ) { + return await this.service.unlinkParentOfFork(user, projectId) + } + + @Get(':projectId/forks') + @RequiredApiKeyAuthorities(Authority.READ_PROJECT) + async getForks( + @CurrentUser() user: User, + @Param('projectId') projectId: Project['id'], + @Query('page') page: number = 0, + @Query('limit') limit: number = 10 + ) { + return await this.service.getAllProjectForks(user, projectId, page, limit) + } + @Get('/all/:workspaceId') @RequiredApiKeyAuthorities(Authority.READ_PROJECT) async getAllProjects( @CurrentUser() user: User, @Param('workspaceId') workspaceId: Workspace['id'], - @Query('page') page: number = 1, + @Query('page') page: number = 0, @Query('limit') limit: number = 10, @Query('sort') sort: string = 'name', @Query('order') order: string = 'asc', diff --git a/apps/api/src/project/dto/fork.project/fork.project.spec.ts b/apps/api/src/project/dto/fork.project/fork.project.spec.ts new file mode 100644 index 00000000..ed2812ac --- /dev/null +++ b/apps/api/src/project/dto/fork.project/fork.project.spec.ts @@ -0,0 +1,7 @@ +import { ForkProject } from './fork.project' + +describe('ForkProject', () => { + it('should be defined', () => { + expect(new ForkProject()).toBeDefined() + }) +}) diff --git a/apps/api/src/project/dto/fork.project/fork.project.ts b/apps/api/src/project/dto/fork.project/fork.project.ts new file mode 100644 index 00000000..f8814a69 --- /dev/null +++ b/apps/api/src/project/dto/fork.project/fork.project.ts @@ -0,0 +1,16 @@ +import { Workspace } from '@prisma/client' +import { IsOptional, IsString } from 'class-validator' + +export class ForkProject { + @IsString() + @IsOptional() + workspaceId?: Workspace['id'] + + @IsString() + @IsOptional() + name?: string + + @IsString() + @IsOptional() + storePrivateKey?: boolean +} diff --git a/apps/api/src/project/project.e2e.spec.ts b/apps/api/src/project/project.e2e.spec.ts index 6ead0efc..00c2092d 100644 --- a/apps/api/src/project/project.e2e.spec.ts +++ b/apps/api/src/project/project.e2e.spec.ts @@ -10,13 +10,17 @@ import { MAIL_SERVICE } from '../mail/services/interface.service' import { MockMailService } from '../mail/services/mock.service' import cleanUp from '../common/cleanup' import { + Authority, + Environment, EventSeverity, EventSource, EventTriggerer, EventType, Project, ProjectAccessLevel, + Secret, User, + Variable, Workspace } from '@prisma/client' import fetchEvents from '../common/fetch-events' @@ -27,6 +31,14 @@ import { WorkspaceService } from '../workspace/service/workspace.service' import { UserService } from '../user/service/user.service' import { WorkspaceModule } from '../workspace/workspace.module' import { UserModule } from '../user/user.module' +import { WorkspaceRoleModule } from '../workspace-role/workspace-role.module' +import { WorkspaceRoleService } from '../workspace-role/service/workspace-role.service' +import { EnvironmentService } from '../environment/service/environment.service' +import { SecretService } from '../secret/service/secret.service' +import { VariableService } from '../variable/service/variable.service' +import { VariableModule } from '../variable/variable.module' +import { SecretModule } from '../secret/secret.module' +import { EnvironmentModule } from '../environment/environment.module' describe('Project Controller Tests', () => { let app: NestFastifyApplication @@ -35,11 +47,14 @@ describe('Project Controller Tests', () => { let projectService: ProjectService let workspaceService: WorkspaceService let userService: UserService + let workspaceRoleService: WorkspaceRoleService + let environmentService: EnvironmentService + let secretService: SecretService + let variableService: VariableService let user1: User, user2: User let workspace1: Workspace, workspace2: Workspace - let project1: Project, project2: Project - let globalProject: Project, internalProject: Project + let project1: Project, project2: Project, project3: Project beforeAll(async () => { const moduleRef = await Test.createTestingModule({ @@ -48,7 +63,11 @@ describe('Project Controller Tests', () => { ProjectModule, EventModule, WorkspaceModule, - UserModule + UserModule, + WorkspaceRoleModule, + EnvironmentModule, + SecretModule, + VariableModule ] }) .overrideProvider(MAIL_SERVICE) @@ -63,6 +82,10 @@ describe('Project Controller Tests', () => { projectService = moduleRef.get(ProjectService) workspaceService = moduleRef.get(WorkspaceService) userService = moduleRef.get(UserService) + workspaceRoleService = moduleRef.get(WorkspaceRoleService) + environmentService = moduleRef.get(EnvironmentService) + secretService = moduleRef.get(SecretService) + variableService = moduleRef.get(VariableService) await app.init() await app.getHttpAdapter().getInstance().ready() @@ -107,6 +130,13 @@ describe('Project Controller Tests', () => { description: 'Project 2 description', storePrivateKey: false })) as Project + + project3 = (await projectService.createProject(user1, workspace1.id, { + name: 'Project for fork', + description: 'Project for fork', + storePrivateKey: true, + accessLevel: ProjectAccessLevel.GLOBAL + })) as Project }) afterEach(async () => { @@ -121,6 +151,10 @@ describe('Project Controller Tests', () => { expect(projectService).toBeDefined() expect(workspaceService).toBeDefined() expect(userService).toBeDefined() + expect(workspaceRoleService).toBeDefined() + expect(environmentService).toBeDefined() + expect(secretService).toBeDefined() + expect(variableService).toBeDefined() }) it('should allow workspace member to create a project', async () => { @@ -138,21 +172,19 @@ describe('Project Controller Tests', () => { }) expect(response.statusCode).toBe(201) - expect(response.json()).toEqual({ - id: expect.any(String), - name: 'Project 3', - description: 'Project 3 description', - storePrivateKey: true, - workspaceId: workspace1.id, - lastUpdatedById: user1.id, - isDisabled: false, - accessLevel: ProjectAccessLevel.PRIVATE, - publicKey: expect.any(String), - privateKey: expect.any(String), - createdAt: expect.any(String), - updatedAt: expect.any(String), - pendingCreation: false - }) + expect(response.json().id).toBeDefined() + expect(response.json().name).toBe('Project 3') + expect(response.json().description).toBe('Project 3 description') + expect(response.json().storePrivateKey).toBe(true) + expect(response.json().workspaceId).toBe(workspace1.id) + expect(response.json().lastUpdatedById).toBe(user1.id) + expect(response.json().isDisabled).toBe(false) + expect(response.json().accessLevel).toBe(ProjectAccessLevel.PRIVATE) + expect(response.json().publicKey).toBeDefined() + expect(response.json().privateKey).toBeDefined() + expect(response.json().createdAt).toBeDefined() + expect(response.json().updatedAt).toBeDefined() + expect(response.json().pendingCreation).toBe(false) }) it('should have created a default environment', async () => { @@ -223,7 +255,7 @@ describe('Project Controller Tests', () => { }) expect(adminRole).toBeDefined() - expect(adminRole.projects).toHaveLength(1) + expect(adminRole.projects).toHaveLength(2) expect(adminRole.projects[0].projectId).toBe(project1.id) }) @@ -285,22 +317,15 @@ describe('Project Controller Tests', () => { }) expect(response.statusCode).toBe(200) - expect(response.json()).toEqual({ - id: project1.id, - name: 'Project 1 Updated', - description: 'Project 1 description updated', - storePrivateKey: true, - workspaceId: workspace1.id, - lastUpdatedById: user1.id, - isDisabled: false, - accessLevel: ProjectAccessLevel.PRIVATE, - publicKey: project1.publicKey, - createdAt: expect.any(String), - updatedAt: expect.any(String), - pendingCreation: false - }) - - project1 = response.json() + expect(response.json().id).toBe(project1.id) + expect(response.json().name).toBe('Project 1 Updated') + expect(response.json().description).toBe('Project 1 description updated') + expect(response.json().storePrivateKey).toBe(true) + expect(response.json().workspaceId).toBe(workspace1.id) + expect(response.json().lastUpdatedById).toBe(user1.id) + expect(response.json().isDisabled).toBe(false) + expect(response.json().accessLevel).toBe(ProjectAccessLevel.PRIVATE) + expect(response.json().publicKey).toBe(project1.publicKey) }) it('should not be able to update the name of a project to an existing name', async () => { @@ -402,8 +427,7 @@ describe('Project Controller Tests', () => { ...project1, lastUpdatedById: user1.id, createdAt: expect.any(String), - updatedAt: expect.any(String), - secrets: [] + updatedAt: expect.any(String) }) }) @@ -451,16 +475,7 @@ describe('Project Controller Tests', () => { }) expect(response.statusCode).toBe(200) - expect(response.json()).toEqual([ - { - ...project1, - lastUpdatedById: user1.id, - createdAt: expect.any(String), - updatedAt: expect.any(String), - publicKey: undefined, - privateKey: undefined - } - ]) + expect(response.json().length).toEqual(2) }) it('should not be able to fetch all projects of a non existing workspace', async () => { @@ -586,8 +601,6 @@ describe('Project Controller Tests', () => { expect(response.statusCode).toBe(200) expect(response.json().publicKey).not.toBeNull() expect(response.json().privateKey).not.toBeNull() - - project1 = response.json() }) it('should not regenerate key-pair if regenerateKeyPair is true and the project does not store the private key and a private key is not specified', async () => { @@ -602,9 +615,7 @@ describe('Project Controller Tests', () => { } }) - expect(response.statusCode).toBe(200) - expect(response.json().publicKey).toEqual(project2.publicKey) - expect(response.json().privateKey).toBeUndefined() + expect(response.statusCode).toBe(400) }) it('should be able to delete a project', async () => { @@ -667,7 +678,7 @@ describe('Project Controller Tests', () => { }) expect(adminRole).toBeDefined() - expect(adminRole.projects).toHaveLength(0) + expect(adminRole.projects).toHaveLength(1) }) it('should not be able to delete a non existing project', async () => { @@ -705,6 +716,8 @@ describe('Project Controller Tests', () => { }) describe('Project Controller tests for access levels', () => { + let globalProject: Project, internalProject: Project + beforeEach(async () => { globalProject = (await projectService.createProject( user1, @@ -748,8 +761,7 @@ describe('Project Controller Tests', () => { ...globalProject, lastUpdatedById: user1.id, createdAt: expect.any(String), - updatedAt: expect.any(String), - secrets: [] + updatedAt: expect.any(String) }) }) @@ -767,8 +779,7 @@ describe('Project Controller Tests', () => { ...internalProject, lastUpdatedById: user1.id, createdAt: expect.any(String), - updatedAt: expect.any(String), - secrets: [] + updatedAt: expect.any(String) }) }) @@ -788,6 +799,178 @@ describe('Project Controller Tests', () => { message: `User with id ${user2.id} does not have the authority in the project with id ${internalProject.id}` }) }) + + it('should not allow outsiders to update a GLOBAL project', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/project/${globalProject.id}`, + payload: { + name: 'Global Project Updated' + }, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + expect(response.json()).toEqual({ + statusCode: 401, + error: 'Unauthorized', + message: `User with id ${user2.id} does not have the authority in the project with id ${globalProject.id}` + }) + }) + + it('should store private key even if specified not to in a global project', async () => { + const project = (await projectService.createProject( + user1, + workspace1.id, + { + name: 'Global Project 2', + description: 'Global Project description', + storePrivateKey: false, + accessLevel: ProjectAccessLevel.GLOBAL + } + )) as Project + + expect(project).toBeDefined() + expect(project.privateKey).not.toBeNull() + expect(project.publicKey).not.toBeNull() + expect(project.storePrivateKey).toBe(true) + }) + + it('should require WORKSPACE_ADMIN authority to alter the access level', async () => { + // Create a user + const johnny = await userService.createUser({ + name: 'Johnny Doe', + email: 'johhny@keyshade.xyz', + isOnboardingFinished: true, + isActive: true, + isAdmin: false + }) + + // Create a member role for the workspace + const role = await workspaceRoleService.createWorkspaceRole( + user1, + workspace1.id, + { + name: 'Member', + authorities: [Authority.READ_PROJECT] + } + ) + + // Add user to workspace as a member + await workspaceService.inviteUsersToWorkspace(user1, workspace1.id, [ + { + email: johnny.email, + roleIds: [role.id] + } + ]) + + // Accept the invitation on behalf of the user + await workspaceService.acceptInvitation(johnny, workspace1.id) + + // Update the access level of the project + const response = await app.inject({ + method: 'PUT', + url: `/project/${internalProject.id}`, + payload: { + accessLevel: ProjectAccessLevel.INTERNAL + }, + headers: { + 'x-e2e-user-email': johnny.email + } + }) + + expect(response.statusCode).toBe(401) + }) + + it('should store the private key if access level of INTERNAL/PRIVATE project is updated to GLOBAL', async () => { + // Create a project with access level INTERNAL + const project = (await projectService.createProject( + user1, + workspace1.id, + { + name: 'Internal Project 2', + description: 'Internal Project description', + storePrivateKey: true, + accessLevel: ProjectAccessLevel.INTERNAL + } + )) as Project + + // Update the access level of the project to GLOBAL + const updatedProject = (await projectService.updateProject( + user1, + project.id, + { + accessLevel: ProjectAccessLevel.GLOBAL + } + )) as Project + + expect(updatedProject).toBeDefined() + expect(updatedProject.privateKey).toBe(project.privateKey) + expect(updatedProject.publicKey).toBe(project.publicKey) + expect(updatedProject.storePrivateKey).toBe(true) + }) + + it('should throw an error while setting access level to GLOBAL if private key is not specified and project does not store private key', async () => { + const project = (await projectService.createProject( + user1, + workspace1.id, + { + name: 'Internal Project 2', + description: 'Internal Project description', + storePrivateKey: false, + accessLevel: ProjectAccessLevel.INTERNAL + } + )) as Project + + const response = await app.inject({ + method: 'PUT', + url: `/project/${project.id}`, + payload: { + accessLevel: ProjectAccessLevel.GLOBAL + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: 'Private key is required to make the project GLOBAL' + }) + }) + + it('should regenerate key-pair if access level of GLOBAL project is updated to INTERNAL or PRIVATE', async () => { + const project = (await projectService.createProject( + user1, + workspace1.id, + { + name: 'Global Project 2', + description: 'Global Project description', + storePrivateKey: true, + accessLevel: ProjectAccessLevel.GLOBAL + } + )) as Project + + const response = await app.inject({ + method: 'PUT', + url: `/project/${project.id}`, + payload: { + accessLevel: ProjectAccessLevel.INTERNAL + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().publicKey).not.toBe(project.publicKey) + expect(response.json().privateKey).not.toBe(project.privateKey) + expect(response.json().storePrivateKey).toBe(false) + }) }) it('should allow users with sufficient access to access a private project', async () => { @@ -815,8 +998,7 @@ describe('Project Controller Tests', () => { ...privateProject, lastUpdatedById: user1.id, createdAt: expect.any(String), - updatedAt: expect.any(String), - secrets: [] + updatedAt: expect.any(String) }) }) @@ -848,7 +1030,608 @@ describe('Project Controller Tests', () => { }) }) - afterAll(async () => { - await cleanUp(prisma) + describe('Project Controller tests for forking', () => { + it('should be able to fork a project', async () => { + const forkedProject = (await projectService.forkProject( + user2, + project3.id, + { + name: 'Forked Project' + } + )) as Project + + expect(forkedProject).toBeDefined() + expect(forkedProject.name).toBe('Forked Project') + expect(forkedProject.publicKey).toBeDefined() + expect(forkedProject.privateKey).toBeDefined() + expect(forkedProject.publicKey).not.toBe(project3.publicKey) + expect(forkedProject.privateKey).not.toBe(project3.privateKey) + expect(forkedProject.storePrivateKey).toBe(true) + expect(forkedProject.isForked).toBe(true) + expect(forkedProject.forkedFromId).toBe(project3.id) + + const forkedProjectFromDB = await prisma.project.findUnique({ + where: { + id: forkedProject.id + } + }) + + expect(forkedProjectFromDB).toBeDefined() + expect(forkedProjectFromDB.name).toBe('Forked Project') + expect(forkedProjectFromDB.publicKey).toBeDefined() + expect(forkedProjectFromDB.privateKey).toBeDefined() + expect(forkedProjectFromDB.publicKey).not.toBe(project3.publicKey) + expect(forkedProjectFromDB.privateKey).not.toBe(project3.privateKey) + expect(forkedProjectFromDB.storePrivateKey).toBe(true) + expect(forkedProjectFromDB.isForked).toBe(true) + expect(forkedProjectFromDB.forkedFromId).toBe(project3.id) + }) + + it('should not be able to fork a project that does not exist', async () => { + const response = await app.inject({ + method: 'POST', + url: `/project/123/fork`, + payload: { + name: 'Forked Project' + }, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json()).toEqual({ + statusCode: 404, + error: 'Not Found', + message: `Project with id 123 not found` + }) + }) + + it('should not be able to fork a project that is not GLOBAL', async () => { + const response = await app.inject({ + method: 'POST', + url: `/project/${project2.id}/fork`, + payload: { + name: 'Forked Project' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(401) + expect(response.json()).toEqual({ + statusCode: 401, + error: 'Unauthorized', + message: `User with id ${user1.id} does not have the authority in the project with id ${project2.id}` + }) + }) + + it('should fork the project in the default workspace if workspace id is not specified', async () => { + const forkedProject = (await projectService.forkProject( + user2, + project3.id, + { + name: 'Forked Project' + } + )) as Project + + expect(forkedProject.workspaceId).toBe(workspace2.id) + }) + + it('should fork the project in the specific workspace if the ID is provided in the payload', async () => { + const newWorkspace = (await workspaceService.createWorkspace(user2, { + name: 'New Workspace' + })) as Workspace + + const forkedProject = (await projectService.forkProject( + user2, + project3.id, + { + name: 'Forked Project', + workspaceId: newWorkspace.id + } + )) as Project + + expect(forkedProject.workspaceId).toBe(newWorkspace.id) + }) + + it('should not be able to create a fork with the same name in a workspace', async () => { + await projectService.createProject(user2, workspace2.id, { + name: 'Forked Project', + description: 'Forked Project description', + storePrivateKey: true, + accessLevel: ProjectAccessLevel.GLOBAL + }) + + const response = await app.inject({ + method: 'POST', + url: `/project/${project3.id}/fork`, + payload: { + name: 'Forked Project' + }, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(409) + expect(response.json()).toEqual({ + statusCode: 409, + error: 'Conflict', + message: `Project with this name **Forked Project** already exists in the selected workspace` + }) + }) + + it('should copy over all environments, secrets and variables into the forked project', async () => { + // Add an environment to the project + const environment = (await environmentService.createEnvironment( + user1, + { + name: 'Dev' + }, + project3.id + )) as Environment + + // Add two secrets + const secret1 = (await secretService.createSecret( + user1, + { + name: 'API_KEY', + value: 'some_key', + environmentId: environment.id + }, + project3.id + )) as Secret + + const secret2 = (await secretService.createSecret( + user1, + { + name: 'DB_PASSWORD', + value: 'password' + }, + project3.id + )) as Secret + + // Add two variables + const variable1 = (await variableService.createVariable( + user1, + { + name: 'PORT', + value: '8080', + environmentId: environment.id + }, + project3.id + )) as Variable + + const variable2 = (await variableService.createVariable( + user1, + { + name: 'EXPIRY', + value: '3600' + }, + project3.id + )) as Variable + + // Try forking the project + const forkedProject = await projectService.forkProject( + user2, + project3.id, + { + name: 'Forked Project' + } + ) + + // Fetch the environments of the forked project + // (there will be 2 because a default environment is always created) + const forkedEnvironments = await prisma.environment.findMany({ + where: { + projectId: forkedProject.id + } + }) + + // Fetch the secrets of the forked project + const forkedSecrets = await prisma.secret.findMany({ + where: { + projectId: forkedProject.id + } + }) + + // Fetch the variables of the forked project + const forkedVariables = await prisma.variable.findMany({ + where: { + projectId: forkedProject.id + } + }) + + expect(forkedEnvironments).toHaveLength(2) + expect(forkedSecrets).toHaveLength(2) + expect(forkedVariables).toHaveLength(2) + + const [defaultEnvironment, devEnvironment] = forkedEnvironments + const [secretInDefaultEnvironment, secretInDevEnvironment] = forkedSecrets + const [variableInDefaultEnvironment, variableInDevEnvironment] = + forkedVariables + + expect(secretInDefaultEnvironment).toBeDefined() + expect(secretInDefaultEnvironment.name).toBe(secret2.name) + expect(secretInDefaultEnvironment.environmentId).toBe( + defaultEnvironment.id + ) + + expect(secretInDevEnvironment).toBeDefined() + expect(secretInDevEnvironment.name).toBe(secret1.name) + expect(secretInDevEnvironment.environmentId).toBe(devEnvironment.id) + + expect(variableInDefaultEnvironment).toBeDefined() + expect(variableInDefaultEnvironment.name).toBe(variable2.name) + expect(variableInDefaultEnvironment.environmentId).toBe( + defaultEnvironment.id + ) + + expect(variableInDevEnvironment).toBeDefined() + expect(variableInDevEnvironment.name).toBe(variable1.name) + expect(variableInDevEnvironment.environmentId).toBe(devEnvironment.id) + }) + + it('should only copy new environments, secrets and variables if sync is not hard', async () => { + // Add an environment to the project + const environment = (await environmentService.createEnvironment( + user1, + { + name: 'Dev' + }, + project3.id + )) as Environment + + // Add two secrets + await secretService.createSecret( + user1, + { + name: 'API_KEY', + value: 'some_key', + environmentId: environment.id + }, + project3.id + ) + + await secretService.createSecret( + user1, + { + name: 'DB_PASSWORD', + value: 'password' + }, + project3.id + ) + + // Add two variables + await variableService.createVariable( + user1, + { + name: 'PORT', + value: '8080', + environmentId: environment.id + }, + project3.id + ) + + await variableService.createVariable( + user1, + { + name: 'EXPIRY', + value: '3600' + }, + project3.id + ) + + // Try forking the project + const forkedProject = await projectService.forkProject( + user2, + project3.id, + { + name: 'Forked Project' + } + ) + + // Add a new environment to the original project + const newEnvironmentOriginal = + (await environmentService.createEnvironment( + user1, + { + name: 'Prod' + }, + project3.id + )) as Environment + + // Add a new secret to the original project + await secretService.createSecret( + user1, + { + name: 'NEW_SECRET', + value: 'new_secret', + environmentId: newEnvironmentOriginal.id + }, + project3.id + ) + + // Add a new variable to the original project + await variableService.createVariable( + user1, + { + name: 'NEW_VARIABLE', + value: 'new_variable', + environmentId: newEnvironmentOriginal.id + }, + project3.id + ) + + // Add a new environment to the forked project + const newEnvironmentForked = (await environmentService.createEnvironment( + user2, + { + name: 'Stage' + }, + forkedProject.id + )) as Environment + + // Add a new secret to the forked project + await secretService.createSecret( + user2, + { + name: 'NEW_SECRET_2', + value: 'new_secret', + environmentId: newEnvironmentForked.id + }, + forkedProject.id + ) + + // Add a new variable to the forked project + await variableService.createVariable( + user2, + { + name: 'NEW_VARIABLE_2', + value: 'new_variable', + environmentId: newEnvironmentForked.id + }, + forkedProject.id + ) + + // Sync the fork + await app.inject({ + method: 'PUT', + url: `/project/${forkedProject.id}/sync-fork`, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + // Fetch the environments of the forked project + const forkedEnvironments = await prisma.environment.findMany({ + where: { + projectId: forkedProject.id + } + }) + + // Fetch the secrets of the forked project + const forkedSecrets = await prisma.secret.findMany({ + where: { + projectId: forkedProject.id + } + }) + + // Fetch the variables of the forked project + const forkedVariables = await prisma.variable.findMany({ + where: { + projectId: forkedProject.id + } + }) + + expect(forkedEnvironments).toHaveLength(4) + expect(forkedSecrets).toHaveLength(4) + expect(forkedVariables).toHaveLength(4) + }) + + it('should only replace environments, secrets and variables if sync is hard', async () => { + const environment = (await environmentService.createEnvironment( + user1, + { + name: 'Dev' + }, + project3.id + )) as Environment + + // Add two secrets + await secretService.createSecret( + user1, + { + name: 'API_KEY', + value: 'some_key', + environmentId: environment.id + }, + project3.id + ) + + await secretService.createSecret( + user1, + { + name: 'DB_PASSWORD', + value: 'password' + }, + project3.id + ) + + // Add two variables + await variableService.createVariable( + user1, + { + name: 'PORT', + value: '8080', + environmentId: environment.id + }, + project3.id + ) + + await variableService.createVariable( + user1, + { + name: 'EXPIRY', + value: '3600' + }, + project3.id + ) + + // Try forking the project + const forkedProject = await projectService.forkProject( + user2, + project3.id, + { + name: 'Forked Project' + } + ) + + // Add a new environment to the original project + const newEnvironmentOriginal = + (await environmentService.createEnvironment( + user1, + { + name: 'Prod' + }, + project3.id + )) as Environment + + // Add a new secret to the original project + await secretService.createSecret( + user1, + { + name: 'NEW_SECRET', + value: 'new_secret', + environmentId: newEnvironmentOriginal.id + }, + project3.id + ) + + // Add a new variable to the original project + await variableService.createVariable( + user1, + { + name: 'NEW_VARIABLE', + value: 'new_variable', + environmentId: newEnvironmentOriginal.id + }, + project3.id + ) + + // Add a new environment to the forked project + const newEnvironmentForked = (await environmentService.createEnvironment( + user2, + { + name: 'Prod' + }, + forkedProject.id + )) as Environment + + // Add a new secret to the forked project + await secretService.createSecret( + user2, + { + name: 'NEW_SECRET', + value: 'new_secret', + environmentId: newEnvironmentForked.id + }, + forkedProject.id + ) + + // Add a new variable to the forked project + await variableService.createVariable( + user2, + { + name: 'NEW_VARIABLE', + value: 'new_variable', + environmentId: newEnvironmentForked.id + }, + forkedProject.id + ) + + // Sync the fork + await app.inject({ + method: 'PUT', + url: `/project/${forkedProject.id}/sync-fork?hardSync=true`, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + // Fetch the environments of the forked project + const forkedEnvironments = await prisma.environment.findMany({ + where: { + projectId: forkedProject.id + } + }) + + // Fetch the secrets of the forked project + const forkedSecrets = await prisma.secret.findMany({ + where: { + projectId: forkedProject.id + } + }) + + // Fetch the variables of the forked project + const forkedVariables = await prisma.variable.findMany({ + where: { + projectId: forkedProject.id + } + }) + + expect(forkedEnvironments).toHaveLength(3) + expect(forkedSecrets).toHaveLength(3) + expect(forkedVariables).toHaveLength(3) + }) + + it('should be able to unlink a forked project', async () => { + const forkedProject = await projectService.forkProject( + user2, + project3.id, + { + name: 'Forked Project' + } + ) + + const response = await app.inject({ + method: 'PUT', + url: `/project/${forkedProject.id}/unlink-fork`, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(200) + + const forkedProjectFromDB = await prisma.project.findUnique({ + where: { + id: forkedProject.id + } + }) + + expect(forkedProjectFromDB).toBeDefined() + expect(forkedProjectFromDB.isForked).toBe(false) + expect(forkedProjectFromDB.forkedFromId).toBeNull() + }) + + it('should be able to fetch all forked projects of a project', async () => { + await projectService.forkProject(user2, project3.id, { + name: 'Forked Project' + }) + + const response = await app.inject({ + method: 'GET', + url: `/project/${project3.id}/forks`, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toHaveLength(1) + }) }) }) diff --git a/apps/api/src/project/service/project.service.ts b/apps/api/src/project/service/project.service.ts index f0b582eb..0cb88471 100644 --- a/apps/api/src/project/service/project.service.ts +++ b/apps/api/src/project/service/project.service.ts @@ -1,14 +1,23 @@ -import { ConflictException, Injectable, Logger } from '@nestjs/common' +import { + BadRequestException, + ConflictException, + Injectable, + Logger +} from '@nestjs/common' import { ApprovalAction, ApprovalItemType, ApprovalStatus, Authority, + Environment, EventSource, EventType, Project, + ProjectAccessLevel, + Secret, SecretVersion, User, + Variable, Workspace } from '@prisma/client' import { CreateProject } from '../dto/create.project/create.project' @@ -25,6 +34,9 @@ import createApproval from '../../common/create-approval' import { UpdateProjectMetadata } from '../../approval/approval.types' import { ProjectWithSecrets } from '../project.types' import { AuthorityCheckerService } from '../../common/authority-checker.service' +import { ForkProject } from '../dto/fork.project/fork.project' +import { SecretWithEnvironment } from 'src/secret/secret.types' +import { VariableWithEnvironment } from 'src/variable/variable.types' @Injectable() export class ProjectService { @@ -67,7 +79,10 @@ export class ProjectService { const data: any = { name: dto.name, description: dto.description, - storePrivateKey: dto.storePrivateKey, + storePrivateKey: + dto.accessLevel === ProjectAccessLevel.GLOBAL + ? true + : dto.storePrivateKey, // If the project is global, the private key must be stored publicKey, accessLevel: dto.accessLevel, pendingCreation: approvalEnabled @@ -222,11 +237,17 @@ export class ProjectService { dto: UpdateProject, reason?: string ) { + // Check if the user has the authority to update the project + let authority: Authority = Authority.UPDATE_PROJECT + + // Only admins can change the visibility of the project + if (dto.accessLevel) authority = Authority.WORKSPACE_ADMIN + const project = await this.authorityCheckerService.checkAuthorityOverProject({ userId: user.id, entity: { id: projectId }, - authority: Authority.UPDATE_PROJECT, + authority, prisma: this.prisma }) @@ -239,6 +260,37 @@ export class ProjectService { `Project with this name **${dto.name}** already exists` ) + if (dto.accessLevel) { + const currentAccessLevel = project.accessLevel + + if ( + currentAccessLevel !== ProjectAccessLevel.GLOBAL && + dto.accessLevel === ProjectAccessLevel.GLOBAL + ) { + // If the project is being made global, the private key must be stored + // This is because we want anyone to see the secrets in the project + dto.storePrivateKey = true + dto.privateKey = dto.privateKey || project.privateKey + + // We can't make the project global if a private key isn't supplied, + // because we need to decrypt the secrets + if (!dto.privateKey) { + throw new BadRequestException( + 'Private key is required to make the project GLOBAL' + ) + } + } else if ( + currentAccessLevel === ProjectAccessLevel.GLOBAL && + dto.accessLevel !== ProjectAccessLevel.GLOBAL + ) { + dto.storePrivateKey = false + dto.regenerateKeyPair = true + + // At this point, we already will have the private key since the project is global + dto.privateKey = project.privateKey + } + } + if ( !project.pendingCreation && (await workspaceApprovalEnabled(project.workspaceId, this.prisma)) @@ -260,6 +312,197 @@ export class ProjectService { } } + async forkProject( + user: User, + projectId: Project['id'], + forkMetadata: ForkProject + ) { + const project = + await this.authorityCheckerService.checkAuthorityOverProject({ + userId: user.id, + entity: { id: projectId }, + authority: Authority.READ_PROJECT, + prisma: this.prisma + }) + + let workspaceId = forkMetadata.workspaceId + + if (workspaceId) { + await this.authorityCheckerService.checkAuthorityOverWorkspace({ + userId: user.id, + entity: { id: workspaceId }, + authority: Authority.CREATE_PROJECT, + prisma: this.prisma + }) + } else { + const defaultWorkspace = await this.prisma.workspaceMember.findFirst({ + where: { + userId: user.id, + workspace: { + isDefault: true + } + } + }) + workspaceId = defaultWorkspace.workspaceId + } + + const newProjectName = forkMetadata.name || project.name + + // Check if project with this name already exists for the user + if (await this.projectExists(newProjectName, workspaceId)) + throw new ConflictException( + `Project with this name **${newProjectName}** already exists in the selected workspace` + ) + + const { privateKey, publicKey } = createKeyPair() + const userId = user.id + const newProjectId = v4() + const adminRole = await this.prisma.workspaceRole.findFirst({ + where: { + workspaceId, + hasAdminAuthority: true + } + }) + + // Create and return the project + const createNewProject = this.prisma.project.create({ + data: { + id: newProjectId, + name: newProjectName, + description: project.description, + storePrivateKey: + forkMetadata.storePrivateKey || project.storePrivateKey, + publicKey: publicKey, + privateKey: + forkMetadata.storePrivateKey || project.storePrivateKey + ? privateKey + : null, + accessLevel: project.accessLevel, + pendingCreation: false, + isForked: true, + forkedFromId: project.id, + workspaceId: workspaceId, + lastUpdatedById: userId + } + }) + + const addProjectToAdminRoleOfItsWorkspace = + this.prisma.workspaceRole.update({ + where: { + id: adminRole.id + }, + data: { + projects: { + create: { + project: { + connect: { + id: newProjectId + } + } + } + } + } + }) + + const copyProjectOp = await this.copyProjectData( + user, + { + id: project.id, + privateKey: project.privateKey + }, + { + id: newProjectId, + publicKey + }, + true + ) + + const [newProject] = await this.prisma.$transaction([ + createNewProject, + addProjectToAdminRoleOfItsWorkspace, + ...copyProjectOp + ]) + + await createEvent( + { + triggeredBy: user, + entity: newProject, + type: EventType.PROJECT_CREATED, + source: EventSource.PROJECT, + title: `Project created`, + metadata: { + projectId: newProject.id, + name: newProject.name, + workspaceId, + workspaceName: workspaceId + }, + workspaceId + }, + this.prisma + ) + + this.log.debug(`Created project ${newProject}`) + return newProject + } + + async unlinkParentOfFork(user: User, projectId: Project['id']) { + await this.authorityCheckerService.checkAuthorityOverProject({ + userId: user.id, + entity: { id: projectId }, + authority: Authority.UPDATE_PROJECT, + prisma: this.prisma + }) + + await this.prisma.project.update({ + where: { + id: projectId + }, + data: { + isForked: false, + forkedFromId: null + } + }) + } + + async syncFork(user: User, projectId: Project['id'], hardSync: boolean) { + const project = + await this.authorityCheckerService.checkAuthorityOverProject({ + userId: user.id, + entity: { id: projectId }, + authority: Authority.UPDATE_PROJECT, + prisma: this.prisma + }) + + if (!project.isForked || project.forkedFromId == null) { + throw new BadRequestException( + `Project with id ${projectId} is not a forked project` + ) + } + + const parentProject = + await this.authorityCheckerService.checkAuthorityOverProject({ + userId: user.id, + entity: { id: project.forkedFromId }, + authority: Authority.READ_PROJECT, + prisma: this.prisma + }) + + const copyProjectOp = await this.copyProjectData( + user, + { + id: parentProject.id, + privateKey: parentProject.privateKey + }, + { + id: projectId, + publicKey: project.publicKey + }, + hardSync + ) + + await this.prisma.$transaction(copyProjectOp) + } + async deleteProject(user: User, projectId: Project['id'], reason?: string) { const project = await this.authorityCheckerService.checkAuthorityOverProject({ @@ -286,6 +529,40 @@ export class ProjectService { } } + async getAllProjectForks( + user: User, + projectId: Project['id'], + page: number, + limit: number + ) { + await this.authorityCheckerService.checkAuthorityOverProject({ + userId: user.id, + entity: { id: projectId }, + authority: Authority.READ_PROJECT, + prisma: this.prisma + }) + + const forks = await this.prisma.project.findMany({ + where: { + forkedFromId: projectId + } + }) + + return forks + .slice(page * limit, (page + 1) * limit) + .filter(async (fork) => { + const allowed = + (await this.authorityCheckerService.checkAuthorityOverProject({ + userId: user.id, + entity: { id: fork.id }, + authority: Authority.READ_PROJECT, + prisma: this.prisma + })) != null + + return allowed + }) + } + async getProjectById(user: User, projectId: Project['id']) { const project = await this.authorityCheckerService.checkAuthorityOverProject({ @@ -295,6 +572,8 @@ export class ProjectService { prisma: this.prisma }) + delete project.secrets + return project } @@ -431,6 +710,274 @@ export class ProjectService { }) } + private async copyProjectData( + user: User, + fromProject: { + id: Project['id'] + privateKey: string + }, + toProject: { + id: Project['id'] + publicKey: string + }, + hardCopy: boolean = false + ) { + // Get all the environments that belongs to the parent project + // and replicate them for the new project + const createEnvironmentOps = [] + const envNameToIdMap = {} + + // These fields will be populated if hardCopy is false + // When we are doing a soft copy, we would only like to add those + // items in the toProject that are not already present in it with + // comparison to the fromProject + const toProjectEnvironments: Set = new Set() + const toProjectSecrets: Set<{ + secret: Secret['name'] + environment: Environment['name'] + }> = new Set() + const toProjectVariables: Set<{ + variable: Variable['name'] + environment: Environment['name'] + }> = new Set() + + if (!hardCopy) { + const environments: Environment[] = + await this.prisma.environment.findMany({ + where: { + projectId: toProject.id + } + }) + + environments.forEach((env) => { + envNameToIdMap[env.name] = env.id + toProjectEnvironments.add(env.name) + }) + + const secrets: SecretWithEnvironment[] = + await this.prisma.secret.findMany({ + where: { + projectId: toProject.id + }, + include: { + environment: true + } + }) + + secrets.forEach((secret) => { + toProjectSecrets.add({ + secret: secret.name, + environment: secret.environment.name + }) + }) + + const variables: VariableWithEnvironment[] = + await this.prisma.variable.findMany({ + where: { + projectId: toProject.id + }, + include: { + environment: true + } + }) + + variables.forEach((variable) => { + toProjectVariables.add({ + variable: variable.name, + environment: variable.environment.name + }) + }) + } + + const environments = await this.prisma.environment.findMany({ + where: { + projectId: fromProject.id, + name: { + notIn: Array.from(toProjectEnvironments) + } + } + }) + + for (const environment of environments) { + const newEnvironmentId = v4() + envNameToIdMap[environment.name] = newEnvironmentId + + createEnvironmentOps.push( + this.prisma.environment.create({ + data: { + id: newEnvironmentId, + name: environment.name, + description: environment.description, + isDefault: environment.isDefault, + projectId: toProject.id, + lastUpdatedById: user.id + } + }) + ) + } + + // Get all the secrets that belongs to the parent project and + // replicate them for the new project + const createSecretOps = [] + + const secrets = await this.prisma.secret.findMany({ + where: { + projectId: fromProject.id, + name: { + notIn: Array.from(toProjectSecrets).map((s) => s.secret) + }, + environment: { + name: { + notIn: Array.from(toProjectSecrets).map((s) => s.environment) + } + } + }, + include: { + environment: true, + versions: true + } + }) + + for (const secret of secrets) { + const secretVersions = secret.versions.map(async (version) => ({ + value: await encrypt( + toProject.publicKey, + await decrypt(fromProject.privateKey, version.value) + ), + version: version.version + })) + + createSecretOps.push( + this.prisma.secret.create({ + data: { + name: secret.name, + environmentId: envNameToIdMap[secret.environment.name], + projectId: toProject.id, + lastUpdatedById: user.id, + note: secret.note, + rotateAt: secret.rotateAt, + versions: { + create: await Promise.all( + secretVersions.map(async (secretVersion) => ({ + value: (await secretVersion).value, + version: (await secretVersion).version, + createdById: user.id + })) + ) + } + } + }) + ) + } + + // Get all the variables that belongs to the parent project and + // replicate them for the new project + const createVariableOps = [] + + const variables = await this.prisma.variable.findMany({ + where: { + projectId: fromProject.id, + name: { + notIn: Array.from(toProjectVariables).map((v) => v.variable) + }, + environment: { + name: { + notIn: Array.from(toProjectVariables).map((v) => v.environment) + } + } + }, + include: { + environment: true, + versions: true + } + }) + + for (const variable of variables) { + createVariableOps.push( + this.prisma.variable.create({ + data: { + name: variable.name, + environmentId: envNameToIdMap[variable.environment.name], + projectId: toProject.id, + lastUpdatedById: user.id, + note: variable.note, + versions: { + create: variable.versions.map((version) => ({ + value: version.value, + version: version.version, + createdById: user.id + })) + } + } + }) + ) + } + + return [...createEnvironmentOps, ...createSecretOps, ...createVariableOps] + } + + private async updateProjectKeyPair( + project: ProjectWithSecrets, + oldPrivateKey: string, + storePrivateKey: boolean + ) { + // A new key pair can be generated only if: + // - The existing private key is provided + // - Or, the private key was stored + const { privateKey: newPrivateKey, publicKey: newPublicKey } = + createKeyPair() + + const txs = [] + + // Re-hash all secrets + for (const secret of project.secrets) { + const versions = await this.prisma.secretVersion.findMany({ + where: { + secretId: secret.id + } + }) + + const updatedVersions: Partial[] = [] + + for (const version of versions) { + updatedVersions.push({ + id: version.id, + value: await encrypt( + await decrypt(oldPrivateKey, version.value), + newPrivateKey + ) + }) + } + + for (const version of updatedVersions) { + txs.push( + this.prisma.secretVersion.update({ + where: { + id: version.id + }, + data: { + value: version.value + } + }) + ) + } + } + + txs.push( + this.prisma.project.update({ + where: { + id: project.id + }, + data: { + publicKey: newPublicKey, + privateKey: storePrivateKey ? newPrivateKey : null + } + }) + ) + + return { txs, newPrivateKey, newPublicKey } + } + async update( dto: UpdateProject | UpdateProjectMetadata, user: User, @@ -440,59 +987,41 @@ export class ProjectService { name: dto.name, description: dto.description, storePrivateKey: dto.storePrivateKey, - privateKey: dto.storePrivateKey ? project.privateKey : null, + privateKey: dto.storePrivateKey ? dto.privateKey : null, accessLevel: dto.accessLevel } - const versionUpdateOps = [] + // If the access level is changed to PRIVATE or internal, we would + // also need to unlink all the forks + if ( + dto.accessLevel !== ProjectAccessLevel.GLOBAL && + project.accessLevel === ProjectAccessLevel.GLOBAL + ) { + data.isForked = false + data.forkedFromId = null + } - let privateKey = undefined, - publicKey = undefined - // A new key pair can be generated only if: - // - The existing private key is provided - // - Or, the private key was stored - // Only administrators can do this action since it's irreversible! - if (dto.regenerateKeyPair && (dto.privateKey || project.privateKey)) { - const res = createKeyPair() - privateKey = res.privateKey - publicKey = res.publicKey - - data.publicKey = publicKey - // Check if the private key should be stored - data.privateKey = dto.storePrivateKey ? privateKey : null - - // Re-hash all secrets - for (const secret of project.secrets) { - const versions = await this.prisma.secretVersion.findMany({ - where: { - secretId: secret.id - } - }) + const versionUpdateOps = [] + let privateKey = dto.privateKey + let publicKey = project.publicKey - const updatedVersions: Partial[] = [] + if (dto.regenerateKeyPair) { + if (dto.privateKey || project.privateKey) { + const { txs, newPrivateKey, newPublicKey } = + await this.updateProjectKeyPair( + project, + dto.privateKey || project.privateKey, + dto.storePrivateKey + ) - for (const version of versions) { - updatedVersions.push({ - id: version.id, - value: await encrypt( - await decrypt(project.privateKey, version.value), - privateKey - ) - }) - } + privateKey = newPrivateKey + publicKey = newPublicKey - for (const version of updatedVersions) { - versionUpdateOps.push( - this.prisma.secretVersion.update({ - where: { - id: version.id - }, - data: { - value: version.value - } - }) - ) - } + versionUpdateOps.push(...txs) + } else { + throw new BadRequestException( + 'Private key is required to regenerate the key pair' + ) } } @@ -531,13 +1060,27 @@ export class ProjectService { this.log.debug(`Updated project ${updatedProject.id}`) return { ...updatedProject, - privateKey + privateKey, + publicKey } } async delete(user: User, project: Project) { const op = [] + // Remove the fork relationships + op.push( + this.prisma.project.updateMany({ + where: { + forkedFromId: project.id + }, + data: { + isForked: false, + forkedFromId: null + } + }) + ) + // Delete the project op.push( this.prisma.project.delete({ diff --git a/apps/api/src/secret/secret.types.ts b/apps/api/src/secret/secret.types.ts index 9a070f2d..89f5c9e7 100644 --- a/apps/api/src/secret/secret.types.ts +++ b/apps/api/src/secret/secret.types.ts @@ -1,4 +1,4 @@ -import { Project, Secret, SecretVersion } from '@prisma/client' +import { Environment, Project, Secret, SecretVersion } from '@prisma/client' export interface SecretWithValue extends Secret { value: string @@ -12,4 +12,8 @@ export interface SecretWithProject extends Secret { project: Project } +export interface SecretWithEnvironment extends Secret { + environment: Environment +} + export type SecretWithProjectAndVersion = SecretWithProject & SecretWithVersion diff --git a/apps/api/src/variable/variable.types.ts b/apps/api/src/variable/variable.types.ts index 89eb040c..ebef53af 100644 --- a/apps/api/src/variable/variable.types.ts +++ b/apps/api/src/variable/variable.types.ts @@ -1,4 +1,4 @@ -import { Project, Variable, VariableVersion } from '@prisma/client' +import { Environment, Project, Variable, VariableVersion } from '@prisma/client' export interface VariableWithValue extends Variable { value: string @@ -12,5 +12,9 @@ export interface VariableWithProject extends Variable { project: Project } +export interface VariableWithEnvironment extends Variable { + environment: Environment +} + export type VariableWithProjectAndVersion = VariableWithProject & VariableWithVersion diff --git a/package.json b/package.json index d52e8c6b..135b9f0c 100644 --- a/package.json +++ b/package.json @@ -110,10 +110,8 @@ "start:platform": "turbo run start --filter=platform", "test": "turbo run test", "test:api": "pnpm unit:api && pnpm e2e:api", - "unit:api": "pnpm db:generate-types && turbo run test --filter=api -- --config=jest.config.ts", - "e2e:api:prepare": "pnpm run --filter=api e2e:prepare", + "unit:api": "pnpm run --filter=api unit", "e2e:api": "pnpm run --filter=api e2e", - "e2e:api:teardown": "pnpm run --filter=api e2e:teardown", "test:web": "turbo run test --filter=web", "test:platform": "turbo run test --filter=platform", "db:generate-types": "pnpm run --filter=api db:generate-types", @@ -148,6 +146,7 @@ "conventional-changelog-conventionalcommits": "7.0.2", "million": "^3.0.5", "sharp": "^0.33.3", + "ts-node": "^10.9.2", "zod": "^3.23.6" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf09de02..d7a253e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: sharp: specifier: ^0.33.3 version: 0.33.3 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@20.11.25)(typescript@5.4.2) zod: specifier: ^3.23.6 version: 3.23.8 @@ -202,6 +205,9 @@ importers: '@typescript-eslint/parser': specifier: ^6.0.0 version: 6.21.0(eslint@8.57.0)(typescript@5.4.2) + ajv: + specifier: ^7 + version: 7.2.4 dotenv-cli: specifier: ^7.4.2 version: 7.4.2 @@ -241,9 +247,6 @@ importers: ts-loader: specifier: ^9.4.3 version: 9.5.1(typescript@5.4.2)(webpack@5.90.1) - ts-node: - specifier: ^10.9.1 - version: 10.9.2(@types/node@20.11.25)(typescript@5.4.2) tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 @@ -485,7 +488,7 @@ importers: devDependencies: '@vercel/style-guide': specifier: ^5.0.0 - version: 5.2.0(@next/eslint-plugin-next@13.5.6)(eslint@8.57.0)(jest@29.7.0)(prettier@3.2.5)(typescript@4.9.5) + version: 5.2.0(@next/eslint-plugin-next@13.5.6)(eslint@8.57.0)(jest@29.7.0(@types/node@20.11.25)(ts-node@10.9.2(@types/node@20.11.25)(typescript@5.4.2)))(prettier@3.2.5)(typescript@4.9.5) eslint-config-turbo: specifier: ^1.10.12 version: 1.12.5(eslint@8.57.0) @@ -3354,6 +3357,9 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@7.2.4: + resolution: {integrity: sha512-nBeQgg/ZZA3u3SYxyaDvpvDtgZ/EZPF547ARgZBrG9Bhu1vKDwAIjtIf+sDtJUKa2zOcEbmRLBRSyMraS/Oy1A==} + ajv@8.12.0: resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} @@ -8080,6 +8086,10 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-typed-array@1.1.14: + resolution: {integrity: sha512-VnXFiIW8yNn9kIHN88xvZ4yOWchftKDsRJ8fEPacX/wl1lOvBrhsJ/OeJCXq7B0AaijRuqgzSKalJoPk+D8MPg==} + engines: {node: '>= 0.4'} + which-typed-array@1.1.15: resolution: {integrity: sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==} engines: {node: '>= 0.4'} @@ -11527,7 +11537,7 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vercel/style-guide@5.2.0(@next/eslint-plugin-next@13.5.6)(eslint@8.57.0)(jest@29.7.0)(prettier@3.2.5)(typescript@4.9.5)': + '@vercel/style-guide@5.2.0(@next/eslint-plugin-next@13.5.6)(eslint@8.57.0)(jest@29.7.0(@types/node@20.11.25)(ts-node@10.9.2(@types/node@20.11.25)(typescript@5.4.2)))(prettier@3.2.5)(typescript@4.9.5)': dependencies: '@babel/core': 7.24.0 '@babel/eslint-parser': 7.23.10(@babel/core@7.24.0)(eslint@8.57.0) @@ -11539,9 +11549,9 @@ snapshots: eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@4.9.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0) eslint-plugin-eslint-comments: 3.2.0(eslint@8.57.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@4.9.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) - eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@4.9.5))(eslint@8.57.0)(typescript@4.9.5))(eslint@8.57.0)(jest@29.7.0)(typescript@4.9.5) + eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@4.9.5))(eslint@8.57.0)(typescript@4.9.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.11.25)(ts-node@10.9.2(@types/node@20.11.25)(typescript@5.4.2)))(typescript@4.9.5) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0) - eslint-plugin-playwright: 0.16.0(eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@4.9.5))(eslint@8.57.0)(typescript@4.9.5))(eslint@8.57.0)(jest@29.7.0)(typescript@4.9.5))(eslint@8.57.0) + eslint-plugin-playwright: 0.16.0(eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@4.9.5))(eslint@8.57.0)(typescript@4.9.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.11.25)(ts-node@10.9.2(@types/node@20.11.25)(typescript@5.4.2)))(typescript@4.9.5))(eslint@8.57.0) eslint-plugin-react: 7.34.0(eslint@8.57.0) eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0) eslint-plugin-testing-library: 6.2.0(eslint@8.57.0)(typescript@4.9.5) @@ -11709,6 +11719,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@7.2.4: + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + ajv@8.12.0: dependencies: fast-deep-equal: 3.1.3 @@ -11850,7 +11867,7 @@ snapshots: array-buffer-byte-length: 1.0.1 call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.23.3 + es-abstract: 1.22.5 es-errors: 1.3.0 get-intrinsic: 1.2.4 is-array-buffer: 3.0.4 @@ -12811,7 +12828,7 @@ snapshots: typed-array-byte-offset: 1.0.2 typed-array-length: 1.0.5 unbox-primitive: 1.0.2 - which-typed-array: 1.1.15 + which-typed-array: 1.1.14 es-abstract@1.23.3: dependencies: @@ -12898,7 +12915,7 @@ snapshots: dependencies: get-intrinsic: 1.2.4 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.1 es-shim-unscopables@1.0.2: dependencies: @@ -13038,7 +13055,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@4.9.5))(eslint@8.57.0)(typescript@4.9.5))(eslint@8.57.0)(jest@29.7.0)(typescript@4.9.5): + eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@4.9.5))(eslint@8.57.0)(typescript@4.9.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.11.25)(ts-node@10.9.2(@types/node@20.11.25)(typescript@5.4.2)))(typescript@4.9.5): dependencies: '@typescript-eslint/utils': 5.62.0(eslint@8.57.0)(typescript@4.9.5) eslint: 8.57.0 @@ -13079,11 +13096,11 @@ snapshots: resolve: 1.22.8 semver: 6.3.1 - eslint-plugin-playwright@0.16.0(eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@4.9.5))(eslint@8.57.0)(typescript@4.9.5))(eslint@8.57.0)(jest@29.7.0)(typescript@4.9.5))(eslint@8.57.0): + eslint-plugin-playwright@0.16.0(eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@4.9.5))(eslint@8.57.0)(typescript@4.9.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.11.25)(ts-node@10.9.2(@types/node@20.11.25)(typescript@5.4.2)))(typescript@4.9.5))(eslint@8.57.0): dependencies: eslint: 8.57.0 optionalDependencies: - eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@4.9.5))(eslint@8.57.0)(typescript@4.9.5))(eslint@8.57.0)(jest@29.7.0)(typescript@4.9.5) + eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@4.9.5))(eslint@8.57.0)(typescript@4.9.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.11.25)(ts-node@10.9.2(@types/node@20.11.25)(typescript@5.4.2)))(typescript@4.9.5) eslint-plugin-prettier@5.1.3(@types/eslint@8.56.5)(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.2.5): dependencies: @@ -13625,7 +13642,7 @@ snapshots: dependencies: call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.23.3 + es-abstract: 1.22.5 functions-have-names: 1.2.3 functions-have-names@1.2.3: {} @@ -14022,7 +14039,7 @@ snapshots: internal-slot@1.0.7: dependencies: es-errors: 1.3.0 - hasown: 2.0.2 + hasown: 2.0.1 side-channel: 1.0.6 interpret@1.4.0: {} @@ -14171,7 +14188,7 @@ snapshots: is-typed-array@1.1.13: dependencies: - which-typed-array: 1.1.15 + which-typed-array: 1.1.14 is-unicode-supported@0.1.0: {} @@ -14599,7 +14616,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 20.11.25 + '@types/node': 17.0.45 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -17427,7 +17444,7 @@ snapshots: isarray: 2.0.5 which-boxed-primitive: 1.0.2 which-collection: 1.0.2 - which-typed-array: 1.1.15 + which-typed-array: 1.1.14 which-collection@1.0.2: dependencies: @@ -17436,6 +17453,14 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.3 + which-typed-array@1.1.14: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-tostringtag: 1.0.2 + which-typed-array@1.1.15: dependencies: available-typed-arrays: 1.0.7