From 4047f1e0380a6fae337c6291ec870d4dc5fbbafe Mon Sep 17 00:00:00 2001 From: muntaxir4 Date: Sat, 28 Dec 2024 23:28:51 +0530 Subject: [PATCH] feat(api) : Enhance permissions to allow filtering by environments through roles --- .../src/common/authority-checker.service.ts | 5 +- apps/api/src/common/collective-authorities.ts | 73 +++++++++ .../create.environment/create.environment.ts | 2 +- apps/api/src/event/event.e2e.spec.ts | 4 +- .../migration.sql | 16 ++ .../src/prisma/migrations/migration_lock.toml | 2 +- apps/api/src/prisma/schema.prisma | 4 + .../create-workspace-role.ts | 21 ++- .../service/workspace-role.service.ts | 118 ++++++++++---- .../workspace-role/workspace-role.e2e.spec.ts | 147 +++++++++++++++++- apps/api/src/workspace/workspace.e2e.spec.ts | 4 +- 11 files changed, 351 insertions(+), 45 deletions(-) create mode 100644 apps/api/src/prisma/migrations/20241226231705_add_accessible_environments_in_role/migration.sql diff --git a/apps/api/src/common/authority-checker.service.ts b/apps/api/src/common/authority-checker.service.ts index d053fc66..6d8641c1 100644 --- a/apps/api/src/common/authority-checker.service.ts +++ b/apps/api/src/common/authority-checker.service.ts @@ -16,6 +16,7 @@ import { ProjectWithSecrets } from '@/project/project.types' import { SecretWithProjectAndVersion } from '@/secret/secret.types' import { CustomLoggerService } from './logger.service' import { + getCollectiveEnvironmentAuthorities, getCollectiveProjectAuthorities, getCollectiveWorkspaceAuthorities } from './collective-authorities' @@ -221,9 +222,9 @@ export class AuthorityCheckerService { throw new NotFoundException(`Environment ${entity.slug} not found`) } - const permittedAuthorities = await getCollectiveProjectAuthorities( + const permittedAuthorities = await getCollectiveEnvironmentAuthorities( userId, - environment.project, + environment, prisma ) diff --git a/apps/api/src/common/collective-authorities.ts b/apps/api/src/common/collective-authorities.ts index b351dede..1f045ad8 100644 --- a/apps/api/src/common/collective-authorities.ts +++ b/apps/api/src/common/collective-authorities.ts @@ -1,3 +1,4 @@ +import { EnvironmentWithProject } from '@/environment/environment.types' import { Authority, PrismaClient, @@ -93,3 +94,75 @@ export const getCollectiveProjectAuthorities = async ( return authorities } + +/** + * Given the userId and environment, this function returns the set of authorities + * that are formed by accumulating a set of all the authorities across all the + * roles that the user has in the workspace, adding an extra layer of filtering + * by the project and the environment. + * @param userId The id of the user + * @param environemnt The environment with the project + * @param prisma The prisma client + * @returns + */ +export const getCollectiveEnvironmentAuthorities = async ( + userId: User['id'], + environemnt: EnvironmentWithProject, + prisma: PrismaClient +): Promise> => { + const authorities = new Set() + + const projectRoleAssociations = + await prisma.workspaceMemberRoleAssociation.findMany({ + where: { + workspaceMember: { + userId, + workspaceId: environemnt.project.workspaceId + } + }, + select: { + role: { + select: { + authorities: true + } + } + } + }) + + if (projectRoleAssociations.length === 0) { + return authorities + } + + const environmentRoleAssociations = + await prisma.projectWorkspaceRoleAssociation.findMany({ + where: { + projectId: environemnt.project.id, + environments: { + some: { + id: environemnt.id + } + } + }, + select: { + role: { + select: { + authorities: true + } + } + } + }) + + projectRoleAssociations.forEach((roleAssociation) => { + roleAssociation.role.authorities.forEach((authority) => { + authorities.add(authority) + }) + }) + + environmentRoleAssociations.forEach((roleAssociation) => { + roleAssociation.role.authorities.forEach((authority) => { + authorities.add(authority) + }) + }) + + return authorities +} diff --git a/apps/api/src/environment/dto/create.environment/create.environment.ts b/apps/api/src/environment/dto/create.environment/create.environment.ts index ae2b7db8..191db159 100644 --- a/apps/api/src/environment/dto/create.environment/create.environment.ts +++ b/apps/api/src/environment/dto/create.environment/create.environment.ts @@ -1,4 +1,4 @@ -import { IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator' +import { IsNotEmpty, IsOptional, IsString } from 'class-validator' export class CreateEnvironment { @IsString() diff --git a/apps/api/src/event/event.e2e.spec.ts b/apps/api/src/event/event.e2e.spec.ts index c2baa4b7..fb46022a 100644 --- a/apps/api/src/event/event.e2e.spec.ts +++ b/apps/api/src/event/event.e2e.spec.ts @@ -438,7 +438,9 @@ describe('Event Controller Tests', () => { description: 'Some description', colorCode: '#000000', authorities: [], - projectSlugs: [project.slug] + projectEnvironments: [ + { projectSlug: project.slug, environmentSlugs: [] } + ] } ) diff --git a/apps/api/src/prisma/migrations/20241226231705_add_accessible_environments_in_role/migration.sql b/apps/api/src/prisma/migrations/20241226231705_add_accessible_environments_in_role/migration.sql new file mode 100644 index 00000000..b24f09b9 --- /dev/null +++ b/apps/api/src/prisma/migrations/20241226231705_add_accessible_environments_in_role/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "_EnvironmentToProjectWorkspaceRoleAssociation" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_EnvironmentToProjectWorkspaceRoleAssociation_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE INDEX "_EnvironmentToProjectWorkspaceRoleAssociation_B_index" ON "_EnvironmentToProjectWorkspaceRoleAssociation"("B"); + +-- AddForeignKey +ALTER TABLE "_EnvironmentToProjectWorkspaceRoleAssociation" ADD CONSTRAINT "_EnvironmentToProjectWorkspaceRoleAssociation_A_fkey" FOREIGN KEY ("A") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_EnvironmentToProjectWorkspaceRoleAssociation" ADD CONSTRAINT "_EnvironmentToProjectWorkspaceRoleAssociation_B_fkey" FOREIGN KEY ("B") REFERENCES "ProjectWorkspaceRoleAssociation"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/src/prisma/migrations/migration_lock.toml b/apps/api/src/prisma/migrations/migration_lock.toml index fbffa92c..648c57fd 100644 --- a/apps/api/src/prisma/migrations/migration_lock.toml +++ b/apps/api/src/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) +# It should be added in your version-control system (e.g., Git) provider = "postgresql" \ No newline at end of file diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma index d64780ef..2c2ea940 100644 --- a/apps/api/src/prisma/schema.prisma +++ b/apps/api/src/prisma/schema.prisma @@ -252,6 +252,8 @@ model Environment { project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) projectId String + projectWorkspaceRoleAssociations ProjectWorkspaceRoleAssociation[] + @@unique([projectId, name]) @@index([name]) } @@ -295,6 +297,8 @@ model ProjectWorkspaceRoleAssociation { project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) projectId String + environments Environment[] + @@unique([roleId, projectId]) } diff --git a/apps/api/src/workspace-role/dto/create-workspace-role/create-workspace-role.ts b/apps/api/src/workspace-role/dto/create-workspace-role/create-workspace-role.ts index 2e51a4ea..772991d1 100644 --- a/apps/api/src/workspace-role/dto/create-workspace-role/create-workspace-role.ts +++ b/apps/api/src/workspace-role/dto/create-workspace-role/create-workspace-role.ts @@ -1,5 +1,11 @@ import { Authority } from '@prisma/client' -import { IsArray, IsOptional, IsString } from 'class-validator' +import { + IsArray, + IsNotEmpty, + IsOptional, + IsString, + ValidateNested +} from 'class-validator' export class CreateWorkspaceRole { @IsString() @@ -19,5 +25,16 @@ export class CreateWorkspaceRole { @IsArray() @IsOptional() - readonly projectSlugs?: string[] + @ValidateNested({ each: true }) + readonly projectEnvironments?: ProjectEnvironments[] +} + +class ProjectEnvironments { + @IsString() + @IsNotEmpty() + readonly projectSlug: string + + @IsArray() + @IsNotEmpty({ each: true }) + readonly environmentSlugs: string[] } 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 f17cd27a..65407b16 100644 --- a/apps/api/src/workspace-role/service/workspace-role.service.ts +++ b/apps/api/src/workspace-role/service/workspace-role.service.ts @@ -83,7 +83,11 @@ export class WorkspaceRoleService { data: { id: workspaceRoleId, name: dto.name, - slug: await generateEntitySlug(dto.name, 'API_KEY', this.prisma), + slug: await generateEntitySlug( + dto.name, + 'WORKSPACE_ROLE', + this.prisma + ), description: dto.description, colorCode: dto.colorCode, authorities: dto.authorities ?? [], @@ -94,6 +98,43 @@ export class WorkspaceRoleService { } } }, + 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) { + // 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: { + connect: pe.environmentSlugs.map((slug) => ({ slug })) + } + } + }) + ) + } + } + } + + // Fetch the new workspace role + op.push( + this.prisma.workspaceRole.findFirst({ + where: { + id: workspaceRoleId + }, include: { projects: { select: { @@ -103,6 +144,13 @@ export class WorkspaceRoleService { slug: true, name: true } + }, + environments: { + select: { + id: true, + slug: true, + name: true + } } } } @@ -110,25 +158,7 @@ export class WorkspaceRoleService { }) ) - if (dto.projectSlugs) { - // Create the project associations - const projectSlugToIdMap = await this.getProjectSlugToIdMap( - dto.projectSlugs - ) - - if (dto.projectSlugs && dto.projectSlugs.length > 0) { - op.push( - this.prisma.projectWorkspaceRoleAssociation.createMany({ - data: dto.projectSlugs.map((projectSlug) => ({ - roleId: workspaceRoleId, - projectId: projectSlugToIdMap.get(projectSlug) - })) - }) - ) - } - } - - const workspaceRole = (await this.prisma.$transaction(op))[0] + const workspaceRole = (await this.prisma.$transaction(op)).pop() await createEvent( { @@ -204,7 +234,7 @@ export class WorkspaceRoleService { ) } - if (dto.projectSlugs) { + if (dto.projectEnvironments) { await this.prisma.projectWorkspaceRoleAssociation.deleteMany({ where: { roleId: workspaceRoleId @@ -212,15 +242,36 @@ export class WorkspaceRoleService { }) const projectSlugToIdMap = await this.getProjectSlugToIdMap( - dto.projectSlugs + dto.projectEnvironments.map((pe) => pe.projectSlug) ) - await this.prisma.projectWorkspaceRoleAssociation.createMany({ - data: dto.projectSlugs.map((projectSlug) => ({ - roleId: workspaceRoleId, - projectId: projectSlugToIdMap.get(projectSlug) - })) - }) + for (const pe of dto.projectEnvironments) { + const projectId = projectSlugToIdMap.get(pe.projectSlug) + if (projectId) { + // 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: { + set: [], + connect: pe.environmentSlugs.map((slug) => ({ slug })) + } + }, + create: { + roleId: workspaceRoleId, + projectId: projectId, + environments: { + connect: pe.environmentSlugs.map((slug) => ({ slug })) + } + } + }) + } + } } const updatedWorkspaceRole = await this.prisma.workspaceRole.update({ @@ -229,6 +280,9 @@ export class WorkspaceRoleService { }, 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 @@ -242,12 +296,18 @@ export class WorkspaceRoleService { slug: true, name: true } + }, + environments: { + select: { + id: true, + slug: true, + name: true + } } } } } }) - await createEvent( { triggeredBy: user, 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 3ca44d07..3cb18195 100644 --- a/apps/api/src/workspace-role/workspace-role.e2e.spec.ts +++ b/apps/api/src/workspace-role/workspace-role.e2e.spec.ts @@ -366,6 +366,62 @@ describe('Workspace Role Controller Tests', () => { expect(response.statusCode).toBe(401) }) + + it('should be able to create workspace role with environment only access for projects', async () => { + const devEnvironment = await prisma.environment.create({ + data: { + name: 'development', + slug: 'development', + projectId: projects[0].id + } + }) + const response = await app.inject({ + method: 'POST', + url: `/workspace-role/${workspaceAlice.slug}`, + payload: { + name: 'Test Role 2', + description: 'Test Role Description', + colorCode: '#0000FF', + authorities: [Authority.READ_ENVIRONMENT, Authority.READ_VARIABLE], + projectEnvironments: [ + { + projectSlug: projects[0].slug, + environmentSlugs: ['development'] + } + ] + }, + headers: { + 'x-e2e-user-email': alice.email + } + }) + expect(response.statusCode).toBe(201) + expect(response.json()).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: 'Test Role 2', + description: 'Test Role Description', + colorCode: '#0000FF', + authorities: [Authority.READ_ENVIRONMENT, Authority.READ_VARIABLE], + workspaceId: workspaceAlice.id, + projects: [ + { + project: { + id: projects[0].id, + name: projects[0].name, + slug: projects[0].slug + }, + environments: [ + { + id: devEnvironment.id, + name: 'development', + slug: 'development' + } + ] + } + ] + }) + ) + }) }) it('should be able to read workspace role with READ_WORKSPACE_ROLE authority', async () => { @@ -588,12 +644,36 @@ describe('Workspace Role Controller Tests', () => { }) }) - it('should be able to add projects to the role', async () => { + it('should be able to add environment access for projects to the role', async () => { + const devEnvironment = await prisma.environment.create({ + data: { + name: 'dev', + slug: 'dev', + projectId: projects[0].id + } + }) + const stageEnvironment = await prisma.environment.create({ + data: { + name: 'stage', + slug: 'stage', + projectId: projects[1].id + } + }) + const response = await app.inject({ method: 'PUT', url: `/workspace-role/${adminRole1.slug}`, payload: { - projectSlugs: projects.map((project) => project.slug) + projectEnvironments: [ + { + projectSlug: projects[0].slug, + environmentSlugs: ['dev'] + }, + { + projectSlug: projects[1].slug, + environmentSlugs: ['stage'] + } + ] }, headers: { 'x-e2e-user-email': alice.email @@ -611,20 +691,48 @@ describe('Workspace Role Controller Tests', () => { id: projects[0].id, name: projects[0].name, slug: projects[0].slug - } + }, + environments: [ + { + id: devEnvironment.id, + name: 'dev', + slug: 'dev' + } + ] }, { project: { id: projects[1].id, name: projects[1].name, slug: projects[1].slug - } + }, + environments: [ + { + id: stageEnvironment.id, + name: 'stage', + slug: 'stage' + } + ] } ]) }) }) - it('should be able to add projects to the role with UPDATE_WORKSPACE_ROLE and READ_PROJECT authorities', async () => { + it('should be able to add environment access for projects to the role with UPDATE_WORKSPACE_ROLE and READ_PROJECT authorities', async () => { + const devEnvironment = await prisma.environment.create({ + data: { + name: 'dev', + slug: 'dev', + projectId: projects[0].id + } + }) + const stageEnvironment = await prisma.environment.create({ + data: { + name: 'stage', + slug: 'stage', + projectId: projects[1].id + } + }) await prisma.workspaceRole.update({ where: { workspaceId_name: { @@ -647,7 +755,16 @@ describe('Workspace Role Controller Tests', () => { method: 'PUT', url: `/workspace-role/${adminRole1.slug}`, payload: { - projectSlugs: projects.map((project) => project.slug) + projectEnvironments: [ + { + projectSlug: projects[0].slug, + environmentSlugs: ['dev'] + }, + { + projectSlug: projects[1].slug, + environmentSlugs: ['stage'] + } + ] }, headers: { 'x-e2e-user-email': charlie.email @@ -666,14 +783,28 @@ describe('Workspace Role Controller Tests', () => { id: projects[0].id, name: projects[0].name, slug: projects[0].slug - } + }, + environments: [ + { + id: devEnvironment.id, + name: 'dev', + slug: 'dev' + } + ] }, { project: { id: projects[1].id, name: projects[1].name, slug: projects[1].slug - } + }, + environments: [ + { + id: stageEnvironment.id, + name: 'stage', + slug: 'stage' + } + ] } ]) }) diff --git a/apps/api/src/workspace/workspace.e2e.spec.ts b/apps/api/src/workspace/workspace.e2e.spec.ts index 30960866..482c3108 100644 --- a/apps/api/src/workspace/workspace.e2e.spec.ts +++ b/apps/api/src/workspace/workspace.e2e.spec.ts @@ -756,7 +756,9 @@ describe('Workspace Controller Tests', () => { Authority.READ_VARIABLE, Authority.READ_WORKSPACE ], - projectSlugs: [project2Response.slug] + projectEnvironments: [ + { projectSlug: project2Response.slug, environmentSlugs: [] } + ] }) const project1DevEnv = await prisma.environment.findUnique({