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
This commit is contained in:
Lauris Skraucis
2025-11-06 17:14:55 +00:00
committed by GitHub
parent 2e8b89c4ee
commit 45278a2195
59 changed files with 6329 additions and 12 deletions
@@ -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],
@@ -0,0 +1,5 @@
import { Reflector } from "@nestjs/core";
import type { PermissionString } from "@calcom/platform-libraries/pbac";
export const Pbac = Reflector.createDecorator<PermissionString[]>();
@@ -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<boolean> {
const request = context.switchToHttp().getRequest<Request & { pbacAuthorizedRequest?: boolean }>();
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<boolean | null> {
return this.redisService.get<boolean>(
REDIS_REQUIRED_PERMISSIONS_CACHE_KEY(userId, teamId, requiredPermissions)
);
}
private async setCacheRequiredPermissions(
userId: number,
teamId: number,
requiredPermissions: PermissionString[],
hasRequired: boolean
): Promise<void> {
await this.redisService.set<boolean>(
REDIS_REQUIRED_PERMISSIONS_CACHE_KEY(userId, teamId, requiredPermissions),
hasRequired,
{ ttl: 300_000 }
);
}
private async getCachePbacEnabled(teamId: number) {
const cachedResult = await this.redisService.get<boolean>(REDIS_PBAC_CACHE_KEY(teamId));
return cachedResult;
}
private async setCachePbacEnabled(teamId: number, pbacEnabled: boolean) {
await this.redisService.set<boolean>(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);
}
}
@@ -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<boolean> {
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
const request = context.switchToHttp().getRequest<Request & { pbacAuthorizedRequest?: boolean }>();
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;
@@ -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,
@@ -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[];
}
@@ -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;
}
@@ -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;
}
@@ -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();
}
});
});
@@ -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<CreateOrgRoleOutput> {
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<GetOrgRoleOutput> {
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<GetAllOrgRolesOutput> {
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<UpdateOrgRoleOutput> {
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<DeleteOrgRoleOutput> {
const role = await this.rolesService.deleteRole(orgId, roleId);
return {
status: SUCCESS_STATUS,
data: this.rolesOutputService.getOrganizationRoleOutput(role),
};
}
}
@@ -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 {}
@@ -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;
}
@@ -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;
}
@@ -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[];
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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[];
}
@@ -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[];
}
@@ -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");
});
});
});
@@ -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')";
}
}
@@ -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();
}
});
});
@@ -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<GetOrgRolePermissionsOutput> {
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<GetOrgRolePermissionsOutput> {
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<GetOrgRolePermissionsOutput> {
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<void> {
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<void> {
await this.rolePermissionsService.removeRolePermissions(orgId, roleId, query.permissions || []);
}
}
@@ -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[];
}
@@ -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));
}
}
@@ -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[];
}
@@ -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;
}
@@ -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;
}
@@ -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();
}
});
});
@@ -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<CreateTeamRoleOutput> {
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<GetTeamRoleOutput> {
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<GetAllTeamRolesOutput> {
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<UpdateTeamRoleOutput> {
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<DeleteTeamRoleOutput> {
const role = await this.rolesService.deleteRole(teamId, roleId);
return {
status: SUCCESS_STATUS,
data: this.rolesOutputService.getTeamRoleOutput(role),
};
}
}
@@ -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 {}
@@ -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;
}
@@ -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;
}
@@ -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[];
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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[];
}
@@ -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[];
}
@@ -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");
});
});
});
@@ -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')";
}
}
@@ -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();
}
});
});
@@ -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<GetTeamRolePermissionsOutput> {
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<GetTeamRolePermissionsOutput> {
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<GetTeamRolePermissionsOutput> {
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<void> {
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<void> {
await this.rolePermissionsService.removeRolePermissions(teamId, roleId, query.permissions || []);
}
}
@@ -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[];
}
@@ -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));
}
}
+37 -1
View File
@@ -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<string[]> {
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<number> {
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<TData>(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<number> {
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;
}
}
}
@@ -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<void> {
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);
}
}
}
@@ -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
);
}
}
@@ -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;
}
}
}
@@ -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 {}
@@ -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;
}
}
}
@@ -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,
},
});
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -39,7 +39,7 @@ export interface CreateRoleData {
export interface UpdateRolePermissionsData {
roleId: string;
permissions: PermissionString[];
permissions?: PermissionString[];
updates?: {
color?: string;
name?: string;
@@ -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<PermissionString> => {
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"],
},
},
@@ -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);
}
+8
View File
@@ -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"
]
}
}
+10
View File
@@ -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";
@@ -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",