diff --git a/packages/features/pbac/domain/repositories/IPermissionRepository.ts b/packages/features/pbac/domain/repositories/IPermissionRepository.ts index a2aea03757..d4f9773830 100644 --- a/packages/features/pbac/domain/repositories/IPermissionRepository.ts +++ b/packages/features/pbac/domain/repositories/IPermissionRepository.ts @@ -44,4 +44,14 @@ export interface IPermissionRepository { teamId: number, resource: Resource ): Promise<(CrudAction | CustomAction)[]>; + + /** + * Gets all team IDs where the user has a specific permission + */ + getTeamIdsWithPermission(userId: number, permission: PermissionString): Promise; + + /** + * Gets all team IDs where the user has all of the specified permissions + */ + getTeamIdsWithPermissions(userId: number, permissions: PermissionString[]): Promise; } diff --git a/packages/features/pbac/infrastructure/repositories/PermissionRepository.ts b/packages/features/pbac/infrastructure/repositories/PermissionRepository.ts index a65bbe38bc..75a1764bcb 100644 --- a/packages/features/pbac/infrastructure/repositories/PermissionRepository.ts +++ b/packages/features/pbac/infrastructure/repositories/PermissionRepository.ts @@ -107,6 +107,11 @@ export class PermissionRepository implements IPermissionRepository { } async checkRolePermissions(roleId: string, permissions: PermissionString[]): Promise { + // Validate that permissions array is not empty to prevent privilege escalation + if (permissions.length === 0) { + return false; + } + const permissionPairs = permissions.map((p) => { const [resource, action] = p.split("."); return { resource, action }; @@ -173,4 +178,46 @@ export class PermissionRepository implements IPermissionRepository { }); return teamPermissions.map((p) => p.action as CrudAction | CustomAction); } + + async getTeamIdsWithPermission(userId: number, permission: PermissionString): Promise { + return this.getTeamIdsWithPermissions(userId, [permission]); + } + + async getTeamIdsWithPermissions(userId: number, permissions: PermissionString[]): Promise { + // Validate that permissions array is not empty to prevent privilege escalation + if (permissions.length === 0) { + return []; + } + + const permissionPairs = permissions.map((p) => { + const [resource, action] = p.split("."); + return { resource, action }; + }); + + const teamsWithPermission = await this.client.$queryRaw<{ teamId: number }[]>` + SELECT DISTINCT m."teamId" + FROM "Membership" m + INNER JOIN "Role" r ON m."customRoleId" = r.id + WHERE m."userId" = ${userId} + AND m."accepted" = true + AND m."customRoleId" IS NOT NULL + AND ( + SELECT COUNT(*) + FROM jsonb_array_elements(${JSON.stringify(permissionPairs)}::jsonb) AS required_perm(perm) + WHERE EXISTS ( + SELECT 1 + FROM "RolePermission" rp + WHERE rp."roleId" = r.id + AND ( + (rp."resource" = '*' AND rp."action" = '*') OR + (rp."resource" = '*' AND rp."action" = required_perm.perm->>'action') OR + (rp."resource" = required_perm.perm->>'resource' AND rp."action" = '*') OR + (rp."resource" = required_perm.perm->>'resource' AND rp."action" = required_perm.perm->>'action') + ) + ) + ) = ${permissions.length} + `; + + return teamsWithPermission.map((team) => team.teamId); + } } diff --git a/packages/features/pbac/services/__tests__/permission-check.service.test.ts b/packages/features/pbac/services/__tests__/permission-check.service.test.ts index dce4f017f7..ce892e8bc9 100644 --- a/packages/features/pbac/services/__tests__/permission-check.service.test.ts +++ b/packages/features/pbac/services/__tests__/permission-check.service.test.ts @@ -5,6 +5,7 @@ import { MembershipRepository } from "@calcom/lib/server/repository/membership"; import type { MembershipRole } from "@calcom/prisma/enums"; import type { IPermissionRepository } from "../../domain/repositories/IPermissionRepository"; +import type { PermissionString } from "../../domain/types/permission-registry"; import { PermissionCheckService } from "../permission-check.service"; import type { PermissionService } from "../permission.service"; @@ -33,7 +34,9 @@ describe("PermissionCheckService", () => { checkRolePermission: vi.fn(), checkRolePermissions: vi.fn(), getResourcePermissions: vi.fn(), - }; + getTeamIdsWithPermission: vi.fn(), + getTeamIdsWithPermissions: vi.fn(), + } as MockRepository; mockFeaturesRepository = { checkIfTeamHasFeature: vi.fn(), @@ -244,6 +247,41 @@ describe("PermissionCheckService", () => { expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledWith(1, "pbac"); expect(mockRepository.checkRolePermissions).not.toHaveBeenCalled(); }); + + it("should return false when permissions array is empty", async () => { + const membership = { + id: 1, + teamId: 1, + userId: 1, + accepted: true, + role: "ADMIN" as MembershipRole, + customRoleId: "admin_role", + disableImpersonation: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + (MembershipRepository.findUniqueByUserIdAndTeamId as Mock).mockResolvedValueOnce(membership); + mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true); + mockRepository.getMembershipByMembershipId.mockResolvedValueOnce({ + id: membership.id, + teamId: membership.teamId, + userId: membership.userId, + customRoleId: membership.customRoleId, + }); + mockRepository.getOrgMembership.mockResolvedValueOnce(null); + mockRepository.checkRolePermissions.mockResolvedValueOnce(false); + + const result = await service.checkPermissions({ + userId: 1, + teamId: 1, + permissions: [], + fallbackRoles: ["ADMIN", "OWNER"], + }); + + expect(result).toBe(false); + expect(mockRepository.checkRolePermissions).toHaveBeenCalledWith("admin_role", []); + }); }); describe("getUserPermissions", () => { @@ -264,4 +302,81 @@ describe("PermissionCheckService", () => { expect(mockRepository.getUserMemberships).toHaveBeenCalledWith(1); }); }); + + describe("getTeamIdsWithPermission", () => { + it("should return team IDs where user has the specified permission", async () => { + const expectedTeamIds = [1, 2, 3]; + mockRepository.getTeamIdsWithPermission.mockResolvedValueOnce(expectedTeamIds); + + const result = await service.getTeamIdsWithPermission(1, "eventType.read"); + + expect(result).toEqual(expectedTeamIds); + expect(mockRepository.getTeamIdsWithPermission).toHaveBeenCalledWith(1, "eventType.read"); + }); + + it("should return empty array when permission validation fails", async () => { + mockPermissionService.validatePermission.mockReturnValueOnce({ + isValid: false, + error: "Invalid permissions", + }); + + const result = await service.getTeamIdsWithPermission(1, "eventType.read"); + + expect(result).toEqual([]); + expect(mockRepository.getTeamIdsWithPermission).not.toHaveBeenCalled(); + }); + + it("should return empty array and log error when repository throws", async () => { + mockRepository.getTeamIdsWithPermission.mockRejectedValueOnce(new Error("Database error")); + + const result = await service.getTeamIdsWithPermission(1, "eventType.read"); + + expect(result).toEqual([]); + expect(mockRepository.getTeamIdsWithPermission).toHaveBeenCalledWith(1, "eventType.read"); + }); + }); + + describe("getTeamIdsWithPermissions", () => { + it("should return team IDs where user has all specified permissions", async () => { + const expectedTeamIds = [1, 2]; + const permissions: PermissionString[] = ["eventType.read", "eventType.create"]; + mockRepository.getTeamIdsWithPermissions.mockResolvedValueOnce(expectedTeamIds); + + const result = await service.getTeamIdsWithPermissions(1, permissions); + + expect(result).toEqual(expectedTeamIds); + expect(mockRepository.getTeamIdsWithPermissions).toHaveBeenCalledWith(1, permissions); + }); + + it("should return empty array when permissions validation fails", async () => { + mockPermissionService.validatePermissions.mockReturnValueOnce({ + isValid: false, + error: "Invalid permissions", + }); + + const result = await service.getTeamIdsWithPermissions(1, ["eventType.read"] as PermissionString[]); + + expect(result).toEqual([]); + expect(mockRepository.getTeamIdsWithPermissions).not.toHaveBeenCalled(); + }); + + it("should return empty array when permissions array is empty", async () => { + mockRepository.getTeamIdsWithPermissions.mockResolvedValueOnce([]); + + const result = await service.getTeamIdsWithPermissions(1, []); + + expect(result).toEqual([]); + expect(mockRepository.getTeamIdsWithPermissions).toHaveBeenCalledWith(1, []); + }); + + it("should return empty array and log error when repository throws", async () => { + const permissions: PermissionString[] = ["eventType.read", "eventType.create"]; + mockRepository.getTeamIdsWithPermissions.mockRejectedValueOnce(new Error("Database error")); + + const result = await service.getTeamIdsWithPermissions(1, permissions); + + expect(result).toEqual([]); + expect(mockRepository.getTeamIdsWithPermissions).toHaveBeenCalledWith(1, permissions); + }); + }); }); diff --git a/packages/features/pbac/services/permission-check.service.ts b/packages/features/pbac/services/permission-check.service.ts index 0fc2ae4606..9063566ed7 100644 --- a/packages/features/pbac/services/permission-check.service.ts +++ b/packages/features/pbac/services/permission-check.service.ts @@ -225,4 +225,40 @@ export class PermissionCheckService { private checkFallbackRoles(userRole: MembershipRole, allowedRoles: MembershipRole[]): boolean { return allowedRoles.includes(userRole); } + + /** + * Gets all team IDs where the user has a specific permission + */ + async getTeamIdsWithPermission(userId: number, permission: PermissionString): Promise { + try { + const validationResult = this.permissionService.validatePermission(permission); + if (!validationResult.isValid) { + this.logger.error(validationResult.error); + return []; + } + + return await this.repository.getTeamIdsWithPermission(userId, permission); + } catch (error) { + this.logger.error(error); + return []; + } + } + + /** + * Gets all team IDs where the user has all of the specified permissions + */ + async getTeamIdsWithPermissions(userId: number, permissions: PermissionString[]): Promise { + try { + const validationResult = this.permissionService.validatePermissions(permissions); + if (!validationResult.isValid) { + this.logger.error(validationResult.error); + return []; + } + + return await this.repository.getTeamIdsWithPermissions(userId, permissions); + } catch (error) { + this.logger.error(error); + return []; + } + } }