diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index b0acb5f73..fc123df4e 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common' +import { Module, Global } from '@nestjs/common' import { AuthService } from './service/auth.service' import { AuthController } from './controller/auth.controller' import { JwtModule } from '@nestjs/jwt' @@ -11,7 +11,10 @@ import { GitlabOAuthStrategyFactory } from '@/config/factory/gitlab/gitlab-strat import { GitlabStrategy } from '@/config/oauth-strategy/gitlab/gitlab.strategy' import { seconds, ThrottlerModule } from '@nestjs/throttler' import { ConfigModule, ConfigService } from '@nestjs/config' +import { AuthorizationService } from './service/authorization.service' +import { AuthorityCheckerService } from './service/authority-checker.service' +@Global() @Module({ imports: [ JwtModule.register({ @@ -37,6 +40,8 @@ import { ConfigModule, ConfigService } from '@nestjs/config' ], providers: [ AuthService, + AuthorizationService, + AuthorityCheckerService, GithubOAuthStrategyFactory, { provide: GithubStrategy, @@ -62,6 +67,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config' inject: [GitlabOAuthStrategyFactory] } ], - controllers: [AuthController] + controllers: [AuthController], + exports: [AuthorizationService] }) export class AuthModule {} diff --git a/apps/api/src/auth/auth.types.ts b/apps/api/src/auth/auth.types.ts index c6d5e3535..6377bf9d7 100644 --- a/apps/api/src/auth/auth.types.ts +++ b/apps/api/src/auth/auth.types.ts @@ -6,6 +6,7 @@ export interface UserAuthenticatedResponse extends UserWithWorkspace { } export type AuthenticatedUserContext = User & { + ipAddress: string isAuthViaApiKey?: boolean apiKeyAuthorities?: Set defaultWorkspace: Workspace diff --git a/apps/api/src/auth/guard/auth/auth.guard.ts b/apps/api/src/auth/guard/auth/auth.guard.ts index cfb104a22..fa0341dfb 100644 --- a/apps/api/src/auth/guard/auth/auth.guard.ts +++ b/apps/api/src/auth/guard/auth/auth.guard.ts @@ -1,208 +1,221 @@ -import { - CanActivate, - ExecutionContext, - ForbiddenException, - Injectable, - UnauthorizedException -} from '@nestjs/common' -import { JwtService } from '@nestjs/jwt' -import { Reflector } from '@nestjs/core' -import { IS_PUBLIC_KEY } from '@/decorators/public.decorator' -import { PrismaService } from '@/prisma/prisma.service' -import { ONBOARDING_BYPASSED } from '@/decorators/bypass-onboarding.decorator' -import { AuthenticatedUserContext } from '../../auth.types' -import { EnvSchema } from '@/common/env/env.schema' -import { CacheService } from '@/cache/cache.service' -import { toSHA256 } from '@/common/cryptography' -import { getUserByEmailOrId } from '@/common/user' -import { constructErrorBody } from '@/common/util' - -const X_E2E_USER_EMAIL = 'x-e2e-user-email' -const X_KEYSHADE_TOKEN = 'x-keyshade-token' - -// FIXME: Error at line:47 & line:55 process.env.NODE_ENV === 'dev' - -@Injectable() -export class AuthGuard implements CanActivate { - constructor( - private readonly jwtService: JwtService, - private readonly prisma: PrismaService, - private readonly reflector: Reflector, - private readonly cache: CacheService - ) {} - - /** - * This method is called by NestJS every time an HTTP request is made to an endpoint - * that is protected by this guard. It checks if the request is authenticated and if - * the user is active. If the user is not active, it throws an UnauthorizedException. - * If the onboarding is not finished, it throws an UnauthorizedException. - * @param context The ExecutionContext object that contains information about the - * request. - * @returns A boolean indicating if the request is authenticated and the user is active. - */ - async canActivate(context: ExecutionContext): Promise { - // Get the kind of route. Routes marked with the @Public() decorator are public. - const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ - context.getHandler(), - context.getClass() - ]) - - // We don't want to check for authentication if the route is public. - if (isPublic) { - return true - } - - let user: AuthenticatedUserContext | null = null - const request = context.switchToHttp().getRequest() - const authType = this.getAuthType(request) - const parsedEnv = EnvSchema.safeParse(process.env) - let nodeEnv - - if (!parsedEnv.success) { - nodeEnv = 'dev' // Default to a valid value or handle appropriately - } else { - nodeEnv = parsedEnv.data.NODE_ENV - } - - if (nodeEnv !== 'e2e' && authType === 'NONE') { - throw new ForbiddenException('No authentication provided') - } - - // In case the environment is e2e, we want to authenticate the user using the email - // else we want to authenticate the user using the JWT token. - - if (authType !== 'API_KEY' && nodeEnv === 'e2e') { - const email = request.headers[X_E2E_USER_EMAIL] - if (!email) { - throw new ForbiddenException() - } - - user = await getUserByEmailOrId(email, this.prisma) - } else { - const request = context.switchToHttp().getRequest() - - if (authType === 'API_KEY') { - const apiKeyValue = this.extractApiKeyFromHeader(request) - if (!apiKeyValue) { - throw new ForbiddenException('No API key provided') - } - - const apiKey = await this.prisma.apiKey.findUnique({ - where: { - value: toSHA256(apiKeyValue) - }, - include: { - user: true - } - }) - - if (!apiKey) { - throw new ForbiddenException('Invalid API key') - } - - const defaultWorkspace = await this.prisma.workspace.findFirst({ - where: { - ownerId: apiKey.userId, - isDefault: true - } - }) - - user = { - ...apiKey.user, - defaultWorkspace - } - user.isAuthViaApiKey = true - user.apiKeyAuthorities = new Set(apiKey.authorities) - } else if (authType === 'JWT') { - const token = this.extractTokenFromCookies(request) - if (!token) { - throw new ForbiddenException() - } - try { - const payload = await this.jwtService.verifyAsync(token, { - secret: process.env.JWT_SECRET - }) - - const cachedUser = await this.cache.getUser(payload['id']) - if (cachedUser) user = cachedUser - else { - user = await getUserByEmailOrId(payload['id'], this.prisma) - } - } catch { - throw new ForbiddenException() - } - } else { - throw new ForbiddenException('No authentication provided') - } - } - - // If the user is not found, we throw a ForbiddenException. - if (!user) { - throw new ForbiddenException() - } - - // If the user is not active, we throw an UnauthorizedException. - if (!user.isActive) { - throw new UnauthorizedException( - constructErrorBody( - 'User not active', - 'Please contact us if you think this is a mistake' - ) - ) - } - - const onboardingBypassed = - this.reflector.getAllAndOverride(ONBOARDING_BYPASSED, [ - context.getHandler(), - context.getClass() - ]) ?? false - - // If the onboarding is not finished, we throw an UnauthorizedException. - if (!onboardingBypassed && !user.isOnboardingFinished) { - throw new UnauthorizedException( - constructErrorBody( - 'Onboarding not finished', - 'Please complete the onboarding' - ) - ) - } - - // We attach the user to the request object. - request['user'] = user - return true - } - - private getAuthType(request: any): 'JWT' | 'API_KEY' | 'NONE' { - const headers = this.getHeaders(request) - const cookies = request.cookies - if (headers[X_KEYSHADE_TOKEN]) { - return 'API_KEY' - } - if (cookies && cookies['token']) { - return 'JWT' - } - return 'NONE' - } - - private extractTokenFromCookies(request: any): string | undefined { - const headers = this.getCookies(request) - const [type, token] = headers.token?.split(' ') ?? [] - return type === 'Bearer' ? token : undefined - } - - private extractApiKeyFromHeader(request: any): string | undefined { - const headers = this.getHeaders(request) - if (Array.isArray(headers[X_KEYSHADE_TOKEN])) { - throw new ForbiddenException('Bad auth') - } - return headers[X_KEYSHADE_TOKEN] - } - - private getHeaders(request: any): any { - return request.headers || request.handshake.headers // For websockets - } - - private getCookies(request: any): any { - return request.cookies - } -} +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + UnauthorizedException +} from '@nestjs/common' +import { JwtService } from '@nestjs/jwt' +import { Reflector } from '@nestjs/core' +import { IS_PUBLIC_KEY } from '@/decorators/public.decorator' +import { PrismaService } from '@/prisma/prisma.service' +import { ONBOARDING_BYPASSED } from '@/decorators/bypass-onboarding.decorator' +import { AuthenticatedUserContext } from '../../auth.types' +import { EnvSchema } from '@/common/env/env.schema' +import { CacheService } from '@/cache/cache.service' +import { toSHA256 } from '@/common/cryptography' +import { getUserByEmailOrId } from '@/common/user' +import { Request } from 'express' +import { constructErrorBody } from '@/common/util' + +const X_E2E_USER_EMAIL = 'x-e2e-user-email' +const X_KEYSHADE_TOKEN = 'x-keyshade-token' + +// FIXME: Error at line:47 & line:55 process.env.NODE_ENV === 'dev' + +@Injectable() +export class AuthGuard implements CanActivate { + constructor( + private readonly jwtService: JwtService, + private readonly prisma: PrismaService, + private readonly reflector: Reflector, + private readonly cache: CacheService + ) {} + + /** + * This method is called by NestJS every time an HTTP request is made to an endpoint + * that is protected by this guard. It checks if the request is authenticated and if + * the user is active. If the user is not active, it throws an UnauthorizedException. + * If the onboarding is not finished, it throws an UnauthorizedException. + * @param context The ExecutionContext object that contains information about the + * request. + * @returns A boolean indicating if the request is authenticated and the user is active. + */ + async canActivate(context: ExecutionContext): Promise { + // Get the kind of route. Routes marked with the @Public() decorator are public. + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass() + ]) + + // We don't want to check for authentication if the route is public. + if (isPublic) { + return true + } + + let userContext: AuthenticatedUserContext | null = null + const request = context.switchToHttp().getRequest() + const authType = this.getAuthType(request) + const parsedEnv = EnvSchema.safeParse(process.env) + let nodeEnv + + if (!parsedEnv.success) { + nodeEnv = 'dev' // Default to a valid value or handle appropriately + } else { + nodeEnv = parsedEnv.data.NODE_ENV + } + + if (nodeEnv !== 'e2e' && authType === 'NONE') { + throw new ForbiddenException('No authentication provided') + } + + // In case the environment is e2e, we want to authenticate the user using the email + // else we want to authenticate the user using the JWT token. + + if (authType !== 'API_KEY' && nodeEnv === 'e2e') { + const email = request.headers[X_E2E_USER_EMAIL] as string + if (!email) { + throw new ForbiddenException() + } + const user = await getUserByEmailOrId(email, this.prisma) + + userContext = { + ...user, + ipAddress: request.ip + } + } else { + if (authType === 'API_KEY') { + const apiKeyValue = this.extractApiKeyFromHeader(request) + if (!apiKeyValue) { + throw new ForbiddenException('No API key provided') + } + + const apiKey = await this.prisma.apiKey.findUnique({ + where: { + value: toSHA256(apiKeyValue) + }, + include: { + user: true + } + }) + + if (!apiKey) { + throw new ForbiddenException('Invalid API key') + } + + const defaultWorkspace = await this.prisma.workspace.findFirst({ + where: { + ownerId: apiKey.userId, + isDefault: true + } + }) + + userContext = { + ...apiKey.user, + defaultWorkspace, + ipAddress: request.ip + } + userContext.isAuthViaApiKey = true + userContext.apiKeyAuthorities = new Set(apiKey.authorities) + } else if (authType === 'JWT') { + const token = this.extractTokenFromCookies(request) + if (!token) { + throw new ForbiddenException() + } + try { + const payload = await this.jwtService.verifyAsync(token, { + secret: process.env.JWT_SECRET + }) + + const cachedUser = await this.cache.getUser(payload['id']) + if (cachedUser) { + userContext = { + ...cachedUser, + ipAddress: request.ip + } + } else { + const user = await getUserByEmailOrId(payload['id'], this.prisma) + + userContext = { + ...user, + ipAddress: request.ip + } + } + } catch { + throw new ForbiddenException() + } + } else { + throw new ForbiddenException('No authentication provided') + } + } + + // If the user is not found, we throw a ForbiddenException. + if (!userContext) { + throw new ForbiddenException() + } + + // If the user is not active, we throw an UnauthorizedException. + if (!userContext.isActive) { + throw new UnauthorizedException( + constructErrorBody( + 'User not active', + 'Please contact us if you think this is a mistake' + ) + ) + } + + const onboardingBypassed = + this.reflector.getAllAndOverride(ONBOARDING_BYPASSED, [ + context.getHandler(), + context.getClass() + ]) ?? false + + // If the onboarding is not finished, we throw an UnauthorizedException. + if (!onboardingBypassed && !userContext.isOnboardingFinished) { + throw new UnauthorizedException( + constructErrorBody( + 'Onboarding not finished', + 'Please complete the onboarding' + ) + ) + } + + // We attach the user to the request object. + request['user'] = userContext + return true + } + + private getAuthType(request: any): 'JWT' | 'API_KEY' | 'NONE' { + const headers = this.getHeaders(request) + const cookies = request.cookies + if (headers[X_KEYSHADE_TOKEN]) { + return 'API_KEY' + } + if (cookies && cookies['token']) { + return 'JWT' + } + return 'NONE' + } + + private extractTokenFromCookies(request: any): string | undefined { + const headers = this.getCookies(request) + const [type, token] = headers.token?.split(' ') ?? [] + return type === 'Bearer' ? token : undefined + } + + private extractApiKeyFromHeader(request: any): string | undefined { + const headers = this.getHeaders(request) + if (Array.isArray(headers[X_KEYSHADE_TOKEN])) { + throw new ForbiddenException('Bad auth') + } + return headers[X_KEYSHADE_TOKEN] + } + + private getHeaders(request: any): any { + return request.headers || request.handshake.headers // For websockets + } + + private getCookies(request: any): any { + return request.cookies + } +} diff --git a/apps/api/src/common/authority-checker.service.ts b/apps/api/src/auth/service/authority-checker.service.ts similarity index 78% rename from apps/api/src/common/authority-checker.service.ts rename to apps/api/src/auth/service/authority-checker.service.ts index 7874206f2..40dad9c76 100644 --- a/apps/api/src/common/authority-checker.service.ts +++ b/apps/api/src/auth/service/authority-checker.service.ts @@ -1,492 +1,485 @@ -import { - PrismaClient, - Authority, - Workspace, - ProjectAccessLevel -} from '@prisma/client' -import { VariableWithProjectAndVersion } from '@/variable/variable.types' -import { - Injectable, - InternalServerErrorException, - NotFoundException, - UnauthorizedException -} from '@nestjs/common' -import { EnvironmentWithProject } from '@/environment/environment.types' -import { ProjectWithSecrets } from '@/project/project.types' -import { SecretWithProjectAndVersion } from '@/secret/secret.types' -import { CustomLoggerService } from './logger.service' -import { - getCollectiveEnvironmentAuthorities, - getCollectiveProjectAuthorities, - getCollectiveWorkspaceAuthorities -} from './collective-authorities' -import { IntegrationWithWorkspace } from '@/integration/integration.types' -import { constructErrorBody } from './util' - -export interface AuthorityInput { - userId: string - authorities: Authority[] - prisma: PrismaClient - entity: { slug?: string; name?: string } -} - -@Injectable() -export class AuthorityCheckerService { - constructor(private customLoggerService: CustomLoggerService) {} - - /** - * Checks if the user has the required authorities to access the given workspace. - * - * @param input The input object containing the userId, entity, authorities, and prisma client - * @returns The workspace if the user has the required authorities - * @throws InternalServerErrorException if there's an error when communicating with the database - * @throws NotFoundException if the workspace is not found - * @throws UnauthorizedException if the user does not have the required authorities - */ - public async checkAuthorityOverWorkspace( - input: AuthorityInput - ): Promise { - const { userId, entity, authorities, prisma } = input - - let workspace: Workspace - - try { - if (entity.slug) { - workspace = await prisma.workspace.findUnique({ - where: { - slug: entity.slug - } - }) - } else { - workspace = await prisma.workspace.findFirst({ - where: { - name: entity.name, - members: { some: { userId: userId } } - } - }) - } - } catch (error) { - this.customLoggerService.error(error) - throw new InternalServerErrorException(error) - } - - if (!workspace) { - throw new NotFoundException( - constructErrorBody( - 'Workspace not found', - `Workspace ${entity.slug} does not exist` - ) - ) - } - - const permittedAuthorities = await getCollectiveWorkspaceAuthorities( - workspace.id, - userId, - prisma - ) - - this.checkHasPermissionOverEntity(permittedAuthorities, authorities) - - return workspace - } - - /** - * Checks if the user has the required authorities to access the given project. - * - * @param input The input object containing the userId, entity, authorities, and prisma client - * @returns The project if the user has the required authorities - * @throws InternalServerErrorException if there's an error when communicating with the database - * @throws NotFoundException if the project is not found - * @throws UnauthorizedException if the user does not have the required authorities - */ - public async checkAuthorityOverProject( - input: AuthorityInput - ): Promise { - const { userId, entity, authorities, prisma } = input - - let project: ProjectWithSecrets - - try { - if (entity.slug) { - project = await prisma.project.findUnique({ - where: { - slug: entity.slug - }, - include: { - secrets: true - } - }) - } else { - project = await prisma.project.findFirst({ - where: { - name: entity.name, - workspace: { members: { some: { userId: userId } } } - }, - include: { - secrets: true - } - }) - } - } catch (error) { - this.customLoggerService.error(error) - throw new InternalServerErrorException(error) - } - - if (!project) { - throw new NotFoundException( - constructErrorBody( - 'Project not found', - `Project ${entity.slug} does not exist` - ) - ) - } - - const permittedAuthoritiesForProject: Set = - await getCollectiveProjectAuthorities(userId, project, prisma) - - const permittedAuthoritiesForWorkspace: Set = - await getCollectiveWorkspaceAuthorities( - project.workspaceId, - userId, - prisma - ) - - const projectAccessLevel = project.accessLevel - switch (projectAccessLevel) { - case ProjectAccessLevel.GLOBAL: - // In the global case, we check if the authorities being passed in - // contains just the READ_PROJECT authority. If not, we need to - // check if the user has access to the other authorities mentioned as well. - if ( - authorities.length !== 1 || - !authorities.includes(Authority.READ_PROJECT) - ) { - this.checkHasPermissionOverEntity( - permittedAuthoritiesForWorkspace, - authorities - ) - } - break - case ProjectAccessLevel.INTERNAL: - this.checkHasPermissionOverEntity( - permittedAuthoritiesForWorkspace, - authorities - ) - break - case ProjectAccessLevel.PRIVATE: - this.checkHasPermissionOverEntity( - permittedAuthoritiesForProject, - authorities - ) - break - } - - return project - } - - /** - * Checks if the user has the required authorities to access the given environment. - * - * @param input The input object containing the userId, entity, authorities, and prisma client - * @returns The environment if the user has the required authorities - * @throws InternalServerErrorException if there's an error when communicating with the database - * @throws NotFoundException if the environment is not found - * @throws UnauthorizedException if the user does not have the required authorities - */ - public async checkAuthorityOverEnvironment( - input: AuthorityInput - ): Promise { - const { userId, entity, authorities, prisma } = input - - let environment: EnvironmentWithProject - - try { - if (entity.slug) { - environment = await prisma.environment.findUnique({ - where: { - slug: entity.slug - }, - include: { - project: true - } - }) - } else { - environment = await prisma.environment.findFirst({ - where: { - name: entity.name, - project: { workspace: { members: { some: { userId: userId } } } } - }, - include: { - project: true - } - }) - } - } catch (error) { - this.customLoggerService.error(error) - throw new InternalServerErrorException(error) - } - - if (!environment) { - throw new NotFoundException( - constructErrorBody( - 'Environment not found', - `Environment ${entity.slug} does not exist` - ) - ) - } - - const permittedAuthorities = await getCollectiveEnvironmentAuthorities( - userId, - environment, - prisma - ) - - this.checkHasPermissionOverEntity(permittedAuthorities, authorities) - - return environment - } - - /** - * Checks if the user has the required authorities to access the given variable. - * - * @param input The input object containing the userId, entity, authorities, and prisma client - * @returns The variable if the user has the required authorities - * @throws InternalServerErrorException if there's an error when communicating with the database - * @throws NotFoundException if the variable is not found - * @throws UnauthorizedException if the user does not have the required authorities - */ - public async checkAuthorityOverVariable( - input: AuthorityInput - ): Promise { - const { userId, entity, authorities, prisma } = input - - let variable: VariableWithProjectAndVersion - - try { - if (entity.slug) { - variable = await prisma.variable.findUnique({ - where: { - slug: entity.slug - }, - include: { - versions: true, - project: true - } - }) - } else { - variable = await prisma.variable.findFirst({ - where: { - name: entity.name, - project: { workspace: { members: { some: { userId: userId } } } } - }, - include: { - versions: true, - project: true - } - }) - } - } catch (error) { - this.customLoggerService.error(error) - throw new InternalServerErrorException(error) - } - - if (!variable) { - throw new NotFoundException( - constructErrorBody( - 'Variable not found', - `Variable ${entity.slug} does not exist` - ) - ) - } - - const permittedAuthorities = await getCollectiveProjectAuthorities( - userId, - variable.project, - prisma - ) - - this.checkHasPermissionOverEntity(permittedAuthorities, authorities) - - return variable - } - - /** - * Checks if the user has the required authorities to access the given secret. - * - * @param input The input object containing the userId, entity, authorities, and prisma client - * @returns The secret if the user has the required authorities - * @throws InternalServerErrorException if there's an error when communicating with the database - * @throws NotFoundException if the secret is not found - * @throws UnauthorizedException if the user does not have the required authorities - */ - public async checkAuthorityOverSecret( - input: AuthorityInput - ): Promise { - const { userId, entity, authorities, prisma } = input - - let secret: SecretWithProjectAndVersion - - try { - if (entity.slug) { - secret = await prisma.secret.findUnique({ - where: { - slug: entity.slug - }, - include: { - versions: true, - project: true - } - }) - } else { - secret = await prisma.secret.findFirst({ - where: { - name: entity.name, - project: { workspace: { members: { some: { userId: userId } } } } - }, - include: { - versions: true, - project: true - } - }) - } - } catch (error) { - this.customLoggerService.error(error) - throw new InternalServerErrorException(error) - } - - if (!secret) { - throw new NotFoundException( - constructErrorBody( - 'Secret not found', - `Secret ${entity.slug} does not exist` - ) - ) - } - - const permittedAuthorities = await getCollectiveProjectAuthorities( - userId, - secret.project, - prisma - ) - - this.checkHasPermissionOverEntity(permittedAuthorities, authorities) - - return secret - } - - /** - * Checks if the user has the required authorities to access the given integration. - * - * @param input The input object containing the userId, entity, authorities, and prisma client - * @returns The integration if the user has the required authorities - * @throws InternalServerErrorException if there's an error when communicating with the database - * @throws NotFoundException if the integration is not found - * @throws UnauthorizedException if the user does not have the required authorities - */ - public async checkAuthorityOverIntegration( - input: AuthorityInput - ): Promise { - const { userId, entity, authorities, prisma } = input - - let integration: IntegrationWithWorkspace | null - - try { - if (entity.slug) { - integration = await prisma.integration.findUnique({ - where: { - slug: entity.slug - }, - include: { - workspace: true - } - }) - } else { - integration = await prisma.integration.findFirst({ - where: { - name: entity.name, - workspace: { members: { some: { userId: userId } } } - }, - include: { - workspace: true - } - }) - } - } catch (error) { - this.customLoggerService.error(error) - throw new InternalServerErrorException(error) - } - - if (!integration) { - throw new NotFoundException( - constructErrorBody( - 'Integration not found', - `Integration ${entity.slug} does not exist` - ) - ) - } - - const permittedAuthorities = await getCollectiveWorkspaceAuthorities( - integration.workspaceId, - userId, - prisma - ) - - this.checkHasPermissionOverEntity(permittedAuthorities, authorities) - - if (integration.projectId) { - const project = await prisma.project.findUnique({ - where: { - id: integration.projectId - } - }) - - if (!project) { - throw new NotFoundException( - `Project with ID ${integration.projectId} not found` - ) - } - - const projectAuthorities = await getCollectiveProjectAuthorities( - userId, - project, - prisma - ) - - this.checkHasPermissionOverEntity(projectAuthorities, authorities) - } - - return integration - } - - /** - * Checks if the user has all the required authorities to perform an action. - * Throws UnauthorizedException if the user does not have all the required authorities. - * - * @param permittedAuthorities The set of authorities that the user has - * @param authorities The set of authorities required to perform the action - * @param userId The slug of the user - * @returns void - * @throws UnauthorizedException if the user does not have all the required authorities - */ - private checkHasPermissionOverEntity( - permittedAuthorities: Set, - authorities: Authority[] - ): void { - // We commence the check if WORKSPACE_ADMIN isn't in the list of permitted authorities - if (!permittedAuthorities.has(Authority.WORKSPACE_ADMIN)) { - // Check if the authority object passed is completely contained within the permitted authorities - const hasRequiredAuthority = authorities.every((auth) => - permittedAuthorities.has(auth) - ) - - if (!hasRequiredAuthority) { - throw new UnauthorizedException( - constructErrorBody( - 'Insufficient permissions', - `You do not have any of the required authorities to perform the action` - ) - ) - } - } - } -} +import { Workspace, Authority, ProjectAccessLevel } from '@prisma/client' +import { VariableWithProjectAndVersion } from '@/variable/variable.types' +import { + Injectable, + InternalServerErrorException, + NotFoundException, + UnauthorizedException +} from '@nestjs/common' +import { EnvironmentWithProject } from '@/environment/environment.types' +import { ProjectWithSecrets } from '@/project/project.types' +import { SecretWithProjectAndVersion } from '@/secret/secret.types' +import { CustomLoggerService } from '@/common/logger.service' +import { + getCollectiveEnvironmentAuthorities, + getCollectiveProjectAuthorities, + getCollectiveWorkspaceAuthorities +} from '@/common/collective-authorities' +import { IntegrationWithWorkspace } from '@/integration/integration.types' +import { AuthorizationParams } from './authorization.types' +import { PrismaService } from '@/prisma/prisma.service' +import { constructErrorBody } from '@/common/util' + +@Injectable() +export class AuthorityCheckerService { + constructor( + private readonly prisma: PrismaService, + private readonly customLoggerService: CustomLoggerService + ) {} + + /** + * Checks if the user has the required authorities to access the given workspace. + * + * @param params The input object containing the user, entity, authorities + * @returns The workspace if the user has the required authorities + * @throws InternalServerErrorException if there's an error when communicating with the database + * @throws NotFoundException if the workspace is not found + * @throws UnauthorizedException if the user does not have the required authorities + */ + public async checkAuthorityOverWorkspace( + params: AuthorizationParams + ): Promise { + const { user, entity, authorities } = params + + let workspace: Workspace + + try { + if (entity.slug) { + workspace = await this.prisma.workspace.findUnique({ + where: { + slug: entity.slug + } + }) + } else if (entity.name) { + workspace = await this.prisma.workspace.findFirst({ + where: { + name: entity.name, + members: { some: { userId: user.id } } + } + }) + } + } catch (error) { + this.customLoggerService.error(error) + throw new InternalServerErrorException(error) + } + + if (!workspace) { + throw new NotFoundException( + constructErrorBody( + 'Workspace not found', + `Workspace ${entity.slug ?? entity.name} not found` + ) + ) + } + + const permittedAuthorities = await getCollectiveWorkspaceAuthorities( + workspace.id, + user.id, + this.prisma + ) + + this.checkHasPermissionOverEntity(permittedAuthorities, authorities) + + return workspace + } + + /** + * Checks if the user has the required authorities to access the given project. + * + * @param params The input object containing the user, entity, authorities + * @returns The project if the user has the required authorities + * @throws InternalServerErrorException if there's an error when communicating with the database + * @throws NotFoundException if the project is not found + * @throws UnauthorizedException if the user does not have the required authorities + */ + public async checkAuthorityOverProject( + params: AuthorizationParams + ): Promise { + const { user, entity, authorities } = params + + let project: ProjectWithSecrets + + try { + if (entity.slug) { + project = await this.prisma.project.findUnique({ + where: { + slug: entity.slug + }, + include: { + secrets: true + } + }) + } else { + project = await this.prisma.project.findFirst({ + where: { + name: entity.name, + workspace: { members: { some: { userId: user.id } } } + }, + include: { + secrets: true + } + }) + } + } catch (error) { + this.customLoggerService.error(error) + throw new InternalServerErrorException(error) + } + + if (!project) { + throw new NotFoundException( + constructErrorBody( + 'Project not found', + `Project ${entity.slug} does not exist` + ) + ) + } + + const permittedAuthoritiesForProject: Set = + await getCollectiveProjectAuthorities(user.id, project, this.prisma) + + const permittedAuthoritiesForWorkspace: Set = + await getCollectiveWorkspaceAuthorities( + project.workspaceId, + user.id, + this.prisma + ) + + const projectAccessLevel = project.accessLevel + switch (projectAccessLevel) { + case ProjectAccessLevel.GLOBAL: + // In the global case, we check if the authorities being passed in + // contains just the READ_PROJECT authority. If not, we need to + // check if the user has access to the other authorities mentioned as well. + if ( + authorities.length !== 1 || + !authorities.includes(Authority.READ_PROJECT) + ) { + this.checkHasPermissionOverEntity( + permittedAuthoritiesForWorkspace, + authorities + ) + } + break + case ProjectAccessLevel.INTERNAL: + this.checkHasPermissionOverEntity( + permittedAuthoritiesForWorkspace, + authorities + ) + break + case ProjectAccessLevel.PRIVATE: + this.checkHasPermissionOverEntity( + permittedAuthoritiesForProject, + authorities + ) + break + } + + return project + } + + /** + * Checks if the user has the required authorities to access the given environment. + * + * @param params The input object containing the user, entity, authorities + * @returns The environment if the user has the required authorities + * @throws InternalServerErrorException if there's an error when communicating with the database + * @throws NotFoundException if the environment is not found + * @throws UnauthorizedException if the user does not have the required authorities + */ + public async checkAuthorityOverEnvironment( + params: AuthorizationParams + ): Promise { + const { user, entity, authorities } = params + + let environment: EnvironmentWithProject + + try { + if (entity.slug) { + environment = await this.prisma.environment.findUnique({ + where: { + slug: entity.slug + }, + include: { + project: true + } + }) + } else { + environment = await this.prisma.environment.findFirst({ + where: { + name: entity.name, + project: { workspace: { members: { some: { userId: user.id } } } } + }, + include: { + project: true + } + }) + } + } catch (error) { + this.customLoggerService.error(error) + throw new InternalServerErrorException(error) + } + + if (!environment) { + throw new NotFoundException( + constructErrorBody( + 'Environment not found', + `Environment ${entity.slug} does not exist` + ) + ) + } + + const permittedAuthorities = await getCollectiveEnvironmentAuthorities( + user.id, + environment, + this.prisma + ) + + this.checkHasPermissionOverEntity(permittedAuthorities, authorities) + + return environment + } + + /** + * Checks if the user has the required authorities to access the given variable. + * + * @param params The input object containing the user, entity, authorities + * @returns The variable if the user has the required authorities + * @throws InternalServerErrorException if there's an error when communicating with the database + * @throws NotFoundException if the variable is not found + * @throws UnauthorizedException if the user does not have the required authorities + */ + public async checkAuthorityOverVariable( + params: AuthorizationParams + ): Promise { + const { user, entity, authorities } = params + + let variable: VariableWithProjectAndVersion + + try { + if (entity.slug) { + variable = await this.prisma.variable.findUnique({ + where: { + slug: entity.slug + }, + include: { + versions: true, + project: true + } + }) + } else { + variable = await this.prisma.variable.findFirst({ + where: { + name: entity.name, + project: { workspace: { members: { some: { userId: user.id } } } } + }, + include: { + versions: true, + project: true + } + }) + } + } catch (error) { + this.customLoggerService.error(error) + throw new InternalServerErrorException(error) + } + + if (!variable) { + throw new NotFoundException( + constructErrorBody( + 'Variable not found', + `Variable ${entity.slug} does not exist` + ) + ) + } + + const permittedAuthorities = await getCollectiveProjectAuthorities( + user.id, + variable.project, + this.prisma + ) + + this.checkHasPermissionOverEntity(permittedAuthorities, authorities) + + return variable + } + + /** + * Checks if the user has the required authorities to access the given secret. + * + * @param params The input object containing the user, entity, authorities + * @returns The secret if the user has the required authorities + * @throws InternalServerErrorException if there's an error when communicating with the database + * @throws NotFoundException if the secret is not found + * @throws UnauthorizedException if the user does not have the required authorities + */ + public async checkAuthorityOverSecret( + params: AuthorizationParams + ): Promise { + const { user, entity, authorities } = params + + let secret: SecretWithProjectAndVersion + + try { + if (entity.slug) { + secret = await this.prisma.secret.findUnique({ + where: { + slug: entity.slug + }, + include: { + versions: true, + project: true + } + }) + } else { + secret = await this.prisma.secret.findFirst({ + where: { + name: entity.name, + project: { workspace: { members: { some: { userId: user.id } } } } + }, + include: { + versions: true, + project: true + } + }) + } + } catch (error) { + this.customLoggerService.error(error) + throw new InternalServerErrorException(error) + } + + if (!secret) { + throw new NotFoundException( + constructErrorBody( + 'Secret not found', + `Secret ${entity.slug} does not exist` + ) + ) + } + + const permittedAuthorities = await getCollectiveProjectAuthorities( + user.id, + secret.project, + this.prisma + ) + + this.checkHasPermissionOverEntity(permittedAuthorities, authorities) + + return secret + } + + /** + * Checks if the user has the required authorities to access the given integration. + * + * @param params The input object containing the user, entity, authorities + * @returns The integration if the user has the required authorities + * @throws InternalServerErrorException if there's an error when communicating with the database + * @throws NotFoundException if the integration is not found + * @throws UnauthorizedException if the user does not have the required authorities + */ + public async checkAuthorityOverIntegration( + params: AuthorizationParams + ): Promise { + const { user, entity, authorities } = params + + let integration: IntegrationWithWorkspace | null + + try { + if (entity.slug) { + integration = await this.prisma.integration.findUnique({ + where: { + slug: entity.slug + }, + include: { + workspace: true + } + }) + } else { + integration = await this.prisma.integration.findFirst({ + where: { + name: entity.name, + workspace: { members: { some: { userId: user.id } } } + }, + include: { + workspace: true + } + }) + } + } catch (error) { + this.customLoggerService.error(error) + throw new InternalServerErrorException(error) + } + + if (!integration) { + throw new NotFoundException( + constructErrorBody( + 'Integration not found', + `Integration ${entity.slug} does not exist` + ) + ) + } + + const permittedAuthorities = await getCollectiveWorkspaceAuthorities( + integration.workspaceId, + user.id, + this.prisma + ) + + this.checkHasPermissionOverEntity(permittedAuthorities, authorities) + + if (integration.projectId) { + const project = await this.prisma.project.findUnique({ + where: { + id: integration.projectId + } + }) + + if (!project) { + throw new NotFoundException( + `Project with ID ${integration.projectId} not found` + ) + } + + const projectAuthorities = await getCollectiveProjectAuthorities( + user.id, + project, + this.prisma + ) + + this.checkHasPermissionOverEntity(projectAuthorities, authorities) + } + + return integration + } + + /** + * Checks if the user has all the required authorities to perform an action. + * Throws UnauthorizedException if the user does not have all the required authorities. + * + * @param permittedAuthorities The set of authorities that the user has + * @param authorities The set of authorities required to perform the action + * @param userId The slug of the user + * @returns void + * @throws UnauthorizedException if the user does not have all the required authorities + */ + private checkHasPermissionOverEntity( + permittedAuthorities: Set, + authorities: Authority[] + ): void { + // We commence the check if WORKSPACE_ADMIN isn't in the list of permitted authorities + if (!permittedAuthorities.has(Authority.WORKSPACE_ADMIN)) { + // Check if the authority object passed is completely contained within the permitted authorities + const hasRequiredAuthority = authorities.every((auth) => + permittedAuthorities.has(auth) + ) + + if (!hasRequiredAuthority) { + throw new UnauthorizedException( + constructErrorBody( + 'Insufficient permissions', + `You do not have any of the required authorities to perform the action` + ) + ) + } + } + } +} diff --git a/apps/api/src/auth/service/authorization.service.spec.ts b/apps/api/src/auth/service/authorization.service.spec.ts new file mode 100644 index 000000000..e3015ead0 --- /dev/null +++ b/apps/api/src/auth/service/authorization.service.spec.ts @@ -0,0 +1,22 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { PrismaService } from '@/prisma/prisma.service' +import { AuthorizationService } from './authorization.service' +import { AuthorityCheckerService } from './authority-checker.service' +import { CommonModule } from '@/common/common.module' + +describe('AuthorizationService', () => { + let service: AuthorizationService + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [CommonModule], + providers: [PrismaService, AuthorizationService, AuthorityCheckerService] + }).compile() + + service = module.get(AuthorizationService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) +}) diff --git a/apps/api/src/auth/service/authorization.service.ts b/apps/api/src/auth/service/authorization.service.ts new file mode 100644 index 000000000..0eb439dd7 --- /dev/null +++ b/apps/api/src/auth/service/authorization.service.ts @@ -0,0 +1,209 @@ +import { UnauthorizedException, Injectable } from '@nestjs/common' +import { AuthorityCheckerService } from './authority-checker.service' +import { ProjectWithSecrets } from '@/project/project.types' +import { EnvironmentWithProject } from '@/environment/environment.types' +import { VariableWithProjectAndVersion } from '@/variable/variable.types' +import { SecretWithProjectAndVersion } from '@/secret/secret.types' +import { IntegrationWithWorkspace } from '@/integration/integration.types' +import { AuthorizationParams } from './authorization.types' +import { AuthenticatedUser } from '@/user/user.types' +import { Workspace, User } from '@prisma/client' +import { PrismaService } from '@/prisma/prisma.service' +import { CustomLoggerService } from '@/common/logger.service' +import { InternalServerErrorException, NotFoundException } from '@nestjs/common' + +@Injectable() +export class AuthorizationService { + constructor( + private readonly prisma: PrismaService, + private readonly authorityCheckerService: AuthorityCheckerService, + private readonly customLoggerService: CustomLoggerService + ) {} + + /** + * Checks if the user is authorized to access the given workspace. + * @param params The authorization parameters + * @returns The workspace if the user is authorized to access it + * @throws InternalServerErrorException if there's an error when communicating with the database + * @throws NotFoundException if the workspace is not found + * @throws UnauthorizedException if the user does not have the required authorities + * @throws ForbiddenException if the user is not authorized to access the workspace + */ + public async authorizeUserAccessToWorkspace( + params: AuthorizationParams + ): Promise { + const workspace = + await this.authorityCheckerService.checkAuthorityOverWorkspace(params) + + this.checkUserHasAccessToWorkspace(params.user, workspace) + + return workspace + } + + /** + * Checks if the user is authorized to access the given project. + * @param params The authorization parameters + * @returns The project if the user is authorized to access it + * @throws InternalServerErrorException if there's an error when communicating with the database + * @throws NotFoundException if the workspace is not found + * @throws UnauthorizedException if the user does not have the required authorities + * @throws ForbiddenException if the user is not authorized to access the project + */ + public async authorizeUserAccessToProject( + params: AuthorizationParams + ): Promise { + const project = + await this.authorityCheckerService.checkAuthorityOverProject(params) + + const workspace = await this.getWorkspace( + params.user.id, + project.workspaceId + ) + + this.checkUserHasAccessToWorkspace(params.user, workspace) + + return project + } + + /** + * Checks if the user is authorized to access the given environment. + * @param params The authorization parameters + * @returns The environment if the user is authorized to access it + * @throws InternalServerErrorException if there's an error when communicating with the database + * @throws NotFoundException if the workspace is not found + * @throws UnauthorizedException if the user does not have the required authorities + * @throws ForbiddenException if the user is not authorized to access the environment + */ + public async authorizeUserAccessToEnvironment( + params: AuthorizationParams + ): Promise { + const environment = + await this.authorityCheckerService.checkAuthorityOverEnvironment(params) + + const workspace = await this.getWorkspace( + params.user.id, + environment.project.workspaceId + ) + + this.checkUserHasAccessToWorkspace(params.user, workspace) + + return environment + } + + /** + * Checks if the user is authorized to access the given variable. + * @param params The authorization parameters + * @returns The variable if the user is authorized to access it + * @throws InternalServerErrorException if there's an error when communicating with the database + * @throws NotFoundException if the workspace is not found + * @throws UnauthorizedException if the user does not have the required authorities + * @throws ForbiddenException if the user is not authorized to access the variable + */ + public async authorizeUserAccessToVariable( + params: AuthorizationParams + ): Promise { + const variable = + await this.authorityCheckerService.checkAuthorityOverVariable(params) + + const workspace = await this.getWorkspace( + params.user.id, + variable.project.workspaceId + ) + + this.checkUserHasAccessToWorkspace(params.user, workspace) + + return variable + } + + /** + * Checks if the user is authorized to access the given secret. + * @param params The authorization parameters + * @returns The secret if the user is authorized to access it + * @throws InternalServerErrorException if there's an error when communicating with the database + * @throws NotFoundException if the workspace is not found + * @throws UnauthorizedException if the user does not have the required authorities + * @throws ForbiddenException if the user is not authorized to access the secret + */ + public async authorizeUserAccessToSecret( + params: AuthorizationParams + ): Promise { + const secret = + await this.authorityCheckerService.checkAuthorityOverSecret(params) + + const workspace = await this.getWorkspace( + params.user.id, + secret.project.workspaceId + ) + + this.checkUserHasAccessToWorkspace(params.user, workspace) + + return secret + } + + /** + * Checks if the user is authorized to access the given integration. + * @param params The authorization parameters + * @returns The integration if the user is authorized to access it + * @throws InternalServerErrorException if there's an error when communicating with the database + * @throws NotFoundException if the workspace is not found + * @throws UnauthorizedException if the user does not have the required authorities + * @throws ForbiddenException if the user is not authorized to access the integration + */ + public async authorizeUserAccessToIntegration( + params: AuthorizationParams + ): Promise { + const integration = + await this.authorityCheckerService.checkAuthorityOverIntegration(params) + + this.checkUserHasAccessToWorkspace(params.user, integration.workspace) + + return integration + } + + /** + * Fetches the requested workspace specified by userId and the filter. + * @param userId The id of the user + * @param filter The filter optionally including the workspace id, slug or name + * @returns The requested workspace + * @throws InternalServerErrorException if there's an error when communicating with the database + * @throws NotFoundException if the workspace is not found + */ + private async getWorkspace( + userId: User['id'], + workspaceId: Workspace['id'] + ): Promise { + let workspace: Workspace + + try { + workspace = await this.prisma.workspace.findUnique({ + where: { + id: workspaceId + } + }) + } catch (error) { + this.customLoggerService.error(error) + throw new InternalServerErrorException(error) + } + + if (!workspace) { + throw new NotFoundException(`Workspace ${workspaceId} not found`) + } + + return workspace + } + + private checkUserHasAccessToWorkspace( + user: AuthenticatedUser, + workspace: Workspace + ) { + if ( + workspace.blacklistedIpAddresses.some( + (ipAddress) => ipAddress === user.ipAddress + ) + ) { + throw new UnauthorizedException( + `User ${user.id} is not allowed to access this workspace` + ) + } + } +} diff --git a/apps/api/src/auth/service/authorization.types.ts b/apps/api/src/auth/service/authorization.types.ts new file mode 100644 index 000000000..2d2874541 --- /dev/null +++ b/apps/api/src/auth/service/authorization.types.ts @@ -0,0 +1,8 @@ +import { Authority } from '@prisma/client' +import { AuthenticatedUser } from '@/user/user.types' + +export interface AuthorizationParams { + user: AuthenticatedUser + authorities: Authority[] + entity: { slug?: string; name?: string } +} diff --git a/apps/api/src/common/common.module.ts b/apps/api/src/common/common.module.ts index 29c83e0fc..8425ff6db 100644 --- a/apps/api/src/common/common.module.ts +++ b/apps/api/src/common/common.module.ts @@ -1,11 +1,10 @@ import { Global, Module } from '@nestjs/common' -import { AuthorityCheckerService } from './authority-checker.service' import { CustomLoggerService } from './logger.service' @Global() @Module({ imports: [], - providers: [AuthorityCheckerService, CustomLoggerService], - exports: [AuthorityCheckerService, CustomLoggerService] + providers: [CustomLoggerService], + exports: [CustomLoggerService] }) export class CommonModule {} diff --git a/apps/api/src/common/environment.ts b/apps/api/src/common/environment.ts index 60b13bacb..48c31f362 100644 --- a/apps/api/src/common/environment.ts +++ b/apps/api/src/common/environment.ts @@ -1,72 +1,72 @@ -import { PrismaService } from '@/prisma/prisma.service' -import { CreateSecret } from '@/secret/dto/create.secret/create.secret' -import { UpdateSecret } from '@/secret/dto/update.secret/update.secret' -import { CreateVariable } from '@/variable/dto/create.variable/create.variable' -import { UpdateVariable } from '@/variable/dto/update.variable/update.variable' -import { BadRequestException, NotFoundException } from '@nestjs/common' -import { Authority, Project, User } from '@prisma/client' -import { AuthorityCheckerService } from './authority-checker.service' -import { constructErrorBody } from './util' - -/** - * Given a list of environment slugs in a CreateSecret, UpdateSecret, CreateVariable, or UpdateVariable DTO, - * this function checks if the user has access to all the environments and if the environments belong to the given project. - * If all the checks pass, it returns a Map of environment slug to environment ID. - * - * @param dto The DTO containing the list of environment slugs - * @param user The user making the request - * @param project The project that the environments must belong to - * @param prisma The PrismaService instance - * @param authorityCheckerService The AuthorityCheckerService instance - * - * @throws NotFoundException if any of the environments do not exist - * @throws BadRequestException if any of the environments do not belong to the given project - * - * @returns A Map of environment slug to environment ID - */ -export const getEnvironmentIdToSlugMap = async ( - dto: CreateSecret | UpdateSecret | CreateVariable | UpdateVariable, - user: User, - project: Project, - prisma: PrismaService, - authorityCheckerService: AuthorityCheckerService -): Promise> => { - const environmentSlugToIdMap = new Map() - - // Check if the user has access to the environments - const environmentSlugs = dto.entries.map((entry) => entry.environmentSlug) - await Promise.all( - environmentSlugs.map(async (environmentSlug) => { - const environment = - await authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, - entity: { slug: environmentSlug }, - authorities: [Authority.READ_ENVIRONMENT], - prisma: prisma - }) - - if (!environment) { - throw new NotFoundException( - constructErrorBody( - 'Environment not found', - `Environment ${environmentSlug} not found` - ) - ) - } - - // Check if the environment belongs to the project - if (environment.projectId !== project.id) { - throw new BadRequestException( - constructErrorBody( - 'Environment does not belong to the project', - `Environment ${environmentSlug} does not belong to project ${project.slug}` - ) - ) - } - - environmentSlugToIdMap.set(environmentSlug, environment.id) - }) - ) - - return environmentSlugToIdMap -} +import { PrismaService } from '@/prisma/prisma.service' +import { CreateSecret } from '@/secret/dto/create.secret/create.secret' +import { UpdateSecret } from '@/secret/dto/update.secret/update.secret' +import { CreateVariable } from '@/variable/dto/create.variable/create.variable' +import { UpdateVariable } from '@/variable/dto/update.variable/update.variable' +import { BadRequestException, NotFoundException } from '@nestjs/common' +import { Authority, Project } from '@prisma/client' +import { AuthorizationService } from '@/auth/service/authorization.service' +import { AuthenticatedUser } from '@/user/user.types' +import { constructErrorBody } from './util' + +/** + * Given a list of environment slugs in a CreateSecret, UpdateSecret, CreateVariable, or UpdateVariable DTO, + * this function checks if the user has access to all the environments and if the environments belong to the given project. + * If all the checks pass, it returns a Map of environment slug to environment ID. + * + * @param dto The DTO containing the list of environment slugs + * @param user The user making the request + * @param project The project that the environments must belong to + * @param prisma The PrismaService instance + * @param authorityCheckerService The AuthorityCheckerService instance + * + * @throws NotFoundException if any of the environments do not exist + * @throws BadRequestException if any of the environments do not belong to the given project + * + * @returns A Map of environment slug to environment ID + */ +export const getEnvironmentIdToSlugMap = async ( + dto: CreateSecret | UpdateSecret | CreateVariable | UpdateVariable, + user: AuthenticatedUser, + project: Project, + prisma: PrismaService, + authorizationService: AuthorizationService +): Promise> => { + const environmentSlugToIdMap = new Map() + + // Check if the user has access to the environments + const environmentSlugs = dto.entries.map((entry) => entry.environmentSlug) + await Promise.all( + environmentSlugs.map(async (environmentSlug) => { + const environment = + await authorizationService.authorizeUserAccessToEnvironment({ + user, + entity: { slug: environmentSlug }, + authorities: [Authority.READ_ENVIRONMENT] + }) + + if (!environment) { + throw new NotFoundException( + constructErrorBody( + 'Environment not found', + `Environment ${environmentSlug} not found` + ) + ) + } + + // Check if the environment belongs to the project + if (environment.projectId !== project.id) { + throw new BadRequestException( + constructErrorBody( + 'Environment does not belong to the project', + `Environment ${environmentSlug} does not belong to project ${project.slug}` + ) + ) + } + + environmentSlugToIdMap.set(environmentSlug, environment.id) + }) + ) + + return environmentSlugToIdMap +} diff --git a/apps/api/src/common/event.ts b/apps/api/src/common/event.ts index 6445326f6..aa350bbd8 100644 --- a/apps/api/src/common/event.ts +++ b/apps/api/src/common/event.ts @@ -17,6 +17,7 @@ import { import { JsonObject } from '@prisma/client/runtime/library' import IntegrationFactory from '@/integration/plugins/factory/integration.factory' import { EventService } from '@/event/service/event.service' +import { AuthenticatedUser } from '@/user/user.types' /** * Creates a new event and saves it to the database. @@ -175,7 +176,7 @@ export const createEvent = async ( */ export const fetchEvents = async ( eventService: EventService, - user: User, + user: AuthenticatedUser, workspaceSlug: string, source?: EventSource, severity?: EventSeverity diff --git a/apps/api/src/decorators/user.decorator.ts b/apps/api/src/decorators/user.decorator.ts index 4c397a5bb..2c5a26582 100644 --- a/apps/api/src/decorators/user.decorator.ts +++ b/apps/api/src/decorators/user.decorator.ts @@ -1,10 +1,10 @@ -import { UserWithWorkspace } from '@/user/user.types' +import { AuthenticatedUser } from '@/user/user.types' import { createParamDecorator, ExecutionContext } from '@nestjs/common' export const CurrentUser = createParamDecorator< unknown, ExecutionContext, - UserWithWorkspace + AuthenticatedUser >((_: unknown, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest() return request.user diff --git a/apps/api/src/environment/controller/environment.controller.spec.ts b/apps/api/src/environment/controller/environment.controller.spec.ts index 7cedc4709..1565968fc 100644 --- a/apps/api/src/environment/controller/environment.controller.spec.ts +++ b/apps/api/src/environment/controller/environment.controller.spec.ts @@ -3,8 +3,9 @@ import { EnvironmentController } from './environment.controller' import { EnvironmentService } from '../service/environment.service' import { PrismaService } from '@/prisma/prisma.service' import { mockDeep } from 'jest-mock-extended' -import { AuthorityCheckerService } from '@/common/authority-checker.service' import { CommonModule } from '@/common/common.module' +import { AuthorizationService } from '@/auth/service/authorization.service' +import { AuthorityCheckerService } from '@/auth/service/authority-checker.service' describe('EnvironmentController', () => { let controller: EnvironmentController @@ -13,7 +14,12 @@ describe('EnvironmentController', () => { const module: TestingModule = await Test.createTestingModule({ imports: [CommonModule], controllers: [EnvironmentController], - providers: [EnvironmentService, PrismaService, AuthorityCheckerService] + providers: [ + EnvironmentService, + PrismaService, + AuthorizationService, + AuthorityCheckerService + ] }) .overrideProvider(PrismaService) .useValue(mockDeep()) diff --git a/apps/api/src/environment/controller/environment.controller.ts b/apps/api/src/environment/controller/environment.controller.ts index b5e92f946..b8248b6d8 100644 --- a/apps/api/src/environment/controller/environment.controller.ts +++ b/apps/api/src/environment/controller/environment.controller.ts @@ -11,9 +11,10 @@ import { import { EnvironmentService } from '../service/environment.service' import { CurrentUser } from '@/decorators/user.decorator' import { CreateEnvironment } from '../dto/create.environment/create.environment' -import { Authority, User } from '@prisma/client' +import { Authority } from '@prisma/client' import { UpdateEnvironment } from '../dto/update.environment/update.environment' import { RequiredApiKeyAuthorities } from '@/decorators/required-api-key-authorities.decorator' +import { AuthenticatedUser } from '@/user/user.types' @Controller('environment') export class EnvironmentController { @@ -22,7 +23,7 @@ export class EnvironmentController { @Post(':projectSlug') @RequiredApiKeyAuthorities(Authority.CREATE_ENVIRONMENT) async createEnvironment( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Body() dto: CreateEnvironment, @Param('projectSlug') projectSlug: string ) { @@ -36,7 +37,7 @@ export class EnvironmentController { @Put(':environmentSlug') @RequiredApiKeyAuthorities(Authority.UPDATE_ENVIRONMENT) async updateEnvironment( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Body() dto: UpdateEnvironment, @Param('environmentSlug') environmentSlug: string ) { @@ -50,7 +51,7 @@ export class EnvironmentController { @Get(':environmentSlug') @RequiredApiKeyAuthorities(Authority.READ_ENVIRONMENT) async getEnvironment( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('environmentSlug') environmentSlug: string ) { return await this.environmentService.getEnvironment(user, environmentSlug) @@ -59,7 +60,7 @@ export class EnvironmentController { @Get('/all/:projectSlug') @RequiredApiKeyAuthorities(Authority.READ_ENVIRONMENT) async getEnvironmentsOfProject( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('projectSlug') projectSlug: string, @Query('page') page: number = 0, @Query('limit') limit: number = 10, @@ -81,7 +82,7 @@ export class EnvironmentController { @Delete(':environmentSlug') @RequiredApiKeyAuthorities(Authority.DELETE_ENVIRONMENT) async deleteEnvironment( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('environmentSlug') environmentSlug: string ) { return await this.environmentService.deleteEnvironment( diff --git a/apps/api/src/environment/environment.e2e.spec.ts b/apps/api/src/environment/environment.e2e.spec.ts index 3befa5d5a..a6eda3e8c 100644 --- a/apps/api/src/environment/environment.e2e.spec.ts +++ b/apps/api/src/environment/environment.e2e.spec.ts @@ -16,7 +16,6 @@ import { EventType, Project, ProjectAccessLevel, - User, Workspace } from '@prisma/client' import { ProjectModule } from '@/project/project.module' @@ -33,6 +32,7 @@ import { ValidationPipe } from '@nestjs/common' // import { VariableService } from '@/variable/service/variable.service' import { SecretModule } from '@/secret/secret.module' import { VariableModule } from '@/variable/variable.module' +import { AuthenticatedUser } from '@/user/user.types' describe('Environment Controller Tests', () => { let app: NestFastifyApplication @@ -44,11 +44,13 @@ describe('Environment Controller Tests', () => { // let secretService: SecretService // let variableService: VariableService - let user1: User, user2: User + let user1: AuthenticatedUser, user2: AuthenticatedUser let workspace1: Workspace let project1: Project let environment1: Environment, environment2: Environment + const USER_IP_ADDRESS = '127.0.0.1' + beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [ @@ -100,8 +102,8 @@ describe('Environment Controller Tests', () => { delete createUser1.defaultWorkspace delete createUser2.defaultWorkspace - user1 = createUser1 - user2 = createUser2 + user1 = { ...createUser1, ipAddress: USER_IP_ADDRESS } + user2 = { ...createUser2, ipAddress: USER_IP_ADDRESS } project1 = (await projectService.createProject(user1, workspace1.slug, { name: 'Project 1', diff --git a/apps/api/src/environment/service/environment.service.spec.ts b/apps/api/src/environment/service/environment.service.spec.ts index a2ea00520..6bb2c90f1 100644 --- a/apps/api/src/environment/service/environment.service.spec.ts +++ b/apps/api/src/environment/service/environment.service.spec.ts @@ -2,8 +2,9 @@ import { Test, TestingModule } from '@nestjs/testing' import { EnvironmentService } from './environment.service' import { PrismaService } from '@/prisma/prisma.service' import { mockDeep } from 'jest-mock-extended' -import { AuthorityCheckerService } from '@/common/authority-checker.service' import { CommonModule } from '@/common/common.module' +import { AuthorizationService } from '@/auth/service/authorization.service' +import { AuthorityCheckerService } from '@/auth/service/authority-checker.service' describe('EnvironmentService', () => { let service: EnvironmentService @@ -11,7 +12,12 @@ describe('EnvironmentService', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [CommonModule], - providers: [EnvironmentService, PrismaService, AuthorityCheckerService] + providers: [ + EnvironmentService, + PrismaService, + AuthorizationService, + AuthorityCheckerService + ] }) .overrideProvider(PrismaService) .useValue(mockDeep()) diff --git a/apps/api/src/environment/service/environment.service.ts b/apps/api/src/environment/service/environment.service.ts index e50b2a1db..6ac01c9e3 100644 --- a/apps/api/src/environment/service/environment.service.ts +++ b/apps/api/src/environment/service/environment.service.ts @@ -1,488 +1,489 @@ -import { - BadRequestException, - ConflictException, - Injectable, - Logger -} from '@nestjs/common' -import { - Authority, - Environment, - EventSource, - EventType, - Project, - User -} from '@prisma/client' -import { CreateEnvironment } from '../dto/create.environment/create.environment' -import { UpdateEnvironment } from '../dto/update.environment/update.environment' -import { PrismaService } from '@/prisma/prisma.service' -import { AuthorityCheckerService } from '@/common/authority-checker.service' -import { paginate } from '@/common/paginate' -import generateEntitySlug from '@/common/slug-generator' -import { createEvent } from '@/common/event' -import { constructErrorBody, limitMaxItemsPerPage } from '@/common/util' - -@Injectable() -export class EnvironmentService { - private readonly logger = new Logger(EnvironmentService.name) - - constructor( - private readonly prisma: PrismaService, - private readonly authorityCheckerService: AuthorityCheckerService - ) {} - - /** - * Creates a new environment in the given project. - * - * This endpoint requires the following authorities: - * - `CREATE_ENVIRONMENT` on the project - * - `READ_ENVIRONMENT` on the project - * - `READ_PROJECT` on the project - * - * If the user does not have the required authorities, a `ForbiddenException` is thrown. - * - * If an environment with the same name already exists in the project, a `ConflictException` is thrown. - * - * The created environment is returned, with the slug generated using the `name` and `ENVIRONMENT` as the entity type. - * - * An event of type `ENVIRONMENT_ADDED` is created, with the following metadata: - * - `environmentId`: The ID of the created environment - * - `name`: The name of the created environment - * - `projectId`: The ID of the project in which the environment was created - * - `projectName`: The name of the project in which the environment was created - * - * @param user The user that is creating the environment - * @param dto The data for the new environment - * @param projectSlug The slug of the project in which to create the environment - * @returns The created environment - */ - async createEnvironment( - user: User, - dto: CreateEnvironment, - projectSlug: Project['slug'] - ) { - // Check if the user has the required role to create an environment - const project = - await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, - entity: { slug: projectSlug }, - authorities: [ - Authority.CREATE_ENVIRONMENT, - Authority.READ_ENVIRONMENT, - Authority.READ_PROJECT - ], - prisma: this.prisma - }) - const projectId = project.id - - // Check if an environment with the same name already exists - await this.environmentExists(dto.name, project) - - // Create the environment - const environment = await this.prisma.environment.create({ - data: { - name: dto.name, - slug: await generateEntitySlug(dto.name, 'ENVIRONMENT', this.prisma), - description: dto.description, - project: { - connect: { - id: projectId - } - }, - lastUpdatedBy: { - connect: { - id: user.id - } - } - }, - include: { - lastUpdatedBy: { - select: { - id: true, - name: true, - profilePictureUrl: true, - email: true - } - } - } - }) - - await createEvent( - { - triggeredBy: user, - entity: environment, - type: EventType.ENVIRONMENT_ADDED, - source: EventSource.ENVIRONMENT, - title: `Environment created`, - metadata: { - environmentId: environment.id, - name: environment.name, - projectId, - projectName: project.name - }, - workspaceId: project.workspaceId - }, - this.prisma - ) - - this.logger.log( - `Environment ${environment.name} created in project ${project.name} (${project.id})` - ) - - return environment - } - - /** - * Updates an environment in the given project. - * - * This endpoint requires the following authorities: - * - `UPDATE_ENVIRONMENT` on the environment - * - `READ_ENVIRONMENT` on the environment - * - `READ_PROJECT` on the project - * - * If the user does not have the required authorities, a `ForbiddenException` is thrown. - * - * If an environment with the same name already exists in the project, a `ConflictException` is thrown. - * - * The updated environment is returned, with the slug generated using the `name` and `ENVIRONMENT` as the entity type. - * - * An event of type `ENVIRONMENT_UPDATED` is created, with the following metadata: - * - `environmentId`: The ID of the updated environment - * - `name`: The name of the updated environment - * - `projectId`: The ID of the project in which the environment was updated - * - `projectName`: The name of the project in which the environment was updated - * - * @param user The user that is updating the environment - * @param dto The data for the updated environment - * @param environmentSlug The slug of the environment to update - * @returns The updated environment - */ - async updateEnvironment( - user: User, - dto: UpdateEnvironment, - environmentSlug: Environment['slug'] - ) { - const environment = - await this.authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, - entity: { slug: environmentSlug }, - authorities: [ - Authority.UPDATE_ENVIRONMENT, - Authority.READ_ENVIRONMENT, - Authority.READ_PROJECT - ], - prisma: this.prisma - }) - - // Check if an environment with the same name already exists - dto.name && (await this.environmentExists(dto.name, environment.project)) - - // Update the environment - const updatedEnvironment = await this.prisma.environment.update({ - where: { - id: environment.id - }, - data: { - name: dto.name, - slug: dto.name - ? await generateEntitySlug(dto.name, 'ENVIRONMENT', this.prisma) - : environment.slug, - description: dto.description, - lastUpdatedById: user.id - } - }) - - const project = environment.project - - await createEvent( - { - triggeredBy: user, - entity: updatedEnvironment, - type: EventType.ENVIRONMENT_UPDATED, - source: EventSource.ENVIRONMENT, - title: `Environment updated`, - metadata: { - environmentId: updatedEnvironment.id, - name: updatedEnvironment.name, - projectId: updatedEnvironment.projectId - }, - workspaceId: project.workspaceId - }, - this.prisma - ) - - this.logger.log( - `Environment ${updatedEnvironment.name} updated in project ${project.name} (${project.id})` - ) - - return updatedEnvironment - } - - /** - * Gets an environment by its slug. - * - * This endpoint requires the `READ_ENVIRONMENT` authority on the environment. - * - * If the user does not have the required authority, a `ForbiddenException` is thrown. - * - * The returned environment object does not include the project property. - * - * @param user The user that is requesting the environment - * @param environmentSlug The slug of the environment to get - * @returns The environment - */ - async getEnvironment(user: User, environmentSlug: Environment['slug']) { - const environment = - await this.authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, - entity: { slug: environmentSlug }, - authorities: [Authority.READ_ENVIRONMENT], - prisma: this.prisma - }) - - delete environment.project - - return environment - } - - /** - * Gets a list of all environments in the given project. - * - * This endpoint requires the `READ_ENVIRONMENT` authority on the project. - * - * If the user does not have the required authority, a `ForbiddenException` is thrown. - * - * The returned list of environments is paginated and sorted according to the provided parameters. - * - * The metadata object contains the following properties: - * - `href`: The URL to the current page - * - `next`: The URL to the next page (if it exists) - * - `prev`: The URL to the previous page (if it exists) - * - `totalPages`: The total number of pages - * - `totalItems`: The total number of items - * - `limit`: The maximum number of items per page - * - `page`: The current page number - * - `sort`: The sort field - * - `order`: The sort order - * - `search`: The search query - * - * @param user The user that is requesting the environments - * @param projectSlug The slug of the project in which to get the environments - * @param page The page number - * @param limit The maximum number of items per page - * @param sort The sort field - * @param order The sort order - * @param search The search query - * @returns An object with a list of environments and metadata - */ - async getEnvironmentsOfProject( - user: User, - projectSlug: Project['slug'], - page: number, - limit: number, - sort: string, - order: string, - search: string - ) { - const project = - await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, - entity: { slug: projectSlug }, - authorities: [Authority.READ_ENVIRONMENT], - prisma: this.prisma - }) - const projectId = project.id - - // Get the environments for the required page - const items = await this.prisma.environment.findMany({ - where: { - projectId, - name: { - contains: search - } - }, - select: { - id: true, - name: true, - slug: true, - description: true, - createdAt: true, - updatedAt: true, - lastUpdatedBy: { - select: { - id: true, - email: true, - profilePictureUrl: true, - name: true - } - } - }, - skip: page * limit, - take: limitMaxItemsPerPage(limit), - orderBy: { - [sort]: order - } - }) - - // Parse the secret and variable counts for each environment - for (const environment of items) { - const secretCount = await this.getSecretCount(environment.id) - const variableCount = await this.getVariableCount(environment.id) - environment['secrets'] = secretCount - environment['variables'] = variableCount - } - - // Calculate metadata for pagination - const totalCount = await this.prisma.environment.count({ - where: { - projectId, - name: { - contains: search - } - } - }) - const metadata = paginate(totalCount, `/environment/all/${projectSlug}`, { - page, - limit: limitMaxItemsPerPage(limit), - sort, - order, - search - }) - - return { items, metadata } - } - - /** - * Deletes an environment in a project. - * - * This endpoint requires the `DELETE_ENVIRONMENT` authority on the environment. - * - * If the user does not have the required authority, a `ForbiddenException` is thrown. - * - * If this is the only existing environment in the project, a `BadRequestException` is thrown. - * - * An event of type `ENVIRONMENT_DELETED` is created, with the following metadata: - * - `environmentId`: The ID of the deleted environment - * - `name`: The name of the deleted environment - * - `projectId`: The ID of the project in which the environment was deleted - * - * @param user The user that is deleting the environment - * @param environmentSlug The slug of the environment to delete - */ - async deleteEnvironment(user: User, environmentSlug: Environment['slug']) { - const environment = - await this.authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, - entity: { slug: environmentSlug }, - authorities: [Authority.DELETE_ENVIRONMENT], - prisma: this.prisma - }) - - // Check if this is the only existing environment - const count = await this.prisma.environment.count({ - where: { - projectId: environment.projectId - } - }) - if (count === 1) { - throw new BadRequestException( - constructErrorBody( - 'Last environment cannot be deleted', - 'Can not delete the last environment in the project. Please create another environment first.' - ) - ) - } - - // Delete the environment - await this.prisma.environment.delete({ - where: { - id: environment.id - } - }) - - await createEvent( - { - triggeredBy: user, - type: EventType.ENVIRONMENT_DELETED, - source: EventSource.ENVIRONMENT, - entity: environment, - title: `Environment deleted`, - metadata: { - environmentId: environment.id, - name: environment.name, - projectId: environment.projectId - }, - workspaceId: environment.project.workspaceId - }, - this.prisma - ) - - this.logger.log( - `Environment ${environment.name} deleted in project ${environment.project.name} (${environment.project.id})` - ) - } - - /** - * Checks if an environment with the given name already exists in the given project. - * @throws ConflictException if an environment with the given name already exists - * @private - */ - private async environmentExists(name: Environment['name'], project: Project) { - const { id: projectId, slug } = project - - if ( - (await this.prisma.environment.findUnique({ - where: { - projectId_name: { - projectId, - name - } - } - })) !== null - ) { - throw new ConflictException( - constructErrorBody( - 'Environment exits', - `Environment with name ${name} already exists in project ${slug}` - ) - ) - } - } - - /** - * Counts the number of unique secrets in an environment. - * @param environmentId The ID of the environment to count secrets for. - * @returns The number of unique secrets in the environment. - * @private - */ - private async getSecretCount( - environmentId: Environment['id'] - ): Promise { - const secrets = await this.prisma.secretVersion.findMany({ - distinct: ['secretId'], - where: { - environmentId - } - }) - - return secrets.length - } - - /** - * Counts the number of unique variables in an environment. - * @param environmentId The ID of the environment to count variables for. - * @returns The number of unique variables in the environment. - * @private - */ - private async getVariableCount( - environmentId: Environment['id'] - ): Promise { - const variables = await this.prisma.variableVersion.findMany({ - distinct: ['variableId'], - where: { - environmentId - } - }) - - return variables.length - } -} +import { + BadRequestException, + ConflictException, + Injectable, + Logger +} from '@nestjs/common' +import { + Authority, + Environment, + EventSource, + EventType, + Project +} from '@prisma/client' +import { CreateEnvironment } from '../dto/create.environment/create.environment' +import { UpdateEnvironment } from '../dto/update.environment/update.environment' +import { PrismaService } from '@/prisma/prisma.service' +import { AuthorizationService } from '@/auth/service/authorization.service' +import { paginate } from '@/common/paginate' +import generateEntitySlug from '@/common/slug-generator' +import { createEvent } from '@/common/event' +import { constructErrorBody, limitMaxItemsPerPage } from '@/common/util' +import { AuthenticatedUser } from '@/user/user.types' + +@Injectable() +export class EnvironmentService { + private readonly logger = new Logger(EnvironmentService.name) + + constructor( + private readonly prisma: PrismaService, + private readonly authorizationService: AuthorizationService + ) {} + + /** + * Creates a new environment in the given project. + * + * This endpoint requires the following authorities: + * - `CREATE_ENVIRONMENT` on the project + * - `READ_ENVIRONMENT` on the project + * - `READ_PROJECT` on the project + * + * If the user does not have the required authorities, a `ForbiddenException` is thrown. + * + * If an environment with the same name already exists in the project, a `ConflictException` is thrown. + * + * The created environment is returned, with the slug generated using the `name` and `ENVIRONMENT` as the entity type. + * + * An event of type `ENVIRONMENT_ADDED` is created, with the following metadata: + * - `environmentId`: The ID of the created environment + * - `name`: The name of the created environment + * - `projectId`: The ID of the project in which the environment was created + * - `projectName`: The name of the project in which the environment was created + * + * @param user The user that is creating the environment + * @param dto The data for the new environment + * @param projectSlug The slug of the project in which to create the environment + * @returns The created environment + */ + async createEnvironment( + user: AuthenticatedUser, + dto: CreateEnvironment, + projectSlug: Project['slug'] + ) { + // Check if the user has the required role to create an environment + const project = + await this.authorizationService.authorizeUserAccessToProject({ + user, + entity: { slug: projectSlug }, + authorities: [ + Authority.CREATE_ENVIRONMENT, + Authority.READ_ENVIRONMENT, + Authority.READ_PROJECT + ] + }) + const projectId = project.id + + // Check if an environment with the same name already exists + await this.environmentExists(dto.name, project) + + // Create the environment + const environment = await this.prisma.environment.create({ + data: { + name: dto.name, + slug: await generateEntitySlug(dto.name, 'ENVIRONMENT', this.prisma), + description: dto.description, + project: { + connect: { + id: projectId + } + }, + lastUpdatedBy: { + connect: { + id: user.id + } + } + }, + include: { + lastUpdatedBy: { + select: { + id: true, + name: true, + profilePictureUrl: true, + email: true + } + } + } + }) + + await createEvent( + { + triggeredBy: user, + entity: environment, + type: EventType.ENVIRONMENT_ADDED, + source: EventSource.ENVIRONMENT, + title: `Environment created`, + metadata: { + environmentId: environment.id, + name: environment.name, + projectId, + projectName: project.name + }, + workspaceId: project.workspaceId + }, + this.prisma + ) + + this.logger.log( + `Environment ${environment.name} created in project ${project.name} (${project.id})` + ) + + return environment + } + + /** + * Updates an environment in the given project. + * + * This endpoint requires the following authorities: + * - `UPDATE_ENVIRONMENT` on the environment + * - `READ_ENVIRONMENT` on the environment + * - `READ_PROJECT` on the project + * + * If the user does not have the required authorities, a `ForbiddenException` is thrown. + * + * If an environment with the same name already exists in the project, a `ConflictException` is thrown. + * + * The updated environment is returned, with the slug generated using the `name` and `ENVIRONMENT` as the entity type. + * + * An event of type `ENVIRONMENT_UPDATED` is created, with the following metadata: + * - `environmentId`: The ID of the updated environment + * - `name`: The name of the updated environment + * - `projectId`: The ID of the project in which the environment was updated + * - `projectName`: The name of the project in which the environment was updated + * + * @param user The user that is updating the environment + * @param dto The data for the updated environment + * @param environmentSlug The slug of the environment to update + * @returns The updated environment + */ + async updateEnvironment( + user: AuthenticatedUser, + dto: UpdateEnvironment, + environmentSlug: Environment['slug'] + ) { + const environment = + await this.authorizationService.authorizeUserAccessToEnvironment({ + user, + entity: { slug: environmentSlug }, + authorities: [ + Authority.UPDATE_ENVIRONMENT, + Authority.READ_ENVIRONMENT, + Authority.READ_PROJECT + ] + }) + + // Check if an environment with the same name already exists + dto.name && (await this.environmentExists(dto.name, environment.project)) + + // Update the environment + const updatedEnvironment = await this.prisma.environment.update({ + where: { + id: environment.id + }, + data: { + name: dto.name, + slug: dto.name + ? await generateEntitySlug(dto.name, 'ENVIRONMENT', this.prisma) + : environment.slug, + description: dto.description, + lastUpdatedById: user.id + } + }) + + const project = environment.project + + await createEvent( + { + triggeredBy: user, + entity: updatedEnvironment, + type: EventType.ENVIRONMENT_UPDATED, + source: EventSource.ENVIRONMENT, + title: `Environment updated`, + metadata: { + environmentId: updatedEnvironment.id, + name: updatedEnvironment.name, + projectId: updatedEnvironment.projectId + }, + workspaceId: project.workspaceId + }, + this.prisma + ) + + this.logger.log( + `Environment ${updatedEnvironment.name} updated in project ${project.name} (${project.id})` + ) + + return updatedEnvironment + } + + /** + * Gets an environment by its slug. + * + * This endpoint requires the `READ_ENVIRONMENT` authority on the environment. + * + * If the user does not have the required authority, a `ForbiddenException` is thrown. + * + * The returned environment object does not include the project property. + * + * @param user The user that is requesting the environment + * @param environmentSlug The slug of the environment to get + * @returns The environment + */ + async getEnvironment( + user: AuthenticatedUser, + environmentSlug: Environment['slug'] + ) { + const environment = + await this.authorizationService.authorizeUserAccessToEnvironment({ + user, + entity: { slug: environmentSlug }, + authorities: [Authority.READ_ENVIRONMENT] + }) + + delete environment.project + + return environment + } + + /** + * Gets a list of all environments in the given project. + * + * This endpoint requires the `READ_ENVIRONMENT` authority on the project. + * + * If the user does not have the required authority, a `ForbiddenException` is thrown. + * + * The returned list of environments is paginated and sorted according to the provided parameters. + * + * The metadata object contains the following properties: + * - `href`: The URL to the current page + * - `next`: The URL to the next page (if it exists) + * - `prev`: The URL to the previous page (if it exists) + * - `totalPages`: The total number of pages + * - `totalItems`: The total number of items + * - `limit`: The maximum number of items per page + * - `page`: The current page number + * - `sort`: The sort field + * - `order`: The sort order + * - `search`: The search query + * + * @param user The user that is requesting the environments + * @param projectSlug The slug of the project in which to get the environments + * @param page The page number + * @param limit The maximum number of items per page + * @param sort The sort field + * @param order The sort order + * @param search The search query + * @returns An object with a list of environments and metadata + */ + async getEnvironmentsOfProject( + user: AuthenticatedUser, + projectSlug: Project['slug'], + page: number, + limit: number, + sort: string, + order: string, + search: string + ) { + const project = + await this.authorizationService.authorizeUserAccessToProject({ + user, + entity: { slug: projectSlug }, + authorities: [Authority.READ_ENVIRONMENT] + }) + const projectId = project.id + + // Get the environments for the required page + const items = await this.prisma.environment.findMany({ + where: { + projectId, + name: { + contains: search + } + }, + select: { + id: true, + name: true, + slug: true, + description: true, + createdAt: true, + updatedAt: true, + lastUpdatedBy: { + select: { + id: true, + email: true, + profilePictureUrl: true, + name: true + } + } + }, + skip: page * limit, + take: limitMaxItemsPerPage(limit), + orderBy: { + [sort]: order + } + }) + + // Parse the secret and variable counts for each environment + for (const environment of items) { + const secretCount = await this.getSecretCount(environment.id) + const variableCount = await this.getVariableCount(environment.id) + environment['secrets'] = secretCount + environment['variables'] = variableCount + } + + // Calculate metadata for pagination + const totalCount = await this.prisma.environment.count({ + where: { + projectId, + name: { + contains: search + } + } + }) + const metadata = paginate(totalCount, `/environment/all/${projectSlug}`, { + page, + limit: limitMaxItemsPerPage(limit), + sort, + order, + search + }) + + return { items, metadata } + } + + /** + * Deletes an environment in a project. + * + * This endpoint requires the `DELETE_ENVIRONMENT` authority on the environment. + * + * If the user does not have the required authority, a `ForbiddenException` is thrown. + * + * If this is the only existing environment in the project, a `BadRequestException` is thrown. + * + * An event of type `ENVIRONMENT_DELETED` is created, with the following metadata: + * - `environmentId`: The ID of the deleted environment + * - `name`: The name of the deleted environment + * - `projectId`: The ID of the project in which the environment was deleted + * + * @param user The user that is deleting the environment + * @param environmentSlug The slug of the environment to delete + */ + async deleteEnvironment( + user: AuthenticatedUser, + environmentSlug: Environment['slug'] + ) { + const environment = + await this.authorizationService.authorizeUserAccessToEnvironment({ + user, + entity: { slug: environmentSlug }, + authorities: [Authority.DELETE_ENVIRONMENT] + }) + + // Check if this is the only existing environment + const count = await this.prisma.environment.count({ + where: { + projectId: environment.projectId + } + }) + if (count === 1) { + throw new BadRequestException( + constructErrorBody( + 'Last environment cannot be deleted', + 'Can not delete the last environment in the project. Please create another environment first.' + ) + ) + } + + // Delete the environment + await this.prisma.environment.delete({ + where: { + id: environment.id + } + }) + + await createEvent( + { + triggeredBy: user, + type: EventType.ENVIRONMENT_DELETED, + source: EventSource.ENVIRONMENT, + entity: environment, + title: `Environment deleted`, + metadata: { + environmentId: environment.id, + name: environment.name, + projectId: environment.projectId + }, + workspaceId: environment.project.workspaceId + }, + this.prisma + ) + + this.logger.log( + `Environment ${environment.name} deleted in project ${environment.project.name} (${environment.project.id})` + ) + } + + /** + * Checks if an environment with the given name already exists in the given project. + * @throws ConflictException if an environment with the given name already exists + * @private + */ + private async environmentExists(name: Environment['name'], project: Project) { + const { id: projectId, slug } = project + + if ( + (await this.prisma.environment.findUnique({ + where: { + projectId_name: { + projectId, + name + } + } + })) !== null + ) { + throw new ConflictException( + constructErrorBody( + 'Environment exits', + `Environment with name ${name} already exists in project ${slug}` + ) + ) + } + } + + /** + * Counts the number of unique secrets in an environment. + * @param environmentId The ID of the environment to count secrets for. + * @returns The number of unique secrets in the environment. + * @private + */ + private async getSecretCount( + environmentId: Environment['id'] + ): Promise { + const secrets = await this.prisma.secretVersion.findMany({ + distinct: ['secretId'], + where: { + environmentId + } + }) + + return secrets.length + } + + /** + * Counts the number of unique variables in an environment. + * @param environmentId The ID of the environment to count variables for. + * @returns The number of unique variables in the environment. + * @private + */ + private async getVariableCount( + environmentId: Environment['id'] + ): Promise { + const variables = await this.prisma.variableVersion.findMany({ + distinct: ['variableId'], + where: { + environmentId + } + }) + + return variables.length + } +} diff --git a/apps/api/src/event/controller/event.controller.spec.ts b/apps/api/src/event/controller/event.controller.spec.ts index 5972a8e66..5a44c0fce 100644 --- a/apps/api/src/event/controller/event.controller.spec.ts +++ b/apps/api/src/event/controller/event.controller.spec.ts @@ -2,8 +2,9 @@ import { Test, TestingModule } from '@nestjs/testing' import { EventController } from './event.controller' import { EventService } from '../service/event.service' import { PrismaService } from '@/prisma/prisma.service' -import { AuthorityCheckerService } from '@/common/authority-checker.service' import { CommonModule } from '@/common/common.module' +import { AuthorizationService } from '@/auth/service/authorization.service' +import { AuthorityCheckerService } from '@/auth/service/authority-checker.service' describe('EventController', () => { let controller: EventController @@ -12,7 +13,12 @@ describe('EventController', () => { const module: TestingModule = await Test.createTestingModule({ imports: [CommonModule], controllers: [EventController], - providers: [EventService, PrismaService, AuthorityCheckerService] + providers: [ + EventService, + PrismaService, + AuthorizationService, + AuthorityCheckerService + ] }).compile() controller = module.get(EventController) diff --git a/apps/api/src/event/controller/event.controller.ts b/apps/api/src/event/controller/event.controller.ts index f8607933f..f1822f410 100644 --- a/apps/api/src/event/controller/event.controller.ts +++ b/apps/api/src/event/controller/event.controller.ts @@ -1,8 +1,9 @@ import { Controller, Get, Param, Query } from '@nestjs/common' import { EventService } from '../service/event.service' -import { Authority, EventSeverity, EventSource, User } from '@prisma/client' +import { Authority, EventSeverity, EventSource } from '@prisma/client' import { CurrentUser } from '@/decorators/user.decorator' import { RequiredApiKeyAuthorities } from '@/decorators/required-api-key-authorities.decorator' +import { AuthenticatedUser } from '@/user/user.types' @Controller('event') export class EventController { @@ -11,7 +12,7 @@ export class EventController { @Get(':workspaceSlug') @RequiredApiKeyAuthorities(Authority.READ_EVENT) async getEvents( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceSlug') workspaceSlug: string, @Query('page') page: number = 0, @Query('limit') limit: number = 10, diff --git a/apps/api/src/event/event.e2e.spec.ts b/apps/api/src/event/event.e2e.spec.ts index d4d6b1444..da1bc505b 100644 --- a/apps/api/src/event/event.e2e.spec.ts +++ b/apps/api/src/event/event.e2e.spec.ts @@ -9,7 +9,6 @@ import { EventType, Project, ProjectAccessLevel, - User, Variable, Workspace } from '@prisma/client' @@ -33,6 +32,7 @@ import { VariableService } from '@/variable/service/variable.service' import { VariableModule } from '@/variable/variable.module' import { QueryTransformPipe } from '@/common/pipes/query.transform.pipe' import { createEvent } from '@/common/event' +import { AuthenticatedUser } from '@/user/user.types' describe('Event Controller Tests', () => { let app: NestFastifyApplication @@ -45,7 +45,9 @@ describe('Event Controller Tests', () => { let secretService: SecretService let variableService: VariableService - let user: User + let user: AuthenticatedUser + + const USER_IP_ADDRESS = '127.0.0.1' beforeAll(async () => { const moduleRef = await Test.createTestingModule({ @@ -82,13 +84,15 @@ describe('Event Controller Tests', () => { }) beforeEach(async () => { - user = await prisma.user.create({ + const createUser = await prisma.user.create({ data: { email: 'johndoe@keyshade.xyz', name: 'John Doe', isOnboardingFinished: true } }) + + user = { ...createUser, ipAddress: USER_IP_ADDRESS } }) it('should be defined', async () => { diff --git a/apps/api/src/event/service/event.service.spec.ts b/apps/api/src/event/service/event.service.spec.ts index c0ab0d6ab..b0074187c 100644 --- a/apps/api/src/event/service/event.service.spec.ts +++ b/apps/api/src/event/service/event.service.spec.ts @@ -1,8 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing' import { EventService } from './event.service' import { PrismaService } from '@/prisma/prisma.service' -import { AuthorityCheckerService } from '@/common/authority-checker.service' +import { AuthorizationService } from '@/auth/service/authorization.service' import { CommonModule } from '@/common/common.module' +import { AuthorityCheckerService } from '@/auth/service/authority-checker.service' describe('EventService', () => { let service: EventService @@ -10,7 +11,12 @@ describe('EventService', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [CommonModule], - providers: [EventService, PrismaService, AuthorityCheckerService] + providers: [ + EventService, + PrismaService, + AuthorizationService, + AuthorityCheckerService + ] }).compile() service = module.get(EventService) diff --git a/apps/api/src/event/service/event.service.ts b/apps/api/src/event/service/event.service.ts index f309c1a54..b3202a541 100644 --- a/apps/api/src/event/service/event.service.ts +++ b/apps/api/src/event/service/event.service.ts @@ -1,92 +1,91 @@ -import { BadRequestException, Injectable } from '@nestjs/common' -import { - Authority, - EventSeverity, - EventSource, - User, - Workspace -} from '@prisma/client' -import { PrismaService } from '@/prisma/prisma.service' -import { AuthorityCheckerService } from '@/common/authority-checker.service' -import { paginate } from '@/common/paginate' -import { constructErrorBody, limitMaxItemsPerPage } from '@/common/util' - -@Injectable() -export class EventService { - constructor( - private readonly prisma: PrismaService, - private readonly authorityCheckerService: AuthorityCheckerService - ) {} - - async getEvents( - user: User, - workspaceSlug: Workspace['slug'], - page: number, - limit: number, - search: string, - severity?: EventSeverity, - source?: EventSource - ) { - if (severity && !Object.values(EventSeverity).includes(severity)) { - throw new BadRequestException( - constructErrorBody('Invalid value', 'Invalid "severity" value') - ) - } - - if (source && !Object.values(EventSource).includes(source)) { - throw new BadRequestException( - constructErrorBody('Invalid value', 'Invalid "source" value') - ) - } - - // Check for workspace authority - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.READ_EVENT], - prisma: this.prisma - }) - const workspaceId = workspace.id - - const query = { - where: { - workspaceId, - title: { - contains: search - } - }, - skip: page * limit, - take: limitMaxItemsPerPage(limit), - - orderBy: { - timestamp: 'desc' - } - } - - if (source) { - query.where['source'] = source - } - - // @ts-expect-error - Prisma does not have a type for severity - const items = await this.prisma.event.findMany(query) - - //calculate metadata for pagination - const totalCount = await this.prisma.event.count({ - where: query.where - }) - - const metadata = paginate( - totalCount, - `/event/${workspaceSlug}`, - { - page, - limit: limitMaxItemsPerPage(limit), - search - }, - { source } - ) - - return { items, metadata } - } -} +import { BadRequestException, Injectable } from '@nestjs/common' +import { + Authority, + EventSeverity, + EventSource, + Workspace +} from '@prisma/client' +import { PrismaService } from '@/prisma/prisma.service' +import { AuthorizationService } from '@/auth/service/authorization.service' +import { paginate } from '@/common/paginate' +import { constructErrorBody, limitMaxItemsPerPage } from '@/common/util' +import { AuthenticatedUser } from '@/user/user.types' + +@Injectable() +export class EventService { + constructor( + private readonly prisma: PrismaService, + private readonly authorizationService: AuthorizationService + ) {} + + async getEvents( + user: AuthenticatedUser, + workspaceSlug: Workspace['slug'], + page: number, + limit: number, + search: string, + severity?: EventSeverity, + source?: EventSource + ) { + if (severity && !Object.values(EventSeverity).includes(severity)) { + throw new BadRequestException( + constructErrorBody('Invalid value', 'Invalid "severity" value') + ) + } + + if (source && !Object.values(EventSource).includes(source)) { + throw new BadRequestException( + constructErrorBody('Invalid value', 'Invalid "source" value') + ) + } + + // Check for workspace authority + const workspace = + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, + entity: { slug: workspaceSlug }, + authorities: [Authority.READ_EVENT] + }) + const workspaceId = workspace.id + + const query = { + where: { + workspaceId, + title: { + contains: search + } + }, + skip: page * limit, + take: limitMaxItemsPerPage(limit), + + orderBy: { + timestamp: 'desc' + } + } + + if (source) { + query.where['source'] = source + } + + // @ts-expect-error - Prisma does not have a type for severity + const items = await this.prisma.event.findMany(query) + + //calculate metadata for pagination + const totalCount = await this.prisma.event.count({ + where: query.where + }) + + const metadata = paginate( + totalCount, + `/event/${workspaceSlug}`, + { + page, + limit: limitMaxItemsPerPage(limit), + search + }, + { source } + ) + + return { items, metadata } + } +} diff --git a/apps/api/src/integration/controller/integration.controller.spec.ts b/apps/api/src/integration/controller/integration.controller.spec.ts index f564a7be6..7d2e16ecc 100644 --- a/apps/api/src/integration/controller/integration.controller.spec.ts +++ b/apps/api/src/integration/controller/integration.controller.spec.ts @@ -2,9 +2,10 @@ import { Test, TestingModule } from '@nestjs/testing' import { IntegrationController } from './integration.controller' import { PrismaService } from '@/prisma/prisma.service' import { mockDeep } from 'jest-mock-extended' -import { AuthorityCheckerService } from '@/common/authority-checker.service' +import { AuthorizationService } from '@/auth/service/authorization.service' import { IntegrationService } from '../service/integration.service' import { CommonModule } from '@/common/common.module' +import { AuthorityCheckerService } from '@/auth/service/authority-checker.service' describe('IntegrationController', () => { let controller: IntegrationController @@ -13,7 +14,12 @@ describe('IntegrationController', () => { const module: TestingModule = await Test.createTestingModule({ imports: [CommonModule], controllers: [IntegrationController], - providers: [PrismaService, AuthorityCheckerService, IntegrationService] + providers: [ + PrismaService, + AuthorizationService, + AuthorityCheckerService, + IntegrationService + ] }) .overrideProvider(PrismaService) .useValue(mockDeep()) diff --git a/apps/api/src/integration/controller/integration.controller.ts b/apps/api/src/integration/controller/integration.controller.ts index 64da4d2e8..ecea3a788 100644 --- a/apps/api/src/integration/controller/integration.controller.ts +++ b/apps/api/src/integration/controller/integration.controller.ts @@ -11,9 +11,10 @@ import { import { IntegrationService } from '../service/integration.service' import { CurrentUser } from '@/decorators/user.decorator' import { CreateIntegration } from '../dto/create.integration/create.integration' -import { Authority, User } from '@prisma/client' +import { Authority } from '@prisma/client' import { RequiredApiKeyAuthorities } from '@/decorators/required-api-key-authorities.decorator' import { UpdateIntegration } from '../dto/update.integration/update.integration' +import { AuthenticatedUser } from '@/user/user.types' @Controller('integration') export class IntegrationController { @@ -27,7 +28,7 @@ export class IntegrationController { Authority.READ_ENVIRONMENT ) async createIntegration( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Body() dto: CreateIntegration, @Param('workspaceSlug') workspaceSlug: string ) { @@ -45,7 +46,7 @@ export class IntegrationController { Authority.READ_ENVIRONMENT ) async updateIntegration( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Body() dto: UpdateIntegration, @Param('integrationSlug') integrationSlug: string ) { @@ -59,7 +60,7 @@ export class IntegrationController { @Get(':integrationSlug') @RequiredApiKeyAuthorities(Authority.READ_INTEGRATION) async getIntegration( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('integrationSlug') integrationSlug: string ) { return await this.integrationService.getIntegration(user, integrationSlug) @@ -69,7 +70,7 @@ export class IntegrationController { @Get('all/:workspaceSlug') @RequiredApiKeyAuthorities(Authority.READ_INTEGRATION) async getAllIntegrations( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceSlug') workspaceSlug: string, @Query('page') page: number = 0, @Query('limit') limit: number = 10, @@ -91,7 +92,7 @@ export class IntegrationController { @Delete(':integrationSlug') @RequiredApiKeyAuthorities(Authority.DELETE_INTEGRATION) async deleteIntegration( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('integrationSlug') integrationSlug: string ) { return await this.integrationService.deleteIntegration( diff --git a/apps/api/src/integration/integration.e2e.spec.ts b/apps/api/src/integration/integration.e2e.spec.ts index dc048b1ce..484e02c52 100644 --- a/apps/api/src/integration/integration.e2e.spec.ts +++ b/apps/api/src/integration/integration.e2e.spec.ts @@ -17,7 +17,6 @@ import { Integration, IntegrationType, Project, - User, Workspace } from '@prisma/client' import { ProjectService } from '@/project/service/project.service' @@ -27,6 +26,7 @@ import { MockMailService } from '@/mail/services/mock.service' import { EnvironmentModule } from '@/environment/environment.module' import { EnvironmentService } from '@/environment/service/environment.service' import { QueryTransformPipe } from '@/common/pipes/query.transform.pipe' +import { AuthenticatedUser, UserWithWorkspace } from '@/user/user.types' describe('Integration Controller Tests', () => { let app: NestFastifyApplication @@ -37,12 +37,14 @@ describe('Integration Controller Tests', () => { let projectService: ProjectService let environmentService: EnvironmentService - let user1: User, user2: User + let user1: AuthenticatedUser, user2: AuthenticatedUser let workspace1: Workspace, workspace2: Workspace let integration1: Integration let project1: Project, project2: Project let environment1: Environment, environment2: Environment + const USER_IP_ADDRESS = '127.0.0.1' + beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [ @@ -74,30 +76,30 @@ describe('Integration Controller Tests', () => { }) beforeEach(async () => { - const createUser1Response = (await userService.createUser({ + const createUser1 = (await userService.createUser({ email: 'john@keyshade.xyz', name: 'John', isActive: true, isAdmin: false, isOnboardingFinished: true - })) as User & { defaultWorkspace: Workspace } + })) as UserWithWorkspace - const createUser2Response = (await userService.createUser({ + const createUser2 = (await userService.createUser({ email: 'jane@keyshade.xyz', name: 'Jane', isActive: true, isAdmin: false, isOnboardingFinished: true - })) as User & { defaultWorkspace: Workspace } + })) as UserWithWorkspace - workspace1 = createUser1Response.defaultWorkspace - workspace2 = createUser2Response.defaultWorkspace + workspace1 = createUser1.defaultWorkspace + workspace2 = createUser2.defaultWorkspace - delete createUser1Response.defaultWorkspace - delete createUser2Response.defaultWorkspace + delete createUser1.defaultWorkspace + delete createUser2.defaultWorkspace - user1 = createUser1Response - user2 = createUser2Response + user1 = { ...createUser1, ipAddress: USER_IP_ADDRESS } + user2 = { ...createUser2, ipAddress: USER_IP_ADDRESS } integration1 = await integrationService.createIntegration( user1, diff --git a/apps/api/src/integration/service/integration.service.spec.ts b/apps/api/src/integration/service/integration.service.spec.ts index 5e2929fe9..1e0ce920e 100644 --- a/apps/api/src/integration/service/integration.service.spec.ts +++ b/apps/api/src/integration/service/integration.service.spec.ts @@ -2,8 +2,9 @@ import { Test, TestingModule } from '@nestjs/testing' import { IntegrationService } from './integration.service' import { CommonModule } from '@/common/common.module' import { PrismaService } from '@/prisma/prisma.service' -import { AuthorityCheckerService } from '@/common/authority-checker.service' +import { AuthorizationService } from '@/auth/service/authorization.service' import { mockDeep } from 'jest-mock-extended' +import { AuthorityCheckerService } from '@/auth/service/authority-checker.service' describe('IntegrationService', () => { let service: IntegrationService @@ -11,7 +12,12 @@ describe('IntegrationService', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [CommonModule], - providers: [PrismaService, AuthorityCheckerService, IntegrationService] + providers: [ + PrismaService, + AuthorizationService, + AuthorityCheckerService, + IntegrationService + ] }) .overrideProvider(PrismaService) .useValue(mockDeep()) diff --git a/apps/api/src/integration/service/integration.service.ts b/apps/api/src/integration/service/integration.service.ts index 95cb69567..f667d8afb 100644 --- a/apps/api/src/integration/service/integration.service.ts +++ b/apps/api/src/integration/service/integration.service.ts @@ -1,498 +1,495 @@ -import { - BadRequestException, - ConflictException, - Injectable, - Logger -} from '@nestjs/common' -import { PrismaService } from '@/prisma/prisma.service' -import { - Authority, - Environment, - EventSource, - EventType, - Integration, - Project, - User, - Workspace -} from '@prisma/client' -import { CreateIntegration } from '../dto/create.integration/create.integration' -import { UpdateIntegration } from '../dto/update.integration/update.integration' -import { AuthorityCheckerService } from '@/common/authority-checker.service' -import IntegrationFactory from '../plugins/factory/integration.factory' -import { paginate } from '@/common/paginate' -import generateEntitySlug from '@/common/slug-generator' -import { createEvent } from '@/common/event' -import { constructErrorBody, limitMaxItemsPerPage } from '@/common/util' - -@Injectable() -export class IntegrationService { - private readonly logger = new Logger(IntegrationService.name) - - constructor( - private readonly prisma: PrismaService, - private readonly authorityCheckerService: AuthorityCheckerService - ) {} - - /** - * Creates a new integration in the given workspace. The user needs to have - * `CREATE_INTEGRATION` and `READ_WORKSPACE` authority in the workspace. - * - * If the integration is of type `PROJECT`, the user needs to have `READ_PROJECT` - * authority in the project specified by `projectSlug`. - * - * If the integration is of type `ENVIRONMENT`, the user needs to have `READ_ENVIRONMENT` - * authority in the environment specified by `environmentSlug`. - * - * If the integration is of type `PROJECT` and `environmentSlug` is provided, - * the user needs to have `READ_ENVIRONMENT` authority in the environment specified - * by `environmentSlug`. - * - * The integration is created with the given name, slug, type, metadata and - * notifyOn events. The slug is generated using the `name` and a unique - * identifier. - * - * @param user The user creating the integration - * @param dto The integration data - * @param workspaceSlug The slug of the workspace the integration is being - * created in - * @returns The created integration - */ - async createIntegration( - user: User, - dto: CreateIntegration, - workspaceSlug: Workspace['slug'] - ) { - // Check if the user is permitted to create integrations in the workspace - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.CREATE_INTEGRATION, Authority.READ_WORKSPACE], - prisma: this.prisma - }) - const workspaceId = workspace.id - - // Check if integration with the same name already exists - await this.existsByNameAndWorkspaceId(dto.name, workspace) - - let project: Project | null = null - let environment: Environment | null = null - - // Check if the user has READ authority over the project - if (dto.projectSlug) { - project = await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, - entity: { slug: dto.projectSlug }, - authorities: [Authority.READ_PROJECT], - prisma: this.prisma - }) - } - - // Check if only environmentId is provided - if (dto.environmentSlug && !dto.projectSlug) { - throw new BadRequestException( - constructErrorBody( - 'Can not provide environment without project', - 'Environment can only be provided if project is also provided' - ) - ) - } - - // Check if the user has READ authority over the environment - if (dto.environmentSlug) { - environment = - await this.authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, - entity: { slug: dto.environmentSlug }, - authorities: [Authority.READ_ENVIRONMENT], - prisma: this.prisma - }) - } - - // Create the integration object - const integrationObject = IntegrationFactory.createIntegration(dto.type) - - // Check for permitted events - integrationObject.validatePermittedEvents(dto.notifyOn) - - // Check for authentication parameters - integrationObject.validateMetadataParameters(dto.metadata) - - // Create the integration - const integration = await this.prisma.integration.create({ - data: { - name: dto.name, - slug: await generateEntitySlug(dto.name, 'INTEGRATION', this.prisma), - type: dto.type, - metadata: dto.metadata, - notifyOn: dto.notifyOn, - environmentId: environment?.id, - projectId: project?.id, - workspaceId - } - }) - - this.logger.log( - `Integration ${integration.id} created by user ${user.id} in workspace ${workspaceId}` - ) - - await createEvent( - { - triggeredBy: user, - entity: integration, - type: EventType.INTEGRATION_ADDED, - source: EventSource.INTEGRATION, - title: `Integration ${integration.name} created`, - metadata: { - integrationId: integration.id - }, - workspaceId: workspaceId - }, - this.prisma - ) - - return integration - } - - /** - * Updates an integration. The user needs to have `UPDATE_INTEGRATION` authority - * over the integration. - * - * If the integration is of type `PROJECT`, the user needs to have `READ_PROJECT` - * authority in the project specified by `projectSlug`. - * - * If the integration is of type `ENVIRONMENT`, the user needs to have `READ_ENVIRONMENT` - * authority in the environment specified by `environmentSlug`. - * - * If the integration is of type `PROJECT` and `environmentSlug` is provided, - * the user needs to have `READ_ENVIRONMENT` authority in the environment specified - * by `environmentSlug`. - * - * The integration is updated with the given name, slug, metadata and - * notifyOn events. - * - * @param user The user updating the integration - * @param dto The integration data - * @param integrationSlug The slug of the integration to update - * @returns The updated integration - */ - async updateIntegration( - user: User, - dto: UpdateIntegration, - integrationSlug: Integration['slug'] - ) { - const integration = - await this.authorityCheckerService.checkAuthorityOverIntegration({ - userId: user.id, - entity: { slug: integrationSlug }, - authorities: [Authority.UPDATE_INTEGRATION], - prisma: this.prisma - }) - const integrationId = integration.id - - // Check if the name of the integration is being changed, and if so, check if the new name is unique - if (dto.name) { - await this.existsByNameAndWorkspaceId(dto.name, integration.workspace) - } - - let project: Project | null = null - let environment: Environment | null = null - - // If the project is being changed, check if the user has READ authority over the new project - if (dto.projectSlug) { - project = await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, - entity: { slug: dto.projectSlug }, - authorities: [Authority.READ_PROJECT], - prisma: this.prisma - }) - } - - // Check if only environmentId is provided, or if the integration has no project associated from prior - if (dto.environmentSlug && !integration.projectId && !dto.projectSlug) { - throw new BadRequestException( - constructErrorBody( - 'Can not provide environment without project', - 'Environment can only be provided if project is also provided' - ) - ) - } - - // If the environment is being changed, check if the user has READ authority over the new environment - if (dto.environmentSlug) { - environment = - await this.authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, - entity: { slug: dto.environmentSlug }, - authorities: [Authority.READ_ENVIRONMENT], - prisma: this.prisma - }) - } - - // Create the integration object - const integrationObject = IntegrationFactory.createIntegration( - integration.type - ) - - // Check for permitted events - dto.notifyOn && integrationObject.validatePermittedEvents(dto.notifyOn) - - // Check for authentication parameters - dto.metadata && integrationObject.validateMetadataParameters(dto.metadata) - - // Update the integration - const updatedIntegration = await this.prisma.integration.update({ - where: { id: integrationId }, - data: { - name: dto.name, - slug: dto.name - ? await generateEntitySlug(dto.name, 'INTEGRATION', this.prisma) - : integration.slug, - metadata: dto.metadata, - notifyOn: dto.notifyOn, - environmentId: environment?.id, - projectId: project?.id - } - }) - - this.logger.log( - `Integration ${integrationId} updated by user ${user.id} in workspace ${integration.workspaceId}` - ) - - await createEvent( - { - triggeredBy: user, - entity: updatedIntegration, - type: EventType.INTEGRATION_UPDATED, - source: EventSource.INTEGRATION, - title: `Integration ${updatedIntegration.name} updated`, - metadata: { - integrationId: updatedIntegration.id - }, - workspaceId: integration.workspaceId - }, - this.prisma - ) - - return updatedIntegration - } - - /** - * Retrieves an integration by its slug. The user needs to have `READ_INTEGRATION` - * authority over the integration. - * - * @param user The user retrieving the integration - * @param integrationSlug The slug of the integration to retrieve - * @returns The integration with the given slug - */ - async getIntegration(user: User, integrationSlug: Integration['slug']) { - return this.authorityCheckerService.checkAuthorityOverIntegration({ - userId: user.id, - entity: { slug: integrationSlug }, - authorities: [Authority.READ_INTEGRATION], - prisma: this.prisma - }) - } - - /* istanbul ignore next */ - // The e2e tests are not working, but the API calls work as expected - /** - * Retrieves all integrations in a workspace that the user has READ authority over. - * - * The user needs to have `READ_INTEGRATION` authority over the workspace. - * - * The results are paginated and can be sorted by name ascending or descending. - * - * @param user The user retrieving the integrations - * @param workspaceSlug The slug of the workspace to retrieve integrations from - * @param page The page number of the results - * @param limit The number of items per page - * @param sort The property to sort the results by (default: name) - * @param order The order to sort the results by (default: ascending) - * @param search The string to search for in the integration names - * @returns A paginated list of integrations in the workspace - */ - async getAllIntegrationsOfWorkspace( - user: User, - workspaceSlug: Workspace['slug'], - page: number, - limit: number, - sort: string, - order: string, - search: string - ) { - // Check if the user has READ authority over the workspace - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.READ_INTEGRATION], - prisma: this.prisma - }) - const workspaceId = workspace.id - - // We need to return only those integrations that have the following properties: - // - belong to the workspace - // - does not belong to any project - // - does not belong to any project where the user does not have READ authority - - // Get the projects the user has READ authority over - const membership = await this.prisma.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - userId: user.id, - workspaceId - } - } - }) - const workspaceRoles = await this.prisma.workspaceRole.findMany({ - where: { - workspaceId, - workspaceMembers: { - some: { - workspaceMemberId: membership.id - } - } - }, - include: { - projects: { - include: { - project: true - } - } - } - }) - const projectIds = - workspaceRoles - .map((role) => role.projects.map((p) => p.projectId)) - .flat() || [] - - // Get all integrations in the workspace - const integrations = await this.prisma.integration.findMany({ - where: { - name: { - contains: search - }, - workspaceId, - OR: [ - { - projectId: null - }, - { - projectId: { - in: projectIds - } - } - ] - }, - skip: page * limit, - take: limitMaxItemsPerPage(limit), - - orderBy: { - [sort]: order - } - }) - - // Calculate metadata for pagination - const totalCount = await this.prisma.integration.count({ - where: { - name: { - contains: search - }, - workspaceId, - OR: [ - { - projectId: null - }, - { - projectId: { - in: projectIds - } - } - ] - } - }) - const metadata = paginate(totalCount, `/integration/all/${workspaceSlug}`, { - page, - limit: limitMaxItemsPerPage(limit), - sort, - order, - search - }) - - return { items: integrations, metadata } - } - - /** - * Deletes an integration by its slug. The user needs to have `DELETE_INTEGRATION` - * authority over the integration. - * - * @param user The user deleting the integration - * @param integrationSlug The slug of the integration to delete - * @returns Nothing - */ - async deleteIntegration(user: User, integrationSlug: Integration['slug']) { - const integration = - await this.authorityCheckerService.checkAuthorityOverIntegration({ - userId: user.id, - entity: { slug: integrationSlug }, - authorities: [Authority.DELETE_INTEGRATION], - prisma: this.prisma - }) - const integrationId = integration.id - - await this.prisma.integration.delete({ - where: { id: integrationId } - }) - - this.logger.log( - `Integration ${integrationId} deleted by user ${user.id} in workspace ${integration.workspaceId}` - ) - - await createEvent( - { - triggeredBy: user, - entity: integration, - type: EventType.INTEGRATION_DELETED, - source: EventSource.INTEGRATION, - title: `Integration ${integration.name} deleted`, - metadata: { - integrationId: integration.id - }, - workspaceId: integration.workspaceId - }, - this.prisma - ) - } - - /** - * Checks if an integration with the same name already exists in the workspace. - * Throws a ConflictException if the integration already exists. - * - * @param name The name of the integration to check - * @param workspace The workspace to check in - */ - private async existsByNameAndWorkspaceId( - name: Integration['name'], - workspace: Workspace - ) { - const workspaceId = workspace.id - - if ( - (await this.prisma.integration.findUnique({ - where: { - workspaceId_name: { - workspaceId, - name - } - } - })) !== null - ) - throw new ConflictException( - constructErrorBody( - 'Integration already exists', - `Integration with name ${name} already exists in workspace ${workspace.slug}` - ) - ) - } -} +import { + BadRequestException, + ConflictException, + Injectable, + Logger +} from '@nestjs/common' +import { PrismaService } from '@/prisma/prisma.service' +import { + Authority, + Environment, + EventSource, + EventType, + Integration, + Project, + Workspace +} from '@prisma/client' +import { CreateIntegration } from '../dto/create.integration/create.integration' +import { UpdateIntegration } from '../dto/update.integration/update.integration' +import { AuthorizationService } from '@/auth/service/authorization.service' +import IntegrationFactory from '../plugins/factory/integration.factory' +import { paginate } from '@/common/paginate' +import generateEntitySlug from '@/common/slug-generator' +import { createEvent } from '@/common/event' +import { constructErrorBody, limitMaxItemsPerPage } from '@/common/util' +import { AuthenticatedUser } from '@/user/user.types' + +@Injectable() +export class IntegrationService { + private readonly logger = new Logger(IntegrationService.name) + + constructor( + private readonly prisma: PrismaService, + private readonly authorizationService: AuthorizationService + ) {} + + /** + * Creates a new integration in the given workspace. The user needs to have + * `CREATE_INTEGRATION` and `READ_WORKSPACE` authority in the workspace. + * + * If the integration is of type `PROJECT`, the user needs to have `READ_PROJECT` + * authority in the project specified by `projectSlug`. + * + * If the integration is of type `ENVIRONMENT`, the user needs to have `READ_ENVIRONMENT` + * authority in the environment specified by `environmentSlug`. + * + * If the integration is of type `PROJECT` and `environmentSlug` is provided, + * the user needs to have `READ_ENVIRONMENT` authority in the environment specified + * by `environmentSlug`. + * + * The integration is created with the given name, slug, type, metadata and + * notifyOn events. The slug is generated using the `name` and a unique + * identifier. + * + * @param user The user creating the integration + * @param dto The integration data + * @param workspaceSlug The slug of the workspace the integration is being + * created in + * @returns The created integration + */ + async createIntegration( + user: AuthenticatedUser, + dto: CreateIntegration, + workspaceSlug: Workspace['slug'] + ) { + // Check if the user is permitted to create integrations in the workspace + const workspace = + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, + entity: { slug: workspaceSlug }, + authorities: [Authority.CREATE_INTEGRATION, Authority.READ_WORKSPACE] + }) + const workspaceId = workspace.id + + // Check if integration with the same name already exists + await this.existsByNameAndWorkspaceId(dto.name, workspace) + + let project: Project | null = null + let environment: Environment | null = null + + // Check if the user has READ authority over the project + if (dto.projectSlug) { + project = await this.authorizationService.authorizeUserAccessToProject({ + user, + entity: { slug: dto.projectSlug }, + authorities: [Authority.READ_PROJECT] + }) + } + + // Check if only environmentId is provided + if (dto.environmentSlug && !dto.projectSlug) { + throw new BadRequestException( + constructErrorBody( + 'Can not provide environment without project', + 'Environment can only be provided if project is also provided' + ) + ) + } + + // Check if the user has READ authority over the environment + if (dto.environmentSlug) { + environment = + await this.authorizationService.authorizeUserAccessToEnvironment({ + user, + entity: { slug: dto.environmentSlug }, + authorities: [Authority.READ_ENVIRONMENT] + }) + } + + // Create the integration object + const integrationObject = IntegrationFactory.createIntegration(dto.type) + + // Check for permitted events + integrationObject.validatePermittedEvents(dto.notifyOn) + + // Check for authentication parameters + integrationObject.validateMetadataParameters(dto.metadata) + + // Create the integration + const integration = await this.prisma.integration.create({ + data: { + name: dto.name, + slug: await generateEntitySlug(dto.name, 'INTEGRATION', this.prisma), + type: dto.type, + metadata: dto.metadata, + notifyOn: dto.notifyOn, + environmentId: environment?.id, + projectId: project?.id, + workspaceId + } + }) + + this.logger.log( + `Integration ${integration.id} created by user ${user.id} in workspace ${workspaceId}` + ) + + await createEvent( + { + triggeredBy: user, + entity: integration, + type: EventType.INTEGRATION_ADDED, + source: EventSource.INTEGRATION, + title: `Integration ${integration.name} created`, + metadata: { + integrationId: integration.id + }, + workspaceId: workspaceId + }, + this.prisma + ) + + return integration + } + + /** + * Updates an integration. The user needs to have `UPDATE_INTEGRATION` authority + * over the integration. + * + * If the integration is of type `PROJECT`, the user needs to have `READ_PROJECT` + * authority in the project specified by `projectSlug`. + * + * If the integration is of type `ENVIRONMENT`, the user needs to have `READ_ENVIRONMENT` + * authority in the environment specified by `environmentSlug`. + * + * If the integration is of type `PROJECT` and `environmentSlug` is provided, + * the user needs to have `READ_ENVIRONMENT` authority in the environment specified + * by `environmentSlug`. + * + * The integration is updated with the given name, slug, metadata and + * notifyOn events. + * + * @param user The user updating the integration + * @param dto The integration data + * @param integrationSlug The slug of the integration to update + * @returns The updated integration + */ + async updateIntegration( + user: AuthenticatedUser, + dto: UpdateIntegration, + integrationSlug: Integration['slug'] + ) { + const integration = + await this.authorizationService.authorizeUserAccessToIntegration({ + user, + entity: { slug: integrationSlug }, + authorities: [Authority.UPDATE_INTEGRATION] + }) + const integrationId = integration.id + + // Check if the name of the integration is being changed, and if so, check if the new name is unique + if (dto.name) { + await this.existsByNameAndWorkspaceId(dto.name, integration.workspace) + } + + let project: Project | null = null + let environment: Environment | null = null + + // If the project is being changed, check if the user has READ authority over the new project + if (dto.projectSlug) { + project = await this.authorizationService.authorizeUserAccessToProject({ + user, + entity: { slug: dto.projectSlug }, + authorities: [Authority.READ_PROJECT] + }) + } + + // Check if only environmentId is provided, or if the integration has no project associated from prior + if (dto.environmentSlug && !integration.projectId && !dto.projectSlug) { + throw new BadRequestException( + constructErrorBody( + 'Can not provide environment without project', + 'Environment can only be provided if project is also provided' + ) + ) + } + + // If the environment is being changed, check if the user has READ authority over the new environment + if (dto.environmentSlug) { + environment = + await this.authorizationService.authorizeUserAccessToEnvironment({ + user, + entity: { slug: dto.environmentSlug }, + authorities: [Authority.READ_ENVIRONMENT] + }) + } + + // Create the integration object + const integrationObject = IntegrationFactory.createIntegration( + integration.type + ) + + // Check for permitted events + dto.notifyOn && integrationObject.validatePermittedEvents(dto.notifyOn) + + // Check for authentication parameters + dto.metadata && integrationObject.validateMetadataParameters(dto.metadata) + + // Update the integration + const updatedIntegration = await this.prisma.integration.update({ + where: { id: integrationId }, + data: { + name: dto.name, + slug: dto.name + ? await generateEntitySlug(dto.name, 'INTEGRATION', this.prisma) + : integration.slug, + metadata: dto.metadata, + notifyOn: dto.notifyOn, + environmentId: environment?.id, + projectId: project?.id + } + }) + + this.logger.log( + `Integration ${integrationId} updated by user ${user.id} in workspace ${integration.workspaceId}` + ) + + await createEvent( + { + triggeredBy: user, + entity: updatedIntegration, + type: EventType.INTEGRATION_UPDATED, + source: EventSource.INTEGRATION, + title: `Integration ${updatedIntegration.name} updated`, + metadata: { + integrationId: updatedIntegration.id + }, + workspaceId: integration.workspaceId + }, + this.prisma + ) + + return updatedIntegration + } + + /** + * Retrieves an integration by its slug. The user needs to have `READ_INTEGRATION` + * authority over the integration. + * + * @param user The user retrieving the integration + * @param integrationSlug The slug of the integration to retrieve + * @returns The integration with the given slug + */ + async getIntegration( + user: AuthenticatedUser, + integrationSlug: Integration['slug'] + ) { + return await this.authorizationService.authorizeUserAccessToIntegration({ + user, + entity: { slug: integrationSlug }, + authorities: [Authority.READ_INTEGRATION] + }) + } + + /* istanbul ignore next */ + // The e2e tests are not working, but the API calls work as expected + /** + * Retrieves all integrations in a workspace that the user has READ authority over. + * + * The user needs to have `READ_INTEGRATION` authority over the workspace. + * + * The results are paginated and can be sorted by name ascending or descending. + * + * @param user The user retrieving the integrations + * @param workspaceSlug The slug of the workspace to retrieve integrations from + * @param page The page number of the results + * @param limit The number of items per page + * @param sort The property to sort the results by (default: name) + * @param order The order to sort the results by (default: ascending) + * @param search The string to search for in the integration names + * @returns A paginated list of integrations in the workspace + */ + async getAllIntegrationsOfWorkspace( + user: AuthenticatedUser, + workspaceSlug: Workspace['slug'], + page: number, + limit: number, + sort: string, + order: string, + search: string + ) { + // Check if the user has READ authority over the workspace + const workspace = + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, + entity: { slug: workspaceSlug }, + authorities: [Authority.READ_INTEGRATION] + }) + const workspaceId = workspace.id + + // We need to return only those integrations that have the following properties: + // - belong to the workspace + // - does not belong to any project + // - does not belong to any project where the user does not have READ authority + + // Get the projects the user has READ authority over + const membership = await this.prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + userId: user.id, + workspaceId + } + } + }) + const workspaceRoles = await this.prisma.workspaceRole.findMany({ + where: { + workspaceId, + workspaceMembers: { + some: { + workspaceMemberId: membership.id + } + } + }, + include: { + projects: { + include: { + project: true + } + } + } + }) + const projectIds = + workspaceRoles + .map((role) => role.projects.map((p) => p.projectId)) + .flat() || [] + + // Get all integrations in the workspace + const integrations = await this.prisma.integration.findMany({ + where: { + name: { + contains: search + }, + workspaceId, + OR: [ + { + projectId: null + }, + { + projectId: { + in: projectIds + } + } + ] + }, + skip: page * limit, + take: limitMaxItemsPerPage(limit), + + orderBy: { + [sort]: order + } + }) + + // Calculate metadata for pagination + const totalCount = await this.prisma.integration.count({ + where: { + name: { + contains: search + }, + workspaceId, + OR: [ + { + projectId: null + }, + { + projectId: { + in: projectIds + } + } + ] + } + }) + const metadata = paginate(totalCount, `/integration/all/${workspaceSlug}`, { + page, + limit: limitMaxItemsPerPage(limit), + sort, + order, + search + }) + + return { items: integrations, metadata } + } + + /** + * Deletes an integration by its slug. The user needs to have `DELETE_INTEGRATION` + * authority over the integration. + * + * @param user The user deleting the integration + * @param integrationSlug The slug of the integration to delete + * @returns Nothing + */ + async deleteIntegration( + user: AuthenticatedUser, + integrationSlug: Integration['slug'] + ) { + const integration = + await this.authorizationService.authorizeUserAccessToIntegration({ + user, + entity: { slug: integrationSlug }, + authorities: [Authority.DELETE_INTEGRATION] + }) + const integrationId = integration.id + + await this.prisma.integration.delete({ + where: { id: integrationId } + }) + + this.logger.log( + `Integration ${integrationId} deleted by user ${user.id} in workspace ${integration.workspaceId}` + ) + + await createEvent( + { + triggeredBy: user, + entity: integration, + type: EventType.INTEGRATION_DELETED, + source: EventSource.INTEGRATION, + title: `Integration ${integration.name} deleted`, + metadata: { + integrationId: integration.id + }, + workspaceId: integration.workspaceId + }, + this.prisma + ) + } + + /** + * Checks if an integration with the same name already exists in the workspace. + * Throws a ConflictException if the integration already exists. + * + * @param name The name of the integration to check + * @param workspace The workspace to check in + */ + private async existsByNameAndWorkspaceId( + name: Integration['name'], + workspace: Workspace + ) { + const workspaceId = workspace.id + + if ( + (await this.prisma.integration.findUnique({ + where: { + workspaceId_name: { + workspaceId, + name + } + } + })) !== null + ) + throw new ConflictException( + constructErrorBody( + 'Integration already exists', + `Integration with name ${name} already exists in workspace ${workspace.slug}` + ) + ) + } +} diff --git a/apps/api/src/prisma/migrations/20250206070313_add_blacklisted_ip_addresses_in_workspace/migration.sql b/apps/api/src/prisma/migrations/20250206070313_add_blacklisted_ip_addresses_in_workspace/migration.sql new file mode 100644 index 000000000..c15ed6b43 --- /dev/null +++ b/apps/api/src/prisma/migrations/20250206070313_add_blacklisted_ip_addresses_in_workspace/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Workspace" ADD COLUMN "blacklistedIpAddresses" TEXT[]; diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma index 7e6ee6026..3a9e7ae0c 100644 --- a/apps/api/src/prisma/schema.prisma +++ b/apps/api/src/prisma/schema.prisma @@ -457,15 +457,16 @@ model Otp { } model Workspace { - id String @id @default(cuid()) - name String - slug String @unique - isFreeTier Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - ownerId String - isDefault Boolean @default(false) - icon String? + id String @id @default(cuid()) + name String + slug String @unique + isFreeTier Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + ownerId String + isDefault Boolean @default(false) + icon String? + blacklistedIpAddresses String[] lastUpdatedBy User? @relation(fields: [lastUpdatedById], references: [id], onUpdate: Cascade, onDelete: SetNull) lastUpdatedById String? diff --git a/apps/api/src/project/controller/project.controller.spec.ts b/apps/api/src/project/controller/project.controller.spec.ts index db68a0bb6..85003900e 100644 --- a/apps/api/src/project/controller/project.controller.spec.ts +++ b/apps/api/src/project/controller/project.controller.spec.ts @@ -5,8 +5,9 @@ import { MAIL_SERVICE } from '@/mail/services/interface.service' import { MockMailService } from '@/mail/services/mock.service' import { PrismaService } from '@/prisma/prisma.service' import { mockDeep } from 'jest-mock-extended' -import { AuthorityCheckerService } from '@/common/authority-checker.service' +import { AuthorizationService } from '@/auth/service/authorization.service' import { CommonModule } from '@/common/common.module' +import { AuthorityCheckerService } from '@/auth/service/authority-checker.service' describe('ProjectController', () => { let controller: ProjectController @@ -19,6 +20,7 @@ describe('ProjectController', () => { ProjectService, PrismaService, { provide: MAIL_SERVICE, useClass: MockMailService }, + AuthorizationService, AuthorityCheckerService ] }) diff --git a/apps/api/src/project/controller/project.controller.ts b/apps/api/src/project/controller/project.controller.ts index 875ee9e05..84ddc86ba 100644 --- a/apps/api/src/project/controller/project.controller.ts +++ b/apps/api/src/project/controller/project.controller.ts @@ -10,11 +10,12 @@ import { } from '@nestjs/common' import { ProjectService } from '../service/project.service' import { CurrentUser } from '@/decorators/user.decorator' -import { Authority, Project, User, Workspace } from '@prisma/client' +import { Authority, Project, Workspace } from '@prisma/client' import { CreateProject } from '../dto/create.project/create.project' import { UpdateProject } from '../dto/update.project/update.project' import { RequiredApiKeyAuthorities } from '@/decorators/required-api-key-authorities.decorator' import { ForkProject } from '../dto/fork.project/fork.project' +import { AuthenticatedUser } from '@/user/user.types' @Controller('project') export class ProjectController { @@ -23,7 +24,7 @@ export class ProjectController { @Post(':workspaceSlug') @RequiredApiKeyAuthorities(Authority.CREATE_PROJECT) async createProject( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceSlug') workspaceSlug: Workspace['id'], @Body() dto: CreateProject ) { @@ -33,7 +34,7 @@ export class ProjectController { @Put(':projectSlug') @RequiredApiKeyAuthorities(Authority.UPDATE_PROJECT) async updateProject( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('projectSlug') projectSlug: Project['slug'], @Body() dto: UpdateProject ) { @@ -43,7 +44,7 @@ export class ProjectController { @Delete(':projectSlug') @RequiredApiKeyAuthorities(Authority.DELETE_PROJECT) async deleteProject( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('projectSlug') projectSlug: Project['slug'] ) { return await this.service.deleteProject(user, projectSlug) @@ -52,7 +53,7 @@ export class ProjectController { @Get(':projectSlug') @RequiredApiKeyAuthorities(Authority.READ_PROJECT) async getProject( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('projectSlug') projectSlug: Project['slug'] ) { return await this.service.getProject(user, projectSlug) @@ -61,7 +62,7 @@ export class ProjectController { @Post(':projectSlug/fork') @RequiredApiKeyAuthorities(Authority.READ_PROJECT, Authority.CREATE_PROJECT) async forkProject( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('projectSlug') projectSlug: Project['slug'], @Body() forkMetadata: ForkProject ) { @@ -71,7 +72,7 @@ export class ProjectController { @Put(':projectSlug/fork') @RequiredApiKeyAuthorities(Authority.READ_PROJECT, Authority.UPDATE_PROJECT) async syncFork( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('projectSlug') projectSlug: Project['slug'], @Query('hardSync') hardSync: boolean = false ) { @@ -81,7 +82,7 @@ export class ProjectController { @Delete(':projectSlug/fork') @RequiredApiKeyAuthorities(Authority.UPDATE_PROJECT) async unlinkFork( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('projectSlug') projectSlug: Project['slug'] ) { return await this.service.unlinkParentOfFork(user, projectSlug) @@ -90,7 +91,7 @@ export class ProjectController { @Get(':projectSlug/forks') @RequiredApiKeyAuthorities(Authority.READ_PROJECT) async getForks( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('projectSlug') projectSlug: Project['slug'], @Query('page') page: number = 0, @Query('limit') limit: number = 10 @@ -101,7 +102,7 @@ export class ProjectController { @Get('/all/:workspaceSlug') @RequiredApiKeyAuthorities(Authority.READ_PROJECT) async getAllProjects( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceSlug') workspaceSlug: Workspace['id'], @Query('page') page: number = 0, @Query('limit') limit: number = 10, diff --git a/apps/api/src/project/project.e2e.spec.ts b/apps/api/src/project/project.e2e.spec.ts index 389fecfca..b984dd623 100644 --- a/apps/api/src/project/project.e2e.spec.ts +++ b/apps/api/src/project/project.e2e.spec.ts @@ -18,7 +18,6 @@ import { Project, ProjectAccessLevel, Secret, - User, Variable, Workspace } from '@prisma/client' @@ -41,6 +40,7 @@ import { SecretModule } from '@/secret/secret.module' import { EnvironmentModule } from '@/environment/environment.module' import { QueryTransformPipe } from '@/common/pipes/query.transform.pipe' import { fetchEvents } from '@/common/event' +import { AuthenticatedUser } from '@/user/user.types' describe('Project Controller Tests', () => { let app: NestFastifyApplication @@ -55,10 +55,12 @@ describe('Project Controller Tests', () => { let secretService: SecretService let variableService: VariableService - let user1: User, user2: User + let user1: AuthenticatedUser, user2: AuthenticatedUser let workspace1: Workspace, workspace2: Workspace let project1: Project, project2: Project, project3: Project, project4: Project + const USER_IP_ADDRESS = '127.0.0.1' + beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [ @@ -115,14 +117,14 @@ describe('Project Controller Tests', () => { isAdmin: false }) - workspace1 = createUser1.defaultWorkspace as Workspace - workspace2 = createUser2.defaultWorkspace as Workspace + workspace1 = createUser1.defaultWorkspace + workspace2 = createUser2.defaultWorkspace delete createUser1.defaultWorkspace delete createUser2.defaultWorkspace - user1 = createUser1 - user2 = createUser2 + user1 = { ...createUser1, ipAddress: USER_IP_ADDRESS } + user2 = { ...createUser2, ipAddress: USER_IP_ADDRESS } project1 = (await projectService.createProject(user1, workspace1.slug, { name: 'Project 1', @@ -929,7 +931,7 @@ describe('Project Controller Tests', () => { it('should require WORKSPACE_ADMIN authority to alter the access level', async () => { // Create a user - const johnny = await userService.createUser({ + const user = await userService.createUser({ name: 'Johnny Doe', email: 'johhny@keyshade.xyz', isOnboardingFinished: true, @@ -937,6 +939,8 @@ describe('Project Controller Tests', () => { isAdmin: false }) + const johnny: AuthenticatedUser = { ...user, ipAddress: USER_IP_ADDRESS } + // Create a member role for the workspace const role = await workspaceRoleService.createWorkspaceRole( user1, diff --git a/apps/api/src/project/service/project.service.spec.ts b/apps/api/src/project/service/project.service.spec.ts index 997ab5872..804a35a3d 100644 --- a/apps/api/src/project/service/project.service.spec.ts +++ b/apps/api/src/project/service/project.service.spec.ts @@ -4,8 +4,9 @@ import { MockMailService } from '@/mail/services/mock.service' import { MAIL_SERVICE } from '@/mail/services/interface.service' import { PrismaService } from '@/prisma/prisma.service' import { mockDeep } from 'jest-mock-extended' -import { AuthorityCheckerService } from '@/common/authority-checker.service' +import { AuthorizationService } from '@/auth/service/authorization.service' import { CommonModule } from '@/common/common.module' +import { AuthorityCheckerService } from '@/auth/service/authority-checker.service' describe('ProjectService', () => { let service: ProjectService @@ -17,6 +18,7 @@ describe('ProjectService', () => { ProjectService, PrismaService, { provide: MAIL_SERVICE, useClass: MockMailService }, + AuthorizationService, AuthorityCheckerService ] }) diff --git a/apps/api/src/project/service/project.service.ts b/apps/api/src/project/service/project.service.ts index 73c5ca126..7db44a553 100644 --- a/apps/api/src/project/service/project.service.ts +++ b/apps/api/src/project/service/project.service.ts @@ -1,1324 +1,1318 @@ -import { - BadRequestException, - ConflictException, - Injectable, - Logger -} from '@nestjs/common' -import { - Authority, - Environment, - EventSource, - EventType, - Project, - ProjectAccessLevel, - Secret, - SecretVersion, - User, - Variable, - Workspace -} from '@prisma/client' -import { CreateProject } from '../dto/create.project/create.project' -import { UpdateProject } from '../dto/update.project/update.project' -import { PrismaService } from '@/prisma/prisma.service' -import { v4 } from 'uuid' -import { ProjectWithCounts, ProjectWithSecrets } from '../project.types' -import { AuthorityCheckerService } from '@/common/authority-checker.service' -import { ForkProject } from '../dto/fork.project/fork.project' -import { paginate } from '@/common/paginate' -import { createKeyPair, decrypt, encrypt } from '@/common/cryptography' -import generateEntitySlug from '@/common/slug-generator' -import { createEvent } from '@/common/event' -import { - constructErrorBody, - excludeFields, - limitMaxItemsPerPage -} from '@/common/util' - -@Injectable() -export class ProjectService { - private readonly log: Logger = new Logger(ProjectService.name) - - constructor( - private readonly prisma: PrismaService, - private readonly authorityCheckerService: AuthorityCheckerService - ) {} - - /** - * Creates a new project in a workspace - * - * @param user The user who is creating the project - * @param workspaceSlug The slug of the workspace where the project will be created - * @param dto The data for the new project - * @returns The newly created project - */ - async createProject( - user: User, - workspaceSlug: Workspace['slug'], - dto: CreateProject - ) { - // Check if the workspace exists or not - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.CREATE_PROJECT], - prisma: this.prisma - }) - const workspaceId = workspace.id - - // Check if project with this name already exists for the user - if (await this.projectExists(dto.name, workspaceId)) - throw new ConflictException( - constructErrorBody( - 'Project already exists', - `Project with name ${dto.name} already exists in workspace ${workspace.slug}` - ) - ) - - // Create the public and private key pair - const { publicKey, privateKey } = createKeyPair() - - const data: any = { - name: dto.name, - slug: await generateEntitySlug(dto.name, 'PROJECT', this.prisma), - description: dto.description, - storePrivateKey: - dto.accessLevel === ProjectAccessLevel.GLOBAL - ? true - : dto.storePrivateKey, // If the project is global, the private key must be stored - publicKey, - accessLevel: dto.accessLevel - } - - // Check if the private key should be stored - // PLEASE DON'T STORE YOUR PRIVATE KEYS WITH US!! - if (dto.storePrivateKey) { - data.privateKey = privateKey - } - - const userId = user.id - - const newProjectId = v4() - - const adminRole = await this.prisma.workspaceRole.findFirst({ - where: { - workspaceId: workspaceId, - hasAdminAuthority: true - } - }) - - // Create and return the project - const createNewProject = this.prisma.project.create({ - data: { - id: newProjectId, - ...data, - workspace: { - connect: { - id: workspaceId - } - }, - lastUpdatedBy: { - connect: { - id: userId - } - } - } - }) - - const addProjectToAdminRoleOfItsWorkspace = - this.prisma.workspaceRole.update({ - where: { - id: adminRole.id - }, - data: { - projects: { - create: { - project: { - connect: { - id: newProjectId - } - } - } - } - } - }) - - const createEnvironmentOps = [] - - // Create and assign the environments provided in the request, if any - // or create a default environment - if (dto.environments && dto.environments.length > 0) { - for (const environment of dto.environments) { - createEnvironmentOps.push( - this.prisma.environment.create({ - data: { - name: environment.name, - slug: await generateEntitySlug( - environment.name, - 'ENVIRONMENT', - this.prisma - ), - description: environment.description, - projectId: newProjectId, - lastUpdatedById: user.id - } - }) - ) - } - } else { - createEnvironmentOps.push( - this.prisma.environment.create({ - data: { - name: 'default', - slug: await generateEntitySlug( - 'default', - 'ENVIRONMENT', - this.prisma - ), - description: 'Default environment for the project', - projectId: newProjectId, - lastUpdatedById: user.id - } - }) - ) - } - - const [newProject] = await this.prisma.$transaction([ - createNewProject, - addProjectToAdminRoleOfItsWorkspace, - ...createEnvironmentOps - ]) - - await createEvent( - { - triggeredBy: user, - entity: newProject, - type: EventType.PROJECT_CREATED, - source: EventSource.PROJECT, - title: `Project created`, - metadata: { - projectId: newProject.id, - name: newProject.name, - workspaceId, - workspaceName: workspace.name - }, - workspaceId - }, - this.prisma - ) - - this.log.debug(`Created project ${newProject}`) - - // It is important that we log before the private key is set - // in order to not log the private key - newProject.privateKey = privateKey - - return newProject - } - - /** - * Updates a project. - * - * @param user The user who is updating the project - * @param projectSlug The slug of the project to update - * @param dto The data to update the project with - * @returns The updated project - * - * @throws ConflictException If a project with the same name already exists for the user - * @throws BadRequestException If the private key is required but not supplied - */ - async updateProject( - user: User, - projectSlug: Project['slug'], - dto: UpdateProject - ) { - // Check if the user has the authority to update the project - let authority: Authority = Authority.UPDATE_PROJECT - - // Only admins can change the visibility of the project - if (dto.accessLevel) authority = Authority.WORKSPACE_ADMIN - - const project = - await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, - entity: { slug: projectSlug }, - authorities: [authority], - prisma: this.prisma - }) - - // Check if project with this name already exists for the user - if ( - (dto.name && (await this.projectExists(dto.name, user.id))) || - project.name === dto.name - ) - throw new ConflictException( - constructErrorBody( - 'Project already exists', - `Project with this name **${dto.name}** already exists` - ) - ) - - if (dto.accessLevel) { - const currentAccessLevel = project.accessLevel - - if ( - currentAccessLevel !== ProjectAccessLevel.GLOBAL && - dto.accessLevel === ProjectAccessLevel.GLOBAL - ) { - // If the project is being made global, the private key must be stored - // This is because we want anyone to see the secrets in the project - dto.storePrivateKey = true - dto.privateKey = dto.privateKey || project.privateKey - - // We can't make the project global if a private key isn't supplied, - // because we need to decrypt the secrets - if (!dto.privateKey) { - throw new BadRequestException( - constructErrorBody( - 'Private key required', - 'Please provide the private key if you wish to set the project as GLOBAL' - ) - ) - } - } else if ( - currentAccessLevel === ProjectAccessLevel.GLOBAL && - dto.accessLevel !== ProjectAccessLevel.GLOBAL - ) { - dto.storePrivateKey = false - dto.regenerateKeyPair = true - - // At this point, we already will have the private key since the project is global - dto.privateKey = project.privateKey - } - } - - const data: Partial = { - name: dto.name, - slug: dto.name - ? await generateEntitySlug(dto.name, 'PROJECT', this.prisma) - : project.slug, - description: dto.description, - storePrivateKey: dto.storePrivateKey, - privateKey: dto.storePrivateKey ? dto.privateKey : null, - accessLevel: dto.accessLevel - } - - // If the access level is changed to PRIVATE or internal, we would - // also need to unlink all the forks - if ( - dto.accessLevel !== ProjectAccessLevel.GLOBAL && - project.accessLevel === ProjectAccessLevel.GLOBAL - ) { - data.isForked = false - data.forkedFromId = null - } - - const versionUpdateOps = [] - let privateKey = dto.privateKey - let publicKey = project.publicKey - - if (dto.regenerateKeyPair) { - if (dto.privateKey || project.privateKey) { - const { txs, newPrivateKey, newPublicKey } = - await this.updateProjectKeyPair( - project, - dto.privateKey || project.privateKey, - dto.storePrivateKey - ) - - privateKey = newPrivateKey - publicKey = newPublicKey - - versionUpdateOps.push(...txs) - } else { - throw new BadRequestException( - constructErrorBody( - 'Private key required', - 'Please provide the private key if you wish to regenerate the key pair' - ) - ) - } - } - - // Update and return the project - const updateProjectOp = this.prisma.project.update({ - where: { - id: project.id - }, - data: { - ...data, - lastUpdatedById: user.id - } - }) - - const [updatedProject] = await this.prisma.$transaction([ - updateProjectOp, - ...versionUpdateOps - ]) - - await createEvent( - { - triggeredBy: user, - entity: updatedProject, - type: EventType.PROJECT_UPDATED, - source: EventSource.PROJECT, - title: `Project updated`, - metadata: { - projectId: updatedProject.id, - name: updatedProject.name - }, - workspaceId: updatedProject.workspaceId - }, - this.prisma - ) - - this.log.debug(`Updated project ${updatedProject.id}`) - return { - ...updatedProject, - privateKey, - publicKey - } - } - - /** - * Forks a project. - * - * @param user The user who is creating the new project - * @param projectSlug The slug of the project to fork - * @param forkMetadata The metadata for the new project - * @returns The newly forked project - * - * @throws ConflictException If a project with the same name already exists for the user - * @throws BadRequestException If the private key is required but not supplied - */ - async forkProject( - user: User, - projectSlug: Project['slug'], - forkMetadata: ForkProject - ) { - const project = - await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, - entity: { slug: projectSlug }, - authorities: [Authority.READ_PROJECT], - prisma: this.prisma - }) - - let workspaceId = null - - if (forkMetadata.workspaceSlug) { - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: forkMetadata.workspaceSlug }, - authorities: [Authority.CREATE_PROJECT], - prisma: this.prisma - }) - - workspaceId = workspace.id - } else { - const defaultWorkspace = await this.prisma.workspaceMember.findFirst({ - where: { - userId: user.id, - workspace: { - isDefault: true - } - } - }) - workspaceId = defaultWorkspace.workspaceId - } - - const newProjectName = forkMetadata.name || project.name - - // Check if project with this name already exists for the user - if (await this.projectExists(newProjectName, workspaceId)) - throw new ConflictException( - constructErrorBody( - 'Project already exists', - `Project with name ${newProjectName} already exists in the selected workspace` - ) - ) - - const { privateKey, publicKey } = createKeyPair() - const userId = user.id - const newProjectId = v4() - const adminRole = await this.prisma.workspaceRole.findFirst({ - where: { - workspaceId, - hasAdminAuthority: true - } - }) - - // Create and return the project - const createNewProject = this.prisma.project.create({ - data: { - id: newProjectId, - name: newProjectName, - slug: await generateEntitySlug(newProjectName, 'PROJECT', this.prisma), - description: project.description, - storePrivateKey: - forkMetadata.storePrivateKey || project.storePrivateKey, - publicKey: publicKey, - privateKey: - forkMetadata.storePrivateKey || project.storePrivateKey - ? privateKey - : null, - accessLevel: project.accessLevel, - isForked: true, - forkedFromId: project.id, - workspaceId: workspaceId, - lastUpdatedById: userId - } - }) - - const addProjectToAdminRoleOfItsWorkspace = - this.prisma.workspaceRole.update({ - where: { - id: adminRole.id - }, - data: { - projects: { - create: { - project: { - connect: { - id: newProjectId - } - } - } - } - } - }) - - const copyProjectOp = await this.copyProjectData( - user, - { - id: project.id, - privateKey: project.privateKey - }, - { - id: newProjectId, - publicKey - }, - true - ) - - const [newProject] = await this.prisma.$transaction([ - createNewProject, - addProjectToAdminRoleOfItsWorkspace, - ...copyProjectOp - ]) - - await createEvent( - { - triggeredBy: user, - entity: newProject, - type: EventType.PROJECT_CREATED, - source: EventSource.PROJECT, - title: `Project created`, - metadata: { - projectId: newProject.id, - name: newProject.name, - workspaceId, - workspaceName: workspaceId - }, - workspaceId - }, - this.prisma - ) - - this.log.debug(`Created project ${newProject}`) - return newProject - } - - /** - * Unlinks a forked project from its parent project. - * - * @param user The user who is unlinking the project - * @param projectSlug The slug of the project to unlink - * @returns The updated project - * - * @throws BadRequestException If the project is not a forked project - * @throws UnauthorizedException If the user does not have the authority to update the project - */ - async unlinkParentOfFork(user: User, projectSlug: Project['slug']) { - const project = - await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, - entity: { slug: projectSlug }, - authorities: [Authority.UPDATE_PROJECT], - prisma: this.prisma - }) - const projectId = project.id - - await this.prisma.project.update({ - where: { - id: projectId - }, - data: { - isForked: false, - forkedFromId: null - } - }) - } - - /** - * Syncs a forked project with its parent project. - * - * @param user The user who is syncing the project - * @param projectSlug The slug of the project to sync - * @param hardSync Whether to do a hard sync or not. If true, all items in the - * forked project will be replaced with the items from the parent project. If - * false, only items that are not present in the forked project will be added - * from the parent project. - * - * @throws BadRequestException If the project is not a forked project - * @throws UnauthorizedException If the user does not have the authority to update the project - */ - async syncFork(user: User, projectSlug: Project['slug'], hardSync: boolean) { - const project = - await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, - entity: { slug: projectSlug }, - authorities: [Authority.UPDATE_PROJECT], - prisma: this.prisma - }) - const projectId = project.id - - if (!project.isForked || project.forkedFromId == null) { - throw new BadRequestException( - constructErrorBody( - 'Not a forked project', - `Project ${projectSlug} is not a forked project` - ) - ) - } - - const forkedFromProject = await this.prisma.project.findUnique({ - where: { - id: project.forkedFromId - } - }) - - const parentProject = - await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, - entity: { slug: forkedFromProject.slug }, - authorities: [Authority.READ_PROJECT], - prisma: this.prisma - }) - - const copyProjectOp = await this.copyProjectData( - user, - { - id: parentProject.id, - privateKey: parentProject.privateKey - }, - { - id: projectId, - publicKey: project.publicKey - }, - hardSync - ) - - await this.prisma.$transaction(copyProjectOp) - } - - /** - * Deletes a project. - * @param user The user who is deleting the project - * @param projectSlug The slug of the project to delete - * - * @throws UnauthorizedException If the user does not have the authority to delete the project - */ - async deleteProject(user: User, projectSlug: Project['slug']) { - const project = - await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, - entity: { slug: projectSlug }, - authorities: [Authority.DELETE_PROJECT], - prisma: this.prisma - }) - - const op = [] - - // Remove the fork relationships - op.push( - this.prisma.project.updateMany({ - where: { - forkedFromId: project.id - }, - data: { - isForked: false, - forkedFromId: null - } - }) - ) - - // Delete the project - op.push( - this.prisma.project.delete({ - where: { - id: project.id - } - }) - ) - - await this.prisma.$transaction(op) - - await createEvent( - { - triggeredBy: user, - type: EventType.PROJECT_DELETED, - source: EventSource.PROJECT, - entity: project, - title: `Project deleted`, - metadata: { - projectId: project.id, - name: project.name - }, - workspaceId: project.workspaceId - }, - this.prisma - ) - - this.log.debug(`Deleted project ${project}`) - } - - /** - * Gets all the forks of a project. - * - * @param user The user who is requesting the forks - * @param projectSlug The slug of the project to get forks for - * @param page The page number to get the forks for - * @param limit The number of forks to get per page - * @returns An object with two properties: `items` and `metadata`. - * `items` is an array of project objects that are forks of the given project, - * and `metadata` is the pagination metadata for the forks. - */ - async getAllProjectForks( - user: User, - projectSlug: Project['slug'], - page: number, - limit: number - ) { - const project = - await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, - entity: { slug: projectSlug }, - authorities: [Authority.READ_PROJECT], - prisma: this.prisma - }) - const projectId = project.id - - const forks = await this.prisma.project.findMany({ - where: { - forkedFromId: projectId - } - }) - - const forksAllowed = forks.filter(async (fork) => { - const allowed = - (await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, - entity: { slug: fork.slug }, - authorities: [Authority.READ_PROJECT], - prisma: this.prisma - })) != null - - return allowed - }) - - const items = forksAllowed.slice(page * limit, (page + 1) * limit) - - // Calculate metadata - const metadata = paginate( - forksAllowed.length, - `/project/${projectSlug}/forks`, - { - page, - limit: limitMaxItemsPerPage(limit) - } - ) - - return { items, metadata } - } - - /** - * Gets a project by slug. - * - * @param user The user who is requesting the project - * @param projectSlug The slug of the project to get - * @returns The project with secrets removed - * - * @throws UnauthorizedException If the user does not have the authority to read the project - */ - async getProject(user: User, projectSlug: Project['slug']) { - const project = - await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, - entity: { slug: projectSlug }, - authorities: [Authority.READ_PROJECT], - prisma: this.prisma - }) - - delete project.secrets - - return await this.countEnvironmentsVariablesAndSecretsInProject( - project, - user - ) - } - - /** - * Gets all the projects in a workspace that the user has access to. - * - * @param user The user who is requesting the projects - * @param workspaceSlug The slug of the workspace to get the projects from - * @param page The page number to get the projects for - * @param limit The number of projects to get per page - * @param sort The field to sort the projects by - * @param order The order to sort the projects in - * @param search The search string to filter the projects by - * @returns An object with two properties: `items` and `metadata`. - * `items` is an array of project objects that match the given criteria, - * and `metadata` is an object with pagination metadata. - */ - async getProjectsOfWorkspace( - user: User, - workspaceSlug: Workspace['slug'], - page: number, - limit: number, - sort: string, - order: string, - search: string - ) { - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.READ_PROJECT], - prisma: this.prisma - }) - const workspaceId = workspace.id - - //fetch projects with required properties - const projects = ( - await this.prisma.project.findMany({ - skip: page * limit, - take: limitMaxItemsPerPage(limit), - orderBy: { - [sort]: order - }, - where: { - workspaceId, - OR: [ - { - name: { - contains: search - } - }, - { - description: { - contains: search - } - } - ], - workspace: { - members: { - some: { - userId: user.id, - roles: { - some: { - role: { - authorities: { - hasSome: [ - Authority.WORKSPACE_ADMIN, - Authority.READ_PROJECT - ] - } - } - } - } - } - } - } - } - }) - ).map((project) => excludeFields(project, 'privateKey', 'publicKey')) - - const items = await Promise.all( - projects.map(async (project) => - this.countEnvironmentsVariablesAndSecretsInProject(project, user) - ) - ) - - //calculate metadata - const totalCount = await this.prisma.project.count({ - where: { - workspaceId, - OR: [ - { - name: { - contains: search - } - }, - { - description: { - contains: search - } - } - ], - workspace: { - members: { - some: { - userId: user.id - } - } - } - } - }) - - const metadata = paginate(totalCount, `/project/all/${workspaceSlug}`, { - page, - limit, - sort, - order, - search - }) - - return { items, metadata } - } - - /** - * Checks if a project with a given name exists in a workspace. - * - * @param projectName The name of the project to check - * @param workspaceId The ID of the workspace to check in - * @returns true if the project exists, false otherwise - */ - private async projectExists( - projectName: string, - workspaceId: Workspace['id'] - ): Promise { - return ( - (await this.prisma.workspaceMember.count({ - where: { - workspaceId, - workspace: { - projects: { - some: { - name: projectName - } - } - } - } - })) > 0 - ) - } - - /** - * Copies the project data from one project to another project. - * - * @param user The user who is performing the copy operation - * @param fromProject The project from which the data is being copied - * @param toProject The project to which the data is being copied - * @param hardCopy If true, replace all the data in the toProject with the fromProject, - * otherwise, only add the items in the fromProject that are not already present in the toProject. - * @returns An array of database operations that need to be performed to copy the data. - */ - private async copyProjectData( - user: User, - fromProject: { - id: Project['id'] - privateKey: string // Need the private key to decrypt the secrets - }, - toProject: { - id: Project['id'] - publicKey: string // Need the public key to encrypt the secrets - }, - // hardCopy = true: Replace everything in the toProject with the fromProject - // hardCopy = false: Only add those items in the toProject that are not already present in it - hardCopy: boolean = false - ) { - // This field will be populated if hardCopy is true - // When we are doing a hard copy, we need to delete all the - // items in the toProject that are already present in it - const deleteOps = [] - - // Get all the environments that belongs to the parent project - // and replicate them for the new project - const createEnvironmentOps = [] - const envNameToIdMap = {} - - // These fields will be populated if hardCopy is false - // When we are doing a soft copy, we would only like to add those - // items in the toProject that are not already present in it with - // comparison to the fromProject - const toProjectEnvironments: Set = new Set() - const toProjectSecrets: Set = new Set() - const toProjectVariables: Set = new Set() - - if (!hardCopy) { - const [environments, secrets, variables] = await this.prisma.$transaction( - [ - this.prisma.environment.findMany({ - where: { - projectId: toProject.id - } - }), - this.prisma.secret.findMany({ - where: { - projectId: toProject.id - } - }), - this.prisma.variable.findMany({ - where: { - projectId: toProject.id - } - }) - ] - ) - - environments.forEach((env) => { - envNameToIdMap[env.name] = env.id - toProjectEnvironments.add(env.name) - }) - - secrets.forEach((secret) => { - toProjectSecrets.add(secret.name) - }) - - variables.forEach((variable) => { - toProjectVariables.add(variable.name) - }) - } else { - deleteOps.push( - this.prisma.environment.deleteMany({ - where: { - projectId: toProject.id - } - }) - ) - - deleteOps.push( - this.prisma.secret.deleteMany({ - where: { - projectId: toProject.id - } - }) - ) - - deleteOps.push( - this.prisma.variable.deleteMany({ - where: { - projectId: toProject.id - } - }) - ) - } - - // We want to find all such environments in the fromProject that - // is not present in the toProject. You can think of this as a set - // difference operation. - // In case of a hard copy, we would just copy all the environments - // since toProjectEnvironments will be empty. - const missingEnvironments = await this.prisma.environment.findMany({ - where: { - projectId: fromProject.id, - name: { - notIn: Array.from(toProjectEnvironments) - } - } - }) - - // For all the new environments that we are creating, we want to map - // the name of the environment to the id of the newly created environment - for (const environment of missingEnvironments) { - const newEnvironmentId = v4() - envNameToIdMap[environment.name] = newEnvironmentId - - createEnvironmentOps.push( - this.prisma.environment.create({ - data: { - id: newEnvironmentId, - name: environment.name, - slug: await generateEntitySlug( - environment.name, - 'ENVIRONMENT', - this.prisma - ), - description: environment.description, - projectId: toProject.id, - lastUpdatedById: user.id - } - }) - ) - } - - const createSecretOps = [] - - // Get all the secrets that belongs to the parent project and - // replicate them for the new project. This too is a set difference - // operation. - const secrets = await this.prisma.secret.findMany({ - where: { - projectId: fromProject.id, - name: { - notIn: Array.from(toProjectSecrets) - } - }, - include: { - versions: { - include: { - environment: { - select: { - name: true - } - } - } - } - } - }) - - for (const secret of secrets) { - const secretVersions = secret.versions.map(async (version) => ({ - value: await encrypt( - toProject.publicKey, - await decrypt(fromProject.privateKey, version.value) - ), - version: version.version, - environmentName: version.environment.name - })) - - createSecretOps.push( - this.prisma.secret.create({ - data: { - name: secret.name, - slug: await generateEntitySlug(secret.name, 'SECRET', this.prisma), - projectId: toProject.id, - lastUpdatedById: user.id, - note: secret.note, - rotateAt: secret.rotateAt, - versions: { - create: await Promise.all( - secretVersions.map(async (secretVersion) => { - const awaitedSecretVersion = await secretVersion - return { - value: awaitedSecretVersion.value, - version: awaitedSecretVersion.version, - environmentId: - envNameToIdMap[awaitedSecretVersion.environmentName], - createdById: user.id - } - }) - ) - } - } - }) - ) - } - - // Get all the variables that belongs to the parent project and - // replicate them for the new project - const createVariableOps = [] - - const variables = await this.prisma.variable.findMany({ - where: { - projectId: fromProject.id, - name: { - notIn: Array.from(toProjectVariables) - } - }, - include: { - versions: { - include: { - environment: { - select: { - name: true - } - } - } - } - } - }) - - for (const variable of variables) { - createVariableOps.push( - this.prisma.variable.create({ - data: { - name: variable.name, - slug: await generateEntitySlug( - variable.name, - 'VARIABLE', - this.prisma - ), - projectId: toProject.id, - lastUpdatedById: user.id, - note: variable.note, - versions: { - create: variable.versions.map((version) => ({ - value: version.value, - version: version.version, - createdById: user.id, - environmentId: envNameToIdMap[version.environment.name] - })) - } - } - }) - ) - } - - return [ - ...deleteOps, - ...createEnvironmentOps, - ...createSecretOps, - ...createVariableOps - ] - } - - /** - * Updates the key pair of a project. - * - * @param project The project to update - * @param oldPrivateKey The old private key of the project - * @param storePrivateKey Whether to store the new private key in the database - * - * @returns An object with three properties: - * - `txs`: an array of database operations that need to be performed to update the project - * - `newPrivateKey`: the new private key of the project - * - `newPublicKey`: the new public key of the project - */ - private async updateProjectKeyPair( - project: ProjectWithSecrets, - oldPrivateKey: string, - storePrivateKey: boolean - ) { - // A new key pair can be generated only if: - // - The existing private key is provided - // - Or, the private key was stored - const { privateKey: newPrivateKey, publicKey: newPublicKey } = - createKeyPair() - - const txs = [] - - // Re-hash all secrets - for (const secret of project.secrets) { - const versions = await this.prisma.secretVersion.findMany({ - where: { - secretId: secret.id - } - }) - - const updatedVersions: Partial[] = [] - - // First, encrypt the values with the new key and store - // them in a temporary array - for (const version of versions) { - updatedVersions.push({ - id: version.id, - value: await encrypt( - await decrypt(oldPrivateKey, version.value), - newPrivateKey - ) - }) - } - - // Apply the changes to the database - for (const version of updatedVersions) { - txs.push( - this.prisma.secretVersion.update({ - where: { - id: version.id - }, - data: { - value: version.value - } - }) - ) - } - } - - // Update the project with the new key pair - txs.push( - this.prisma.project.update({ - where: { - id: project.id - }, - data: { - publicKey: newPublicKey, - privateKey: storePrivateKey ? newPrivateKey : null - } - }) - ) - - return { txs, newPrivateKey, newPublicKey } - } - - private async countEnvironmentsVariablesAndSecretsInProject( - project: Partial, - user: User - ): Promise { - let environmentCount = 0 - let variableCount = 0 - let secretCount = 0 - // When we later implement RBAC for environments, we would need to updated - // this code to only include environments like we do while fetching projects. - - // What would be even better is, we should fetch environments directly. And then, - // accumulate the projects into a set of projects. And then, return that set along - // with the required data. - const allEnvs = await this.prisma.environment.findMany({ - where: { projectId: project.id } - }) - - // This entire block will become invalid after RBAC for environments are implemented - const envPromises = allEnvs.map(async (env) => { - const hasRequiredPermission = - await this.authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, - entity: { slug: env.slug }, - authorities: - project.accessLevel == ProjectAccessLevel.GLOBAL - ? [] - : [ - Authority.READ_ENVIRONMENT, - Authority.READ_SECRET, - Authority.READ_VARIABLE - ], - prisma: this.prisma - }) - if (hasRequiredPermission) { - environmentCount += 1 - - const fetchSecretCount = this.prisma.secret.count({ - where: { - projectId: project.id, - versions: { some: { environmentId: env.id } } - } - }) - - const fetchVariableCount = this.prisma.variable.count({ - where: { - projectId: project.id, - versions: { some: { environmentId: env.id } } - } - }) - - return this.prisma.$transaction([fetchSecretCount, fetchVariableCount]) - } - return [0, 0] - }) - const counts = await Promise.all(envPromises) - secretCount = counts.reduce((sum, [secretCount]) => sum + secretCount, 0) - variableCount = counts.reduce( - (sum, [, variableCount]) => sum + variableCount, - 0 - ) - return { - ...project, - environmentCount, - variableCount, - secretCount - } - } -} +import { + BadRequestException, + ConflictException, + Injectable, + Logger +} from '@nestjs/common' +import { + Authority, + Environment, + EventSource, + EventType, + Project, + ProjectAccessLevel, + Secret, + SecretVersion, + Variable, + Workspace +} from '@prisma/client' +import { CreateProject } from '../dto/create.project/create.project' +import { UpdateProject } from '../dto/update.project/update.project' +import { PrismaService } from '@/prisma/prisma.service' +import { AuthorizationService } from '@/auth/service/authorization.service' +import { v4 } from 'uuid' +import { ProjectWithCounts, ProjectWithSecrets } from '../project.types' +import { ForkProject } from '../dto/fork.project/fork.project' +import { paginate } from '@/common/paginate' +import { createKeyPair, decrypt, encrypt } from '@/common/cryptography' +import generateEntitySlug from '@/common/slug-generator' +import { createEvent } from '@/common/event' +import { + constructErrorBody, + excludeFields, + limitMaxItemsPerPage +} from '@/common/util' +import { AuthenticatedUser } from '@/user/user.types' + +@Injectable() +export class ProjectService { + private readonly log: Logger = new Logger(ProjectService.name) + + constructor( + private readonly prisma: PrismaService, + private readonly authorizationService: AuthorizationService + ) {} + + /** + * Creates a new project in a workspace + * + * @param user The user who is creating the project + * @param workspaceSlug The slug of the workspace where the project will be created + * @param dto The data for the new project + * @returns The newly created project + */ + async createProject( + user: AuthenticatedUser, + workspaceSlug: Workspace['slug'], + dto: CreateProject + ) { + // Check if the workspace exists or not + const workspace = + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, + entity: { slug: workspaceSlug }, + authorities: [Authority.CREATE_PROJECT] + }) + const workspaceId = workspace.id + + // Check if project with this name already exists for the user + if (await this.projectExists(dto.name, workspaceId)) + throw new ConflictException( + constructErrorBody( + 'Project already exists', + `Project with name ${dto.name} already exists in workspace ${workspace.slug}` + ) + ) + + // Create the public and private key pair + const { publicKey, privateKey } = createKeyPair() + + const data: any = { + name: dto.name, + slug: await generateEntitySlug(dto.name, 'PROJECT', this.prisma), + description: dto.description, + storePrivateKey: + dto.accessLevel === ProjectAccessLevel.GLOBAL + ? true + : dto.storePrivateKey, // If the project is global, the private key must be stored + publicKey, + accessLevel: dto.accessLevel + } + + // Check if the private key should be stored + // PLEASE DON'T STORE YOUR PRIVATE KEYS WITH US!! + if (dto.storePrivateKey) { + data.privateKey = privateKey + } + + const userId = user.id + + const newProjectId = v4() + + const adminRole = await this.prisma.workspaceRole.findFirst({ + where: { + workspaceId: workspaceId, + hasAdminAuthority: true + } + }) + + // Create and return the project + const createNewProject = this.prisma.project.create({ + data: { + id: newProjectId, + ...data, + workspace: { + connect: { + id: workspaceId + } + }, + lastUpdatedBy: { + connect: { + id: userId + } + } + } + }) + + const addProjectToAdminRoleOfItsWorkspace = + this.prisma.workspaceRole.update({ + where: { + id: adminRole.id + }, + data: { + projects: { + create: { + project: { + connect: { + id: newProjectId + } + } + } + } + } + }) + + const createEnvironmentOps = [] + + // Create and assign the environments provided in the request, if any + // or create a default environment + if (dto.environments && dto.environments.length > 0) { + for (const environment of dto.environments) { + createEnvironmentOps.push( + this.prisma.environment.create({ + data: { + name: environment.name, + slug: await generateEntitySlug( + environment.name, + 'ENVIRONMENT', + this.prisma + ), + description: environment.description, + projectId: newProjectId, + lastUpdatedById: user.id + } + }) + ) + } + } else { + createEnvironmentOps.push( + this.prisma.environment.create({ + data: { + name: 'default', + slug: await generateEntitySlug( + 'default', + 'ENVIRONMENT', + this.prisma + ), + description: 'Default environment for the project', + projectId: newProjectId, + lastUpdatedById: user.id + } + }) + ) + } + + const [newProject] = await this.prisma.$transaction([ + createNewProject, + addProjectToAdminRoleOfItsWorkspace, + ...createEnvironmentOps + ]) + + await createEvent( + { + triggeredBy: user, + entity: newProject, + type: EventType.PROJECT_CREATED, + source: EventSource.PROJECT, + title: `Project created`, + metadata: { + projectId: newProject.id, + name: newProject.name, + workspaceId, + workspaceName: workspace.name + }, + workspaceId + }, + this.prisma + ) + + this.log.debug(`Created project ${newProject}`) + + // It is important that we log before the private key is set + // in order to not log the private key + newProject.privateKey = privateKey + + return newProject + } + + /** + * Updates a project. + * + * @param user The user who is updating the project + * @param projectSlug The slug of the project to update + * @param dto The data to update the project with + * @returns The updated project + * + * @throws ConflictException If a project with the same name already exists for the user + * @throws BadRequestException If the private key is required but not supplied + */ + async updateProject( + user: AuthenticatedUser, + projectSlug: Project['slug'], + dto: UpdateProject + ) { + // Check if the user has the authority to update the project + let authority: Authority = Authority.UPDATE_PROJECT + + // Only admins can change the visibility of the project + if (dto.accessLevel) authority = Authority.WORKSPACE_ADMIN + + const project = + await this.authorizationService.authorizeUserAccessToProject({ + user, + entity: { slug: projectSlug }, + authorities: [authority] + }) + + // Check if project with this name already exists for the user + if ( + (dto.name && (await this.projectExists(dto.name, user.id))) || + project.name === dto.name + ) + throw new ConflictException( + constructErrorBody( + 'Project already exists', + `Project with this name **${dto.name}** already exists` + ) + ) + + if (dto.accessLevel) { + const currentAccessLevel = project.accessLevel + + if ( + currentAccessLevel !== ProjectAccessLevel.GLOBAL && + dto.accessLevel === ProjectAccessLevel.GLOBAL + ) { + // If the project is being made global, the private key must be stored + // This is because we want anyone to see the secrets in the project + dto.storePrivateKey = true + dto.privateKey = dto.privateKey || project.privateKey + + // We can't make the project global if a private key isn't supplied, + // because we need to decrypt the secrets + if (!dto.privateKey) { + throw new BadRequestException( + constructErrorBody( + 'Private key required', + 'Please provide the private key if you wish to set the project as GLOBAL' + ) + ) + } + } else if ( + currentAccessLevel === ProjectAccessLevel.GLOBAL && + dto.accessLevel !== ProjectAccessLevel.GLOBAL + ) { + dto.storePrivateKey = false + dto.regenerateKeyPair = true + + // At this point, we already will have the private key since the project is global + dto.privateKey = project.privateKey + } + } + + const data: Partial = { + name: dto.name, + slug: dto.name + ? await generateEntitySlug(dto.name, 'PROJECT', this.prisma) + : project.slug, + description: dto.description, + storePrivateKey: dto.storePrivateKey, + privateKey: dto.storePrivateKey ? dto.privateKey : null, + accessLevel: dto.accessLevel + } + + // If the access level is changed to PRIVATE or internal, we would + // also need to unlink all the forks + if ( + dto.accessLevel !== ProjectAccessLevel.GLOBAL && + project.accessLevel === ProjectAccessLevel.GLOBAL + ) { + data.isForked = false + data.forkedFromId = null + } + + const versionUpdateOps = [] + let privateKey = dto.privateKey + let publicKey = project.publicKey + + if (dto.regenerateKeyPair) { + if (dto.privateKey || project.privateKey) { + const { txs, newPrivateKey, newPublicKey } = + await this.updateProjectKeyPair( + project, + dto.privateKey || project.privateKey, + dto.storePrivateKey + ) + + privateKey = newPrivateKey + publicKey = newPublicKey + + versionUpdateOps.push(...txs) + } else { + throw new BadRequestException( + constructErrorBody( + 'Private key required', + 'Please provide the private key if you wish to regenerate the key pair' + ) + ) + } + } + + // Update and return the project + const updateProjectOp = this.prisma.project.update({ + where: { + id: project.id + }, + data: { + ...data, + lastUpdatedById: user.id + } + }) + + const [updatedProject] = await this.prisma.$transaction([ + updateProjectOp, + ...versionUpdateOps + ]) + + await createEvent( + { + triggeredBy: user, + entity: updatedProject, + type: EventType.PROJECT_UPDATED, + source: EventSource.PROJECT, + title: `Project updated`, + metadata: { + projectId: updatedProject.id, + name: updatedProject.name + }, + workspaceId: updatedProject.workspaceId + }, + this.prisma + ) + + this.log.debug(`Updated project ${updatedProject.id}`) + return { + ...updatedProject, + privateKey, + publicKey + } + } + + /** + * Forks a project. + * + * @param user The user who is creating the new project + * @param projectSlug The slug of the project to fork + * @param forkMetadata The metadata for the new project + * @returns The newly forked project + * + * @throws ConflictException If a project with the same name already exists for the user + * @throws BadRequestException If the private key is required but not supplied + */ + async forkProject( + user: AuthenticatedUser, + projectSlug: Project['slug'], + forkMetadata: ForkProject + ) { + const project = + await this.authorizationService.authorizeUserAccessToProject({ + user, + entity: { slug: projectSlug }, + authorities: [Authority.READ_PROJECT] + }) + + let workspaceId = null + + if (forkMetadata.workspaceSlug) { + const workspace = + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, + entity: { slug: forkMetadata.workspaceSlug }, + authorities: [Authority.CREATE_PROJECT] + }) + + workspaceId = workspace.id + } else { + const defaultWorkspace = await this.prisma.workspaceMember.findFirst({ + where: { + userId: user.id, + workspace: { + isDefault: true + } + } + }) + workspaceId = defaultWorkspace.workspaceId + } + + const newProjectName = forkMetadata.name || project.name + + // Check if project with this name already exists for the user + if (await this.projectExists(newProjectName, workspaceId)) + throw new ConflictException( + constructErrorBody( + 'Project already exists', + `Project with name ${newProjectName} already exists in the selected workspace` + ) + ) + + const { privateKey, publicKey } = createKeyPair() + const userId = user.id + const newProjectId = v4() + const adminRole = await this.prisma.workspaceRole.findFirst({ + where: { + workspaceId, + hasAdminAuthority: true + } + }) + + // Create and return the project + const createNewProject = this.prisma.project.create({ + data: { + id: newProjectId, + name: newProjectName, + slug: await generateEntitySlug(newProjectName, 'PROJECT', this.prisma), + description: project.description, + storePrivateKey: + forkMetadata.storePrivateKey || project.storePrivateKey, + publicKey: publicKey, + privateKey: + forkMetadata.storePrivateKey || project.storePrivateKey + ? privateKey + : null, + accessLevel: project.accessLevel, + isForked: true, + forkedFromId: project.id, + workspaceId: workspaceId, + lastUpdatedById: userId + } + }) + + const addProjectToAdminRoleOfItsWorkspace = + this.prisma.workspaceRole.update({ + where: { + id: adminRole.id + }, + data: { + projects: { + create: { + project: { + connect: { + id: newProjectId + } + } + } + } + } + }) + + const copyProjectOp = await this.copyProjectData( + user, + { + id: project.id, + privateKey: project.privateKey + }, + { + id: newProjectId, + publicKey + }, + true + ) + + const [newProject] = await this.prisma.$transaction([ + createNewProject, + addProjectToAdminRoleOfItsWorkspace, + ...copyProjectOp + ]) + + await createEvent( + { + triggeredBy: user, + entity: newProject, + type: EventType.PROJECT_CREATED, + source: EventSource.PROJECT, + title: `Project created`, + metadata: { + projectId: newProject.id, + name: newProject.name, + workspaceId, + workspaceName: workspaceId + }, + workspaceId + }, + this.prisma + ) + + this.log.debug(`Created project ${newProject}`) + return newProject + } + + /** + * Unlinks a forked project from its parent project. + * + * @param user The user who is unlinking the project + * @param projectSlug The slug of the project to unlink + * @returns The updated project + * + * @throws BadRequestException If the project is not a forked project + * @throws UnauthorizedException If the user does not have the authority to update the project + */ + async unlinkParentOfFork( + user: AuthenticatedUser, + projectSlug: Project['slug'] + ) { + const project = + await this.authorizationService.authorizeUserAccessToProject({ + user, + entity: { slug: projectSlug }, + authorities: [Authority.UPDATE_PROJECT] + }) + const projectId = project.id + + await this.prisma.project.update({ + where: { + id: projectId + }, + data: { + isForked: false, + forkedFromId: null + } + }) + } + + /** + * Syncs a forked project with its parent project. + * + * @param user The user who is syncing the project + * @param projectSlug The slug of the project to sync + * @param hardSync Whether to do a hard sync or not. If true, all items in the + * forked project will be replaced with the items from the parent project. If + * false, only items that are not present in the forked project will be added + * from the parent project. + * + * @throws BadRequestException If the project is not a forked project + * @throws UnauthorizedException If the user does not have the authority to update the project + */ + async syncFork( + user: AuthenticatedUser, + projectSlug: Project['slug'], + hardSync: boolean + ) { + const project = + await this.authorizationService.authorizeUserAccessToProject({ + user, + entity: { slug: projectSlug }, + authorities: [Authority.UPDATE_PROJECT] + }) + const projectId = project.id + + if (!project.isForked || project.forkedFromId == null) { + throw new BadRequestException( + constructErrorBody( + 'Not a forked project', + `Project ${projectSlug} is not a forked project` + ) + ) + } + + const forkedFromProject = await this.prisma.project.findUnique({ + where: { + id: project.forkedFromId + } + }) + + const parentProject = + await this.authorizationService.authorizeUserAccessToProject({ + user, + entity: { slug: forkedFromProject.slug }, + authorities: [Authority.READ_PROJECT] + }) + + const copyProjectOp = await this.copyProjectData( + user, + { + id: parentProject.id, + privateKey: parentProject.privateKey + }, + { + id: projectId, + publicKey: project.publicKey + }, + hardSync + ) + + await this.prisma.$transaction(copyProjectOp) + } + + /** + * Deletes a project. + * @param user The user who is deleting the project + * @param projectSlug The slug of the project to delete + * + * @throws UnauthorizedException If the user does not have the authority to delete the project + */ + async deleteProject(user: AuthenticatedUser, projectSlug: Project['slug']) { + const project = + await this.authorizationService.authorizeUserAccessToProject({ + user, + entity: { slug: projectSlug }, + authorities: [Authority.DELETE_PROJECT] + }) + + const op = [] + + // Remove the fork relationships + op.push( + this.prisma.project.updateMany({ + where: { + forkedFromId: project.id + }, + data: { + isForked: false, + forkedFromId: null + } + }) + ) + + // Delete the project + op.push( + this.prisma.project.delete({ + where: { + id: project.id + } + }) + ) + + await this.prisma.$transaction(op) + + await createEvent( + { + triggeredBy: user, + type: EventType.PROJECT_DELETED, + source: EventSource.PROJECT, + entity: project, + title: `Project deleted`, + metadata: { + projectId: project.id, + name: project.name + }, + workspaceId: project.workspaceId + }, + this.prisma + ) + + this.log.debug(`Deleted project ${project}`) + } + + /** + * Gets all the forks of a project. + * + * @param user The user who is requesting the forks + * @param projectSlug The slug of the project to get forks for + * @param page The page number to get the forks for + * @param limit The number of forks to get per page + * @returns An object with two properties: `items` and `metadata`. + * `items` is an array of project objects that are forks of the given project, + * and `metadata` is the pagination metadata for the forks. + */ + async getAllProjectForks( + user: AuthenticatedUser, + projectSlug: Project['slug'], + page: number, + limit: number + ) { + const project = + await this.authorizationService.authorizeUserAccessToProject({ + user, + entity: { slug: projectSlug }, + authorities: [Authority.READ_PROJECT] + }) + const projectId = project.id + + const forks = await this.prisma.project.findMany({ + where: { + forkedFromId: projectId + } + }) + + const forksAllowed = forks.filter(async (fork) => { + const allowed = + (await this.authorizationService.authorizeUserAccessToProject({ + user, + entity: { slug: fork.slug }, + authorities: [Authority.READ_PROJECT] + })) != null + + return allowed + }) + + const items = forksAllowed.slice(page * limit, (page + 1) * limit) + + // Calculate metadata + const metadata = paginate( + forksAllowed.length, + `/project/${projectSlug}/forks`, + { + page, + limit: limitMaxItemsPerPage(limit) + } + ) + + return { items, metadata } + } + + /** + * Gets a project by slug. + * + * @param user The user who is requesting the project + * @param projectSlug The slug of the project to get + * @returns The project with secrets removed + * + * @throws UnauthorizedException If the user does not have the authority to read the project + */ + async getProject(user: AuthenticatedUser, projectSlug: Project['slug']) { + const project = + await this.authorizationService.authorizeUserAccessToProject({ + user, + entity: { slug: projectSlug }, + authorities: [Authority.READ_PROJECT] + }) + + delete project.secrets + + return await this.countEnvironmentsVariablesAndSecretsInProject( + project, + user + ) + } + + /** + * Gets all the projects in a workspace that the user has access to. + * + * @param user The user who is requesting the projects + * @param workspaceSlug The slug of the workspace to get the projects from + * @param page The page number to get the projects for + * @param limit The number of projects to get per page + * @param sort The field to sort the projects by + * @param order The order to sort the projects in + * @param search The search string to filter the projects by + * @returns An object with two properties: `items` and `metadata`. + * `items` is an array of project objects that match the given criteria, + * and `metadata` is an object with pagination metadata. + */ + async getProjectsOfWorkspace( + user: AuthenticatedUser, + workspaceSlug: Workspace['slug'], + page: number, + limit: number, + sort: string, + order: string, + search: string + ) { + const workspace = + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, + entity: { slug: workspaceSlug }, + authorities: [Authority.READ_PROJECT] + }) + const workspaceId = workspace.id + + //fetch projects with required properties + const projects = ( + await this.prisma.project.findMany({ + skip: page * limit, + take: limitMaxItemsPerPage(limit), + orderBy: { + [sort]: order + }, + where: { + workspaceId, + OR: [ + { + name: { + contains: search + } + }, + { + description: { + contains: search + } + } + ], + workspace: { + members: { + some: { + userId: user.id, + roles: { + some: { + role: { + authorities: { + hasSome: [ + Authority.WORKSPACE_ADMIN, + Authority.READ_PROJECT + ] + } + } + } + } + } + } + } + } + }) + ).map((project) => excludeFields(project, 'privateKey', 'publicKey')) + + const items = await Promise.all( + projects.map(async (project) => + this.countEnvironmentsVariablesAndSecretsInProject(project, user) + ) + ) + + //calculate metadata + const totalCount = await this.prisma.project.count({ + where: { + workspaceId, + OR: [ + { + name: { + contains: search + } + }, + { + description: { + contains: search + } + } + ], + workspace: { + members: { + some: { + userId: user.id + } + } + } + } + }) + + const metadata = paginate(totalCount, `/project/all/${workspaceSlug}`, { + page, + limit, + sort, + order, + search + }) + + return { items, metadata } + } + + /** + * Checks if a project with a given name exists in a workspace. + * + * @param projectName The name of the project to check + * @param workspaceId The ID of the workspace to check in + * @returns true if the project exists, false otherwise + */ + private async projectExists( + projectName: string, + workspaceId: Workspace['id'] + ): Promise { + return ( + (await this.prisma.workspaceMember.count({ + where: { + workspaceId, + workspace: { + projects: { + some: { + name: projectName + } + } + } + } + })) > 0 + ) + } + + /** + * Copies the project data from one project to another project. + * + * @param user The user who is performing the copy operation + * @param fromProject The project from which the data is being copied + * @param toProject The project to which the data is being copied + * @param hardCopy If true, replace all the data in the toProject with the fromProject, + * otherwise, only add the items in the fromProject that are not already present in the toProject. + * @returns An array of database operations that need to be performed to copy the data. + */ + private async copyProjectData( + user: AuthenticatedUser, + fromProject: { + id: Project['id'] + privateKey: string // Need the private key to decrypt the secrets + }, + toProject: { + id: Project['id'] + publicKey: string // Need the public key to encrypt the secrets + }, + // hardCopy = true: Replace everything in the toProject with the fromProject + // hardCopy = false: Only add those items in the toProject that are not already present in it + hardCopy: boolean = false + ) { + // This field will be populated if hardCopy is true + // When we are doing a hard copy, we need to delete all the + // items in the toProject that are already present in it + const deleteOps = [] + + // Get all the environments that belongs to the parent project + // and replicate them for the new project + const createEnvironmentOps = [] + const envNameToIdMap = {} + + // These fields will be populated if hardCopy is false + // When we are doing a soft copy, we would only like to add those + // items in the toProject that are not already present in it with + // comparison to the fromProject + const toProjectEnvironments: Set = new Set() + const toProjectSecrets: Set = new Set() + const toProjectVariables: Set = new Set() + + if (!hardCopy) { + const [environments, secrets, variables] = await this.prisma.$transaction( + [ + this.prisma.environment.findMany({ + where: { + projectId: toProject.id + } + }), + this.prisma.secret.findMany({ + where: { + projectId: toProject.id + } + }), + this.prisma.variable.findMany({ + where: { + projectId: toProject.id + } + }) + ] + ) + + environments.forEach((env) => { + envNameToIdMap[env.name] = env.id + toProjectEnvironments.add(env.name) + }) + + secrets.forEach((secret) => { + toProjectSecrets.add(secret.name) + }) + + variables.forEach((variable) => { + toProjectVariables.add(variable.name) + }) + } else { + deleteOps.push( + this.prisma.environment.deleteMany({ + where: { + projectId: toProject.id + } + }) + ) + + deleteOps.push( + this.prisma.secret.deleteMany({ + where: { + projectId: toProject.id + } + }) + ) + + deleteOps.push( + this.prisma.variable.deleteMany({ + where: { + projectId: toProject.id + } + }) + ) + } + + // We want to find all such environments in the fromProject that + // is not present in the toProject. You can think of this as a set + // difference operation. + // In case of a hard copy, we would just copy all the environments + // since toProjectEnvironments will be empty. + const missingEnvironments = await this.prisma.environment.findMany({ + where: { + projectId: fromProject.id, + name: { + notIn: Array.from(toProjectEnvironments) + } + } + }) + + // For all the new environments that we are creating, we want to map + // the name of the environment to the id of the newly created environment + for (const environment of missingEnvironments) { + const newEnvironmentId = v4() + envNameToIdMap[environment.name] = newEnvironmentId + + createEnvironmentOps.push( + this.prisma.environment.create({ + data: { + id: newEnvironmentId, + name: environment.name, + slug: await generateEntitySlug( + environment.name, + 'ENVIRONMENT', + this.prisma + ), + description: environment.description, + projectId: toProject.id, + lastUpdatedById: user.id + } + }) + ) + } + + const createSecretOps = [] + + // Get all the secrets that belongs to the parent project and + // replicate them for the new project. This too is a set difference + // operation. + const secrets = await this.prisma.secret.findMany({ + where: { + projectId: fromProject.id, + name: { + notIn: Array.from(toProjectSecrets) + } + }, + include: { + versions: { + include: { + environment: { + select: { + name: true + } + } + } + } + } + }) + + for (const secret of secrets) { + const secretVersions = secret.versions.map(async (version) => ({ + value: await encrypt( + toProject.publicKey, + await decrypt(fromProject.privateKey, version.value) + ), + version: version.version, + environmentName: version.environment.name + })) + + createSecretOps.push( + this.prisma.secret.create({ + data: { + name: secret.name, + slug: await generateEntitySlug(secret.name, 'SECRET', this.prisma), + projectId: toProject.id, + lastUpdatedById: user.id, + note: secret.note, + rotateAt: secret.rotateAt, + versions: { + create: await Promise.all( + secretVersions.map(async (secretVersion) => { + const awaitedSecretVersion = await secretVersion + return { + value: awaitedSecretVersion.value, + version: awaitedSecretVersion.version, + environmentId: + envNameToIdMap[awaitedSecretVersion.environmentName], + createdById: user.id + } + }) + ) + } + } + }) + ) + } + + // Get all the variables that belongs to the parent project and + // replicate them for the new project + const createVariableOps = [] + + const variables = await this.prisma.variable.findMany({ + where: { + projectId: fromProject.id, + name: { + notIn: Array.from(toProjectVariables) + } + }, + include: { + versions: { + include: { + environment: { + select: { + name: true + } + } + } + } + } + }) + + for (const variable of variables) { + createVariableOps.push( + this.prisma.variable.create({ + data: { + name: variable.name, + slug: await generateEntitySlug( + variable.name, + 'VARIABLE', + this.prisma + ), + projectId: toProject.id, + lastUpdatedById: user.id, + note: variable.note, + versions: { + create: variable.versions.map((version) => ({ + value: version.value, + version: version.version, + createdById: user.id, + environmentId: envNameToIdMap[version.environment.name] + })) + } + } + }) + ) + } + + return [ + ...deleteOps, + ...createEnvironmentOps, + ...createSecretOps, + ...createVariableOps + ] + } + + /** + * Updates the key pair of a project. + * + * @param project The project to update + * @param oldPrivateKey The old private key of the project + * @param storePrivateKey Whether to store the new private key in the database + * + * @returns An object with three properties: + * - `txs`: an array of database operations that need to be performed to update the project + * - `newPrivateKey`: the new private key of the project + * - `newPublicKey`: the new public key of the project + */ + private async updateProjectKeyPair( + project: ProjectWithSecrets, + oldPrivateKey: string, + storePrivateKey: boolean + ) { + // A new key pair can be generated only if: + // - The existing private key is provided + // - Or, the private key was stored + const { privateKey: newPrivateKey, publicKey: newPublicKey } = + createKeyPair() + + const txs = [] + + // Re-hash all secrets + for (const secret of project.secrets) { + const versions = await this.prisma.secretVersion.findMany({ + where: { + secretId: secret.id + } + }) + + const updatedVersions: Partial[] = [] + + // First, encrypt the values with the new key and store + // them in a temporary array + for (const version of versions) { + updatedVersions.push({ + id: version.id, + value: await encrypt( + await decrypt(oldPrivateKey, version.value), + newPrivateKey + ) + }) + } + + // Apply the changes to the database + for (const version of updatedVersions) { + txs.push( + this.prisma.secretVersion.update({ + where: { + id: version.id + }, + data: { + value: version.value + } + }) + ) + } + } + + // Update the project with the new key pair + txs.push( + this.prisma.project.update({ + where: { + id: project.id + }, + data: { + publicKey: newPublicKey, + privateKey: storePrivateKey ? newPrivateKey : null + } + }) + ) + + return { txs, newPrivateKey, newPublicKey } + } + + private async countEnvironmentsVariablesAndSecretsInProject( + project: Partial, + user: AuthenticatedUser + ): Promise { + let environmentCount = 0 + let variableCount = 0 + let secretCount = 0 + // When we later implement RBAC for environments, we would need to updated + // this code to only include environments like we do while fetching projects. + + // What would be even better is, we should fetch environments directly. And then, + // accumulate the projects into a set of projects. And then, return that set along + // with the required data. + const allEnvs = await this.prisma.environment.findMany({ + where: { projectId: project.id } + }) + + // This entire block will become invalid after RBAC for environments are implemented + const envPromises = allEnvs.map(async (env) => { + const hasRequiredPermission = + await this.authorizationService.authorizeUserAccessToEnvironment({ + user, + entity: { slug: env.slug }, + authorities: + project.accessLevel == ProjectAccessLevel.GLOBAL + ? [] + : [ + Authority.READ_ENVIRONMENT, + Authority.READ_SECRET, + Authority.READ_VARIABLE + ] + }) + if (hasRequiredPermission) { + environmentCount += 1 + + const fetchSecretCount = this.prisma.secret.count({ + where: { + projectId: project.id, + versions: { some: { environmentId: env.id } } + } + }) + + const fetchVariableCount = this.prisma.variable.count({ + where: { + projectId: project.id, + versions: { some: { environmentId: env.id } } + } + }) + + return this.prisma.$transaction([fetchSecretCount, fetchVariableCount]) + } + return [0, 0] + }) + const counts = await Promise.all(envPromises) + secretCount = counts.reduce((sum, [secretCount]) => sum + secretCount, 0) + variableCount = counts.reduce( + (sum, [, variableCount]) => sum + variableCount, + 0 + ) + return { + ...project, + environmentCount, + variableCount, + secretCount + } + } +} diff --git a/apps/api/src/secret/controller/secret.controller.spec.ts b/apps/api/src/secret/controller/secret.controller.spec.ts index 353b91140..3c0379c1d 100644 --- a/apps/api/src/secret/controller/secret.controller.spec.ts +++ b/apps/api/src/secret/controller/secret.controller.spec.ts @@ -1,14 +1,13 @@ import { Test, TestingModule } from '@nestjs/testing' import { SecretController } from './secret.controller' -import { MAIL_SERVICE } from '@/mail/services/interface.service' -import { MockMailService } from '@/mail/services/mock.service' import { SecretService } from '../service/secret.service' import { PrismaService } from '@/prisma/prisma.service' import { mockDeep } from 'jest-mock-extended' import { REDIS_CLIENT } from '@/provider/redis.provider' import { RedisClientType } from 'redis' import { ProviderModule } from '@/provider/provider.module' -import { AuthorityCheckerService } from '@/common/authority-checker.service' +import { AuthorizationService } from '@/auth/service/authorization.service' +import { AuthorityCheckerService } from '@/auth/service/authority-checker.service' import { CommonModule } from '@/common/common.module' describe('SecretController', () => { @@ -20,11 +19,8 @@ describe('SecretController', () => { controllers: [SecretController], providers: [ PrismaService, - { - provide: MAIL_SERVICE, - useClass: MockMailService - }, SecretService, + AuthorizationService, AuthorityCheckerService ] }) diff --git a/apps/api/src/secret/controller/secret.controller.ts b/apps/api/src/secret/controller/secret.controller.ts index e2b03191d..7f3e757eb 100644 --- a/apps/api/src/secret/controller/secret.controller.ts +++ b/apps/api/src/secret/controller/secret.controller.ts @@ -10,10 +10,11 @@ import { } from '@nestjs/common' import { SecretService } from '../service/secret.service' import { CurrentUser } from '@/decorators/user.decorator' -import { Authority, User } from '@prisma/client' +import { Authority } from '@prisma/client' import { CreateSecret } from '../dto/create.secret/create.secret' import { UpdateSecret } from '../dto/update.secret/update.secret' import { RequiredApiKeyAuthorities } from '@/decorators/required-api-key-authorities.decorator' +import { AuthenticatedUser } from '@/user/user.types' @Controller('secret') export class SecretController { @@ -22,7 +23,7 @@ export class SecretController { @Post(':projectSlug') @RequiredApiKeyAuthorities(Authority.CREATE_SECRET) async createSecret( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('projectSlug') projectSlug: string, @Body() dto: CreateSecret ) { @@ -32,7 +33,7 @@ export class SecretController { @Put(':secretSlug') @RequiredApiKeyAuthorities(Authority.UPDATE_SECRET) async updateSecret( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('secretSlug') secretSlug: string, @Body() dto: UpdateSecret ) { @@ -42,7 +43,7 @@ export class SecretController { @Put(':secretSlug/rollback/:rollbackVersion') @RequiredApiKeyAuthorities(Authority.UPDATE_SECRET) async rollbackSecret( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('secretSlug') secretSlug: string, @Query('environmentSlug') environmentSlug: string, @Param('rollbackVersion') rollbackVersion: number @@ -58,7 +59,7 @@ export class SecretController { @Delete(':secretSlug') @RequiredApiKeyAuthorities(Authority.DELETE_SECRET) async deleteSecret( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('secretSlug') secretSlug: string ) { return await this.secretService.deleteSecret(user, secretSlug) @@ -67,7 +68,7 @@ export class SecretController { @Get('/:projectSlug') @RequiredApiKeyAuthorities(Authority.READ_SECRET) async getAllSecretsOfProject( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('projectSlug') projectSlug: string, @Query('page') page: number = 0, @Query('limit') limit: number = 10, @@ -91,7 +92,7 @@ export class SecretController { @Get(':secretSlug/revisions/:environmentSlug') @RequiredApiKeyAuthorities(Authority.READ_SECRET) async getRevisionsOfSecret( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('secretSlug') secretSlug: string, @Param('environmentSlug') environmentSlug: string, @Query('decryptValue') decryptValue: boolean = false, diff --git a/apps/api/src/secret/secret.e2e.spec.ts b/apps/api/src/secret/secret.e2e.spec.ts index 0dbdaadcf..917c0f25a 100644 --- a/apps/api/src/secret/secret.e2e.spec.ts +++ b/apps/api/src/secret/secret.e2e.spec.ts @@ -1,1161 +1,1163 @@ -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, - ProjectAccessLevel, - 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 { EnvironmentService } from '@/environment/service/environment.service' -import { SecretService } from './service/secret.service' -import { EventService } from '@/event/service/event.service' -import { REDIS_CLIENT } from '@/provider/redis.provider' -import { RedisClientType } from 'redis' -import { mockDeep } from 'jest-mock-extended' -import { UserService } from '@/user/service/user.service' -import { UserModule } from '@/user/user.module' -import { QueryTransformPipe } from '@/common/pipes/query.transform.pipe' -import { fetchEvents } from '@/common/event' -import { ValidationPipe } from '@nestjs/common' - -describe('Secret Controller Tests', () => { - let app: NestFastifyApplication - let prisma: PrismaService - let projectService: ProjectService - let workspaceService: WorkspaceService - let environmentService: EnvironmentService - let secretService: SecretService - let eventService: EventService - let userService: UserService - let user1: User, user2: User - let workspace1: Workspace - let project1: Project, project2: Project - let environment1: Environment - let secret1: Secret - - beforeAll(async () => { - const moduleRef = await Test.createTestingModule({ - imports: [ - AppModule, - EventModule, - WorkspaceModule, - ProjectModule, - EnvironmentModule, - SecretModule, - UserModule - ] - }) - .overrideProvider(MAIL_SERVICE) - .useClass(MockMailService) - .overrideProvider(REDIS_CLIENT) - .useValue(mockDeep()) - .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) - eventService = moduleRef.get(EventService) - userService = moduleRef.get(UserService) - - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - transform: true - }), - new QueryTransformPipe() - ) - - await app.init() - await app.getHttpAdapter().getInstance().ready() - }) - - beforeEach(async () => { - const createUser1 = await userService.createUser({ - email: 'johndoe@keyshade.xyz', - name: 'John Doe', - isOnboardingFinished: true - }) - - const createUser2 = await userService.createUser({ - email: 'janedoe@keyshade.xyz', - name: 'Jane Doe', - isOnboardingFinished: true - }) - - workspace1 = createUser1.defaultWorkspace - - delete createUser1.defaultWorkspace - delete createUser2.defaultWorkspace - - user1 = createUser1 - user2 = createUser2 - - project1 = (await projectService.createProject(user1, workspace1.slug, { - name: 'Project 1', - description: 'Project 1 description', - storePrivateKey: true, - accessLevel: ProjectAccessLevel.PRIVATE, - environments: [ - { - name: 'Environment 1', - description: 'Environment 1 description' - }, - { - name: 'Environment 2', - description: 'Environment 2 description' - } - ] - })) as Project - - project2 = (await projectService.createProject(user1, workspace1.slug, { - name: 'Project 2', - description: 'Project 2 description', - storePrivateKey: false, - accessLevel: ProjectAccessLevel.PRIVATE, - environments: [ - { - name: 'Environment 1', - description: 'Environment 1 description' - } - ] - })) as Project - - environment1 = await prisma.environment.findFirst({ - where: { - projectId: project1.id, - name: 'Environment 1' - } - }) - - secret1 = ( - await secretService.createSecret( - user1, - { - name: 'Secret 1', - rotateAfter: '24', - note: 'Secret 1 note', - entries: [ - { - environmentSlug: environment1.slug, - value: 'Secret 1 value' - } - ] - }, - project1.slug - ) - ).secret as Secret - }) - - afterEach(async () => { - await prisma.$transaction([ - prisma.user.deleteMany(), - prisma.workspace.deleteMany() - ]) - }) - - it('should be defined', async () => { - expect(app).toBeDefined() - expect(prisma).toBeDefined() - expect(projectService).toBeDefined() - expect(workspaceService).toBeDefined() - expect(environmentService).toBeDefined() - }) - - describe('Create Secret Tests', () => { - it('should be able to create a secret', async () => { - const response = await app.inject({ - method: 'POST', - url: `/secret/${project1.slug}`, - payload: { - name: 'Secret 2', - note: 'Secret 2 note', - entries: [ - { - value: 'Secret 2 value', - environmentSlug: environment1.slug - } - ], - rotateAfter: '24' - }, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(201) - - const body = response.json() - - expect(body).toBeDefined() - expect(body.secret.name).toBe('Secret 2') - expect(body.secret.note).toBe('Secret 2 note') - expect(body.secret.projectId).toBe(project1.id) - expect(body.values.length).toBe(1) - expect(body.values[0].value).not.toBe('Secret 2 value') - expect(body.values[0].environment.id).toBe(environment1.id) - expect(body.values[0].environment.slug).toBe(environment1.slug) - }) - - 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) - expect(secretVersion.environmentId).toBe(environment1.id) - }) - - it('should not be able to create secret with empty name', async () => { - const response = await app.inject({ - method: 'POST', - url: `/secret/${project1.slug}`, - payload: { - name: ' ' - }, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(400) - - const messages = response.json().message - - expect(messages).toHaveLength(1) - expect(messages[0]).toEqual('name should not be empty') - }) - - 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.slug}`, - payload: { - name: 'Secret 3', - rotateAfter: '24', - entries: [ - { - value: 'Secret 3 value', - environmentSlug: 'non-existing-environment-slug' - } - ] - }, - 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.slug}`, - payload: { - name: 'Secret 3', - rotateAfter: '24' - }, - headers: { - 'x-e2e-user-email': user2.email - } - }) - - expect(response.statusCode).toBe(401) - }) - - it('should not be able to create a duplicate secret in the same project', async () => { - const response = await app.inject({ - method: 'POST', - url: `/secret/${project1.slug}`, - payload: { - name: 'Secret 1', - rotateAfter: '24' - }, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(409) - }) - - it('should have created a SECRET_ADDED event', async () => { - const response = await fetchEvents( - eventService, - user1, - workspace1.slug, - EventSource.SECRET - ) - - const event = response.items[0] - - expect(event.source).toBe(EventSource.SECRET) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.SECRET_ADDED) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() - }) - }) - - describe('Update Secret Tests', () => { - it('should not be able to update a non-existing secret', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/secret/non-existing-secret-slug`, - payload: { - name: 'Updated Secret 1', - rotateAfter: '24' - }, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(404) - }) - - it('should not be able to update secret with empty name', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/secret/${secret1.slug}`, - payload: { - name: ' ' - }, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(400) - - const messages = response.json().message - - expect(messages).toHaveLength(1) - expect(messages[0]).toEqual('name should not be empty') - }) - - it('should not be able to update secret with empty name', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/secret/${secret1.slug}`, - payload: { - name: ' ' - }, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(400) - - const messages = response.json().message - - expect(messages).toHaveLength(1) - expect(messages[0]).toEqual('name should not be empty') - }) - - it('should be able to update the secret name and note without creating a new version', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/secret/${secret1.slug}`, - payload: { - name: 'Updated Secret 1', - note: 'Updated Secret 1 note' - }, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(200) - expect(response.json().secret.name).toEqual('Updated Secret 1') - expect(response.json().secret.note).toEqual('Updated Secret 1 note') - expect(response.json().updatedVersions.length).toBe(0) - - const secretVersion = await prisma.secretVersion.findMany({ - where: { - secretId: secret1.id - }, - include: { - environment: true - } - }) - - expect(secretVersion.length).toBe(1) - expect(secretVersion[0].environment.id).toBe(environment1.id) - expect(secretVersion[0].environment.slug).toBe(environment1.slug) - }) - - it('should create a new version if the value is updated', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/secret/${secret1.slug}`, - payload: { - entries: [ - { - value: 'Updated Secret 1 value', - environmentSlug: environment1.slug - } - ] - }, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(200) - expect(response.json().updatedVersions.length).toBe(1) - - const secretVersion = await prisma.secretVersion.findMany({ - where: { - secretId: secret1.id, - environmentId: environment1.id - }, - include: { - environment: true - } - }) - - expect(secretVersion.length).toBe(2) - }) - - it('should fail to create a new version if the environment does not exist', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/secret/${secret1.slug}`, - payload: { - entries: [ - { - value: 'Updated Secret 1 value', - environmentSlug: 'non-existing-environment-slug' - } - ] - }, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(404) - }) - - it('should have created a SECRET_UPDATED event', async () => { - // Update a secret - await secretService.updateSecret(user1, secret1.slug, { - name: 'Updated Secret 1' - }) - - const response = await fetchEvents( - eventService, - user1, - workspace1.slug, - EventSource.SECRET - ) - - const event = response.items[0] - - expect(event.source).toBe(EventSource.SECRET) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.SECRET_UPDATED) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBe(secret1.id) - }) - }) - - describe('Rollback Tests', () => { - 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-slug/rollback/1?environmentSlug=${environment1.slug}`, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(404) - }) - - 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.slug}/rollback/1?environmentSlug=${environment1.slug}`, - headers: { - 'x-e2e-user-email': user2.email - } - }) - - expect(response.statusCode).toBe(401) - }) - - it('should not be able to roll back to a non-existing version', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/secret/${secret1.slug}/rollback/2?environmentSlug=${environment1.slug}`, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(404) - }) - - it('should not be able to roll back if the secret has no versions', async () => { - await prisma.secretVersion.deleteMany({ - where: { - secretId: secret1.id - } - }) - - const response = await app.inject({ - method: 'PUT', - url: `/secret/${secret1.slug}/rollback/1?environmentSlug=${environment1.slug}`, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(404) - }) - - it('should not create a secret version entity if value-environmentSlug is not provided during creation', async () => { - const secret = ( - await secretService.createSecret( - user1, - { - name: 'Secret 4', - note: 'Secret 4 note', - rotateAfter: '24' - }, - project1.slug - ) - ).secret - - const secretVersion = await prisma.secretVersion.findMany({ - where: { - secretId: secret.id - } - }) - - expect(secretVersion.length).toBe(0) - }) - - it('should be able to roll back a secret', async () => { - // Creating a few versions first - await secretService.updateSecret(user1, secret1.slug, { - entries: [ - { - value: 'Updated Secret 1 value', - environmentSlug: environment1.slug - } - ] - }) - - await secretService.updateSecret(user1, secret1.slug, { - entries: [ - { - value: 'Updated Secret 1 value 2', - environmentSlug: environment1.slug - } - ] - }) - - let versions: SecretVersion[] - - // eslint-disable-next-line prefer-const - versions = await prisma.secretVersion.findMany({ - where: { - secretId: secret1.id - } - }) - - expect(versions.length).toBe(3) - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const response = await app.inject({ - method: 'PUT', - url: `/secret/${secret1.slug}/rollback/1?environmentSlug=${environment1.slug}`, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - // expect(response.json().count).toEqual(2) - - // versions = await prisma.secretVersion.findMany({ - // where: { - // secretId: secret1.id - // } - // }) - - // expect(versions.length).toBe(1) - }) - }) - - describe('Get All Secrets By Project Tests', () => { - it('should not be able to fetch decrypted secrets if the project does not store the private key', async () => { - // Fetch the environment of the project - const environment = await prisma.environment.findFirst({ - where: { - projectId: project2.id - } - }) - - await secretService.createSecret( - user1, - { - name: 'Secret 20', - entries: [ - { - environmentSlug: environment.slug, - value: 'Secret 20 value' - } - ], - rotateAfter: '24', - note: 'Secret 20 note' - }, - project2.slug - ) - - const response = await app.inject({ - method: 'GET', - url: `/secret/${project2.slug}?decryptValue=true`, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(400) - }) - - it('should be able to fetch all secrets', async () => { - const response = await app.inject({ - method: 'GET', - url: `/secret/${project1.slug}?page=0&limit=10`, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(200) - expect(response.json().items.length).toBe(1) - - const { secret, values } = response.json().items[0] - expect(secret).toStrictEqual({ - id: secret1.id, - name: secret1.name, - slug: secret1.slug, - note: secret1.note, - projectId: project1.id, - lastUpdatedById: secret1.lastUpdatedById, - lastUpdatedBy: { - id: user1.id, - name: user1.name, - profilePictureUrl: user1.profilePictureUrl - }, - createdAt: secret1.createdAt.toISOString(), - updatedAt: secret1.updatedAt.toISOString(), - rotateAfter: secret1.rotateAfter, - rotateAt: secret1.rotateAt.toISOString() - }) - expect(values.length).toBe(1) - - const value = values[0] - expect(value.version).toBe(1) - expect(value.environment.id).toBe(environment1.id) - expect(value.environment.slug).toBe(environment1.slug) - expect(value.environment.name).toBe(environment1.name) - expect(value.value).not.toEqual('Secret 1 value') - - //check metadata - const metadata = response.json().metadata - expect(metadata.totalCount).toEqual(1) - expect(metadata.links.self).toEqual( - `/secret/${project1.slug}?decryptValue=false&page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.first).toEqual( - `/secret/${project1.slug}?decryptValue=false&page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.previous).toBeNull() - expect(metadata.links.next).toBeNull() - expect(metadata.links.last).toEqual( - `/secret/${project1.slug}?decryptValue=false&page=0&limit=10&sort=name&order=asc&search=` - ) - }) - - it('should be able to fetch only new versions of secrets', async () => { - // Update secret1 - await secretService.updateSecret(user1, secret1.slug, { - entries: [ - { - value: 'Secret new 1 value', - environmentSlug: environment1.slug - } - ] - }) - - const response = await app.inject({ - method: 'GET', - url: `/secret/${project1.slug}?page=0&limit=10`, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(200) - expect(response.json().items.length).toBe(1) - - const { secret, values } = response.json().items[0] - - expect(secret).toStrictEqual({ - id: secret1.id, - name: secret1.name, - slug: secret1.slug, - note: secret1.note, - projectId: project1.id, - lastUpdatedById: secret1.lastUpdatedById, - lastUpdatedBy: { - id: user1.id, - name: user1.name, - profilePictureUrl: user1.profilePictureUrl - }, - createdAt: secret1.createdAt.toISOString(), - updatedAt: expect.any(String), - rotateAfter: secret1.rotateAfter, - rotateAt: secret1.rotateAt.toISOString() - }) - expect(values.length).toBe(1) - - const value = values[0] - expect(value.version).toBe(2) - expect(value.environment.id).toBe(environment1.id) - expect(value.environment.slug).toBe(environment1.slug) - expect(value.environment.name).toBe(environment1.name) - expect(value.value).not.toEqual('Secret 1 new value') - - //check metadata - const metadata = response.json().metadata - expect(metadata.totalCount).toEqual(1) - expect(metadata.links.self).toEqual( - `/secret/${project1.slug}?decryptValue=false&page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.first).toEqual( - `/secret/${project1.slug}?decryptValue=false&page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.previous).toBeNull() - expect(metadata.links.next).toBeNull() - expect(metadata.links.last).toEqual( - `/secret/${project1.slug}?decryptValue=false&page=0&limit=10&sort=name&order=asc&search=` - ) - }) - - it('should be able to fetch all secrets decrypted', async () => { - const response = await app.inject({ - method: 'GET', - url: `/secret/${project1.slug}?decryptValue=true&page=0&limit=10`, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(200) - expect(response.json().items.length).toBe(1) - - const { secret, values } = response.json().items[0] - expect(secret).toStrictEqual({ - id: secret1.id, - name: secret1.name, - slug: secret1.slug, - note: secret1.note, - projectId: project1.id, - lastUpdatedById: secret1.lastUpdatedById, - lastUpdatedBy: { - id: user1.id, - name: user1.name, - profilePictureUrl: user1.profilePictureUrl - }, - createdAt: secret1.createdAt.toISOString(), - updatedAt: secret1.updatedAt.toISOString(), - rotateAfter: secret1.rotateAfter, - rotateAt: secret1.rotateAt.toISOString() - }) - expect(values.length).toBe(1) - - const value = values[0] - expect(value.version).toBe(1) - expect(value.environment.id).toBe(environment1.id) - expect(value.environment.slug).toBe(environment1.slug) - expect(value.environment.name).toBe(environment1.name) - expect(value.value).toEqual('Secret 1 value') - - //check metadata - const metadata = response.json().metadata - expect(metadata.totalCount).toEqual(1) - expect(metadata.links.self).toEqual( - `/secret/${project1.slug}?decryptValue=true&page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.first).toEqual( - `/secret/${project1.slug}?decryptValue=true&page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.previous).toBeNull() - expect(metadata.links.next).toBeNull() - expect(metadata.links.last).toEqual( - `/secret/${project1.slug}?decryptValue=true&page=0&limit=10&sort=name&order=asc&search=` - ) - }) - - 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/${project2.slug}?decryptValue=true`, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(400) - }) - - 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/${project1.slug}?decryptValue=true`, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(404) - - 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/${project1.slug}`, - headers: { - 'x-e2e-user-email': user2.email - } - }) - - expect(response.statusCode).toBe(401) - }) - - 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/non-existing-project-slug`, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(404) - }) - }) - - describe('Delete Secret Tests', () => { - it('should not be able to delete a non-existing secret', async () => { - const response = await app.inject({ - method: 'DELETE', - url: `/secret/non-existing-secret-slug`, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(404) - }) - - 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.slug}`, - headers: { - 'x-e2e-user-email': user2.email - } - }) - - expect(response.statusCode).toBe(401) - }) - - it('should be able to delete a secret', async () => { - const response = await app.inject({ - method: 'DELETE', - url: `/secret/${secret1.slug}`, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(200) - }) - - it('should have created a SECRET_DELETED event', async () => { - // Delete a secret - await secretService.deleteSecret(user1, secret1.slug) - - const response = await fetchEvents( - eventService, - user1, - workspace1.slug, - EventSource.SECRET - ) - - const event = response.items[0] - - expect(event.source).toBe(EventSource.SECRET) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.SECRET_DELETED) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBe(secret1.id) - }) - }) - - describe('Revisions Tests', () => { - it('should be able to fetch all revisions of secrets', async () => { - // create two more entries,totalling three versions - // checks if its able to fetch multiple revisions - await secretService.updateSecret(user1, secret1.slug, { - entries: [ - { - value: 'Updated Secret 1 value', - environmentSlug: environment1.slug - } - ] - }) - - await secretService.updateSecret(user1, secret1.slug, { - entries: [ - { - value: 'Updated Secret 1 value 2', - environmentSlug: environment1.slug - } - ] - }) - - const response = await app.inject({ - method: 'GET', - url: `/secret/${secret1.slug}/revisions/${environment1.slug}`, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(200) - expect(response.json().items).toHaveLength(3) - }) - - it('should return [] if the secret has no revision', async () => { - //returns [] if secret has no revision - await prisma.secretVersion.deleteMany({ - where: { - secretId: secret1.id - } - }) - - const response = await app.inject({ - method: 'GET', - url: `/secret/${secret1.slug}/revisions/${environment1.slug}`, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(200) - expect(response.json().items).toHaveLength(0) - }) - - it('should return error if secret does not exist', async () => { - const response = await app.inject({ - method: 'GET', - url: `/secret/9999/revisions/${environment1.slug}`, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(404) - }) - - it('should return error if environment does not exist', async () => { - const response = await app.inject({ - method: 'GET', - url: `/secret/${secret1.slug}/revisions/9999`, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(404) - }) - - it('returns error if secret is not accessible', async () => { - const response = await app.inject({ - method: 'GET', - url: `/secret/${secret1.slug}/revisions/${environment1.slug}`, - headers: { - 'x-e2e-user-email': user2.email - } - }) - - expect(response.statusCode).toBe(401) - }) - }) - - describe('Rotate Secrets Tests', () => { - it('should have not created a new secret version when there is no rotation defined', async () => { - const secretWithoutRotation = ( - await secretService.createSecret( - user1, - { - name: 'Secret', - note: 'Secret note', - rotateAfter: 'never', - entries: [ - { - environmentSlug: environment1.slug, - value: 'Secret value' - } - ] - }, - project1.slug - ) - ).secret as Secret - - await secretService.rotateSecrets() - - const secretVersion = await prisma.secretVersion.findFirst({ - where: { - secretId: secretWithoutRotation.id, - environmentId: environment1.id - }, - orderBy: { - version: 'desc' - }, - take: 1 - }) - - expect(secretVersion).toBeDefined() - expect(secretVersion.version).toBe(1) - expect(secretVersion.environmentId).toBe(environment1.id) - }) - - it('should have not created a new secret version when rotation is not due', async () => { - await secretService.rotateSecrets() - - const secretVersion = await prisma.secretVersion.findFirst({ - where: { - secretId: secret1.id, - environmentId: environment1.id - }, - orderBy: { - version: 'desc' - }, - take: 1 - }) - - expect(secretVersion).toBeDefined() - expect(secretVersion.version).toBe(1) - expect(secretVersion.environmentId).toBe(environment1.id) - }) - - it('should have created a new secret version when rotation is due', async () => { - const currentTime = new Date() - - currentTime.setHours(currentTime.getHours() + secret1.rotateAfter) - - await secretService.rotateSecrets(currentTime) - - const secretVersion = await prisma.secretVersion.findFirst({ - where: { - secretId: secret1.id, - environmentId: environment1.id - }, - orderBy: { - version: 'desc' - }, - take: 1 - }) - - expect(secretVersion).toBeDefined() - expect(secretVersion.version).toBe(2) - expect(secretVersion.environmentId).toBe(environment1.id) - }) - - it('should have created a SECRET_UPDATED event when rotation is due', async () => { - const currentTime = new Date() - - currentTime.setHours(currentTime.getHours() + secret1.rotateAfter) - - await secretService.rotateSecrets(currentTime) - - const events = await fetchEvents( - eventService, - user1, - workspace1.slug, - EventSource.SECRET - ) - - const event = events.items[0] - - expect(event.source).toBe(EventSource.SECRET) - expect(event.triggerer).toBe(EventTriggerer.SYSTEM) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.SECRET_UPDATED) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBe(secret1.id) - expect(event.title).toBe('Secret rotated') - }) - }) -}) +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, + ProjectAccessLevel, + Secret, + SecretVersion, + 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 { EnvironmentService } from '@/environment/service/environment.service' +import { SecretService } from './service/secret.service' +import { EventService } from '@/event/service/event.service' +import { REDIS_CLIENT } from '@/provider/redis.provider' +import { RedisClientType } from 'redis' +import { mockDeep } from 'jest-mock-extended' +import { UserService } from '@/user/service/user.service' +import { UserModule } from '@/user/user.module' +import { QueryTransformPipe } from '@/common/pipes/query.transform.pipe' +import { fetchEvents } from '@/common/event' +import { AuthenticatedUser } from '@/user/user.types' +import { ValidationPipe } from '@nestjs/common' + +describe('Secret Controller Tests', () => { + let app: NestFastifyApplication + let prisma: PrismaService + let projectService: ProjectService + let workspaceService: WorkspaceService + let environmentService: EnvironmentService + let secretService: SecretService + let eventService: EventService + let userService: UserService + let user1: AuthenticatedUser, user2: AuthenticatedUser + let workspace1: Workspace + let project1: Project, project2: Project + let environment1: Environment + let secret1: Secret + + const USER_IP_ADDRESS = '127.0.0.1' + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + AppModule, + EventModule, + WorkspaceModule, + ProjectModule, + EnvironmentModule, + SecretModule, + UserModule + ] + }) + .overrideProvider(MAIL_SERVICE) + .useClass(MockMailService) + .overrideProvider(REDIS_CLIENT) + .useValue(mockDeep()) + .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) + eventService = moduleRef.get(EventService) + userService = moduleRef.get(UserService) + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true + }), + new QueryTransformPipe() + ) + + await app.init() + await app.getHttpAdapter().getInstance().ready() + }) + + beforeEach(async () => { + const createUser1 = await userService.createUser({ + email: 'johndoe@keyshade.xyz', + name: 'John Doe', + isOnboardingFinished: true + }) + + const createUser2 = await userService.createUser({ + email: 'janedoe@keyshade.xyz', + name: 'Jane Doe', + isOnboardingFinished: true + }) + + workspace1 = createUser1.defaultWorkspace + + delete createUser1.defaultWorkspace + delete createUser2.defaultWorkspace + + user1 = { ...createUser1, ipAddress: USER_IP_ADDRESS } + user2 = { ...createUser2, ipAddress: USER_IP_ADDRESS } + + project1 = (await projectService.createProject(user1, workspace1.slug, { + name: 'Project 1', + description: 'Project 1 description', + storePrivateKey: true, + accessLevel: ProjectAccessLevel.PRIVATE, + environments: [ + { + name: 'Environment 1', + description: 'Environment 1 description' + }, + { + name: 'Environment 2', + description: 'Environment 2 description' + } + ] + })) as Project + + project2 = (await projectService.createProject(user1, workspace1.slug, { + name: 'Project 2', + description: 'Project 2 description', + storePrivateKey: false, + accessLevel: ProjectAccessLevel.PRIVATE, + environments: [ + { + name: 'Environment 1', + description: 'Environment 1 description' + } + ] + })) as Project + + environment1 = await prisma.environment.findFirst({ + where: { + projectId: project1.id, + name: 'Environment 1' + } + }) + + secret1 = ( + await secretService.createSecret( + user1, + { + name: 'Secret 1', + rotateAfter: '24', + note: 'Secret 1 note', + entries: [ + { + environmentSlug: environment1.slug, + value: 'Secret 1 value' + } + ] + }, + project1.slug + ) + ).secret as Secret + }) + + afterEach(async () => { + await prisma.$transaction([ + prisma.user.deleteMany(), + prisma.workspace.deleteMany() + ]) + }) + + it('should be defined', async () => { + expect(app).toBeDefined() + expect(prisma).toBeDefined() + expect(projectService).toBeDefined() + expect(workspaceService).toBeDefined() + expect(environmentService).toBeDefined() + }) + + describe('Create Secret Tests', () => { + it('should be able to create a secret', async () => { + const response = await app.inject({ + method: 'POST', + url: `/secret/${project1.slug}`, + payload: { + name: 'Secret 2', + note: 'Secret 2 note', + entries: [ + { + value: 'Secret 2 value', + environmentSlug: environment1.slug + } + ], + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(201) + + const body = response.json() + + expect(body).toBeDefined() + expect(body.secret.name).toBe('Secret 2') + expect(body.secret.note).toBe('Secret 2 note') + expect(body.secret.projectId).toBe(project1.id) + expect(body.values.length).toBe(1) + expect(body.values[0].value).not.toBe('Secret 2 value') + expect(body.values[0].environment.id).toBe(environment1.id) + expect(body.values[0].environment.slug).toBe(environment1.slug) + }) + + 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) + expect(secretVersion.environmentId).toBe(environment1.id) + }) + + it('should not be able to create secret with empty name', async () => { + const response = await app.inject({ + method: 'POST', + url: `/secret/${project1.slug}`, + payload: { + name: ' ' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(400) + + const messages = response.json().message + + expect(messages).toHaveLength(1) + expect(messages[0]).toEqual('name should not be empty') + }) + + 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.slug}`, + payload: { + name: 'Secret 3', + rotateAfter: '24', + entries: [ + { + value: 'Secret 3 value', + environmentSlug: 'non-existing-environment-slug' + } + ] + }, + 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.slug}`, + payload: { + name: 'Secret 3', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + }) + + it('should not be able to create a duplicate secret in the same project', async () => { + const response = await app.inject({ + method: 'POST', + url: `/secret/${project1.slug}`, + payload: { + name: 'Secret 1', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(409) + }) + + it('should have created a SECRET_ADDED event', async () => { + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.SECRET + ) + + const event = response.items[0] + + expect(event.source).toBe(EventSource.SECRET) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.SECRET_ADDED) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() + }) + }) + + describe('Update Secret Tests', () => { + it('should not be able to update a non-existing secret', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/non-existing-secret-slug`, + payload: { + name: 'Updated Secret 1', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + }) + + it('should not be able to update secret with empty name', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.slug}`, + payload: { + name: ' ' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(400) + + const messages = response.json().message + + expect(messages).toHaveLength(1) + expect(messages[0]).toEqual('name should not be empty') + }) + + it('should not be able to update secret with empty name', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.slug}`, + payload: { + name: ' ' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(400) + + const messages = response.json().message + + expect(messages).toHaveLength(1) + expect(messages[0]).toEqual('name should not be empty') + }) + + it('should be able to update the secret name and note without creating a new version', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.slug}`, + payload: { + name: 'Updated Secret 1', + note: 'Updated Secret 1 note' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().secret.name).toEqual('Updated Secret 1') + expect(response.json().secret.note).toEqual('Updated Secret 1 note') + expect(response.json().updatedVersions.length).toBe(0) + + const secretVersion = await prisma.secretVersion.findMany({ + where: { + secretId: secret1.id + }, + include: { + environment: true + } + }) + + expect(secretVersion.length).toBe(1) + expect(secretVersion[0].environment.id).toBe(environment1.id) + expect(secretVersion[0].environment.slug).toBe(environment1.slug) + }) + + it('should create a new version if the value is updated', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.slug}`, + payload: { + entries: [ + { + value: 'Updated Secret 1 value', + environmentSlug: environment1.slug + } + ] + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().updatedVersions.length).toBe(1) + + const secretVersion = await prisma.secretVersion.findMany({ + where: { + secretId: secret1.id, + environmentId: environment1.id + }, + include: { + environment: true + } + }) + + expect(secretVersion.length).toBe(2) + }) + + it('should fail to create a new version if the environment does not exist', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.slug}`, + payload: { + entries: [ + { + value: 'Updated Secret 1 value', + environmentSlug: 'non-existing-environment-slug' + } + ] + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + }) + + it('should have created a SECRET_UPDATED event', async () => { + // Update a secret + await secretService.updateSecret(user1, secret1.slug, { + name: 'Updated Secret 1' + }) + + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.SECRET + ) + + const event = response.items[0] + + expect(event.source).toBe(EventSource.SECRET) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.SECRET_UPDATED) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBe(secret1.id) + }) + }) + + describe('Rollback Tests', () => { + 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-slug/rollback/1?environmentSlug=${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + }) + + 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.slug}/rollback/1?environmentSlug=${environment1.slug}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + }) + + it('should not be able to roll back to a non-existing version', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.slug}/rollback/2?environmentSlug=${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + }) + + it('should not be able to roll back if the secret has no versions', async () => { + await prisma.secretVersion.deleteMany({ + where: { + secretId: secret1.id + } + }) + + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.slug}/rollback/1?environmentSlug=${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + }) + + it('should not create a secret version entity if value-environmentSlug is not provided during creation', async () => { + const secret = ( + await secretService.createSecret( + user1, + { + name: 'Secret 4', + note: 'Secret 4 note', + rotateAfter: '24' + }, + project1.slug + ) + ).secret + + const secretVersion = await prisma.secretVersion.findMany({ + where: { + secretId: secret.id + } + }) + + expect(secretVersion.length).toBe(0) + }) + + it('should be able to roll back a secret', async () => { + // Creating a few versions first + await secretService.updateSecret(user1, secret1.slug, { + entries: [ + { + value: 'Updated Secret 1 value', + environmentSlug: environment1.slug + } + ] + }) + + await secretService.updateSecret(user1, secret1.slug, { + entries: [ + { + value: 'Updated Secret 1 value 2', + environmentSlug: environment1.slug + } + ] + }) + + let versions: SecretVersion[] + + // eslint-disable-next-line prefer-const + versions = await prisma.secretVersion.findMany({ + where: { + secretId: secret1.id + } + }) + + expect(versions.length).toBe(3) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.slug}/rollback/1?environmentSlug=${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + // expect(response.json().count).toEqual(2) + + // versions = await prisma.secretVersion.findMany({ + // where: { + // secretId: secret1.id + // } + // }) + + // expect(versions.length).toBe(1) + }) + }) + + describe('Get All Secrets By Project Tests', () => { + it('should not be able to fetch decrypted secrets if the project does not store the private key', async () => { + // Fetch the environment of the project + const environment = await prisma.environment.findFirst({ + where: { + projectId: project2.id + } + }) + + await secretService.createSecret( + user1, + { + name: 'Secret 20', + entries: [ + { + environmentSlug: environment.slug, + value: 'Secret 20 value' + } + ], + rotateAfter: '24', + note: 'Secret 20 note' + }, + project2.slug + ) + + const response = await app.inject({ + method: 'GET', + url: `/secret/${project2.slug}?decryptValue=true`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(400) + }) + + it('should be able to fetch all secrets', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/${project1.slug}?page=0&limit=10`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().items.length).toBe(1) + + const { secret, values } = response.json().items[0] + expect(secret).toStrictEqual({ + id: secret1.id, + name: secret1.name, + slug: secret1.slug, + note: secret1.note, + projectId: project1.id, + lastUpdatedById: secret1.lastUpdatedById, + lastUpdatedBy: { + id: user1.id, + name: user1.name, + profilePictureUrl: user1.profilePictureUrl + }, + createdAt: secret1.createdAt.toISOString(), + updatedAt: secret1.updatedAt.toISOString(), + rotateAfter: secret1.rotateAfter, + rotateAt: secret1.rotateAt.toISOString() + }) + expect(values.length).toBe(1) + + const value = values[0] + expect(value.version).toBe(1) + expect(value.environment.id).toBe(environment1.id) + expect(value.environment.slug).toBe(environment1.slug) + expect(value.environment.name).toBe(environment1.name) + expect(value.value).not.toEqual('Secret 1 value') + + //check metadata + const metadata = response.json().metadata + expect(metadata.totalCount).toEqual(1) + expect(metadata.links.self).toEqual( + `/secret/${project1.slug}?decryptValue=false&page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.first).toEqual( + `/secret/${project1.slug}?decryptValue=false&page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.previous).toBeNull() + expect(metadata.links.next).toBeNull() + expect(metadata.links.last).toEqual( + `/secret/${project1.slug}?decryptValue=false&page=0&limit=10&sort=name&order=asc&search=` + ) + }) + + it('should be able to fetch only new versions of secrets', async () => { + // Update secret1 + await secretService.updateSecret(user1, secret1.slug, { + entries: [ + { + value: 'Secret new 1 value', + environmentSlug: environment1.slug + } + ] + }) + + const response = await app.inject({ + method: 'GET', + url: `/secret/${project1.slug}?page=0&limit=10`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().items.length).toBe(1) + + const { secret, values } = response.json().items[0] + + expect(secret).toStrictEqual({ + id: secret1.id, + name: secret1.name, + slug: secret1.slug, + note: secret1.note, + projectId: project1.id, + lastUpdatedById: secret1.lastUpdatedById, + lastUpdatedBy: { + id: user1.id, + name: user1.name, + profilePictureUrl: user1.profilePictureUrl + }, + createdAt: secret1.createdAt.toISOString(), + updatedAt: expect.any(String), + rotateAfter: secret1.rotateAfter, + rotateAt: secret1.rotateAt.toISOString() + }) + expect(values.length).toBe(1) + + const value = values[0] + expect(value.version).toBe(2) + expect(value.environment.id).toBe(environment1.id) + expect(value.environment.slug).toBe(environment1.slug) + expect(value.environment.name).toBe(environment1.name) + expect(value.value).not.toEqual('Secret 1 new value') + + //check metadata + const metadata = response.json().metadata + expect(metadata.totalCount).toEqual(1) + expect(metadata.links.self).toEqual( + `/secret/${project1.slug}?decryptValue=false&page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.first).toEqual( + `/secret/${project1.slug}?decryptValue=false&page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.previous).toBeNull() + expect(metadata.links.next).toBeNull() + expect(metadata.links.last).toEqual( + `/secret/${project1.slug}?decryptValue=false&page=0&limit=10&sort=name&order=asc&search=` + ) + }) + + it('should be able to fetch all secrets decrypted', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/${project1.slug}?decryptValue=true&page=0&limit=10`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().items.length).toBe(1) + + const { secret, values } = response.json().items[0] + expect(secret).toStrictEqual({ + id: secret1.id, + name: secret1.name, + slug: secret1.slug, + note: secret1.note, + projectId: project1.id, + lastUpdatedById: secret1.lastUpdatedById, + lastUpdatedBy: { + id: user1.id, + name: user1.name, + profilePictureUrl: user1.profilePictureUrl + }, + createdAt: secret1.createdAt.toISOString(), + updatedAt: secret1.updatedAt.toISOString(), + rotateAfter: secret1.rotateAfter, + rotateAt: secret1.rotateAt.toISOString() + }) + expect(values.length).toBe(1) + + const value = values[0] + expect(value.version).toBe(1) + expect(value.environment.id).toBe(environment1.id) + expect(value.environment.slug).toBe(environment1.slug) + expect(value.environment.name).toBe(environment1.name) + expect(value.value).toEqual('Secret 1 value') + + //check metadata + const metadata = response.json().metadata + expect(metadata.totalCount).toEqual(1) + expect(metadata.links.self).toEqual( + `/secret/${project1.slug}?decryptValue=true&page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.first).toEqual( + `/secret/${project1.slug}?decryptValue=true&page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.previous).toBeNull() + expect(metadata.links.next).toBeNull() + expect(metadata.links.last).toEqual( + `/secret/${project1.slug}?decryptValue=true&page=0&limit=10&sort=name&order=asc&search=` + ) + }) + + 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/${project2.slug}?decryptValue=true`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(400) + }) + + 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/${project1.slug}?decryptValue=true`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + + 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/${project1.slug}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + }) + + 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/non-existing-project-slug`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + }) + }) + + describe('Delete Secret Tests', () => { + it('should not be able to delete a non-existing secret', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/secret/non-existing-secret-slug`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + }) + + 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.slug}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + }) + + it('should be able to delete a secret', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/secret/${secret1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + }) + + it('should have created a SECRET_DELETED event', async () => { + // Delete a secret + await secretService.deleteSecret(user1, secret1.slug) + + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.SECRET + ) + + const event = response.items[0] + + expect(event.source).toBe(EventSource.SECRET) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.SECRET_DELETED) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBe(secret1.id) + }) + }) + + describe('Revisions Tests', () => { + it('should be able to fetch all revisions of secrets', async () => { + // create two more entries,totalling three versions + // checks if its able to fetch multiple revisions + await secretService.updateSecret(user1, secret1.slug, { + entries: [ + { + value: 'Updated Secret 1 value', + environmentSlug: environment1.slug + } + ] + }) + + await secretService.updateSecret(user1, secret1.slug, { + entries: [ + { + value: 'Updated Secret 1 value 2', + environmentSlug: environment1.slug + } + ] + }) + + const response = await app.inject({ + method: 'GET', + url: `/secret/${secret1.slug}/revisions/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().items).toHaveLength(3) + }) + + it('should return [] if the secret has no revision', async () => { + //returns [] if secret has no revision + await prisma.secretVersion.deleteMany({ + where: { + secretId: secret1.id + } + }) + + const response = await app.inject({ + method: 'GET', + url: `/secret/${secret1.slug}/revisions/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().items).toHaveLength(0) + }) + + it('should return error if secret does not exist', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/9999/revisions/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + }) + + it('should return error if environment does not exist', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/${secret1.slug}/revisions/9999`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + }) + + it('returns error if secret is not accessible', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/${secret1.slug}/revisions/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + }) + }) + + describe('Rotate Secrets Tests', () => { + it('should have not created a new secret version when there is no rotation defined', async () => { + const secretWithoutRotation = ( + await secretService.createSecret( + user1, + { + name: 'Secret', + note: 'Secret note', + rotateAfter: 'never', + entries: [ + { + environmentSlug: environment1.slug, + value: 'Secret value' + } + ] + }, + project1.slug + ) + ).secret as Secret + + await secretService.rotateSecrets() + + const secretVersion = await prisma.secretVersion.findFirst({ + where: { + secretId: secretWithoutRotation.id, + environmentId: environment1.id + }, + orderBy: { + version: 'desc' + }, + take: 1 + }) + + expect(secretVersion).toBeDefined() + expect(secretVersion.version).toBe(1) + expect(secretVersion.environmentId).toBe(environment1.id) + }) + + it('should have not created a new secret version when rotation is not due', async () => { + await secretService.rotateSecrets() + + const secretVersion = await prisma.secretVersion.findFirst({ + where: { + secretId: secret1.id, + environmentId: environment1.id + }, + orderBy: { + version: 'desc' + }, + take: 1 + }) + + expect(secretVersion).toBeDefined() + expect(secretVersion.version).toBe(1) + expect(secretVersion.environmentId).toBe(environment1.id) + }) + + it('should have created a new secret version when rotation is due', async () => { + const currentTime = new Date() + + currentTime.setHours(currentTime.getHours() + secret1.rotateAfter) + + await secretService.rotateSecrets(currentTime) + + const secretVersion = await prisma.secretVersion.findFirst({ + where: { + secretId: secret1.id, + environmentId: environment1.id + }, + orderBy: { + version: 'desc' + }, + take: 1 + }) + + expect(secretVersion).toBeDefined() + expect(secretVersion.version).toBe(2) + expect(secretVersion.environmentId).toBe(environment1.id) + }) + + it('should have created a SECRET_UPDATED event when rotation is due', async () => { + const currentTime = new Date() + + currentTime.setHours(currentTime.getHours() + secret1.rotateAfter) + + await secretService.rotateSecrets(currentTime) + + const events = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.SECRET + ) + + const event = events.items[0] + + expect(event.source).toBe(EventSource.SECRET) + expect(event.triggerer).toBe(EventTriggerer.SYSTEM) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.SECRET_UPDATED) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBe(secret1.id) + expect(event.title).toBe('Secret rotated') + }) + }) +}) diff --git a/apps/api/src/secret/service/secret.service.spec.ts b/apps/api/src/secret/service/secret.service.spec.ts index 421ede605..7434a1cc8 100644 --- a/apps/api/src/secret/service/secret.service.spec.ts +++ b/apps/api/src/secret/service/secret.service.spec.ts @@ -1,13 +1,12 @@ import { Test, TestingModule } from '@nestjs/testing' import { SecretService } from './secret.service' -import { MAIL_SERVICE } from '@/mail/services/interface.service' -import { MockMailService } from '@/mail/services/mock.service' import { PrismaService } from '@/prisma/prisma.service' import { mockDeep } from 'jest-mock-extended' import { REDIS_CLIENT } from '@/provider/redis.provider' import { RedisClientType } from 'redis' import { ProviderModule } from '@/provider/provider.module' -import { AuthorityCheckerService } from '@/common/authority-checker.service' +import { AuthorizationService } from '@/auth/service/authorization.service' +import { AuthorityCheckerService } from '@/auth/service/authority-checker.service' import { CommonModule } from '@/common/common.module' describe('SecretService', () => { @@ -18,11 +17,8 @@ describe('SecretService', () => { imports: [ProviderModule, CommonModule], providers: [ PrismaService, - { - provide: MAIL_SERVICE, - useClass: MockMailService - }, SecretService, + AuthorizationService, AuthorityCheckerService ] }) diff --git a/apps/api/src/secret/service/secret.service.ts b/apps/api/src/secret/service/secret.service.ts index a1e891158..caf87540f 100644 --- a/apps/api/src/secret/service/secret.service.ts +++ b/apps/api/src/secret/service/secret.service.ts @@ -20,10 +20,11 @@ import { import { CreateSecret } from '../dto/create.secret/create.secret' import { UpdateSecret } from '../dto/update.secret/update.secret' import { PrismaService } from '@/prisma/prisma.service' +import { AuthorizationService } from '@/auth/service/authorization.service' import { RedisClientType } from 'redis' import { REDIS_CLIENT } from '@/provider/redis.provider' import { CHANGE_NOTIFIER_RSC } from '@/socket/change-notifier.socket' -import { AuthorityCheckerService } from '@/common/authority-checker.service' +import { ChangeNotificationEvent } from 'src/socket/socket.types' import { paginate } from '@/common/paginate' import { addHoursToDate, @@ -41,7 +42,7 @@ import { } from '@/common/secret' import { Cron, CronExpression } from '@nestjs/schedule' import { SecretWithProject } from '../secret.types' -import { ChangeNotificationEvent } from '@/socket/socket.types' +import { AuthenticatedUser } from '@/user/user.types' @Injectable() export class SecretService { @@ -50,11 +51,11 @@ export class SecretService { constructor( private readonly prisma: PrismaService, + private readonly authorizationService: AuthorizationService, @Inject(REDIS_CLIENT) readonly redisClient: { publisher: RedisClientType - }, - private readonly authorityCheckerService: AuthorityCheckerService + } ) { this.redis = redisClient.publisher } @@ -67,17 +68,16 @@ export class SecretService { * @returns the created secret */ async createSecret( - user: User, + user: AuthenticatedUser, dto: CreateSecret, projectSlug: Project['slug'] ): Promise { // Fetch the project const project = - await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, + await this.authorizationService.authorizeUserAccessToProject({ + user, entity: { slug: projectSlug }, - authorities: [Authority.CREATE_SECRET], - prisma: this.prisma + authorities: [Authority.CREATE_SECRET] }) const projectId = project.id @@ -93,7 +93,7 @@ export class SecretService { user, project, this.prisma, - this.authorityCheckerService + this.authorizationService ) : new Map() @@ -192,15 +192,14 @@ export class SecretService { * @returns the updated secret and the updated versions */ async updateSecret( - user: User, + user: AuthenticatedUser, secretSlug: Secret['slug'], dto: UpdateSecret ) { - const secret = await this.authorityCheckerService.checkAuthorityOverSecret({ - userId: user.id, + const secret = await this.authorizationService.authorizeUserAccessToSecret({ + user, entity: { slug: secretSlug }, - authorities: [Authority.UPDATE_SECRET], - prisma: this.prisma + authorities: [Authority.UPDATE_SECRET] }) const shouldCreateRevisions = dto.entries && dto.entries.length > 0 @@ -215,7 +214,7 @@ export class SecretService { user, secret.project, this.prisma, - this.authorityCheckerService + this.authorizationService ) : new Map() @@ -358,26 +357,24 @@ export class SecretService { * @returns the deleted secret versions */ async rollbackSecret( - user: User, + user: AuthenticatedUser, secretSlug: Secret['slug'], environmentSlug: Environment['slug'], rollbackVersion: SecretVersion['version'] ) { // Fetch the secret - const secret = await this.authorityCheckerService.checkAuthorityOverSecret({ - userId: user.id, + const secret = await this.authorizationService.authorizeUserAccessToSecret({ + user, entity: { slug: secretSlug }, - authorities: [Authority.UPDATE_SECRET], - prisma: this.prisma + authorities: [Authority.UPDATE_SECRET] }) // Fetch the environment const environment = - await this.authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, + await this.authorizationService.authorizeUserAccessToEnvironment({ + user, entity: { slug: environmentSlug }, - authorities: [Authority.UPDATE_SECRET], - prisma: this.prisma + authorities: [Authority.UPDATE_SECRET] }) const environmentId = environment.id @@ -465,13 +462,12 @@ export class SecretService { * @param secretSlug the slug of the secret to delete * @returns void */ - async deleteSecret(user: User, secretSlug: Secret['slug']) { + async deleteSecret(user: AuthenticatedUser, secretSlug: Secret['slug']) { // Check if the user has the required role - const secret = await this.authorityCheckerService.checkAuthorityOverSecret({ - userId: user.id, + const secret = await this.authorizationService.authorizeUserAccessToSecret({ + user, entity: { slug: secretSlug }, - authorities: [Authority.DELETE_SECRET], - prisma: this.prisma + authorities: [Authority.DELETE_SECRET] }) // Delete the secret @@ -511,7 +507,7 @@ export class SecretService { * @returns an object with the items and the pagination metadata */ async getRevisionsOfSecret( - user: User, + user: AuthenticatedUser, secretSlug: Secret['slug'], environmentSlug: Environment['slug'], decryptValue: boolean, @@ -520,21 +516,19 @@ export class SecretService { order: 'asc' | 'desc' = 'desc' ) { // Fetch the secret - const secret = await this.authorityCheckerService.checkAuthorityOverSecret({ - userId: user.id, + const secret = await this.authorizationService.authorizeUserAccessToSecret({ + user, entity: { slug: secretSlug }, - authorities: [Authority.READ_SECRET], - prisma: this.prisma + authorities: [Authority.READ_SECRET] }) const secretId = secret.id // Fetch the environment const environment = - await this.authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, + await this.authorizationService.authorizeUserAccessToEnvironment({ + user, entity: { slug: environmentSlug }, - authorities: [Authority.READ_ENVIRONMENT], - prisma: this.prisma + authorities: [Authority.READ_ENVIRONMENT] }) const environmentId = environment.id @@ -599,7 +593,7 @@ export class SecretService { * @returns an object with the items and the pagination metadata */ async getAllSecretsOfProject( - user: User, + user: AuthenticatedUser, projectSlug: Project['slug'], decryptValue: boolean, page: number, @@ -610,11 +604,10 @@ export class SecretService { ) { // Fetch the project const project = - await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, + await this.authorizationService.authorizeUserAccessToProject({ + user, entity: { slug: projectSlug }, - authorities: [Authority.READ_SECRET], - prisma: this.prisma + authorities: [Authority.READ_SECRET] }) const projectId = project.id diff --git a/apps/api/src/socket/change-notifier.socket.ts b/apps/api/src/socket/change-notifier.socket.ts index a86884275..31288b45b 100644 --- a/apps/api/src/socket/change-notifier.socket.ts +++ b/apps/api/src/socket/change-notifier.socket.ts @@ -14,16 +14,17 @@ import { ChangeNotificationEvent, ChangeNotifierRegistration } from './socket.types' -import { Authority, User } from '@prisma/client' +import { Authority } from '@prisma/client' import { CurrentUser } from '@/decorators/user.decorator' import { PrismaService } from '@/prisma/prisma.service' +import { AuthorizationService } from '@/auth/service/authorization.service' import { REDIS_CLIENT } from '@/provider/redis.provider' import { RedisClientType } from 'redis' import { ApiKeyGuard } from '@/auth/guard/api-key/api-key.guard' import { AuthGuard } from '@/auth/guard/auth/auth.guard' import { RequiredApiKeyAuthorities } from '@/decorators/required-api-key-authorities.decorator' import { Cron, CronExpression } from '@nestjs/schedule' -import { AuthorityCheckerService } from '@/common/authority-checker.service' +import { AuthenticatedUser } from '@/user/user.types' // The redis subscription channel for configuration updates export const CHANGE_NOTIFIER_RSC = 'configuration-updates' @@ -51,7 +52,7 @@ export default class ChangeNotifier publisher: RedisClientType }, private readonly prisma: PrismaService, - private readonly authorityCheckerService: AuthorityCheckerService + private readonly authorizationService: AuthorizationService ) { this.redis = redisClient.publisher this.redisSubscriber = redisClient.subscriber @@ -97,7 +98,7 @@ export default class ChangeNotifier async handleRegister( @ConnectedSocket() client: Socket, @MessageBody() data: ChangeNotifierRegistration, - @CurrentUser() user: User + @CurrentUser() user: AuthenticatedUser ) { /** * This event is emitted from the CLI to register @@ -116,32 +117,29 @@ export default class ChangeNotifier try { // Check if the user has access to the workspace - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, entity: { slug: data.workspaceSlug }, authorities: [ Authority.READ_WORKSPACE, Authority.READ_VARIABLE, Authority.READ_SECRET - ], - prisma: this.prisma + ] }) // Check if the user has access to the project - await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, + await this.authorizationService.authorizeUserAccessToProject({ + user, entity: { slug: data.projectSlug }, - authorities: [Authority.READ_PROJECT], - prisma: this.prisma + authorities: [Authority.READ_PROJECT] }) // Check if the user has access to the environment const environment = - await this.authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, + await this.authorizationService.authorizeUserAccessToEnvironment({ + user, entity: { slug: data.environmentSlug }, - authorities: [Authority.READ_ENVIRONMENT], - prisma: this.prisma + authorities: [Authority.READ_ENVIRONMENT] }) // Add the client to the environment diff --git a/apps/api/src/user/user.e2e.spec.ts b/apps/api/src/user/user.e2e.spec.ts index 54ffe9beb..2687af5bf 100644 --- a/apps/api/src/user/user.e2e.spec.ts +++ b/apps/api/src/user/user.e2e.spec.ts @@ -19,6 +19,8 @@ describe('User Controller Tests', () => { let adminUser: User let regularUser: User + const USER_IP_ADDRESS = '127.0.0.1' + beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [AppModule, UserModule] @@ -83,7 +85,8 @@ describe('User Controller Tests', () => { expect(result.statusCode).toEqual(200) expect(JSON.parse(result.body)).toEqual({ ...adminUser, - defaultWorkspace: null + defaultWorkspace: null, + ipAddress: USER_IP_ADDRESS }) }) @@ -106,7 +109,8 @@ describe('User Controller Tests', () => { expect(result.statusCode).toEqual(200) expect(JSON.parse(result.body)).toEqual({ ...regularUser, - defaultWorkspace: expect.any(Object) + defaultWorkspace: expect.any(Object), + ipAddress: USER_IP_ADDRESS }) expect(result.json().defaultWorkspace).toMatchObject({ diff --git a/apps/api/src/user/user.types.ts b/apps/api/src/user/user.types.ts index a0bc48221..2a7baf998 100644 --- a/apps/api/src/user/user.types.ts +++ b/apps/api/src/user/user.types.ts @@ -3,3 +3,7 @@ import { User, Workspace } from '@prisma/client' export interface UserWithWorkspace extends User { defaultWorkspace: Workspace } + +export interface AuthenticatedUser extends User { + ipAddress: string +} diff --git a/apps/api/src/variable/controller/variable.controller.spec.ts b/apps/api/src/variable/controller/variable.controller.spec.ts index 4abddf2d0..9875f45a1 100644 --- a/apps/api/src/variable/controller/variable.controller.spec.ts +++ b/apps/api/src/variable/controller/variable.controller.spec.ts @@ -1,14 +1,13 @@ import { Test, TestingModule } from '@nestjs/testing' import { VariableController } from './variable.controller' import { PrismaService } from '@/prisma/prisma.service' -import { MAIL_SERVICE } from '@/mail/services/interface.service' -import { MockMailService } from '@/mail/services/mock.service' import { VariableService } from '../service/variable.service' import { REDIS_CLIENT } from '@/provider/redis.provider' import { RedisClientType } from 'redis' import { mockDeep } from 'jest-mock-extended' import { ProviderModule } from '@/provider/provider.module' -import { AuthorityCheckerService } from '@/common/authority-checker.service' +import { AuthorizationService } from '@/auth/service/authorization.service' +import { AuthorityCheckerService } from '@/auth/service/authority-checker.service' import { CommonModule } from '@/common/common.module' describe('VariableController', () => { @@ -19,11 +18,8 @@ describe('VariableController', () => { imports: [ProviderModule, CommonModule], providers: [ PrismaService, - { - provide: MAIL_SERVICE, - useClass: MockMailService - }, VariableService, + AuthorizationService, AuthorityCheckerService ], controllers: [VariableController] diff --git a/apps/api/src/variable/controller/variable.controller.ts b/apps/api/src/variable/controller/variable.controller.ts index eb94bd3d3..1a15d29d7 100644 --- a/apps/api/src/variable/controller/variable.controller.ts +++ b/apps/api/src/variable/controller/variable.controller.ts @@ -10,10 +10,11 @@ import { } from '@nestjs/common' import { VariableService } from '../service/variable.service' import { RequiredApiKeyAuthorities } from '@/decorators/required-api-key-authorities.decorator' -import { Authority, User } from '@prisma/client' +import { Authority } from '@prisma/client' import { CurrentUser } from '@/decorators/user.decorator' import { CreateVariable } from '../dto/create.variable/create.variable' import { UpdateVariable } from '../dto/update.variable/update.variable' +import { AuthenticatedUser } from '@/user/user.types' @Controller('variable') export class VariableController { @@ -22,7 +23,7 @@ export class VariableController { @Post(':projectSlug') @RequiredApiKeyAuthorities(Authority.CREATE_VARIABLE) async createVariable( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('projectSlug') projectSlug: string, @Body() dto: CreateVariable ) { @@ -32,7 +33,7 @@ export class VariableController { @Put(':variableSlug') @RequiredApiKeyAuthorities(Authority.UPDATE_VARIABLE) async updateVariable( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('variableSlug') variableSlug: string, @Body() dto: UpdateVariable ) { @@ -42,7 +43,7 @@ export class VariableController { @Put(':variableSlug/rollback/:rollbackVersion') @RequiredApiKeyAuthorities(Authority.UPDATE_VARIABLE) async rollbackVariable( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('variableSlug') variableSlug: string, @Query('environmentSlug') environmentSlug: string, @Param('rollbackVersion') rollbackVersion: number @@ -58,7 +59,7 @@ export class VariableController { @Delete(':variableSlug') @RequiredApiKeyAuthorities(Authority.DELETE_VARIABLE) async deleteVariable( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('variableSlug') variableSlug: string ) { return await this.variableService.deleteVariable(user, variableSlug) @@ -67,7 +68,7 @@ export class VariableController { @Get('/:projectSlug') @RequiredApiKeyAuthorities(Authority.READ_VARIABLE) async getAllVariablesOfProject( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('projectSlug') projectSlug: string, @Query('page') page: number = 0, @Query('limit') limit: number = 10, @@ -89,7 +90,7 @@ export class VariableController { @Get('/:variableSlug/revisions/:environmentSlug') @RequiredApiKeyAuthorities(Authority.READ_VARIABLE) async getRevisionsOfVariable( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('variableSlug') variableSlug: string, @Param('environmentSlug') environmentSlug: string, @Query('page') page: number = 0, diff --git a/apps/api/src/variable/service/variable.service.spec.ts b/apps/api/src/variable/service/variable.service.spec.ts index 3efd6efd4..ce30dc197 100644 --- a/apps/api/src/variable/service/variable.service.spec.ts +++ b/apps/api/src/variable/service/variable.service.spec.ts @@ -1,13 +1,12 @@ import { Test, TestingModule } from '@nestjs/testing' import { VariableService } from './variable.service' import { PrismaService } from '@/prisma/prisma.service' -import { MAIL_SERVICE } from '@/mail/services/interface.service' -import { MockMailService } from '@/mail/services/mock.service' import { REDIS_CLIENT } from '@/provider/redis.provider' import { RedisClientType } from 'redis' import { mockDeep } from 'jest-mock-extended' import { ProviderModule } from '@/provider/provider.module' -import { AuthorityCheckerService } from '@/common/authority-checker.service' +import { AuthorizationService } from '@/auth/service/authorization.service' +import { AuthorityCheckerService } from '@/auth/service/authority-checker.service' import { CommonModule } from '@/common/common.module' describe('VariableService', () => { @@ -18,11 +17,8 @@ describe('VariableService', () => { imports: [ProviderModule, CommonModule], providers: [ PrismaService, - { - provide: MAIL_SERVICE, - useClass: MockMailService - }, VariableService, + AuthorizationService, AuthorityCheckerService ] }) diff --git a/apps/api/src/variable/service/variable.service.ts b/apps/api/src/variable/service/variable.service.ts index 2b26e6d8d..d54e48e9a 100644 --- a/apps/api/src/variable/service/variable.service.ts +++ b/apps/api/src/variable/service/variable.service.ts @@ -21,7 +21,7 @@ import { UpdateVariable } from '../dto/update.variable/update.variable' import { RedisClientType } from 'redis' import { REDIS_CLIENT } from '@/provider/redis.provider' import { CHANGE_NOTIFIER_RSC } from '@/socket/change-notifier.socket' -import { AuthorityCheckerService } from '@/common/authority-checker.service' +import { AuthorizationService } from '@/auth/service/authorization.service' import { ChangeNotificationEvent } from 'src/socket/socket.types' import { paginate } from '@/common/paginate' import { getEnvironmentIdToSlugMap } from '@/common/environment' @@ -29,6 +29,7 @@ import generateEntitySlug from '@/common/slug-generator' import { createEvent } from '@/common/event' import { constructErrorBody, limitMaxItemsPerPage } from '@/common/util' import { getVariableWithValues, VariableWithValues } from '@/common/variable' +import { AuthenticatedUser } from '@/user/user.types' @Injectable() export class VariableService { @@ -37,11 +38,11 @@ export class VariableService { constructor( private readonly prisma: PrismaService, + private readonly authorizationService: AuthorizationService, @Inject(REDIS_CLIENT) readonly redisClient: { publisher: RedisClientType - }, - private readonly authorityCheckerService: AuthorityCheckerService + } ) { this.redis = redisClient.publisher } @@ -54,17 +55,16 @@ export class VariableService { * @returns the newly created variable */ async createVariable( - user: User, + user: AuthenticatedUser, dto: CreateVariable, projectSlug: Project['slug'] ): Promise { // Fetch the project const project = - await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, + await this.authorizationService.authorizeUserAccessToProject({ + user, entity: { slug: projectSlug }, - authorities: [Authority.CREATE_VARIABLE], - prisma: this.prisma + authorities: [Authority.CREATE_VARIABLE] }) const projectId = project.id @@ -80,7 +80,7 @@ export class VariableService { user, project, this.prisma, - this.authorityCheckerService + this.authorizationService ) : new Map() @@ -127,14 +127,7 @@ export class VariableService { } }, version: true, - value: true, - createdBy: { - select: { - id: true, - name: true, - profilePictureUrl: true - } - } + value: true } } } @@ -173,16 +166,15 @@ export class VariableService { * @returns the updated variable and its new versions */ async updateVariable( - user: User, + user: AuthenticatedUser, variableSlug: Variable['slug'], dto: UpdateVariable ) { const variable = - await this.authorityCheckerService.checkAuthorityOverVariable({ - userId: user.id, + await this.authorizationService.authorizeUserAccessToVariable({ + user, entity: { slug: variableSlug }, - authorities: [Authority.UPDATE_VARIABLE], - prisma: this.prisma + authorities: [Authority.UPDATE_VARIABLE] }) // Check if the variable already exists in the project @@ -197,7 +189,7 @@ export class VariableService { user, variable.project, this.prisma, - this.authorityCheckerService + this.authorizationService ) : new Map() @@ -337,26 +329,24 @@ export class VariableService { * @returns the deleted variable versions */ async rollbackVariable( - user: User, + user: AuthenticatedUser, variableSlug: Variable['slug'], environmentSlug: Environment['slug'], rollbackVersion: VariableVersion['version'] ) { const environment = - await this.authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, + await this.authorizationService.authorizeUserAccessToEnvironment({ + user, entity: { slug: environmentSlug }, - authorities: [Authority.UPDATE_VARIABLE], - prisma: this.prisma + authorities: [Authority.UPDATE_VARIABLE] }) const environmentId = environment.id const variable = - await this.authorityCheckerService.checkAuthorityOverVariable({ - userId: user.id, + await this.authorizationService.authorizeUserAccessToVariable({ + user, entity: { slug: variableSlug }, - authorities: [Authority.UPDATE_VARIABLE], - prisma: this.prisma + authorities: [Authority.UPDATE_VARIABLE] }) // Filter the variable versions by the environment @@ -443,13 +433,15 @@ export class VariableService { * @throws `NotFoundException` if the variable does not exist * @throws `ForbiddenException` if the user does not have the required authority */ - async deleteVariable(user: User, variableSlug: Variable['slug']) { + async deleteVariable( + user: AuthenticatedUser, + variableSlug: Variable['slug'] + ) { const variable = - await this.authorityCheckerService.checkAuthorityOverVariable({ - userId: user.id, + await this.authorizationService.authorizeUserAccessToVariable({ + user, entity: { slug: variableSlug }, - authorities: [Authority.DELETE_VARIABLE], - prisma: this.prisma + authorities: [Authority.DELETE_VARIABLE] }) // Delete the variable @@ -494,7 +486,7 @@ export class VariableService { * @throws `ForbiddenException` if the user does not have the required authority */ async getAllVariablesOfProject( - user: User, + user: AuthenticatedUser, projectSlug: Project['slug'], page: number, limit: number, @@ -504,11 +496,10 @@ export class VariableService { ) { // Check if the user has the required authorities in the project const { id: projectId } = - await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, + await this.authorizationService.authorizeUserAccessToProject({ + user, entity: { slug: projectSlug }, - authorities: [Authority.READ_VARIABLE], - prisma: this.prisma + authorities: [Authority.READ_VARIABLE] }) const variables = await this.prisma.variable.findMany({ @@ -674,7 +665,7 @@ export class VariableService { * @throws `ForbiddenException` if the user does not have the required authority */ async getRevisionsOfVariable( - user: User, + user: AuthenticatedUser, variableSlug: Variable['slug'], environmentSlug: Environment['slug'], page: number, @@ -682,19 +673,17 @@ export class VariableService { order: 'asc' | 'desc' = 'desc' ) { const { id: variableId } = - await this.authorityCheckerService.checkAuthorityOverVariable({ - userId: user.id, + await this.authorizationService.authorizeUserAccessToVariable({ + user, entity: { slug: variableSlug }, - authorities: [Authority.READ_VARIABLE], - prisma: this.prisma + authorities: [Authority.READ_VARIABLE] }) const { id: environmentId } = - await this.authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, + await this.authorizationService.authorizeUserAccessToEnvironment({ + user, entity: { slug: environmentSlug }, - authorities: [Authority.READ_ENVIRONMENT], - prisma: this.prisma + authorities: [Authority.READ_ENVIRONMENT] }) const items = await this.prisma.variableVersion.findMany({ @@ -704,17 +693,9 @@ export class VariableService { }, skip: page * limit, take: limitMaxItemsPerPage(limit), + orderBy: { version: order - }, - include: { - createdBy: { - select: { - id: true, - name: true, - profilePictureUrl: true - } - } } }) diff --git a/apps/api/src/variable/variable.e2e.spec.ts b/apps/api/src/variable/variable.e2e.spec.ts index 4d521ce28..70872af83 100644 --- a/apps/api/src/variable/variable.e2e.spec.ts +++ b/apps/api/src/variable/variable.e2e.spec.ts @@ -14,7 +14,6 @@ import { Project, Variable, VariableVersion, - User, Workspace, ProjectAccessLevel } from '@prisma/client' @@ -37,6 +36,7 @@ import { UserService } from '@/user/service/user.service' import { UserModule } from '@/user/user.module' import { QueryTransformPipe } from '@/common/pipes/query.transform.pipe' import { fetchEvents } from '@/common/event' +import { AuthenticatedUser } from '@/user/user.types' import { ValidationPipe } from '@nestjs/common' describe('Variable Controller Tests', () => { @@ -49,13 +49,15 @@ describe('Variable Controller Tests', () => { let eventService: EventService let userService: UserService - let user1: User, user2: User + let user1: AuthenticatedUser, user2: AuthenticatedUser let workspace1: Workspace let project1: Project let environment1: Environment let environment2: Environment let variable1: Variable + const USER_IP_ADDRESS = '127.0.0.1' + beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [ @@ -115,8 +117,8 @@ describe('Variable Controller Tests', () => { delete createUser1.defaultWorkspace delete createUser2.defaultWorkspace - user1 = createUser1 - user2 = createUser2 + user1 = { ...createUser1, ipAddress: USER_IP_ADDRESS } + user2 = { ...createUser2, ipAddress: USER_IP_ADDRESS } project1 = (await projectService.createProject(user1, workspace1.slug, { name: 'Project 1', diff --git a/apps/api/src/workspace-membership/controller/workspace-membership.controller.spec.ts b/apps/api/src/workspace-membership/controller/workspace-membership.controller.spec.ts index 8a0e9ad94..3de0168ce 100644 --- a/apps/api/src/workspace-membership/controller/workspace-membership.controller.spec.ts +++ b/apps/api/src/workspace-membership/controller/workspace-membership.controller.spec.ts @@ -5,7 +5,8 @@ import { PrismaService } from '@/prisma/prisma.service' import { MAIL_SERVICE } from '@/mail/services/interface.service' import { MockMailService } from '@/mail/services/mock.service' import { JwtService } from '@nestjs/jwt' -import { AuthorityCheckerService } from '@/common/authority-checker.service' +import { AuthorizationService } from '@/auth/service/authorization.service' +import { AuthorityCheckerService } from '@/auth/service/authority-checker.service' import { CommonModule } from '@/common/common.module' describe('WorkspaceMembershipController', () => { @@ -22,6 +23,7 @@ describe('WorkspaceMembershipController', () => { useClass: MockMailService }, JwtService, + AuthorizationService, AuthorityCheckerService ], controllers: [WorkspaceMembershipController] diff --git a/apps/api/src/workspace-membership/controller/workspace-membership.controller.ts b/apps/api/src/workspace-membership/controller/workspace-membership.controller.ts index 32c827f95..2592c187f 100644 --- a/apps/api/src/workspace-membership/controller/workspace-membership.controller.ts +++ b/apps/api/src/workspace-membership/controller/workspace-membership.controller.ts @@ -13,6 +13,7 @@ import { import { Authority, User, Workspace, WorkspaceRole } from '@prisma/client' import { CreateWorkspaceMember } from '../dto/create.workspace/create.workspace-membership' import { WorkspaceMembershipService } from '../service/workspace-membership.service' +import { AuthenticatedUser } from '@/user/user.types' @Controller('workspace-membership') export class WorkspaceMembershipController { @@ -23,7 +24,7 @@ export class WorkspaceMembershipController { @Put(':workspaceSlug/transfer-ownership/:userEmail') @RequiredApiKeyAuthorities(Authority.WORKSPACE_ADMIN) async transferOwnership( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceSlug') workspaceSlug: Workspace['slug'], @Param('userEmail') userEmail: User['email'] ) { @@ -37,7 +38,7 @@ export class WorkspaceMembershipController { @Post(':workspaceSlug/invite-users') @RequiredApiKeyAuthorities(Authority.ADD_USER, Authority.READ_WORKSPACE) async addUsers( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceSlug') workspaceSlug: Workspace['slug'], @Body() members: CreateWorkspaceMember[] ) { @@ -51,7 +52,7 @@ export class WorkspaceMembershipController { @Delete(':workspaceSlug/remove-users') @RequiredApiKeyAuthorities(Authority.REMOVE_USER, Authority.READ_WORKSPACE) async removeUsers( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceSlug') workspaceSlug: Workspace['slug'], @Query('userEmails') userEmails: string = '' ) { @@ -68,7 +69,7 @@ export class WorkspaceMembershipController { Authority.READ_WORKSPACE ) async updateMemberRoles( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceSlug') workspaceSlug: Workspace['slug'], @Param('userEmail') userEmail: User['email'], @Body() roleSlugs: WorkspaceRole['slug'][] @@ -84,7 +85,7 @@ export class WorkspaceMembershipController { @Post(':workspaceSlug/accept-invitation') @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) async acceptInvitation( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceSlug') workspaceSlug: Workspace['slug'] ) { return this.workspaceMembershipService.acceptInvitation(user, workspaceSlug) @@ -93,7 +94,7 @@ export class WorkspaceMembershipController { @Delete(':workspaceSlug/decline-invitation') @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) async declineInvitation( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceSlug') workspaceSlug: Workspace['slug'] ) { return this.workspaceMembershipService.declineInvitation( @@ -105,7 +106,7 @@ export class WorkspaceMembershipController { @Delete(':workspaceSlug/cancel-invitation/:userEmail') @RequiredApiKeyAuthorities(Authority.REMOVE_USER) async cancelInvitation( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceSlug') workspaceSlug: Workspace['slug'], @Param('userEmail') userEmail: User['email'] ) { @@ -119,7 +120,7 @@ export class WorkspaceMembershipController { @Delete(':workspaceSlug/leave') @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) async leave( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceSlug') workspaceSlug: Workspace['slug'] ) { return this.workspaceMembershipService.leaveWorkspace(user, workspaceSlug) @@ -128,7 +129,7 @@ export class WorkspaceMembershipController { @Get(':workspaceSlug/is-member/:userEmail') @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) async isMember( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceSlug') workspaceSlug: Workspace['slug'], @Param('userEmail') userEmail: User['email'] ) { @@ -142,7 +143,7 @@ export class WorkspaceMembershipController { @Get(':workspaceSlug/members') @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) async getMembers( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceSlug') workspaceSlug: Workspace['slug'], @Query('page') page: number = 0, @Query('limit') limit: number = 10, diff --git a/apps/api/src/workspace-membership/service/workspace-membership.service.spec.ts b/apps/api/src/workspace-membership/service/workspace-membership.service.spec.ts index 7c6b0bc5e..c483aae6d 100644 --- a/apps/api/src/workspace-membership/service/workspace-membership.service.spec.ts +++ b/apps/api/src/workspace-membership/service/workspace-membership.service.spec.ts @@ -4,7 +4,8 @@ import { PrismaService } from '@/prisma/prisma.service' import { MAIL_SERVICE } from '@/mail/services/interface.service' import { MockMailService } from '@/mail/services/mock.service' import { JwtService } from '@nestjs/jwt' -import { AuthorityCheckerService } from '@/common/authority-checker.service' +import { AuthorizationService } from '@/auth/service/authorization.service' +import { AuthorityCheckerService } from '@/auth/service/authority-checker.service' import { CommonModule } from '@/common/common.module' describe('WorkspaceMembershipService', () => { @@ -21,6 +22,7 @@ describe('WorkspaceMembershipService', () => { useClass: MockMailService }, JwtService, + AuthorizationService, AuthorityCheckerService ] }).compile() diff --git a/apps/api/src/workspace-membership/service/workspace-membership.service.ts b/apps/api/src/workspace-membership/service/workspace-membership.service.ts index e38cea5f4..76278e1bb 100644 --- a/apps/api/src/workspace-membership/service/workspace-membership.service.ts +++ b/apps/api/src/workspace-membership/service/workspace-membership.service.ts @@ -1,1090 +1,1081 @@ -import { AuthorityCheckerService } from '@/common/authority-checker.service' -import { paginate } from '@/common/paginate' -import { createUser, getUserByEmailOrId } from '@/common/user' -import { IMailService, MAIL_SERVICE } from '@/mail/services/interface.service' -import { PrismaService } from '@/prisma/prisma.service' -import { - BadRequestException, - ConflictException, - Inject, - Injectable, - InternalServerErrorException, - Logger, - NotFoundException -} from '@nestjs/common' -import { JwtService } from '@nestjs/jwt' -import { - Authority, - AuthProvider, - EventSource, - EventType, - User, - Workspace, - WorkspaceMember, - WorkspaceRole -} from '@prisma/client' -import { v4 } from 'uuid' -import { CreateWorkspaceMember } from '../dto/create.workspace/create.workspace-membership' - -import { createEvent } from '@/common/event' -import { constructErrorBody, limitMaxItemsPerPage } from '@/common/util' - -@Injectable() -export class WorkspaceMembershipService { - private readonly log = new Logger(WorkspaceMembershipService.name) - - constructor( - private readonly prisma: PrismaService, - private readonly jwt: JwtService, - @Inject(MAIL_SERVICE) private readonly mailService: IMailService, - private readonly authorityCheckerService: AuthorityCheckerService - ) {} - - /** - * Transfers ownership of a workspace to another user. - * @param user The user transferring the ownership - * @param workspaceSlug The slug of the workspace to transfer - * @param otherUserEmail The email of the user to transfer the ownership to - * @throws BadRequestException if the user is already the owner of the workspace, - * or if the workspace is the default workspace - * @throws NotFoundException if the other user is not a member of the workspace - * @throws InternalServerErrorException if there is an error in the transaction - */ - async transferOwnership( - user: User, - workspaceSlug: Workspace['slug'], - otherUserEmail: User['email'] - ): Promise { - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.WORKSPACE_ADMIN], - - prisma: this.prisma - }) - - const otherUser = await getUserByEmailOrId(otherUserEmail, this.prisma) - - if (otherUser.id === user.id) { - throw new BadRequestException( - constructErrorBody( - 'You cannot transfer ownership to yourself', - `You are already the owner of this workspace` - ) - ) - } - - // We don't want the users to be able to transfer - // ownership if the workspace is the default workspace - if (workspace.isDefault) { - throw new BadRequestException( - constructErrorBody( - 'Can not transfer default workspace ownership', - `You cannot transfer ownership of a default workspace.` - ) - ) - } - - const workspaceMembership = await this.getWorkspaceMembership( - workspace.id, - otherUser.id - ) - - // Check if the user is a member of the workspace - if (!workspaceMembership) { - throw new NotFoundException( - constructErrorBody( - 'You are not a member of this workspace', - `Could not resolve your access to this workspace. If you think this is a mistake, please get in touch with the workspace admin.` - ) - ) - } - - // Check if the user has accepted the invitation - if (!workspaceMembership.invitationAccepted) { - throw new BadRequestException( - constructErrorBody( - 'You have not accepted the invitation', - `Your invitation to this workspace is still pending. Check the invitations tab to accept the invitation.` - ) - ) - } - - const currentUserMembership = await this.getWorkspaceMembership( - workspace.id, - user.id - ) - - // Get the admin ownership role - const adminOwnershipRole = await this.prisma.workspaceRole.findFirst({ - where: { - workspaceId: workspace.id, - hasAdminAuthority: true - } - }) - - // Remove this role from the current owner - const removeRole = this.prisma.workspaceMemberRoleAssociation.delete({ - where: { - roleId_workspaceMemberId: { - roleId: adminOwnershipRole.id, - workspaceMemberId: currentUserMembership.id - } - } - }) - - // Assign this role to the new owner - const assignRole = this.prisma.workspaceMemberRoleAssociation.create({ - data: { - role: { - connect: { - id: adminOwnershipRole.id - } - }, - workspaceMember: { - connect: { - id: workspaceMembership.id - } - } - } - }) - - // Update the owner of the workspace - const updateWorkspace = this.prisma.workspace.update({ - where: { - id: workspace.id - }, - data: { - ownerId: otherUser.id, - lastUpdatedBy: { - connect: { - id: user.id - } - } - } - }) - - try { - await this.prisma.$transaction([removeRole, assignRole, updateWorkspace]) - } catch (e) { - this.log.error('Error in transaction', e) - throw new InternalServerErrorException('Error in transaction') - } - - await createEvent( - { - triggeredBy: user, - entity: workspace, - type: EventType.WORKSPACE_UPDATED, - source: EventSource.WORKSPACE, - title: `Workspace transferred`, - metadata: { - workspaceId: workspace.id, - name: workspace.name, - newOwnerId: otherUser.id - }, - workspaceId: workspace.id - }, - this.prisma - ) - - this.log.debug( - `Transferred ownership of workspace ${workspace.name} (${workspace.id}) to user ${otherUser.email} (${otherUser.id})` - ) - } - - /** - * Invites users to a workspace. - * @param user The user to invite the users for - * @param workspaceSlug The slug of the workspace to invite users to - * @param members The members to invite - * @throws BadRequestException if the user does not have the authority to add users to the workspace - * @throws NotFoundException if the workspace or any of the users to invite do not exist - * @throws InternalServerErrorException if there is an error in the transaction - */ - async inviteUsersToWorkspace( - user: User, - workspaceSlug: Workspace['slug'], - members: CreateWorkspaceMember[] - ): Promise { - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.ADD_USER], - prisma: this.prisma - }) - - // Add users to the workspace if any - if (members && members.length > 0) { - await this.addMembersToWorkspace(workspace, user, members) - - await createEvent( - { - triggeredBy: user, - entity: workspace, - type: EventType.INVITED_TO_WORKSPACE, - source: EventSource.WORKSPACE, - title: `Invited users to workspace`, - metadata: { - workspaceId: workspace.id, - name: workspace.name, - members: members.map((m) => m.email) - }, - workspaceId: workspace.id - }, - this.prisma - ) - - this.log.debug( - `Added users to workspace ${workspace.name} (${workspace.id})` - ) - - return - } - - this.log.warn( - `No users to add to workspace ${workspace.name} (${workspace.id})` - ) - } - - /** - * Removes users from a workspace. - * @param user The user to remove users from the workspace for - * @param workspaceSlug The slug of the workspace to remove users from - * @param userEmails The emails of the users to remove from the workspace - * @throws BadRequestException if the user is trying to remove themselves from the workspace, - * or if the user is not a member of the workspace - * @throws NotFoundException if the workspace or any of the users to remove do not exist - * @throws InternalServerErrorException if there is an error in the transaction - */ - async removeUsersFromWorkspace( - user: User, - workspaceSlug: Workspace['slug'], - userEmails: User['email'][] - ): Promise { - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.REMOVE_USER], - prisma: this.prisma - }) - - const userIds = await this.prisma.user - .findMany({ - where: { - email: { - in: userEmails.map((email) => email.toLowerCase()) - } - }, - select: { - id: true - } - }) - .then((users) => users.map((u) => u.id)) - - // Remove users from the workspace if any - if (userIds && userIds.length > 0) { - if (userIds.find((id) => id === user.id)) { - throw new BadRequestException( - constructErrorBody( - `You can not remove yourself from the workspace.`, - `You can only leave a workspace.` - ) - ) - } - - // Delete the membership - await this.prisma.workspaceMember.deleteMany({ - where: { - workspaceId: workspace.id, - userId: { - in: userIds - } - } - }) - - // Send an email to the removed users - const removedOn = new Date() - const emailPromises = userEmails.map((userEmail) => - this.mailService.removedFromWorkspace( - userEmail, - workspace.name, - removedOn - ) - ) - - await Promise.all(emailPromises) - } - - await createEvent( - { - triggeredBy: user, - entity: workspace, - type: EventType.REMOVED_FROM_WORKSPACE, - source: EventSource.WORKSPACE, - title: `Removed users from workspace`, - metadata: { - workspaceId: workspace.id, - name: workspace.name, - members: userIds - }, - workspaceId: workspace.id - }, - this.prisma - ) - - this.log.debug( - `Removed users from workspace ${workspace.name} (${workspace.id})` - ) - } - - /** - * Updates the roles of a user in a workspace. - * - * @throws NotFoundException if the user is not a member of the workspace - * @throws BadRequestException if the admin role is tried to be assigned to the user - * @param user The user to update the roles for - * @param workspaceSlug The slug of the workspace to update the roles in - * @param otherUserEmail The email of the user to update the roles for - * @param roleSlugs The slugs of the roles to assign to the user - */ - async updateMemberRoles( - user: User, - workspaceSlug: Workspace['slug'], - otherUserEmail: User['email'], - roleSlugs: WorkspaceRole['slug'][] - ): Promise { - const otherUser = await getUserByEmailOrId(otherUserEmail, this.prisma) - - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.UPDATE_USER_ROLE], - prisma: this.prisma - }) - - if (!roleSlugs || roleSlugs.length === 0) { - this.log.warn( - `No roles to update for user ${otherUserEmail} in workspace ${workspace.name} (${workspace.id})` - ) - } - - // Check if the member in concern is a part of the workspace or not - if (!(await this.memberExistsInWorkspace(workspace.id, otherUser.id))) - throw new NotFoundException( - constructErrorBody( - 'User is not a member of the workspace', - 'Please check the teams tab to confirm whether the user is a member of this workspace' - ) - ) - - const workspaceAdminRole = await this.getWorkspaceAdminRole(workspace.id) - - // Check if the admin role is tried to be assigned to the user - if (roleSlugs.includes(workspaceAdminRole.slug)) { - throw new BadRequestException( - constructErrorBody( - 'This role can not be assigned', - 'You can not assign admin role to other members of the workspace' - ) - ) - } - - // Update the role of the user - const membership = await this.prisma.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - workspaceId: workspace.id, - userId: otherUser.id - } - } - }) - - // Clear out the existing roles - const deleteExistingAssociations = - this.prisma.workspaceMemberRoleAssociation.deleteMany({ - where: { - workspaceMemberId: membership.id - } - }) - - const roleSet = new Set() - - for (const slug of roleSlugs) { - const role = await this.prisma.workspaceRole.findUnique({ - where: { - slug - } - }) - - if (!role) { - throw new NotFoundException( - constructErrorBody( - 'Role not found', - `Role ${slug} not found in the workspace ${workspace.name} (${workspace.id})` - ) - ) - } - - roleSet.add(role) - } - - // Create new associations - const createNewAssociations = - this.prisma.workspaceMemberRoleAssociation.createMany({ - data: Array.from(roleSet).map((role) => ({ - roleId: role.id, - workspaceMemberId: membership.id - })) - }) - - await this.prisma.$transaction([ - deleteExistingAssociations, - createNewAssociations - ]) - - await createEvent( - { - triggeredBy: user, - entity: workspace, - type: EventType.WORKSPACE_MEMBERSHIP_UPDATED, - source: EventSource.WORKSPACE, - title: `Updated role of user in workspace`, - metadata: { - workspaceId: workspace.id, - name: workspace.name, - userId: otherUser.id, - roleIds: roleSlugs - }, - workspaceId: workspace.id - }, - this.prisma - ) - - this.log.debug( - `Updated role of user ${otherUser.id} in workspace ${workspace.name} (${workspace.id})` - ) - } - - /** - * Gets all members of a workspace, paginated. - * @param user The user to get the members for - * @param workspaceSlug The slug of the workspace to get the members from - * @param page The page number to get - * @param limit The number of items per page to get - * @param sort The field to sort by - * @param order The order to sort in - * @param search The search string to filter by - * @returns The members of the workspace, paginated, with metadata - */ - async getAllMembersOfWorkspace( - user: User, - workspaceSlug: Workspace['slug'], - page: number, - limit: number, - sort: string, - order: string, - search: string - ) { - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.READ_USERS], - prisma: this.prisma - }) - //get all members of workspace for page with limit - const items = await this.prisma.workspaceMember.findMany({ - skip: page * limit, - take: limit, - orderBy: { - workspace: { - [sort]: order - } - }, - where: { - workspaceId: workspace.id, - user: { - OR: [ - { - name: { - contains: search - } - }, - { - email: { - contains: search.toLowerCase() - } - } - ] - } - }, - select: { - id: true, - user: true, - roles: { - select: { - id: true, - role: { - select: { - id: true, - name: true, - description: true, - colorCode: true, - authorities: true, - projects: { - select: { - id: true - } - } - } - } - } - }, - invitationAccepted: true - } - }) - - //calculate metadata for pagination - const totalCount = await this.prisma.workspaceMember.count({ - where: { - workspaceId: workspace.id, - user: { - OR: [ - { - name: { - contains: search - } - }, - { - email: { - contains: search.toLowerCase() - } - } - ] - } - } - }) - - const metadata = paginate( - totalCount, - `/workspace-membership/${workspace.slug}/members`, - { - page, - limit: limitMaxItemsPerPage(limit), - sort, - order, - search - } - ) - - return { items, metadata } - } - - /** - * Accepts an invitation to a workspace. - * @param user The user to accept the invitation for - * @param workspaceSlug The slug of the workspace to accept the invitation for - * @throws BadRequestException if the user does not have a pending invitation to the workspace - * @throws NotFoundException if the workspace does not exist - * @throws InternalServerErrorException if there is an error in the transaction - */ - async acceptInvitation( - user: User, - workspaceSlug: Workspace['slug'] - ): Promise { - // Check if the user has a pending invitation to the workspace - await this.checkInvitationPending(workspaceSlug, user) - - const workspace = await this.prisma.workspace.findUnique({ - where: { - slug: workspaceSlug - } - }) - - // Update the membership - await this.prisma.workspaceMember.update({ - where: { - workspaceId_userId: { - workspaceId: workspace.id, - userId: user.id - } - }, - data: { - invitationAccepted: true - } - }) - - await createEvent( - { - triggeredBy: user, - entity: workspace, - type: EventType.ACCEPTED_INVITATION, - source: EventSource.WORKSPACE, - title: `${user.name} accepted invitation to workspace ${workspace.name}`, - metadata: { - workspaceId: workspace.id - }, - workspaceId: workspace.id - }, - this.prisma - ) - - this.log.debug( - `User ${user.name} (${user.id}) accepted invitation to workspace ${workspace.id}` - ) - } - - /** - * Cancels an invitation to a workspace. - * @param user The user cancelling the invitation - * @param workspaceSlug The slug of the workspace to cancel the invitation for - * @param inviteeEmail The email of the user to cancel the invitation for - * @throws BadRequestException if the user does not have a pending invitation to the workspace - * @throws NotFoundException if the workspace or the user to cancel the invitation for do not exist - * @throws InternalServerErrorException if there is an error in the transaction - */ - async cancelInvitation( - user: User, - workspaceSlug: Workspace['slug'], - inviteeEmail: User['email'] - ): Promise { - const inviteeUser = await getUserByEmailOrId(inviteeEmail, this.prisma) - - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.REMOVE_USER], - prisma: this.prisma - }) - - // Check if the user has a pending invitation to the workspace - await this.checkInvitationPending(workspaceSlug, inviteeUser) - - // Delete the membership - await this.deleteMembership(workspace.id, inviteeUser.id) - - await createEvent( - { - triggeredBy: user, - entity: workspace, - type: EventType.CANCELLED_INVITATION, - source: EventSource.WORKSPACE, - title: `Cancelled invitation to workspace`, - metadata: { - workspaceId: workspace.id, - inviteeId: inviteeUser.id - }, - workspaceId: workspace.id - }, - this.prisma - ) - - this.log.debug( - `User ${user.name} (${user.id}) cancelled invitation to workspace ${workspace.id}` - ) - } - - /** - * Declines an invitation to a workspace. - * @param user The user declining the invitation - * @param workspaceSlug The slug of the workspace to decline the invitation for - * @throws BadRequestException if the user does not have a pending invitation to the workspace - * @throws NotFoundException if the workspace does not exist - * @throws InternalServerErrorException if there is an error in the transaction - */ - async declineInvitation( - user: User, - workspaceSlug: Workspace['slug'] - ): Promise { - // Check if the user has a pending invitation to the workspace - await this.checkInvitationPending(workspaceSlug, user) - - const workspace = await this.prisma.workspace.findUnique({ - where: { - slug: workspaceSlug - } - }) - - // Delete the membership - await this.deleteMembership(workspace.id, user.id) - - await createEvent( - { - triggeredBy: user, - entity: workspace, - type: EventType.DECLINED_INVITATION, - source: EventSource.WORKSPACE, - title: `${user.name} declined invitation to workspace ${workspace.name}`, - metadata: { - workspaceId: workspace.id - }, - workspaceId: workspace.id - }, - this.prisma - ) - - this.log.debug( - `User ${user.name} (${user.id}) declined invitation to workspace ${workspace.id}` - ) - } - - /** - * Leaves a workspace. - * @throws BadRequestException if the user is the owner of the workspace - * @param user The user to leave the workspace for - * @param workspaceSlug The slug of the workspace to leave - */ - async leaveWorkspace( - user: User, - workspaceSlug: Workspace['slug'] - ): Promise { - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.READ_WORKSPACE], - prisma: this.prisma - }) - - const workspaceOwnerId = await this.prisma.workspace - .findUnique({ - where: { - id: workspace.id - }, - select: { - ownerId: true - } - }) - .then((workspace) => workspace.ownerId) - - // Check if the user is the owner of the workspace - if (workspaceOwnerId === user.id) - throw new BadRequestException( - constructErrorBody( - 'Can not leave workspace', - 'You cannot leave the workspace as you are the owner of the workspace. Please transfer the ownership to another member before leaving the workspace.' - ) - ) - - // Delete the membership - await this.deleteMembership(workspace.id, user.id) - - await createEvent( - { - triggeredBy: user, - entity: workspace, - type: EventType.LEFT_WORKSPACE, - source: EventSource.WORKSPACE, - title: `User left workspace`, - metadata: { - workspaceId: workspace.id - }, - workspaceId: workspace.id - }, - this.prisma - ) - - this.log.debug( - `User ${user.name} (${user.id}) left workspace ${workspace.id}` - ) - } - - /** - * Checks if a user is a member of a workspace. - * @param user The user to check if the other user is a member of the workspace for - * @param workspaceSlug The slug of the workspace to check if the user is a member of - * @param otherUserEmail The email of the user to check if is a member of the workspace - * @returns True if the user is a member of the workspace, false otherwise - */ - async isUserMemberOfWorkspace( - user: User, - workspaceSlug: Workspace['slug'], - otherUserEmail: User['email'] - ): Promise { - let otherUser: User | null = null - - try { - otherUser = await getUserByEmailOrId(otherUserEmail, this.prisma) - } catch (e) { - return false - } - - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.READ_USERS], - prisma: this.prisma - }) - - return await this.memberExistsInWorkspace(workspace.id, otherUser.id) - } - - private async getWorkspaceAdminRole( - workspaceId: Workspace['id'] - ): Promise { - const adminRole = await this.prisma.workspaceRole.findFirst({ - where: { - hasAdminAuthority: true, - workspaceId - } - }) - - if (!adminRole) { - throw new InternalServerErrorException( - `Admin role not found for workspace ${workspaceId}` - ) - } - - return adminRole - } - - /** - * Adds members to a workspace. - * @param workspace The workspace to add members to - * @param currentUser The user performing the action - * @param members The members to add to the workspace - * @throws BadRequestException if the admin role is tried to be assigned to the user - * @throws ConflictException if the user is already a member of the workspace - * @throws InternalServerErrorException if there is an error in the transaction - * @private - */ - private async addMembersToWorkspace( - workspace: Workspace, - currentUser: User, - members: CreateWorkspaceMember[] - ) { - const workspaceAdminRole = await this.getWorkspaceAdminRole(workspace.id) - - for (const member of members) { - // Check if the admin role is tried to be assigned to the user - if (member.roleSlugs.includes(workspaceAdminRole.slug)) { - throw new BadRequestException( - constructErrorBody( - 'Admin role cannot be assigned to the user', - 'You can not assign the admin role to the user. Please check the teams tab to confirm whether the user is a member of this workspace' - ) - ) - } - - const memberUser: User | null = await this.prisma.user.findUnique({ - where: { - email: member.email.toLowerCase() - } - }) - - const userId = memberUser?.id ?? v4() - - // Check if the user is already a member of the workspace - if ( - memberUser && - (await this.memberExistsInWorkspace(workspace.id, userId)) - ) { - this.log.warn( - `User ${ - memberUser.name || memberUser.email - } (${userId}) is already a member of workspace ${workspace.name} (${ - workspace.slug - }). Skipping.` - ) - throw new ConflictException( - constructErrorBody( - `User ${memberUser.name || memberUser.email} is already a member of this workspace`, - 'Please check the teams tab to confirm whether the user is a member of this workspace' - ) - ) - } - - const roleSet = new Set() - - for (const slug of member.roleSlugs) { - const role = await this.prisma.workspaceRole.findUnique({ - where: { - slug - } - }) - - if (!role) { - throw new NotFoundException( - constructErrorBody( - `Workspace role ${slug} does not exist`, - `Please check the workspace roles to confirm whether the role exists` - ) - ) - } - - roleSet.add(role) - } - - const invitedOn = new Date() - - // Create the workspace membership - const createMembership = this.prisma.workspaceMember.create({ - data: { - workspaceId: workspace.id, - userId, - createdOn: invitedOn, - roles: { - create: Array.from(roleSet).map((role) => ({ - role: { - connect: { - id: role.id - } - } - })) - } - } - }) - - if (memberUser) { - await this.prisma.$transaction([createMembership]) - - this.mailService.invitedToWorkspace( - member.email, - workspace.name, - `${process.env.WORKSPACE_FRONTEND_URL}/workspace/${workspace.slug}/join`, - currentUser.name, - invitedOn.toISOString(), - true - ) - - this.log.debug( - `Sent workspace invitation mail to registered user ${memberUser}` - ) - } else { - // Create the user - await createUser( - { - id: userId, - email: member.email, - authProvider: AuthProvider.EMAIL_OTP - }, - this.prisma - ) - - await this.prisma.$transaction([createMembership]) - - this.log.debug(`Created non-registered user ${memberUser}`) - - this.mailService.invitedToWorkspace( - member.email, - workspace.name, - `${process.env.WORKSPACE_FRONTEND_URL}/workspace/${ - workspace.id - }/join?token=${await this.jwt.signAsync({ - id: userId - })}`, - currentUser.name, - new Date().toISOString(), - false - ) - - this.log.debug( - `Sent workspace invitation mail to non-registered user ${memberUser}` - ) - } - - this.log.debug(`Added user ${memberUser} to workspace ${workspace.name}.`) - } - } - - /** - * Checks if a user is a member of a workspace. - * @param workspaceId The ID of the workspace to check - * @param userId The ID of the user to check - * @returns True if the user is a member of the workspace, false otherwise - * @private - */ - private async memberExistsInWorkspace( - workspaceId: string, - userId: string - ): Promise { - return ( - (await this.prisma.workspaceMember.count({ - where: { - workspaceId, - userId - } - })) > 0 - ) - } - - /** - * Gets the workspace membership of a user in a workspace. - * @param workspaceId The ID of the workspace to get the membership for - * @param userId The ID of the user to get the membership for - * @returns The workspace membership of the user in the workspace - * @private - */ - private async getWorkspaceMembership( - workspaceId: Workspace['id'], - userId: User['id'] - ): Promise { - return await this.prisma.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - workspaceId, - userId - } - } - }) - } - - /** - * Deletes the membership of a user in a workspace. - * @param workspaceId The ID of the workspace to delete the membership from - * @param userId The ID of the user to delete the membership for - * @returns A promise that resolves when the membership is deleted - * @private - */ - private async deleteMembership( - workspaceId: Workspace['id'], - userId: User['id'] - ): Promise { - await this.prisma.workspaceMember.delete({ - where: { - workspaceId_userId: { - workspaceId, - userId - } - } - }) - } - - /** - * Checks if a user has a pending invitation to a workspace. - * @throws BadRequestException if the user is not invited to the workspace - * @param workspaceSlug The slug of the workspace to check if the user is invited to - * @param user The user to check if the user is invited to the workspace - */ - private async checkInvitationPending( - workspaceSlug: Workspace['slug'], - user: User - ): Promise { - const membershipExists = await this.prisma.workspaceMember - .count({ - where: { - workspace: { - slug: workspaceSlug - }, - userId: user.id, - invitationAccepted: false - } - }) - .then((count) => count > 0) - - if (!membershipExists) - throw new BadRequestException( - constructErrorBody( - 'User is not invited to the workspace', - `${user.email} is not invited to workspace ${workspaceSlug}` - ) - ) - } -} +import { paginate } from '@/common/paginate' +import { createUser, getUserByEmailOrId } from '@/common/user' +import { IMailService, MAIL_SERVICE } from '@/mail/services/interface.service' +import { PrismaService } from '@/prisma/prisma.service' +import { AuthorizationService } from '@/auth/service/authorization.service' +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException +} from '@nestjs/common' +import { JwtService } from '@nestjs/jwt' +import { + Authority, + AuthProvider, + EventSource, + EventType, + User, + Workspace, + WorkspaceMember, + WorkspaceRole +} from '@prisma/client' +import { v4 } from 'uuid' +import { CreateWorkspaceMember } from '../dto/create.workspace/create.workspace-membership' +import { createEvent } from '@/common/event' +import { constructErrorBody, limitMaxItemsPerPage } from '@/common/util' +import { AuthenticatedUser } from '@/user/user.types' + +@Injectable() +export class WorkspaceMembershipService { + private readonly log = new Logger(WorkspaceMembershipService.name) + + constructor( + private readonly prisma: PrismaService, + private readonly authorizationService: AuthorizationService, + private readonly jwt: JwtService, + @Inject(MAIL_SERVICE) private readonly mailService: IMailService + ) {} + + /** + * Transfers ownership of a workspace to another user. + * @param user The user transferring the ownership + * @param workspaceSlug The slug of the workspace to transfer + * @param otherUserEmail The email of the user to transfer the ownership to + * @throws BadRequestException if the user is already the owner of the workspace, + * or if the workspace is the default workspace + * @throws NotFoundException if the other user is not a member of the workspace + * @throws InternalServerErrorException if there is an error in the transaction + */ + async transferOwnership( + user: AuthenticatedUser, + workspaceSlug: Workspace['slug'], + otherUserEmail: User['email'] + ): Promise { + const workspace = + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, + entity: { slug: workspaceSlug }, + authorities: [Authority.WORKSPACE_ADMIN] + }) + + const otherUser = await getUserByEmailOrId(otherUserEmail, this.prisma) + + if (otherUser.id === user.id) { + throw new BadRequestException( + constructErrorBody( + 'You cannot transfer ownership to yourself', + `You are already the owner of this workspace` + ) + ) + } + + // We don't want the users to be able to transfer + // ownership if the workspace is the default workspace + if (workspace.isDefault) { + throw new BadRequestException( + constructErrorBody( + 'Can not transfer default workspace ownership', + `You cannot transfer ownership of a default workspace.` + ) + ) + } + + const workspaceMembership = await this.getWorkspaceMembership( + workspace.id, + otherUser.id + ) + + // Check if the user is a member of the workspace + if (!workspaceMembership) { + throw new NotFoundException( + constructErrorBody( + 'You are not a member of this workspace', + `Could not resolve your access to this workspace. If you think this is a mistake, please get in touch with the workspace admin.` + ) + ) + } + + // Check if the user has accepted the invitation + if (!workspaceMembership.invitationAccepted) { + throw new BadRequestException( + constructErrorBody( + 'You have not accepted the invitation', + `Your invitation to this workspace is still pending. Check the invitations tab to accept the invitation.` + ) + ) + } + + const currentUserMembership = await this.getWorkspaceMembership( + workspace.id, + user.id + ) + + // Get the admin ownership role + const adminOwnershipRole = await this.prisma.workspaceRole.findFirst({ + where: { + workspaceId: workspace.id, + hasAdminAuthority: true + } + }) + + // Remove this role from the current owner + const removeRole = this.prisma.workspaceMemberRoleAssociation.delete({ + where: { + roleId_workspaceMemberId: { + roleId: adminOwnershipRole.id, + workspaceMemberId: currentUserMembership.id + } + } + }) + + // Assign this role to the new owner + const assignRole = this.prisma.workspaceMemberRoleAssociation.create({ + data: { + role: { + connect: { + id: adminOwnershipRole.id + } + }, + workspaceMember: { + connect: { + id: workspaceMembership.id + } + } + } + }) + + // Update the owner of the workspace + const updateWorkspace = this.prisma.workspace.update({ + where: { + id: workspace.id + }, + data: { + ownerId: otherUser.id, + lastUpdatedBy: { + connect: { + id: user.id + } + } + } + }) + + try { + await this.prisma.$transaction([removeRole, assignRole, updateWorkspace]) + } catch (e) { + this.log.error('Error in transaction', e) + throw new InternalServerErrorException('Error in transaction') + } + + await createEvent( + { + triggeredBy: user, + entity: workspace, + type: EventType.WORKSPACE_UPDATED, + source: EventSource.WORKSPACE, + title: `Workspace transferred`, + metadata: { + workspaceId: workspace.id, + name: workspace.name, + newOwnerId: otherUser.id + }, + workspaceId: workspace.id + }, + this.prisma + ) + + this.log.debug( + `Transferred ownership of workspace ${workspace.name} (${workspace.id}) to user ${otherUser.email} (${otherUser.id})` + ) + } + + /** + * Invites users to a workspace. + * @param user The user to invite the users for + * @param workspaceSlug The slug of the workspace to invite users to + * @param members The members to invite + * @throws BadRequestException if the user does not have the authority to add users to the workspace + * @throws NotFoundException if the workspace or any of the users to invite do not exist + * @throws InternalServerErrorException if there is an error in the transaction + */ + async inviteUsersToWorkspace( + user: AuthenticatedUser, + workspaceSlug: Workspace['slug'], + members: CreateWorkspaceMember[] + ): Promise { + const workspace = + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, + entity: { slug: workspaceSlug }, + authorities: [Authority.ADD_USER] + }) + + // Add users to the workspace if any + if (members && members.length > 0) { + await this.addMembersToWorkspace(workspace, user, members) + + await createEvent( + { + triggeredBy: user, + entity: workspace, + type: EventType.INVITED_TO_WORKSPACE, + source: EventSource.WORKSPACE, + title: `Invited users to workspace`, + metadata: { + workspaceId: workspace.id, + name: workspace.name, + members: members.map((m) => m.email) + }, + workspaceId: workspace.id + }, + this.prisma + ) + + this.log.debug( + `Added users to workspace ${workspace.name} (${workspace.id})` + ) + + return + } + + this.log.warn( + `No users to add to workspace ${workspace.name} (${workspace.id})` + ) + } + + /** + * Removes users from a workspace. + * @param user The user to remove users from the workspace for + * @param workspaceSlug The slug of the workspace to remove users from + * @param userEmails The emails of the users to remove from the workspace + * @throws BadRequestException if the user is trying to remove themselves from the workspace, + * or if the user is not a member of the workspace + * @throws NotFoundException if the workspace or any of the users to remove do not exist + * @throws InternalServerErrorException if there is an error in the transaction + */ + async removeUsersFromWorkspace( + user: AuthenticatedUser, + workspaceSlug: Workspace['slug'], + userEmails: User['email'][] + ): Promise { + const workspace = + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, + entity: { slug: workspaceSlug }, + authorities: [Authority.REMOVE_USER] + }) + + const userIds = await this.prisma.user + .findMany({ + where: { + email: { + in: userEmails.map((email) => email.toLowerCase()) + } + }, + select: { + id: true + } + }) + .then((users) => users.map((u) => u.id)) + + // Remove users from the workspace if any + if (userIds && userIds.length > 0) { + if (userIds.find((id) => id === user.id)) { + throw new BadRequestException( + constructErrorBody( + `You can not remove yourself from the workspace.`, + `You can only leave a workspace.` + ) + ) + } + + // Delete the membership + await this.prisma.workspaceMember.deleteMany({ + where: { + workspaceId: workspace.id, + userId: { + in: userIds + } + } + }) + + // Send an email to the removed users + const removedOn = new Date() + const emailPromises = userEmails.map((userEmail) => + this.mailService.removedFromWorkspace( + userEmail, + workspace.name, + removedOn + ) + ) + + await Promise.all(emailPromises) + } + + await createEvent( + { + triggeredBy: user, + entity: workspace, + type: EventType.REMOVED_FROM_WORKSPACE, + source: EventSource.WORKSPACE, + title: `Removed users from workspace`, + metadata: { + workspaceId: workspace.id, + name: workspace.name, + members: userIds + }, + workspaceId: workspace.id + }, + this.prisma + ) + + this.log.debug( + `Removed users from workspace ${workspace.name} (${workspace.id})` + ) + } + + /** + * Updates the roles of a user in a workspace. + * + * @throws NotFoundException if the user is not a member of the workspace + * @throws BadRequestException if the admin role is tried to be assigned to the user + * @param user The user to update the roles for + * @param workspaceSlug The slug of the workspace to update the roles in + * @param otherUserEmail The email of the user to update the roles for + * @param roleSlugs The slugs of the roles to assign to the user + */ + async updateMemberRoles( + user: AuthenticatedUser, + workspaceSlug: Workspace['slug'], + otherUserEmail: User['email'], + roleSlugs: WorkspaceRole['slug'][] + ): Promise { + const otherUser = await getUserByEmailOrId(otherUserEmail, this.prisma) + + const workspace = + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, + entity: { slug: workspaceSlug }, + authorities: [Authority.UPDATE_USER_ROLE] + }) + + if (!roleSlugs || roleSlugs.length === 0) { + this.log.warn( + `No roles to update for user ${otherUserEmail} in workspace ${workspace.name} (${workspace.id})` + ) + } + + // Check if the member in concern is a part of the workspace or not + if (!(await this.memberExistsInWorkspace(workspace.id, otherUser.id))) + throw new NotFoundException( + constructErrorBody( + 'User is not a member of the workspace', + 'Please check the teams tab to confirm whether the user is a member of this workspace' + ) + ) + + const workspaceAdminRole = await this.getWorkspaceAdminRole(workspace.id) + + // Check if the admin role is tried to be assigned to the user + if (roleSlugs.includes(workspaceAdminRole.slug)) { + throw new BadRequestException( + constructErrorBody( + 'This role can not be assigned', + 'You can not assign admin role to other members of the workspace' + ) + ) + } + + // Update the role of the user + const membership = await this.prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace.id, + userId: otherUser.id + } + } + }) + + // Clear out the existing roles + const deleteExistingAssociations = + this.prisma.workspaceMemberRoleAssociation.deleteMany({ + where: { + workspaceMemberId: membership.id + } + }) + + const roleSet = new Set() + + for (const slug of roleSlugs) { + const role = await this.prisma.workspaceRole.findUnique({ + where: { + slug + } + }) + + if (!role) { + throw new NotFoundException( + constructErrorBody( + 'Role not found', + `Role ${slug} not found in the workspace ${workspace.name} (${workspace.id})` + ) + ) + } + + roleSet.add(role) + } + + // Create new associations + const createNewAssociations = + this.prisma.workspaceMemberRoleAssociation.createMany({ + data: Array.from(roleSet).map((role) => ({ + roleId: role.id, + workspaceMemberId: membership.id + })) + }) + + await this.prisma.$transaction([ + deleteExistingAssociations, + createNewAssociations + ]) + + await createEvent( + { + triggeredBy: user, + entity: workspace, + type: EventType.WORKSPACE_MEMBERSHIP_UPDATED, + source: EventSource.WORKSPACE, + title: `Updated role of user in workspace`, + metadata: { + workspaceId: workspace.id, + name: workspace.name, + userId: otherUser.id, + roleIds: roleSlugs + }, + workspaceId: workspace.id + }, + this.prisma + ) + + this.log.debug( + `Updated role of user ${otherUser.id} in workspace ${workspace.name} (${workspace.id})` + ) + } + + /** + * Gets all members of a workspace, paginated. + * @param user The user to get the members for + * @param workspaceSlug The slug of the workspace to get the members from + * @param page The page number to get + * @param limit The number of items per page to get + * @param sort The field to sort by + * @param order The order to sort in + * @param search The search string to filter by + * @returns The members of the workspace, paginated, with metadata + */ + async getAllMembersOfWorkspace( + user: AuthenticatedUser, + workspaceSlug: Workspace['slug'], + page: number, + limit: number, + sort: string, + order: string, + search: string + ) { + const workspace = + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, + entity: { slug: workspaceSlug }, + authorities: [Authority.READ_USERS] + }) + //get all members of workspace for page with limit + const items = await this.prisma.workspaceMember.findMany({ + skip: page * limit, + take: limit, + orderBy: { + workspace: { + [sort]: order + } + }, + where: { + workspaceId: workspace.id, + user: { + OR: [ + { + name: { + contains: search + } + }, + { + email: { + contains: search.toLowerCase() + } + } + ] + } + }, + select: { + id: true, + user: true, + roles: { + select: { + id: true, + role: { + select: { + id: true, + name: true, + description: true, + colorCode: true, + authorities: true, + projects: { + select: { + id: true + } + } + } + } + } + }, + invitationAccepted: true + } + }) + + //calculate metadata for pagination + const totalCount = await this.prisma.workspaceMember.count({ + where: { + workspaceId: workspace.id, + user: { + OR: [ + { + name: { + contains: search + } + }, + { + email: { + contains: search.toLowerCase() + } + } + ] + } + } + }) + + const metadata = paginate( + totalCount, + `/workspace-membership/${workspace.slug}/members`, + { + page, + limit: limitMaxItemsPerPage(limit), + sort, + order, + search + } + ) + + return { items, metadata } + } + + /** + * Accepts an invitation to a workspace. + * @param user The user to accept the invitation for + * @param workspaceSlug The slug of the workspace to accept the invitation for + * @throws BadRequestException if the user does not have a pending invitation to the workspace + * @throws NotFoundException if the workspace does not exist + * @throws InternalServerErrorException if there is an error in the transaction + */ + async acceptInvitation( + user: AuthenticatedUser, + workspaceSlug: Workspace['slug'] + ): Promise { + // Check if the user has a pending invitation to the workspace + await this.checkInvitationPending(workspaceSlug, user) + + const workspace = await this.prisma.workspace.findUnique({ + where: { + slug: workspaceSlug + } + }) + + // Update the membership + await this.prisma.workspaceMember.update({ + where: { + workspaceId_userId: { + workspaceId: workspace.id, + userId: user.id + } + }, + data: { + invitationAccepted: true + } + }) + + await createEvent( + { + triggeredBy: user, + entity: workspace, + type: EventType.ACCEPTED_INVITATION, + source: EventSource.WORKSPACE, + title: `${user.name} accepted invitation to workspace ${workspace.name}`, + metadata: { + workspaceId: workspace.id + }, + workspaceId: workspace.id + }, + this.prisma + ) + + this.log.debug( + `User ${user.name} (${user.id}) accepted invitation to workspace ${workspace.id}` + ) + } + + /** + * Cancels an invitation to a workspace. + * @param user The user cancelling the invitation + * @param workspaceSlug The slug of the workspace to cancel the invitation for + * @param inviteeEmail The email of the user to cancel the invitation for + * @throws BadRequestException if the user does not have a pending invitation to the workspace + * @throws NotFoundException if the workspace or the user to cancel the invitation for do not exist + * @throws InternalServerErrorException if there is an error in the transaction + */ + async cancelInvitation( + user: AuthenticatedUser, + workspaceSlug: Workspace['slug'], + inviteeEmail: User['email'] + ): Promise { + const inviteeUser = await getUserByEmailOrId(inviteeEmail, this.prisma) + + const workspace = + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, + entity: { slug: workspaceSlug }, + authorities: [Authority.REMOVE_USER] + }) + + // Check if the user has a pending invitation to the workspace + await this.checkInvitationPending(workspaceSlug, inviteeUser) + + // Delete the membership + await this.deleteMembership(workspace.id, inviteeUser.id) + + await createEvent( + { + triggeredBy: user, + entity: workspace, + type: EventType.CANCELLED_INVITATION, + source: EventSource.WORKSPACE, + title: `Cancelled invitation to workspace`, + metadata: { + workspaceId: workspace.id, + inviteeId: inviteeUser.id + }, + workspaceId: workspace.id + }, + this.prisma + ) + + this.log.debug( + `User ${user.name} (${user.id}) cancelled invitation to workspace ${workspace.id}` + ) + } + + /** + * Declines an invitation to a workspace. + * @param user The user declining the invitation + * @param workspaceSlug The slug of the workspace to decline the invitation for + * @throws BadRequestException if the user does not have a pending invitation to the workspace + * @throws NotFoundException if the workspace does not exist + * @throws InternalServerErrorException if there is an error in the transaction + */ + async declineInvitation( + user: AuthenticatedUser, + workspaceSlug: Workspace['slug'] + ): Promise { + // Check if the user has a pending invitation to the workspace + await this.checkInvitationPending(workspaceSlug, user) + + const workspace = await this.prisma.workspace.findUnique({ + where: { + slug: workspaceSlug + } + }) + + // Delete the membership + await this.deleteMembership(workspace.id, user.id) + + await createEvent( + { + triggeredBy: user, + entity: workspace, + type: EventType.DECLINED_INVITATION, + source: EventSource.WORKSPACE, + title: `${user.name} declined invitation to workspace ${workspace.name}`, + metadata: { + workspaceId: workspace.id + }, + workspaceId: workspace.id + }, + this.prisma + ) + + this.log.debug( + `User ${user.name} (${user.id}) declined invitation to workspace ${workspace.id}` + ) + } + + /** + * Leaves a workspace. + * @throws BadRequestException if the user is the owner of the workspace + * @param user The user to leave the workspace for + * @param workspaceSlug The slug of the workspace to leave + */ + async leaveWorkspace( + user: AuthenticatedUser, + workspaceSlug: Workspace['slug'] + ): Promise { + const workspace = + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, + entity: { slug: workspaceSlug }, + authorities: [Authority.READ_WORKSPACE] + }) + + const workspaceOwnerId = await this.prisma.workspace + .findUnique({ + where: { + id: workspace.id + }, + select: { + ownerId: true + } + }) + .then((workspace) => workspace.ownerId) + + // Check if the user is the owner of the workspace + if (workspaceOwnerId === user.id) + throw new BadRequestException( + constructErrorBody( + 'Can not leave workspace', + 'You cannot leave the workspace as you are the owner of the workspace. Please transfer the ownership to another member before leaving the workspace.' + ) + ) + + // Delete the membership + await this.deleteMembership(workspace.id, user.id) + + await createEvent( + { + triggeredBy: user, + entity: workspace, + type: EventType.LEFT_WORKSPACE, + source: EventSource.WORKSPACE, + title: `User left workspace`, + metadata: { + workspaceId: workspace.id + }, + workspaceId: workspace.id + }, + this.prisma + ) + + this.log.debug( + `User ${user.name} (${user.id}) left workspace ${workspace.id}` + ) + } + + /** + * Checks if a user is a member of a workspace. + * @param user The user to check if the other user is a member of the workspace for + * @param workspaceSlug The slug of the workspace to check if the user is a member of + * @param otherUserEmail The email of the user to check if is a member of the workspace + * @returns True if the user is a member of the workspace, false otherwise + */ + async isUserMemberOfWorkspace( + user: AuthenticatedUser, + workspaceSlug: Workspace['slug'], + otherUserEmail: User['email'] + ): Promise { + let otherUser: User | null = null + + try { + otherUser = await getUserByEmailOrId(otherUserEmail, this.prisma) + } catch (e) { + return false + } + + const workspace = + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, + entity: { slug: workspaceSlug }, + authorities: [Authority.READ_USERS] + }) + + return await this.memberExistsInWorkspace(workspace.id, otherUser.id) + } + + private async getWorkspaceAdminRole( + workspaceId: Workspace['id'] + ): Promise { + const adminRole = await this.prisma.workspaceRole.findFirst({ + where: { + hasAdminAuthority: true, + workspaceId + } + }) + + if (!adminRole) { + throw new InternalServerErrorException( + `Admin role not found for workspace ${workspaceId}` + ) + } + + return adminRole + } + + /** + * Adds members to a workspace. + * @param workspace The workspace to add members to + * @param currentUser The user performing the action + * @param members The members to add to the workspace + * @throws BadRequestException if the admin role is tried to be assigned to the user + * @throws ConflictException if the user is already a member of the workspace + * @throws InternalServerErrorException if there is an error in the transaction + * @private + */ + private async addMembersToWorkspace( + workspace: Workspace, + currentUser: AuthenticatedUser, + members: CreateWorkspaceMember[] + ) { + const workspaceAdminRole = await this.getWorkspaceAdminRole(workspace.id) + + for (const member of members) { + // Check if the admin role is tried to be assigned to the user + if (member.roleSlugs.includes(workspaceAdminRole.slug)) { + throw new BadRequestException( + constructErrorBody( + 'Admin role cannot be assigned to the user', + 'You can not assign the admin role to the user. Please check the teams tab to confirm whether the user is a member of this workspace' + ) + ) + } + + const memberUser: User | null = await this.prisma.user.findUnique({ + where: { + email: member.email.toLowerCase() + } + }) + + const userId = memberUser?.id ?? v4() + + // Check if the user is already a member of the workspace + if ( + memberUser && + (await this.memberExistsInWorkspace(workspace.id, userId)) + ) { + this.log.warn( + `User ${ + memberUser.name || memberUser.email + } (${userId}) is already a member of workspace ${workspace.name} (${ + workspace.slug + }). Skipping.` + ) + throw new ConflictException( + constructErrorBody( + `User ${memberUser.name || memberUser.email} is already a member of this workspace`, + 'Please check the teams tab to confirm whether the user is a member of this workspace' + ) + ) + } + + const roleSet = new Set() + + for (const slug of member.roleSlugs) { + const role = await this.prisma.workspaceRole.findUnique({ + where: { + slug + } + }) + + if (!role) { + throw new NotFoundException( + constructErrorBody( + `Workspace role ${slug} does not exist`, + `Please check the workspace roles to confirm whether the role exists` + ) + ) + } + + roleSet.add(role) + } + + const invitedOn = new Date() + + // Create the workspace membership + const createMembership = this.prisma.workspaceMember.create({ + data: { + workspaceId: workspace.id, + userId, + createdOn: invitedOn, + roles: { + create: Array.from(roleSet).map((role) => ({ + role: { + connect: { + id: role.id + } + } + })) + } + } + }) + + if (memberUser) { + await this.prisma.$transaction([createMembership]) + + this.mailService.invitedToWorkspace( + member.email, + workspace.name, + `${process.env.WORKSPACE_FRONTEND_URL}/workspace/${workspace.slug}/join`, + currentUser.name, + invitedOn.toISOString(), + true + ) + + this.log.debug( + `Sent workspace invitation mail to registered user ${memberUser}` + ) + } else { + // Create the user + await createUser( + { + id: userId, + email: member.email, + authProvider: AuthProvider.EMAIL_OTP + }, + this.prisma + ) + + await this.prisma.$transaction([createMembership]) + + this.log.debug(`Created non-registered user ${memberUser}`) + + this.mailService.invitedToWorkspace( + member.email, + workspace.name, + `${process.env.WORKSPACE_FRONTEND_URL}/workspace/${ + workspace.id + }/join?token=${await this.jwt.signAsync({ + id: userId + })}`, + currentUser.name, + new Date().toISOString(), + false + ) + + this.log.debug( + `Sent workspace invitation mail to non-registered user ${memberUser}` + ) + } + + this.log.debug(`Added user ${memberUser} to workspace ${workspace.name}.`) + } + } + + /** + * Checks if a user is a member of a workspace. + * @param workspaceId The ID of the workspace to check + * @param userId The ID of the user to check + * @returns True if the user is a member of the workspace, false otherwise + * @private + */ + private async memberExistsInWorkspace( + workspaceId: string, + userId: string + ): Promise { + return ( + (await this.prisma.workspaceMember.count({ + where: { + workspaceId, + userId + } + })) > 0 + ) + } + + /** + * Gets the workspace membership of a user in a workspace. + * @param workspaceId The ID of the workspace to get the membership for + * @param userId The ID of the user to get the membership for + * @returns The workspace membership of the user in the workspace + * @private + */ + private async getWorkspaceMembership( + workspaceId: Workspace['id'], + userId: User['id'] + ): Promise { + return await this.prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId, + userId + } + } + }) + } + + /** + * Deletes the membership of a user in a workspace. + * @param workspaceId The ID of the workspace to delete the membership from + * @param userId The ID of the user to delete the membership for + * @returns A promise that resolves when the membership is deleted + * @private + */ + private async deleteMembership( + workspaceId: Workspace['id'], + userId: User['id'] + ): Promise { + await this.prisma.workspaceMember.delete({ + where: { + workspaceId_userId: { + workspaceId, + userId + } + } + }) + } + + /** + * Checks if a user has a pending invitation to a workspace. + * @throws BadRequestException if the user is not invited to the workspace + * @param workspaceSlug The slug of the workspace to check if the user is invited to + * @param user The user to check if the user is invited to the workspace + */ + private async checkInvitationPending( + workspaceSlug: Workspace['slug'], + user: User + ): Promise { + const membershipExists = await this.prisma.workspaceMember + .count({ + where: { + workspace: { + slug: workspaceSlug + }, + userId: user.id, + invitationAccepted: false + } + }) + .then((count) => count > 0) + + if (!membershipExists) + throw new BadRequestException( + constructErrorBody( + 'User is not invited to the workspace', + `${user.email} is not invited to workspace ${workspaceSlug}` + ) + ) + } +} diff --git a/apps/api/src/workspace-membership/workspace-membership.e2e.spec.ts b/apps/api/src/workspace-membership/workspace-membership.e2e.spec.ts index 939bfbf3d..98409f813 100644 --- a/apps/api/src/workspace-membership/workspace-membership.e2e.spec.ts +++ b/apps/api/src/workspace-membership/workspace-membership.e2e.spec.ts @@ -32,12 +32,12 @@ import { EventSource, EventTriggerer, EventType, - User, Workspace, WorkspaceRole } from '@prisma/client' import { WorkspaceMembershipService } from './service/workspace-membership.service' import { WorkspaceMembershipModule } from './workspace-membership.module' +import { AuthenticatedUser } from '@/user/user.types' const createMembership = async ( roleId: string, @@ -75,10 +75,14 @@ describe('Workspace Membership Controller Tests', () => { let variableService: VariableService let workspaceRoleService: WorkspaceRoleService - let user1: User, user2: User, user3: User + let user1: AuthenticatedUser, + user2: AuthenticatedUser, + user3: AuthenticatedUser let workspace1: Workspace let adminRole: WorkspaceRole, memberRole: WorkspaceRole + const USER_IP_ADDRESS = '127.0.0.1' + beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [ @@ -143,9 +147,9 @@ describe('Workspace Membership Controller Tests', () => { delete createUser2.defaultWorkspace delete createUser3.defaultWorkspace - user1 = createUser1 - user2 = createUser2 - user3 = createUser3 + user1 = { ...createUser1, ipAddress: USER_IP_ADDRESS } + user2 = { ...createUser2, ipAddress: USER_IP_ADDRESS } + user3 = { ...createUser3, ipAddress: USER_IP_ADDRESS } memberRole = await prisma.workspaceRole.create({ data: { diff --git a/apps/api/src/workspace-role/controller/workspace-role.controller.spec.ts b/apps/api/src/workspace-role/controller/workspace-role.controller.spec.ts index 2b3ce240b..a52993071 100644 --- a/apps/api/src/workspace-role/controller/workspace-role.controller.spec.ts +++ b/apps/api/src/workspace-role/controller/workspace-role.controller.spec.ts @@ -1,10 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing' import { WorkspaceRoleController } from './workspace-role.controller' -import { MockMailService } from '@/mail/services/mock.service' -import { MAIL_SERVICE } from '@/mail/services/interface.service' import { PrismaService } from '@/prisma/prisma.service' import { WorkspaceRoleService } from '../service/workspace-role.service' -import { AuthorityCheckerService } from '@/common/authority-checker.service' +import { AuthorizationService } from '@/auth/service/authorization.service' +import { AuthorityCheckerService } from '@/auth/service/authority-checker.service' import { CommonModule } from '@/common/common.module' describe('WorkspaceRoleController', () => { @@ -16,7 +15,7 @@ describe('WorkspaceRoleController', () => { providers: [ WorkspaceRoleService, PrismaService, - { provide: MAIL_SERVICE, useClass: MockMailService }, + AuthorizationService, AuthorityCheckerService ], controllers: [WorkspaceRoleController] 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 478ba424f..e46b010bd 100644 --- a/apps/api/src/workspace-role/controller/workspace-role.controller.ts +++ b/apps/api/src/workspace-role/controller/workspace-role.controller.ts @@ -10,10 +10,11 @@ import { } 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 { Authority, Workspace, WorkspaceRole } from '@prisma/client' import { CreateWorkspaceRole } from '../dto/create-workspace-role/create-workspace-role' import { UpdateWorkspaceRole } from '../dto/update-workspace-role/update-workspace-role' import { RequiredApiKeyAuthorities } from '@/decorators/required-api-key-authorities.decorator' +import { AuthenticatedUser } from '@/user/user.types' @Controller('workspace-role') export class WorkspaceRoleController { @@ -22,7 +23,7 @@ export class WorkspaceRoleController { @Post(':workspaceSlug') @RequiredApiKeyAuthorities(Authority.CREATE_WORKSPACE_ROLE) async createWorkspaceRole( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceSlug') workspaceSlug: Workspace['slug'], @Body() dto: CreateWorkspaceRole ) { @@ -36,7 +37,7 @@ export class WorkspaceRoleController { @Put(':workspaceRoleSlug') @RequiredApiKeyAuthorities(Authority.UPDATE_WORKSPACE_ROLE) async updateWorkspaceRole( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceRoleSlug') workspaceRoleSlug: WorkspaceRole['slug'], @Body() dto: UpdateWorkspaceRole ) { @@ -50,7 +51,7 @@ export class WorkspaceRoleController { @Delete(':workspaceRoleSlug') @RequiredApiKeyAuthorities(Authority.DELETE_WORKSPACE_ROLE) async deleteWorkspaceRole( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceRoleSlug') workspaceRoleSlug: WorkspaceRole['slug'] ) { return await this.workspaceRoleService.deleteWorkspaceRole( @@ -62,7 +63,7 @@ export class WorkspaceRoleController { @Get(':workspaceSlug/exists/:workspaceRoleName') @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE_ROLE) async checkWorkspaceRoleExists( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceSlug') workspaceSlug: Workspace['slug'], @Param('workspaceRoleName') name: WorkspaceRole['name'] ) { @@ -78,7 +79,7 @@ export class WorkspaceRoleController { @Get(':workspaceRoleSlug') @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE_ROLE) async getWorkspaceRole( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceRoleSlug') workspaceRoleSlug: WorkspaceRole['slug'] ) { return await this.workspaceRoleService.getWorkspaceRole( @@ -90,7 +91,7 @@ export class WorkspaceRoleController { @Get(':workspaceSlug/all') @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE_ROLE) async getAllWorkspaceRolesOfWorkspace( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceSlug') workspaceSlug: Workspace['slug'], @Query('page') page: number = 0, @Query('limit') limit: number = 10, diff --git a/apps/api/src/workspace-role/service/workspace-role.service.spec.ts b/apps/api/src/workspace-role/service/workspace-role.service.spec.ts index 4ff5ce730..4c93b9320 100644 --- a/apps/api/src/workspace-role/service/workspace-role.service.spec.ts +++ b/apps/api/src/workspace-role/service/workspace-role.service.spec.ts @@ -1,9 +1,8 @@ import { Test, TestingModule } from '@nestjs/testing' import { WorkspaceRoleService } from './workspace-role.service' import { PrismaService } from '@/prisma/prisma.service' -import { MAIL_SERVICE } from '@/mail/services/interface.service' -import { MockMailService } from '@/mail/services/mock.service' -import { AuthorityCheckerService } from '@/common/authority-checker.service' +import { AuthorizationService } from '@/auth/service/authorization.service' +import { AuthorityCheckerService } from '@/auth/service/authority-checker.service' import { CommonModule } from '@/common/common.module' describe('WorkspaceRoleService', () => { @@ -15,7 +14,7 @@ describe('WorkspaceRoleService', () => { providers: [ WorkspaceRoleService, PrismaService, - { provide: MAIL_SERVICE, useClass: MockMailService }, + AuthorizationService, AuthorityCheckerService ] }).compile() 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 139242afa..64b0b64c3 100644 --- a/apps/api/src/workspace-role/service/workspace-role.service.ts +++ b/apps/api/src/workspace-role/service/workspace-role.service.ts @@ -1,728 +1,724 @@ -import { - BadRequestException, - ConflictException, - Injectable, - Logger, - NotFoundException, - UnauthorizedException -} from '@nestjs/common' -import { - Authority, - EventSource, - EventType, - User, - Workspace, - WorkspaceRole -} from '@prisma/client' -import { CreateWorkspaceRole } from '../dto/create-workspace-role/create-workspace-role' -import { UpdateWorkspaceRole } from '../dto/update-workspace-role/update-workspace-role' -import { PrismaService } from '@/prisma/prisma.service' -import { WorkspaceRoleWithProjects } from '../workspace-role.types' -import { v4 } from 'uuid' -import { AuthorityCheckerService } from '@/common/authority-checker.service' -import { paginate, PaginatedMetadata } from '@/common/paginate' -import generateEntitySlug from '@/common/slug-generator' -import { createEvent } from '@/common/event' -import { getCollectiveWorkspaceAuthorities } from '@/common/collective-authorities' -import { constructErrorBody, limitMaxItemsPerPage } from '@/common/util' - -@Injectable() -export class WorkspaceRoleService { - private readonly logger: Logger = new Logger(WorkspaceRoleService.name) - - constructor( - private readonly prisma: PrismaService, - private readonly authorityCheckerService: AuthorityCheckerService - ) {} - - /** - * Creates a new workspace role - * @throws {BadRequestException} if the role has workspace admin authority - * @throws {ConflictException} if a workspace role with the same name already exists - * @param user the user that is creating the workspace role - * @param workspaceSlug the slug of the workspace - * @param dto the data for the new workspace role - * @returns the newly created workspace role - */ - async createWorkspaceRole( - user: User, - workspaceSlug: Workspace['slug'], - dto: CreateWorkspaceRole - ) { - if ( - dto.authorities && - dto.authorities.includes(Authority.WORKSPACE_ADMIN) - ) { - throw new BadRequestException( - constructErrorBody( - 'Can not add workspace admin authority', - 'You can not explicitly assign workspace admin authority to a role' - ) - ) - } - - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.CREATE_WORKSPACE_ROLE], - prisma: this.prisma - }) - const workspaceId = workspace.id - - if (await this.checkWorkspaceRoleExists(user, workspaceSlug, dto.name)) { - throw new ConflictException( - constructErrorBody( - 'Workspace role already exists', - `Another workspace role with the name ${dto.name} already exists` - ) - ) - } - - const workspaceRoleId = v4() - - const op = [] - - // Create the workspace role - op.push( - this.prisma.workspaceRole.create({ - data: { - id: workspaceRoleId, - name: dto.name, - slug: await generateEntitySlug( - dto.name, - 'WORKSPACE_ROLE', - this.prisma - ), - description: dto.description, - colorCode: dto.colorCode, - authorities: dto.authorities ?? [], - hasAdminAuthority: false, - workspace: { - connect: { - id: workspaceId - } - } - }, - select: { - id: true - } - }) - ) - - if (dto.projectEnvironments) { - // Create the project associations - const projectSlugToIdMap = await this.getProjectSlugToIdMap( - dto.projectEnvironments.map((pe) => pe.projectSlug) - ) - - for (const pe of dto.projectEnvironments) { - const projectId = projectSlugToIdMap.get(pe.projectSlug) - if (projectId) { - if (pe.environmentSlugs && pe.environmentSlugs.length === 0) - throw new BadRequestException( - constructErrorBody( - 'Environment slugs in the project are required', - `Environment slugs in the project ${pe.projectSlug} are required` - ) - ) - if (pe.environmentSlugs) { - //Check if all environments are part of the project - const project = await this.prisma.project.findFirst({ - where: { - id: projectId, - AND: pe.environmentSlugs.map((slug) => ({ - environments: { - some: { - slug: slug - } - } - })) - } - }) - - if (!project) { - throw new BadRequestException( - constructErrorBody( - 'Some environment slugs are not part of the project', - 'Some or all of the environment slugs specified do not belong to this project' - ) - ) - } - - // Check if the user has read authority over all the environments - for (const environmentSlug of pe.environmentSlugs) { - try { - await this.authorityCheckerService.checkAuthorityOverEnvironment( - { - userId: user.id, - entity: { - slug: environmentSlug - }, - authorities: [Authority.READ_ENVIRONMENT], - prisma: this.prisma - } - ) - } catch { - throw new UnauthorizedException( - constructErrorBody( - `Autority to read environment ${environmentSlug} is required`, - `You do not have the required read authority over environment ${environmentSlug}` - ) - ) - } - } - } - // Create the project workspace role association with the environments accessible on the project - op.push( - this.prisma.projectWorkspaceRoleAssociation.create({ - data: { - roleId: workspaceRoleId, - projectId: projectId, - environments: pe.environmentSlugs && { - connect: pe.environmentSlugs.map((slug) => ({ slug })) - } - } - }) - ) - } else { - throw new NotFoundException( - constructErrorBody( - `Project not found`, - `Project ${pe.projectSlug} does not exist` - ) - ) - } - } - } - - // Fetch the new workspace role - op.push( - this.prisma.workspaceRole.findFirst({ - where: { - id: workspaceRoleId - }, - include: { - projects: { - select: { - project: { - select: { - id: true, - slug: true, - name: true - } - }, - environments: { - select: { - id: true, - slug: true, - name: true - } - } - } - } - } - }) - ) - - const workspaceRole = (await this.prisma.$transaction(op)).pop() - - await createEvent( - { - triggeredBy: user, - entity: workspaceRole, - type: EventType.WORKSPACE_ROLE_CREATED, - source: EventSource.WORKSPACE_ROLE, - title: `Workspace role created`, - metadata: { - workspaceRoleId: workspaceRole.id, - name: workspaceRole.name, - workspaceId, - workspaceName: workspace.name - }, - workspaceId - }, - this.prisma - ) - - this.logger.log( - `${user.email} created workspace role ${workspaceRole.slug}` - ) - - return workspaceRole - } - - /** - * Updates a workspace role - * @throws {BadRequestException} if the role has workspace admin authority - * @throws {ConflictException} if a workspace role with the same name already exists - * @param user the user that is updating the workspace role - * @param workspaceRoleSlug the slug of the workspace role to be updated - * @param dto the data for the updated workspace role - * @returns the updated workspace role - */ - async updateWorkspaceRole( - user: User, - workspaceRoleSlug: WorkspaceRole['slug'], - dto: UpdateWorkspaceRole - ) { - if ( - dto.authorities && - dto.authorities.includes(Authority.WORKSPACE_ADMIN) - ) { - throw new BadRequestException( - constructErrorBody( - 'Can not assign admin authority', - 'You can not explicitly assign workspace admin authority to a role' - ) - ) - } - - const workspaceRole = (await this.getWorkspaceRoleWithAuthority( - user.id, - workspaceRoleSlug, - Authority.UPDATE_WORKSPACE_ROLE - )) as WorkspaceRoleWithProjects - const workspaceRoleId = workspaceRole.id - - const { slug: workspaceSlug } = await this.prisma.workspace.findUnique({ - where: { - id: workspaceRole.workspaceId - }, - select: { - slug: true - } - }) - - if ( - dto.name && - ((await this.checkWorkspaceRoleExists(user, workspaceSlug, dto.name)) || - dto.name === workspaceRole.name) - ) { - throw new ConflictException( - constructErrorBody( - 'Workspace role already exists', - `A workspace role with the name ${dto.name} already exists in this workspace` - ) - ) - } - - if (dto.projectEnvironments) { - await this.prisma.projectWorkspaceRoleAssociation.deleteMany({ - where: { - roleId: workspaceRoleId - } - }) - - const projectSlugToIdMap = await this.getProjectSlugToIdMap( - dto.projectEnvironments.map((pe) => pe.projectSlug) - ) - - for (const pe of dto.projectEnvironments) { - const projectId = projectSlugToIdMap.get(pe.projectSlug) - if (projectId) { - if (pe.environmentSlugs && pe.environmentSlugs.length === 0) - throw new BadRequestException( - constructErrorBody( - 'Missing environment slugs', - `Environment slugs must be specified for project ${pe.projectSlug}` - ) - ) - if (pe.environmentSlugs) { - //Check if all environments are part of the project - const project = await this.prisma.project.findFirst({ - where: { - id: projectId, - AND: pe.environmentSlugs.map((slug) => ({ - environments: { - some: { - slug: slug - } - } - })) - } - }) - - if (!project) { - throw new BadRequestException( - constructErrorBody( - 'Invalid environment slugs', - `All environmentSlugs in the project ${pe.projectSlug} are not part of the project` - ) - ) - } - - // Check if the user has read authority over all the environments - for (const environmentSlug of pe.environmentSlugs) { - try { - await this.authorityCheckerService.checkAuthorityOverEnvironment( - { - userId: user.id, - entity: { - slug: environmentSlug - }, - authorities: [Authority.READ_ENVIRONMENT], - prisma: this.prisma - } - ) - } catch { - throw new BadRequestException( - constructErrorBody( - 'Missing required authorities', - `You do not have update authority over environment ${environmentSlug}` - ) - ) - } - } - } - // Create or Update the project workspace role association with the environments accessible on the project - await this.prisma.projectWorkspaceRoleAssociation.upsert({ - where: { - roleId_projectId: { - roleId: workspaceRoleId, - projectId: projectId - } - }, - update: { - environments: pe.environmentSlugs && { - set: [], - connect: pe.environmentSlugs.map((slug) => ({ slug })) - } - }, - create: { - roleId: workspaceRoleId, - projectId: projectId, - environments: pe.environmentSlugs && { - connect: pe.environmentSlugs.map((slug) => ({ slug })) - } - } - }) - } else { - throw new NotFoundException( - constructErrorBody( - 'Project not found', - `Project ${pe.projectSlug} not found` - ) - ) - } - } - } - - const updatedWorkspaceRole = await this.prisma.workspaceRole.update({ - where: { - id: workspaceRoleId - }, - data: { - name: dto.name, - slug: dto.name - ? await generateEntitySlug(dto.name, 'WORKSPACE_ROLE', this.prisma) - : undefined, - description: dto.description, - colorCode: dto.colorCode, - authorities: dto.authorities - }, - include: { - projects: { - select: { - project: { - select: { - id: true, - slug: true, - name: true - } - }, - environments: { - select: { - id: true, - slug: true, - name: true - } - } - } - } - } - }) - await createEvent( - { - triggeredBy: user, - entity: workspaceRole, - type: EventType.WORKSPACE_ROLE_UPDATED, - source: EventSource.WORKSPACE_ROLE, - title: `Workspace role updated`, - metadata: { - workspaceRoleId: workspaceRole.id, - name: workspaceRole.name, - workspaceId: workspaceRole.workspaceId - }, - workspaceId: workspaceRole.workspaceId - }, - this.prisma - ) - - this.logger.log(`${user.email} updated workspace role ${workspaceRoleSlug}`) - - return updatedWorkspaceRole - } - - /** - * Deletes a workspace role - * @throws {UnauthorizedException} if the role has administrative authority - * @param user the user that is deleting the workspace role - * @param workspaceRoleSlug the slug of the workspace role to be deleted - */ - async deleteWorkspaceRole( - user: User, - workspaceRoleSlug: WorkspaceRole['slug'] - ) { - const workspaceRole = await this.getWorkspaceRoleWithAuthority( - user.id, - workspaceRoleSlug, - Authority.DELETE_WORKSPACE_ROLE - ) - const workspaceRoleId = workspaceRole.id - - if (workspaceRole.hasAdminAuthority) { - throw new UnauthorizedException( - constructErrorBody( - 'Can not delete workspace role', - 'This role contains the workspace admin authority. You can not delete this role' - ) - ) - } - - await this.prisma.workspaceRole.delete({ - where: { - id: workspaceRoleId - } - }) - - await createEvent( - { - triggeredBy: user, - type: EventType.WORKSPACE_ROLE_DELETED, - source: EventSource.WORKSPACE_ROLE, - title: `Workspace role deleted`, - entity: workspaceRole, - metadata: { - workspaceRoleId: workspaceRole.id, - name: workspaceRole.name, - workspaceId: workspaceRole.workspaceId - }, - workspaceId: workspaceRole.workspaceId - }, - this.prisma - ) - - this.logger.log(`${user.email} deleted workspace role ${workspaceRoleSlug}`) - } - - /** - * Checks if a workspace role with the given name exists - * @throws {UnauthorizedException} if the user does not have the required authority - * @param user the user performing the check - * @param workspaceSlug the slug of the workspace - * @param name the name of the workspace role to check - * @returns true if a workspace role with the given name exists, false otherwise - */ - async checkWorkspaceRoleExists( - user: User, - workspaceSlug: Workspace['slug'], - name: string - ) { - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.READ_WORKSPACE_ROLE], - prisma: this.prisma - }) - const workspaceId = workspace.id - - return ( - (await this.prisma.workspaceRole.count({ - where: { - workspaceId, - name - } - })) > 0 - ) - } - - /** - * Gets a workspace role by its slug - * @throws {UnauthorizedException} if the user does not have the required authority - * @param user the user performing the request - * @param workspaceRoleSlug the slug of the workspace role to get - * @returns the workspace role with the given slug - */ - async getWorkspaceRole( - user: User, - workspaceRoleSlug: WorkspaceRole['slug'] - ): Promise { - return await this.getWorkspaceRoleWithAuthority( - user.id, - workspaceRoleSlug, - Authority.READ_WORKSPACE_ROLE - ) - } - - /** - * Gets all workspace roles of a workspace, with pagination and optional filtering by name - * @throws {UnauthorizedException} if the user does not have the required authority - * @param user the user performing the request - * @param workspaceSlug the slug of the workspace - * @param page the page to get (0-indexed) - * @param limit the maximum number of items to return - * @param sort the field to sort the results by (e.g. "name", "slug", etc.) - * @param order the order to sort the results in (e.g. "asc", "desc") - * @param search an optional search string to filter the results by - * @returns a PaginatedMetadata object containing the items and metadata - */ - async getWorkspaceRolesOfWorkspace( - user: User, - workspaceSlug: Workspace['slug'], - page: number, - limit: number, - sort: string, - order: string, - search: string - ): Promise<{ items: WorkspaceRole[]; metadata: PaginatedMetadata }> { - const { id: workspaceId } = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.READ_WORKSPACE_ROLE], - prisma: this.prisma - }) - //get workspace roles of a workspace for given page and limit - const items = await this.prisma.workspaceRole.findMany({ - where: { - workspaceId, - name: { - contains: search - } - }, - skip: page * limit, - take: limitMaxItemsPerPage(limit), - - orderBy: { - [sort]: order - } - }) - - //calculate metadata - const totalCount = await this.prisma.workspaceRole.count({ - where: { - workspaceId, - name: { - contains: search - } - } - }) - - const metadata = paginate( - totalCount, - `/workspace-role/${workspaceSlug}/all`, - { - page, - limit: limitMaxItemsPerPage(limit), - sort, - order, - search - } - ) - - return { items, metadata } - } - - /** - * Gets a workspace role by its slug, with additional authorities check - * @throws {NotFoundException} if the workspace role does not exist - * @throws {UnauthorizedException} if the user does not have the required authority - * @param userId the user that is performing the request - * @param workspaceRoleSlug the slug of the workspace role to get - * @param authorities the authorities to check against - * @returns the workspace role with the given slug - */ - private async getWorkspaceRoleWithAuthority( - userId: User['id'], - workspaceRoleSlug: Workspace['slug'], - authorities: Authority - ) { - const workspaceRole = (await this.prisma.workspaceRole.findUnique({ - where: { - slug: workspaceRoleSlug - }, - include: { - projects: { - select: { - project: { - select: { - id: true, - slug: true, - name: true - } - }, - environments: { - select: { - id: true, - slug: true, - name: true - } - } - } - } - } - })) as WorkspaceRoleWithProjects - - if (!workspaceRole) { - throw new NotFoundException( - constructErrorBody( - `Workspace role not found`, - `The workspace role ${workspaceRoleSlug} does not exist` - ) - ) - } - - const permittedAuthorities = await getCollectiveWorkspaceAuthorities( - workspaceRole.workspaceId, - userId, - this.prisma - ) - - if ( - !permittedAuthorities.has(authorities) && - !permittedAuthorities.has(Authority.WORKSPACE_ADMIN) - ) { - throw new UnauthorizedException( - constructErrorBody( - 'Unauthorized', - `You do not have the required authorities to perform the action` - ) - ) - } - - return workspaceRole - } - - /** - * Given an array of project slugs, returns a Map of slug to id for all projects - * found in the database. - * - * @param projectSlugs the array of project slugs - * @returns a Map of project slug to id - */ - private async getProjectSlugToIdMap(projectSlugs: string[]) { - const projects = projectSlugs.length - ? await this.prisma.project.findMany({ - where: { - slug: { - in: projectSlugs - } - } - }) - : [] - - return new Map(projects.map((project) => [project.slug, project.id])) - } -} +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, + UnauthorizedException +} from '@nestjs/common' +import { + Authority, + EventSource, + EventType, + User, + Workspace, + WorkspaceRole +} from '@prisma/client' +import { CreateWorkspaceRole } from '../dto/create-workspace-role/create-workspace-role' +import { UpdateWorkspaceRole } from '../dto/update-workspace-role/update-workspace-role' +import { PrismaService } from '@/prisma/prisma.service' +import { WorkspaceRoleWithProjects } from '../workspace-role.types' +import { v4 } from 'uuid' +import { AuthorizationService } from '@/auth/service/authorization.service' +import { paginate, PaginatedMetadata } from '@/common/paginate' +import generateEntitySlug from '@/common/slug-generator' +import { createEvent } from '@/common/event' +import { getCollectiveWorkspaceAuthorities } from '@/common/collective-authorities' +import { constructErrorBody, limitMaxItemsPerPage } from '@/common/util' +import { AuthenticatedUser } from '@/user/user.types' + +@Injectable() +export class WorkspaceRoleService { + private readonly logger: Logger = new Logger(WorkspaceRoleService.name) + + constructor( + private readonly prisma: PrismaService, + private readonly authorizationService: AuthorizationService + ) {} + + /** + * Creates a new workspace role + * @throws {BadRequestException} if the role has workspace admin authority + * @throws {ConflictException} if a workspace role with the same name already exists + * @param user the user that is creating the workspace role + * @param workspaceSlug the slug of the workspace + * @param dto the data for the new workspace role + * @returns the newly created workspace role + */ + async createWorkspaceRole( + user: AuthenticatedUser, + workspaceSlug: Workspace['slug'], + dto: CreateWorkspaceRole + ) { + if ( + dto.authorities && + dto.authorities.includes(Authority.WORKSPACE_ADMIN) + ) { + throw new BadRequestException( + constructErrorBody( + 'Can not add workspace admin authority', + 'You can not explicitly assign workspace admin authority to a role' + ) + ) + } + + const workspace = + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, + entity: { slug: workspaceSlug }, + authorities: [Authority.CREATE_WORKSPACE_ROLE] + }) + const workspaceId = workspace.id + + if (await this.checkWorkspaceRoleExists(user, workspaceSlug, dto.name)) { + throw new ConflictException( + constructErrorBody( + 'Workspace role already exists', + `Another workspace role with the name ${dto.name} already exists` + ) + ) + } + + const workspaceRoleId = v4() + + const op = [] + + // Create the workspace role + op.push( + this.prisma.workspaceRole.create({ + data: { + id: workspaceRoleId, + name: dto.name, + slug: await generateEntitySlug( + dto.name, + 'WORKSPACE_ROLE', + this.prisma + ), + description: dto.description, + colorCode: dto.colorCode, + authorities: dto.authorities ?? [], + hasAdminAuthority: false, + workspace: { + connect: { + id: workspaceId + } + } + }, + select: { + id: true + } + }) + ) + + if (dto.projectEnvironments) { + // Create the project associations + const projectSlugToIdMap = await this.getProjectSlugToIdMap( + dto.projectEnvironments.map((pe) => pe.projectSlug) + ) + + for (const pe of dto.projectEnvironments) { + const projectId = projectSlugToIdMap.get(pe.projectSlug) + if (projectId) { + if (pe.environmentSlugs && pe.environmentSlugs.length === 0) + throw new BadRequestException( + constructErrorBody( + 'Environment slugs in the project are required', + `Environment slugs in the project ${pe.projectSlug} are required` + ) + ) + if (pe.environmentSlugs) { + //Check if all environments are part of the project + const project = await this.prisma.project.findFirst({ + where: { + id: projectId, + AND: pe.environmentSlugs.map((slug) => ({ + environments: { + some: { + slug: slug + } + } + })) + } + }) + + if (!project) { + throw new BadRequestException( + constructErrorBody( + 'Some environment slugs are not part of the project', + 'Some or all of the environment slugs specified do not belong to this project' + ) + ) + } + + // Check if the user has read authority over all the environments + for (const environmentSlug of pe.environmentSlugs) { + try { + await this.authorizationService.authorizeUserAccessToEnvironment( + { + user, + entity: { + slug: environmentSlug + }, + authorities: [Authority.READ_ENVIRONMENT] + } + ) + } catch { + throw new UnauthorizedException( + constructErrorBody( + `Autority to read environment ${environmentSlug} is required`, + `You do not have the required read authority over environment ${environmentSlug}` + ) + ) + } + } + } + // Create the project workspace role association with the environments accessible on the project + op.push( + this.prisma.projectWorkspaceRoleAssociation.create({ + data: { + roleId: workspaceRoleId, + projectId: projectId, + environments: pe.environmentSlugs && { + connect: pe.environmentSlugs.map((slug) => ({ slug })) + } + } + }) + ) + } else { + throw new NotFoundException( + constructErrorBody( + `Project not found`, + `Project ${pe.projectSlug} does not exist` + ) + ) + } + } + } + + // Fetch the new workspace role + op.push( + this.prisma.workspaceRole.findFirst({ + where: { + id: workspaceRoleId + }, + include: { + projects: { + select: { + project: { + select: { + id: true, + slug: true, + name: true + } + }, + environments: { + select: { + id: true, + slug: true, + name: true + } + } + } + } + } + }) + ) + + const workspaceRole = (await this.prisma.$transaction(op)).pop() + + await createEvent( + { + triggeredBy: user, + entity: workspaceRole, + type: EventType.WORKSPACE_ROLE_CREATED, + source: EventSource.WORKSPACE_ROLE, + title: `Workspace role created`, + metadata: { + workspaceRoleId: workspaceRole.id, + name: workspaceRole.name, + workspaceId, + workspaceName: workspace.name + }, + workspaceId + }, + this.prisma + ) + + this.logger.log( + `${user.email} created workspace role ${workspaceRole.slug}` + ) + + return workspaceRole + } + + /** + * Updates a workspace role + * @throws {BadRequestException} if the role has workspace admin authority + * @throws {ConflictException} if a workspace role with the same name already exists + * @param user the user that is updating the workspace role + * @param workspaceRoleSlug the slug of the workspace role to be updated + * @param dto the data for the updated workspace role + * @returns the updated workspace role + */ + async updateWorkspaceRole( + user: AuthenticatedUser, + workspaceRoleSlug: WorkspaceRole['slug'], + dto: UpdateWorkspaceRole + ) { + if ( + dto.authorities && + dto.authorities.includes(Authority.WORKSPACE_ADMIN) + ) { + throw new BadRequestException( + constructErrorBody( + 'Can not assign admin authority', + 'You can not explicitly assign workspace admin authority to a role' + ) + ) + } + + const workspaceRole = (await this.getWorkspaceRoleWithAuthority( + user.id, + workspaceRoleSlug, + Authority.UPDATE_WORKSPACE_ROLE + )) as WorkspaceRoleWithProjects + const workspaceRoleId = workspaceRole.id + + const { slug: workspaceSlug } = await this.prisma.workspace.findUnique({ + where: { + id: workspaceRole.workspaceId + }, + select: { + slug: true + } + }) + + if ( + dto.name && + ((await this.checkWorkspaceRoleExists(user, workspaceSlug, dto.name)) || + dto.name === workspaceRole.name) + ) { + throw new ConflictException( + constructErrorBody( + 'Workspace role already exists', + `A workspace role with the name ${dto.name} already exists in this workspace` + ) + ) + } + + if (dto.projectEnvironments) { + await this.prisma.projectWorkspaceRoleAssociation.deleteMany({ + where: { + roleId: workspaceRoleId + } + }) + + const projectSlugToIdMap = await this.getProjectSlugToIdMap( + dto.projectEnvironments.map((pe) => pe.projectSlug) + ) + + for (const pe of dto.projectEnvironments) { + const projectId = projectSlugToIdMap.get(pe.projectSlug) + if (projectId) { + if (pe.environmentSlugs && pe.environmentSlugs.length === 0) + throw new BadRequestException( + constructErrorBody( + 'Missing environment slugs', + `Environment slugs must be specified for project ${pe.projectSlug}` + ) + ) + if (pe.environmentSlugs) { + //Check if all environments are part of the project + const project = await this.prisma.project.findFirst({ + where: { + id: projectId, + AND: pe.environmentSlugs.map((slug) => ({ + environments: { + some: { + slug: slug + } + } + })) + } + }) + + if (!project) { + throw new BadRequestException( + constructErrorBody( + 'Invalid environment slugs', + `All environmentSlugs in the project ${pe.projectSlug} are not part of the project` + ) + ) + } + + // Check if the user has read authority over all the environments + for (const environmentSlug of pe.environmentSlugs) { + try { + await this.authorizationService.authorizeUserAccessToEnvironment( + { + user, + entity: { + slug: environmentSlug + }, + authorities: [Authority.READ_ENVIRONMENT] + } + ) + } catch { + throw new BadRequestException( + constructErrorBody( + 'Missing required authorities', + `You do not have update authority over environment ${environmentSlug}` + ) + ) + } + } + } + // Create or Update the project workspace role association with the environments accessible on the project + await this.prisma.projectWorkspaceRoleAssociation.upsert({ + where: { + roleId_projectId: { + roleId: workspaceRoleId, + projectId: projectId + } + }, + update: { + environments: pe.environmentSlugs && { + set: [], + connect: pe.environmentSlugs.map((slug) => ({ slug })) + } + }, + create: { + roleId: workspaceRoleId, + projectId: projectId, + environments: pe.environmentSlugs && { + connect: pe.environmentSlugs.map((slug) => ({ slug })) + } + } + }) + } else { + throw new NotFoundException( + constructErrorBody( + 'Project not found', + `Project ${pe.projectSlug} not found` + ) + ) + } + } + } + + const updatedWorkspaceRole = await this.prisma.workspaceRole.update({ + where: { + id: workspaceRoleId + }, + data: { + name: dto.name, + slug: dto.name + ? await generateEntitySlug(dto.name, 'WORKSPACE_ROLE', this.prisma) + : undefined, + description: dto.description, + colorCode: dto.colorCode, + authorities: dto.authorities + }, + include: { + projects: { + select: { + project: { + select: { + id: true, + slug: true, + name: true + } + }, + environments: { + select: { + id: true, + slug: true, + name: true + } + } + } + } + } + }) + await createEvent( + { + triggeredBy: user, + entity: workspaceRole, + type: EventType.WORKSPACE_ROLE_UPDATED, + source: EventSource.WORKSPACE_ROLE, + title: `Workspace role updated`, + metadata: { + workspaceRoleId: workspaceRole.id, + name: workspaceRole.name, + workspaceId: workspaceRole.workspaceId + }, + workspaceId: workspaceRole.workspaceId + }, + this.prisma + ) + + this.logger.log(`${user.email} updated workspace role ${workspaceRoleSlug}`) + + return updatedWorkspaceRole + } + + /** + * Deletes a workspace role + * @throws {UnauthorizedException} if the role has administrative authority + * @param user the user that is deleting the workspace role + * @param workspaceRoleSlug the slug of the workspace role to be deleted + */ + async deleteWorkspaceRole( + user: AuthenticatedUser, + workspaceRoleSlug: WorkspaceRole['slug'] + ) { + const workspaceRole = await this.getWorkspaceRoleWithAuthority( + user.id, + workspaceRoleSlug, + Authority.DELETE_WORKSPACE_ROLE + ) + const workspaceRoleId = workspaceRole.id + + if (workspaceRole.hasAdminAuthority) { + throw new UnauthorizedException( + constructErrorBody( + 'Can not delete workspace role', + 'This role contains the workspace admin authority. You can not delete this role' + ) + ) + } + + await this.prisma.workspaceRole.delete({ + where: { + id: workspaceRoleId + } + }) + + await createEvent( + { + triggeredBy: user, + type: EventType.WORKSPACE_ROLE_DELETED, + source: EventSource.WORKSPACE_ROLE, + title: `Workspace role deleted`, + entity: workspaceRole, + metadata: { + workspaceRoleId: workspaceRole.id, + name: workspaceRole.name, + workspaceId: workspaceRole.workspaceId + }, + workspaceId: workspaceRole.workspaceId + }, + this.prisma + ) + + this.logger.log(`${user.email} deleted workspace role ${workspaceRoleSlug}`) + } + + /** + * Checks if a workspace role with the given name exists + * @throws {UnauthorizedException} if the user does not have the required authority + * @param user the user performing the check + * @param workspaceSlug the slug of the workspace + * @param name the name of the workspace role to check + * @returns true if a workspace role with the given name exists, false otherwise + */ + async checkWorkspaceRoleExists( + user: AuthenticatedUser, + workspaceSlug: Workspace['slug'], + name: string + ) { + const workspace = + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, + entity: { slug: workspaceSlug }, + authorities: [Authority.READ_WORKSPACE_ROLE] + }) + const workspaceId = workspace.id + + return ( + (await this.prisma.workspaceRole.count({ + where: { + workspaceId, + name + } + })) > 0 + ) + } + + /** + * Gets a workspace role by its slug + * @throws {UnauthorizedException} if the user does not have the required authority + * @param user the user performing the request + * @param workspaceRoleSlug the slug of the workspace role to get + * @returns the workspace role with the given slug + */ + async getWorkspaceRole( + user: AuthenticatedUser, + workspaceRoleSlug: WorkspaceRole['slug'] + ): Promise { + return await this.getWorkspaceRoleWithAuthority( + user.id, + workspaceRoleSlug, + Authority.READ_WORKSPACE_ROLE + ) + } + + /** + * Gets all workspace roles of a workspace, with pagination and optional filtering by name + * @throws {UnauthorizedException} if the user does not have the required authority + * @param user the user performing the request + * @param workspaceSlug the slug of the workspace + * @param page the page to get (0-indexed) + * @param limit the maximum number of items to return + * @param sort the field to sort the results by (e.g. "name", "slug", etc.) + * @param order the order to sort the results in (e.g. "asc", "desc") + * @param search an optional search string to filter the results by + * @returns a PaginatedMetadata object containing the items and metadata + */ + async getWorkspaceRolesOfWorkspace( + user: AuthenticatedUser, + workspaceSlug: Workspace['slug'], + page: number, + limit: number, + sort: string, + order: string, + search: string + ): Promise<{ items: WorkspaceRole[]; metadata: PaginatedMetadata }> { + const { id: workspaceId } = + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, + entity: { slug: workspaceSlug }, + authorities: [Authority.READ_WORKSPACE_ROLE] + }) + //get workspace roles of a workspace for given page and limit + const items = await this.prisma.workspaceRole.findMany({ + where: { + workspaceId, + name: { + contains: search + } + }, + skip: page * limit, + take: limitMaxItemsPerPage(limit), + + orderBy: { + [sort]: order + } + }) + + //calculate metadata + const totalCount = await this.prisma.workspaceRole.count({ + where: { + workspaceId, + name: { + contains: search + } + } + }) + + const metadata = paginate( + totalCount, + `/workspace-role/${workspaceSlug}/all`, + { + page, + limit: limitMaxItemsPerPage(limit), + sort, + order, + search + } + ) + + return { items, metadata } + } + + /** + * Gets a workspace role by its slug, with additional authorities check + * @throws {NotFoundException} if the workspace role does not exist + * @throws {UnauthorizedException} if the user does not have the required authority + * @param userId the user that is performing the request + * @param workspaceRoleSlug the slug of the workspace role to get + * @param authorities the authorities to check against + * @returns the workspace role with the given slug + */ + private async getWorkspaceRoleWithAuthority( + userId: User['id'], + workspaceRoleSlug: Workspace['slug'], + authorities: Authority + ) { + const workspaceRole = (await this.prisma.workspaceRole.findUnique({ + where: { + slug: workspaceRoleSlug + }, + include: { + projects: { + select: { + project: { + select: { + id: true, + slug: true, + name: true + } + }, + environments: { + select: { + id: true, + slug: true, + name: true + } + } + } + } + } + })) as WorkspaceRoleWithProjects + + if (!workspaceRole) { + throw new NotFoundException( + constructErrorBody( + `Workspace role not found`, + `The workspace role ${workspaceRoleSlug} does not exist` + ) + ) + } + + const permittedAuthorities = await getCollectiveWorkspaceAuthorities( + workspaceRole.workspaceId, + userId, + this.prisma + ) + + if ( + !permittedAuthorities.has(authorities) && + !permittedAuthorities.has(Authority.WORKSPACE_ADMIN) + ) { + throw new UnauthorizedException( + constructErrorBody( + 'Unauthorized', + `You do not have the required authorities to perform the action` + ) + ) + } + + return workspaceRole + } + + /** + * Given an array of project slugs, returns a Map of slug to id for all projects + * found in the database. + * + * @param projectSlugs the array of project slugs + * @returns a Map of project slug to id + */ + private async getProjectSlugToIdMap(projectSlugs: string[]) { + const projects = projectSlugs.length + ? await this.prisma.project.findMany({ + where: { + slug: { + in: projectSlugs + } + } + }) + : [] + + return new Map(projects.map((project) => [project.slug, project.id])) + } +} diff --git a/apps/api/src/workspace-role/workspace-role.e2e.spec.ts b/apps/api/src/workspace-role/workspace-role.e2e.spec.ts index 0981c564a..2edb3e11c 100644 --- a/apps/api/src/workspace-role/workspace-role.e2e.spec.ts +++ b/apps/api/src/workspace-role/workspace-role.e2e.spec.ts @@ -10,7 +10,6 @@ import { EventTriggerer, EventType, Project, - User, Workspace, WorkspaceRole } from '@prisma/client' @@ -27,6 +26,7 @@ import { UserService } from '@/user/service/user.service' import { UserModule } from '@/user/user.module' import { QueryTransformPipe } from '@/common/pipes/query.transform.pipe' import { fetchEvents } from '@/common/event' +import { AuthenticatedUser } from '@/user/user.types' describe('Workspace Role Controller Tests', () => { let app: NestFastifyApplication @@ -35,15 +35,17 @@ describe('Workspace Role Controller Tests', () => { let workspaceRoleService: WorkspaceRoleService let userService: UserService - let alice: User - let bob: User - let charlie: User + let alice: AuthenticatedUser + let bob: AuthenticatedUser + let charlie: AuthenticatedUser let workspaceAlice: Workspace let workspaceBob: Workspace let adminRole1: WorkspaceRole let adminRole2: WorkspaceRole let projects: Project[] + const USER_IP_ADDRESS = '127.0.0.1' + beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [AppModule, WorkspaceRoleModule, EventModule, UserModule] @@ -98,9 +100,9 @@ describe('Workspace Role Controller Tests', () => { delete createBob.defaultWorkspace delete createCharlie.defaultWorkspace - alice = createAlice - bob = createBob - charlie = createCharlie + alice = { ...createAlice, ipAddress: USER_IP_ADDRESS } + bob = { ...createBob, ipAddress: USER_IP_ADDRESS } + charlie = { ...createCharlie, ipAddress: USER_IP_ADDRESS } adminRole1 = await prisma.workspaceRole.findFirst({ where: { diff --git a/apps/api/src/workspace/controller/workspace.controller.spec.ts b/apps/api/src/workspace/controller/workspace.controller.spec.ts index 7310830ff..526346979 100644 --- a/apps/api/src/workspace/controller/workspace.controller.spec.ts +++ b/apps/api/src/workspace/controller/workspace.controller.spec.ts @@ -5,7 +5,8 @@ import { PrismaService } from '@/prisma/prisma.service' import { MAIL_SERVICE } from '@/mail/services/interface.service' import { MockMailService } from '@/mail/services/mock.service' import { JwtService } from '@nestjs/jwt' -import { AuthorityCheckerService } from '@/common/authority-checker.service' +import { AuthorizationService } from '@/auth/service/authorization.service' +import { AuthorityCheckerService } from '@/auth/service/authority-checker.service' import { CommonModule } from '@/common/common.module' describe('WorkspaceController', () => { @@ -22,6 +23,7 @@ describe('WorkspaceController', () => { useClass: MockMailService }, JwtService, + AuthorizationService, AuthorityCheckerService ], controllers: [WorkspaceController] diff --git a/apps/api/src/workspace/controller/workspace.controller.ts b/apps/api/src/workspace/controller/workspace.controller.ts index 5356caa7f..046f7b14d 100644 --- a/apps/api/src/workspace/controller/workspace.controller.ts +++ b/apps/api/src/workspace/controller/workspace.controller.ts @@ -10,10 +10,12 @@ import { } from '@nestjs/common' import { WorkspaceService } from '../service/workspace.service' import { CurrentUser } from '@/decorators/user.decorator' -import { Authority, User, Workspace } from '@prisma/client' +import { Authority, Workspace } from '@prisma/client' import { CreateWorkspace } from '../dto/create.workspace/create.workspace' import { UpdateWorkspace } from '../dto/update.workspace/update.workspace' import { RequiredApiKeyAuthorities } from '@/decorators/required-api-key-authorities.decorator' +import { AuthenticatedUser } from '@/user/user.types' +import { UpdateBlacklistedIpAddresses } from '../dto/update.blacklistedIpAddresses/update.blacklistedIpAddresses' @Controller('workspace') export class WorkspaceController { @@ -21,14 +23,17 @@ export class WorkspaceController { @Post() @RequiredApiKeyAuthorities(Authority.CREATE_WORKSPACE) - async create(@CurrentUser() user: User, @Body() dto: CreateWorkspace) { + async create( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: CreateWorkspace + ) { return this.workspaceService.createWorkspace(user, dto) } @Put(':workspaceSlug') @RequiredApiKeyAuthorities(Authority.UPDATE_WORKSPACE) async update( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceSlug') workspaceSlug: Workspace['slug'], @Body() dto: UpdateWorkspace ) { @@ -38,7 +43,7 @@ export class WorkspaceController { @Delete(':workspaceSlug') @RequiredApiKeyAuthorities(Authority.DELETE_WORKSPACE) async delete( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceSlug') workspaceSlug: Workspace['slug'] ) { return this.workspaceService.deleteWorkspace(user, workspaceSlug) @@ -47,7 +52,7 @@ export class WorkspaceController { @Get('invitations') @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) async getAllInvitationsOfUser( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Query('page') page: number = 0, @Query('limit') limit: number = 10, @Query('sort') sort: string = 'name', @@ -67,7 +72,7 @@ export class WorkspaceController { @Get(':workspaceSlug') @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) async getWorkspace( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceSlug') workspaceSlug: Workspace['slug'] ) { return this.workspaceService.getWorkspaceBySlug(user, workspaceSlug) @@ -76,7 +81,7 @@ export class WorkspaceController { @Get(':workspaceSlug/export-data') @RequiredApiKeyAuthorities(Authority.WORKSPACE_ADMIN) async exportData( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceSlug') workspaceSlug: Workspace['slug'] ) { return this.workspaceService.exportData(user, workspaceSlug) @@ -85,7 +90,7 @@ export class WorkspaceController { @Get() @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) async getAllWorkspacesOfUser( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Query('page') page: number = 0, @Query('limit') limit: number = 10, @Query('sort') sort: string = 'name', @@ -111,10 +116,33 @@ export class WorkspaceController { Authority.READ_PROJECT ) async globalSearch( - @CurrentUser() user: User, + @CurrentUser() user: AuthenticatedUser, @Param('workspaceSlug') workspaceSlug: Workspace['slug'], @Param('searchTerm') searchTerm: string ) { return this.workspaceService.globalSearch(user, workspaceSlug, searchTerm) } + + @Get(':workspaceSlug/blacklistedIpAddresses') + @RequiredApiKeyAuthorities(Authority.WORKSPACE_ADMIN) + async getBlacklistedIpAddresses( + @CurrentUser() user: AuthenticatedUser, + @Param('workspaceSlug') workspaceSlug: Workspace['slug'] + ) { + return this.workspaceService.getBlacklistedIpAddresses(user, workspaceSlug) + } + + @Put(':workspaceSlug/blacklistedIpAddresses') + @RequiredApiKeyAuthorities(Authority.WORKSPACE_ADMIN) + async updateBlacklistedIpAddresses( + @CurrentUser() user: AuthenticatedUser, + @Param('workspaceSlug') workspaceSlug: Workspace['slug'], + @Body() dto: UpdateBlacklistedIpAddresses + ) { + return this.workspaceService.updateBlacklistedIpAddresses( + user, + workspaceSlug, + dto + ) + } } diff --git a/apps/api/src/workspace/dto/update.blacklistedIpAddresses/update.blacklistedIpAddresses.spec.ts b/apps/api/src/workspace/dto/update.blacklistedIpAddresses/update.blacklistedIpAddresses.spec.ts new file mode 100644 index 000000000..fc29d31ec --- /dev/null +++ b/apps/api/src/workspace/dto/update.blacklistedIpAddresses/update.blacklistedIpAddresses.spec.ts @@ -0,0 +1,7 @@ +import { UpdateBlacklistedIpAddresses } from './update.blacklistedIpAddresses' + +describe('UpdateBlacklistedIpAddresses', () => { + it('should be defined', () => { + expect(new UpdateBlacklistedIpAddresses()).toBeDefined() + }) +}) diff --git a/apps/api/src/workspace/dto/update.blacklistedIpAddresses/update.blacklistedIpAddresses.ts b/apps/api/src/workspace/dto/update.blacklistedIpAddresses/update.blacklistedIpAddresses.ts new file mode 100644 index 000000000..5142f56e7 --- /dev/null +++ b/apps/api/src/workspace/dto/update.blacklistedIpAddresses/update.blacklistedIpAddresses.ts @@ -0,0 +1,8 @@ +import { IsArray, ArrayNotEmpty, IsIP } from 'class-validator' + +export class UpdateBlacklistedIpAddresses { + @IsArray() + @ArrayNotEmpty() + @IsIP(undefined, { each: true }) + ipAddresses: string[] +} diff --git a/apps/api/src/workspace/service/workspace.service.spec.ts b/apps/api/src/workspace/service/workspace.service.spec.ts index 84ffad6e4..222552061 100644 --- a/apps/api/src/workspace/service/workspace.service.spec.ts +++ b/apps/api/src/workspace/service/workspace.service.spec.ts @@ -4,7 +4,8 @@ import { PrismaService } from '@/prisma/prisma.service' import { MAIL_SERVICE } from '@/mail/services/interface.service' import { MockMailService } from '@/mail/services/mock.service' import { JwtService } from '@nestjs/jwt' -import { AuthorityCheckerService } from '@/common/authority-checker.service' +import { AuthorizationService } from '@/auth/service/authorization.service' +import { AuthorityCheckerService } from '@/auth/service/authority-checker.service' import { CommonModule } from '@/common/common.module' describe('WorkspaceService', () => { @@ -21,6 +22,7 @@ describe('WorkspaceService', () => { useClass: MockMailService }, JwtService, + AuthorizationService, AuthorityCheckerService ] }).compile() diff --git a/apps/api/src/workspace/service/workspace.service.ts b/apps/api/src/workspace/service/workspace.service.ts index 16a94853a..d97bf1239 100644 --- a/apps/api/src/workspace/service/workspace.service.ts +++ b/apps/api/src/workspace/service/workspace.service.ts @@ -1,4 +1,3 @@ -import { AuthorityCheckerService } from '@/common/authority-checker.service' import { getCollectiveProjectAuthorities } from '@/common/collective-authorities' import { createEvent } from '@/common/event' import { paginate } from '@/common/paginate' @@ -7,6 +6,7 @@ import { constructErrorBody, limitMaxItemsPerPage } from '@/common/util' import { createWorkspace } from '@/common/workspace' import { IMailService, MAIL_SERVICE } from '@/mail/services/interface.service' import { PrismaService } from '@/prisma/prisma.service' +import { AuthorizationService } from '@/auth/service/authorization.service' import { BadRequestException, ConflictException, @@ -29,6 +29,8 @@ import { } from '@prisma/client' import { CreateWorkspace } from '../dto/create.workspace/create.workspace' import { UpdateWorkspace } from '../dto/update.workspace/update.workspace' +import { AuthenticatedUser } from '@/user/user.types' +import { UpdateBlacklistedIpAddresses } from '../dto/update.blacklistedIpAddresses/update.blacklistedIpAddresses' @Injectable() export class WorkspaceService { @@ -36,9 +38,9 @@ export class WorkspaceService { constructor( private readonly prisma: PrismaService, + private readonly authorizationService: AuthorizationService, private readonly jwt: JwtService, - @Inject(MAIL_SERVICE) private readonly mailService: IMailService, - private readonly authorityCheckerService: AuthorityCheckerService + @Inject(MAIL_SERVICE) private readonly mailService: IMailService ) {} /** @@ -48,7 +50,7 @@ export class WorkspaceService { * @param dto The data to create the workspace with * @returns The created workspace */ - async createWorkspace(user: User, dto: CreateWorkspace) { + async createWorkspace(user: AuthenticatedUser, dto: CreateWorkspace) { if (await this.existsByName(dto.name, user.id)) { throw new ConflictException( constructErrorBody( @@ -70,18 +72,16 @@ export class WorkspaceService { * @returns The updated workspace */ async updateWorkspace( - user: User, + user: AuthenticatedUser, workspaceSlug: Workspace['slug'], dto: UpdateWorkspace ) { // Fetch the workspace const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, entity: { slug: workspaceSlug }, - authorities: [Authority.UPDATE_WORKSPACE], - - prisma: this.prisma + authorities: [Authority.UPDATE_WORKSPACE] }) // Check if a same named workspace already exists @@ -142,15 +142,14 @@ export class WorkspaceService { * @param workspaceSlug The slug of the workspace to delete */ async deleteWorkspace( - user: User, + user: AuthenticatedUser, workspaceSlug: Workspace['slug'] ): Promise { const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, entity: { slug: workspaceSlug }, - authorities: [Authority.DELETE_WORKSPACE], - prisma: this.prisma + authorities: [Authority.DELETE_WORKSPACE] }) // We don't want the users to delete their default workspace @@ -181,15 +180,14 @@ export class WorkspaceService { * @throws NotFoundException if the workspace does not exist or the user does not have the authority to read the workspace */ async getWorkspaceBySlug( - user: User, + user: AuthenticatedUser, workspaceSlug: Workspace['slug'] ): Promise { const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, entity: { slug: workspaceSlug }, - authorities: [Authority.READ_USERS], - prisma: this.prisma + authorities: [Authority.READ_USERS] }) return { @@ -209,7 +207,7 @@ export class WorkspaceService { * @returns The workspaces of the user, paginated, with metadata */ async getWorkspacesOfUser( - user: User, + user: AuthenticatedUser, page: number, limit: number, sort: string, @@ -237,10 +235,7 @@ export class WorkspaceService { }) for (const workspace of items) { - workspace['projects'] = await this.getProjectsOfWorkspace( - workspace.id, - user.id - ) + workspace['projects'] = await this.getProjectsOfWorkspace(workspace, user) } // get total count of workspaces of the user @@ -284,13 +279,12 @@ export class WorkspaceService { * @throws NotFoundException if the workspace does not exist or the user does not have the authority to read the workspace * @throws InternalServerErrorException if there is an error in the transaction */ - async exportData(user: User, workspaceSlug: Workspace['slug']) { + async exportData(user: AuthenticatedUser, workspaceSlug: Workspace['slug']) { const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, entity: { slug: workspaceSlug }, - authorities: [Authority.WORKSPACE_ADMIN], - prisma: this.prisma + authorities: [Authority.WORKSPACE_ADMIN] }) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -372,7 +366,7 @@ export class WorkspaceService { * @returns An object with the search results */ async globalSearch( - user: User, + user: AuthenticatedUser, workspaceSlug: Workspace['slug'], searchTerm: string ): Promise<{ @@ -383,8 +377,8 @@ export class WorkspaceService { }> { // Check authority over workspace const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, entity: { slug: workspaceSlug }, authorities: [ Authority.READ_WORKSPACE, @@ -392,8 +386,7 @@ export class WorkspaceService { Authority.READ_ENVIRONMENT, Authority.READ_SECRET, Authority.READ_VARIABLE - ], - prisma: this.prisma + ] }) // Get a list of project IDs that the user has access to READ @@ -428,7 +421,7 @@ export class WorkspaceService { * @returns The workspace invitations of the user, paginated, with metadata */ async getAllWorkspaceInvitations( - user: User, + user: AuthenticatedUser, page: number, limit: number, sort: string, @@ -522,6 +515,80 @@ export class WorkspaceService { } } + /** + * Gets a list of blacklisted IP addresses. + * @param user The user to get the workspace for + * @param workspaceSlug The slug of the workspace to delete + * @returns The list of IP addresses + */ + async getBlacklistedIpAddresses( + user: AuthenticatedUser, + workspaceSlug: Workspace['slug'] + ): Promise { + const workspace = + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, + entity: { slug: workspaceSlug }, + authorities: [Authority.WORKSPACE_ADMIN] + }) + + return workspace.blacklistedIpAddresses + } + + /** + * Updates the list of blacklisted IP addresses + * @throws ConflictException if the workspace with the same name already exists + * @param user The user to update the workspace for + * @param workspaceSlug The slug of the workspace to update + * @param dto The data to update the list of blacklisted IP addresses with + * @returns The updated list of blacklisted IP addresses + */ + async updateBlacklistedIpAddresses( + user: AuthenticatedUser, + workspaceSlug: Workspace['slug'], + dto: UpdateBlacklistedIpAddresses + ) { + // Fetch the workspace + const workspace = + await this.authorizationService.authorizeUserAccessToWorkspace({ + user, + entity: { slug: workspaceSlug }, + authorities: [Authority.WORKSPACE_ADMIN] + }) + + // Update blacklisted IP addresses + const updatedWorkspace = await this.prisma.workspace.update({ + where: { + id: workspace.id + }, + data: { + blacklistedIpAddresses: dto.ipAddresses + } + }) + + this.log.debug( + `Updated workspace blacklisted IP addresses ${workspace.name} (${workspace.id})` + ) + + await createEvent( + { + triggeredBy: user, + entity: workspace, + type: EventType.WORKSPACE_UPDATED, + source: EventSource.WORKSPACE, + title: `Workspace blacklisted IP addresses updated`, + metadata: { + workspaceId: workspace.id, + name: workspace.name + }, + workspaceId: workspace.id + }, + this.prisma + ) + + return updatedWorkspace.blacklistedIpAddresses + } + /** * Gets a list of project IDs that the user has access to READ. * The user has access to a project if the project is global or if the user has the READ_PROJECT authority. @@ -690,12 +757,12 @@ export class WorkspaceService { */ private async getProjectsOfWorkspace( - workspaceId: Workspace['id'], - userId: User['id'] + workspace: Workspace, + user: AuthenticatedUser ) { const projects = await this.prisma.project.findMany({ where: { - workspaceId + workspaceId: workspace.id } }) @@ -703,11 +770,10 @@ export class WorkspaceService { for (const project of projects) { const hasAuthority = - await this.authorityCheckerService.checkAuthorityOverProject({ - userId, + await this.authorizationService.authorizeUserAccessToProject({ + user, entity: { slug: project.slug }, - authorities: [Authority.READ_PROJECT], - prisma: this.prisma + authorities: [Authority.READ_PROJECT] }) if (hasAuthority) { diff --git a/apps/api/src/workspace/workspace.e2e.spec.ts b/apps/api/src/workspace/workspace.e2e.spec.ts index ef5e5f7bd..ad25b2cc6 100644 --- a/apps/api/src/workspace/workspace.e2e.spec.ts +++ b/apps/api/src/workspace/workspace.e2e.spec.ts @@ -15,7 +15,6 @@ import { EventTriggerer, EventType, ProjectAccessLevel, - User, Workspace, WorkspaceRole } from '@prisma/client' @@ -38,6 +37,8 @@ import { WorkspaceRoleModule } from '@/workspace-role/workspace-role.module' import { WorkspaceMembershipService } from '@/workspace-membership/service/workspace-membership.service' import { WorkspaceMembershipModule } from '@/workspace-membership/workspace-membership.module' import { fetchEvents } from '@/common/event' +import { AuthenticatedUser } from '@/user/user.types' +import { HttpStatus } from '@nestjs/common' const createMembership = async ( roleId: string, @@ -75,10 +76,13 @@ describe('Workspace Controller Tests', () => { let workspaceRoleService: WorkspaceRoleService let workspaceMembershipService: WorkspaceMembershipService - let user1: User, user2: User + let user1: AuthenticatedUser, user2: AuthenticatedUser let workspace1: Workspace, workspace2: Workspace let adminRole: WorkspaceRole, memberRole: WorkspaceRole + const USER_IP_ADDRESS = '127.0.0.1' + const BLACKLISTED_IP_ADDRESS = '192.168.0.1' + beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [ @@ -144,8 +148,8 @@ describe('Workspace Controller Tests', () => { delete createUser2.defaultWorkspace delete createUser3.defaultWorkspace - user1 = createUser1 - user2 = createUser2 + user1 = { ...createUser1, ipAddress: USER_IP_ADDRESS } + user2 = { ...createUser2, ipAddress: USER_IP_ADDRESS } memberRole = await prisma.workspaceRole.create({ data: { @@ -603,6 +607,107 @@ describe('Workspace Controller Tests', () => { }) }) + describe('Get Blacklisted IP Addresses Tests', () => { + it('should not be able to fetch blacklisted IP addresses by user', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${workspace1.slug}/blacklistedIpAddresses` + }) + + expect(response.statusCode).toBe(HttpStatus.UNAUTHORIZED) + }) + + it('should be able to fetch blacklisted IP addresses by workspace administrator', async () => { + await prisma.workspace.update({ + where: { + id: workspace1.id + }, + data: { + blacklistedIpAddresses: [BLACKLISTED_IP_ADDRESS] + } + }) + + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}/blacklistedIpAddresses` + }) + + expect(response.statusCode).toBe(HttpStatus.OK) + + const blacklistedIpAddresses = response.json() + + expect(blacklistedIpAddresses).toHaveLength(1) + expect(blacklistedIpAddresses[0]).toBe(BLACKLISTED_IP_ADDRESS) + }) + }) + + describe('Update Blacklisted IP Addresses Tests', () => { + it('should not be able to update blacklisted IP addresses by user', async () => { + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${workspace1.slug}/blacklistedIpAddresses`, + payload: { + ipAddresses: [BLACKLISTED_IP_ADDRESS] + } + }) + + expect(response.statusCode).toBe(HttpStatus.UNAUTHORIZED) + }) + + it('should not be able to update blacklisted IP addresses by workspace administrator from a blacklisted IP address', async () => { + await prisma.workspace.update({ + where: { + id: workspace1.id + }, + data: { + blacklistedIpAddresses: [USER_IP_ADDRESS] + } + }) + + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}/blacklistedIpAddresses`, + payload: { + ipAddresses: [] + } + }) + + expect(response.statusCode).toBe(HttpStatus.UNAUTHORIZED) + }) + + it('should be able to update blacklisted IP addresses by workspace administrator', async () => { + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}/blacklistedIpAddresses`, + payload: { + ipAddresses: [BLACKLISTED_IP_ADDRESS] + } + }) + + expect(response.statusCode).toBe(HttpStatus.OK) + + const updatedBlacklistedIpAddresses = response.json() + + expect(updatedBlacklistedIpAddresses).toHaveLength(1) + expect(updatedBlacklistedIpAddresses[0]).toBe(BLACKLISTED_IP_ADDRESS) + }) + }) + describe('Export Data Tests', () => { it('should not be able to export data of a non-existing workspace', async () => { const response = await app.inject({