feat: add method for getting teamId permissions PBAC (#22375)

* feat:add method for getting teamId permissions

* add logic and test for zero permsiion strings

* fix test by updating mock
This commit is contained in:
sean-brydon authored and GitHub committed 2025-07-11 08:34:33 +01:00
1 parent 260f4e1b51
commit b5911a9a2d
4 files changed
+209 -1

No files matched your search

@@ -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<number[]>;
/**
* Gets all team IDs where the user has all of the specified permissions
*/
getTeamIdsWithPermissions(userId: number, permissions: PermissionString[]): Promise<number[]>;
}
@@ -107,6 +107,11 @@ export class PermissionRepository implements IPermissionRepository {
}
async checkRolePermissions(roleId: string, permissions: PermissionString[]): Promise<boolean> {
// 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<number[]> {
return this.getTeamIdsWithPermissions(userId, [permission]);
}
async getTeamIdsWithPermissions(userId: number, permissions: PermissionString[]): Promise<number[]> {
// 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);
}
}
@@ -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);
});
});
});
@@ -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<number[]> {
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<number[]> {
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 [];
}
}
}