Files
calendar/packages/features/pbac/services/pbac-role-manager.service.ts
sean-brydonGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
e3742dbc66 fix: Always enforce one owner with pbac (#24144)
* Always enforce one owner

* updates from merge

* remove redudant eslint rules

* Update packages/features/pbac/services/pbac-role-manager.service.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-10-01 11:06:18 +01:00

146 lines
4.7 KiB
TypeScript

import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { RoleManagementError, RoleManagementErrorCode } from "../domain/errors/role-management.error";
import { DEFAULT_ROLE_IDS } from "../lib/constants";
import { PermissionCheckService } from "./permission-check.service";
import type { IRoleManager } from "./role-manager.interface";
import { RoleService } from "./role.service";
export class PBACRoleManager implements IRoleManager {
public isPBACEnabled = true;
constructor(
private readonly roleService: RoleService,
private readonly permissionCheckService: PermissionCheckService
) {}
async checkPermissionToChangeRole(
userId: number,
targetId: number,
scope: "org" | "team",
_memberId?: number,
_newRole?: MembershipRole | string
): Promise<void> {
const hasPermission = await this.permissionCheckService.checkPermission({
userId,
teamId: targetId,
permission: scope === "team" ? "team.changeMemberRole" : "organization.changeMemberRole",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (!hasPermission) {
throw new RoleManagementError(
"You do not have permission to change roles",
RoleManagementErrorCode.UNAUTHORIZED
);
}
}
private async validateNotLastOwner(
organizationId: number,
membershipId: number,
newRole: MembershipRole | string
): Promise<void> {
// Get current membership details
const currentMembership = await prisma.membership.findUnique({
where: { id: membershipId },
select: {
id: true,
customRoleId: true,
role: true,
teamId: true,
},
});
if (!currentMembership) {
throw new RoleManagementError("Membership not found", RoleManagementErrorCode.UNAUTHORIZED);
}
// Check if the current membership has owner role (either through customRoleId or role field)
const hasOwnerRole =
currentMembership.customRoleId === DEFAULT_ROLE_IDS[MembershipRole.OWNER] ||
currentMembership.role === MembershipRole.OWNER;
// If current membership is not an owner, no validation needed
if (!hasOwnerRole) {
return;
}
// Check if new role is still owner - if so, no validation needed
const newRoleIsOwner =
newRole === MembershipRole.OWNER || newRole === DEFAULT_ROLE_IDS[MembershipRole.OWNER];
if (newRoleIsOwner) {
return;
}
// Count total owners in the organization/team
const ownerCount = await prisma.membership.count({
where: {
teamId: organizationId,
accepted: true,
OR: [{ customRoleId: DEFAULT_ROLE_IDS[MembershipRole.OWNER] }, { role: MembershipRole.OWNER }],
},
});
// If this is the last owner, prevent the role change
if (ownerCount <= 1) {
throw new RoleManagementError(
"Cannot change the role of the last owner in the organization",
RoleManagementErrorCode.UNAUTHORIZED
);
}
}
async assignRole(
_userId: number,
organizationId: number,
role: MembershipRole | string,
membershipId: number
): Promise<void> {
await this.validateNotLastOwner(organizationId, membershipId, role);
// Check if role is one of the default MembershipRole enum values
const isDefaultRole = role in DEFAULT_ROLE_IDS;
// Also check if the role is a default role ID value
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isDefaultRoleId = Object.values(DEFAULT_ROLE_IDS).includes(role as any);
if (isDefaultRole) {
// Handle enum values like MembershipRole.ADMIN
await this.roleService.assignRoleToMember(DEFAULT_ROLE_IDS[role as MembershipRole], membershipId);
} else if (isDefaultRoleId) {
// Handle default role IDs like "admin_role"
await this.roleService.assignRoleToMember(role as string, membershipId);
} else {
// Handle custom roles
const roleExists = await this.roleService.roleBelongsToTeam(role as string, organizationId);
if (!roleExists) {
throw new RoleManagementError(
"You do not have access to this role",
RoleManagementErrorCode.INVALID_ROLE
);
}
await this.roleService.assignRoleToMember(role as string, membershipId);
}
}
async getAllRoles(organizationId: number): Promise<{ id: string; name: string }[]> {
const roles = await this.roleService.getTeamRoles(organizationId);
return roles.map((role) => ({
id: role.id,
name: role.name,
}));
}
async getTeamRoles(teamId: number): Promise<{ id: string; name: string }[]> {
const roles = await this.roleService.getTeamRoles(teamId);
return roles.map((role) => ({
id: role.id,
name: role.name,
}));
}
}