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:
@@ -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;
|
||||
}
|
||||
+638
@@ -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;
|
||||
}
|
||||
+20
@@ -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[];
|
||||
}
|
||||
+33
@@ -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[];
|
||||
}
|
||||
+71
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
+23
@@ -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')";
|
||||
}
|
||||
}
|
||||
+395
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
+126
@@ -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 || []);
|
||||
}
|
||||
}
|
||||
+14
@@ -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[];
|
||||
}
|
||||
+26
@@ -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;
|
||||
}
|
||||
+469
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
+159
@@ -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;
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
+20
@@ -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[];
|
||||
}
|
||||
+33
@@ -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[];
|
||||
}
|
||||
+88
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
+23
@@ -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')";
|
||||
}
|
||||
}
|
||||
+287
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
+144
@@ -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 || []);
|
||||
}
|
||||
}
|
||||
+14
@@ -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[];
|
||||
}
|
||||
+25
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user