From 45278a2195f23b737089cf805b663ca40674124c Mon Sep 17 00:00:00 2001 From: Lauris Skraucis Date: Thu, 6 Nov 2025 18:14:55 +0100 Subject: [PATCH] feat: api v2 pbac support (#24402) * feat: Pbac decorator and guard * feat: v2 roles endpoints * fix: test * fix: starting v2 * fix: test error * fix: test api keys * fix fixture * test permission creation * feat: permissions endpoints * refactors * refactor: project structure * test: role permissions crud * test: permissions endpoint negative tests * docs: org, team permissions swagger * unit tests for validator * Update roles.guard.ts * fix type * test: error messages * refactor: dont throw error in pbac * delete redundant test file * feedback: logging error * fix: persist role.permissions when updating role.otherProperty * refactor: use output service to return permissions * refactor: service functions return current permissions * refactor: remove OrganizationsRepository from providers * refactor: try catch possibly duplicate create * refactor: require min length name if provided * refactor: org role has orgId and team role teamId * fix: pbac guard caching * fix: e2e tests in parallel * refactor: use IsTeamInOrg guard for orgs teams roles and permissions endpoints * refactor: use redis service getter and setter * refactor: invalidate team permissions cache when permissions change * refactor: delete keys instead of versioning when caching --- apps/api/v2/src/modules/auth/auth.module.ts | 2 + .../auth/decorators/pbac/pbac.decorator.ts | 5 + .../modules/auth/guards/pbac/pbac.guard.ts | 177 ++ .../modules/auth/guards/roles/roles.guard.ts | 10 +- .../organizations/organizations.module.ts | 4 + .../roles/inputs/base-org-role.input.ts | 34 + .../roles/inputs/create-org-role.input.ts | 11 + .../roles/inputs/update-org-role.input.ts | 12 + ...organizations-roles.controller.e2e-spec.ts | 638 +++++ .../roles/organizations-roles.controller.ts | 145 ++ .../roles/organizations-roles.module.ts | 17 + .../roles/outputs/create-org-role.output.ts | 20 + .../roles/outputs/delete-org-role.output.ts | 20 + .../roles/outputs/get-all-org-roles.output.ts | 20 + .../roles/outputs/get-org-role.output.ts | 20 + .../roles/outputs/org-role.output.ts | 71 + .../roles/outputs/update-org-role.output.ts | 20 + .../create-org-role-permissions.input.ts | 20 + .../delete-org-role-permissions.query.ts | 33 + .../org-permission-string.validator.spec.ts | 71 + .../org-permission-string.validator.ts | 23 + ...s-roles-permissions.controller.e2e-spec.ts | 395 +++ ...anizations-roles-permissions.controller.ts | 126 + .../get-org-role-permissions.output.ts | 14 + .../organizations-roles-output.service.ts | 26 + .../roles/inputs/base-team-role.input.ts | 34 + .../roles/inputs/create-team-role.input.ts | 11 + .../roles/inputs/update-team-role.input.ts | 12 + ...zations-teams-roles.controller.e2e-spec.ts | 469 ++++ .../organizations-teams-roles.controller.ts | 159 ++ .../roles/organizations-teams-roles.module.ts | 18 + .../roles/outputs/create-team-role.output.ts | 20 + .../roles/outputs/delete-team-role.output.ts | 20 + .../outputs/get-all-team-roles.output.ts | 20 + .../roles/outputs/get-team-role.output.ts | 20 + .../teams/roles/outputs/team-role.output.ts | 71 + .../roles/outputs/update-team-role.output.ts | 20 + .../create-team-role-permissions.input.ts | 20 + .../delete-team-role-permissions.query.ts | 33 + .../team-permission-string.validator.spec.ts | 88 + .../team-permission-string.validator.ts | 23 + ...s-roles-permissions.controller.e2e-spec.ts | 287 ++ ...ions-teams-roles-permissions.controller.ts | 144 + .../get-team-role-permissions.output.ts | 14 + .../services/team-roles-output.service.ts | 25 + .../api/v2/src/modules/redis/redis.service.ts | 38 +- .../roles-permissions-cache.service.ts | 28 + .../roles-permissions-output.service.ts | 12 + .../services/roles-permissions.service.ts | 172 ++ apps/api/v2/src/modules/roles/roles.module.ts | 39 + .../modules/roles/services/roles.service.ts | 143 + .../repository/features.repository.fixture.ts | 71 + docs/api-reference/v2/openapi.json | 2320 +++++++++++++++++ packages/features/pbac/domain/models/Role.ts | 2 +- .../pbac/domain/types/permission-registry.ts | 23 + .../features/pbac/services/role.service.ts | 27 +- packages/platform/libraries/package.json | 8 + packages/platform/libraries/pbac.ts | 10 + packages/platform/libraries/vite.config.js | 6 + 59 files changed, 6329 insertions(+), 12 deletions(-) create mode 100644 apps/api/v2/src/modules/auth/decorators/pbac/pbac.decorator.ts create mode 100644 apps/api/v2/src/modules/auth/guards/pbac/pbac.guard.ts create mode 100644 apps/api/v2/src/modules/organizations/roles/inputs/base-org-role.input.ts create mode 100644 apps/api/v2/src/modules/organizations/roles/inputs/create-org-role.input.ts create mode 100644 apps/api/v2/src/modules/organizations/roles/inputs/update-org-role.input.ts create mode 100644 apps/api/v2/src/modules/organizations/roles/organizations-roles.controller.e2e-spec.ts create mode 100644 apps/api/v2/src/modules/organizations/roles/organizations-roles.controller.ts create mode 100644 apps/api/v2/src/modules/organizations/roles/organizations-roles.module.ts create mode 100644 apps/api/v2/src/modules/organizations/roles/outputs/create-org-role.output.ts create mode 100644 apps/api/v2/src/modules/organizations/roles/outputs/delete-org-role.output.ts create mode 100644 apps/api/v2/src/modules/organizations/roles/outputs/get-all-org-roles.output.ts create mode 100644 apps/api/v2/src/modules/organizations/roles/outputs/get-org-role.output.ts create mode 100644 apps/api/v2/src/modules/organizations/roles/outputs/org-role.output.ts create mode 100644 apps/api/v2/src/modules/organizations/roles/outputs/update-org-role.output.ts create mode 100644 apps/api/v2/src/modules/organizations/roles/permissions/inputs/create-org-role-permissions.input.ts create mode 100644 apps/api/v2/src/modules/organizations/roles/permissions/inputs/delete-org-role-permissions.query.ts create mode 100644 apps/api/v2/src/modules/organizations/roles/permissions/inputs/validators/org-permission-string.validator.spec.ts create mode 100644 apps/api/v2/src/modules/organizations/roles/permissions/inputs/validators/org-permission-string.validator.ts create mode 100644 apps/api/v2/src/modules/organizations/roles/permissions/organizations-roles-permissions.controller.e2e-spec.ts create mode 100644 apps/api/v2/src/modules/organizations/roles/permissions/organizations-roles-permissions.controller.ts create mode 100644 apps/api/v2/src/modules/organizations/roles/permissions/outputs/get-org-role-permissions.output.ts create mode 100644 apps/api/v2/src/modules/organizations/roles/services/organizations-roles-output.service.ts create mode 100644 apps/api/v2/src/modules/organizations/teams/roles/inputs/base-team-role.input.ts create mode 100644 apps/api/v2/src/modules/organizations/teams/roles/inputs/create-team-role.input.ts create mode 100644 apps/api/v2/src/modules/organizations/teams/roles/inputs/update-team-role.input.ts create mode 100644 apps/api/v2/src/modules/organizations/teams/roles/organizations-teams-roles.controller.e2e-spec.ts create mode 100644 apps/api/v2/src/modules/organizations/teams/roles/organizations-teams-roles.controller.ts create mode 100644 apps/api/v2/src/modules/organizations/teams/roles/organizations-teams-roles.module.ts create mode 100644 apps/api/v2/src/modules/organizations/teams/roles/outputs/create-team-role.output.ts create mode 100644 apps/api/v2/src/modules/organizations/teams/roles/outputs/delete-team-role.output.ts create mode 100644 apps/api/v2/src/modules/organizations/teams/roles/outputs/get-all-team-roles.output.ts create mode 100644 apps/api/v2/src/modules/organizations/teams/roles/outputs/get-team-role.output.ts create mode 100644 apps/api/v2/src/modules/organizations/teams/roles/outputs/team-role.output.ts create mode 100644 apps/api/v2/src/modules/organizations/teams/roles/outputs/update-team-role.output.ts create mode 100644 apps/api/v2/src/modules/organizations/teams/roles/permissions/inputs/create-team-role-permissions.input.ts create mode 100644 apps/api/v2/src/modules/organizations/teams/roles/permissions/inputs/delete-team-role-permissions.query.ts create mode 100644 apps/api/v2/src/modules/organizations/teams/roles/permissions/inputs/validators/team-permission-string.validator.spec.ts create mode 100644 apps/api/v2/src/modules/organizations/teams/roles/permissions/inputs/validators/team-permission-string.validator.ts create mode 100644 apps/api/v2/src/modules/organizations/teams/roles/permissions/organizations-teams-roles-permissions.controller.e2e-spec.ts create mode 100644 apps/api/v2/src/modules/organizations/teams/roles/permissions/organizations-teams-roles-permissions.controller.ts create mode 100644 apps/api/v2/src/modules/organizations/teams/roles/permissions/outputs/get-team-role-permissions.output.ts create mode 100644 apps/api/v2/src/modules/organizations/teams/roles/services/team-roles-output.service.ts create mode 100644 apps/api/v2/src/modules/roles/permissions/services/roles-permissions-cache.service.ts create mode 100644 apps/api/v2/src/modules/roles/permissions/services/roles-permissions-output.service.ts create mode 100644 apps/api/v2/src/modules/roles/permissions/services/roles-permissions.service.ts create mode 100644 apps/api/v2/src/modules/roles/roles.module.ts create mode 100644 apps/api/v2/src/modules/roles/services/roles.service.ts create mode 100644 apps/api/v2/test/fixtures/repository/features.repository.fixture.ts create mode 100644 packages/platform/libraries/pbac.ts diff --git a/apps/api/v2/src/modules/auth/auth.module.ts b/apps/api/v2/src/modules/auth/auth.module.ts index 649565f6aa..e3d427cc6f 100644 --- a/apps/api/v2/src/modules/auth/auth.module.ts +++ b/apps/api/v2/src/modules/auth/auth.module.ts @@ -7,6 +7,7 @@ import { DeploymentsModule } from "@/modules/deployments/deployments.module"; import { MembershipsModule } from "@/modules/memberships/memberships.module"; import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; import { RedisModule } from "@/modules/redis/redis.module"; +import { RolesModule } from "@/modules/roles/roles.module"; import { TokensModule } from "@/modules/tokens/tokens.module"; import { UsersModule } from "@/modules/users/users.module"; import { Module } from "@nestjs/common"; @@ -21,6 +22,7 @@ import { PassportModule } from "@nestjs/passport"; MembershipsModule, TokensModule, DeploymentsModule, + RolesModule, ], providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService], exports: [NextAuthGuard, ApiAuthGuard], diff --git a/apps/api/v2/src/modules/auth/decorators/pbac/pbac.decorator.ts b/apps/api/v2/src/modules/auth/decorators/pbac/pbac.decorator.ts new file mode 100644 index 0000000000..349af67eb8 --- /dev/null +++ b/apps/api/v2/src/modules/auth/decorators/pbac/pbac.decorator.ts @@ -0,0 +1,5 @@ +import { Reflector } from "@nestjs/core"; + +import type { PermissionString } from "@calcom/platform-libraries/pbac"; + +export const Pbac = Reflector.createDecorator(); diff --git a/apps/api/v2/src/modules/auth/guards/pbac/pbac.guard.ts b/apps/api/v2/src/modules/auth/guards/pbac/pbac.guard.ts new file mode 100644 index 0000000000..6845c504a1 --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/pbac/pbac.guard.ts @@ -0,0 +1,177 @@ +import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator"; +import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { RedisService } from "@/modules/redis/redis.service"; +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, + UnauthorizedException, + BadRequestException, +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { Request } from "express"; + +import type { PermissionString } from "@calcom/platform-libraries/pbac"; +import { PermissionCheckService, FeaturesRepository } from "@calcom/platform-libraries/pbac"; + +export const REDIS_PBAC_CACHE_KEY = (teamId: number) => `apiv2:team:${teamId}:has:pbac:guard:pbac`; +export const REDIS_REQUIRED_PERMISSIONS_CACHE_KEY = ( + userId: number, + teamId: number, + requiredPermissions: PermissionString[] +) => + `apiv2:user:${userId}:team:${teamId}:requiredPermissions:${requiredPermissions + .sort() + .join(",")}:guard:pbac`; + +@Injectable() +export class PbacGuard implements CanActivate { + constructor( + private reflector: Reflector, + private prismaReadService: PrismaReadService, + private readonly redisService: RedisService + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const user = request.user as ApiAuthGuardUser; + const teamId = request.params.teamId; + const orgId = request.params.orgId; + const requiredPermissions = this.reflector.get(Pbac, context.getHandler()); + + const effectiveTeamId = teamId || orgId; + if (!user) { + throw new UnauthorizedException("PbacGuard - the request does not have an authorized user provided"); + } + if (!effectiveTeamId) { + throw new BadRequestException( + "PbacGuard - can't check pbac because no teamId or orgId provided within the request url" + ); + } + + if (!requiredPermissions || requiredPermissions.length === 0) { + request.pbacAuthorizedRequest = false; + return true; + } + + const hasPbacEnabled = await this.hasPbacEnabled(Number(effectiveTeamId)); + if (!hasPbacEnabled) { + request.pbacAuthorizedRequest = false; + return true; + } + + const hasRequiredPermissions = await this.checkUserHasRequiredPermissions( + user.id, + Number(effectiveTeamId), + requiredPermissions + ); + + if (!hasRequiredPermissions) { + request.pbacAuthorizedRequest = false; + return true; + } + + request.pbacAuthorizedRequest = true; + return true; + } + + async hasPbacEnabled(teamId: number) { + const cachedHasPbacEnabled = await this.getCachePbacEnabled(teamId); + + if (cachedHasPbacEnabled) { + return cachedHasPbacEnabled; + } + + const pbacFeatureFlag = "pbac"; + const featuresRepository = new FeaturesRepository(this.prismaReadService.prisma); + const hasPbacEnabled = await featuresRepository.checkIfTeamHasFeature(teamId, pbacFeatureFlag); + + if (hasPbacEnabled) { + await this.setCachePbacEnabled(teamId, hasPbacEnabled); + } + + return hasPbacEnabled; + } + + async checkUserHasRequiredPermissions( + userId: number, + teamId: number, + requiredPermissions: PermissionString[] + ) { + const cachedAccess = await this.getCacheRequiredPermissions(userId, teamId, requiredPermissions); + + if (cachedAccess) { + return cachedAccess; + } + + const permissionCheckService = new PermissionCheckService(); + const hasRequiredPermissions = await permissionCheckService.checkPermissions({ + userId, + teamId, + permissions: requiredPermissions, + fallbackRoles: [], + }); + + if (hasRequiredPermissions) { + await this.setCacheRequiredPermissions(userId, teamId, requiredPermissions, hasRequiredPermissions); + } + + return hasRequiredPermissions; + } + + private async getCacheRequiredPermissions( + userId: number, + teamId: number, + requiredPermissions: PermissionString[] + ): Promise { + return this.redisService.get( + REDIS_REQUIRED_PERMISSIONS_CACHE_KEY(userId, teamId, requiredPermissions) + ); + } + + private async setCacheRequiredPermissions( + userId: number, + teamId: number, + requiredPermissions: PermissionString[], + hasRequired: boolean + ): Promise { + await this.redisService.set( + REDIS_REQUIRED_PERMISSIONS_CACHE_KEY(userId, teamId, requiredPermissions), + hasRequired, + { ttl: 300_000 } + ); + } + + private async getCachePbacEnabled(teamId: number) { + const cachedResult = await this.redisService.get(REDIS_PBAC_CACHE_KEY(teamId)); + return cachedResult; + } + + private async setCachePbacEnabled(teamId: number, pbacEnabled: boolean) { + await this.redisService.set(REDIS_PBAC_CACHE_KEY(teamId), pbacEnabled, { + ttl: 300_000, + }); + } + + throwForbiddenError( + userId: number, + teamId: string, + orgId: string, + requiredPermissions: PermissionString[] + ) { + let errorMessage = `PbacGuard - user with id=${userId} does not have the minimum required permissions=${requiredPermissions.join( + "," + )} `; + if (teamId) { + errorMessage += `within team with id=${teamId}`; + } + if (orgId) { + errorMessage += `within organization with id=${orgId}`; + } + errorMessage += `.`; + + throw new ForbiddenException(errorMessage); + } +} diff --git a/apps/api/v2/src/modules/auth/guards/roles/roles.guard.ts b/apps/api/v2/src/modules/auth/guards/roles/roles.guard.ts index cd56544d4a..0109f36358 100644 --- a/apps/api/v2/src/modules/auth/guards/roles/roles.guard.ts +++ b/apps/api/v2/src/modules/auth/guards/roles/roles.guard.ts @@ -7,8 +7,6 @@ import { Injectable, CanActivate, ExecutionContext, ForbiddenException, Logger } import { Reflector } from "@nestjs/core"; import { Request } from "express"; -import type { Team } from "@calcom/prisma/client"; - @Injectable() export class RolesGuard implements CanActivate { private readonly logger = new Logger("RolesGuard Logger"); @@ -19,7 +17,13 @@ export class RolesGuard implements CanActivate { ) {} async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); + const request = context.switchToHttp().getRequest(); + + if (request.pbacAuthorizedRequest === true) { + this.logger.debug("PBAC authorized request, skipping legacy role checking"); + return true; + } + const teamId = request.params.teamId as string; const orgId = request.params.orgId as string; const user = request.user as ApiAuthGuardUser; diff --git a/apps/api/v2/src/modules/organizations/organizations.module.ts b/apps/api/v2/src/modules/organizations/organizations.module.ts index 4c725b7f41..58b5347796 100644 --- a/apps/api/v2/src/modules/organizations/organizations.module.ts +++ b/apps/api/v2/src/modules/organizations/organizations.module.ts @@ -39,6 +39,7 @@ import { OrganizationsMembershipRepository } from "@/modules/organizations/membe import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service"; import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service"; import { OrganizationsOrganizationsModule } from "@/modules/organizations/organizations/organizations-organizations.module"; +import { OrganizationsRolesModule } from "@/modules/organizations/roles/organizations-roles.module"; import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller"; import { OrganizationsSchedulesService } from "@/modules/organizations/schedules/services/organizations-schedules.service"; import { OrganizationsStripeModule } from "@/modules/organizations/stripe/organizations-stripe.module"; @@ -50,6 +51,7 @@ import { OrganizationsTeamsInviteController } from "@/modules/organizations/team import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller"; import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository"; import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service"; +import { OrganizationsTeamsRolesModule } from "@/modules/organizations/teams/roles/organizations-teams-roles.module"; import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module"; import { OrganizationsTeamsSchedulesController } from "@/modules/organizations/teams/schedules/organizations-teams-schedules.controller"; import { OrganizationTeamWorkflowsController } from "@/modules/organizations/teams/workflows/controllers/org-team-workflows.controller"; @@ -96,6 +98,8 @@ import { Module } from "@nestjs/common"; TeamsModule, OrganizationsDelegationCredentialModule, OrganizationsOrganizationsModule, + OrganizationsRolesModule, + OrganizationsTeamsRolesModule, OrganizationsStripeModule, OrganizationsTeamsRoutingFormsModule, MembershipsModule, diff --git a/apps/api/v2/src/modules/organizations/roles/inputs/base-org-role.input.ts b/apps/api/v2/src/modules/organizations/roles/inputs/base-org-role.input.ts new file mode 100644 index 0000000000..aee4f5cb3f --- /dev/null +++ b/apps/api/v2/src/modules/organizations/roles/inputs/base-org-role.input.ts @@ -0,0 +1,34 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsArray, IsOptional, IsString, Validate } from "class-validator"; + +import type { PermissionString } from "@calcom/platform-libraries/pbac"; +import { getAllPermissionStringsForScope, Scope } from "@calcom/platform-libraries/pbac"; + +import { OrgPermissionStringValidator } from "../permissions/inputs/validators/org-permission-string.validator"; + +export const orgPermissionEnum = [...getAllPermissionStringsForScope(Scope.Organization)] as const; + +export class BaseOrgRoleInput { + @ApiPropertyOptional({ description: "Color for the role (hex code)" }) + @IsString() + @IsOptional() + color?: string; + + @ApiPropertyOptional({ description: "Description of the role" }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional({ + description: + "Permissions for this role (format: resource.action). On update, this field replaces the entire permission set for the role (full replace). Use granular permission endpoints for one-by-one changes.", + enum: orgPermissionEnum, + isArray: true, + example: ["eventType.read", "eventType.create", "booking.read"], + }) + @IsArray() + @IsString({ each: true }) + @Validate(OrgPermissionStringValidator, { each: true }) + @IsOptional() + permissions?: PermissionString[]; +} diff --git a/apps/api/v2/src/modules/organizations/roles/inputs/create-org-role.input.ts b/apps/api/v2/src/modules/organizations/roles/inputs/create-org-role.input.ts new file mode 100644 index 0000000000..75b8397a93 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/roles/inputs/create-org-role.input.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, MinLength } from "class-validator"; + +import { BaseOrgRoleInput } from "./base-org-role.input"; + +export class CreateOrgRoleInput extends BaseOrgRoleInput { + @ApiProperty({ description: "Name of the role", minLength: 1 }) + @IsString() + @MinLength(1) + name!: string; +} diff --git a/apps/api/v2/src/modules/organizations/roles/inputs/update-org-role.input.ts b/apps/api/v2/src/modules/organizations/roles/inputs/update-org-role.input.ts new file mode 100644 index 0000000000..3d9840187a --- /dev/null +++ b/apps/api/v2/src/modules/organizations/roles/inputs/update-org-role.input.ts @@ -0,0 +1,12 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsString, IsOptional, MinLength } from "class-validator"; + +import { BaseOrgRoleInput } from "./base-org-role.input"; + +export class UpdateOrgRoleInput extends BaseOrgRoleInput { + @ApiPropertyOptional({ description: "Name of the role", minLength: 1 }) + @IsString() + @MinLength(1) + @IsOptional() + name?: string; +} diff --git a/apps/api/v2/src/modules/organizations/roles/organizations-roles.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/roles/organizations-roles.controller.e2e-spec.ts new file mode 100644 index 0000000000..88ef73256a --- /dev/null +++ b/apps/api/v2/src/modules/organizations/roles/organizations-roles.controller.e2e-spec.ts @@ -0,0 +1,638 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { CreateOrgRoleInput } from "@/modules/organizations/roles/inputs/create-org-role.input"; +import { UpdateOrgRoleInput } from "@/modules/organizations/roles/inputs/update-org-role.input"; +import { CreateOrgRoleOutput } from "@/modules/organizations/roles/outputs/create-org-role.output"; +import { DeleteOrgRoleOutput } from "@/modules/organizations/roles/outputs/delete-org-role.output"; +import { GetAllOrgRolesOutput } from "@/modules/organizations/roles/outputs/get-all-org-roles.output"; +import { GetOrgRoleOutput } from "@/modules/organizations/roles/outputs/get-org-role.output"; +import { UpdateOrgRoleOutput } from "@/modules/organizations/roles/outputs/update-org-role.output"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import * as request from "supertest"; +import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture"; +import { FeaturesRepositoryFixture } from "test/fixtures/repository/features.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { RoleService } from "@calcom/platform-libraries/pbac"; +import type { User, Team } from "@calcom/prisma/client"; +import { MembershipRole } from "@calcom/prisma/enums"; + +describe("Organizations Roles Endpoints", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let membershipRepositoryFixture: MembershipRepositoryFixture; + let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; + let featuresRepositoryFixture: FeaturesRepositoryFixture; + let roleService: RoleService; + + // Test users + let legacyOrgAdminUser: User; + let legacyOrgMemberUser: User; + let pbacOrgUserWithRolePermission: User; + let pbacOrgUserWithoutRolePermission: User; + let nonOrgUser: User; + + // API Keys + let legacyOrgAdminApiKey: string; + let legacyOrgMemberApiKey: string; + let pbacOrgUserWithRolePermissionApiKey: string; + let pbacOrgUserWithoutRolePermissionApiKey: string; + let nonOrgUserApiKey: string; + + // Organization + let organization: Team; + let pbacEnabledOrganization: Team; + + const legacyOrgAdminEmail = `legacy-org-admin-${randomString()}@api.com`; + const legacyOrgMemberEmail = `legacy-org-member-${randomString()}@api.com`; + const pbacOrgUserWithRolePermissionEmail = `pbac-org-user-with-role-permission-${randomString()}@api.com`; + const pbacOrgUserWithoutRolePermissionEmail = `pbac-org-user-without-role-permission-${randomString()}@api.com`; + const nonOrgUserEmail = `non-org-user-${randomString()}@api.com`; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef); + featuresRepositoryFixture = new FeaturesRepositoryFixture(moduleRef); + roleService = new RoleService(); + + // Create test users + legacyOrgAdminUser = await userRepositoryFixture.create({ + email: legacyOrgAdminEmail, + username: legacyOrgAdminEmail, + }); + + legacyOrgMemberUser = await userRepositoryFixture.create({ + email: legacyOrgMemberEmail, + username: legacyOrgMemberEmail, + }); + + pbacOrgUserWithRolePermission = await userRepositoryFixture.create({ + email: pbacOrgUserWithRolePermissionEmail, + username: pbacOrgUserWithRolePermissionEmail, + }); + + pbacOrgUserWithoutRolePermission = await userRepositoryFixture.create({ + email: pbacOrgUserWithoutRolePermissionEmail, + username: pbacOrgUserWithoutRolePermissionEmail, + }); + + nonOrgUser = await userRepositoryFixture.create({ + email: nonOrgUserEmail, + username: nonOrgUserEmail, + }); + + // Create organizations + organization = await organizationsRepositoryFixture.create({ + name: `org-roles-test-${randomString()}`, + isOrganization: true, + }); + + pbacEnabledOrganization = await organizationsRepositoryFixture.create({ + name: `pbac-org-roles-test-${randomString()}`, + isOrganization: true, + }); + + await featuresRepositoryFixture.create({ slug: "pbac", enabled: true }); + await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledOrganization.id, "pbac"); + + // Create memberships + await membershipRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: legacyOrgAdminUser.id } }, + team: { connect: { id: organization.id } }, + }); + + await membershipRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: legacyOrgMemberUser.id } }, + team: { connect: { id: organization.id } }, + }); + + const pbacOrgUserWithRolePermissionMembership = await membershipRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: pbacOrgUserWithRolePermission.id } }, + team: { connect: { id: pbacEnabledOrganization.id } }, + }); + + const pbacOrgUserWithoutRolePermissionMembership = await membershipRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: pbacOrgUserWithoutRolePermission.id } }, + team: { connect: { id: pbacEnabledOrganization.id } }, + }); + + const roleWithPermission = await roleService.createRole({ + name: "Role Manager", + teamId: pbacEnabledOrganization.id, + permissions: ["role.create", "role.read", "role.update", "role.delete"], + type: "CUSTOM", + }); + + const roleWithoutPermission = await roleService.createRole({ + name: "Basic Role", + teamId: pbacEnabledOrganization.id, + permissions: ["booking.read"], + type: "CUSTOM", + }); + + await roleService.assignRoleToMember(roleWithPermission.id, pbacOrgUserWithRolePermissionMembership.id); + await roleService.assignRoleToMember( + roleWithoutPermission.id, + pbacOrgUserWithoutRolePermissionMembership.id + ); + + const { keyString: legacyOrgAdminKeyString } = await apiKeysRepositoryFixture.createApiKey( + legacyOrgAdminUser.id, + null + ); + legacyOrgAdminApiKey = `cal_test_${legacyOrgAdminKeyString}`; + + const { keyString: legacyOrgMemberKeyString } = await apiKeysRepositoryFixture.createApiKey( + legacyOrgMemberUser.id, + null + ); + legacyOrgMemberApiKey = `cal_test_${legacyOrgMemberKeyString}`; + + const { keyString: pbacOrgUserWithRolePermissionKeyString } = await apiKeysRepositoryFixture.createApiKey( + pbacOrgUserWithRolePermission.id, + null + ); + pbacOrgUserWithRolePermissionApiKey = `cal_test_${pbacOrgUserWithRolePermissionKeyString}`; + + const { keyString: pbacOrgUserWithoutRolePermissionKeyString } = + await apiKeysRepositoryFixture.createApiKey(pbacOrgUserWithoutRolePermission.id, null); + pbacOrgUserWithoutRolePermissionApiKey = `cal_test_${pbacOrgUserWithoutRolePermissionKeyString}`; + + const { keyString: nonOrgUserKeyString } = await apiKeysRepositoryFixture.createApiKey( + nonOrgUser.id, + null + ); + nonOrgUserApiKey = `cal_test_${nonOrgUserKeyString}`; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + }); + + describe("Role Creation Authorization", () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + + describe("Positive Tests", () => { + it("should allow role creation when organization has PBAC enabled and user has a create permission", async () => { + const createRoleInput: CreateOrgRoleInput = { + name: "Test Role PBAC", + permissions: ["booking.read", "eventType.create"], + }; + + const pbacSpyCanActivate = jest.spyOn(PbacGuard.prototype, "canActivate"); + const pbacSpyHasPbacEnabled = jest.spyOn( + PbacGuard.prototype as unknown as PbacGuard, + "hasPbacEnabled" + ); + + const rolesSpyCanActivate = jest.spyOn(RolesGuard.prototype, "canActivate"); + const rolesSpyCheckUserRoleAccess = jest.spyOn( + RolesGuard.prototype as unknown as RolesGuard, + "checkUserRoleAccess" + ); + + return request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(createRoleInput) + .expect(201) + .then(async (response) => { + const responseBody: CreateOrgRoleOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.name).toEqual(createRoleInput.name); + expect(responseBody.data.permissions).toEqual(createRoleInput.permissions); + expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id); + + expect(pbacSpyCanActivate).toHaveBeenCalled(); + expect(pbacSpyHasPbacEnabled).toHaveBeenCalled(); + expect(rolesSpyCanActivate).toHaveBeenCalled(); + const pbacCanActivateResult = await pbacSpyCanActivate.mock.results[0].value; + const rolesCanActivateResult = await rolesSpyCanActivate.mock.results[0].value; + const hasPbac = await pbacSpyHasPbacEnabled.mock.results[0].value; + expect(pbacCanActivateResult).toBe(true); + expect(rolesCanActivateResult).toBe(true); + expect(hasPbac).toBe(true); + expect(rolesSpyCheckUserRoleAccess).not.toHaveBeenCalled(); + }); + }); + + it("should allow role creation when organization does not have PBAC enabled and user is org admin", async () => { + const createRoleInput: CreateOrgRoleInput = { + name: "Test Role Legacy Admin", + permissions: ["booking.read"], + }; + + const pbacSpyCanActivate = jest.spyOn(PbacGuard.prototype, "canActivate"); + const pbacSpyHasPbacEnabled = jest.spyOn( + PbacGuard.prototype as unknown as PbacGuard, + "hasPbacEnabled" + ); + const rolesSpyCanActivate = jest.spyOn(RolesGuard.prototype, "canActivate"); + const rolesSpyCheckUserRoleAccess = jest.spyOn( + RolesGuard.prototype as unknown as RolesGuard, + "checkUserRoleAccess" + ); + + return request(app.getHttpServer()) + .post(`/v2/organizations/${organization.id}/roles`) + .set("Authorization", `Bearer ${legacyOrgAdminApiKey}`) + .send(createRoleInput) + .expect(201) + .then(async (response) => { + const responseBody: CreateOrgRoleOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.name).toEqual(createRoleInput.name); + expect(responseBody.data.organizationId).toEqual(organization.id); + + expect(pbacSpyCanActivate).toHaveBeenCalled(); + expect(rolesSpyCanActivate).toHaveBeenCalled(); + expect(pbacSpyHasPbacEnabled).toHaveBeenCalled(); + const pbacCanActivateResult = await pbacSpyCanActivate.mock.results[0].value; + const rolesCanActivateResult = await rolesSpyCanActivate.mock.results[0].value; + const hasPbac = await pbacSpyHasPbacEnabled.mock.results[0].value; + expect(pbacCanActivateResult).toBe(true); + expect(rolesCanActivateResult).toBe(true); + expect(hasPbac).toBe(false); + expect(rolesSpyCheckUserRoleAccess).toHaveBeenCalled(); + }); + }); + }); + + describe("Negative Tests", () => { + it("should not allow role creation when organization has PBAC enabled but user has no role assigned", async () => { + const createRoleInput: CreateOrgRoleInput = { + name: "Test Role No Role", + permissions: ["booking.read"], + }; + + const userWithNoRole = await userRepositoryFixture.create({ + email: `no-role-user-${randomString()}@api.com`, + username: `no-role-user-${randomString()}@api.com`, + }); + + await membershipRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: userWithNoRole.id } }, + team: { connect: { id: pbacEnabledOrganization.id } }, + }); + + const { keyString: noRoleKeyString } = await apiKeysRepositoryFixture.createApiKey( + userWithNoRole.id, + null + ); + const noRoleApiKey = `cal_test_${noRoleKeyString}`; + + const response = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`) + .set("Authorization", `Bearer ${noRoleApiKey}`) + .send(createRoleInput); + expect(response.status).toBe(403); + expect(response.body.error.message).toBe( + `RolesGuard - user with id=${userWithNoRole.id} does not have the minimum required role=ORG_ADMIN within organization with id=${pbacEnabledOrganization.id}.` + ); + }); + + it("should not allow role creation when organization has PBAC enabled but user role lacks required permission", async () => { + const createRoleInput: CreateOrgRoleInput = { + name: "Test Role No Permission", + permissions: ["booking.read"], + }; + + const response = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithoutRolePermissionApiKey}`) + .send(createRoleInput); + expect(response.status).toBe(403); + expect(response.body.error.message).toBe( + `RolesGuard - user with id=${pbacOrgUserWithoutRolePermission.id} does not have the minimum required role=ORG_ADMIN within organization with id=${pbacEnabledOrganization.id}.` + ); + }); + + it("should not allow role creation when organization does not have PBAC enabled and user has no membership", async () => { + const createRoleInput: CreateOrgRoleInput = { + name: "Test Role No Membership", + permissions: ["booking.read"], + }; + + const response = await request(app.getHttpServer()) + .post(`/v2/organizations/${organization.id}/roles`) + .set("Authorization", `Bearer ${nonOrgUserApiKey}`) + .send(createRoleInput); + expect(response.status).toBe(403); + expect(response.body.error.message).toBe( + `RolesGuard - User is not a member of the organization with id=${organization.id}.` + ); + }); + + it("should not allow role creation when organization does not have PBAC enabled and user has member membership (not admin)", async () => { + const createRoleInput: CreateOrgRoleInput = { + name: "Test Role Member Only", + permissions: ["booking.read"], + }; + + const response = await request(app.getHttpServer()) + .post(`/v2/organizations/${organization.id}/roles`) + .set("Authorization", `Bearer ${legacyOrgMemberApiKey}`) + .send(createRoleInput); + expect(response.status).toBe(403); + expect(response.body.error.message).toBe( + `RolesGuard - user with id=${legacyOrgMemberUser.id} does not have the minimum required role=ORG_ADMIN within organization with id=${organization.id}.` + ); + }); + }); + + describe("CRUD Role Endpoints", () => { + let createdRoleId: string; + + it("should create a role", async () => { + const createRoleInput: CreateOrgRoleInput = { + name: "CRUD Test Role", + permissions: ["booking.read", "eventType.create"], + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(createRoleInput) + .expect(201) + .then((response) => { + const responseBody: CreateOrgRoleOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.name).toEqual(createRoleInput.name); + expect(responseBody.data.permissions).toEqual(createRoleInput.permissions); + expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id); + createdRoleId = responseBody.data.id; + }); + }); + + it("should update role permissions and name", async () => { + const updateRoleInput: UpdateOrgRoleInput = { + name: "CRUD Test Role Updated", + permissions: ["booking.read", "eventType.read"], + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${createdRoleId}`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(updateRoleInput) + .expect(200) + .then((response) => { + const responseBody: UpdateOrgRoleOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.id).toEqual(createdRoleId); + expect(responseBody.data.name).toEqual(updateRoleInput.name); + expect(responseBody.data.permissions).toEqual(updateRoleInput.permissions); + expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id); + }); + }); + + it("should fetch the role", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${createdRoleId}`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .expect(200) + .then((response) => { + const responseBody: GetOrgRoleOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.id).toEqual(createdRoleId); + expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id); + }); + }); + + it("should fetch all roles", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${pbacEnabledOrganization.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .expect(200) + .then((response) => { + const responseBody: GetAllOrgRolesOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(Array.isArray(responseBody.data)).toBe(true); + expect(responseBody.data.find((r) => r.id === createdRoleId)).toBeDefined(); + const created = responseBody.data.find((r) => r.id === createdRoleId); + expect(created?.organizationId).toEqual(pbacEnabledOrganization.id); + }); + }); + + it("should delete the role", async () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${createdRoleId}`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .expect(200) + .then((response) => { + const responseBody: DeleteOrgRoleOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.id).toEqual(createdRoleId); + expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id); + }); + }); + + describe("Negative error cases", () => { + it("should fail to create a role with a duplicate name (400)", async () => { + const name = `dup-role-${randomString()}`; + + const firstCreate: CreateOrgRoleInput = { name, permissions: ["booking.read"] }; + await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(firstCreate) + .expect(201); + + const secondCreate: CreateOrgRoleInput = { name, permissions: ["booking.read"] }; + const response = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(secondCreate); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe(`Role with name "${name}" already exists`); + }); + + it("should fail to create a role with invalid permissions (400)", async () => { + const createRoleInput = { + name: `invalid-perms-${randomString()}`, + permissions: ["invalid"], + }; + + const response = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(createRoleInput); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe( + "Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')" + ); + }); + + it("should return 404 when updating a role not belonging to the organization", async () => { + const defaultAdminRoleId = await roleService.getDefaultRoleId(MembershipRole.ADMIN); + const updateRoleInput: UpdateOrgRoleInput = { + name: `no-update-default-${randomString()}`, + permissions: ["booking.read"], + }; + + const response = await request(app.getHttpServer()) + .patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultAdminRoleId}`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(updateRoleInput); + expect(response.status).toBe(404); + expect(response.body.error.message).toBe( + `Role with id ${defaultAdminRoleId} within team id ${pbacEnabledOrganization.id} not found` + ); + }); + + it("should return 404 when updating a default (system) role not belonging to the organization", async () => { + const defaultAdminRoleId = await roleService.getDefaultRoleId(MembershipRole.ADMIN); + const updateRoleInput: UpdateOrgRoleInput = { + name: `no-update-default-${randomString()}`, + permissions: ["booking.read"], + }; + + const response = await request(app.getHttpServer()) + .patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultAdminRoleId}`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(updateRoleInput); + expect(response.status).toBe(404); + expect(response.body.error.message).toBe( + `Role with id ${defaultAdminRoleId} within team id ${pbacEnabledOrganization.id} not found` + ); + }); + + it("should fail to update with invalid permissions (400)", async () => { + // Create a fresh role to update + const { body: createRes } = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send({ name: `update-invalid-${randomString()}` as string, permissions: ["booking.read"] }) + .expect(201); + const roleId: string = createRes.data.id; + + const updateRoleInput = { + name: `update-invalid-${randomString()}`, + permissions: ["invalid"], + }; + + const response = await request(app.getHttpServer()) + .patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(updateRoleInput); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe( + "Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')" + ); + }); + + it("should return 404 when updating a role from a different organization", async () => { + // Create a role in a different org (legacy/non-PBAC org) + const foreignRole = await roleService.createRole({ + name: `foreign-update-${randomString()}`, + teamId: organization.id, + permissions: ["booking.read"], + type: "CUSTOM", + }); + + const response = await request(app.getHttpServer()) + .patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${foreignRole.id}`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send({ permissions: ["eventType.read"] }); + expect(response.status).toBe(404); + expect(response.body.error.message).toBe( + `Role with id ${foreignRole.id} within team id ${pbacEnabledOrganization.id} not found` + ); + }); + + it("should return 404 when deleting a role not belonging to the organization", async () => { + const defaultMemberRoleId = await roleService.getDefaultRoleId(MembershipRole.MEMBER); + const response = await request(app.getHttpServer()) + .delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultMemberRoleId}`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`); + expect(response.status).toBe(404); + expect(response.body.error.message).toBe( + `Role with id ${defaultMemberRoleId} within team id ${pbacEnabledOrganization.id} not found` + ); + }); + + it("should return 404 when deleting a default (system) role not belonging to the organization", async () => { + const defaultMemberRoleId = await roleService.getDefaultRoleId(MembershipRole.MEMBER); + const response = await request(app.getHttpServer()) + .delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultMemberRoleId}`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`); + expect(response.status).toBe(404); + expect(response.body.error.message).toBe( + `Role with id ${defaultMemberRoleId} within team id ${pbacEnabledOrganization.id} not found` + ); + }); + + it("should return 404 when deleting a role from a different organization", async () => { + // Create a role in a different org + const foreignRole = await roleService.createRole({ + name: `foreign-delete-${randomString()}`, + teamId: organization.id, + permissions: ["booking.read"], + type: "CUSTOM", + }); + + const response = await request(app.getHttpServer()) + .delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${foreignRole.id}`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`); + expect(response.status).toBe(404); + expect(response.body.error.message).toBe( + `Role with id ${foreignRole.id} within team id ${pbacEnabledOrganization.id} not found` + ); + }); + }); + }); + }); + + afterAll(async () => { + try { + await featuresRepositoryFixture.deleteTeamFeature(pbacEnabledOrganization.id, "pbac"); + + await organizationsRepositoryFixture.delete(organization.id); + await organizationsRepositoryFixture.delete(pbacEnabledOrganization.id); + + await userRepositoryFixture.deleteByEmail(legacyOrgAdminUser.email); + await userRepositoryFixture.deleteByEmail(legacyOrgMemberUser.email); + await userRepositoryFixture.deleteByEmail(pbacOrgUserWithRolePermission.email); + await userRepositoryFixture.deleteByEmail(pbacOrgUserWithoutRolePermission.email); + await userRepositoryFixture.deleteByEmail(nonOrgUser.email); + + try { + await featuresRepositoryFixture.deleteBySlug("pbac"); + } catch (error) { + console.error("Cleanup error:", error); + } + } catch (error) { + console.error("Cleanup error:", error); + } finally { + await app.close(); + } + }); +}); diff --git a/apps/api/v2/src/modules/organizations/roles/organizations-roles.controller.ts b/apps/api/v2/src/modules/organizations/roles/organizations-roles.controller.ts new file mode 100644 index 0000000000..8a95eab6a1 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/roles/organizations-roles.controller.ts @@ -0,0 +1,145 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { + OPTIONAL_API_KEY_HEADER, + OPTIONAL_X_CAL_CLIENT_ID_HEADER, + OPTIONAL_X_CAL_SECRET_KEY_HEADER, +} from "@/lib/docs/headers"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { CreateOrgRoleInput } from "@/modules/organizations/roles/inputs/create-org-role.input"; +import { UpdateOrgRoleInput } from "@/modules/organizations/roles/inputs/update-org-role.input"; +import { CreateOrgRoleOutput } from "@/modules/organizations/roles/outputs/create-org-role.output"; +import { DeleteOrgRoleOutput } from "@/modules/organizations/roles/outputs/delete-org-role.output"; +import { GetAllOrgRolesOutput } from "@/modules/organizations/roles/outputs/get-all-org-roles.output"; +import { GetOrgRoleOutput } from "@/modules/organizations/roles/outputs/get-org-role.output"; +import { UpdateOrgRoleOutput } from "@/modules/organizations/roles/outputs/update-org-role.output"; +import { OrganizationsRolesOutputService } from "@/modules/organizations/roles/services/organizations-roles-output.service"; +import { RolesService } from "@/modules/roles/services/roles.service"; +import { + Controller, + UseGuards, + Get, + Param, + ParseIntPipe, + Query, + Delete, + Patch, + Post, + Body, + HttpCode, + HttpStatus, +} from "@nestjs/common"; +import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { SkipTakePagination } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/organizations/:orgId/roles", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, IsOrgGuard, PbacGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) +@DocsTags("Orgs / Roles") +@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER) +@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER) +@ApiHeader(OPTIONAL_API_KEY_HEADER) +export class OrganizationsRolesController { + constructor( + private readonly rolesService: RolesService, + private readonly rolesOutputService: OrganizationsRolesOutputService + ) {} + + @Roles("ORG_ADMIN") + @PlatformPlan("SCALE") + @Pbac(["role.create"]) + @Post("/") + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: "Create a new organization role" }) + async createRole( + @Param("orgId", ParseIntPipe) orgId: number, + @Body() body: CreateOrgRoleInput + ): Promise { + const role = await this.rolesService.createRole(orgId, body); + return { + status: SUCCESS_STATUS, + data: this.rolesOutputService.getOrganizationRoleOutput(role), + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("SCALE") + @Pbac(["role.read"]) + @Get("/:roleId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Get a specific organization role" }) + async getRole( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("roleId") roleId: string + ): Promise { + const role = await this.rolesService.getRole(orgId, roleId); + return { + status: SUCCESS_STATUS, + data: this.rolesOutputService.getOrganizationRoleOutput(role), + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("SCALE") + @Pbac(["role.read"]) + @Get("/") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Get all organization roles" }) + async getAllRoles( + @Param("orgId", ParseIntPipe) orgId: number, + @Query() queryParams: SkipTakePagination + ): Promise { + const { skip, take } = queryParams; + const roles = await this.rolesService.getTeamRoles(orgId, skip ?? 0, take ?? 250); + return { + status: SUCCESS_STATUS, + data: this.rolesOutputService.getOrganizationRolesOutput(roles), + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("SCALE") + @Pbac(["role.update"]) + @Patch("/:roleId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Update an organization role" }) + async updateRole( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("roleId") roleId: string, + @Body() body: UpdateOrgRoleInput + ): Promise { + const role = await this.rolesService.updateRole(orgId, roleId, body); + return { + status: SUCCESS_STATUS, + data: this.rolesOutputService.getOrganizationRoleOutput(role), + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("SCALE") + @Pbac(["role.delete"]) + @Delete("/:roleId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Delete an organization role" }) + async deleteRole( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("roleId") roleId: string + ): Promise { + const role = await this.rolesService.deleteRole(orgId, roleId); + return { + status: SUCCESS_STATUS, + data: this.rolesOutputService.getOrganizationRoleOutput(role), + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/roles/organizations-roles.module.ts b/apps/api/v2/src/modules/organizations/roles/organizations-roles.module.ts new file mode 100644 index 0000000000..a9e1d5ecf8 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/roles/organizations-roles.module.ts @@ -0,0 +1,17 @@ +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository"; +import { OrganizationsRolesPermissionsController } from "@/modules/organizations/roles/permissions/organizations-roles-permissions.controller"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RedisModule } from "@/modules/redis/redis.module"; +import { RolesModule } from "@/modules/roles/roles.module"; +import { StripeModule } from "@/modules/stripe/stripe.module"; +import { Module } from "@nestjs/common"; + +import { OrganizationsRolesController } from "./organizations-roles.controller"; + +@Module({ + imports: [StripeModule, PrismaModule, RedisModule, MembershipsModule, RolesModule], + providers: [OrganizationsRepository], + controllers: [OrganizationsRolesController, OrganizationsRolesPermissionsController], +}) +export class OrganizationsRolesModule {} diff --git a/apps/api/v2/src/modules/organizations/roles/outputs/create-org-role.output.ts b/apps/api/v2/src/modules/organizations/roles/outputs/create-org-role.output.ts new file mode 100644 index 0000000000..3a6d062933 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/roles/outputs/create-org-role.output.ts @@ -0,0 +1,20 @@ +import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class CreateOrgRoleOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: OrgRoleOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => OrgRoleOutput) + data!: OrgRoleOutput; +} diff --git a/apps/api/v2/src/modules/organizations/roles/outputs/delete-org-role.output.ts b/apps/api/v2/src/modules/organizations/roles/outputs/delete-org-role.output.ts new file mode 100644 index 0000000000..191b712ce1 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/roles/outputs/delete-org-role.output.ts @@ -0,0 +1,20 @@ +import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class DeleteOrgRoleOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: OrgRoleOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => OrgRoleOutput) + data!: OrgRoleOutput; +} diff --git a/apps/api/v2/src/modules/organizations/roles/outputs/get-all-org-roles.output.ts b/apps/api/v2/src/modules/organizations/roles/outputs/get-all-org-roles.output.ts new file mode 100644 index 0000000000..62936ee32a --- /dev/null +++ b/apps/api/v2/src/modules/organizations/roles/outputs/get-all-org-roles.output.ts @@ -0,0 +1,20 @@ +import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsArray, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetAllOrgRolesOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: [OrgRoleOutput], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => OrgRoleOutput) + data!: OrgRoleOutput[]; +} diff --git a/apps/api/v2/src/modules/organizations/roles/outputs/get-org-role.output.ts b/apps/api/v2/src/modules/organizations/roles/outputs/get-org-role.output.ts new file mode 100644 index 0000000000..481047d878 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/roles/outputs/get-org-role.output.ts @@ -0,0 +1,20 @@ +import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetOrgRoleOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: OrgRoleOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => OrgRoleOutput) + data!: OrgRoleOutput; +} diff --git a/apps/api/v2/src/modules/organizations/roles/outputs/org-role.output.ts b/apps/api/v2/src/modules/organizations/roles/outputs/org-role.output.ts new file mode 100644 index 0000000000..c2226241dd --- /dev/null +++ b/apps/api/v2/src/modules/organizations/roles/outputs/org-role.output.ts @@ -0,0 +1,71 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; +import { IsString, IsOptional, IsArray, IsEnum, IsDateString, IsNumber } from "class-validator"; + +import { orgPermissionEnum } from "../inputs/base-org-role.input"; + +enum RoleTypeEnum { + SYSTEM = "SYSTEM", + CUSTOM = "CUSTOM", +} + +type RoleType = keyof typeof RoleTypeEnum; + +export class OrgRoleOutput { + @ApiProperty({ description: "Unique identifier for the role" }) + @IsString() + @Expose() + id!: string; + + @ApiProperty({ description: "Name of the role" }) + @IsString() + @Expose() + name!: string; + + @ApiPropertyOptional({ description: "Color for the role (hex code)" }) + @IsString() + @IsOptional() + @Expose() + color?: string | null; + + @ApiPropertyOptional({ description: "Description of the role" }) + @IsString() + @IsOptional() + @Expose() + description?: string | null; + + @ApiPropertyOptional({ description: "Organization ID this role belongs to" }) + @IsNumber() + @IsOptional() + @Expose() + organizationId?: number | null; + + @ApiProperty({ + description: "Type of role", + enum: RoleTypeEnum, + }) + @IsEnum(RoleTypeEnum) + @Expose() + type!: RoleType; + + @ApiProperty({ + description: "Permissions assigned to this role in 'resource.action' format.", + enum: orgPermissionEnum, + isArray: true, + example: ["booking.read", "eventType.create"], + }) + @IsArray() + @IsString({ each: true }) + @Expose() + permissions!: string[]; + + @ApiProperty({ description: "When the role was created" }) + @IsDateString() + @Expose() + createdAt!: string; + + @ApiProperty({ description: "When the role was last updated" }) + @IsDateString() + @Expose() + updatedAt!: string; +} diff --git a/apps/api/v2/src/modules/organizations/roles/outputs/update-org-role.output.ts b/apps/api/v2/src/modules/organizations/roles/outputs/update-org-role.output.ts new file mode 100644 index 0000000000..5288cc55e1 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/roles/outputs/update-org-role.output.ts @@ -0,0 +1,20 @@ +import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class UpdateOrgRoleOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: OrgRoleOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => OrgRoleOutput) + data!: OrgRoleOutput; +} diff --git a/apps/api/v2/src/modules/organizations/roles/permissions/inputs/create-org-role-permissions.input.ts b/apps/api/v2/src/modules/organizations/roles/permissions/inputs/create-org-role-permissions.input.ts new file mode 100644 index 0000000000..62f7334113 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/roles/permissions/inputs/create-org-role-permissions.input.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsArray, IsString, Validate } from "class-validator"; + +import type { PermissionString } from "@calcom/platform-libraries/pbac"; + +import { orgPermissionEnum } from "../../inputs/base-org-role.input"; +import { OrgPermissionStringValidator } from "./validators/org-permission-string.validator"; + +export class CreateOrgRolePermissionsInput { + @ApiProperty({ + description: "Permissions to add (format: resource.action)", + enum: orgPermissionEnum, + isArray: true, + example: ["eventType.read", "booking.read"], + }) + @IsArray() + @IsString({ each: true }) + @Validate(OrgPermissionStringValidator, { each: true }) + permissions!: PermissionString[]; +} diff --git a/apps/api/v2/src/modules/organizations/roles/permissions/inputs/delete-org-role-permissions.query.ts b/apps/api/v2/src/modules/organizations/roles/permissions/inputs/delete-org-role-permissions.query.ts new file mode 100644 index 0000000000..118b4ef18f --- /dev/null +++ b/apps/api/v2/src/modules/organizations/roles/permissions/inputs/delete-org-role-permissions.query.ts @@ -0,0 +1,33 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { ArrayNotEmpty, IsArray, IsOptional, IsString, Validate } from "class-validator"; + +import type { PermissionString } from "@calcom/platform-libraries/pbac"; + +import { orgPermissionEnum } from "../../inputs/base-org-role.input"; +import { OrgPermissionStringValidator } from "./validators/org-permission-string.validator"; + +export class DeleteOrgRolePermissionsQuery { + @ApiPropertyOptional({ + description: + "Permissions to remove (format: resource.action). Supports comma-separated values as well as repeated query params.", + example: "?permissions=eventType.read,booking.read", + enum: orgPermissionEnum, + isArray: true, + }) + @IsOptional() + @Transform(({ value }) => { + if (typeof value === "string") { + return value + .split(",") + .map((p: string) => p.trim()) + .filter((p: string) => p.length > 0); + } + return value; + }) + @IsArray() + @ArrayNotEmpty({ message: "permissions cannot be empty." }) + @IsString({ each: true }) + @Validate(OrgPermissionStringValidator, { each: true }) + permissions?: PermissionString[]; +} diff --git a/apps/api/v2/src/modules/organizations/roles/permissions/inputs/validators/org-permission-string.validator.spec.ts b/apps/api/v2/src/modules/organizations/roles/permissions/inputs/validators/org-permission-string.validator.spec.ts new file mode 100644 index 0000000000..61c23b7fd0 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/roles/permissions/inputs/validators/org-permission-string.validator.spec.ts @@ -0,0 +1,71 @@ +import { BadRequestException } from "@nestjs/common"; + +import { OrgPermissionStringValidator } from "./org-permission-string.validator"; + +describe("PermissionStringValidator", () => { + let validator: OrgPermissionStringValidator; + + beforeEach(() => { + validator = new OrgPermissionStringValidator(); + }); + + describe("validate", () => { + it("should return true for valid permission strings", () => { + const validPermissions = [ + "eventType.read", + "eventType.create", + "eventType.update", + "eventType.delete", + "booking.read", + "booking.update", + "role.create", + "role.read", + "role.update", + "role.delete", + "team.read", + "team.invite", + "organization.read", + "organization.listMembers", + // no wildcards here + ]; + + validPermissions.forEach((permission) => { + expect(validator.validate(permission)).toBe(true); + }); + }); + + it("should throw for invalid permission strings", () => { + const invalidPermissions = [ + "invalid", // no dot + "invalid.", // no action + ".invalid", // no resource + "invalid.action", // invalid resource + "eventType.invalid", // invalid action + "event-type.read", // wrong format (should be eventType) + "", // empty string + "eventType..read", // double dot + "eventType.read.extra", // too many parts + ]; + + invalidPermissions.forEach((permission) => { + expect(() => validator.validate(permission)).toThrow(BadRequestException); + }); + }); + + it("should throw for wildcard permission strings", () => { + const wildcardPermissions = ["*", "*.read", "eventType.*"]; + + wildcardPermissions.forEach((permission) => { + expect(() => validator.validate(permission)).toThrow(BadRequestException); + }); + }); + }); + + describe("defaultMessage", () => { + it("should return appropriate error message", () => { + const message = validator.defaultMessage(); + expect(message).toContain("Permission must be a valid permission string"); + expect(message).toContain("resource.action"); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/roles/permissions/inputs/validators/org-permission-string.validator.ts b/apps/api/v2/src/modules/organizations/roles/permissions/inputs/validators/org-permission-string.validator.ts new file mode 100644 index 0000000000..6bd8f8c061 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/roles/permissions/inputs/validators/org-permission-string.validator.ts @@ -0,0 +1,23 @@ +import { BadRequestException } from "@nestjs/common"; +import type { ValidatorConstraintInterface } from "class-validator"; +import { ValidatorConstraint } from "class-validator"; + +import { isValidPermissionStringForScope } from "@calcom/platform-libraries/pbac"; +import { Scope } from "@calcom/platform-libraries/pbac"; + +@ValidatorConstraint({ name: "orgPermissionStringValidator", async: false }) +export class OrgPermissionStringValidator implements ValidatorConstraintInterface { + validate(permission: string) { + const isValid = isValidPermissionStringForScope(permission, Scope.Organization); + if (!isValid) { + throw new BadRequestException( + `Permission '${permission}' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')` + ); + } + return true; + } + + defaultMessage() { + return "Permission must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"; + } +} diff --git a/apps/api/v2/src/modules/organizations/roles/permissions/organizations-roles-permissions.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/roles/permissions/organizations-roles-permissions.controller.e2e-spec.ts new file mode 100644 index 0000000000..4890e1a203 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/roles/permissions/organizations-roles-permissions.controller.e2e-spec.ts @@ -0,0 +1,395 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/create-team-role.input"; +import type { CreateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/create-team-role.output"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import * as request from "supertest"; +import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture"; +import { FeaturesRepositoryFixture } from "test/fixtures/repository/features.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { RoleService } from "@calcom/platform-libraries/pbac"; +import type { PermissionString } from "@calcom/platform-libraries/pbac"; +import type { Team, User } from "@calcom/prisma/client"; +import { MembershipRole } from "@calcom/prisma/enums"; + +describe("Organizations Roles Permissions Endpoints", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let membershipRepositoryFixture: MembershipRepositoryFixture; + let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; + let featuresRepositoryFixture: FeaturesRepositoryFixture; + let roleService: RoleService; + + let pbacOrgUserWithRolePermission: User; + let pbacOrgUserWithRolePermissionApiKey: string; + + let pbacEnabledOrganization: Team; + + const pbacUserEmail = `pbac-org-user-with-permissions-${randomString()}@api.com`; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef); + featuresRepositoryFixture = new FeaturesRepositoryFixture(moduleRef); + roleService = new RoleService(); + + // Create PBAC org + pbacEnabledOrganization = await organizationsRepositoryFixture.create({ + name: `pbac-org-role-perm-test-${randomString()}`, + isOrganization: true, + }); + + await featuresRepositoryFixture.create({ slug: "pbac", enabled: true }); + await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledOrganization.id, "pbac"); + + // Create user + membership in org + pbacOrgUserWithRolePermission = await userRepositoryFixture.create({ + email: pbacUserEmail, + username: pbacUserEmail, + }); + + const membership = await membershipRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: pbacOrgUserWithRolePermission.id } }, + team: { connect: { id: pbacEnabledOrganization.id } }, + }); + + // Create a role that allows role.read and role.update, assign to user + const managerRole = await roleService.createRole({ + name: `role-manager-${randomString()}`, + teamId: pbacEnabledOrganization.id, + permissions: ["role.create", "role.read", "role.update"], + type: "CUSTOM", + }); + await roleService.assignRoleToMember(managerRole.id, membership.id); + + // API key for user + const { keyString } = await apiKeysRepositoryFixture.createApiKey(pbacOrgUserWithRolePermission.id, null); + pbacOrgUserWithRolePermissionApiKey = `cal_test_${keyString}`; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + }); + + describe("Negative Tests", () => { + it("rejects invalid permission on add (400)", async () => { + const baseRoleInput: CreateTeamRoleInput = { + name: `neg-add-invalid-${randomString()}`, + permissions: ["booking.read"], + }; + const createRes = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(baseRoleInput) + .expect(201); + const roleId = createRes.body.data.id as string; + + const response = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send({ permissions: ["invalid"] }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe( + "Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')" + ); + }); + + it("rejects invalid permission on replace (400)", async () => { + const baseRoleInput: CreateTeamRoleInput = { + name: `neg-put-invalid-${randomString()}`, + permissions: ["booking.read"], + }; + const createRes = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(baseRoleInput) + .expect(201); + const roleId = createRes.body.data.id as string; + + const response = await request(app.getHttpServer()) + .put(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send({ permissions: ["invalid"] }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe( + "Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')" + ); + }); + + it("rejects invalid permission on delete single (400)", async () => { + const baseRoleInput: CreateTeamRoleInput = { + name: `neg-delone-invalid-${randomString()}`, + permissions: ["booking.read"], + }; + const createRes = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(baseRoleInput) + .expect(201); + const roleId = createRes.body.data.id as string; + + const response = await request(app.getHttpServer()) + .delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions/${"invalid"}`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe( + "Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')" + ); + }); + + it("rejects invalid permission on bulk delete (400)", async () => { + const baseRoleInput: CreateTeamRoleInput = { + name: `neg-delmany-invalid-${randomString()}`, + permissions: ["booking.read", "eventType.create"], + }; + const createRes = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(baseRoleInput) + .expect(201); + const roleId = createRes.body.data.id as string; + + const invalid = "invalid"; + const res = await request(app.getHttpServer()) + .delete( + `/v2/organizations/${ + pbacEnabledOrganization.id + }/roles/${roleId}/permissions?permissions=${encodeURIComponent(`booking.read,${invalid}`)}` + ) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`); + + expect(res.status).toBe(400); + expect(res.body.error.message).toBe( + "Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')" + ); + }); + + it("returns 404 when modifying permissions for role not belonging to organization", async () => { + // Create foreign organization and role + const foreignOrg = await organizationsRepositoryFixture.create({ + name: `foreign-org-${randomString()}`, + isOrganization: true, + }); + const foreignRole = await roleService.createRole({ + name: `foreign-role-${randomString()}`, + teamId: foreignOrg.id, + permissions: ["booking.read"], + type: "CUSTOM", + }); + + const response = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${foreignRole.id}/permissions`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send({ permissions: ["eventType.read"] }); + expect(response.status).toBe(404); + expect(response.body.error.message).toBe( + `Role with id ${foreignRole.id} within team id ${pbacEnabledOrganization.id} not found` + ); + + await organizationsRepositoryFixture.delete(foreignOrg.id); + }); + + it("returns 404 when modifying permissions of a default (system) role not belonging to organization", async () => { + const defaultMemberRoleId = await roleService.getDefaultRoleId(MembershipRole.MEMBER); + const response = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultMemberRoleId}/permissions`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send({ permissions: ["eventType.read"] }); + expect(response.status).toBe(404); + expect(response.body.error.message).toBe( + `Role with id ${defaultMemberRoleId} within team id ${pbacEnabledOrganization.id} not found` + ); + }); + }); + + it("lists permissions for a role (GET /)", async () => { + const initialPermissions = ["booking.read"] as const; + const baseRoleInput: CreateTeamRoleInput = { + name: `perm-target-role-list-${randomString()}`, + permissions: [...initialPermissions], + }; + + const createRes = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(baseRoleInput) + .expect(201); + const responseBody: CreateTeamRoleOutput = createRes.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions); + const roleId = responseBody.data.id; + + const listRes = await request(app.getHttpServer()) + .get(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .expect(200); + expect(listRes.body.status).toEqual(SUCCESS_STATUS); + expect(listRes.body.data).toEqual(initialPermissions); + }); + + it("adds permissions (POST /)", async () => { + const initialPermissions = ["booking.read"] as const; + const toAdd = ["eventType.create", "eventType.read"] as const; + const expected = [...initialPermissions, ...toAdd] as string[]; + + const baseRoleInput: CreateTeamRoleInput = { + name: `perm-target-role-add-${randomString()}`, + permissions: [...initialPermissions], + }; + + const createRes = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(baseRoleInput) + .expect(201); + const responseBody: CreateTeamRoleOutput = createRes.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions); + const roleId = responseBody.data.id; + + const addRes = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send({ permissions: toAdd }) + .expect(200); + expect(addRes.body.status).toEqual(SUCCESS_STATUS); + expect(addRes.body.data).toEqual(expected); + }); + + it("bulk removes permissions via query (DELETE /)", async () => { + const initialPermissions: PermissionString[] = ["booking.read", "eventType.create", "eventType.read"]; + const toRemove: PermissionString[] = ["eventType.create", "eventType.read"]; + const expected: PermissionString[] = ["booking.read"]; + + const baseRoleInput: CreateTeamRoleInput = { + name: `perm-target-role-delmany-${randomString()}`, + permissions: initialPermissions, + }; + const createRes = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(baseRoleInput) + .expect(201); + const responseBody: CreateTeamRoleOutput = createRes.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions); + const roleId = responseBody.data.id; + + await request(app.getHttpServer()) + .delete( + `/v2/organizations/${ + pbacEnabledOrganization.id + }/roles/${roleId}/permissions?permissions=${toRemove.join(",")}` + ) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .expect(204); + + const listRes = await request(app.getHttpServer()) + .get(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .expect(200); + expect(listRes.body.data).toEqual(expected); + }); + + it("replaces all permissions (PUT /)", async () => { + const initialPermissions = ["booking.read"] as const; + const replacement = ["booking.read", "eventType.update"] as const; + + const baseRoleInput: CreateTeamRoleInput = { + name: `perm-target-role-put-${randomString()}`, + permissions: [...initialPermissions], + }; + const createRes = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(baseRoleInput) + .expect(201); + const responseBody: CreateTeamRoleOutput = createRes.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions); + const roleId = responseBody.data.id; + + const putRes = await request(app.getHttpServer()) + .put(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send({ permissions: replacement }) + .expect(200); + + expect(putRes.body.status).toEqual(SUCCESS_STATUS); + expect(putRes.body.data).toEqual(replacement); + }); + + it("removes a single permission (DELETE /:permission)", async () => { + const initialPermissions = ["booking.read", "eventType.update"] as const; + const toRemove = "eventType.update" as const; + const expected = ["booking.read"] as const; + + const baseRoleInput: CreateTeamRoleInput = { + name: `perm-target-role-delone-${randomString()}`, + permissions: [...initialPermissions], + }; + const createRes = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(baseRoleInput) + .expect(201); + const responseBody: CreateTeamRoleOutput = createRes.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions); + const roleId = responseBody.data.id; + + await request(app.getHttpServer()) + .delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions/${toRemove}`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .expect(204); + + const listRes = await request(app.getHttpServer()) + .get(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .expect(200); + expect(listRes.body.data).toEqual(expected); + }); + + afterAll(async () => { + try { + // Clean up feature flag from organization + await featuresRepositoryFixture.deleteTeamFeature(pbacEnabledOrganization.id, "pbac"); + + // Clean up org + await organizationsRepositoryFixture.delete(pbacEnabledOrganization.id); + + // Clean up user + await userRepositoryFixture.deleteByEmail(pbacOrgUserWithRolePermission.email); + + // Clean up feature definition + try { + await featuresRepositoryFixture.deleteBySlug("pbac"); + } catch (err) { + console.log(err); + } + } catch (err) { + console.log(err); + } finally { + await app.close(); + } + }); +}); diff --git a/apps/api/v2/src/modules/organizations/roles/permissions/organizations-roles-permissions.controller.ts b/apps/api/v2/src/modules/organizations/roles/permissions/organizations-roles-permissions.controller.ts new file mode 100644 index 0000000000..97eb19c9ae --- /dev/null +++ b/apps/api/v2/src/modules/organizations/roles/permissions/organizations-roles-permissions.controller.ts @@ -0,0 +1,126 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { + OPTIONAL_API_KEY_HEADER, + OPTIONAL_X_CAL_CLIENT_ID_HEADER, + OPTIONAL_X_CAL_SECRET_KEY_HEADER, +} from "@/lib/docs/headers"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { CreateOrgRolePermissionsInput } from "@/modules/organizations/roles/permissions/inputs/create-org-role-permissions.input"; +import { DeleteOrgRolePermissionsQuery } from "@/modules/organizations/roles/permissions/inputs/delete-org-role-permissions.query"; +import { GetOrgRolePermissionsOutput } from "@/modules/organizations/roles/permissions/outputs/get-org-role-permissions.output"; +import { RolesPermissionsService } from "@/modules/roles/permissions/services/roles-permissions.service"; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseIntPipe, + Put, + Query, + Post, + UseGuards, +} from "@nestjs/common"; +import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { PermissionString } from "@calcom/platform-libraries/pbac"; + +@Controller({ + path: "/v2/organizations/:orgId/roles/:roleId/permissions", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, IsOrgGuard, PbacGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) +@DocsTags("Orgs / Roles / Permissions") +@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER) +@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER) +@ApiHeader(OPTIONAL_API_KEY_HEADER) +export class OrganizationsRolesPermissionsController { + constructor(private readonly rolePermissionsService: RolesPermissionsService) {} + + @Roles("ORG_ADMIN") + @PlatformPlan("SCALE") + @Pbac(["role.update"]) + @Post("/") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Add permissions to an organization role (single or batch)" }) + async addPermissions( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("roleId") roleId: string, + @Body() body: CreateOrgRolePermissionsInput + ): Promise { + const permissions = await this.rolePermissionsService.addRolePermissions(orgId, roleId, body.permissions); + return { status: SUCCESS_STATUS, data: permissions }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("SCALE") + @Pbac(["role.read"]) + @Get("/") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "List permissions for an organization role" }) + async listPermissions( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("roleId") roleId: string + ): Promise { + const permissions = await this.rolePermissionsService.getRolePermissions(orgId, roleId); + return { status: SUCCESS_STATUS, data: permissions }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("SCALE") + @Pbac(["role.update"]) + @Put("/") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Replace all permissions for an organization role" }) + async setPermissions( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("roleId") roleId: string, + @Body() body: CreateOrgRolePermissionsInput + ): Promise { + const permissions = await this.rolePermissionsService.setRolePermissions( + orgId, + roleId, + body.permissions || [] + ); + return { status: SUCCESS_STATUS, data: permissions }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("SCALE") + @Pbac(["role.update"]) + @Delete("/:permission") + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: "Remove a permission from an organization role" }) + async removePermission( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("roleId") roleId: string, + @Param("permission") permission: PermissionString + ): Promise { + await this.rolePermissionsService.removeRolePermission(orgId, roleId, permission); + } + + @Roles("ORG_ADMIN") + @PlatformPlan("SCALE") + @Pbac(["role.update"]) + @Delete("/") + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: "Remove multiple permissions from an organization role" }) + async removePermissions( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("roleId") roleId: string, + @Query() query: DeleteOrgRolePermissionsQuery + ): Promise { + await this.rolePermissionsService.removeRolePermissions(orgId, roleId, query.permissions || []); + } +} diff --git a/apps/api/v2/src/modules/organizations/roles/permissions/outputs/get-org-role-permissions.output.ts b/apps/api/v2/src/modules/organizations/roles/permissions/outputs/get-org-role-permissions.output.ts new file mode 100644 index 0000000000..2927eb636d --- /dev/null +++ b/apps/api/v2/src/modules/organizations/roles/permissions/outputs/get-org-role-permissions.output.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsArray, IsString } from "class-validator"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; + +export class GetOrgRolePermissionsOutput { + @ApiProperty({ example: SUCCESS_STATUS }) + status!: typeof SUCCESS_STATUS; + + @ApiProperty({ type: [String] }) + @IsArray() + @IsString({ each: true }) + data!: string[]; +} diff --git a/apps/api/v2/src/modules/organizations/roles/services/organizations-roles-output.service.ts b/apps/api/v2/src/modules/organizations/roles/services/organizations-roles-output.service.ts new file mode 100644 index 0000000000..1a929d9cae --- /dev/null +++ b/apps/api/v2/src/modules/organizations/roles/services/organizations-roles-output.service.ts @@ -0,0 +1,26 @@ +import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output"; +import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output"; +import { Injectable } from "@nestjs/common"; + +import type { Role } from "@calcom/platform-libraries/pbac"; + +@Injectable() +export class OrganizationsRolesOutputService { + getOrganizationRoleOutput(role: Role): OrgRoleOutput { + return { + id: role.id, + name: role.name, + color: role.color || null, + description: role.description || null, + organizationId: role.teamId || null, + type: role.type, + permissions: role.permissions.map((permission) => `${permission.resource}.${permission.action}`), + createdAt: role.createdAt.toISOString(), + updatedAt: role.updatedAt.toISOString(), + }; + } + + getOrganizationRolesOutput(roles: Role[]): TeamRoleOutput[] { + return roles.map((role) => this.getOrganizationRoleOutput(role)); + } +} diff --git a/apps/api/v2/src/modules/organizations/teams/roles/inputs/base-team-role.input.ts b/apps/api/v2/src/modules/organizations/teams/roles/inputs/base-team-role.input.ts new file mode 100644 index 0000000000..f0fad18af6 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/roles/inputs/base-team-role.input.ts @@ -0,0 +1,34 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsArray, IsOptional, IsString, Validate } from "class-validator"; + +import type { PermissionString } from "@calcom/platform-libraries/pbac"; +import { getAllPermissionStringsForScope, Scope } from "@calcom/platform-libraries/pbac"; + +import { TeamPermissionStringValidator } from "../permissions/inputs/validators/team-permission-string.validator"; + +export const teamPermissionEnum = [...getAllPermissionStringsForScope(Scope.Team)] as const; + +export class BaseTeamRoleInput { + @ApiPropertyOptional({ description: "Color for the role (hex code)" }) + @IsString() + @IsOptional() + color?: string; + + @ApiPropertyOptional({ description: "Description of the role" }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional({ + description: + "Permissions for this role (format: resource.action). On update, this field replaces the entire permission set for the role (full replace). Use granular permission endpoints for one-by-one changes.", + enum: teamPermissionEnum, + isArray: true, + example: ["eventType.read", "eventType.create", "booking.read"], + }) + @IsArray() + @IsString({ each: true }) + @Validate(TeamPermissionStringValidator, { each: true }) + @IsOptional() + permissions?: PermissionString[]; +} diff --git a/apps/api/v2/src/modules/organizations/teams/roles/inputs/create-team-role.input.ts b/apps/api/v2/src/modules/organizations/teams/roles/inputs/create-team-role.input.ts new file mode 100644 index 0000000000..35a1e56c66 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/roles/inputs/create-team-role.input.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, MinLength } from "class-validator"; + +import { BaseTeamRoleInput } from "./base-team-role.input"; + +export class CreateTeamRoleInput extends BaseTeamRoleInput { + @ApiProperty({ description: "Name of the role", minLength: 1 }) + @IsString() + @MinLength(1) + name!: string; +} diff --git a/apps/api/v2/src/modules/organizations/teams/roles/inputs/update-team-role.input.ts b/apps/api/v2/src/modules/organizations/teams/roles/inputs/update-team-role.input.ts new file mode 100644 index 0000000000..7df0cf86d5 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/roles/inputs/update-team-role.input.ts @@ -0,0 +1,12 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsString, IsOptional, MinLength } from "class-validator"; + +import { BaseTeamRoleInput } from "./base-team-role.input"; + +export class UpdateTeamRoleInput extends BaseTeamRoleInput { + @ApiPropertyOptional({ description: "Name of the role", minLength: 1 }) + @IsString() + @MinLength(1) + @IsOptional() + name?: string; +} diff --git a/apps/api/v2/src/modules/organizations/teams/roles/organizations-teams-roles.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/teams/roles/organizations-teams-roles.controller.e2e-spec.ts new file mode 100644 index 0000000000..ab69ce2dd1 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/roles/organizations-teams-roles.controller.e2e-spec.ts @@ -0,0 +1,469 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/create-team-role.input"; +import { UpdateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/update-team-role.input"; +import { CreateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/create-team-role.output"; +import { DeleteTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/delete-team-role.output"; +import { GetAllTeamRolesOutput } from "@/modules/organizations/teams/roles/outputs/get-all-team-roles.output"; +import { GetTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/get-team-role.output"; +import { UpdateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/update-team-role.output"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import * as request from "supertest"; +import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture"; +import { FeaturesRepositoryFixture } from "test/fixtures/repository/features.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { RoleService } from "@calcom/platform-libraries/pbac"; +import type { User, Team } from "@calcom/prisma/client"; + +describe("Organizations Roles Endpoints", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let membershipRepositoryFixture: MembershipRepositoryFixture; + let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; + let featuresRepositoryFixture: FeaturesRepositoryFixture; + let roleService: RoleService; + + // Test users + let legacyOrgAdminUser: User; + let legacyOrgMemberUser: User; + let pbacOrgUserWithRolePermission: User; + let pbacOrgUserWithoutRolePermission: User; + let nonOrgUser: User; + + // API Keys + let legacyOrgAdminApiKey: string; + let legacyOrgMemberApiKey: string; + let pbacOrgUserWithRolePermissionApiKey: string; + let pbacOrgUserWithoutRolePermissionApiKey: string; + let nonOrgUserApiKey: string; + + // Organization and team + let organization: Team; + let team: Team; + let pbacEnabledOrganization: Team; + let pbacEnabledTeam: Team; + + const legacyOrgAdminEmail = `legacy-org-admin-${randomString()}@api.com`; + const legacyOrgMemberEmail = `legacy-org-member-${randomString()}@api.com`; + const pbacOrgUserWithRolePermissionEmail = `pbac-org-user-with-role-permission-${randomString()}@api.com`; + const pbacOrgUserWithoutRolePermissionEmail = `pbac-org-user-without-role-permission-${randomString()}@api.com`; + const nonOrgUserEmail = `non-org-user-${randomString()}@api.com`; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef); + featuresRepositoryFixture = new FeaturesRepositoryFixture(moduleRef); + roleService = new RoleService(); + + // Create test users + legacyOrgAdminUser = await userRepositoryFixture.create({ + email: legacyOrgAdminEmail, + username: legacyOrgAdminEmail, + }); + + legacyOrgMemberUser = await userRepositoryFixture.create({ + email: legacyOrgMemberEmail, + username: legacyOrgMemberEmail, + }); + + pbacOrgUserWithRolePermission = await userRepositoryFixture.create({ + email: pbacOrgUserWithRolePermissionEmail, + username: pbacOrgUserWithRolePermissionEmail, + }); + + pbacOrgUserWithoutRolePermission = await userRepositoryFixture.create({ + email: pbacOrgUserWithoutRolePermissionEmail, + username: pbacOrgUserWithoutRolePermissionEmail, + }); + + nonOrgUser = await userRepositoryFixture.create({ + email: nonOrgUserEmail, + username: nonOrgUserEmail, + }); + + // Create organizations and teams + organization = await organizationsRepositoryFixture.create({ + name: `org-roles-test-${randomString()}`, + isOrganization: true, + }); + + team = await teamRepositoryFixture.create({ + name: `team-roles-test-${randomString()}`, + isOrganization: false, + parent: { connect: { id: organization.id } }, + }); + + pbacEnabledOrganization = await organizationsRepositoryFixture.create({ + name: `pbac-org-roles-test-${randomString()}`, + isOrganization: true, + }); + + pbacEnabledTeam = await teamRepositoryFixture.create({ + name: `pbac-team-roles-test-${randomString()}`, + isOrganization: false, + parent: { connect: { id: pbacEnabledOrganization.id } }, + }); + + await featuresRepositoryFixture.create({ slug: "pbac", enabled: true }); + await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledTeam.id, "pbac"); + + // Create memberships + await membershipRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: legacyOrgAdminUser.id } }, + team: { connect: { id: organization.id } }, + }); + + await membershipRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: legacyOrgMemberUser.id } }, + team: { connect: { id: organization.id } }, + }); + + const pbacOrgUserWithRolePermissionMembership = await membershipRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: pbacOrgUserWithRolePermission.id } }, + team: { connect: { id: pbacEnabledOrganization.id } }, + }); + + const pbacOrgUserWithoutRolePermissionMembership = await membershipRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: pbacOrgUserWithoutRolePermission.id } }, + team: { connect: { id: pbacEnabledOrganization.id } }, + }); + + const roleWithPermission = await roleService.createRole({ + name: "Role Manager", + teamId: pbacEnabledOrganization.id, + permissions: ["role.create", "role.read", "role.update", "role.delete"], + type: "CUSTOM", + }); + + const roleWithoutPermission = await roleService.createRole({ + name: "Basic Role", + teamId: pbacEnabledOrganization.id, + permissions: ["booking.read"], + type: "CUSTOM", + }); + + await roleService.assignRoleToMember(roleWithPermission.id, pbacOrgUserWithRolePermissionMembership.id); + await roleService.assignRoleToMember( + roleWithoutPermission.id, + pbacOrgUserWithoutRolePermissionMembership.id + ); + + const { keyString: legacyOrgAdminKeyString } = await apiKeysRepositoryFixture.createApiKey( + legacyOrgAdminUser.id, + null + ); + legacyOrgAdminApiKey = `cal_test_${legacyOrgAdminKeyString}`; + + const { keyString: legacyOrgMemberKeyString } = await apiKeysRepositoryFixture.createApiKey( + legacyOrgMemberUser.id, + null + ); + legacyOrgMemberApiKey = `cal_test_${legacyOrgMemberKeyString}`; + + const { keyString: pbacOrgUserWithRolePermissionKeyString } = await apiKeysRepositoryFixture.createApiKey( + pbacOrgUserWithRolePermission.id, + null + ); + pbacOrgUserWithRolePermissionApiKey = `cal_test_${pbacOrgUserWithRolePermissionKeyString}`; + + const { keyString: pbacOrgUserWithoutRolePermissionKeyString } = + await apiKeysRepositoryFixture.createApiKey(pbacOrgUserWithoutRolePermission.id, null); + pbacOrgUserWithoutRolePermissionApiKey = `cal_test_${pbacOrgUserWithoutRolePermissionKeyString}`; + + const { keyString: nonOrgUserKeyString } = await apiKeysRepositoryFixture.createApiKey( + nonOrgUser.id, + null + ); + nonOrgUserApiKey = `cal_test_${nonOrgUserKeyString}`; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + }); + + describe("Role Creation Authorization", () => { + describe("Positive Tests", () => { + it("should allow role creation when organization has PBAC enabled and user has a create permission", async () => { + const createRoleInput: CreateTeamRoleInput = { + name: "Test Role PBAC", + permissions: ["booking.read", "eventType.create"], + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(createRoleInput) + .expect(201) + .then((response) => { + const responseBody: CreateTeamRoleOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.name).toEqual(createRoleInput.name); + expect(responseBody.data.permissions).toEqual(createRoleInput.permissions); + expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id); + }); + }); + + it("should allow role creation when organization does not have PBAC enabled and user is org admin", async () => { + const createRoleInput: CreateTeamRoleInput = { + name: "Test Role Legacy Admin", + permissions: ["booking.read"], + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${organization.id}/teams/${team.id}/roles`) + .set("Authorization", `Bearer ${legacyOrgAdminApiKey}`) + .send(createRoleInput) + .expect(201) + .then((response) => { + const responseBody: CreateTeamRoleOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.name).toEqual(createRoleInput.name); + expect(responseBody.data.teamId).toEqual(team.id); + }); + }); + }); + + describe("Negative Tests", () => { + it("should not allow role creation when organization has PBAC enabled but user has no role assigned", async () => { + const createRoleInput: CreateTeamRoleInput = { + name: "Test Role No Role", + permissions: ["booking.read"], + }; + + const userWithNoRole = await userRepositoryFixture.create({ + email: `no-role-user-${randomString()}@api.com`, + username: `no-role-user-${randomString()}@api.com`, + }); + + await membershipRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: userWithNoRole.id } }, + team: { connect: { id: pbacEnabledOrganization.id } }, + }); + + const { keyString: noRoleKeyString } = await apiKeysRepositoryFixture.createApiKey( + userWithNoRole.id, + null + ); + const noRoleApiKey = `cal_test_${noRoleKeyString}`; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`) + .set("Authorization", `Bearer ${noRoleApiKey}`) + .send(createRoleInput) + .expect(403); + }); + + it("should not allow role creation when organization has PBAC enabled but user role lacks required permission", async () => { + const createRoleInput: CreateTeamRoleInput = { + name: "Test Role No Permission", + permissions: ["booking.read"], + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithoutRolePermissionApiKey}`) + .send(createRoleInput) + .expect(403); + }); + + it("should not allow role creation when organization does not have PBAC enabled and user has no membership", async () => { + const createRoleInput: CreateTeamRoleInput = { + name: "Test Role No Membership", + permissions: ["booking.read"], + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${organization.id}/teams/${team.id}/roles`) + .set("Authorization", `Bearer ${nonOrgUserApiKey}`) + .send(createRoleInput) + .expect(403); + }); + + it("should not allow role creation when organization does not have PBAC enabled and user has member membership (not admin)", async () => { + const createRoleInput: CreateTeamRoleInput = { + name: "Test Role Member Only", + permissions: ["booking.read"], + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${organization.id}/teams/${team.id}/roles`) + .set("Authorization", `Bearer ${legacyOrgMemberApiKey}`) + .send(createRoleInput) + .expect(403); + }); + }); + }); + + describe("CRUD Role Endpoints", () => { + let createdRoleId: string; + + it("should create a role", async () => { + const createRoleInput: CreateTeamRoleInput = { + name: "CRUD Test Role", + permissions: ["booking.read", "eventType.create"], + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(createRoleInput) + .expect(201) + .then((response) => { + const responseBody: CreateTeamRoleOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.name).toEqual(createRoleInput.name); + expect(responseBody.data.permissions).toEqual(createRoleInput.permissions); + expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id); + createdRoleId = responseBody.data.id; + }); + }); + + it("should update role permissions and name", async () => { + const updateRoleInput: UpdateTeamRoleInput = { + name: "CRUD Test Role Updated", + permissions: ["booking.read", "eventType.read"], + }; + + return request(app.getHttpServer()) + .patch( + `/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${createdRoleId}` + ) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(updateRoleInput) + .expect(200) + .then((response) => { + const responseBody: UpdateTeamRoleOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.id).toEqual(createdRoleId); + expect(responseBody.data.name).toEqual(updateRoleInput.name); + expect(responseBody.data.permissions).toEqual(updateRoleInput.permissions); + expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id); + }); + }); + + it("should update only name and keep permissions unchanged", async () => { + const updateNameOnly: UpdateTeamRoleInput = { + name: "CRUD Test Role Renamed Only", + }; + const expectedPermissions = ["booking.read", "eventType.read"]; // from previous update + + return request(app.getHttpServer()) + .patch( + `/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${createdRoleId}` + ) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(updateNameOnly) + .expect(200) + .then((response) => { + const responseBody: UpdateTeamRoleOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.id).toEqual(createdRoleId); + expect(responseBody.data.name).toEqual(updateNameOnly.name); + expect(responseBody.data.permissions).toEqual(expectedPermissions); + expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id); + }); + }); + + it("should fetch the role", async () => { + return request(app.getHttpServer()) + .get( + `/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${createdRoleId}` + ) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .expect(200) + .then((response) => { + const responseBody: GetTeamRoleOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.id).toEqual(createdRoleId); + expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id); + }); + }); + + it("should fetch all roles", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .expect(200) + .then((response) => { + const responseBody: GetAllTeamRolesOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(Array.isArray(responseBody.data)).toBe(true); + expect(responseBody.data.find((r) => r.id === createdRoleId)).toBeDefined(); + const created = responseBody.data.find((r) => r.id === createdRoleId); + expect(created?.teamId).toEqual(pbacEnabledTeam.id); + }); + }); + + it("should delete the role", async () => { + return request(app.getHttpServer()) + .delete( + `/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${createdRoleId}` + ) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .expect(200) + .then((response) => { + const responseBody: DeleteTeamRoleOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.id).toEqual(createdRoleId); + expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id); + }); + }); + }); + + afterAll(async () => { + try { + await featuresRepositoryFixture.deleteTeamFeature(pbacEnabledTeam.id, "pbac"); + + await teamRepositoryFixture.delete(team.id); + await teamRepositoryFixture.delete(pbacEnabledTeam.id); + + await organizationsRepositoryFixture.delete(organization.id); + await organizationsRepositoryFixture.delete(pbacEnabledOrganization.id); + + await userRepositoryFixture.deleteByEmail(legacyOrgAdminUser.email); + await userRepositoryFixture.deleteByEmail(legacyOrgMemberUser.email); + await userRepositoryFixture.deleteByEmail(pbacOrgUserWithRolePermission.email); + await userRepositoryFixture.deleteByEmail(pbacOrgUserWithoutRolePermission.email); + await userRepositoryFixture.deleteByEmail(nonOrgUser.email); + + try { + await featuresRepositoryFixture.deleteBySlug("pbac"); + } catch (error) { + console.error("Cleanup error:", error); + } + } catch (error) { + console.error("Cleanup error:", error); + } finally { + await app.close(); + } + }); +}); diff --git a/apps/api/v2/src/modules/organizations/teams/roles/organizations-teams-roles.controller.ts b/apps/api/v2/src/modules/organizations/teams/roles/organizations-teams-roles.controller.ts new file mode 100644 index 0000000000..8b150a9103 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/roles/organizations-teams-roles.controller.ts @@ -0,0 +1,159 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { + OPTIONAL_API_KEY_HEADER, + OPTIONAL_X_CAL_CLIENT_ID_HEADER, + OPTIONAL_X_CAL_SECRET_KEY_HEADER, +} from "@/lib/docs/headers"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard"; +import { CreateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/create-team-role.input"; +import { UpdateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/update-team-role.input"; +import { CreateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/create-team-role.output"; +import { DeleteTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/delete-team-role.output"; +import { GetAllTeamRolesOutput } from "@/modules/organizations/teams/roles/outputs/get-all-team-roles.output"; +import { GetTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/get-team-role.output"; +import { UpdateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/update-team-role.output"; +import { TeamRolesOutputService } from "@/modules/organizations/teams/roles/services/team-roles-output.service"; +import { RolesService } from "@/modules/roles/services/roles.service"; +import { + Controller, + UseGuards, + Get, + Param, + ParseIntPipe, + Query, + Delete, + Patch, + Post, + Body, + HttpCode, + HttpStatus, +} from "@nestjs/common"; +import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { SkipTakePagination } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/organizations/:orgId/teams/:teamId/roles", + version: API_VERSIONS_VALUES, +}) +@UseGuards( + ApiAuthGuard, + IsOrgGuard, + PbacGuard, + RolesGuard, + IsTeamInOrg, + PlatformPlanGuard, + IsAdminAPIEnabledGuard +) +@DocsTags("Orgs / Teams / Roles") +@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER) +@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER) +@ApiHeader(OPTIONAL_API_KEY_HEADER) +export class OrganizationsTeamsRolesController { + constructor( + private readonly rolesService: RolesService, + private readonly rolesOutputService: TeamRolesOutputService + ) {} + + @Roles("ORG_ADMIN") + @PlatformPlan("SCALE") + @Pbac(["role.create"]) + @Post("/") + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: "Create a new organization team role" }) + async createRole( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number, + @Body() body: CreateTeamRoleInput + ): Promise { + const role = await this.rolesService.createRole(teamId, body); + return { + status: SUCCESS_STATUS, + data: this.rolesOutputService.getTeamRoleOutput(role), + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("SCALE") + @Pbac(["role.read"]) + @Get("/:roleId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Get a specific organization team role" }) + async getRole( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number, + @Param("roleId") roleId: string + ): Promise { + const role = await this.rolesService.getRole(teamId, roleId); + return { + status: SUCCESS_STATUS, + data: this.rolesOutputService.getTeamRoleOutput(role), + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("SCALE") + @Pbac(["role.read"]) + @Get("/") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Get all organization team roles" }) + async getAllRoles( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number, + @Query() queryParams: SkipTakePagination + ): Promise { + const { skip, take } = queryParams; + const roles = await this.rolesService.getTeamRoles(teamId, skip ?? 0, take ?? 250); + return { + status: SUCCESS_STATUS, + data: this.rolesOutputService.getTeamRolesOutput(roles), + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("SCALE") + @Pbac(["role.update"]) + @Patch("/:roleId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Update an organization team role" }) + async updateRole( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number, + @Param("roleId") roleId: string, + @Body() body: UpdateTeamRoleInput + ): Promise { + const role = await this.rolesService.updateRole(teamId, roleId, body); + return { + status: SUCCESS_STATUS, + data: this.rolesOutputService.getTeamRoleOutput(role), + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("SCALE") + @Pbac(["role.delete"]) + @Delete("/:roleId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Delete an organization team role" }) + async deleteRole( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number, + @Param("roleId") roleId: string + ): Promise { + const role = await this.rolesService.deleteRole(teamId, roleId); + return { + status: SUCCESS_STATUS, + data: this.rolesOutputService.getTeamRoleOutput(role), + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/teams/roles/organizations-teams-roles.module.ts b/apps/api/v2/src/modules/organizations/teams/roles/organizations-teams-roles.module.ts new file mode 100644 index 0000000000..7321539306 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/roles/organizations-teams-roles.module.ts @@ -0,0 +1,18 @@ +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository"; +import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository"; +import { OrganizationsTeamsRolesPermissionsController } from "@/modules/organizations/teams/roles/permissions/organizations-teams-roles-permissions.controller"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RedisModule } from "@/modules/redis/redis.module"; +import { RolesModule } from "@/modules/roles/roles.module"; +import { StripeModule } from "@/modules/stripe/stripe.module"; +import { Module } from "@nestjs/common"; + +import { OrganizationsTeamsRolesController } from "./organizations-teams-roles.controller"; + +@Module({ + imports: [StripeModule, PrismaModule, RedisModule, MembershipsModule, RolesModule], + providers: [OrganizationsRepository, OrganizationsTeamsRepository], + controllers: [OrganizationsTeamsRolesController, OrganizationsTeamsRolesPermissionsController], +}) +export class OrganizationsTeamsRolesModule {} diff --git a/apps/api/v2/src/modules/organizations/teams/roles/outputs/create-team-role.output.ts b/apps/api/v2/src/modules/organizations/teams/roles/outputs/create-team-role.output.ts new file mode 100644 index 0000000000..77fa4d5f86 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/roles/outputs/create-team-role.output.ts @@ -0,0 +1,20 @@ +import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class CreateTeamRoleOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: TeamRoleOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => TeamRoleOutput) + data!: TeamRoleOutput; +} diff --git a/apps/api/v2/src/modules/organizations/teams/roles/outputs/delete-team-role.output.ts b/apps/api/v2/src/modules/organizations/teams/roles/outputs/delete-team-role.output.ts new file mode 100644 index 0000000000..fae7ca6d23 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/roles/outputs/delete-team-role.output.ts @@ -0,0 +1,20 @@ +import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class DeleteTeamRoleOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: TeamRoleOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => TeamRoleOutput) + data!: TeamRoleOutput; +} diff --git a/apps/api/v2/src/modules/organizations/teams/roles/outputs/get-all-team-roles.output.ts b/apps/api/v2/src/modules/organizations/teams/roles/outputs/get-all-team-roles.output.ts new file mode 100644 index 0000000000..404088d5c9 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/roles/outputs/get-all-team-roles.output.ts @@ -0,0 +1,20 @@ +import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsArray, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetAllTeamRolesOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: [TeamRoleOutput], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TeamRoleOutput) + data!: TeamRoleOutput[]; +} diff --git a/apps/api/v2/src/modules/organizations/teams/roles/outputs/get-team-role.output.ts b/apps/api/v2/src/modules/organizations/teams/roles/outputs/get-team-role.output.ts new file mode 100644 index 0000000000..8a0b52835b --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/roles/outputs/get-team-role.output.ts @@ -0,0 +1,20 @@ +import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetTeamRoleOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: TeamRoleOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => TeamRoleOutput) + data!: TeamRoleOutput; +} diff --git a/apps/api/v2/src/modules/organizations/teams/roles/outputs/team-role.output.ts b/apps/api/v2/src/modules/organizations/teams/roles/outputs/team-role.output.ts new file mode 100644 index 0000000000..98c56d2d3c --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/roles/outputs/team-role.output.ts @@ -0,0 +1,71 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; +import { IsString, IsOptional, IsArray, IsEnum, IsDateString, IsNumber } from "class-validator"; + +import { teamPermissionEnum } from "../inputs/base-team-role.input"; + +enum RoleTypeEnum { + SYSTEM = "SYSTEM", + CUSTOM = "CUSTOM", +} + +type RoleType = keyof typeof RoleTypeEnum; + +export class TeamRoleOutput { + @ApiProperty({ description: "Unique identifier for the role" }) + @IsString() + @Expose() + id!: string; + + @ApiProperty({ description: "Name of the role" }) + @IsString() + @Expose() + name!: string; + + @ApiPropertyOptional({ description: "Color for the role (hex code)" }) + @IsString() + @IsOptional() + @Expose() + color?: string | null; + + @ApiPropertyOptional({ description: "Description of the role" }) + @IsString() + @IsOptional() + @Expose() + description?: string | null; + + @ApiPropertyOptional({ description: "Team ID this role belongs to" }) + @IsNumber() + @IsOptional() + @Expose() + teamId?: number | null; + + @ApiProperty({ + description: "Type of role", + enum: RoleTypeEnum, + }) + @IsEnum(RoleTypeEnum) + @Expose() + type!: RoleType; + + @ApiProperty({ + description: "Permissions assigned to this role in 'resource.action' format.", + enum: teamPermissionEnum, + isArray: true, + example: ["booking.read", "eventType.create"], + }) + @IsArray() + @IsString({ each: true }) + @Expose() + permissions!: string[]; + + @ApiProperty({ description: "When the role was created" }) + @IsDateString() + @Expose() + createdAt!: string; + + @ApiProperty({ description: "When the role was last updated" }) + @IsDateString() + @Expose() + updatedAt!: string; +} diff --git a/apps/api/v2/src/modules/organizations/teams/roles/outputs/update-team-role.output.ts b/apps/api/v2/src/modules/organizations/teams/roles/outputs/update-team-role.output.ts new file mode 100644 index 0000000000..bea1e365d2 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/roles/outputs/update-team-role.output.ts @@ -0,0 +1,20 @@ +import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class UpdateTeamRoleOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: TeamRoleOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => TeamRoleOutput) + data!: TeamRoleOutput; +} diff --git a/apps/api/v2/src/modules/organizations/teams/roles/permissions/inputs/create-team-role-permissions.input.ts b/apps/api/v2/src/modules/organizations/teams/roles/permissions/inputs/create-team-role-permissions.input.ts new file mode 100644 index 0000000000..0ae5a2acee --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/roles/permissions/inputs/create-team-role-permissions.input.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsArray, IsString, Validate } from "class-validator"; + +import type { PermissionString } from "@calcom/platform-libraries/pbac"; + +import { teamPermissionEnum } from "../../inputs/base-team-role.input"; +import { TeamPermissionStringValidator } from "./validators/team-permission-string.validator"; + +export class CreateTeamRolePermissionsInput { + @ApiProperty({ + description: "Permissions to add (format: resource.action)", + enum: teamPermissionEnum, + isArray: true, + example: ["eventType.read", "booking.read"], + }) + @IsArray() + @IsString({ each: true }) + @Validate(TeamPermissionStringValidator, { each: true }) + permissions!: PermissionString[]; +} diff --git a/apps/api/v2/src/modules/organizations/teams/roles/permissions/inputs/delete-team-role-permissions.query.ts b/apps/api/v2/src/modules/organizations/teams/roles/permissions/inputs/delete-team-role-permissions.query.ts new file mode 100644 index 0000000000..12c0af3673 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/roles/permissions/inputs/delete-team-role-permissions.query.ts @@ -0,0 +1,33 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { ArrayNotEmpty, IsArray, IsOptional, IsString, Validate } from "class-validator"; + +import type { PermissionString } from "@calcom/platform-libraries/pbac"; + +import { teamPermissionEnum } from "../../inputs/base-team-role.input"; +import { TeamPermissionStringValidator } from "./validators/team-permission-string.validator"; + +export class DeleteTeamRolePermissionsQuery { + @ApiPropertyOptional({ + description: + "Permissions to remove (format: resource.action). Supports comma-separated values as well as repeated query params.", + example: "?permissions=eventType.read,booking.read", + enum: teamPermissionEnum, + isArray: true, + }) + @IsOptional() + @Transform(({ value }) => { + if (typeof value === "string") { + return value + .split(",") + .map((p: string) => p.trim()) + .filter((p: string) => p.length > 0); + } + return value; + }) + @IsArray() + @ArrayNotEmpty({ message: "permissions cannot be empty." }) + @IsString({ each: true }) + @Validate(TeamPermissionStringValidator, { each: true }) + permissions?: PermissionString[]; +} diff --git a/apps/api/v2/src/modules/organizations/teams/roles/permissions/inputs/validators/team-permission-string.validator.spec.ts b/apps/api/v2/src/modules/organizations/teams/roles/permissions/inputs/validators/team-permission-string.validator.spec.ts new file mode 100644 index 0000000000..d603355df1 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/roles/permissions/inputs/validators/team-permission-string.validator.spec.ts @@ -0,0 +1,88 @@ +import { BadRequestException } from "@nestjs/common"; + +import { TeamPermissionStringValidator } from "./team-permission-string.validator"; + +describe("PermissionStringValidator", () => { + let validator: TeamPermissionStringValidator; + + beforeEach(() => { + validator = new TeamPermissionStringValidator(); + }); + + describe("validate", () => { + it("should return true for valid permission strings", () => { + const validPermissions = [ + "eventType.read", + "eventType.create", + "eventType.update", + "eventType.delete", + "booking.read", + "booking.update", + "role.create", + "role.read", + "role.update", + "role.delete", + "team.read", + "team.invite", + ]; + + validPermissions.forEach((permission) => { + expect(validator.validate(permission)).toBe(true); + }); + }); + + it("should throw for wildcard permission strings", () => { + const wildcardPermissions = ["*", "*.read", "eventType.*"]; + + wildcardPermissions.forEach((permission) => { + expect(() => validator.validate(permission)).toThrow(BadRequestException); + }); + }); + + it("should throw for invalid permission strings", () => { + const invalidPermissions = [ + "invalid", // no dot + "invalid.", // no action + ".invalid", // no resource + "invalid.action", // invalid resource + "eventType.invalid", // invalid action + "event-type.read", // wrong format (should be eventType) + "", // empty string + "eventType..read", // double dot + "eventType.read.extra", // too many parts + ]; + + invalidPermissions.forEach((permission) => { + expect(() => validator.validate(permission)).toThrow(BadRequestException); + }); + }); + + it("should throw for organization-scoped permission strings", () => { + // These are explicitly marked with scope: [Scope.Organization] in permission-registry + const orgScoped = [ + "organization.create", + "organization.read", + "organization.listMembers", + "organization.listMembersPrivate", + "organization.invite", + "organization.remove", + "organization.manageBilling", + "organization.changeMemberRole", + "organization.impersonate", + "organization.update", + ]; + + orgScoped.forEach((permission) => { + expect(() => validator.validate(permission)).toThrow(BadRequestException); + }); + }); + }); + + describe("defaultMessage", () => { + it("should return appropriate error message", () => { + const message = validator.defaultMessage(); + expect(message).toContain("Permission must be a valid permission string"); + expect(message).toContain("resource.action"); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/teams/roles/permissions/inputs/validators/team-permission-string.validator.ts b/apps/api/v2/src/modules/organizations/teams/roles/permissions/inputs/validators/team-permission-string.validator.ts new file mode 100644 index 0000000000..674a623545 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/roles/permissions/inputs/validators/team-permission-string.validator.ts @@ -0,0 +1,23 @@ +import { BadRequestException } from "@nestjs/common"; +import type { ValidatorConstraintInterface } from "class-validator"; +import { ValidatorConstraint } from "class-validator"; + +import { isValidPermissionStringForScope } from "@calcom/platform-libraries/pbac"; +import { Scope } from "@calcom/platform-libraries/pbac"; + +@ValidatorConstraint({ name: "teamPermissionStringValidator", async: false }) +export class TeamPermissionStringValidator implements ValidatorConstraintInterface { + validate(permission: string) { + const isValid = isValidPermissionStringForScope(permission, Scope.Team); + if (!isValid) { + throw new BadRequestException( + `Permission '${permission}' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')` + ); + } + return true; + } + + defaultMessage() { + return "Permission must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"; + } +} diff --git a/apps/api/v2/src/modules/organizations/teams/roles/permissions/organizations-teams-roles-permissions.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/teams/roles/permissions/organizations-teams-roles-permissions.controller.e2e-spec.ts new file mode 100644 index 0000000000..a7a949b94d --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/roles/permissions/organizations-teams-roles-permissions.controller.e2e-spec.ts @@ -0,0 +1,287 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/create-team-role.input"; +import type { CreateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/create-team-role.output"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import * as request from "supertest"; +import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture"; +import { FeaturesRepositoryFixture } from "test/fixtures/repository/features.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { RoleService } from "@calcom/platform-libraries/pbac"; +import type { PermissionString } from "@calcom/platform-libraries/pbac"; +import type { Team, User } from "@calcom/prisma/client"; + +describe("Organizations Teams Roles Permissions Endpoints", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let membershipRepositoryFixture: MembershipRepositoryFixture; + let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; + let featuresRepositoryFixture: FeaturesRepositoryFixture; + let roleService: RoleService; + + let pbacOrgUserWithRolePermission: User; + let pbacOrgUserWithRolePermissionApiKey: string; + + let pbacEnabledOrganization: Team; + let pbacEnabledTeam: Team; + + const pbacUserEmail = `pbac-org-user-with-permissions-${randomString()}@api.com`; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef); + featuresRepositoryFixture = new FeaturesRepositoryFixture(moduleRef); + roleService = new RoleService(); + + // Create PBAC org/team + pbacEnabledOrganization = await organizationsRepositoryFixture.create({ + name: `pbac-org-role-perm-test-${randomString()}`, + isOrganization: true, + }); + + pbacEnabledTeam = await teamRepositoryFixture.create({ + name: `pbac-team-role-perm-test-${randomString()}`, + isOrganization: false, + parent: { connect: { id: pbacEnabledOrganization.id } }, + }); + + await featuresRepositoryFixture.create({ slug: "pbac", enabled: true }); + await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledTeam.id, "pbac"); + + // Create user + membership in org + pbacOrgUserWithRolePermission = await userRepositoryFixture.create({ + email: pbacUserEmail, + username: pbacUserEmail, + }); + + const membership = await membershipRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: pbacOrgUserWithRolePermission.id } }, + team: { connect: { id: pbacEnabledOrganization.id } }, + }); + + // Create a role that allows role.read and role.update, assign to user + const managerRole = await roleService.createRole({ + name: `role-manager-${randomString()}`, + teamId: pbacEnabledOrganization.id, + permissions: ["role.create", "role.read", "role.update"], + type: "CUSTOM", + }); + await roleService.assignRoleToMember(managerRole.id, membership.id); + + // API key for user + const { keyString } = await apiKeysRepositoryFixture.createApiKey(pbacOrgUserWithRolePermission.id, null); + pbacOrgUserWithRolePermissionApiKey = `cal_test_${keyString}`; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + }); + + it("lists permissions for a role (GET /)", async () => { + const initialPermissions = ["booking.read"] as const; + const baseRoleInput: CreateTeamRoleInput = { + name: `perm-target-role-list-${randomString()}`, + permissions: [...initialPermissions], + }; + + const createRes = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(baseRoleInput) + .expect(201); + const responseBody: CreateTeamRoleOutput = createRes.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions); + const roleId = responseBody.data.id; + + const listRes = await request(app.getHttpServer()) + .get( + `/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions` + ) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .expect(200); + expect(listRes.body.status).toEqual(SUCCESS_STATUS); + expect(listRes.body.data).toEqual(initialPermissions); + }); + + it("adds permissions (POST /)", async () => { + const initialPermissions = ["booking.read"] as const; + const toAdd = ["eventType.create", "eventType.read"] as const; + const expected = [...initialPermissions, ...toAdd] as string[]; + + const baseRoleInput: CreateTeamRoleInput = { + name: `perm-target-role-add-${randomString()}`, + permissions: [...initialPermissions], + }; + + const createRes = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(baseRoleInput) + .expect(201); + const responseBody: CreateTeamRoleOutput = createRes.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions); + const roleId = responseBody.data.id; + + const addRes = await request(app.getHttpServer()) + .post( + `/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions` + ) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send({ permissions: toAdd }) + .expect(200); + expect(addRes.body.status).toEqual(SUCCESS_STATUS); + expect(addRes.body.data).toEqual(expected); + }); + + it("bulk removes permissions via query (DELETE /)", async () => { + // Seed role + const initialPermissions: PermissionString[] = ["booking.read", "eventType.create", "eventType.read"]; + const toRemove: PermissionString[] = ["eventType.create", "eventType.read"]; + const expected: PermissionString[] = ["booking.read"]; + + const baseRoleInput: CreateTeamRoleInput = { + name: `perm-target-role-delmany-${randomString()}`, + permissions: initialPermissions, + }; + const createRes = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(baseRoleInput) + .expect(201); + const responseBody: CreateTeamRoleOutput = createRes.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions); + const roleId = responseBody.data.id; + + await request(app.getHttpServer()) + .delete( + `/v2/organizations/${pbacEnabledOrganization.id}/teams/${ + pbacEnabledTeam.id + }/roles/${roleId}/permissions?permissions=${toRemove.join(",")}` + ) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .expect(204); + + const listRes = await request(app.getHttpServer()) + .get( + `/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions` + ) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .expect(200); + expect(listRes.body.data).toEqual(expected); + }); + + it("replaces all permissions (PUT /)", async () => { + const initialPermissions = ["booking.read"] as const; + const replacement = ["booking.read", "eventType.update"] as const; + + const baseRoleInput: CreateTeamRoleInput = { + name: `perm-target-role-put-${randomString()}`, + permissions: [...initialPermissions], + }; + const createRes = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(baseRoleInput) + .expect(201); + const responseBody: CreateTeamRoleOutput = createRes.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions); + const roleId = responseBody.data.id; + + const putRes = await request(app.getHttpServer()) + .put( + `/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions` + ) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send({ permissions: replacement }) + .expect(200); + + expect(putRes.body.status).toEqual(SUCCESS_STATUS); + expect(putRes.body.data).toEqual(replacement); + }); + + it("removes a single permission (DELETE /:permission)", async () => { + const initialPermissions = ["booking.read", "eventType.update"] as const; + const toRemove = "eventType.update" as const; + const expected = ["booking.read"] as const; + + const baseRoleInput: CreateTeamRoleInput = { + name: `perm-target-role-delone-${randomString()}`, + permissions: [...initialPermissions], + }; + const createRes = await request(app.getHttpServer()) + .post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .send(baseRoleInput) + .expect(201); + const responseBody: CreateTeamRoleOutput = createRes.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions); + const roleId = responseBody.data.id; + + await request(app.getHttpServer()) + .delete( + `/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions/${toRemove}` + ) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .expect(204); + + const listRes = await request(app.getHttpServer()) + .get( + `/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions` + ) + .set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`) + .expect(200); + expect(listRes.body.data).toEqual(expected); + }); + + afterAll(async () => { + try { + // Clean up feature flag from team + await featuresRepositoryFixture.deleteTeamFeature(pbacEnabledTeam.id, "pbac"); + + // Clean up teams and orgs + await teamRepositoryFixture.delete(pbacEnabledTeam.id); + await organizationsRepositoryFixture.delete(pbacEnabledOrganization.id); + + // Clean up user + await userRepositoryFixture.deleteByEmail(pbacOrgUserWithRolePermission.email); + + // Clean up feature definition + try { + await featuresRepositoryFixture.deleteBySlug("pbac"); + } catch (err) { + console.log(err); + } + } catch (err) { + console.log(err); + } finally { + await app.close(); + } + }); +}); diff --git a/apps/api/v2/src/modules/organizations/teams/roles/permissions/organizations-teams-roles-permissions.controller.ts b/apps/api/v2/src/modules/organizations/teams/roles/permissions/organizations-teams-roles-permissions.controller.ts new file mode 100644 index 0000000000..f2fc201d9b --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/roles/permissions/organizations-teams-roles-permissions.controller.ts @@ -0,0 +1,144 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { + OPTIONAL_API_KEY_HEADER, + OPTIONAL_X_CAL_CLIENT_ID_HEADER, + OPTIONAL_X_CAL_SECRET_KEY_HEADER, +} from "@/lib/docs/headers"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard"; +import { CreateTeamRolePermissionsInput } from "@/modules/organizations/teams/roles/permissions/inputs/create-team-role-permissions.input"; +import { DeleteTeamRolePermissionsQuery } from "@/modules/organizations/teams/roles/permissions/inputs/delete-team-role-permissions.query"; +import { GetTeamRolePermissionsOutput } from "@/modules/organizations/teams/roles/permissions/outputs/get-team-role-permissions.output"; +import { RolesPermissionsService } from "@/modules/roles/permissions/services/roles-permissions.service"; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseIntPipe, + Put, + Query, + Post, + UseGuards, +} from "@nestjs/common"; +import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { PermissionString } from "@calcom/platform-libraries/pbac"; + +@Controller({ + path: "/v2/organizations/:orgId/teams/:teamId/roles/:roleId/permissions", + version: API_VERSIONS_VALUES, +}) +@UseGuards( + ApiAuthGuard, + IsOrgGuard, + PbacGuard, + RolesGuard, + IsTeamInOrg, + PlatformPlanGuard, + IsAdminAPIEnabledGuard +) +@DocsTags("Orgs / Teams / Roles / Permissions") +@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER) +@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER) +@ApiHeader(OPTIONAL_API_KEY_HEADER) +export class OrganizationsTeamsRolesPermissionsController { + constructor(private readonly rolePermissionsService: RolesPermissionsService) {} + + @Roles("ORG_ADMIN") + @PlatformPlan("SCALE") + @Pbac(["role.update"]) + @Post("/") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Add permissions to an organization team role (single or batch)" }) + async addPermissions( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number, + @Param("roleId") roleId: string, + @Body() body: CreateTeamRolePermissionsInput + ): Promise { + const permissions = await this.rolePermissionsService.addRolePermissions( + teamId, + roleId, + body.permissions + ); + return { status: SUCCESS_STATUS, data: permissions }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("SCALE") + @Pbac(["role.read"]) + @Get("/") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "List permissions for an organization team role" }) + async listPermissions( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number, + @Param("roleId") roleId: string + ): Promise { + const permissions = await this.rolePermissionsService.getRolePermissions(teamId, roleId); + return { status: SUCCESS_STATUS, data: permissions }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("SCALE") + @Pbac(["role.update"]) + @Put("/") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Replace all permissions for an organization team role" }) + async setPermissions( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number, + @Param("roleId") roleId: string, + @Body() body: CreateTeamRolePermissionsInput + ): Promise { + const permissions = await this.rolePermissionsService.setRolePermissions( + teamId, + roleId, + body.permissions || [] + ); + return { status: SUCCESS_STATUS, data: permissions }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("SCALE") + @Pbac(["role.update"]) + @Delete("/:permission") + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: "Remove a permission from an organization team role" }) + async removePermission( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number, + @Param("roleId") roleId: string, + @Param("permission") permission: PermissionString + ): Promise { + await this.rolePermissionsService.removeRolePermission(teamId, roleId, permission); + } + + @Roles("ORG_ADMIN") + @PlatformPlan("SCALE") + @Pbac(["role.update"]) + @Delete("/") + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: "Remove multiple permissions from an organization team role" }) + async removePermissions( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number, + @Param("roleId") roleId: string, + @Query() query: DeleteTeamRolePermissionsQuery + ): Promise { + await this.rolePermissionsService.removeRolePermissions(teamId, roleId, query.permissions || []); + } +} diff --git a/apps/api/v2/src/modules/organizations/teams/roles/permissions/outputs/get-team-role-permissions.output.ts b/apps/api/v2/src/modules/organizations/teams/roles/permissions/outputs/get-team-role-permissions.output.ts new file mode 100644 index 0000000000..0d9297a52e --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/roles/permissions/outputs/get-team-role-permissions.output.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsArray, IsString } from "class-validator"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; + +export class GetTeamRolePermissionsOutput { + @ApiProperty({ example: SUCCESS_STATUS }) + status!: typeof SUCCESS_STATUS; + + @ApiProperty({ type: [String] }) + @IsArray() + @IsString({ each: true }) + data!: string[]; +} diff --git a/apps/api/v2/src/modules/organizations/teams/roles/services/team-roles-output.service.ts b/apps/api/v2/src/modules/organizations/teams/roles/services/team-roles-output.service.ts new file mode 100644 index 0000000000..60bbfb889d --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/roles/services/team-roles-output.service.ts @@ -0,0 +1,25 @@ +import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output"; +import { Injectable } from "@nestjs/common"; + +import type { Role } from "@calcom/platform-libraries/pbac"; + +@Injectable() +export class TeamRolesOutputService { + getTeamRoleOutput(role: Role): TeamRoleOutput { + return { + id: role.id, + name: role.name, + color: role.color || null, + description: role.description || null, + teamId: role.teamId || null, + type: role.type, + permissions: role.permissions.map((permission) => `${permission.resource}.${permission.action}`), + createdAt: role.createdAt.toISOString(), + updatedAt: role.updatedAt.toISOString(), + }; + } + + getTeamRolesOutput(roles: Role[]): TeamRoleOutput[] { + return roles.map((role) => this.getTeamRoleOutput(role)); + } +} diff --git a/apps/api/v2/src/modules/redis/redis.service.ts b/apps/api/v2/src/modules/redis/redis.service.ts index 952ae14918..4958ac9b1e 100644 --- a/apps/api/v2/src/modules/redis/redis.service.ts +++ b/apps/api/v2/src/modules/redis/redis.service.ts @@ -61,7 +61,7 @@ export class RedisService implements OnModuleDestroy { try { return JSON.parse(data) as TData; - } catch (e) { + } catch { return data as TData; } } @@ -78,6 +78,30 @@ export class RedisService implements OnModuleDestroy { } } + async getKeys(pattern: string): Promise { + if (!this.isReady) { + return []; + } + try { + return this.redis.keys(pattern); + } catch (err) { + if (err instanceof Error) this.logger.error(`IoRedis getKeys failed: ${err.message}`); + return []; + } + } + + async delMany(keys: string[]): Promise { + if (!this.isReady || keys.length === 0) { + return 0; + } + try { + return this.redis.del(...keys); + } catch (err) { + if (err instanceof Error) this.logger.error(`IoRedis delMany failed: ${err.message}`); + return 0; + } + } + async set(key: string, value: TData, opts?: { ttl?: number }): Promise<"OK" | TData | null> { if (!this.isReady) { return null; @@ -137,4 +161,16 @@ export class RedisService implements OnModuleDestroy { return 0; } } + + async incr(key: string): Promise { + if (!this.isReady) { + return 0; + } + try { + return this.redis.incr(key); + } catch (err) { + if (err instanceof Error) this.logger.error(`IoRedis incr failed: ${err.message}`); + return 0; + } + } } diff --git a/apps/api/v2/src/modules/roles/permissions/services/roles-permissions-cache.service.ts b/apps/api/v2/src/modules/roles/permissions/services/roles-permissions-cache.service.ts new file mode 100644 index 0000000000..6d0211055b --- /dev/null +++ b/apps/api/v2/src/modules/roles/permissions/services/roles-permissions-cache.service.ts @@ -0,0 +1,28 @@ +import { RedisService } from "@/modules/redis/redis.service"; +import { Injectable, Logger } from "@nestjs/common"; + +export const REDIS_TEAM_PERMISSIONS_CACHE_PATTERN = (teamId: number) => + `apiv2:user:*:team:${teamId}:requiredPermissions:*:guard:pbac`; + +@Injectable() +export class RolesPermissionsCacheService { + private readonly logger = new Logger(RolesPermissionsCacheService.name); + + constructor(private readonly redisService: RedisService) {} + + async deleteTeamPermissionsCache(teamId: number): Promise { + try { + const pattern = REDIS_TEAM_PERMISSIONS_CACHE_PATTERN(teamId); + const keys = await this.redisService.getKeys(pattern); + + if (keys.length > 0) { + await this.redisService.delMany(keys); + this.logger.log(`Deleted ${keys.length} permission cache keys for team ${teamId}`); + } else { + this.logger.log(`No permission cache keys found for team ${teamId}`); + } + } catch (error) { + this.logger.error(`Failed to delete permission cache for team ${teamId}:`, error); + } + } +} diff --git a/apps/api/v2/src/modules/roles/permissions/services/roles-permissions-output.service.ts b/apps/api/v2/src/modules/roles/permissions/services/roles-permissions-output.service.ts new file mode 100644 index 0000000000..ca4d8fd08d --- /dev/null +++ b/apps/api/v2/src/modules/roles/permissions/services/roles-permissions-output.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from "@nestjs/common"; + +import type { PermissionString, Role } from "@calcom/platform-libraries/pbac"; + +@Injectable() +export class RolesPermissionsOutputService { + getPermissionsFromRole(role: Role): PermissionString[] { + return role.permissions.map( + (permission) => `${permission.resource}.${permission.action}` as PermissionString + ); + } +} diff --git a/apps/api/v2/src/modules/roles/permissions/services/roles-permissions.service.ts b/apps/api/v2/src/modules/roles/permissions/services/roles-permissions.service.ts new file mode 100644 index 0000000000..adafce6275 --- /dev/null +++ b/apps/api/v2/src/modules/roles/permissions/services/roles-permissions.service.ts @@ -0,0 +1,172 @@ +import { RolesPermissionsCacheService } from "@/modules/roles/permissions/services/roles-permissions-cache.service"; +import { RolesPermissionsOutputService } from "@/modules/roles/permissions/services/roles-permissions-output.service"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; + +import type { PermissionString, UpdateRolePermissionsData } from "@calcom/platform-libraries/pbac"; +import { RoleService, isValidPermissionString } from "@calcom/platform-libraries/pbac"; + +@Injectable() +export class RolesPermissionsService { + constructor( + private readonly roleService: RoleService, + private readonly rolesPermissionsOutputService: RolesPermissionsOutputService, + private readonly rolesPermissionsCacheService: RolesPermissionsCacheService + ) {} + + async getRolePermissions(teamId: number, roleId: string) { + const belongsToTeam = await this.roleService.roleBelongsToTeam(roleId, teamId); + if (!belongsToTeam) { + throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`); + } + + const role = await this.roleService.getRole(roleId); + if (!role) { + throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`); + } + + return this.rolesPermissionsOutputService.getPermissionsFromRole(role); + } + + async addRolePermissions(teamId: number, roleId: string, permissionsToAdd: PermissionString[]) { + const belongsToTeam = await this.roleService.roleBelongsToTeam(roleId, teamId); + if (!belongsToTeam) { + throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`); + } + + const current = await this.getRolePermissions(teamId, roleId); + const desired = Array.from(new Set([...(current || []), ...(permissionsToAdd || [])])); + + const updateData: UpdateRolePermissionsData = { + roleId, + permissions: desired, + updates: {}, + }; + + try { + const updatedRole = await this.roleService.update(updateData); + + await this.rolesPermissionsCacheService.deleteTeamPermissionsCache(teamId); + + return this.rolesPermissionsOutputService.getPermissionsFromRole(updatedRole); + } catch (error) { + if (error instanceof Error && error.message.includes("Invalid permissions provided")) { + throw new BadRequestException(error.message); + } + throw error; + } + } + + async removeRolePermission(teamId: number, roleId: string, permissionToRemove: PermissionString) { + if (!isValidPermissionString(permissionToRemove)) { + throw new BadRequestException( + `Permission '${permissionToRemove}' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')` + ); + } + const belongsToTeam = await this.roleService.roleBelongsToTeam(roleId, teamId); + if (!belongsToTeam) { + throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`); + } + + const current = await this.getRolePermissions(teamId, roleId); + const desired = (current || []).filter((p) => p !== permissionToRemove); + + const updateData: UpdateRolePermissionsData = { + roleId, + permissions: desired, + updates: {}, + }; + + try { + const updatedRole = await this.roleService.update(updateData); + + await this.rolesPermissionsCacheService.deleteTeamPermissionsCache(teamId); + + return this.rolesPermissionsOutputService.getPermissionsFromRole(updatedRole); + } catch (error) { + if (error instanceof Error) { + if (error.message.includes("Invalid permissions provided")) { + throw new BadRequestException(error.message); + } + if (error.message.includes("Cannot update default roles")) { + throw new BadRequestException(error.message); + } + } + throw error; + } + } + + async removeRolePermissions(teamId: number, roleId: string, permissionsToRemove: PermissionString[]) { + if (permissionsToRemove && permissionsToRemove.length > 0) { + for (const permission of permissionsToRemove) { + if (!isValidPermissionString(permission)) { + throw new BadRequestException( + `Permission '${permission}' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')` + ); + } + } + } + const belongsToTeam = await this.roleService.roleBelongsToTeam(roleId, teamId); + if (!belongsToTeam) { + throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`); + } + + const current = await this.getRolePermissions(teamId, roleId); + const toRemove = new Set(permissionsToRemove || []); + const desired = (current || []).filter((p) => !toRemove.has(p)); + + const updateData: UpdateRolePermissionsData = { + roleId, + permissions: desired, + updates: {}, + }; + + try { + const updatedRole = await this.roleService.update(updateData); + + await this.rolesPermissionsCacheService.deleteTeamPermissionsCache(teamId); + + return this.rolesPermissionsOutputService.getPermissionsFromRole(updatedRole); + } catch (error) { + if (error instanceof Error) { + if (error.message.includes("Invalid permissions provided")) { + throw new BadRequestException(error.message); + } + if (error.message.includes("Cannot update default roles")) { + throw new BadRequestException(error.message); + } + } + throw error; + } + } + + async setRolePermissions(teamId: number, roleId: string, permissions: PermissionString[]) { + const belongsToTeam = await this.roleService.roleBelongsToTeam(roleId, teamId); + if (!belongsToTeam) { + throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`); + } + + const updateData: UpdateRolePermissionsData = { + roleId, + permissions, + updates: {}, + }; + + try { + const updatedRole = await this.roleService.update(updateData); + + await this.rolesPermissionsCacheService.deleteTeamPermissionsCache(teamId); + + return this.rolesPermissionsOutputService.getPermissionsFromRole(updatedRole); + } catch (error) { + if (error instanceof Error) { + if (error.message.includes("Invalid permissions provided")) { + throw new BadRequestException(error.message); + } + if (error.message.includes("Cannot update default roles")) { + throw new BadRequestException(error.message); + } + } + throw error; + } + } +} diff --git a/apps/api/v2/src/modules/roles/roles.module.ts b/apps/api/v2/src/modules/roles/roles.module.ts new file mode 100644 index 0000000000..a39f66eacf --- /dev/null +++ b/apps/api/v2/src/modules/roles/roles.module.ts @@ -0,0 +1,39 @@ +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { OrganizationsRolesOutputService } from "@/modules/organizations/roles/services/organizations-roles-output.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RedisModule } from "@/modules/redis/redis.module"; +import { StripeModule } from "@/modules/stripe/stripe.module"; +import { Module } from "@nestjs/common"; + +import { RoleService } from "@calcom/platform-libraries/pbac"; + +import { TeamRolesOutputService } from "../organizations/teams/roles/services/team-roles-output.service"; +import { RolesPermissionsCacheService } from "./permissions/services/roles-permissions-cache.service"; +import { RolesPermissionsOutputService } from "./permissions/services/roles-permissions-output.service"; +import { RolesPermissionsService } from "./permissions/services/roles-permissions.service"; +import { RolesService } from "./services/roles.service"; + +@Module({ + imports: [StripeModule, PrismaModule, RedisModule, MembershipsModule], + providers: [ + { + provide: RoleService, + useFactory: () => new RoleService(), + }, + RolesService, + TeamRolesOutputService, + OrganizationsRolesOutputService, + RolesPermissionsService, + RolesPermissionsOutputService, + RolesPermissionsCacheService, + ], + exports: [ + RolesService, + TeamRolesOutputService, + OrganizationsRolesOutputService, + RolesPermissionsService, + RolesPermissionsOutputService, + RolesPermissionsCacheService, + ], +}) +export class RolesModule {} diff --git a/apps/api/v2/src/modules/roles/services/roles.service.ts b/apps/api/v2/src/modules/roles/services/roles.service.ts new file mode 100644 index 0000000000..fe2534ed0b --- /dev/null +++ b/apps/api/v2/src/modules/roles/services/roles.service.ts @@ -0,0 +1,143 @@ +import { CreateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/create-team-role.input"; +import { UpdateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/update-team-role.input"; +import { RolesPermissionsCacheService } from "@/modules/roles/permissions/services/roles-permissions-cache.service"; +import { BadRequestException, Injectable, NotFoundException, Logger } from "@nestjs/common"; + +import { RoleService } from "@calcom/platform-libraries/pbac"; +import type { CreateRoleData, UpdateRolePermissionsData } from "@calcom/platform-libraries/pbac"; + +@Injectable() +export class RolesService { + private readonly logger = new Logger(RolesService.name); + constructor( + private readonly rolesService: RoleService, + private readonly rolesPermissionsCacheService: RolesPermissionsCacheService + ) {} + + async createRole(teamId: number, data: CreateTeamRoleInput) { + const createRoleData: CreateRoleData = { + name: data.name, + color: data.color, + description: data.description, + permissions: data.permissions || [], + teamId: teamId, + type: "CUSTOM", + }; + + try { + const role = await this.rolesService.createRole(createRoleData); + + await this.rolesPermissionsCacheService.deleteTeamPermissionsCache(teamId); + + return role; + } catch (error) { + this.logger.error( + `RolesService - createRole failed (teamId=${teamId}, roleName=${data.name}): ${ + error instanceof Error ? `${error.name}: ${error.message}` : String(error) + }`, + error instanceof Error ? error.stack : undefined + ); + if (error instanceof Error) { + if (error.message.includes("already exists")) { + throw new BadRequestException(error.message); + } + // Map permission validation failures from PBAC service + if (error.message.toLowerCase().includes("permission")) { + throw new BadRequestException(error.message); + } + } + throw error; + } + } + + async getRole(teamId: number, roleId: string) { + const role = await this.rolesService.getRole(roleId); + + if (!role || role.teamId !== teamId) { + throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`); + } + + return role; + } + + async getTeamRoles(teamId: number, skip = 0, take = 250) { + const allRoles = await this.rolesService.getTeamRoles(teamId); + + const paginatedRoles = allRoles.slice(skip, skip + take); + + return paginatedRoles; + } + + async updateRole(teamId: number, roleId: string, data: UpdateTeamRoleInput) { + const belongsToTeam = await this.rolesService.roleBelongsToTeam(roleId, teamId); + if (!belongsToTeam) { + throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`); + } + + const updateData: UpdateRolePermissionsData = { + roleId, + permissions: data.permissions, + updates: { + name: data.name, + color: data.color, + }, + }; + + try { + const role = await this.rolesService.update(updateData); + + if (data.permissions) { + await this.rolesPermissionsCacheService.deleteTeamPermissionsCache(teamId); + } + + return role; + } catch (error) { + if (error instanceof Error) { + if (error.message.includes("Role not found")) { + throw new NotFoundException(error.message); + } + if (error.message.includes("Cannot update default roles")) { + throw new BadRequestException(error.message); + } + if ( + error.message.includes("Invalid permissions provided") || + error.message.toLowerCase().includes("permission") + ) { + throw new BadRequestException(error.message); + } + } + throw error; + } + } + + async deleteRole(teamId: number, roleId: string) { + const belongsToTeam = await this.rolesService.roleBelongsToTeam(roleId, teamId); + if (!belongsToTeam) { + throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`); + } + + const role = await this.rolesService.getRole(roleId); + if (!role) { + throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`); + } + + try { + await this.rolesService.deleteRole(roleId); + + await this.rolesPermissionsCacheService.deleteTeamPermissionsCache(teamId); + + return role; + } catch (error) { + if (error instanceof Error) { + if (error.message.includes("Cannot delete default roles")) { + throw new BadRequestException(error.message); + } + + if (error.message.includes("Role not found")) { + throw new NotFoundException(error.message); + } + } + throw error; + } + } +} diff --git a/apps/api/v2/test/fixtures/repository/features.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/features.repository.fixture.ts new file mode 100644 index 0000000000..0216b1baac --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/features.repository.fixture.ts @@ -0,0 +1,71 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; + +import type { Prisma } from "@calcom/prisma/client"; + +export class FeaturesRepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + async create(data: Prisma.FeatureCreateInput) { + // note(Lauris): upserting because this create function is called in multiple tests in parallel and otherwise would lead to unique + // key constraint violation. + return await this.prismaWriteClient.feature.upsert({ + where: { slug: data.slug }, + create: data, + update: {}, + }); + } + + async createTeamFeature(data: Prisma.TeamFeaturesCreateInput) { + return await this.prismaWriteClient.teamFeatures.create({ + data, + }); + } + + async enableFeatureForTeam(teamId: number, featureId: string, assignedBy = "test") { + await this.prismaWriteClient.teamFeatures.upsert({ + where: { + teamId_featureId: { + teamId, + featureId, + }, + }, + create: { + teamId, + featureId, + assignedBy, + }, + update: {}, + }); + } + + async disableFeatureForTeam(teamId: number, featureSlug: string) { + return await this.prismaWriteClient.teamFeatures.deleteMany({ + where: { + teamId, + featureId: featureSlug, + }, + }); + } + + async deleteBySlug(slug: string) { + return await this.prismaWriteClient.feature.delete({ + where: { slug }, + }); + } + + async deleteTeamFeature(teamId: number, featureSlug: string) { + return await this.prismaWriteClient.teamFeatures.deleteMany({ + where: { + teamId, + featureId: featureSlug, + }, + }); + } +} diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 59d344d127..e65cb5a7d9 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -2169,6 +2169,746 @@ "tags": ["Orgs / Memberships"] } }, + "/v2/organizations/{orgId}/roles": { + "post": { + "operationId": "OrganizationsRolesController_createRole", + "summary": "Create a new organization role", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrgRoleInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrgRoleOutput" + } + } + } + } + }, + "tags": ["Orgs / Roles"] + }, + "get": { + "operationId": "OrganizationsRolesController_getAllRoles", + "summary": "Get all organization roles", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "Maximum number of items to return", + "example": 25, + "schema": { + "minimum": 1, + "maximum": 250, + "default": 250, + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "Number of items to skip", + "example": 0, + "schema": { + "minimum": 0, + "default": 0, + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAllOrgRolesOutput" + } + } + } + } + }, + "tags": ["Orgs / Roles"] + } + }, + "/v2/organizations/{orgId}/roles/{roleId}": { + "get": { + "operationId": "OrganizationsRolesController_getRole", + "summary": "Get a specific organization role", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "roleId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrgRoleOutput" + } + } + } + } + }, + "tags": ["Orgs / Roles"] + }, + "patch": { + "operationId": "OrganizationsRolesController_updateRole", + "summary": "Update an organization role", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "roleId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrgRoleInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrgRoleOutput" + } + } + } + } + }, + "tags": ["Orgs / Roles"] + }, + "delete": { + "operationId": "OrganizationsRolesController_deleteRole", + "summary": "Delete an organization role", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "roleId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteOrgRoleOutput" + } + } + } + } + }, + "tags": ["Orgs / Roles"] + } + }, + "/v2/organizations/{orgId}/roles/{roleId}/permissions": { + "post": { + "operationId": "OrganizationsRolesPermissionsController_addPermissions", + "summary": "Add permissions to an organization role (single or batch)", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "roleId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrgRolePermissionsInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrgRolePermissionsOutput" + } + } + } + } + }, + "tags": ["Orgs / Roles / Permissions"] + }, + "get": { + "operationId": "OrganizationsRolesPermissionsController_listPermissions", + "summary": "List permissions for an organization role", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "roleId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrgRolePermissionsOutput" + } + } + } + } + }, + "tags": ["Orgs / Roles / Permissions"] + }, + "put": { + "operationId": "OrganizationsRolesPermissionsController_setPermissions", + "summary": "Replace all permissions for an organization role", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "roleId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrgRolePermissionsInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrgRolePermissionsOutput" + } + } + } + } + }, + "tags": ["Orgs / Roles / Permissions"] + }, + "delete": { + "operationId": "OrganizationsRolesPermissionsController_removePermissions", + "summary": "Remove multiple permissions from an organization role", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "roleId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "permissions", + "required": false, + "in": "query", + "description": "Permissions to remove (format: resource.action). Supports comma-separated values as well as repeated query params.", + "example": "?permissions=eventType.read,booking.read", + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "*.*", + "role.create", + "role.read", + "role.update", + "role.delete", + "eventType.create", + "eventType.read", + "eventType.update", + "eventType.delete", + "team.create", + "team.read", + "team.update", + "team.delete", + "team.invite", + "team.remove", + "team.listMembers", + "team.listMembersPrivate", + "team.changeMemberRole", + "team.impersonate", + "organization.create", + "organization.read", + "organization.listMembers", + "organization.listMembersPrivate", + "organization.invite", + "organization.remove", + "organization.manageBilling", + "organization.changeMemberRole", + "organization.impersonate", + "organization.update", + "booking.read", + "booking.readOrgBookings", + "booking.readRecordings", + "booking.update", + "insights.read", + "workflow.create", + "workflow.read", + "workflow.update", + "workflow.delete", + "organization.attributes.read", + "organization.attributes.update", + "organization.attributes.delete", + "organization.attributes.create", + "routingForm.create", + "routingForm.read", + "routingForm.update", + "routingForm.delete", + "webhook.create", + "webhook.read", + "webhook.update", + "webhook.delete", + "watchlist.create", + "watchlist.read", + "watchlist.update", + "watchlist.delete" + ] + } + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "tags": ["Orgs / Roles / Permissions"] + } + }, + "/v2/organizations/{orgId}/roles/{roleId}/permissions/{permission}": { + "delete": { + "operationId": "OrganizationsRolesPermissionsController_removePermission", + "summary": "Remove a permission from an organization role", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "roleId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "permission", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "tags": ["Orgs / Roles / Permissions"] + } + }, "/v2/organizations/{orgId}/routing-forms": { "get": { "operationId": "OrganizationsRoutingFormsController_getOrganizationRoutingForms", @@ -4868,6 +5608,806 @@ "tags": ["Orgs / Teams / Memberships"] } }, + "/v2/organizations/{orgId}/teams/{teamId}/roles": { + "post": { + "operationId": "OrganizationsTeamsRolesController_createRole", + "summary": "Create a new organization team role", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamRoleInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamRoleOutput" + } + } + } + } + }, + "tags": ["Orgs / Teams / Roles"] + }, + "get": { + "operationId": "OrganizationsTeamsRolesController_getAllRoles", + "summary": "Get all organization team roles", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "Maximum number of items to return", + "example": 25, + "schema": { + "minimum": 1, + "maximum": 250, + "default": 250, + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "Number of items to skip", + "example": 0, + "schema": { + "minimum": 0, + "default": 0, + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAllTeamRolesOutput" + } + } + } + } + }, + "tags": ["Orgs / Teams / Roles"] + } + }, + "/v2/organizations/{orgId}/teams/{teamId}/roles/{roleId}": { + "get": { + "operationId": "OrganizationsTeamsRolesController_getRole", + "summary": "Get a specific organization team role", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "roleId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTeamRoleOutput" + } + } + } + } + }, + "tags": ["Orgs / Teams / Roles"] + }, + "patch": { + "operationId": "OrganizationsTeamsRolesController_updateRole", + "summary": "Update an organization team role", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "roleId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTeamRoleInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTeamRoleOutput" + } + } + } + } + }, + "tags": ["Orgs / Teams / Roles"] + }, + "delete": { + "operationId": "OrganizationsTeamsRolesController_deleteRole", + "summary": "Delete an organization team role", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "roleId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteTeamRoleOutput" + } + } + } + } + }, + "tags": ["Orgs / Teams / Roles"] + } + }, + "/v2/organizations/{orgId}/teams/{teamId}/roles/{roleId}/permissions": { + "post": { + "operationId": "OrganizationsTeamsRolesPermissionsController_addPermissions", + "summary": "Add permissions to an organization team role (single or batch)", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "roleId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamRolePermissionsInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTeamRolePermissionsOutput" + } + } + } + } + }, + "tags": ["Orgs / Teams / Roles / Permissions"] + }, + "get": { + "operationId": "OrganizationsTeamsRolesPermissionsController_listPermissions", + "summary": "List permissions for an organization team role", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "roleId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTeamRolePermissionsOutput" + } + } + } + } + }, + "tags": ["Orgs / Teams / Roles / Permissions"] + }, + "put": { + "operationId": "OrganizationsTeamsRolesPermissionsController_setPermissions", + "summary": "Replace all permissions for an organization team role", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "roleId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamRolePermissionsInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTeamRolePermissionsOutput" + } + } + } + } + }, + "tags": ["Orgs / Teams / Roles / Permissions"] + }, + "delete": { + "operationId": "OrganizationsTeamsRolesPermissionsController_removePermissions", + "summary": "Remove multiple permissions from an organization team role", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "roleId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "permissions", + "required": false, + "in": "query", + "description": "Permissions to remove (format: resource.action). Supports comma-separated values as well as repeated query params.", + "example": "?permissions=eventType.read,booking.read", + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "role.create", + "role.read", + "role.update", + "role.delete", + "eventType.create", + "eventType.read", + "eventType.update", + "eventType.delete", + "team.read", + "team.update", + "team.delete", + "team.invite", + "team.remove", + "team.listMembers", + "team.listMembersPrivate", + "team.changeMemberRole", + "team.impersonate", + "booking.read", + "booking.readTeamBookings", + "booking.readRecordings", + "booking.update", + "insights.read", + "workflow.create", + "workflow.read", + "workflow.update", + "workflow.delete", + "routingForm.create", + "routingForm.read", + "routingForm.update", + "routingForm.delete", + "webhook.create", + "webhook.read", + "webhook.update", + "webhook.delete" + ] + } + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "tags": ["Orgs / Teams / Roles / Permissions"] + } + }, + "/v2/organizations/{orgId}/teams/{teamId}/roles/{roleId}/permissions/{permission}": { + "delete": { + "operationId": "OrganizationsTeamsRolesPermissionsController_removePermission", + "summary": "Remove a permission from an organization team role", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "roleId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "permission", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "tags": ["Orgs / Teams / Roles / Permissions"] + } + }, "/v2/organizations/{orgId}/teams/{teamId}/routing-forms": { "get": { "operationId": "OrganizationsTeamsRoutingFormsController_getTeamRoutingForms", @@ -24607,6 +26147,786 @@ } } }, + "CreateOrgRoleInput": { + "type": "object", + "properties": { + "color": { + "type": "string", + "description": "Color for the role (hex code)" + }, + "description": { + "type": "string", + "description": "Description of the role" + }, + "permissions": { + "type": "array", + "description": "Permissions for this role (format: resource.action). On update, this field replaces the entire permission set for the role (full replace). Use granular permission endpoints for one-by-one changes.", + "example": ["eventType.read", "eventType.create", "booking.read"], + "items": { + "type": "string", + "enum": [ + "*.*", + "role.create", + "role.read", + "role.update", + "role.delete", + "eventType.create", + "eventType.read", + "eventType.update", + "eventType.delete", + "team.create", + "team.read", + "team.update", + "team.delete", + "team.invite", + "team.remove", + "team.listMembers", + "team.listMembersPrivate", + "team.changeMemberRole", + "team.impersonate", + "organization.create", + "organization.read", + "organization.listMembers", + "organization.listMembersPrivate", + "organization.invite", + "organization.remove", + "organization.manageBilling", + "organization.changeMemberRole", + "organization.impersonate", + "organization.update", + "booking.read", + "booking.readOrgBookings", + "booking.readRecordings", + "booking.update", + "insights.read", + "workflow.create", + "workflow.read", + "workflow.update", + "workflow.delete", + "organization.attributes.read", + "organization.attributes.update", + "organization.attributes.delete", + "organization.attributes.create", + "routingForm.create", + "routingForm.read", + "routingForm.update", + "routingForm.delete", + "webhook.create", + "webhook.read", + "webhook.update", + "webhook.delete", + "watchlist.create", + "watchlist.read", + "watchlist.update", + "watchlist.delete" + ] + } + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Name of the role" + } + }, + "required": ["name"] + }, + "OrgRoleOutput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the role" + }, + "name": { + "type": "string", + "description": "Name of the role" + }, + "color": { + "type": "string", + "nullable": true, + "description": "Color for the role (hex code)" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Description of the role" + }, + "organizationId": { + "type": "number", + "nullable": true, + "description": "Organization ID this role belongs to" + }, + "type": { + "type": "string", + "description": "Type of role", + "enum": ["SYSTEM", "CUSTOM"] + }, + "permissions": { + "type": "array", + "description": "Permissions assigned to this role in 'resource.action' format.", + "example": ["booking.read", "eventType.create"], + "items": { + "type": "string", + "enum": [ + "*.*", + "role.create", + "role.read", + "role.update", + "role.delete", + "eventType.create", + "eventType.read", + "eventType.update", + "eventType.delete", + "team.create", + "team.read", + "team.update", + "team.delete", + "team.invite", + "team.remove", + "team.listMembers", + "team.listMembersPrivate", + "team.changeMemberRole", + "team.impersonate", + "organization.create", + "organization.read", + "organization.listMembers", + "organization.listMembersPrivate", + "organization.invite", + "organization.remove", + "organization.manageBilling", + "organization.changeMemberRole", + "organization.impersonate", + "organization.update", + "booking.read", + "booking.readOrgBookings", + "booking.readRecordings", + "booking.update", + "insights.read", + "workflow.create", + "workflow.read", + "workflow.update", + "workflow.delete", + "organization.attributes.read", + "organization.attributes.update", + "organization.attributes.delete", + "organization.attributes.create", + "routingForm.create", + "routingForm.read", + "routingForm.update", + "routingForm.delete", + "webhook.create", + "webhook.read", + "webhook.update", + "webhook.delete", + "watchlist.create", + "watchlist.read", + "watchlist.update", + "watchlist.delete" + ] + } + }, + "createdAt": { + "type": "string", + "description": "When the role was created" + }, + "updatedAt": { + "type": "string", + "description": "When the role was last updated" + } + }, + "required": ["id", "name", "type", "permissions", "createdAt", "updatedAt"] + }, + "CreateOrgRoleOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "$ref": "#/components/schemas/OrgRoleOutput" + } + }, + "required": ["status", "data"] + }, + "GetOrgRoleOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "$ref": "#/components/schemas/OrgRoleOutput" + } + }, + "required": ["status", "data"] + }, + "GetAllOrgRolesOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrgRoleOutput" + } + } + }, + "required": ["status", "data"] + }, + "UpdateOrgRoleInput": { + "type": "object", + "properties": { + "color": { + "type": "string", + "description": "Color for the role (hex code)" + }, + "description": { + "type": "string", + "description": "Description of the role" + }, + "permissions": { + "type": "array", + "description": "Permissions for this role (format: resource.action). On update, this field replaces the entire permission set for the role (full replace). Use granular permission endpoints for one-by-one changes.", + "example": ["eventType.read", "eventType.create", "booking.read"], + "items": { + "type": "string", + "enum": [ + "*.*", + "role.create", + "role.read", + "role.update", + "role.delete", + "eventType.create", + "eventType.read", + "eventType.update", + "eventType.delete", + "team.create", + "team.read", + "team.update", + "team.delete", + "team.invite", + "team.remove", + "team.listMembers", + "team.listMembersPrivate", + "team.changeMemberRole", + "team.impersonate", + "organization.create", + "organization.read", + "organization.listMembers", + "organization.listMembersPrivate", + "organization.invite", + "organization.remove", + "organization.manageBilling", + "organization.changeMemberRole", + "organization.impersonate", + "organization.update", + "booking.read", + "booking.readOrgBookings", + "booking.readRecordings", + "booking.update", + "insights.read", + "workflow.create", + "workflow.read", + "workflow.update", + "workflow.delete", + "organization.attributes.read", + "organization.attributes.update", + "organization.attributes.delete", + "organization.attributes.create", + "routingForm.create", + "routingForm.read", + "routingForm.update", + "routingForm.delete", + "webhook.create", + "webhook.read", + "webhook.update", + "webhook.delete", + "watchlist.create", + "watchlist.read", + "watchlist.update", + "watchlist.delete" + ] + } + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Name of the role" + } + } + }, + "UpdateOrgRoleOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "$ref": "#/components/schemas/OrgRoleOutput" + } + }, + "required": ["status", "data"] + }, + "DeleteOrgRoleOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "$ref": "#/components/schemas/OrgRoleOutput" + } + }, + "required": ["status", "data"] + }, + "CreateOrgRolePermissionsInput": { + "type": "object", + "properties": { + "permissions": { + "type": "array", + "description": "Permissions to add (format: resource.action)", + "example": ["eventType.read", "booking.read"], + "items": { + "type": "string", + "enum": [ + "*.*", + "role.create", + "role.read", + "role.update", + "role.delete", + "eventType.create", + "eventType.read", + "eventType.update", + "eventType.delete", + "team.create", + "team.read", + "team.update", + "team.delete", + "team.invite", + "team.remove", + "team.listMembers", + "team.listMembersPrivate", + "team.changeMemberRole", + "team.impersonate", + "organization.create", + "organization.read", + "organization.listMembers", + "organization.listMembersPrivate", + "organization.invite", + "organization.remove", + "organization.manageBilling", + "organization.changeMemberRole", + "organization.impersonate", + "organization.update", + "booking.read", + "booking.readOrgBookings", + "booking.readRecordings", + "booking.update", + "insights.read", + "workflow.create", + "workflow.read", + "workflow.update", + "workflow.delete", + "organization.attributes.read", + "organization.attributes.update", + "organization.attributes.delete", + "organization.attributes.create", + "routingForm.create", + "routingForm.read", + "routingForm.update", + "routingForm.delete", + "webhook.create", + "webhook.read", + "webhook.update", + "webhook.delete", + "watchlist.create", + "watchlist.read", + "watchlist.update", + "watchlist.delete" + ] + } + } + }, + "required": ["permissions"] + }, + "GetOrgRolePermissionsOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success" + }, + "data": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["status", "data"] + }, + "CreateTeamRoleInput": { + "type": "object", + "properties": { + "color": { + "type": "string", + "description": "Color for the role (hex code)" + }, + "description": { + "type": "string", + "description": "Description of the role" + }, + "permissions": { + "type": "array", + "description": "Permissions for this role (format: resource.action). On update, this field replaces the entire permission set for the role (full replace). Use granular permission endpoints for one-by-one changes.", + "example": ["eventType.read", "eventType.create", "booking.read"], + "items": { + "type": "string", + "enum": [ + "role.create", + "role.read", + "role.update", + "role.delete", + "eventType.create", + "eventType.read", + "eventType.update", + "eventType.delete", + "team.read", + "team.update", + "team.delete", + "team.invite", + "team.remove", + "team.listMembers", + "team.listMembersPrivate", + "team.changeMemberRole", + "team.impersonate", + "booking.read", + "booking.readTeamBookings", + "booking.readRecordings", + "booking.update", + "insights.read", + "workflow.create", + "workflow.read", + "workflow.update", + "workflow.delete", + "routingForm.create", + "routingForm.read", + "routingForm.update", + "routingForm.delete", + "webhook.create", + "webhook.read", + "webhook.update", + "webhook.delete" + ] + } + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Name of the role" + } + }, + "required": ["name"] + }, + "TeamRoleOutput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the role" + }, + "name": { + "type": "string", + "description": "Name of the role" + }, + "color": { + "type": "string", + "nullable": true, + "description": "Color for the role (hex code)" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Description of the role" + }, + "teamId": { + "type": "number", + "nullable": true, + "description": "Team ID this role belongs to" + }, + "type": { + "type": "string", + "description": "Type of role", + "enum": ["SYSTEM", "CUSTOM"] + }, + "permissions": { + "type": "array", + "description": "Permissions assigned to this role in 'resource.action' format.", + "example": ["booking.read", "eventType.create"], + "items": { + "type": "string", + "enum": [ + "role.create", + "role.read", + "role.update", + "role.delete", + "eventType.create", + "eventType.read", + "eventType.update", + "eventType.delete", + "team.read", + "team.update", + "team.delete", + "team.invite", + "team.remove", + "team.listMembers", + "team.listMembersPrivate", + "team.changeMemberRole", + "team.impersonate", + "booking.read", + "booking.readTeamBookings", + "booking.readRecordings", + "booking.update", + "insights.read", + "workflow.create", + "workflow.read", + "workflow.update", + "workflow.delete", + "routingForm.create", + "routingForm.read", + "routingForm.update", + "routingForm.delete", + "webhook.create", + "webhook.read", + "webhook.update", + "webhook.delete" + ] + } + }, + "createdAt": { + "type": "string", + "description": "When the role was created" + }, + "updatedAt": { + "type": "string", + "description": "When the role was last updated" + } + }, + "required": ["id", "name", "type", "permissions", "createdAt", "updatedAt"] + }, + "CreateTeamRoleOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "$ref": "#/components/schemas/TeamRoleOutput" + } + }, + "required": ["status", "data"] + }, + "GetTeamRoleOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "$ref": "#/components/schemas/TeamRoleOutput" + } + }, + "required": ["status", "data"] + }, + "GetAllTeamRolesOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamRoleOutput" + } + } + }, + "required": ["status", "data"] + }, + "UpdateTeamRoleInput": { + "type": "object", + "properties": { + "color": { + "type": "string", + "description": "Color for the role (hex code)" + }, + "description": { + "type": "string", + "description": "Description of the role" + }, + "permissions": { + "type": "array", + "description": "Permissions for this role (format: resource.action). On update, this field replaces the entire permission set for the role (full replace). Use granular permission endpoints for one-by-one changes.", + "example": ["eventType.read", "eventType.create", "booking.read"], + "items": { + "type": "string", + "enum": [ + "role.create", + "role.read", + "role.update", + "role.delete", + "eventType.create", + "eventType.read", + "eventType.update", + "eventType.delete", + "team.read", + "team.update", + "team.delete", + "team.invite", + "team.remove", + "team.listMembers", + "team.listMembersPrivate", + "team.changeMemberRole", + "team.impersonate", + "booking.read", + "booking.readTeamBookings", + "booking.readRecordings", + "booking.update", + "insights.read", + "workflow.create", + "workflow.read", + "workflow.update", + "workflow.delete", + "routingForm.create", + "routingForm.read", + "routingForm.update", + "routingForm.delete", + "webhook.create", + "webhook.read", + "webhook.update", + "webhook.delete" + ] + } + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Name of the role" + } + } + }, + "UpdateTeamRoleOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "$ref": "#/components/schemas/TeamRoleOutput" + } + }, + "required": ["status", "data"] + }, + "DeleteTeamRoleOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "$ref": "#/components/schemas/TeamRoleOutput" + } + }, + "required": ["status", "data"] + }, + "CreateTeamRolePermissionsInput": { + "type": "object", + "properties": { + "permissions": { + "type": "array", + "description": "Permissions to add (format: resource.action)", + "example": ["eventType.read", "booking.read"], + "items": { + "type": "string", + "enum": [ + "role.create", + "role.read", + "role.update", + "role.delete", + "eventType.create", + "eventType.read", + "eventType.update", + "eventType.delete", + "team.read", + "team.update", + "team.delete", + "team.invite", + "team.remove", + "team.listMembers", + "team.listMembersPrivate", + "team.changeMemberRole", + "team.impersonate", + "booking.read", + "booking.readTeamBookings", + "booking.readRecordings", + "booking.update", + "insights.read", + "workflow.create", + "workflow.read", + "workflow.update", + "workflow.delete", + "routingForm.create", + "routingForm.read", + "routingForm.update", + "routingForm.delete", + "webhook.create", + "webhook.read", + "webhook.update", + "webhook.delete" + ] + } + } + }, + "required": ["permissions"] + }, + "GetTeamRolePermissionsOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success" + }, + "data": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["status", "data"] + }, "RoutingFormResponseOutput": { "type": "object", "properties": { diff --git a/packages/features/pbac/domain/models/Role.ts b/packages/features/pbac/domain/models/Role.ts index f7a1543360..decbbe3c79 100644 --- a/packages/features/pbac/domain/models/Role.ts +++ b/packages/features/pbac/domain/models/Role.ts @@ -39,7 +39,7 @@ export interface CreateRoleData { export interface UpdateRolePermissionsData { roleId: string; - permissions: PermissionString[]; + permissions?: PermissionString[]; updates?: { color?: string; name?: string; diff --git a/packages/features/pbac/domain/types/permission-registry.ts b/packages/features/pbac/domain/types/permission-registry.ts index 7511b7ae85..90efdbd3ce 100644 --- a/packages/features/pbac/domain/types/permission-registry.ts +++ b/packages/features/pbac/domain/types/permission-registry.ts @@ -168,6 +168,25 @@ export const getPermissionsForScope = (scope: Scope, isPrivate?: boolean): Permi return filteredRegistry as PermissionRegistry; }; +export const getAllPermissionStringsForScope = (scope: Scope): PermissionString[] => { + const registry = getPermissionsForScope(scope); + return Object.entries(registry).flatMap(([resource, config]) => + Object.keys(config) + .filter((k) => k !== "_resource") + .map((action) => `${resource}.${action}` as PermissionString) + ); +}; + +const getPermissionSetForScope = (scope: Scope): Set => { + return new Set(getAllPermissionStringsForScope(scope)); +}; + +export const isValidPermissionStringForScope = (val: unknown, scope: Scope): val is PermissionString => { + if (!isValidPermissionString(val)) return false; + const allowed = getPermissionSetForScope(scope); + return allowed.has(val as PermissionString); +}; + // Keep in mind these are on a team/organization level, not a user level export const PERMISSION_REGISTRY: PermissionRegistry = { [Resource.All]: { @@ -514,12 +533,14 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { category: "attributes", i18nKey: "pbac_action_read", descriptionI18nKey: "pbac_desc_view_organization_attributes", + scope: [Scope.Organization], }, [CrudAction.Update]: { description: "Update organization attributes", category: "attributes", i18nKey: "pbac_action_update", descriptionI18nKey: "pbac_desc_update_organization_attributes", + scope: [Scope.Organization], dependsOn: ["organization.attributes.read"], }, [CrudAction.Delete]: { @@ -527,6 +548,7 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { category: "attributes", i18nKey: "pbac_action_delete", descriptionI18nKey: "pbac_desc_delete_organization_attributes", + scope: [Scope.Organization], dependsOn: ["organization.attributes.read"], }, [CrudAction.Create]: { @@ -534,6 +556,7 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { category: "attributes", i18nKey: "pbac_action_create", descriptionI18nKey: "pbac_desc_create_organization_attributes", + scope: [Scope.Organization], dependsOn: ["organization.attributes.read"], }, }, diff --git a/packages/features/pbac/services/role.service.ts b/packages/features/pbac/services/role.service.ts index 2eecce7ab3..30375e64fe 100644 --- a/packages/features/pbac/services/role.service.ts +++ b/packages/features/pbac/services/role.service.ts @@ -91,14 +91,8 @@ export class RoleService { if (role.type === DomainRoleType.SYSTEM) { throw new Error("Cannot update default roles"); } - // Validate permissions - const validationResult = this.permissionService.validatePermissions(data.permissions); - if (!validationResult.isValid) { - throw new Error(validationResult.error || "Invalid permissions provided"); - } - const existingPermissions = await this.repository.getPermissions(data.roleId); - const permissionChanges = this.permissionDiffService.calculateDiff(data.permissions, existingPermissions); + const permissionChanges = await this.getUpdatePermissionChanges(data); return this.repository.update(data.roleId, permissionChanges, { color: data.updates?.color, @@ -106,6 +100,25 @@ export class RoleService { }); } + private async getUpdatePermissionChanges(data: UpdateRolePermissionsData) { + if (!data.permissions) { + return { + toAdd: [], + toRemove: [], + }; + } + + const validationResult = this.permissionService.validatePermissions(data.permissions); + + if (!validationResult.isValid) { + throw new Error(validationResult.error || "Invalid permissions provided"); + } + + const existingPermissions = await this.repository.getPermissions(data.roleId); + const permissionChanges = this.permissionDiffService.calculateDiff(data.permissions, existingPermissions); + return permissionChanges; + } + async roleBelongsToTeam(roleId: string, teamId: number) { return this.repository.roleBelongsToTeam(roleId, teamId); } diff --git a/packages/platform/libraries/package.json b/packages/platform/libraries/package.json index 1e75dc1ea9..4eb67fb3cf 100644 --- a/packages/platform/libraries/package.json +++ b/packages/platform/libraries/package.json @@ -83,6 +83,11 @@ "import": "./dist/private-links.js", "require": "./dist/private-links.cjs", "types": "./dist/private-links.d.ts" + }, + "./pbac": { + "import": "./dist/pbac.js", + "require": "./dist/pbac.cjs", + "types": "./dist/pbac.d.ts" } }, "typesVersions": { @@ -116,6 +121,9 @@ ], "private-links": [ "dist/private-links.d.ts" + ], + "pbac": [ + "dist/pbac.d.ts" ] } } diff --git a/packages/platform/libraries/pbac.ts b/packages/platform/libraries/pbac.ts new file mode 100644 index 0000000000..649eb5797c --- /dev/null +++ b/packages/platform/libraries/pbac.ts @@ -0,0 +1,10 @@ +export type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry"; +export { Scope } from "@calcom/features/pbac/domain/types/permission-registry"; +export { getAllPermissionStringsForScope } from "@calcom/features/pbac/domain/types/permission-registry"; +export { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; +export { FeaturesRepository } from "@calcom/features/flags/features.repository"; +export { RoleService } from "@calcom/features/pbac/services/role.service"; +export type { Role } from "@calcom/features/pbac/domain/models/Role"; +export type { CreateRoleData, UpdateRolePermissionsData } from "@calcom/features/pbac/domain/models/Role"; +export { isValidPermissionString } from "@calcom/features/pbac/domain/types/permission-registry"; +export { isValidPermissionStringForScope } from "@calcom/features/pbac/domain/types/permission-registry"; diff --git a/packages/platform/libraries/vite.config.js b/packages/platform/libraries/vite.config.js index da6a1981ef..d975221306 100644 --- a/packages/platform/libraries/vite.config.js +++ b/packages/platform/libraries/vite.config.js @@ -3,9 +3,14 @@ import react from "@vitejs/plugin-react"; import { resolve } from "path"; import path from "path"; +import { dirname } from "path"; +import { fileURLToPath } from "url"; import { defineConfig } from "vite"; import dts from "vite-plugin-dts"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + // https://vitejs.dev/guide/build.html#library-mode export default defineConfig({ define: { @@ -32,6 +37,7 @@ export default defineConfig({ repositories: resolve(__dirname, "./repositories.ts"), bookings: resolve(__dirname, "./bookings.ts"), "private-links": resolve(__dirname, "./private-links.ts"), + pbac: resolve(__dirname, "./pbac.ts"), }, name: "calcom-lib", fileName: "calcom-lib",