From 4047f1e0380a6fae337c6291ec870d4dc5fbbafe Mon Sep 17 00:00:00 2001 From: muntaxir4 Date: Sat, 28 Dec 2024 23:28:51 +0530 Subject: [PATCH 1/6] 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({ From bc4bdf3fb56b173a490000680724252ef2a63641 Mon Sep 17 00:00:00 2001 From: muntaxir4 Date: Sun, 29 Dec 2024 01:35:56 +0530 Subject: [PATCH 2/6] feat(docs): Update workspace role documentation to include project environments --- .../Workspace Role Controller/Create workspace role.bru | 2 +- .../Workspace Role Controller/Update workspace role.bru | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api-collection/Workspace Role Controller/Create workspace role.bru b/api-collection/Workspace Role Controller/Create workspace role.bru index de18bc0f..64d79058 100644 --- a/api-collection/Workspace Role Controller/Create workspace role.bru +++ b/api-collection/Workspace Role Controller/Create workspace role.bru @@ -39,5 +39,5 @@ docs { - `description`: (Optional) A description about the role - `colorCode`: (Optional) A hex color code for the role - `authorities`: (Optional) An array of allowed `Authorities`. Refer prisma schema. - - `projectSlugs`: (Optional) An array of project slugs to associate to this role. Associating projects to a role will apply all the authorities in the role to the project aswell. + - `projectEnvironments`: (Optional) An array of record containing projectSlug and environmentSlugs array to associate to this role. Associating project with particular environments to a role will allow access to only provided environments for the project. } diff --git a/api-collection/Workspace Role Controller/Update workspace role.bru b/api-collection/Workspace Role Controller/Update workspace role.bru index 8ea24ced..1ac8105e 100644 --- a/api-collection/Workspace Role Controller/Update workspace role.bru +++ b/api-collection/Workspace Role Controller/Update workspace role.bru @@ -36,5 +36,5 @@ docs { - `description`: (Optional) A description about the role - `colorCode`: (Optional) A hex color code for the role - `authorities`: (Optional) An array of allowed `Authorities`. Refer prisma schema. - - `projectIds`: (Optional) An array of project IDs to associate to this role. Associating projects to a role will apply all the authorities in the role to the project aswell. + - `projectEnvironments`: (Optional) An array of record containing projectSlug and environmentSlugs array to associate to this role. Associating project with particular environments to a role will allow access to only provided environments for the project. } From 1bfac4cc67b32b53e798f9ac4a0e53ec5b7fc518 Mon Sep 17 00:00:00 2001 From: muntaxir4 Date: Sun, 29 Dec 2024 03:01:18 +0530 Subject: [PATCH 3/6] feat(cli,api-client,schema): Add project environments support to workspace role --- apps/api/src/common/collective-authorities.ts | 2 +- .../service/workspace-role.service.ts | 31 +++-- .../commands/workspace/role/create.role.ts | 130 ++++++++++++++++++ .../commands/workspace/role/update.role.ts | 37 ++++- .../api-client/tests/workspace-role.spec.ts | 15 +- packages/schema/src/workspace-role/index.ts | 19 ++- packages/schema/tests/workspace-role.spec.ts | 88 +++++++++--- 7 files changed, 280 insertions(+), 42 deletions(-) create mode 100644 apps/cli/src/commands/workspace/role/create.role.ts diff --git a/apps/api/src/common/collective-authorities.ts b/apps/api/src/common/collective-authorities.ts index 1f045ad8..1f17a600 100644 --- a/apps/api/src/common/collective-authorities.ts +++ b/apps/api/src/common/collective-authorities.ts @@ -101,7 +101,7 @@ export const getCollectiveProjectAuthorities = async ( * 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 environment The environment with the project * @param prisma The prisma client * @returns */ 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 65407b16..591e1574 100644 --- a/apps/api/src/workspace-role/service/workspace-role.service.ts +++ b/apps/api/src/workspace-role/service/workspace-role.service.ts @@ -107,7 +107,9 @@ export class WorkspaceRoleService { if (dto.projectEnvironments) { // Create the project associations const projectSlugToIdMap = await this.getProjectSlugToIdMap( - dto.projectEnvironments.map((pe) => pe.projectSlug) + dto.projectEnvironments + .map((pe) => pe.projectSlug) + .filter((slug) => slug) ) for (const pe of dto.projectEnvironments) { @@ -242,7 +244,9 @@ export class WorkspaceRoleService { }) const projectSlugToIdMap = await this.getProjectSlugToIdMap( - dto.projectEnvironments.map((pe) => pe.projectSlug) + dto.projectEnvironments + .map((pe) => pe.projectSlug) + .filter((slug) => slug) ) for (const pe of dto.projectEnvironments) { @@ -525,6 +529,13 @@ export class WorkspaceRoleService { slug: true, name: true } + }, + environments: { + select: { + id: true, + slug: true, + name: true + } } } } @@ -563,13 +574,15 @@ export class WorkspaceRoleService { * @returns a Map of project slug to id */ private async getProjectSlugToIdMap(projectSlugs: string[]) { - const projects = await this.prisma.project.findMany({ - where: { - slug: { - in: projectSlugs - } - } - }) + 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/cli/src/commands/workspace/role/create.role.ts b/apps/cli/src/commands/workspace/role/create.role.ts new file mode 100644 index 00000000..e94fce2e --- /dev/null +++ b/apps/cli/src/commands/workspace/role/create.role.ts @@ -0,0 +1,130 @@ +import BaseCommand from '@/commands/base.command' +import { + type CommandOption, + type CommandActionData, + type CommandArgument +} from '@/types/command/command.types' +import { Logger } from '@/util/logger' +import ControllerInstance from '@/util/controller-instance' + +export default class UpdateRoleCommand extends BaseCommand { + getName() { + return 'create' + } + + getDescription(): string { + return 'Create workspace role' + } + + getArguments(): CommandArgument[] { + return [ + { + name: '', + description: 'Slug of the workspace role you want to fetch.' + } + ] + } + + getOptions(): CommandOption[] { + return [ + { + short: '-n', + long: '--name ', + description: 'Name of the workspace role.' + }, + { + short: '-d', + long: '--description ', + description: 'Description of the workspace role.' + }, + { + short: '-c', + long: '--color-code ', + description: 'Color code of the workspace role.' + }, + { + short: '-a', + long: '--authorities ', + description: 'Authorities of the workspace role.' + }, + { + short: '-p', + long: '--project-slugs ', + description: 'Project slugs of the workspace role.' + }, + { + short: '-e', + long: '--environment-slugs ', + description: + 'Environment slugs to be associated for projects. Separate list of environments with colon(:) for each project. And comma(,) to separate each project.' + } + ] + } + + async action({ args, options }: CommandActionData): Promise { + const [workspaceRoleSlug] = args + const { + name, + description, + colorCode, + authorities, + projectSlugs, + environmentSlugs + } = options + + const authoritiesArray = authorities?.split(',') + const projectSlugsArray = projectSlugs?.split(',') + const environmentSlugsArray = environmentSlugs?.split(',') + + if (projectSlugsArray?.length !== environmentSlugsArray?.length) { + Logger.error('Number of projects and environments should be equal') + return + } + + const projectEnvironments: Array<{ + projectSlug: string + environmentSlugs: string[] + }> = [] + + const len = projectSlugsArray.length + for (let i = 0; i < len; i++) { + projectEnvironments.push({ + projectSlug: projectSlugsArray[i], + environmentSlugs: environmentSlugsArray[i].split(':') + }) + } + + const { data, error, success } = + await ControllerInstance.getInstance().workspaceRoleController.updateWorkspaceRole( + { + workspaceRoleSlug, + name, + description, + colorCode, + authorities: authoritiesArray, + projectEnvironments: + projectEnvironments.length > 0 ? projectEnvironments : undefined + }, + this.headers + ) + + if (success) { + Logger.info('Workspace role created successfully:') + Logger.info(`Workspace role: ${data.name} (${data.slug})`) + Logger.info(`Description: ${data.description || 'N/A'}`) + Logger.info(`Created at ${data.createdAt}`) + Logger.info(`Updated at ${data.updatedAt}`) + Logger.info(`Color code: ${data.colorCode}`) + Logger.info('Authorities:') + for (const authority of data.authorities) { + Logger.info(`- ${authority}`) + } + Logger.info('Projects:') + for (const project of data.projects) { + Logger.info(`- ${project.project.name} (${project.project.slug})`) + } + } else { + Logger.error(`Failed creating workspace role: ${error.message}`) + } + } +} diff --git a/apps/cli/src/commands/workspace/role/update.role.ts b/apps/cli/src/commands/workspace/role/update.role.ts index cb6a01fa..e9e917ae 100644 --- a/apps/cli/src/commands/workspace/role/update.role.ts +++ b/apps/cli/src/commands/workspace/role/update.role.ts @@ -51,16 +51,48 @@ export default class UpdateRoleCommand extends BaseCommand { short: '-p', long: '--project-slugs ', description: 'Project slugs of the workspace role.' + }, + { + short: '-e', + long: '--environment-slugs ', + description: + 'Environment slugs to be associated for projects. Separate list of environments with colon(:) for each project. And comma(,) to separate each project.' } ] } async action({ args, options }: CommandActionData): Promise { const [workspaceRoleSlug] = args - const { name, description, colorCode, authorities, projectSlugs } = options + const { + name, + description, + colorCode, + authorities, + projectSlugs, + environmentSlugs + } = options const authoritiesArray = authorities?.split(',') const projectSlugsArray = projectSlugs?.split(',') + const environmentSlugsArray = environmentSlugs?.split(',') + + if (projectSlugsArray?.length !== environmentSlugsArray?.length) { + Logger.error('Number of projects and environments should be equal') + return + } + + const projectEnvironments: Array<{ + projectSlug: string + environmentSlugs: string[] + }> = [] + + const len = projectSlugsArray.length + for (let i = 0; i < len; i++) { + projectEnvironments.push({ + projectSlug: projectSlugsArray[i], + environmentSlugs: environmentSlugsArray[i].split(':') + }) + } const { data, error, success } = await ControllerInstance.getInstance().workspaceRoleController.updateWorkspaceRole( @@ -70,7 +102,8 @@ export default class UpdateRoleCommand extends BaseCommand { description, colorCode, authorities: authoritiesArray, - projectSlugs: projectSlugsArray + projectEnvironments: + projectEnvironments.length > 0 ? projectEnvironments : undefined }, this.headers ) diff --git a/packages/api-client/tests/workspace-role.spec.ts b/packages/api-client/tests/workspace-role.spec.ts index 8f920e20..f2d218d2 100644 --- a/packages/api-client/tests/workspace-role.spec.ts +++ b/packages/api-client/tests/workspace-role.spec.ts @@ -64,7 +64,7 @@ describe('Workspace Role Controller Tests', () => { description: 'Role for developers', colorCode: '#FF0000', authorities: ['READ_WORKSPACE', 'READ_PROJECT'], - projectSlugs: [projectSlug!] + projectEnvironments: [{ projectSlug, environmentSlugs: [] }] } const createWorkspaceRoleResponse = ( @@ -124,7 +124,7 @@ describe('Workspace Role Controller Tests', () => { description: 'Role for admins', colorCode: '#0000FF', authorities: ['READ_WORKSPACE'], - projectSlugs: [] + projectEnvironments: [] } const createRoleResponse = ( @@ -155,15 +155,6 @@ describe('Workspace Role Controller Tests', () => { ).data expect(updateRoleResponse.name).toBe('Lead Developer') - - const fetchRole = ( - await workspaceRoleController.getWorkspaceRole( - { workspaceRoleSlug: workspaceRoleSlug! }, - { 'x-e2e-user-email': email } - ) - ).data - - expect(fetchRole.name).toBe('Lead Developer') }) it('should delete a workspace role', async () => { @@ -208,7 +199,7 @@ describe('Workspace Role Controller Tests', () => { description: 'Role with project access', colorCode: '#0000FF', authorities: ['READ_WORKSPACE'], - projectSlugs: [projectSlug!] + projectEnvironments: [{ projectSlug, environmentSlugs: [] }] } const createRoleResponse = ( diff --git a/packages/schema/src/workspace-role/index.ts b/packages/schema/src/workspace-role/index.ts index 09633264..ce34585e 100644 --- a/packages/schema/src/workspace-role/index.ts +++ b/packages/schema/src/workspace-role/index.ts @@ -1,4 +1,5 @@ import { authorityEnum } from '@/enums' +import { EnvironmentSchema } from '@/environment' import { PageRequestSchema, PageResponseSchema } from '@/pagination' import { BaseProjectSchema } from '@/project' import { WorkspaceSchema } from '@/workspace' @@ -21,7 +22,14 @@ export const WorkspaceRoleSchema = z.object({ id: BaseProjectSchema.shape.id, name: BaseProjectSchema.shape.name, slug: BaseProjectSchema.shape.slug - }) + }), + environments: z.array( + z.object({ + id: EnvironmentSchema.shape.id, + name: EnvironmentSchema.shape.name, + slug: EnvironmentSchema.shape.slug + }) + ) }) ) }) @@ -32,7 +40,14 @@ export const CreateWorkspaceRoleRequestSchema = z.object({ description: z.string().optional(), colorCode: z.string().optional(), authorities: z.array(authorityEnum).optional(), - projectSlugs: z.array(BaseProjectSchema.shape.slug).optional() + projectEnvironments: z + .array( + z.object({ + projectSlug: BaseProjectSchema.shape.slug, + environmentSlugs: z.array(EnvironmentSchema.shape.slug) + }) + ) + .optional() }) export const CreateWorkspaceRoleResponseSchema = WorkspaceRoleSchema diff --git a/packages/schema/tests/workspace-role.spec.ts b/packages/schema/tests/workspace-role.spec.ts index e25531f6..17d799f4 100644 --- a/packages/schema/tests/workspace-role.spec.ts +++ b/packages/schema/tests/workspace-role.spec.ts @@ -34,7 +34,14 @@ describe('WorkspaceRoleSchema Tests', () => { id: 'project123', name: 'Project Name', slug: 'project-slug' - } + }, + environments: [ + { + id: 'env123', + name: 'Environment Name', + slug: 'env-slug' + } + ] } ] }) @@ -59,7 +66,8 @@ describe('WorkspaceRoleSchema Tests', () => { id: 'project123', name: 'Project Name', slug: 'project-slug' - } + }, + environments: [] } ] }) @@ -85,11 +93,12 @@ describe('WorkspaceRoleSchema Tests', () => { name: 'Project Name', slug: 'project-slug' } + // missing environments } ] }) expect(result.success).toBe(false) - expect(result.error?.issues).toHaveLength(3) + expect(result.error?.issues).toHaveLength(4) }) describe('CreateWorkspaceRoleRequestSchema Tests', () => { @@ -98,7 +107,12 @@ describe('WorkspaceRoleSchema Tests', () => { workspaceSlug: 'workspace-1', name: 'Admin Role', authorities: [authorityEnum.enum['CREATE_PROJECT']], - projectIds: ['project1', 'project2'] + projectEnvironments: [ + { + projectSlug: 'project-1', + environmentSlugs: ['env-1', 'env-2'] + } + ] }) expect(result.success).toBe(true) @@ -113,10 +127,11 @@ describe('WorkspaceRoleSchema Tests', () => { expect(result.success).toBe(true) }) - it('should validate if optional fields are omitted for CreateWorkspaceRoleRequestSchema', () => { + it('should validate if some optional fields are omitted for CreateWorkspaceRoleRequestSchema', () => { const result = CreateWorkspaceRoleRequestSchema.safeParse({ workspaceSlug: 'workspace-1', - name: 'Manager Role' + name: 'Manager Role', + colorCode: '#FF5733' }) expect(result.success).toBe(true) @@ -154,7 +169,12 @@ describe('WorkspaceRoleSchema Tests', () => { authorityEnum.enum['CREATE_PROJECT'], authorityEnum.enum['READ_USERS'] ], - projectIds: ['project1', 'project2'] + projectEnvironments: [ + { + projectSlug: 'project-1', + environmentSlugs: ['env-1', 'env-2'] + } + ] }) expect(result.success).toBe(true) @@ -180,7 +200,14 @@ describe('WorkspaceRoleSchema Tests', () => { id: 'project123', name: 'Project Name', slug: 'project-slug' - } + }, + environments: [ + { + id: 'env123', + name: 'Environment Name', + slug: 'env-slug' + } + ] } ] }) @@ -205,7 +232,14 @@ describe('WorkspaceRoleSchema Tests', () => { id: 'project123', name: 'Project Name', slug: 'project-slug' - } + }, + environments: [ + { + id: 'env123', + name: 'Environment Name', + slug: 'env-slug' + } + ] } ] }) @@ -220,7 +254,13 @@ describe('WorkspaceRoleSchema Tests', () => { workspaceRoleSlug: 'admin-role', name: 'Updated Admin Role', description: 'Updated role with admin privileges', - colorCode: '#FF5733' + colorCode: '#FF5733', + projectEnvironments: [ + { + projectSlug: 'project-1', + environmentSlugs: ['env-1', 'env-2'] + } + ] }) expect(result.success).toBe(true) }) @@ -232,10 +272,10 @@ describe('WorkspaceRoleSchema Tests', () => { description: 'Updated role with admin privileges', colorCode: '#FF5733', authorities: ['INVALID_AUTHORITY'], // Invalid authority - projectSlugs: ['project-slug'] + projectEnvironments: ['project-slug'] // Should be object }) expect(result.success).toBe(false) - expect(result.error?.issues).toHaveLength(2) + expect(result.error?.issues).toHaveLength(3) }) }) @@ -258,7 +298,14 @@ describe('WorkspaceRoleSchema Tests', () => { id: 'project123', name: 'Project Name', slug: 'project-slug' - } + }, + environments: [ + { + id: 'env123', + name: 'Environment Name', + slug: 'env-slug' + } + ] } ] }) @@ -284,11 +331,12 @@ describe('WorkspaceRoleSchema Tests', () => { name: 'Project Name', slug: 'project-slug' } + // missing environments } ] }) expect(result.success).toBe(false) - expect(result.error?.issues).toHaveLength(2) + expect(result.error?.issues).toHaveLength(3) }) }) @@ -395,7 +443,14 @@ describe('WorkspaceRoleSchema Tests', () => { id: 'project123', name: 'Project Name', slug: 'project-slug' - } + }, + environments: [ + { + id: 'env123', + name: 'Environment Name', + slug: 'env-slug' + } + ] } ] }) @@ -421,11 +476,12 @@ describe('WorkspaceRoleSchema Tests', () => { name: 'Project Name' // Missing slug } + // missing environments } ] }) expect(result.success).toBe(false) - expect(result.error?.issues).toHaveLength(3) + expect(result.error?.issues).toHaveLength(4) }) }) From 62c575121978914ef630e092250cf37eee442d07 Mon Sep 17 00:00:00 2001 From: muntaxir4 Date: Mon, 30 Dec 2024 04:17:02 +0530 Subject: [PATCH 4/6] fix typo --- apps/api/src/common/collective-authorities.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api/src/common/collective-authorities.ts b/apps/api/src/common/collective-authorities.ts index 1f17a600..29990484 100644 --- a/apps/api/src/common/collective-authorities.ts +++ b/apps/api/src/common/collective-authorities.ts @@ -107,7 +107,7 @@ export const getCollectiveProjectAuthorities = async ( */ export const getCollectiveEnvironmentAuthorities = async ( userId: User['id'], - environemnt: EnvironmentWithProject, + environment: EnvironmentWithProject, prisma: PrismaClient ): Promise> => { const authorities = new Set() @@ -117,7 +117,7 @@ export const getCollectiveEnvironmentAuthorities = async ( where: { workspaceMember: { userId, - workspaceId: environemnt.project.workspaceId + workspaceId: environment.project.workspaceId } }, select: { @@ -136,10 +136,10 @@ export const getCollectiveEnvironmentAuthorities = async ( const environmentRoleAssociations = await prisma.projectWorkspaceRoleAssociation.findMany({ where: { - projectId: environemnt.project.id, + projectId: environment.project.id, environments: { some: { - id: environemnt.id + id: environment.id } } }, From 2a9d188c897cc783578c24769f3c48ac3b1df213 Mon Sep 17 00:00:00 2001 From: rajdip-b Date: Tue, 31 Dec 2024 11:19:24 +0530 Subject: [PATCH 5/6] simplified environment authority fetch --- apps/api/src/common/collective-authorities.ts | 37 +++++-------------- 1 file changed, 10 insertions(+), 27 deletions(-) diff --git a/apps/api/src/common/collective-authorities.ts b/apps/api/src/common/collective-authorities.ts index 29990484..11e1801f 100644 --- a/apps/api/src/common/collective-authorities.ts +++ b/apps/api/src/common/collective-authorities.ts @@ -118,28 +118,17 @@ export const getCollectiveEnvironmentAuthorities = async ( workspaceMember: { userId, workspaceId: environment.project.workspaceId - } - }, - select: { + }, role: { - select: { - authorities: true - } - } - } - }) - - if (projectRoleAssociations.length === 0) { - return authorities - } - - const environmentRoleAssociations = - await prisma.projectWorkspaceRoleAssociation.findMany({ - where: { - projectId: environment.project.id, - environments: { - some: { - id: environment.id + projects: { + some: { + projectId: environment.project.id, + environments: { + some: { + id: environment.id + } + } + } } } }, @@ -158,11 +147,5 @@ export const getCollectiveEnvironmentAuthorities = async ( }) }) - environmentRoleAssociations.forEach((roleAssociation) => { - roleAssociation.role.authorities.forEach((authority) => { - authorities.add(authority) - }) - }) - return authorities } From 27c0e8804c35d219afe73bcc87e460353de480c5 Mon Sep 17 00:00:00 2001 From: muntaxir4 Date: Thu, 2 Jan 2025 03:14:49 +0530 Subject: [PATCH 6/6] fix getAuthorities over Environment and add validation while including environments to projects --- apps/api/src/common/collective-authorities.ts | 35 ++++++++++----- .../service/workspace-role.service.ts | 40 +++++++++++++++++ .../workspace-role/workspace-role.e2e.spec.ts | 43 +++++++++++++++++++ 3 files changed, 108 insertions(+), 10 deletions(-) diff --git a/apps/api/src/common/collective-authorities.ts b/apps/api/src/common/collective-authorities.ts index 11e1801f..3504e321 100644 --- a/apps/api/src/common/collective-authorities.ts +++ b/apps/api/src/common/collective-authorities.ts @@ -112,24 +112,38 @@ export const getCollectiveEnvironmentAuthorities = async ( ): Promise> => { const authorities = new Set() - const projectRoleAssociations = - await prisma.workspaceMemberRoleAssociation.findMany({ + const roleAssociations = await prisma.workspaceMemberRoleAssociation.findMany( + { where: { workspaceMember: { userId, workspaceId: environment.project.workspaceId }, role: { - projects: { - some: { - projectId: environment.project.id, - environments: { + OR: [ + { + projects: { + some: { + projectId: environment.project.id, + environments: { + none: {} + } + } + } + }, + { + projects: { some: { - id: environment.id + projectId: environment.project.id, + environments: { + some: { + id: environment.id + } + } } } } - } + ] } }, select: { @@ -139,9 +153,10 @@ export const getCollectiveEnvironmentAuthorities = async ( } } } - }) + } + ) - projectRoleAssociations.forEach((roleAssociation) => { + roleAssociations.forEach((roleAssociation) => { roleAssociation.role.authorities.forEach((authority) => { authorities.add(authority) }) 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 591e1574..9b4ef93c 100644 --- a/apps/api/src/workspace-role/service/workspace-role.service.ts +++ b/apps/api/src/workspace-role/service/workspace-role.service.ts @@ -115,6 +115,26 @@ export class WorkspaceRoleService { for (const pe of dto.projectEnvironments) { const projectId = projectSlugToIdMap.get(pe.projectSlug) if (projectId) { + //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( + `All environmentSlugs in the project ${pe.projectSlug} are not part of the project` + ) + } + // Create the project workspace role association with the environments accessible on the project op.push( this.prisma.projectWorkspaceRoleAssociation.create({ @@ -252,6 +272,26 @@ export class WorkspaceRoleService { for (const pe of dto.projectEnvironments) { const projectId = projectSlugToIdMap.get(pe.projectSlug) if (projectId) { + //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( + `All environmentSlugs in the project ${pe.projectSlug} are not part of the project` + ) + } + // Create or Update the project workspace role association with the environments accessible on the project await this.prisma.projectWorkspaceRoleAssociation.upsert({ where: { 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 3cb18195..3bb4790e 100644 --- a/apps/api/src/workspace-role/workspace-role.e2e.spec.ts +++ b/apps/api/src/workspace-role/workspace-role.e2e.spec.ts @@ -422,6 +422,29 @@ describe('Workspace Role Controller Tests', () => { }) ) }) + + it('should not be able to create workspace role where environments do not belong to the project', async () => { + 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: ['production'] + } + ] + }, + headers: { + 'x-e2e-user-email': alice.email + } + }) + expect(response.statusCode).toBe(400) + }) }) it('should be able to read workspace role with READ_WORKSPACE_ROLE authority', async () => { @@ -838,6 +861,26 @@ describe('Workspace Role Controller Tests', () => { expect(response.statusCode).toBe(401) }) + + it('should not be able to update the workspace role with environments that do not belong to the project', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/workspace-role/${adminRole1.slug}`, + payload: { + projectEnvironments: [ + { + projectSlug: projects[0].slug, + environmentSlugs: ['production'] + } + ] + }, + headers: { + 'x-e2e-user-email': alice.email + } + }) + + expect(response.statusCode).toBe(400) + }) }) describe('Delete Workspace Role Tests', () => {