diff --git a/apps/api/src/common/get-collective-project-authorities.ts b/apps/api/src/common/get-collective-project-authorities.ts index c79ad6aa..24c95e64 100644 --- a/apps/api/src/common/get-collective-project-authorities.ts +++ b/apps/api/src/common/get-collective-project-authorities.ts @@ -32,7 +32,7 @@ export default async function getCollectiveProjectAuthorities( } } }, - include: { + select: { role: { select: { authorities: true @@ -48,5 +48,7 @@ export default async function getCollectiveProjectAuthorities( }) }) + // console.log('authorities: ', authorities) + return authorities } diff --git a/apps/api/src/common/get-environment-with-authority.ts b/apps/api/src/common/get-environment-with-authority.ts index f0353c9d..a7e485cd 100644 --- a/apps/api/src/common/get-environment-with-authority.ts +++ b/apps/api/src/common/get-environment-with-authority.ts @@ -1,5 +1,11 @@ import { NotFoundException, UnauthorizedException } from '@nestjs/common' -import { Authority, Environment, PrismaClient, User } from '@prisma/client' +import { + Authority, + Environment, + PrismaClient, + Project, + User +} from '@prisma/client' import getCollectiveProjectAuthorities from './get-collective-project-authorities' export default async function getEnvironmentWithAuthority( @@ -9,14 +15,20 @@ export default async function getEnvironmentWithAuthority( prisma: PrismaClient ): Promise { // Fetch the environment - const environment = await prisma.environment.findUnique({ - where: { - id: environmentId - }, - include: { - project: true - } - }) + let environment: Environment & { project: Project } + + try { + environment = await prisma.environment.findUnique({ + where: { + id: environmentId + }, + include: { + project: true + } + }) + } catch (e) { + /* empty */ + } if (!environment) { throw new NotFoundException( diff --git a/apps/api/src/common/get-project-with-authority.ts b/apps/api/src/common/get-project-with-authority.ts index 2f485956..300e1e84 100644 --- a/apps/api/src/common/get-project-with-authority.ts +++ b/apps/api/src/common/get-project-with-authority.ts @@ -40,7 +40,7 @@ export default async function getProjectWithAuthority( !permittedAuthorities.has(Authority.WORKSPACE_ADMIN) ) { throw new UnauthorizedException( - `User with id ${userId} does not have the authority ${authority} in the project with id ${projectId}` + `User with id ${userId} does not have the authority in the project with id ${projectId}` ) } diff --git a/apps/api/src/common/get-secret-with-authority.ts b/apps/api/src/common/get-secret-with-authority.ts index a8886bf6..13320df9 100644 --- a/apps/api/src/common/get-secret-with-authority.ts +++ b/apps/api/src/common/get-secret-with-authority.ts @@ -1,7 +1,7 @@ import { Authority, PrismaClient, Secret, User } from '@prisma/client' import { SecretWithProjectAndVersion } from '../secret/secret.types' import getCollectiveProjectAuthorities from './get-collective-project-authorities' -import { ConflictException, NotFoundException } from '@nestjs/common' +import { NotFoundException, UnauthorizedException } from '@nestjs/common' export default async function getSecretWithAuthority( userId: User['id'], @@ -10,23 +10,27 @@ export default async function getSecretWithAuthority( prisma: PrismaClient ): Promise { // Fetch the secret - const secret = await prisma.secret.findUnique({ - where: { - id: secretId - }, - include: { - versions: true, - project: { - include: { - workspace: { - include: { - members: true - } + let secret: SecretWithProjectAndVersion + + try { + secret = await prisma.secret.findUnique({ + where: { + id: secretId + }, + include: { + versions: true, + project: true, + environment: { + select: { + id: true, + name: true } } } - } - }) + }) + } catch (error) { + /* empty */ + } if (!secret) { throw new NotFoundException(`Secret with id ${secretId} not found`) @@ -44,13 +48,10 @@ export default async function getSecretWithAuthority( !permittedAuthorities.has(authority) && !permittedAuthorities.has(Authority.WORKSPACE_ADMIN) ) { - throw new ConflictException( + throw new UnauthorizedException( `User ${userId} does not have the required authorities` ) } - // Remove the workspace from the secret - secret.project.workspace = undefined - return secret } diff --git a/apps/api/src/environment/environment.e2e.spec.ts b/apps/api/src/environment/environment.e2e.spec.ts index 04c003b0..e60bddfa 100644 --- a/apps/api/src/environment/environment.e2e.spec.ts +++ b/apps/api/src/environment/environment.e2e.spec.ts @@ -10,7 +10,6 @@ import { import { PrismaService } from '../prisma/prisma.service' import cleanUp from '../common/cleanup' import { - Authority, Environment, EventSeverity, EventSource, @@ -26,11 +25,7 @@ import { ProjectModule } from '../project/project.module' import { WorkspaceService } from '../workspace/service/workspace.service' import { ProjectService } from '../project/service/project.service' import { EventModule } from '../event/event.module' -import { UserModule } from '../user/user.module' import { WorkspaceModule } from '../workspace/workspace.module' -import { WorkspaceRoleModule } from '../workspace-role/workspace-role.module' -import { SecretModule } from '../secret/secret.module' -import { ApiKeyModule } from '../api-key/api-key.module' describe('Environment Controller Tests', () => { let app: NestFastifyApplication @@ -48,13 +43,9 @@ describe('Environment Controller Tests', () => { imports: [ AppModule, EventModule, - UserModule, WorkspaceModule, - WorkspaceRoleModule, - SecretModule, ProjectModule, - EnvironmentModule, - ApiKeyModule + EnvironmentModule ] }) .overrideProvider(MAIL_SERVICE) @@ -186,7 +177,7 @@ describe('Environment Controller Tests', () => { expect(response.statusCode).toBe(401) expect(response.json().message).toBe( - `User with id ${user2.id} does not have the authority ${Authority.CREATE_ENVIRONMENT} in the project with id ${project1.id}` + `User with id ${user2.id} does not have the authority in the project with id ${project1.id}` ) }) @@ -487,7 +478,7 @@ describe('Environment Controller Tests', () => { expect(response.statusCode).toBe(401) expect(response.json().message).toBe( - `User with id ${user2.id} does not have the authority ${Authority.READ_ENVIRONMENT} in the project with id ${project1.id}` + `User with id ${user2.id} does not have the authority in the project with id ${project1.id}` ) }) diff --git a/apps/api/src/prisma/migrations/20240218085410_add_unique_constraint_in_secret/migration.sql b/apps/api/src/prisma/migrations/20240218085410_add_unique_constraint_in_secret/migration.sql new file mode 100644 index 00000000..653a982a --- /dev/null +++ b/apps/api/src/prisma/migrations/20240218085410_add_unique_constraint_in_secret/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - A unique constraint covering the columns `[projectId,environmentId,name]` on the table `Secret` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[secretId,version]` on the table `SecretVersion` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "Secret_projectId_environmentId_name_key" ON "Secret"("projectId", "environmentId", "name"); + +-- CreateIndex +CREATE UNIQUE INDEX "SecretVersion_secretId_version_key" ON "SecretVersion"("secretId", "version"); diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma index 7de82c5e..681fe049 100644 --- a/apps/api/src/prisma/schema.prisma +++ b/apps/api/src/prisma/schema.prisma @@ -291,6 +291,8 @@ model SecretVersion { createdOn DateTime @default(now()) createdBy User? @relation(fields: [createdById], references: [id], onUpdate: Cascade, onDelete: SetNull) createdById String? + + @@unique([secretId, version]) } model Secret { @@ -311,6 +313,8 @@ model Secret { environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) events Event[] + + @@unique([projectId, environmentId, name]) } model ApiKey { diff --git a/apps/api/src/project/project.e2e.spec.ts b/apps/api/src/project/project.e2e.spec.ts index 63b4b776..d33483bc 100644 --- a/apps/api/src/project/project.e2e.spec.ts +++ b/apps/api/src/project/project.e2e.spec.ts @@ -389,7 +389,7 @@ describe('Project Controller Tests', () => { expect(response.json()).toEqual({ statusCode: 401, error: 'Unauthorized', - message: `User with id ${user2.id} does not have the authority UPDATE_PROJECT in the project with id ${project1.id}` + message: `User with id ${user2.id} does not have the authority in the project with id ${project1.id}` }) }) @@ -462,7 +462,7 @@ describe('Project Controller Tests', () => { expect(response.json()).toEqual({ statusCode: 401, error: 'Unauthorized', - message: `User with id ${user2.id} does not have the authority READ_PROJECT in the project with id ${project1.id}` + message: `User with id ${user2.id} does not have the authority in the project with id ${project1.id}` }) }) @@ -700,7 +700,7 @@ describe('Project Controller Tests', () => { expect(response.json()).toEqual({ statusCode: 401, error: 'Unauthorized', - message: `User with id ${user1.id} does not have the authority DELETE_PROJECT in the project with id ${otherProject.id}` + message: `User with id ${user1.id} does not have the authority in the project with id ${otherProject.id}` }) }) diff --git a/apps/api/src/secret/controller/secret.controller.ts b/apps/api/src/secret/controller/secret.controller.ts index 87cf9a3b..b89f2c85 100644 --- a/apps/api/src/secret/controller/secret.controller.ts +++ b/apps/api/src/secret/controller/secret.controller.ts @@ -6,15 +6,13 @@ import { Param, Post, Put, - Query, - UseGuards + Query } from '@nestjs/common' import { SecretService } from '../service/secret.service' import { CurrentUser } from '../../decorators/user.decorator' import { Authority, User } from '@prisma/client' import { CreateSecret } from '../dto/create.secret/create.secret' import { UpdateSecret } from '../dto/update.secret/update.secret' -import { AdminGuard } from '../../auth/guard/admin/admin.guard' import { ApiTags } from '@nestjs/swagger' import { RequiredApiKeyAuthorities } from '../../decorators/required-api-key-authorities.decorator' @@ -93,21 +91,12 @@ export class SecretController { return await this.secretService.getSecretById(user, secretId, decryptValue) } - @Get(':secretId/versions') - @RequiredApiKeyAuthorities(Authority.READ_SECRET) - async getAllVersionsOfSecret( - @CurrentUser() user: User, - @Param('secretId') secretId: string - ) { - return await this.secretService.getAllVersionsOfSecret(user, secretId) - } - @Get('/all/:projectId') @RequiredApiKeyAuthorities(Authority.READ_SECRET) async getAllSecretsOfProject( @CurrentUser() user: User, @Param('projectId') projectId: string, - @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', @@ -126,21 +115,21 @@ export class SecretController { ) } - @UseGuards(AdminGuard) - @Get() - async getAllSecrets( - @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.secretService.getAllSecrets( - page, - limit, - sort, - order, - search - ) - } + // @UseGuards(AdminGuard) + // @Get() + // async getAllSecrets( + // @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.secretService.getAllSecrets( + // page, + // limit, + // sort, + // order, + // search + // ) + // } } diff --git a/apps/api/src/secret/secret.e2e.spec.ts b/apps/api/src/secret/secret.e2e.spec.ts new file mode 100644 index 00000000..b3f9ec9e --- /dev/null +++ b/apps/api/src/secret/secret.e2e.spec.ts @@ -0,0 +1,974 @@ +import { + FastifyAdapter, + NestFastifyApplication +} from '@nestjs/platform-fastify' +import { PrismaService } from '../prisma/prisma.service' +import { ProjectService } from '../project/service/project.service' +import { WorkspaceService } from '../workspace/service/workspace.service' +import { + Environment, + EventSeverity, + EventSource, + EventTriggerer, + EventType, + Project, + Secret, + SecretVersion, + User, + Workspace +} from '@prisma/client' +import { Test } from '@nestjs/testing' +import { AppModule } from '../app/app.module' +import { EventModule } from '../event/event.module' +import { WorkspaceModule } from '../workspace/workspace.module' +import { ProjectModule } from '../project/project.module' +import { EnvironmentModule } from '../environment/environment.module' +import { SecretModule } from './secret.module' +import { MAIL_SERVICE } from '../mail/services/interface.service' +import { MockMailService } from '../mail/services/mock.service' +import cleanUp from '../common/cleanup' +import { EnvironmentService } from '../environment/service/environment.service' +import { v4 } from 'uuid' +import fetchEvents from '../common/fetch-events' +import { SecretService } from './service/secret.service' + +describe('Secret Controller Tests', () => { + let app: NestFastifyApplication + let prisma: PrismaService + let projectService: ProjectService + let workspaceService: WorkspaceService + let environmentService: EnvironmentService + let secretService: SecretService + + let user1: User, user2: User + let workspace1: Workspace, workspace2: Workspace + let project1: Project, project2: Project, workspace2Project: Project + let environment1: Environment, + environment2: Environment, + workspace2Environment: Environment + let secret1: Secret + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + AppModule, + EventModule, + WorkspaceModule, + ProjectModule, + EnvironmentModule, + SecretModule + ] + }) + .overrideProvider(MAIL_SERVICE) + .useClass(MockMailService) + .compile() + + app = moduleRef.createNestApplication( + new FastifyAdapter() + ) + prisma = moduleRef.get(PrismaService) + projectService = moduleRef.get(ProjectService) + workspaceService = moduleRef.get(WorkspaceService) + environmentService = moduleRef.get(EnvironmentService) + secretService = moduleRef.get(SecretService) + + await app.init() + await app.getHttpAdapter().getInstance().ready() + + await cleanUp(prisma) + + const user1Id = v4() + const user2Id = v4() + + user1 = await prisma.user.create({ + data: { + id: user1Id, + email: 'johndoe@keyshade.xyz', + name: 'John Doe', + isOnboardingFinished: true + } + }) + + user2 = await prisma.user.create({ + data: { + id: user2Id, + email: 'janedoe@keyshade.xyz', + name: 'Jane Doe', + isOnboardingFinished: true + } + }) + + workspace1 = await workspaceService.createWorkspace(user1, { + name: 'Workspace 1', + description: 'Workspace 1 description' + }) + + workspace2 = await workspaceService.createWorkspace(user2, { + name: 'Workspace 2', + description: 'Workspace 2 description' + }) + + project1 = await projectService.createProject(user1, workspace1.id, { + name: 'Project 1', + description: 'Project 1 description', + storePrivateKey: true, + environments: [ + { + name: 'Environment 1', + description: 'Environment 1 description', + isDefault: true + }, + { + name: 'Environment 2', + description: 'Environment 2 description', + isDefault: false + } + ] + }) + + project2 = await projectService.createProject(user1, workspace1.id, { + name: 'Project 2', + description: 'Project 2 description', + storePrivateKey: false, + environments: [ + { + name: 'Environment 1', + description: 'Environment 1 description', + isDefault: true + } + ] + }) + + workspace2Project = await projectService.createProject( + user2, + workspace2.id, + { + name: 'Workspace 2 Project', + description: 'Workspace 2 Project description', + storePrivateKey: true, + environments: [ + { + name: 'Environment 1', + description: 'Environment 1 description', + isDefault: true + } + ] + } + ) + + workspace2Environment = await prisma.environment.findUnique({ + where: { + projectId_name: { + projectId: workspace2Project.id, + name: 'Environment 1' + } + } + }) + + environment1 = await prisma.environment.findUnique({ + where: { + projectId_name: { + projectId: project1.id, + name: 'Environment 1' + } + } + }) + + environment2 = await prisma.environment.findUnique({ + where: { + projectId_name: { + projectId: project1.id, + name: 'Environment 2' + } + } + }) + }) + + it('should be defined', async () => { + expect(app).toBeDefined() + expect(prisma).toBeDefined() + expect(projectService).toBeDefined() + expect(workspaceService).toBeDefined() + expect(environmentService).toBeDefined() + }) + + it('should be able to create a secret', async () => { + const response = await app.inject({ + method: 'POST', + url: `/secret/${project1.id}`, + payload: { + environmentId: environment2.id, + name: 'Secret 1', + value: 'Secret 1 value', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(201) + + const body = response.json() + + expect(body).toBeDefined() + expect(body.name).toBe('Secret 1') + expect(body.environmentId).toBe(environment2.id) + expect(body.projectId).toBe(project1.id) + + secret1 = body + }) + + it('should have created a secret version', async () => { + const secretVersion = await prisma.secretVersion.findFirst({ + where: { + secretId: secret1.id + } + }) + + expect(secretVersion).toBeDefined() + expect(secretVersion.value).not.toBe('Secret 1 value') + expect(secretVersion.version).toBe(1) + }) + + it('should create secret in default environment if environmentId is not provided', async () => { + const response = await app.inject({ + method: 'POST', + url: `/secret/${project1.id}`, + payload: { + name: 'Secret 2', + value: 'Secret 2 value', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(201) + + const body = response.json() + + expect(body).toBeDefined() + expect(body.name).toBe('Secret 2') + expect(body.environmentId).toBe(environment1.id) + expect(body.projectId).toBe(project1.id) + }) + + it('should not be able to create a secret with a non-existing environment', async () => { + const response = await app.inject({ + method: 'POST', + url: `/secret/${project1.id}`, + payload: { + environmentId: 'non-existing-environment-id', + name: 'Secret 3', + value: 'Secret 3 value', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + }) + + it('should not be able to create a secret if the user has no access to the project', async () => { + const response = await app.inject({ + method: 'POST', + url: `/secret/${project1.id}`, + payload: { + name: 'Secret 3', + value: 'Secret 3 value', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + expect(response.json().message).toEqual( + `User with id ${user2.id} does not have the authority in the project with id ${project1.id}` + ) + }) + + it('should fail if project has no default environment(hypothetical case)', async () => { + await prisma.environment.update({ + where: { + projectId_name: { + projectId: project1.id, + name: 'Environment 1' + } + }, + data: { + isDefault: false + } + }) + + const response = await app.inject({ + method: 'POST', + url: `/secret/${project1.id}`, + payload: { + name: 'Secret 4', + value: 'Secret 4 value', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + `No default environment found for project: ${project1.id}` + ) + + await prisma.environment.update({ + where: { + projectId_name: { + projectId: project1.id, + name: 'Environment 1' + } + }, + data: { + isDefault: true + } + }) + }) + + it('should not be able to create a duplicate secret in the same environment', async () => { + const response = await app.inject({ + method: 'POST', + url: `/secret/${project1.id}`, + payload: { + environmentId: environment2.id, + name: 'Secret 1', + value: 'Secret 1 value', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(409) + expect(response.json().message).toEqual( + `Secret already exists: Secret 1 in environment ${environment2.name} in project ${project1.id}` + ) + }) + + it('should have created a SECRET_ADDED event', async () => { + const response = await fetchEvents(app, user1, 'secretId=' + secret1.id) + + const event = { + id: expect.any(String), + title: expect.any(String), + description: expect.any(String), + source: EventSource.SECRET, + triggerer: EventTriggerer.USER, + severity: EventSeverity.INFO, + type: EventType.SECRET_ADDED, + timestamp: expect.any(String), + metadata: expect.any(Object) + } + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual(expect.arrayContaining([event])) + }) + + it('should not be able to update a non-existing secret', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/non-existing-secret-id`, + payload: { + name: 'Updated Secret 1', + value: 'Updated Secret 1 value', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + 'Secret with id non-existing-secret-id not found' + ) + }) + + it('should not be able to update a secret with same name in the same environment', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.id}`, + payload: { + name: 'Secret 1', + value: 'Updated Secret 1 value', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(409) + expect(response.json().message).toEqual( + `Secret already exists: Secret 1 in environment ${environment2.id}` + ) + }) + + it('should be able to update the environment name without creating a new version', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.id}`, + payload: { + name: 'Updated Secret 1' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().name).toEqual('Updated Secret 1') + + const secretVersion = await prisma.secretVersion.findMany({ + where: { + secretId: secret1.id + } + }) + + expect(secretVersion.length).toBe(1) + + secret1 = response.json() + }) + + it('should create a new version if the value is updated', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.id}`, + payload: { + value: 'Updated Secret 1 value' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + + const secretVersion = await prisma.secretVersion.findMany({ + where: { + secretId: secret1.id + } + }) + + expect(secretVersion.length).toBe(2) + }) + + it('should have created a SECRET_UPDATED event', async () => { + const response = await fetchEvents(app, user1, 'secretId=' + secret1.id) + + const event = { + id: expect.any(String), + title: expect.any(String), + description: expect.any(String), + source: EventSource.SECRET, + triggerer: EventTriggerer.USER, + severity: EventSeverity.INFO, + type: EventType.SECRET_UPDATED, + timestamp: expect.any(String), + metadata: expect.any(Object) + } + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual(expect.arrayContaining([event])) + }) + + it('should be able to update the environment of a secret', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.id}/environment/${environment1.id}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().environmentId).toBe(environment1.id) + }) + + it('should not be able to move to a non-existing environment', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.id}/environment/non-existing-environment-id`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + }) + + it('should not be able to move to an environment in another project', async () => { + const otherEnvironment = await prisma.environment.findUnique({ + where: { + projectId_name: { + projectId: project2.id, + name: 'Environment 1' + } + } + }) + + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.id}/environment/${otherEnvironment.id}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(400) + expect(response.json().message).toEqual( + `Environment ${otherEnvironment.id} does not belong to project ${project1.id}` + ) + }) + + it('should not be able to move the secret to the same environment', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.id}/environment/${environment1.id}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(400) + expect(response.json().message).toEqual( + `Can not update the environment of the secret to the same environment: ${environment1.id} in project ${project1.id}` + ) + }) + + it('should not be able to move the secret if the user has no access to the project', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.id}/environment/${workspace2Environment.id}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(401) + expect(response.json().message).toEqual( + `User ${user1.id} does not have the required authorities` + ) + }) + + it('should have created a SECRET_UPDATED event', async () => { + const response = await fetchEvents(app, user1, 'secretId=' + secret1.id) + + const event = { + id: expect.any(String), + title: expect.any(String), + description: expect.any(String), + source: EventSource.SECRET, + triggerer: EventTriggerer.USER, + severity: EventSeverity.INFO, + type: EventType.SECRET_UPDATED, + timestamp: expect.any(String), + metadata: expect.any(Object) + } + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual(expect.arrayContaining([event])) + }) + + it('should not be able to move a secret of the same name to an environment', async () => { + const newSecret = await prisma.secret.create({ + data: { + projectId: project1.id, + environmentId: environment2.id, + name: 'Updated Secret 1' + } + }) + + const response = await app.inject({ + method: 'PUT', + url: `/secret/${newSecret.id}/environment/${environment1.id}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(409) + expect(response.json().message).toEqual( + `Secret already exists: Updated Secret 1 in environment ${environment1.id} in project ${project1.id}` + ) + }) + + it('should not be able to roll back a non-existing secret', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/non-existing-secret-id/rollback/1`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + 'Secret with id non-existing-secret-id not found' + ) + }) + + it('should not be able to roll back a secret it does not have access to', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.id}/rollback/1`, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + expect(response.json().message).toEqual( + `User ${user2.id} does not have the required authorities` + ) + }) + + it('should not be able to roll back to a non-existing version', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.id}/rollback/2`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + `Invalid rollback version: 2 for secret: ${secret1.id}` + ) + }) + + it('should be able to roll back a secret', async () => { + // Creating a few versions first + await secretService.updateSecret(user1, secret1.id, { + value: 'Updated Secret 1 value' + }) + + await secretService.updateSecret(user1, secret1.id, { + value: 'Updated Secret 1 value 2' + }) + + let versions: SecretVersion[] + + versions = await prisma.secretVersion.findMany({ + where: { + secretId: secret1.id + } + }) + + expect(versions.length).toBe(4) + + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.id}/rollback/1`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().count).toEqual(3) + + versions = await prisma.secretVersion.findMany({ + where: { + secretId: secret1.id + } + }) + + expect(versions.length).toBe(1) + }) + + it('should not be able to fetch a non existing secret', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/non-existing-secret-id`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + 'Secret with id non-existing-secret-id not found' + ) + }) + + it('should not be able to fetch a secret it does not have access to', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/${secret1.id}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + expect(response.json().message).toEqual( + `User ${user2.id} does not have the required authorities` + ) + }) + + it('should be able to fetch a secret', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/${secret1.id}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().id).toEqual(secret1.id) + + const versions = await response.json().versions + + expect(versions.length).toBe(1) + expect(versions[0].value).not.toEqual('Secret 1 value') // Secret should be in encrypted form until specified otherwise + }) + + it('should be able to fetch a decrypted secret', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/${secret1.id}?decryptValue=true`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().id).toEqual(secret1.id) + + const versions = await response.json().versions + + expect(versions.length).toBe(1) + expect(versions[0].value).toEqual('Secret 1 value') + }) + + it('should not be able to fetch a decrypted secret if the project does not store the private key', async () => { + const secret = await secretService.createSecret( + user1, + { + environmentId: environment1.id, + name: 'Secret 20', + value: 'Secret 20 value', + rotateAfter: '24' + }, + project2.id + ) + + const response = await app.inject({ + method: 'GET', + url: `/secret/${secret.id}?decryptValue=true`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(400) + expect(response.json().message).toEqual( + `Cannot decrypt secret value: ${secret.id} as the project does not store the private key` + ) + }) + + it('should not be able to fetch a decrypted secret if somehow the project does not have a private key even though it stores it (hypothetical)', async () => { + await prisma.project.update({ + where: { + id: project1.id + }, + data: { + storePrivateKey: true, + privateKey: null + } + }) + + const response = await app.inject({ + method: 'GET', + url: `/secret/${secret1.id}?decryptValue=true`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + `Cannot decrypt secret value: ${secret1.id} as the project does not have a private key` + ) + + await prisma.project.update({ + where: { + id: project1.id + }, + data: { + privateKey: project1.privateKey + } + }) + }) + + it('should be able to fetch all secrets', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/all/${project1.id}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().length).toBe(3) + }) + + it('should be able to fetch all secrets decrypted', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/all/${project1.id}?decryptValue=true`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().length).toBe(3) + + const secret = response.json()[0] + + expect(secret.versions[0].value).toEqual('Secret 2 value') + }) + + it('should not be able to fetch all secrets decrypted if the project does not store the private key', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/all/${project2.id}?decryptValue=true`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(400) + expect(response.json().message).toEqual( + `Cannot decrypt secret values as the project does not store the private key` + ) + }) + + it('should not be able to fetch all secrets decrypted if somehow the project does not have a private key even though it stores it (hypothetical)', async () => { + await prisma.project.update({ + where: { + id: project1.id + }, + data: { + storePrivateKey: true, + privateKey: null + } + }) + + const response = await app.inject({ + method: 'GET', + url: `/secret/all/${project1.id}?decryptValue=true`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + `Cannot decrypt secret values as the project does not have a private key` + ) + + await prisma.project.update({ + where: { + id: project1.id + }, + data: { + privateKey: project1.privateKey + } + }) + }) + + it('should not be able to fetch all secrets if the user has no access to the project', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/all/${project1.id}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + expect(response.json().message).toEqual( + `User with id ${user2.id} does not have the authority in the project with id ${project1.id}` + ) + }) + + it('should not be able to fetch all secrets if the project does not exist', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/all/non-existing-project-id`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + 'Project with id non-existing-project-id not found' + ) + }) + + it('should not be able to delete a non-existing secret', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/secret/non-existing-secret-id`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + 'Secret with id non-existing-secret-id not found' + ) + }) + + it('should not be able to delete a secret it does not have access to', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/secret/${secret1.id}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + expect(response.json().message).toEqual( + `User ${user2.id} does not have the required authorities` + ) + }) + + it('should be able to delete a secret', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/secret/${secret1.id}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + }) + + afterAll(async () => { + await app.close() + }) +}) diff --git a/apps/api/src/secret/service/secret.service.ts b/apps/api/src/secret/service/secret.service.ts index e47ab6a6..5cea1034 100644 --- a/apps/api/src/secret/service/secret.service.ts +++ b/apps/api/src/secret/service/secret.service.ts @@ -146,6 +146,8 @@ export class SecretService { } // Update the secret + // If a new secret value is proposed, we want to create a new version for + // that secret if (dto.value) { const previousVersion = await this.prisma.secretVersion.findFirst({ where: { @@ -177,19 +179,19 @@ export class SecretService { } } }) + } else { + result = await this.prisma.secret.update({ + where: { + id: secretId + }, + data: { + name: dto.name ?? secret.name, + rotateAt: dto.rotateAfter ?? secret.rotateAt, + lastUpdatedById: user.id + } + }) } - result = await this.prisma.secret.update({ - where: { - id: secretId - }, - data: { - name: secret.name, - rotateAt: secret.rotateAt, - lastUpdatedById: user.id - } - }) - createEvent( { triggeredBy: user, @@ -224,21 +226,30 @@ export class SecretService { this.prisma ) + if (secret.environmentId === environmentId) { + throw new BadRequestException( + `Can not update the environment of the secret to the same environment: ${environmentId} in project ${secret.projectId}` + ) + } + // Check if the environment exists const environment = await getEnvironmentWithAuthority( - secret.projectId, + user.id, environmentId, Authority.READ_ENVIRONMENT, this.prisma ) + if (environment.projectId !== secret.projectId) { + throw new BadRequestException( + `Environment ${environmentId} does not belong to project ${secret.projectId}` + ) + } + // Check if the secret already exists in the environment - if ( - (await this.secretExists(secret.name, environment.id)) || - secret.environmentId !== environment.id - ) { + if (await this.secretExists(secret.name, environmentId)) { throw new ConflictException( - `Secret already exists: ${secret.name} in environment ${environment.id} in project ${secret.projectId}` + `Secret already exists: ${secret.name} in environment ${environmentId} in project ${secret.projectId}` ) } @@ -289,8 +300,10 @@ export class SecretService { this.prisma ) + const maxVersion = secret.versions[secret.versions.length - 1].version + // Check if the rollback version is valid - if (rollbackVersion < 1 || rollbackVersion > secret.versions[0].version) { + if (rollbackVersion < 1 || rollbackVersion >= maxVersion) { throw new NotFoundException( `Invalid rollback version: ${rollbackVersion} for secret: ${secretId}` ) @@ -301,7 +314,7 @@ export class SecretService { where: { secretId, version: { - gt: rollbackVersion + gt: Number(rollbackVersion) } } }) @@ -356,29 +369,19 @@ export class SecretService { if (decryptValue) { // Decrypt the secret value - const decryptedValue = await decrypt( - project.privateKey, - secret.versions[0].value - ) - secret.versions[0].value = decryptedValue + for (let i = 0; i < secret.versions.length; i++) { + const decryptedValue = await decrypt( + project.privateKey, + secret.versions[i].value + ) + secret.versions[i].value = decryptedValue + } } // Return the secret return secret } - async getAllVersionsOfSecret(user: User, secretId: Secret['id']) { - // Fetch the secret - const secret = await getSecretWithAuthority( - user.id, - secretId, - Authority.READ_SECRET, - this.prisma - ) - - return secret.versions - } - async getAllSecretsOfProject( user: User, projectId: Project['id'], @@ -439,59 +442,61 @@ export class SecretService { } } }, - skip: (page - 1) * limit, + skip: page * limit, take: limit, orderBy: { [sort]: order } })) as SecretWithVersion[] - // Return the secrets - return secrets.map(async (secret) => { - if (decryptValue) { + if (decryptValue) { + for (const secret of secrets) { // Decrypt the secret value - const decryptedValue = await decrypt( - project.privateKey, - secret.versions[0].value - ) - secret.versions[0].value = decryptedValue - } - return secret - }) - } - - async getAllSecrets( - page: number, - limit: number, - sort: string, - order: string, - search: string - ) { - // Return the secrets - return await this.prisma.secret.findMany({ - where: { - name: { - contains: search + for (let i = 0; i < secret.versions.length; i++) { + const decryptedValue = await decrypt( + project.privateKey, + secret.versions[i].value + ) + secret.versions[i].value = decryptedValue } - }, - include: { - versions: { - orderBy: { - version: 'desc' - }, - take: 1 - }, - lastUpdatedBy: true, - environment: true - }, - skip: (page - 1) * limit, - take: limit, - orderBy: { - [sort]: order } - }) + } + + return secrets } + // async getAllSecrets( + // page: number, + // limit: number, + // sort: string, + // order: string, + // search: string + // ) { + // // Return the secrets + // return await this.prisma.secret.findMany({ + // where: { + // name: { + // contains: search + // } + // }, + // include: { + // versions: { + // orderBy: { + // version: 'desc' + // }, + // take: 1 + // }, + // lastUpdatedBy: true, + // environment: true + // }, + // skip: page * limit, + // take: limit, + // orderBy: { + // [sort]: order + // } + // }) + // } + private async getDefaultEnvironmentOfProject( projectId: Project['id'] ): Promise { diff --git a/apps/api/src/workspace-role/controller/workspace-role.controller.ts b/apps/api/src/workspace-role/controller/workspace-role.controller.ts index e9794fa7..793c6435 100644 --- a/apps/api/src/workspace-role/controller/workspace-role.controller.ts +++ b/apps/api/src/workspace-role/controller/workspace-role.controller.ts @@ -6,14 +6,12 @@ import { Param, Post, Put, - Query, - UseGuards + Query } from '@nestjs/common' import { WorkspaceRoleService } from '../service/workspace-role.service' import { CurrentUser } from '../../decorators/user.decorator' import { Authority, User, Workspace, WorkspaceRole } from '@prisma/client' import { CreateWorkspaceRole } from '../dto/create-workspace-role/create-workspace-role' -import { AdminGuard } from '../../auth/guard/admin/admin.guard' import { UpdateWorkspaceRole } from '../dto/update-workspace-role/update-workspace-role' import { RequiredApiKeyAuthorities } from '../../decorators/required-api-key-authorities.decorator' @@ -94,7 +92,7 @@ export class WorkspaceRoleController { async getAllWorkspaceRolesOfWorkspace( @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', @@ -111,21 +109,21 @@ export class WorkspaceRoleController { ) } - @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 - ) - } + // @Get() + // @UseGuards(AdminGuard) + // async getAllWorkspaceRoles( + // @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 await this.workspaceRoleService.getWorkspaceRoles( + // page, + // limit, + // sort, + // order, + // search + // ) + // } } diff --git a/apps/api/src/workspace-role/service/workspace-role.service.ts b/apps/api/src/workspace-role/service/workspace-role.service.ts index 38f95b79..f839a1c8 100644 --- a/apps/api/src/workspace-role/service/workspace-role.service.ts +++ b/apps/api/src/workspace-role/service/workspace-role.service.ts @@ -147,7 +147,7 @@ export class WorkspaceRoleService { description: dto.description, colorCode: dto.colorCode, projects: { - connect: dto.projectIds?.map((id) => ({ id })) + set: dto.projectIds?.map((id) => ({ id })) } }, include: { @@ -168,7 +168,7 @@ export class WorkspaceRoleService { colorCode: dto.colorCode, authorities: dto.authorities ?? [], projects: { - connect: dto.projectIds?.map((id) => ({ id })) + set: dto.projectIds?.map((id) => ({ id })) } }, include: { @@ -298,28 +298,8 @@ export class WorkspaceRoleService { 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, + skip: page * limit, take: limit, orderBy: { [sort]: order @@ -327,6 +307,27 @@ export class WorkspaceRoleService { }) } + // 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 * limit, + // take: limit, + // orderBy: { + // [sort]: order + // } + // }) + // } + private async getWorkspaceRoleWithAuthority( userId: User['id'], workspaceRoleId: Workspace['id'],