Skip to content

Commit f796802

Browse files
feat: add fallbackRoles parameter to getTeamIdsWithPermissions method (calcom#24042)
* feat: add fallbackRoles parameter to getTeamIdsWithPermissions method - Add fallbackRoles parameter to method signature in interface and implementation - Implement second query for teams without PBAC where user has fallback roles - Combine and deduplicate results from both PBAC-enabled and fallback role teams - Supports fallback to role-based permissions when PBAC is disabled - Fix linting issue in getUserMemberships method by replacing include with select Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix usages * revert some change * fix unit tests --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent b2ac227 commit f796802

7 files changed

Lines changed: 142 additions & 30 deletions

File tree

packages/features/pbac/domain/repositories/IPermissionRepository.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { MembershipRole } from "@calcom/prisma/enums";
2+
13
import type { TeamPermissions } from "../models/Permission";
24
import type { PermissionString, Resource, CrudAction, CustomAction } from "../types/permission-registry";
35

@@ -62,10 +64,18 @@ export interface IPermissionRepository {
6264
/**
6365
* Gets all team IDs where the user has a specific permission
6466
*/
65-
getTeamIdsWithPermission(userId: number, permission: PermissionString): Promise<number[]>;
67+
getTeamIdsWithPermission(params: {
68+
userId: number;
69+
permission: PermissionString;
70+
fallbackRoles: MembershipRole[];
71+
}): Promise<number[]>;
6672

6773
/**
6874
* Gets all team IDs where the user has all of the specified permissions
6975
*/
70-
getTeamIdsWithPermissions(userId: number, permissions: PermissionString[]): Promise<number[]>;
76+
getTeamIdsWithPermissions(params: {
77+
userId: number;
78+
permissions: PermissionString[];
79+
fallbackRoles: MembershipRole[];
80+
}): Promise<number[]>;
7181
}

packages/features/pbac/infrastructure/repositories/PermissionRepository.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import db from "@calcom/prisma";
22
import type { PrismaClient as PrismaClientWithExtensions } from "@calcom/prisma";
3+
import type { MembershipRole } from "@calcom/prisma/enums";
34

45
import { PermissionMapper } from "../../domain/mappers/PermissionMapper";
56
import type { TeamPermissions } from "../../domain/models/Permission";
@@ -12,6 +13,7 @@ import {
1213
} from "../../domain/types/permission-registry";
1314

1415
export class PermissionRepository implements IPermissionRepository {
16+
private readonly PBAC_FEATURE_FLAG = "pbac" as const;
1517
private client: PrismaClientWithExtensions;
1618

1719
constructor(client: PrismaClientWithExtensions = db) {
@@ -210,11 +212,27 @@ export class PermissionRepository implements IPermissionRepository {
210212
return permissions.map((p) => p.action as CrudAction | CustomAction);
211213
}
212214

213-
async getTeamIdsWithPermission(userId: number, permission: PermissionString): Promise<number[]> {
214-
return this.getTeamIdsWithPermissions(userId, [permission]);
215+
async getTeamIdsWithPermission({
216+
userId,
217+
permission,
218+
fallbackRoles,
219+
}: {
220+
userId: number;
221+
permission: PermissionString;
222+
fallbackRoles: MembershipRole[];
223+
}): Promise<number[]> {
224+
return this.getTeamIdsWithPermissions({ userId, permissions: [permission], fallbackRoles });
215225
}
216226

217-
async getTeamIdsWithPermissions(userId: number, permissions: PermissionString[]): Promise<number[]> {
227+
async getTeamIdsWithPermissions({
228+
userId,
229+
permissions,
230+
fallbackRoles,
231+
}: {
232+
userId: number;
233+
permissions: PermissionString[];
234+
fallbackRoles: MembershipRole[];
235+
}): Promise<number[]> {
218236
// Validate that permissions array is not empty to prevent privilege escalation
219237
if (permissions.length === 0) {
220238
return [];
@@ -225,7 +243,7 @@ export class PermissionRepository implements IPermissionRepository {
225243
return { resource, action };
226244
});
227245

228-
const teamsWithPermission = await this.client.$queryRaw<{ teamId: number }[]>`
246+
const teamsWithPermissionPromise = this.client.$queryRaw<{ teamId: number }[]>`
229247
SELECT DISTINCT m."teamId"
230248
FROM "Membership" m
231249
INNER JOIN "Role" r ON m."customRoleId" = r.id
@@ -249,6 +267,26 @@ export class PermissionRepository implements IPermissionRepository {
249267
) = ${permissions.length}
250268
`;
251269

252-
return teamsWithPermission.map((team) => team.teamId);
270+
const teamsWithFallbackRolesPromise = this.client.$queryRaw<{ teamId: number }[]>`
271+
SELECT DISTINCT m."teamId"
272+
FROM "Membership" m
273+
INNER JOIN "Team" t ON m."teamId" = t.id
274+
LEFT JOIN "TeamFeatures" f ON t.id = f."teamId" AND f."featureId" = ${this.PBAC_FEATURE_FLAG}
275+
WHERE m."userId" = ${userId}
276+
AND m."accepted" = true
277+
AND m."role"::text = ANY(${fallbackRoles})
278+
AND f."teamId" IS NULL
279+
`;
280+
281+
const [teamsWithPermission, teamsWithFallbackRoles] = await Promise.all([
282+
teamsWithPermissionPromise,
283+
teamsWithFallbackRolesPromise,
284+
]);
285+
286+
const pbacTeamIds = teamsWithPermission.map((team) => team.teamId);
287+
const fallbackTeamIds = teamsWithFallbackRoles.map((team) => team.teamId);
288+
289+
const allTeamIds = Array.from(new Set([...pbacTeamIds, ...fallbackTeamIds]));
290+
return allTeamIds;
253291
}
254292
}

packages/features/pbac/services/__tests__/permission-check.service.test.ts

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -327,10 +327,18 @@ describe("PermissionCheckService", () => {
327327
const expectedTeamIds = [1, 2, 3];
328328
mockRepository.getTeamIdsWithPermission.mockResolvedValueOnce(expectedTeamIds);
329329

330-
const result = await service.getTeamIdsWithPermission(1, "eventType.read");
330+
const result = await service.getTeamIdsWithPermission({
331+
userId: 1,
332+
permission: "eventType.read",
333+
fallbackRoles: [],
334+
});
331335

332336
expect(result).toEqual(expectedTeamIds);
333-
expect(mockRepository.getTeamIdsWithPermission).toHaveBeenCalledWith(1, "eventType.read");
337+
expect(mockRepository.getTeamIdsWithPermission).toHaveBeenCalledWith({
338+
userId: 1,
339+
permission: "eventType.read",
340+
fallbackRoles: [],
341+
});
334342
});
335343

336344
it("should return empty array when permission validation fails", async () => {
@@ -339,7 +347,11 @@ describe("PermissionCheckService", () => {
339347
error: "Invalid permissions",
340348
});
341349

342-
const result = await service.getTeamIdsWithPermission(1, "eventType.read");
350+
const result = await service.getTeamIdsWithPermission({
351+
userId: 1,
352+
permission: "eventType.read",
353+
fallbackRoles: [],
354+
});
343355

344356
expect(result).toEqual([]);
345357
expect(mockRepository.getTeamIdsWithPermission).not.toHaveBeenCalled();
@@ -348,10 +360,18 @@ describe("PermissionCheckService", () => {
348360
it("should return empty array and log error when repository throws", async () => {
349361
mockRepository.getTeamIdsWithPermission.mockRejectedValueOnce(new Error("Database error"));
350362

351-
const result = await service.getTeamIdsWithPermission(1, "eventType.read");
363+
const result = await service.getTeamIdsWithPermission({
364+
userId: 1,
365+
permission: "eventType.read",
366+
fallbackRoles: [],
367+
});
352368

353369
expect(result).toEqual([]);
354-
expect(mockRepository.getTeamIdsWithPermission).toHaveBeenCalledWith(1, "eventType.read");
370+
expect(mockRepository.getTeamIdsWithPermission).toHaveBeenCalledWith({
371+
userId: 1,
372+
permission: "eventType.read",
373+
fallbackRoles: [],
374+
});
355375
});
356376
});
357377

@@ -361,10 +381,14 @@ describe("PermissionCheckService", () => {
361381
const permissions: PermissionString[] = ["eventType.read", "eventType.create"];
362382
mockRepository.getTeamIdsWithPermissions.mockResolvedValueOnce(expectedTeamIds);
363383

364-
const result = await service.getTeamIdsWithPermissions(1, permissions);
384+
const result = await service.getTeamIdsWithPermissions({ userId: 1, permissions, fallbackRoles: [] });
365385

366386
expect(result).toEqual(expectedTeamIds);
367-
expect(mockRepository.getTeamIdsWithPermissions).toHaveBeenCalledWith(1, permissions);
387+
expect(mockRepository.getTeamIdsWithPermissions).toHaveBeenCalledWith({
388+
userId: 1,
389+
permissions,
390+
fallbackRoles: [],
391+
});
368392
});
369393

370394
it("should return empty array when permissions validation fails", async () => {
@@ -373,7 +397,11 @@ describe("PermissionCheckService", () => {
373397
error: "Invalid permissions",
374398
});
375399

376-
const result = await service.getTeamIdsWithPermissions(1, ["eventType.read"] as PermissionString[]);
400+
const result = await service.getTeamIdsWithPermissions({
401+
userId: 1,
402+
permissions: ["eventType.read"] as PermissionString[],
403+
fallbackRoles: [],
404+
});
377405

378406
expect(result).toEqual([]);
379407
expect(mockRepository.getTeamIdsWithPermissions).not.toHaveBeenCalled();
@@ -382,20 +410,32 @@ describe("PermissionCheckService", () => {
382410
it("should return empty array when permissions array is empty", async () => {
383411
mockRepository.getTeamIdsWithPermissions.mockResolvedValueOnce([]);
384412

385-
const result = await service.getTeamIdsWithPermissions(1, []);
413+
const result = await service.getTeamIdsWithPermissions({
414+
userId: 1,
415+
permissions: [],
416+
fallbackRoles: [],
417+
});
386418

387419
expect(result).toEqual([]);
388-
expect(mockRepository.getTeamIdsWithPermissions).toHaveBeenCalledWith(1, []);
420+
expect(mockRepository.getTeamIdsWithPermissions).toHaveBeenCalledWith({
421+
userId: 1,
422+
permissions: [],
423+
fallbackRoles: [],
424+
});
389425
});
390426

391427
it("should return empty array and log error when repository throws", async () => {
392428
const permissions: PermissionString[] = ["eventType.read", "eventType.create"];
393429
mockRepository.getTeamIdsWithPermissions.mockRejectedValueOnce(new Error("Database error"));
394430

395-
const result = await service.getTeamIdsWithPermissions(1, permissions);
431+
const result = await service.getTeamIdsWithPermissions({ userId: 1, permissions, fallbackRoles: [] });
396432

397433
expect(result).toEqual([]);
398-
expect(mockRepository.getTeamIdsWithPermissions).toHaveBeenCalledWith(1, permissions);
434+
expect(mockRepository.getTeamIdsWithPermissions).toHaveBeenCalledWith({
435+
userId: 1,
436+
permissions,
437+
fallbackRoles: [],
438+
});
399439
});
400440
});
401441

packages/features/pbac/services/permission-check.service.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -255,15 +255,23 @@ export class PermissionCheckService {
255255
/**
256256
* Gets all team IDs where the user has a specific permission
257257
*/
258-
async getTeamIdsWithPermission(userId: number, permission: PermissionString): Promise<number[]> {
258+
async getTeamIdsWithPermission({
259+
userId,
260+
permission,
261+
fallbackRoles,
262+
}: {
263+
userId: number;
264+
permission: PermissionString;
265+
fallbackRoles: MembershipRole[];
266+
}): Promise<number[]> {
259267
try {
260268
const validationResult = this.permissionService.validatePermission(permission);
261269
if (!validationResult.isValid) {
262270
this.logger.error(validationResult.error);
263271
return [];
264272
}
265273

266-
return await this.repository.getTeamIdsWithPermission(userId, permission);
274+
return await this.repository.getTeamIdsWithPermission({ userId, permission, fallbackRoles });
267275
} catch (error) {
268276
this.logger.error(error);
269277
return [];
@@ -273,15 +281,23 @@ export class PermissionCheckService {
273281
/**
274282
* Gets all team IDs where the user has all of the specified permissions
275283
*/
276-
async getTeamIdsWithPermissions(userId: number, permissions: PermissionString[]): Promise<number[]> {
284+
async getTeamIdsWithPermissions({
285+
userId,
286+
permissions,
287+
fallbackRoles,
288+
}: {
289+
userId: number;
290+
permissions: PermissionString[];
291+
fallbackRoles: MembershipRole[];
292+
}): Promise<number[]> {
277293
try {
278294
const validationResult = this.permissionService.validatePermissions(permissions);
279295
if (!validationResult.isValid) {
280296
this.logger.error(validationResult.error);
281297
return [];
282298
}
283299

284-
return await this.repository.getTeamIdsWithPermissions(userId, permissions);
300+
return await this.repository.getTeamIdsWithPermissions({ userId, permissions, fallbackRoles });
285301
} catch (error) {
286302
this.logger.error(error);
287303
return [];

packages/trpc/server/routers/viewer/teams/listSimpleMembers.handler.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
66
import type { PrismaClient } from "@calcom/prisma";
7+
import { MembershipRole } from "@calcom/prisma/enums";
78
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
89

910
type ListSimpleMembersOptions = {
@@ -23,7 +24,11 @@ export const listSimpleMembers = async ({ ctx }: ListSimpleMembersOptions) => {
2324
}
2425

2526
const permissionCheckService = new PermissionCheckService();
26-
const teamsToQuery = await permissionCheckService.getTeamIdsWithPermission(ctx.user.id, "team.listMembers");
27+
const teamsToQuery = await permissionCheckService.getTeamIdsWithPermission({
28+
userId: ctx.user.id,
29+
permission: "team.listMembers",
30+
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
31+
});
2732

2833
if (!teamsToQuery.length) {
2934
return [];

packages/trpc/server/routers/viewer/teams/removeMember/PBACRemoveMemberService.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as teamQueries from "@calcom/features/ee/teams/lib/queries";
22
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
33
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
44
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
5+
import { MembershipRole } from "@calcom/prisma/enums";
56

67
import { TRPCError } from "@trpc/server";
78

@@ -20,10 +21,11 @@ export class PBACRemoveMemberService extends BaseRemoveMemberService {
2021
action: CustomAction.Remove,
2122
});
2223

23-
const teamsWithPermission = await this.permissionService.getTeamIdsWithPermission(
24+
const teamsWithPermission = await this.permissionService.getTeamIdsWithPermission({
2425
userId,
25-
removePermission
26-
);
26+
permission: removePermission,
27+
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
28+
});
2729

2830
// Convert to Set for O(1) lookup
2931
const teamsWithPermissionSet = new Set(teamsWithPermission);

packages/trpc/server/routers/viewer/teams/removeMember/__tests__/PBACRemoveMemberService.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,11 @@ describe("PBACRemoveMemberService", () => {
6868
action: CustomAction.Remove,
6969
});
7070

71-
expect(mockPermissionCheckService.getTeamIdsWithPermission).toHaveBeenCalledWith(
71+
expect(mockPermissionCheckService.getTeamIdsWithPermission).toHaveBeenCalledWith({
7272
userId,
73-
removePermission
74-
);
73+
permission: removePermission,
74+
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
75+
});
7576
});
7677

7778
it("should check organization.remove permission for org context", async () => {

0 commit comments

Comments
 (0)