From cd79172ca33aa5c6c72b7859acdeaa2bb5b10970 Mon Sep 17 00:00:00 2001 From: rajdip-b Date: Mon, 8 Jan 2024 00:05:27 +0530 Subject: [PATCH] feat: add secret module --- .../repository/environment.repository.ts | 20 + .../repository/interface.repository.ts | 9 + .../environment/repository/mock.repository.ts | 14 + .../service/environment.service.ts | 26 +- .../migration.sql | 5 + apps/api/src/prisma/schema.prisma | 5 + .../controller/project.controller.spec.ts | 3 + .../src/project/misc/project.permission.ts | 55 +-- apps/api/src/project/project.module.ts | 3 +- apps/api/src/project/project.types.ts | 30 ++ .../repository/interface.repository.ts | 17 +- .../src/project/repository/mock.repository.ts | 21 +- .../project/repository/project.repository.ts | 38 +- .../project/service/project.service.spec.ts | 3 + .../src/project/service/project.service.ts | 71 +-- .../controller/secret.controller.spec.ts | 58 +++ .../secret/controller/secret.controller.ts | 149 ++++++ .../dto/create.secret/create.secret.spec.ts | 7 + .../secret/dto/create.secret/create.secret.ts | 13 + .../dto/update.secret/update.secret.spec.ts | 7 + .../secret/dto/update.secret/update.secret.ts | 4 + .../secret/repository/interface.repository.ts | 70 +++ .../src/secret/repository/mock.repository.ts | 167 +++++++ .../secret/repository/secret.repository.ts | 272 +++++++++++ apps/api/src/secret/secret.module.ts | 36 ++ apps/api/src/secret/secret.types.ts | 9 + .../src/secret/service/secret.service.spec.ts | 56 +++ apps/api/src/secret/service/secret.service.ts | 432 ++++++++++++++++++ 28 files changed, 1475 insertions(+), 125 deletions(-) rename apps/api/src/prisma/migrations/{20240102080245_init => 20240107183040_init}/migration.sql (96%) create mode 100644 apps/api/src/project/project.types.ts create mode 100644 apps/api/src/secret/controller/secret.controller.spec.ts create mode 100644 apps/api/src/secret/controller/secret.controller.ts create mode 100644 apps/api/src/secret/dto/create.secret/create.secret.spec.ts create mode 100644 apps/api/src/secret/dto/create.secret/create.secret.ts create mode 100644 apps/api/src/secret/dto/update.secret/update.secret.spec.ts create mode 100644 apps/api/src/secret/dto/update.secret/update.secret.ts create mode 100644 apps/api/src/secret/repository/interface.repository.ts create mode 100644 apps/api/src/secret/repository/mock.repository.ts create mode 100644 apps/api/src/secret/repository/secret.repository.ts create mode 100644 apps/api/src/secret/secret.module.ts create mode 100644 apps/api/src/secret/secret.types.ts create mode 100644 apps/api/src/secret/service/secret.service.spec.ts create mode 100644 apps/api/src/secret/service/secret.service.ts diff --git a/apps/api/src/environment/repository/environment.repository.ts b/apps/api/src/environment/repository/environment.repository.ts index dbaae88c..7ae9fb84 100644 --- a/apps/api/src/environment/repository/environment.repository.ts +++ b/apps/api/src/environment/repository/environment.repository.ts @@ -37,6 +37,26 @@ export class EnvironmentRepository implements IEnvironmentRepository { .then((count) => count > 0) } + async getDefaultEnvironmentOfProject( + projectId: string + ): Promise<{ + id: string + name: string + description: string + createdAt: Date + updatedAt: Date + isDefault: boolean + lastUpdatedById: string + projectId: string + }> { + return await this.prisma.environment.findFirst({ + where: { + projectId, + isDefault: true + } + }) + } + async getEnvironmentByProjectIdAndId( projectId: string, environmentId: string diff --git a/apps/api/src/environment/repository/interface.repository.ts b/apps/api/src/environment/repository/interface.repository.ts index c4d75e9c..c79ff918 100644 --- a/apps/api/src/environment/repository/interface.repository.ts +++ b/apps/api/src/environment/repository/interface.repository.ts @@ -30,6 +30,15 @@ export interface IEnvironmentRepository { projectId: Project['id'] ): Promise + /** + * Retrieves the default environment of a project. + * @param {Project['id']} projectId - The ID of the project. + * @returns {Promise} - A promise that resolves to the default environment or null if not found. + */ + getDefaultEnvironmentOfProject( + projectId: Project['id'] + ): Promise + /** * Retrieves an environment by project ID and environment ID. * @param {Project['id']} projectId - The ID of the project. diff --git a/apps/api/src/environment/repository/mock.repository.ts b/apps/api/src/environment/repository/mock.repository.ts index 7e2a8d83..7e75f18b 100644 --- a/apps/api/src/environment/repository/mock.repository.ts +++ b/apps/api/src/environment/repository/mock.repository.ts @@ -3,6 +3,20 @@ import { Environment } from '@prisma/client' import { IEnvironmentRepository } from './interface.repository' export class MockEnvironmentRepository implements IEnvironmentRepository { + getDefaultEnvironmentOfProject( + projectId: string + ): Promise<{ + id: string + name: string + description: string + createdAt: Date + updatedAt: Date + isDefault: boolean + lastUpdatedById: string + projectId: string + }> { + throw new Error('Method not implemented.') + } countTotalEnvironmentsInProject(projectId: string): Promise { throw new Error('Method not implemented.') } diff --git a/apps/api/src/environment/service/environment.service.ts b/apps/api/src/environment/service/environment.service.ts index 6d7b3362..eee5e197 100644 --- a/apps/api/src/environment/service/environment.service.ts +++ b/apps/api/src/environment/service/environment.service.ts @@ -42,10 +42,7 @@ export class EnvironmentService { } // Check if the user can manage environments of the project - await this.projectPermissionService.canManageEnvironmentsOfProject( - user, - projectId - ) + await this.projectPermissionService.isProjectMaintainer(user, projectId) // Check if an environment with the same name already exists if ( @@ -80,10 +77,7 @@ export class EnvironmentService { } // Check if the user can manage environments of the project - await this.projectPermissionService.canManageEnvironmentsOfProject( - user, - projectId - ) + await this.projectPermissionService.isProjectMaintainer(user, projectId) // Check if the environment exists const environment = @@ -132,10 +126,7 @@ export class EnvironmentService { } // Check if the user can manage environments of the project - await this.projectPermissionService.canManageEnvironmentsOfProject( - user, - projectId - ) + await this.projectPermissionService.isProjectMaintainer(user, projectId) // Check if the environment exists const environment = @@ -168,12 +159,6 @@ export class EnvironmentService { throw new NotFoundException('Project not found') } - // Check if the user can manage environments of the project - await this.projectPermissionService.canManageEnvironmentsOfProject( - user, - projectId - ) - // Get the environments return this.environmentRepository.getEnvironmentsOfProject( projectId, @@ -217,10 +202,7 @@ export class EnvironmentService { } // Check if the user can manage environments of the project - await this.projectPermissionService.canManageEnvironmentsOfProject( - user, - projectId - ) + await this.projectPermissionService.isProjectMaintainer(user, projectId) // Check if the environment exists const environment = diff --git a/apps/api/src/prisma/migrations/20240102080245_init/migration.sql b/apps/api/src/prisma/migrations/20240107183040_init/migration.sql similarity index 96% rename from apps/api/src/prisma/migrations/20240102080245_init/migration.sql rename to apps/api/src/prisma/migrations/20240107183040_init/migration.sql index 15a6200a..61ccb498 100644 --- a/apps/api/src/prisma/migrations/20240102080245_init/migration.sql +++ b/apps/api/src/prisma/migrations/20240107183040_init/migration.sql @@ -101,6 +101,8 @@ CREATE TABLE "SecretVersion" ( "value" TEXT NOT NULL, "version" INTEGER NOT NULL DEFAULT 1, "secretId" TEXT NOT NULL, + "createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdById" TEXT NOT NULL, CONSTRAINT "SecretVersion_pkey" PRIMARY KEY ("id") ); @@ -180,6 +182,9 @@ ALTER TABLE "ApiKeyScope" ADD CONSTRAINT "ApiKeyScope_projectId_fkey" FOREIGN KE -- AddForeignKey ALTER TABLE "SecretVersion" ADD CONSTRAINT "SecretVersion_secretId_fkey" FOREIGN KEY ("secretId") REFERENCES "Secret"("id") ON DELETE CASCADE ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "SecretVersion" ADD CONSTRAINT "SecretVersion_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "Secret" ADD CONSTRAINT "Secret_lastUpdatedById_fkey" FOREIGN KEY ("lastUpdatedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma index 01c6d73c..237c6b7e 100644 --- a/apps/api/src/prisma/schema.prisma +++ b/apps/api/src/prisma/schema.prisma @@ -79,6 +79,7 @@ model User { Secret Secret[] // Stores the secrets the user updated project Project[] // Stores the projects the user updated environments Environment[] // Stores the environments the user updated + SecretVersion SecretVersion[] } model Subscription { @@ -158,6 +159,10 @@ model SecretVersion { secretId String secret Secret @relation(fields: [secretId], references: [id], onDelete: Cascade, onUpdate: Cascade) + + createdOn DateTime @default(now()) + createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade, onUpdate: Cascade) + createdById String } model Secret { diff --git a/apps/api/src/project/controller/project.controller.spec.ts b/apps/api/src/project/controller/project.controller.spec.ts index 67aa0a32..9cba939f 100644 --- a/apps/api/src/project/controller/project.controller.spec.ts +++ b/apps/api/src/project/controller/project.controller.spec.ts @@ -11,6 +11,8 @@ import { MAIL_SERVICE } from '../../mail/services/interface.service' import { MockMailService } from '../../mail/services/mock.service' import { JwtService } from '@nestjs/jwt' import { ProjectPermission } from '../misc/project.permission' +import { SECRET_REPOSITORY } from '../../secret/repository/interface.repository' +import { MockSecretRepository } from '../../secret/repository/mock.repository' describe('ProjectController', () => { let controller: ProjectController @@ -27,6 +29,7 @@ describe('ProjectController', () => { }, { provide: USER_REPOSITORY, useClass: MockUserRepository }, { provide: MAIL_SERVICE, useClass: MockMailService }, + { provide: SECRET_REPOSITORY, useClass: MockSecretRepository }, JwtService, ProjectPermission ] diff --git a/apps/api/src/project/misc/project.permission.ts b/apps/api/src/project/misc/project.permission.ts index 3eba0d99..76dc6416 100644 --- a/apps/api/src/project/misc/project.permission.ts +++ b/apps/api/src/project/misc/project.permission.ts @@ -9,7 +9,7 @@ export class ProjectPermission { @Inject(PROJECT_REPOSITORY) private readonly repository: ProjectRepository ) {} - async canUpdateProject(user: User, projectId: Project['id']): Promise { + async isProjectAdmin(user: User, projectId: Project['id']): Promise { // Admins can do everything if (user.isAdmin) Promise.resolve() @@ -21,44 +21,15 @@ export class ProjectPermission { if (!membership) { throw new UnauthorizedException('User is not a member of the project') } - if (membership.role === ProjectRole.VIEWER) { - throw new UnauthorizedException('OWNER or MAINTAINER role is required') + if (membership.role !== ProjectRole.OWNER) { + throw new UnauthorizedException('Atleast OWNER role is required') } } - async canDeleteProject(user: User, projectId: Project['id']): Promise { - await this.isProjectAdmin(user, projectId) - } - - async canAddUserToProject( - user: User, - projectId: Project['id'] - ): Promise { - await this.isProjectAdmin(user, projectId) - } - - async canRemoveUserFromProject( - user: User, - projectId: Project['id'] - ): Promise { - await this.isProjectAdmin(user, projectId) - } - - async canUpdateUserPermissionsOfProject( - user: User, - projectId: Project['id'] - ): Promise { - await this.isProjectAdmin(user, projectId) - } - - async canManageEnvironmentsOfProject( + async isProjectMaintainer( user: User, projectId: Project['id'] ): Promise { - await this.isProjectMaintainer(user, projectId) - } - - async isProjectAdmin(user: User, projectId: Project['id']): Promise { // Admins can do everything if (user.isAdmin) Promise.resolve() @@ -70,15 +41,15 @@ export class ProjectPermission { if (!membership) { throw new UnauthorizedException('User is not a member of the project') } - if (membership.role !== ProjectRole.OWNER) { - throw new UnauthorizedException('Atleast OWNER role is required') + if ( + membership.role !== ProjectRole.OWNER && + membership.role !== ProjectRole.MAINTAINER + ) { + throw new UnauthorizedException('Atleast MAINTAINER role is required') } } - async isProjectMaintainer( - user: User, - projectId: Project['id'] - ): Promise { + async isProjectMember(user: User, projectId: Project['id']): Promise { // Admins can do everything if (user.isAdmin) Promise.resolve() @@ -90,12 +61,6 @@ export class ProjectPermission { if (!membership) { throw new UnauthorizedException('User is not a member of the project') } - if ( - membership.role !== ProjectRole.OWNER && - membership.role !== ProjectRole.MAINTAINER - ) { - throw new UnauthorizedException('Atleast MAINTAINER role is required') - } } private async resolveProjectsOfUser( diff --git a/apps/api/src/project/project.module.ts b/apps/api/src/project/project.module.ts index 6d431254..6de23038 100644 --- a/apps/api/src/project/project.module.ts +++ b/apps/api/src/project/project.module.ts @@ -6,6 +6,7 @@ import { PROJECT_REPOSITORY } from './repository/interface.repository' import { ProjectPermission } from './misc/project.permission' import { EnvironmentModule } from '../environment/environment.module' import { UserModule } from '../user/user.module' +import { SecretModule } from '../secret/secret.module' @Module({ providers: [ @@ -18,6 +19,6 @@ import { UserModule } from '../user/user.module' ], controllers: [ProjectController], exports: [ProjectPermission], - imports: [UserModule, EnvironmentModule] + imports: [UserModule, EnvironmentModule, SecretModule] }) export class ProjectModule {} diff --git a/apps/api/src/project/project.types.ts b/apps/api/src/project/project.types.ts new file mode 100644 index 00000000..a6c14874 --- /dev/null +++ b/apps/api/src/project/project.types.ts @@ -0,0 +1,30 @@ +import { + Project, + ProjectMember, + ProjectRole, + Secret, + User +} from '@prisma/client' + +export interface ProjectWithMembers extends Project { + members: ProjectMember[] +} + +export interface ProjectWithUserRole extends Project { + role: ProjectMember['role'] +} + +export interface ProjectWithSecrets extends Project { + secrets: Secret[] +} + +export interface ProjectWithMembersAndSecrets + extends ProjectWithMembers, + ProjectWithSecrets {} + +export interface ProjectMembership { + id: string + role: ProjectRole + user: User + invitationAccepted: boolean +} diff --git a/apps/api/src/project/repository/interface.repository.ts b/apps/api/src/project/repository/interface.repository.ts index b1cc001c..933c7341 100644 --- a/apps/api/src/project/repository/interface.repository.ts +++ b/apps/api/src/project/repository/interface.repository.ts @@ -1,4 +1,9 @@ import { Project, ProjectMember, ProjectRole, User } from '@prisma/client' +import { + ProjectWithMembersAndSecrets, + ProjectWithSecrets, + ProjectWithUserRole +} from '../project.types' export const PROJECT_REPOSITORY = 'PROJECT_REPOSITORY' @@ -113,19 +118,19 @@ export interface IProjectRepository { * Retrieves a project by user ID and project ID. * @param {User['id']} userId - The ID of the user. * @param {Project['id']} projectId - The ID of the project. - * @returns {Promise} - A promise that resolves to the project or null if not found. + * @returns {Promise} - A promise that resolves to the project or null if not found. */ getProjectByUserIdAndId( userId: User['id'], projectId: Project['id'] - ): Promise + ): Promise /** * Retrieves a project by ID. * @param {Project['id']} projectId - The ID of the project. - * @returns {Promise} - A promise that resolves to the project or null if not found. + * @returns {Promise} - A promise that resolves to the project or null if not found. */ - getProjectById(projectId: Project['id']): Promise + getProjectById(projectId: Project['id']): Promise /** * Retrieves projects of a user with optional pagination, sorting, and search. @@ -135,7 +140,7 @@ export interface IProjectRepository { * @param {string} sort - The field to sort by. * @param {string} order - The sort order ('asc' or 'desc'). * @param {string} search - The search term for project names or descriptions. - * @returns {Promise>} - A promise that resolves to an array of projects and permission. + * @returns {Promise>} - A promise that resolves to an array of projects and permission. */ getProjectsOfUser( userId: User['id'], @@ -144,7 +149,7 @@ export interface IProjectRepository { sort: string, order: string, search: string - ): Promise> + ): Promise> /** * Retrieves projects with optional pagination, sorting, and search. diff --git a/apps/api/src/project/repository/mock.repository.ts b/apps/api/src/project/repository/mock.repository.ts index c74295f7..5d910263 100644 --- a/apps/api/src/project/repository/mock.repository.ts +++ b/apps/api/src/project/repository/mock.repository.ts @@ -7,8 +7,21 @@ import { User } from '@prisma/client' import { IProjectRepository } from './interface.repository' +import { + ProjectWithMembersAndSecrets, + ProjectWithSecrets +} from '../project.types' export class MockProjectRepository implements IProjectRepository { + getProjectByUserIdAndId( + userId: string, + projectId: string + ): Promise { + throw new Error('Method not implemented.') + } + getProjectById(projectId: string): Promise { + throw new Error('Method not implemented.') + } deleteMembership(projectId: string, userId: string): Promise { throw new Error('Method not implemented.') } @@ -92,14 +105,6 @@ export class MockProjectRepository implements IProjectRepository { throw new Error('Method not implemented.') } - getProjectByUserIdAndId(userId: string, projectId: string): Promise { - throw new Error('Method not implemented.') - } - - getProjectById(projectId: string): Promise { - throw new Error('Method not implemented.') - } - getProjectsOfUser( userId: User['id'], page: number, diff --git a/apps/api/src/project/repository/project.repository.ts b/apps/api/src/project/repository/project.repository.ts index 6ef1626b..6f04b318 100644 --- a/apps/api/src/project/repository/project.repository.ts +++ b/apps/api/src/project/repository/project.repository.ts @@ -2,6 +2,7 @@ import { Project, ProjectMember, ProjectRole, User } from '@prisma/client' import { PrismaService } from '../../prisma/prisma.service' import { IProjectRepository } from './interface.repository' import { Injectable } from '@nestjs/common' +import { ProjectMembership, ProjectWithUserRole } from '../project.types' @Injectable() export class ProjectRepository implements IProjectRepository { @@ -183,10 +184,18 @@ export class ProjectRepository implements IProjectRepository { }, include: { members: { - select: { - user: true, - invitationAccepted: true, - role: true + include: { + user: true + } + }, + secrets: { + include: { + versions: { + orderBy: { + version: 'desc' + }, + take: 1 + } } } } @@ -205,6 +214,16 @@ export class ProjectRepository implements IProjectRepository { invitationAccepted: true, role: true } + }, + secrets: { + include: { + versions: { + orderBy: { + version: 'desc' + }, + take: 1 + } + } } } }) @@ -217,7 +236,7 @@ export class ProjectRepository implements IProjectRepository { sort: string, order: string, search: string - ): Promise> { + ): Promise> { const memberships = await this.prisma.projectMember.findMany({ skip: (page - 1) * limit, orderBy: { @@ -299,14 +318,7 @@ export class ProjectRepository implements IProjectRepository { sort: string, order: string, search: string - ): Promise< - { - id: string - role: ProjectRole - user: User - invitationAccepted: boolean - }[] - > { + ): Promise { return await this.prisma.projectMember.findMany({ skip: (page - 1) * limit, take: limit, diff --git a/apps/api/src/project/service/project.service.spec.ts b/apps/api/src/project/service/project.service.spec.ts index 537ff262..d1e34291 100644 --- a/apps/api/src/project/service/project.service.spec.ts +++ b/apps/api/src/project/service/project.service.spec.ts @@ -10,6 +10,8 @@ import { MockMailService } from '../../mail/services/mock.service' import { MAIL_SERVICE } from '../../mail/services/interface.service' import { JwtService } from '@nestjs/jwt' import { ProjectPermission } from '../misc/project.permission' +import { SECRET_REPOSITORY } from '../../secret/repository/interface.repository' +import { MockSecretRepository } from '../../secret/repository/mock.repository' describe('ProjectService', () => { let service: ProjectService @@ -25,6 +27,7 @@ describe('ProjectService', () => { }, { provide: USER_REPOSITORY, useClass: MockUserRepository }, { provide: MAIL_SERVICE, useClass: MockMailService }, + { provide: SECRET_REPOSITORY, useClass: MockSecretRepository }, ProjectPermission, JwtService ] diff --git a/apps/api/src/project/service/project.service.ts b/apps/api/src/project/service/project.service.ts index 9bcb93c7..369213ca 100644 --- a/apps/api/src/project/service/project.service.ts +++ b/apps/api/src/project/service/project.service.ts @@ -5,7 +5,7 @@ import { Logger, NotFoundException } from '@nestjs/common' -import { Project, ProjectRole, User } from '@prisma/client' +import { Project, ProjectRole, SecretVersion, User } from '@prisma/client' import { CreateProject, ProjectMemberDTO @@ -32,6 +32,14 @@ import { CurrentUser } from '../../decorators/user.decorator' import { JwtService } from '@nestjs/jwt' import { createKeyPair } from '../../common/create-key-pair' import { excludeFields } from '../../common/exclude-fields' +import { + ProjectWithMembersAndSecrets, + ProjectWithSecrets +} from '../project.types' +import { + ISecretRepository, + SECRET_REPOSITORY +} from '../../secret/repository/interface.repository' @Injectable() export class ProjectService { @@ -43,6 +51,8 @@ export class ProjectService { @Inject(ENVIRONMENT_REPOSITORY) private readonly environmentRepository: IEnvironmentRepository, @Inject(USER_REPOSITORY) private readonly userRepository: IUserRepository, + @Inject(SECRET_REPOSITORY) + private readonly secretRepository: ISecretRepository, @Inject(MAIL_SERVICE) private readonly resendService: IMailService, private readonly jwt: JwtService, private readonly permission: ProjectPermission @@ -127,10 +137,10 @@ export class ProjectService { projectId: Project['id'], dto: UpdateProject ): Promise { - const project = await this.projectRepository.getProjectByUserIdAndId( + const project = (await this.projectRepository.getProjectByUserIdAndId( user.id, projectId - ) + )) as ProjectWithSecrets // Check if the project exists or not if (!project) @@ -146,7 +156,7 @@ export class ProjectService { ) // Check if the user has the permission to update the project - this.permission.canUpdateProject(user, projectId) + this.permission.isProjectMaintainer(user, projectId) const data: Partial = { name: dto.name, @@ -174,7 +184,23 @@ export class ProjectService { // Check if the private key should be stored data.privateKey = dto.storePrivateKey ? privateKey : null - // TODO: Re-hash all secrets + // Re-hash all secrets + for (const secret of project.secrets) { + const versions = await this.secretRepository.getAllVersionsOfSecret( + secret.id + ) + + const updatedVersions: Partial[] = [] + for (const version of versions) { + updatedVersions.push({ + id: version.id, + value: version.value, + version: version.version + 1 + }) + } + + await this.secretRepository.updateVersions(secret.id, updatedVersions) + } } // Update and return the project @@ -202,7 +228,7 @@ export class ProjectService { throw new NotFoundException(`Project with id ${projectId} not found`) // Check if the user has the permission to delete the project - this.permission.canDeleteProject(user, projectId) + this.permission.isProjectAdmin(user, projectId) // Delete the project await this.projectRepository.deleteProject(projectId) @@ -224,7 +250,7 @@ export class ProjectService { throw new NotFoundException(`Project with id ${projectId} not found`) // Check if the user has the permission to add users to the project - this.permission.canAddUserToProject(user, projectId) + this.permission.isProjectAdmin(user, projectId) // Add users to the project if any if (members && members.length > 0) { @@ -247,7 +273,7 @@ export class ProjectService { throw new NotFoundException(`Project with id ${projectId} not found`) // Check if the user has the permission to remove users from the project - this.permission.canRemoveUserFromProject(user, projectId) + this.permission.isProjectAdmin(user, projectId) // Check if the user is already a member of the project if ( @@ -290,7 +316,7 @@ export class ProjectService { throw new NotFoundException(`Project with id ${projectId} not found`) // Check if the user has the permission to update the role of the user - this.permission.canUpdateUserPermissionsOfProject(user, projectId) + this.permission.isProjectAdmin(user, projectId) // Check if the member in concern is a part of the project or not if ( @@ -324,9 +350,6 @@ export class ProjectService { if (!project) throw new NotFoundException(`Project with id ${projectId} not found`) - // Check if the user has maintainer or owner role in the project - this.permission.canUpdateUserPermissionsOfProject(user, projectId) - return await this.projectRepository.memberExistsInProject( projectId, otherUserId @@ -359,7 +382,7 @@ export class ProjectService { inviteeId: User['id'] ): Promise { // Check if the user has permission to decline the invitation - this.permission.canRemoveUserFromProject(user, projectId) + this.permission.isProjectAdmin(user, projectId) // Check if the user has a pending invitation to the project if (!(await this.projectRepository.invitationPending(projectId, inviteeId))) @@ -398,12 +421,7 @@ export class ProjectService { projectId: Project['id'] ): Promise { // Check if the user is a member of the project - if ( - !(await this.projectRepository.memberExistsInProject(projectId, user.id)) - ) - throw new ConflictException( - `User ${user.name} (${user.id}) is not a member of project ${projectId}` - ) + await this.permission.isProjectMember(user, projectId) // Delete the membership await this.projectRepository.deleteMembership(projectId, user.id) @@ -414,24 +432,17 @@ export class ProjectService { async getProjectByUserAndId( user: User, projectId: Project['id'] - ): Promise { - const project = await this.projectRepository.getProjectByUserIdAndId( + ): Promise { + const project = (await this.projectRepository.getProjectByUserIdAndId( user.id, projectId - ) + )) as ProjectWithMembersAndSecrets // Check if the project exists or not if (!project) throw new NotFoundException(`Project with id ${projectId} not found`) - //@ts-expect-error We know that project.members is not undefined since it is included in the query - const memberCount = project.members.length - - const data = { - ...project, - members: memberCount - } - return data + return project } async getProjectById( diff --git a/apps/api/src/secret/controller/secret.controller.spec.ts b/apps/api/src/secret/controller/secret.controller.spec.ts new file mode 100644 index 00000000..374133ab --- /dev/null +++ b/apps/api/src/secret/controller/secret.controller.spec.ts @@ -0,0 +1,58 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { SecretController } from './secret.controller' +import { PROJECT_REPOSITORY } from '../../project/repository/interface.repository' +import { MockProjectRepository } from '../../project/repository/mock.repository' +import { ENVIRONMENT_REPOSITORY } from '../../environment/repository/interface.repository' +import { MockEnvironmentRepository } from '../../environment/repository/mock.repository' +import { SECRET_REPOSITORY } from '../repository/interface.repository' +import { MockSecretRepository } from '../repository/mock.repository' +import { ProjectPermission } from '../../project/misc/project.permission' +import { ProjectService } from '../../project/service/project.service' +import { USER_REPOSITORY } from '../../user/repository/interface.repository' +import { MockUserRepository } from '../../user/repository/mock.repository' +import { MAIL_SERVICE } from '../../mail/services/interface.service' +import { MockMailService } from '../../mail/services/mock.service' +import { JwtService } from '@nestjs/jwt' +import { SecretService } from '../service/secret.service' + +describe('SecretController', () => { + let controller: SecretController + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SecretController], + providers: [ + { + provide: PROJECT_REPOSITORY, + useClass: MockProjectRepository + }, + { + provide: ENVIRONMENT_REPOSITORY, + useClass: MockEnvironmentRepository + }, + { + provide: SECRET_REPOSITORY, + useClass: MockSecretRepository + }, + { + provide: USER_REPOSITORY, + useClass: MockUserRepository + }, + { + provide: MAIL_SERVICE, + useClass: MockMailService + }, + ProjectPermission, + ProjectService, + JwtService, + SecretService + ] + }).compile() + + controller = module.get(SecretController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) +}) diff --git a/apps/api/src/secret/controller/secret.controller.ts b/apps/api/src/secret/controller/secret.controller.ts new file mode 100644 index 00000000..643933bf --- /dev/null +++ b/apps/api/src/secret/controller/secret.controller.ts @@ -0,0 +1,149 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + UseGuards +} from '@nestjs/common' +import { SecretService } from '../service/secret.service' +import { CurrentUser } from '../../decorators/user.decorator' +import { 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.guard' + +@Controller('secret') +export class SecretController { + constructor(private readonly secretService: SecretService) {} + + @Post(':projectId') + async createSecret( + @CurrentUser() user: User, + @Param('projectId') projectId: string, + @Body() dto: CreateSecret + ) { + return await this.secretService.createSecret(user, dto, projectId) + } + + @Put(':projectId/:secretId') + async updateSecret( + @CurrentUser() user: User, + @Param('projectId') projectId: string, + @Param('secretId') secretId: string, + @Body() dto: UpdateSecret + ) { + return await this.secretService.updateSecret(user, secretId, dto, projectId) + } + + @Put(':projectId/:secretId/environment/:environmentId') + async updateSecretEnvironment( + @CurrentUser() user: User, + @Param('projectId') projectId: string, + @Param('secretId') secretId: string, + @Param('environmentId') environmentId: string + ) { + return await this.secretService.updateSecretEnvironment( + user, + secretId, + environmentId, + projectId + ) + } + + @Put(':projectId/:secretId/rollback/:rollbackVersion') + async rollbackSecret( + @CurrentUser() user: User, + @Param('projectId') projectId: string, + @Param('secretId') secretId: string, + @Param('rollbackVersion') rollbackVersion: number + ) { + return await this.secretService.rollbackSecret( + user, + secretId, + rollbackVersion, + projectId + ) + } + + @Delete(':projectId/:secretId') + async deleteSecret( + @CurrentUser() user: User, + @Param('projectId') projectId: string, + @Param('secretId') secretId: string + ) { + return await this.secretService.deleteSecret(user, secretId, projectId) + } + + @Get(':projectId/:secretId') + async getSecret( + @CurrentUser() user: User, + @Param('projectId') projectId: string, + @Param('secretId') secretId: string, + @Query('decryptValue') decryptValue: boolean = false + ) { + return await this.secretService.getSecret( + user, + secretId, + projectId, + decryptValue + ) + } + + @Get(':projectId/:secretId/versions') + async getAllVersionsOfSecret( + @CurrentUser() user: User, + @Param('projectId') projectId: string, + @Param('secretId') secretId: string + ) { + return await this.secretService.getAllVersionsOfSecret( + user, + secretId, + projectId + ) + } + + @Get(':projectId') + async getAllSecretsOfProject( + @CurrentUser() user: User, + @Param('projectId') projectId: string, + @Query('page') page: number = 1, + @Query('limit') limit: number = 10, + @Query('sort') sort: string = 'name', + @Query('order') order: string = 'asc', + @Query('search') search: string = '', + @Query('decryptValue') decryptValue: boolean = false + ) { + return await this.secretService.getAllSecretsOfProject( + user, + projectId, + decryptValue, + 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/dto/create.secret/create.secret.spec.ts b/apps/api/src/secret/dto/create.secret/create.secret.spec.ts new file mode 100644 index 00000000..e90e3cec --- /dev/null +++ b/apps/api/src/secret/dto/create.secret/create.secret.spec.ts @@ -0,0 +1,7 @@ +import { CreateSecret } from './create.secret'; + +describe('CreateSecret', () => { + it('should be defined', () => { + expect(new CreateSecret()).toBeDefined(); + }); +}); diff --git a/apps/api/src/secret/dto/create.secret/create.secret.ts b/apps/api/src/secret/dto/create.secret/create.secret.ts new file mode 100644 index 00000000..ea2eec2c --- /dev/null +++ b/apps/api/src/secret/dto/create.secret/create.secret.ts @@ -0,0 +1,13 @@ +import { IsNumber, IsOptional, IsString } from 'class-validator' + +export class CreateSecret { + @IsString() + name: string + + @IsString() + value: string + + @IsNumber() + @IsOptional() + environmentId: string +} diff --git a/apps/api/src/secret/dto/update.secret/update.secret.spec.ts b/apps/api/src/secret/dto/update.secret/update.secret.spec.ts new file mode 100644 index 00000000..21548bd5 --- /dev/null +++ b/apps/api/src/secret/dto/update.secret/update.secret.spec.ts @@ -0,0 +1,7 @@ +import { UpdateSecret } from './update.secret'; + +describe('UpdateSecret', () => { + it('should be defined', () => { + expect(new UpdateSecret()).toBeDefined(); + }); +}); diff --git a/apps/api/src/secret/dto/update.secret/update.secret.ts b/apps/api/src/secret/dto/update.secret/update.secret.ts new file mode 100644 index 00000000..31cfd13e --- /dev/null +++ b/apps/api/src/secret/dto/update.secret/update.secret.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger' +import { CreateSecret } from '../create.secret/create.secret' + +export class UpdateSecret extends PartialType(CreateSecret) {} diff --git a/apps/api/src/secret/repository/interface.repository.ts b/apps/api/src/secret/repository/interface.repository.ts new file mode 100644 index 00000000..50c2d53a --- /dev/null +++ b/apps/api/src/secret/repository/interface.repository.ts @@ -0,0 +1,70 @@ +import { + Environment, + Project, + Secret, + SecretVersion, + User +} from '@prisma/client' + +export const SECRET_REPOSITORY = 'SECRET_REPOSITORY' + +export interface ISecretRepository { + secretExists( + secretName: Secret['name'], + environmentId: Environment['id'], + projectId: Project['id'], + userId: User['id'] + ): Promise + + createSecret( + secret: Partial, + projectId: Project['id'], + environmentId: Environment['id'], + userId: User['id'] + ): Promise + + updateSecret( + secretId: Secret['id'], + secret: Partial, + userId: User['id'] + ): Promise + + updateVersions( + secretId: Secret['id'], + versions: Partial[] + ): Promise + + updateSecretEnvironment( + secretId: Secret['id'], + environmentId: Environment['id'], + userId: User['id'] + ): Promise + + rollbackSecret( + secretId: Secret['id'], + rollbackVersion: SecretVersion['version'] + ): Promise + + deleteSecret(secretId: Secret['id'], userId: User['id']): Promise + + getSecret(secretId: Secret['id'], projectId: Project['id']): Promise + + getAllVersionsOfSecret(secretId: string): Promise + + getAllSecretsOfProject( + projectId: Project['id'], + page: number, + limit: number, + sort: string, + order: string, + search: string + ): Promise + + getAllSecrets( + page: number, + limit: number, + sort: string, + order: string, + search: string + ): Promise +} diff --git a/apps/api/src/secret/repository/mock.repository.ts b/apps/api/src/secret/repository/mock.repository.ts new file mode 100644 index 00000000..24150d54 --- /dev/null +++ b/apps/api/src/secret/repository/mock.repository.ts @@ -0,0 +1,167 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { ISecretRepository } from './interface.repository' + +export class MockSecretRepository implements ISecretRepository { + secretExists( + secretName: string, + environmentId: string, + projectId: string, + userId: string + ): Promise { + throw new Error('Method not implemented.') + } + createSecret( + secret: Partial<{ + id: string + name: string + createdAt: Date + updatedAt: Date + rotateAt: Date + lastUpdatedById: string + projectId: string + environmentId: string + }>, + projectId: string, + environmentId: string, + userId: string + ): Promise<{ + id: string + name: string + createdAt: Date + updatedAt: Date + rotateAt: Date + lastUpdatedById: string + projectId: string + environmentId: string + }> { + throw new Error('Method not implemented.') + } + updateSecret( + secretId: string, + secret: Partial<{ + id: string + name: string + createdAt: Date + updatedAt: Date + rotateAt: Date + lastUpdatedById: string + projectId: string + environmentId: string + }>, + userId: string + ): Promise<{ + id: string + name: string + createdAt: Date + updatedAt: Date + rotateAt: Date + lastUpdatedById: string + projectId: string + environmentId: string + }> { + throw new Error('Method not implemented.') + } + updateVersions( + secretId: string, + versions: Partial<{ + id: string + value: string + version: number + secretId: string + createdOn: Date + createdById: string + }>[] + ): Promise { + throw new Error('Method not implemented.') + } + updateSecretEnvironment( + secretId: string, + environmentId: string, + userId: string + ): Promise<{ + id: string + name: string + createdAt: Date + updatedAt: Date + rotateAt: Date + lastUpdatedById: string + projectId: string + environmentId: string + }> { + throw new Error('Method not implemented.') + } + rollbackSecret(secretId: string, rollbackVersion: number): Promise { + throw new Error('Method not implemented.') + } + deleteSecret(secretId: string, userId: string): Promise { + throw new Error('Method not implemented.') + } + getSecret( + secretId: string, + projectId: string + ): Promise<{ + id: string + name: string + createdAt: Date + updatedAt: Date + rotateAt: Date + lastUpdatedById: string + projectId: string + environmentId: string + }> { + throw new Error('Method not implemented.') + } + getAllVersionsOfSecret(secretId: string): Promise< + { + id: string + value: string + version: number + secretId: string + createdOn: Date + createdById: string + }[] + > { + throw new Error('Method not implemented.') + } + getAllSecretsOfProject( + projectId: string, + page: number, + limit: number, + sort: string, + order: string, + search: string + ): Promise< + { + id: string + name: string + createdAt: Date + updatedAt: Date + rotateAt: Date + lastUpdatedById: string + projectId: string + environmentId: string + }[] + > { + throw new Error('Method not implemented.') + } + getAllSecrets( + page: number, + limit: number, + sort: string, + order: string, + search: string + ): Promise< + { + id: string + name: string + createdAt: Date + updatedAt: Date + rotateAt: Date + lastUpdatedById: string + projectId: string + environmentId: string + }[] + > { + throw new Error('Method not implemented.') + } +} diff --git a/apps/api/src/secret/repository/secret.repository.ts b/apps/api/src/secret/repository/secret.repository.ts new file mode 100644 index 00000000..17074589 --- /dev/null +++ b/apps/api/src/secret/repository/secret.repository.ts @@ -0,0 +1,272 @@ +import { Injectable } from '@nestjs/common' +import { PrismaService } from '../../prisma/prisma.service' +import { ISecretRepository } from './interface.repository' +import { + Environment, + Project, + Secret, + SecretVersion, + User +} from '@prisma/client' +import { SecretWithValue } from '../secret.types' + +@Injectable() +export class SecretRepository implements ISecretRepository { + constructor(private readonly prisma: PrismaService) {} + + async secretExists( + secretName: Secret['name'], + environmentId: Environment['id'], + projectId: Project['id'], + userId: User['id'] + ): Promise { + return ( + (await this.prisma.secret.count({ + where: { + name: secretName, + environment: { + id: environmentId + }, + projectId, + project: { + members: { + some: { + userId + } + } + } + } + })) > 0 + ) + } + + async createSecret( + secret: Partial, // Value comes in hashed + projectId: Project['id'], + environmentId: Environment['id'], + userId: User['id'] + ): Promise { + return await this.prisma.secret.create({ + data: { + name: secret.name, + rotateAt: secret.rotateAt, + versions: { + create: { + value: secret.value, + version: 1, + createdById: userId + } + }, + environmentId, + projectId, + lastUpdatedById: userId + } + }) + } + + async updateSecret( + secretId: Secret['id'], + secret: Partial, // Value comes in hashed + userId: User['id'] + ): Promise { + if (secret.value) { + const previousVersion = await this.prisma.secretVersion.findFirst({ + where: { + secretId + }, + select: { + version: true + }, + orderBy: { + version: 'desc' + }, + take: 1 + }) + + return await this.prisma.secret.update({ + where: { + id: secretId + }, + data: { + name: secret.name, + rotateAt: secret.rotateAt, + lastUpdatedById: userId, + versions: { + create: { + value: secret.value, + version: previousVersion.version + 1, + createdById: userId + } + } + } + }) + } + + return await this.prisma.secret.update({ + where: { + id: secretId + }, + data: { + name: secret.name, + rotateAt: secret.rotateAt, + lastUpdatedById: userId + } + }) + } + + async updateVersions( + secretId: Secret['id'], + versions: Partial[] // Value comes in hashed + ): Promise { + await this.prisma.secret.update({ + where: { + id: secretId + }, + data: { + versions: { + updateMany: { + where: { + id: { + in: versions.map((version) => version.id) + } + }, + data: versions.map((version) => ({ + value: version.value + })) + } + } + } + }) + } + + async updateSecretEnvironment( + secretId: Secret['id'], + environmentId: Environment['id'] + ): Promise { + return await this.prisma.secret.update({ + where: { + id: secretId + }, + data: { + environmentId + } + }) + } + + async rollbackSecret( + secretId: Secret['id'], + rollbackVersion: SecretVersion['version'] + ): Promise { + await this.prisma.secretVersion.deleteMany({ + where: { + secretId, + version: { + gt: rollbackVersion + } + } + }) + } + + async deleteSecret(secretId: Secret['id']): Promise { + await this.prisma.secret.delete({ + where: { + id: secretId + } + }) + return Promise.resolve() + } + + async getSecret( + secretId: Secret['id'], + projectId: Project['id'] + ): Promise { + return await this.prisma.secret.findUnique({ + where: { + id: secretId, + projectId + }, + include: { + versions: { + orderBy: { + version: 'desc' + }, + take: 1 + }, + lastUpdatedBy: true, + environment: true + } + }) + } + + async getAllVersionsOfSecret(secretId: string): Promise { + return await this.prisma.secretVersion.findMany({ + where: { + secretId + } + }) + } + + async getAllSecretsOfProject( + projectId: Project['id'], + page: number, + limit: number, + sort: string, + order: string, + search: string + ): Promise { + return await this.prisma.secret.findMany({ + where: { + projectId, + name: { + contains: search + } + }, + include: { + versions: { + orderBy: { + version: 'desc' + }, + take: 1 + }, + lastUpdatedBy: true, + environment: true + }, + skip: (page - 1) * limit, + take: limit, + orderBy: { + [sort]: order + } + }) + } + + async getAllSecrets( + page: number, + limit: number, + sort: string, + order: string, + search: string + ): Promise { + return await this.prisma.secret.findMany({ + where: { + name: { + contains: search + } + }, + include: { + versions: { + orderBy: { + version: 'desc' + }, + take: 1 + }, + lastUpdatedBy: true, + environment: true + }, + skip: (page - 1) * limit, + take: limit, + orderBy: { + [sort]: order + } + }) + } +} diff --git a/apps/api/src/secret/secret.module.ts b/apps/api/src/secret/secret.module.ts new file mode 100644 index 00000000..8c3ed38c --- /dev/null +++ b/apps/api/src/secret/secret.module.ts @@ -0,0 +1,36 @@ +import { Module } from '@nestjs/common' +import { SecretController } from './controller/secret.controller' +import { SecretService } from './service/secret.service' +import { SECRET_REPOSITORY } from './repository/interface.repository' +import { SecretRepository } from './repository/secret.repository' +import { PROJECT_REPOSITORY } from '../project/repository/interface.repository' +import { ProjectPermission } from '../project/misc/project.permission' +import { ENVIRONMENT_REPOSITORY } from '../environment/repository/interface.repository' +import { EnvironmentRepository } from '../environment/repository/environment.repository' + +@Module({ + controllers: [SecretController], + providers: [ + SecretService, + { + provide: SECRET_REPOSITORY, + useClass: SecretRepository + }, + { + provide: PROJECT_REPOSITORY, + useClass: SecretRepository + }, + { + provide: ENVIRONMENT_REPOSITORY, + useClass: EnvironmentRepository + }, + ProjectPermission + ], + exports: [ + { + provide: SECRET_REPOSITORY, + useClass: SecretRepository + } + ] +}) +export class SecretModule {} diff --git a/apps/api/src/secret/secret.types.ts b/apps/api/src/secret/secret.types.ts new file mode 100644 index 00000000..a1f05d7f --- /dev/null +++ b/apps/api/src/secret/secret.types.ts @@ -0,0 +1,9 @@ +import { Secret, SecretVersion } from '@prisma/client' + +export interface SecretWithValue extends Secret { + value: string +} + +export interface SecretWithVersion extends Secret { + versions: SecretVersion[] +} diff --git a/apps/api/src/secret/service/secret.service.spec.ts b/apps/api/src/secret/service/secret.service.spec.ts new file mode 100644 index 00000000..e89299e6 --- /dev/null +++ b/apps/api/src/secret/service/secret.service.spec.ts @@ -0,0 +1,56 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { SecretService } from './secret.service' +import { PROJECT_REPOSITORY } from '../../project/repository/interface.repository' +import { MockProjectRepository } from '../../project/repository/mock.repository' +import { ENVIRONMENT_REPOSITORY } from '../../environment/repository/interface.repository' +import { MockEnvironmentRepository } from '../../environment/repository/mock.repository' +import { SECRET_REPOSITORY } from '../repository/interface.repository' +import { MockSecretRepository } from '../repository/mock.repository' +import { ProjectPermission } from '../../project/misc/project.permission' +import { ProjectService } from '../../project/service/project.service' +import { USER_REPOSITORY } from '../../user/repository/interface.repository' +import { MockUserRepository } from '../../user/repository/mock.repository' +import { MAIL_SERVICE } from '../../mail/services/interface.service' +import { MockMailService } from '../../mail/services/mock.service' +import { JwtService } from '@nestjs/jwt' + +describe('SecretService', () => { + let service: SecretService + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: PROJECT_REPOSITORY, + useClass: MockProjectRepository + }, + { + provide: ENVIRONMENT_REPOSITORY, + useClass: MockEnvironmentRepository + }, + { + provide: SECRET_REPOSITORY, + useClass: MockSecretRepository + }, + { + provide: USER_REPOSITORY, + useClass: MockUserRepository + }, + { + provide: MAIL_SERVICE, + useClass: MockMailService + }, + ProjectPermission, + ProjectService, + JwtService, + SecretService + ] + }).compile() + + service = module.get(SecretService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) +}) diff --git a/apps/api/src/secret/service/secret.service.ts b/apps/api/src/secret/service/secret.service.ts new file mode 100644 index 00000000..ac92cda6 --- /dev/null +++ b/apps/api/src/secret/service/secret.service.ts @@ -0,0 +1,432 @@ +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException +} from '@nestjs/common' +import { + IProjectRepository, + PROJECT_REPOSITORY +} from '../../project/repository/interface.repository' +import { + ISecretRepository, + SECRET_REPOSITORY +} from '../repository/interface.repository' +import { + Environment, + Project, + Secret, + SecretVersion, + User +} from '@prisma/client' +import { CreateSecret } from '../dto/create.secret/create.secret' +import { ProjectPermission } from '../../project/misc/project.permission' +import { + ENVIRONMENT_REPOSITORY, + IEnvironmentRepository +} from '../../environment/repository/interface.repository' +import { UpdateSecret } from '../dto/update.secret/update.secret' +import { decrypt } from '../../common/decrypt' +import { SecretWithVersion } from '../secret.types' + +@Injectable() +export class SecretService { + constructor( + @Inject(PROJECT_REPOSITORY) + private readonly projectRepository: IProjectRepository, + @Inject(SECRET_REPOSITORY) + private readonly secretReposiotory: ISecretRepository, + @Inject(ENVIRONMENT_REPOSITORY) + private readonly environmentRepository: IEnvironmentRepository, + private readonly projectPermission: ProjectPermission + ) {} + + private async secretExists( + user: User, + secretName: Secret['name'], + environmentId: Environment['name'], + projectId: Project['id'] + ) { + return await this.secretReposiotory.secretExists( + user.id, + secretName, + environmentId, + projectId + ) + } + + async createSecret(user: User, dto: CreateSecret, projectId: Project['id']) { + const environmentId = dto.environmentId + // Fetch the project + const project = await this.projectRepository.getProjectByUserIdAndId( + user.id, + projectId + ) + if (!project) { + throw new NotFoundException(`Project not found: ${projectId}`) + } + + // Check if the project can create secrets in the project + await this.projectPermission.isProjectMaintainer(user, projectId) + + // Check if the environment exists + let environment: Environment | null = null + if (environmentId) { + environment = + await this.environmentRepository.getEnvironmentByProjectIdAndId( + projectId, + environmentId + ) + if (!environment) { + throw new NotFoundException( + `Environment not found: ${environmentId} in project ${projectId}` + ) + } + } + if (!environment) { + environment = + await this.environmentRepository.getDefaultEnvironmentOfProject( + projectId + ) + } + + // If any default environment was not found, throw an error + if (!environment) { + throw new NotFoundException( + `No default environment found for project: ${projectId}` + ) + } + + // Check if the secret already exists in the environment + if (await this.secretExists(user, dto.name, environment.id, projectId)) { + throw new ConflictException( + `Secret already exists: ${dto.name} in environment ${environment.name} in project ${projectId}` + ) + } + + // Create the secret + return await this.secretReposiotory.createSecret( + dto, + projectId, + environment.id, + user.id + ) + } + + async updateSecret( + user: User, + secretId: Secret['id'], + dto: UpdateSecret, + projectId: Project['id'] + ) { + // Fetch the project + const project = await this.projectRepository.getProjectByUserIdAndId( + user.id, + projectId + ) + if (!project) { + throw new NotFoundException(`Project not found: ${projectId}`) + } + + // Check if the project can create secrets in the project + await this.projectPermission.isProjectMaintainer(user, projectId) + + // Check if the secret exists + const secret = await this.secretReposiotory.getSecret(secretId, projectId) + if (!secret) { + throw new NotFoundException(`Secret not found: ${secretId}`) + } + + // Check if the secret already exists in the environment + if ( + dto.name && + (await this.secretExists( + user, + dto.name, + secret.environmentId, + projectId + )) && + secret.name !== dto.name + ) { + throw new ConflictException( + `Secret already exists: ${dto.name} in environment ${secret.environmentId} in project ${projectId}` + ) + } + + // Update the secret + return await this.secretReposiotory.updateSecret(secretId, dto, user.id) + } + + async updateSecretEnvironment( + user: User, + secretId: Secret['id'], + environmentId: Environment['id'], + projectId: Project['id'] + ) { + // Fetch the project + const project = await this.projectRepository.getProjectByUserIdAndId( + user.id, + projectId + ) + if (!project) { + throw new NotFoundException(`Project not found: ${projectId}`) + } + + // Check if the project can create secrets in the project + await this.projectPermission.isProjectMaintainer(user, projectId) + + // Check if the secret exists + const secret = await this.secretReposiotory.getSecret(secretId, projectId) + if (!secret) { + throw new NotFoundException(`Secret not found: ${secretId}`) + } + + // Check if the environment exists + const environment = + await this.environmentRepository.getEnvironmentByProjectIdAndId( + projectId, + environmentId + ) + if (!environment) { + throw new NotFoundException( + `Environment not found: ${environmentId} in project ${projectId}` + ) + } + + // Check if the secret already exists in the environment + if ( + (await this.secretExists(user, secret.name, environment.id, projectId)) && + secret.environmentId !== environment.id + ) { + throw new ConflictException( + `Secret already exists: ${secret.name} in environment ${environment.id} in project ${projectId}` + ) + } + + // Update the secret + return await this.secretReposiotory.updateSecretEnvironment( + secretId, + environmentId, + user.id + ) + } + + async rollbackSecret( + user: User, + secretId: Secret['id'], + rollbackVersion: SecretVersion['version'], + projectId: Project['id'] + ) { + // Fetch the project + const project = await this.projectRepository.getProjectByUserIdAndId( + user.id, + projectId + ) + if (!project) { + throw new NotFoundException(`Project not found: ${projectId}`) + } + + // Check if the project can create secrets in the project + await this.projectPermission.isProjectMaintainer(user, projectId) + + // Check if the secret exists + const secret = (await this.secretReposiotory.getSecret( + secretId, + projectId + )) as SecretWithVersion + if (!secret) { + throw new NotFoundException(`Secret not found: ${secretId}`) + } + + // Check if the rollback version is valid + if (rollbackVersion <= 1 || rollbackVersion > secret.versions[0].version) { + throw new NotFoundException( + `Invalid rollback version: ${rollbackVersion} for secret: ${secretId}` + ) + } + + // Rollback the secret + return await this.secretReposiotory.rollbackSecret( + secretId, + rollbackVersion + ) + } + + async deleteSecret( + user: User, + secretId: Secret['id'], + projectId: Project['id'] + ) { + // Fetch the project + const project = await this.projectRepository.getProjectByUserIdAndId( + user.id, + projectId + ) + if (!project) { + throw new NotFoundException(`Project not found: ${projectId}`) + } + + // Check if the project can create secrets in the project + await this.projectPermission.isProjectMaintainer(user, projectId) + + // Check if the secret exists + const secret = await this.secretReposiotory.getSecret(secretId, projectId) + if (!secret) { + throw new NotFoundException(`Secret not found: ${secretId}`) + } + + // Delete the secret + return await this.secretReposiotory.deleteSecret(secretId, user.id) + } + + async getSecret( + user: User, + secretId: Secret['id'], + projectId: Project['id'], + decryptValue: boolean + ) { + // Fetch the project + const project = await this.projectRepository.getProjectByUserIdAndId( + user.id, + projectId + ) + if (!project) { + throw new NotFoundException(`Project not found: ${projectId}`) + } + + // Fetch the secret + const secret = (await this.secretReposiotory.getSecret( + secretId, + projectId + )) as SecretWithVersion + if (!secret) { + throw new NotFoundException(`Secret not found: ${secretId}`) + } + + // Check if the project is allowed to store the private key + if (decryptValue && !project.storePrivateKey) { + throw new BadRequestException( + `Cannot decrypt secret value: ${secretId} as the project does not store the private key` + ) + } + + // Check if the project has a private key. This is just to ensure that we don't run into any + // problems while decrypting the secret + if (decryptValue && !project.privateKey) { + throw new NotFoundException( + `Cannot decrypt secret value: ${secretId} as the project does not have a private key` + ) + } + + if (decryptValue) { + // Decrypt the secret value + const decryptedValue = decrypt( + project.privateKey, + secret.versions[0].value + ) + secret.versions[0].value = decryptedValue + } + + // Return the secret + return secret + } + + async getAllVersionsOfSecret( + user: User, + secretId: Secret['id'], + projectId: Project['id'] + ) { + // Fetch the project + const project = await this.projectRepository.getProjectByUserIdAndId( + user.id, + projectId + ) + if (!project) { + throw new NotFoundException(`Project not found: ${projectId}`) + } + + // Fetch the secret + const secret = await this.secretReposiotory.getSecret(secretId, projectId) + if (!secret) { + throw new NotFoundException(`Secret not found: ${secretId}`) + } + + // Return the secret versions + return await this.secretReposiotory.getAllVersionsOfSecret(secretId) + } + + async getAllSecretsOfProject( + user: User, + projectId: Project['id'], + decryptValue: boolean, + page: number, + limit: number, + sort: string, + order: string, + search: string + ) { + // Fetch the project + const project = await this.projectRepository.getProjectByUserIdAndId( + user.id, + projectId + ) + if (!project) { + throw new NotFoundException(`Project not found: ${projectId}`) + } + + // Check if the project is allowed to store the private key + if (decryptValue && !project.storePrivateKey) { + throw new BadRequestException( + `Cannot decrypt secret values as the project does not store the private key` + ) + } + + // Check if the project has a private key. This is just to ensure that we don't run into any + // problems while decrypting the secret + if (decryptValue && !project.privateKey) { + throw new NotFoundException( + `Cannot decrypt secret values as the project does not have a private key` + ) + } + + const secrets = (await this.secretReposiotory.getAllSecretsOfProject( + projectId, + page, + limit, + sort, + order, + search + )) as SecretWithVersion[] + + // Return the secrets + return secrets.map((secret) => { + if (decryptValue) { + // Decrypt the secret value + const decryptedValue = 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.secretReposiotory.getAllSecrets( + page, + limit, + sort, + order, + search + ) + } +}