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. } 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..a9220415 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,69 @@ 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 environment The environment with the project + * @param prisma The prisma client + * @returns + */ +export const getCollectiveEnvironmentAuthorities = async ( + userId: User['id'], + environment: EnvironmentWithProject, + prisma: PrismaClient +): Promise> => { + const authorities = new Set() + + const roleAssociations = await prisma.workspaceMemberRoleAssociation.findMany( + { + where: { + workspaceMember: { + userId, + workspaceId: environment.project.workspaceId + }, + role: { + OR: [ + { + projects: { + some: { + projectId: environment.project.id, + environments: { + some: { + id: environment.id + } + } + } + } + }, + // Check if the user has the WORKSPACE_ADMIN authority + { + authorities: { + has: Authority.WORKSPACE_ADMIN + } + } + ] + } + }, + select: { + role: { + select: { + authorities: true + } + } + } + } + ) + + roleAssociations.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..4789b7c5 100644 --- a/apps/api/src/event/event.e2e.spec.ts +++ b/apps/api/src/event/event.e2e.spec.ts @@ -438,7 +438,7 @@ describe('Event Controller Tests', () => { description: 'Some description', colorCode: '#000000', authorities: [], - projectSlugs: [project.slug] + projectEnvironments: [{ projectSlug: project.slug }] } ) 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 2fb61b27..126557a9 100644 --- a/apps/api/src/prisma/schema.prisma +++ b/apps/api/src/prisma/schema.prisma @@ -253,6 +253,8 @@ model Environment { project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) projectId String + projectWorkspaceRoleAssociations ProjectWorkspaceRoleAssociation[] + @@unique([projectId, name]) @@index([name]) } @@ -296,6 +298,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.spec.ts b/apps/api/src/workspace-role/dto/create-workspace-role/create-workspace-role.spec.ts index d1230768..9d32872a 100644 --- a/apps/api/src/workspace-role/dto/create-workspace-role/create-workspace-role.spec.ts +++ b/apps/api/src/workspace-role/dto/create-workspace-role/create-workspace-role.spec.ts @@ -1,3 +1,4 @@ +import 'reflect-metadata' import { CreateWorkspaceRole } from './create-workspace-role' describe('CreateWorkspaceRole', () => { 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..423a2cb7 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,23 @@ import { Authority } from '@prisma/client' -import { IsArray, IsOptional, IsString } from 'class-validator' +import { + IsArray, + IsNotEmpty, + IsOptional, + IsString, + ValidateNested +} from 'class-validator' +import { Type } from 'class-transformer' + +class ProjectEnvironments { + @IsString() + @IsNotEmpty() + readonly projectSlug: string + + @IsArray() + @IsOptional() + @IsNotEmpty({ each: true }) + readonly environmentSlugs?: string[] +} export class CreateWorkspaceRole { @IsString() @@ -19,5 +37,7 @@ export class CreateWorkspaceRole { @IsArray() @IsOptional() - readonly projectSlugs?: string[] + @ValidateNested({ each: true }) + @Type(() => ProjectEnvironments) + readonly projectEnvironments?: ProjectEnvironments[] } 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..ccbb3419 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,92 @@ 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) { + if (pe.environmentSlugs && pe.environmentSlugs.length === 0) + throw new BadRequestException( + `EnvironmentSlugs 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( + `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 UnauthorizedException( + `User does not have 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( + `Project with slug ${pe.projectSlug} not found` + ) + } + } + } + + // Fetch the new workspace role + op.push( + this.prisma.workspaceRole.findFirst({ + where: { + id: workspaceRoleId + }, include: { projects: { select: { @@ -103,6 +193,13 @@ export class WorkspaceRoleService { slug: true, name: true } + }, + environments: { + select: { + id: true, + slug: true, + name: true + } } } } @@ -110,25 +207,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 +283,7 @@ export class WorkspaceRoleService { ) } - if (dto.projectSlugs) { + if (dto.projectEnvironments) { await this.prisma.projectWorkspaceRoleAssociation.deleteMany({ where: { roleId: workspaceRoleId @@ -212,15 +291,85 @@ 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) { + if (pe.environmentSlugs && pe.environmentSlugs.length === 0) + throw new BadRequestException( + `EnvironmentSlugs 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( + `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 UnauthorizedException( + `User does 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( + `Project with slug ${pe.projectSlug} not found` + ) + } + } } const updatedWorkspaceRole = await this.prisma.workspaceRole.update({ @@ -229,6 +378,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 +394,18 @@ export class WorkspaceRoleService { slug: true, name: true } + }, + environments: { + select: { + id: true, + slug: true, + name: true + } } } } } }) - await createEvent( { triggeredBy: user, @@ -465,6 +623,13 @@ export class WorkspaceRoleService { slug: true, name: true } + }, + environments: { + select: { + id: true, + slug: true, + name: true + } } } } @@ -503,13 +668,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/api/src/workspace-role/workspace-role.e2e.spec.ts b/apps/api/src/workspace-role/workspace-role.e2e.spec.ts index 3ca44d07..0981c564 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,115 @@ 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 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 not be able to add environments that the user doesn't have read access to", async () => { + 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': charlie.email + } + }) + expect(response.statusCode).toBe(401) + }) }) it('should be able to read workspace role with READ_WORKSPACE_ROLE authority', async () => { @@ -588,12 +697,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 +744,49 @@ 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 READ_WORKSPACE, UPDATE_WORKSPACE_ROLE, READ_PROJECT, READ_ENVIRONMENT 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 + } + }) + // update the workspace member role with the required authorities await prisma.workspaceRole.update({ where: { workspaceId_name: { @@ -637,7 +799,34 @@ describe('Workspace Role Controller Tests', () => { set: [ Authority.UPDATE_WORKSPACE_ROLE, Authority.READ_PROJECT, - Authority.READ_WORKSPACE_ROLE + Authority.READ_WORKSPACE_ROLE, + Authority.READ_ENVIRONMENT + ] + }, + projects: { + create: [ + { + project: { + connect: { + id: projects[0].id + } + }, + environments: { + connect: { + id: devEnvironment.id + } + } + }, + { + project: { + connect: { + id: projects[1].id + } + }, + environments: { + connect: { id: stageEnvironment.id } + } + } ] } } @@ -647,13 +836,21 @@ 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 } }) - expect(response.statusCode).toBe(200) expect(response.json()).toEqual({ ...adminRole1, @@ -666,14 +863,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' + } + ] } ]) }) @@ -707,6 +918,53 @@ 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) + }) + + it('should not be able to update the workspace role with environments that the user does not have read access to', async () => { + await prisma.environment.create({ + data: { + name: 'dev', + slug: 'dev', + projectId: projects[0].id + } + }) + const response = await app.inject({ + method: 'PUT', + url: `/workspace-role/${adminRole1.slug}`, + payload: { + projectEnvironments: [ + { + projectSlug: projects[0].slug, + environmentSlugs: ['dev'] + } + ] + }, + headers: { + 'x-e2e-user-email': charlie.email + } + }) + + expect(response.statusCode).toBe(401) + }) }) describe('Delete Workspace Role Tests', () => { diff --git a/apps/api/src/workspace/workspace.e2e.spec.ts b/apps/api/src/workspace/workspace.e2e.spec.ts index 30960866..0f0cd1f2 100644 --- a/apps/api/src/workspace/workspace.e2e.spec.ts +++ b/apps/api/src/workspace/workspace.e2e.spec.ts @@ -756,7 +756,7 @@ describe('Workspace Controller Tests', () => { Authority.READ_VARIABLE, Authority.READ_WORKSPACE ], - projectSlugs: [project2Response.slug] + projectEnvironments: [{ projectSlug: project2Response.slug }] }) const project1DevEnv = await prisma.environment.findUnique({ 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..96b59a05 --- /dev/null +++ b/apps/cli/src/commands/workspace/role/create.role.ts @@ -0,0 +1,134 @@ +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 to associate this role.' + } + ] + } + + 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 [workspaceSlug] = args + const { + name, + description, + colorCode, + authorities, + projectSlugs, + environmentSlugs + } = options + + const authoritiesArray = authorities?.split(',') + const projectSlugsArray = projectSlugs?.split(',') + const environmentSlugsArray = environmentSlugs?.split(',') + + if ( + projectSlugsArray && + environmentSlugsArray && + 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.createWorkspaceRole( + { + workspaceSlug, + 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..ab0e57c9 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 }] } 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 }] } const createRoleResponse = ( diff --git a/packages/schema/src/workspace-role/index.ts b/packages/schema/src/workspace-role/index.ts index 09633264..1a29786b 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() + }) + ) + .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) }) })