diff --git a/packages/features/pbac/domain/errors/role-management.error.ts b/packages/features/pbac/domain/errors/role-management.error.ts new file mode 100644 index 0000000000..f41f0c35c7 --- /dev/null +++ b/packages/features/pbac/domain/errors/role-management.error.ts @@ -0,0 +1,12 @@ +export class RoleManagementError extends Error { + constructor(message: string, public readonly code: RoleManagementErrorCode) { + super(message); + this.name = "RoleManagementError"; + } +} + +export enum RoleManagementErrorCode { + UNAUTHORIZED = "UNAUTHORIZED", + ROLE_NOT_FOUND = "ROLE_NOT_FOUND", + INVALID_ROLE = "INVALID_ROLE", +} diff --git a/packages/features/pbac/services/__tests__/role-management.factory.test.ts b/packages/features/pbac/services/__tests__/role-management.factory.test.ts index b379f5f8f8..c0fd3a2274 100644 --- a/packages/features/pbac/services/__tests__/role-management.factory.test.ts +++ b/packages/features/pbac/services/__tests__/role-management.factory.test.ts @@ -5,8 +5,7 @@ import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations"; import { prisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; -import { TRPCError } from "@trpc/server"; - +import { RoleManagementError, RoleManagementErrorCode } from "../../domain/errors/role-management.error"; import { DEFAULT_ROLE_IDS } from "../../lib/constants"; import { PermissionCheckService } from "../permission-check.service"; import { RoleManagementFactory } from "../role-management.factory"; @@ -117,7 +116,12 @@ describe("RoleManagementFactory", () => { it("should throw UNAUTHORIZED when user lacks permission", async () => { mockPermissionCheckService.checkPermission.mockResolvedValue(false); const manager = await factory.createRoleManager(organizationId); - await expect(manager.checkPermissionToChangeRole(userId, organizationId)).rejects.toThrow(TRPCError); + await expect(manager.checkPermissionToChangeRole(userId, organizationId)).rejects.toThrow( + new RoleManagementError( + "You do not have permission to change roles", + RoleManagementErrorCode.UNAUTHORIZED + ) + ); }); }); @@ -144,14 +148,16 @@ describe("RoleManagementFactory", () => { expect(mockRoleService.assignRoleToMember).toHaveBeenCalledWith(customRoleId, membershipId); }); - it("should throw UNAUTHORIZED for invalid custom role", async () => { + it("should throw INVALID_ROLE for invalid custom role", async () => { const customRoleId = "invalid-role"; mockRoleService.roleBelongsToTeam.mockResolvedValue(false); const manager = await factory.createRoleManager(organizationId); await expect( manager.assignRole(userId, organizationId, customRoleId as MembershipRole, membershipId) - ).rejects.toThrow(TRPCError); + ).rejects.toThrow( + new RoleManagementError("You do not have access to this role", RoleManagementErrorCode.INVALID_ROLE) + ); }); }); }); @@ -159,13 +165,33 @@ describe("RoleManagementFactory", () => { describe("LegacyRoleManager", () => { beforeEach(() => { mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValue(false); - vi.mocked(prisma.membership.update).mockResolvedValue({}); + vi.mocked(prisma.membership.update).mockResolvedValue({ + id: 1, + teamId: organizationId, + createdAt: new Date(), + updatedAt: new Date(), + role: MembershipRole.MEMBER, + userId: userId, + disableImpersonation: false, + accepted: true, + customRoleId: null, + }); vi.mocked(isOrganisationAdmin).mockResolvedValue(false); }); describe("checkPermissionToChangeRole", () => { it("should allow role change when user is owner", async () => { - vi.mocked(isOrganisationAdmin).mockResolvedValue(true); + vi.mocked(isOrganisationAdmin).mockResolvedValue({ + id: 1, + teamId: organizationId, + userId: userId, + role: MembershipRole.OWNER, + accepted: true, + disableImpersonation: false, + createdAt: new Date(), + updatedAt: new Date(), + customRoleId: null, + }); const manager = await factory.createRoleManager(organizationId); await expect(manager.checkPermissionToChangeRole(userId, organizationId)).resolves.not.toThrow(); }); @@ -173,7 +199,12 @@ describe("RoleManagementFactory", () => { it("should throw UNAUTHORIZED when user is not owner", async () => { vi.mocked(isOrganisationAdmin).mockResolvedValue(false); const manager = await factory.createRoleManager(organizationId); - await expect(manager.checkPermissionToChangeRole(userId, organizationId)).rejects.toThrow(TRPCError); + await expect(manager.checkPermissionToChangeRole(userId, organizationId)).rejects.toThrow( + new RoleManagementError( + "Only owners or admin can update roles", + RoleManagementErrorCode.UNAUTHORIZED + ) + ); }); }); diff --git a/packages/features/pbac/services/role-management.factory.ts b/packages/features/pbac/services/role-management.factory.ts index e41846b67f..b0ba2423d7 100644 --- a/packages/features/pbac/services/role-management.factory.ts +++ b/packages/features/pbac/services/role-management.factory.ts @@ -3,8 +3,7 @@ import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations"; import { prisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; -import { TRPCError } from "@trpc/server"; - +import { RoleManagementError, RoleManagementErrorCode } from "../domain/errors/role-management.error"; import { DEFAULT_ROLE_IDS } from "../lib/constants"; import { PermissionCheckService } from "./permission-check.service"; import { RoleService } from "./role.service"; @@ -38,7 +37,10 @@ class PBACRoleManager implements IRoleManager { }); if (!hasPermission) { - throw new TRPCError({ code: "UNAUTHORIZED" }); + throw new RoleManagementError( + "You do not have permission to change roles", + RoleManagementErrorCode.UNAUTHORIZED + ); } } @@ -55,7 +57,10 @@ class PBACRoleManager implements IRoleManager { } else { const roleExists = await this.roleService.roleBelongsToTeam(role, organizationId); if (!roleExists) { - throw new TRPCError({ code: "UNAUTHORIZED", message: "You do not have access to this role" }); + throw new RoleManagementError( + "You do not have access to this role", + RoleManagementErrorCode.INVALID_ROLE + ); } await this.roleService.assignRoleToMember(role, membershipId); } @@ -73,11 +78,14 @@ class PBACRoleManager implements IRoleManager { class LegacyRoleManager implements IRoleManager { public isPBACEnabled = false; async checkPermissionToChangeRole(userId: number, organizationId: number): Promise { - const isUpdaterAnOwner = await isOrganisationAdmin(userId, organizationId); + const membership = await isOrganisationAdmin(userId, organizationId); - // Only OWNER can update role to OWNER - if (!isUpdaterAnOwner) { - throw new TRPCError({ code: "UNAUTHORIZED" }); + // Only OWNER/ADMIN can update role + if (!membership) { + throw new RoleManagementError( + "Only owners or admin can update roles", + RoleManagementErrorCode.UNAUTHORIZED + ); } } diff --git a/packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts b/packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts index bbb20dccf3..5eeec3f8a5 100644 --- a/packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts @@ -2,6 +2,7 @@ import type { Prisma, PrismaPromise, User, Membership, Profile } from "@prisma/c import { ensureOrganizationIsReviewed } from "@calcom/ee/organizations/lib/ensureOrganizationIsReviewed"; import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner"; +import { RoleManagementError } from "@calcom/features/pbac/domain/errors/role-management.error"; import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory"; import { uploadAvatar } from "@calcom/lib/server/avatar"; import { checkRegularUsername } from "@calcom/lib/server/checkRegularUsername"; @@ -44,7 +45,15 @@ export const updateUserHandler = async ({ ctx, input }: UpdateUserOptions) => { throw new TRPCError({ code: "UNAUTHORIZED", message: "You must be a member of an organizaiton" }); const roleManager = await RoleManagementFactory.getInstance().createRoleManager(organizationId); - await roleManager.checkPermissionToChangeRole(userId, organizationId); + + try { + await roleManager.checkPermissionToChangeRole(userId, organizationId); + } catch (error) { + if (error instanceof RoleManagementError) { + throw new TRPCError({ code: "UNAUTHORIZED", message: error.message }); + } + throw error; + } await ensureOrganizationIsReviewed(organizationId);