fix: get bookings handler for pbac and fallback roles (#25434)

* fix: getTeamIdsWithPermission

* fix get bookings handler for pbac and fallback roles

* add tests

* fix fallback roll query

* push integration test fixes

* Check for pbac features

* fix

* restore role behaviour

* fix fallabck roles no pbac enabled

* debug: add logging to investigate PBAC query failures

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* re-write integration tests

* fix: use >= comparison with bigint cast for PBAC permission count

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* Restore permission repo

* re-write from scratch

* fix tests + file

* imrpove perf by makign CTE and moving to left join

* fix org scoped taems

* clean up

* fix: update PermissionCheckService mock to use function instead of arrow function

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* refactor: rename scopedOrgId to orgId

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* refactor: use MembershipRole enum instead of hardcoded strings

Co-Authored-By: sean@cal.com <Sean@brydon.io>

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
This commit is contained in:
sean-brydon
2026-01-16 15:03:15 +00:00
committed by GitHub
co-authored by sean@cal.com Anik Dhabal Babu Udit Takkar Devin AI Eunjae Lee Benny Joo
parent d6c9967c1b
commit c0302bfc51
6 changed files with 621 additions and 127 deletions
@@ -63,19 +63,23 @@ export interface IPermissionRepository {
/**
* Gets all team IDs where the user has a specific permission
* @param orgId Optional organization ID to scope results to. When provided, only returns teams within this organization.
*/
getTeamIdsWithPermission(params: {
userId: number;
permission: PermissionString;
fallbackRoles: MembershipRole[];
orgId?: number;
}): Promise<number[]>;
/**
* Gets all team IDs where the user has all of the specified permissions
* @param orgId Optional organization ID to scope results to. When provided, only returns teams within this organization.
*/
getTeamIdsWithPermissions(params: {
userId: number;
permissions: PermissionString[];
fallbackRoles: MembershipRole[];
orgId?: number;
}): Promise<number[]>;
}
@@ -212,22 +212,26 @@ export class PermissionRepository implements IPermissionRepository {
userId,
permission,
fallbackRoles,
orgId,
}: {
userId: number;
permission: PermissionString;
fallbackRoles: MembershipRole[];
orgId?: number;
}): Promise<number[]> {
return this.getTeamIdsWithPermissions({ userId, permissions: [permission], fallbackRoles });
return this.getTeamIdsWithPermissions({ userId, permissions: [permission], fallbackRoles, orgId });
}
async getTeamIdsWithPermissions({
userId,
permissions,
fallbackRoles,
orgId,
}: {
userId: number;
permissions: PermissionString[];
fallbackRoles: MembershipRole[];
orgId?: number;
}): Promise<number[]> {
// Validate that permissions array is not empty to prevent privilege escalation
if (permissions.length === 0) {
@@ -241,8 +245,29 @@ export class PermissionRepository implements IPermissionRepository {
const permissionPairsJson = JSON.stringify(permissionPairs);
// Teams with PBAC permissions (direct memberships + child teams via org membership)
const teamsWithPermissionPromise = this.client.$queryRaw<{ teamId: number }[]>`
const [teamsWithPermission, teamsWithFallbackRoles] = await Promise.all([
this.getTeamsWithPBACPermissions(userId, permissionPairsJson, permissions.length, orgId),
this.getTeamsWithFallbackRoles(userId, fallbackRoles, orgId),
]);
const pbacTeamIds = teamsWithPermission.map((team) => team.teamId);
const fallbackTeamIds = teamsWithFallbackRoles.map((team) => team.teamId);
const allTeamIds = Array.from(new Set([...pbacTeamIds, ...fallbackTeamIds]));
return allTeamIds;
}
/**
* Gets teams where user has PBAC permissions (direct memberships + child teams via org membership)
* @param orgId Optional organization ID to scope results. When null/undefined, returns all teams.
*/
private async getTeamsWithPBACPermissions(
userId: number,
permissionPairsJson: string,
permissionsCount: number,
orgId?: number | null
): Promise<{ teamId: number }[]> {
return this.client.$queryRaw<{ teamId: number }[]>`
WITH required_permissions AS (
SELECT
required_perm->>'resource' as resource,
@@ -252,9 +277,11 @@ export class PermissionRepository implements IPermissionRepository {
SELECT DISTINCT m."teamId"
FROM "Membership" m
INNER JOIN "Role" r ON m."customRoleId" = r.id
INNER JOIN "Team" t ON m."teamId" = t.id
WHERE m."userId" = ${userId}
AND m."accepted" = true
AND m."customRoleId" IS NOT NULL
AND (${orgId}::bigint IS NULL OR t."id" = ${orgId} OR t."parentId" = ${orgId})
AND (
SELECT COUNT(*)
FROM required_permissions rp_req
@@ -269,7 +296,7 @@ export class PermissionRepository implements IPermissionRepository {
(rp."resource" = rp_req.resource AND rp."action" = rp_req.action)
)
)
) = ${permissions.length}
) = ${permissionsCount}
UNION
SELECT DISTINCT child."id"
FROM "Membership" m
@@ -279,6 +306,7 @@ export class PermissionRepository implements IPermissionRepository {
WHERE m."userId" = ${userId}
AND m."accepted" = true
AND m."customRoleId" IS NOT NULL
AND (${orgId}::bigint IS NULL OR org."id" = ${orgId} OR child."id" = ${orgId} OR child."parentId" = ${orgId})
AND (
SELECT COUNT(*)
FROM required_permissions rp_req
@@ -293,11 +321,20 @@ export class PermissionRepository implements IPermissionRepository {
(rp."resource" = rp_req.resource AND rp."action" = rp_req.action)
)
)
) = ${permissions.length}
) = ${permissionsCount}
`;
}
// Teams with fallback roles (direct memberships + child teams via org membership, PBAC disabled)
const teamsWithFallbackRolesPromise = this.client.$queryRaw<{ teamId: number }[]>`
/**
* Gets teams where user has fallback roles (direct memberships + child teams via org membership, PBAC disabled)
* @param orgId Optional organization ID to scope results. When null/undefined, returns all teams.
*/
private async getTeamsWithFallbackRoles(
userId: number,
fallbackRoles: MembershipRole[],
orgId?: number | null
): Promise<{ teamId: number }[]> {
return this.client.$queryRaw<{ teamId: number }[]>`
SELECT DISTINCT m."teamId"
FROM "Membership" m
INNER JOIN "Team" t ON m."teamId" = t.id
@@ -306,6 +343,7 @@ export class PermissionRepository implements IPermissionRepository {
AND m."accepted" = true
AND m."role"::text = ANY(${fallbackRoles})
AND f."teamId" IS NULL
AND (${orgId}::bigint IS NULL OR t."id" = ${orgId} OR t."parentId" = ${orgId})
UNION
SELECT DISTINCT child."id"
FROM "Membership" m
@@ -316,17 +354,7 @@ export class PermissionRepository implements IPermissionRepository {
AND m."accepted" = true
AND m."role"::text = ANY(${fallbackRoles})
AND f."teamId" IS NULL
AND (${orgId}::bigint IS NULL OR org."id" = ${orgId} OR child."id" = ${orgId} OR child."parentId" = ${orgId})
`;
const [teamsWithPermission, teamsWithFallbackRoles] = await Promise.all([
teamsWithPermissionPromise,
teamsWithFallbackRolesPromise,
]);
const pbacTeamIds = teamsWithPermission.map((team) => team.teamId);
const fallbackTeamIds = teamsWithFallbackRoles.map((team) => team.teamId);
const allTeamIds = Array.from(new Set([...pbacTeamIds, ...fallbackTeamIds]));
return allTeamIds;
}
}
@@ -969,5 +969,132 @@ describe("PermissionRepository - Integration Tests", () => {
expect(result).toContain(testTeamId);
});
it("should filter teams by orgId when provided", async () => {
// Create two organizations
const org1 = await prisma.team.create({
data: {
name: `Org 1 ${Date.now()}`,
slug: `org1-${Date.now()}`,
isOrganization: true,
},
});
const org2 = await prisma.team.create({
data: {
name: `Org 2 ${Date.now()}`,
slug: `org2-${Date.now()}`,
isOrganization: true,
},
});
// Create teams within each organization
const team1 = await prisma.team.create({
data: {
name: `Team 1 ${Date.now()}`,
slug: `team1-${Date.now()}`,
parentId: org1.id,
},
});
const team2 = await prisma.team.create({
data: {
name: `Team 2 ${Date.now()}`,
slug: `team2-${Date.now()}`,
parentId: org2.id,
},
});
// Create memberships with ADMIN role in both organizations
await prisma.membership.create({
data: {
userId: testUserId,
teamId: org1.id,
role: MembershipRole.ADMIN,
accepted: true,
},
});
await prisma.membership.create({
data: {
userId: testUserId,
teamId: org2.id,
role: MembershipRole.ADMIN,
accepted: true,
},
});
// Without orgId, should return both organizations
const resultWithoutScope = await repository.getTeamIdsWithPermissions({
userId: testUserId,
permissions: ["eventType.create"],
fallbackRoles: [MembershipRole.ADMIN],
});
expect(resultWithoutScope).toContain(org1.id);
expect(resultWithoutScope).toContain(org2.id);
// With orgId = org1, should only return org1 and its child teams
const resultWithScope = await repository.getTeamIdsWithPermissions({
userId: testUserId,
permissions: ["eventType.create"],
fallbackRoles: [MembershipRole.ADMIN],
orgId: org1.id,
});
expect(resultWithScope).toContain(org1.id);
expect(resultWithScope).toContain(team1.id);
expect(resultWithScope).not.toContain(org2.id);
expect(resultWithScope).not.toContain(team2.id);
// Cleanup
await prisma.membership.deleteMany({ where: { userId: testUserId } });
await prisma.team.deleteMany({ where: { id: { in: [org1.id, org2.id, team1.id, team2.id] } } });
});
it("should include child teams when orgId is provided", async () => {
// Create organization
const org = await prisma.team.create({
data: {
name: `Org ${Date.now()}`,
slug: `org-${Date.now()}`,
isOrganization: true,
},
});
// Create child team
const childTeam = await prisma.team.create({
data: {
name: `Child Team ${Date.now()}`,
slug: `child-team-${Date.now()}`,
parentId: org.id,
},
});
// Create membership with ADMIN role in organization
await prisma.membership.create({
data: {
userId: testUserId,
teamId: org.id,
role: MembershipRole.ADMIN,
accepted: true,
},
});
// With orgId, should return both org and child team
const result = await repository.getTeamIdsWithPermissions({
userId: testUserId,
permissions: ["eventType.create"],
fallbackRoles: [MembershipRole.ADMIN],
orgId: org.id,
});
expect(result).toContain(org.id);
expect(result).toContain(childTeam.id);
// Cleanup
await prisma.membership.deleteMany({ where: { userId: testUserId } });
await prisma.team.deleteMany({ where: { id: { in: [org.id, childTeam.id] } } });
});
});
});
@@ -136,7 +136,7 @@ export class PermissionCheckService {
userId,
teamId: team.parentId,
});
// Use the highest role between team and org
if (orgMembership) {
effectiveRole = this.getHighestRole(effectiveRole, orgMembership.role);
@@ -200,7 +200,7 @@ export class PermissionCheckService {
userId,
teamId: team.parentId,
});
// Use the highest role between team and org
if (orgMembership) {
effectiveRole = this.getHighestRole(effectiveRole, orgMembership.role);
@@ -293,10 +293,7 @@ export class PermissionCheckService {
return allowedRoles.includes(userRole);
}
private getHighestRole(
role1: MembershipRole | null,
role2: MembershipRole | null
): MembershipRole | null {
private getHighestRole(role1: MembershipRole | null, role2: MembershipRole | null): MembershipRole | null {
if (!role1) return role2;
if (!role2) return role1;
@@ -311,15 +308,18 @@ export class PermissionCheckService {
/**
* Gets all team IDs where the user has a specific permission
* @param orgId Optional organization ID to scope results to. When provided, only returns teams within this organization.
*/
async getTeamIdsWithPermission({
userId,
permission,
fallbackRoles,
orgId,
}: {
userId: number;
permission: PermissionString;
fallbackRoles: MembershipRole[];
orgId?: number;
}): Promise<number[]> {
try {
const validationResult = this.permissionService.validatePermission(permission);
@@ -328,7 +328,12 @@ export class PermissionCheckService {
return [];
}
return await this.repository.getTeamIdsWithPermission({ userId, permission, fallbackRoles });
return await this.repository.getTeamIdsWithPermission({
userId,
permission,
fallbackRoles,
orgId,
});
} catch (error) {
this.logger.error(error);
return [];
@@ -337,15 +342,18 @@ export class PermissionCheckService {
/**
* Gets all team IDs where the user has all of the specified permissions
* @param orgId Optional organization ID to scope results to. When provided, only returns teams within this organization.
*/
async getTeamIdsWithPermissions({
userId,
permissions,
fallbackRoles,
orgId,
}: {
userId: number;
permissions: PermissionString[];
fallbackRoles: MembershipRole[];
orgId?: number;
}): Promise<number[]> {
try {
const validationResult = this.permissionService.validatePermissions(permissions);
@@ -354,7 +362,12 @@ export class PermissionCheckService {
return [];
}
return await this.repository.getTeamIdsWithPermissions({ userId, permissions, fallbackRoles });
return await this.repository.getTeamIdsWithPermissions({
userId,
permissions,
fallbackRoles,
orgId,
});
} catch (error) {
this.logger.error(error);
return [];
@@ -0,0 +1,383 @@
import getAllUserBookings from "@calcom/features/bookings/lib/getAllUserBookings";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import type { DB } from "@calcom/kysely";
import type { PrismaClient } from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { Kysely } from "kysely";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getBookings, getHandler } from "./get.handler";
vi.mock("@calcom/features/pbac/services/permission-check.service", () => ({
PermissionCheckService: vi.fn(),
}));
vi.mock("@calcom/features/bookings/lib/getAllUserBookings");
vi.mock("@calcom/kysely", () => ({
default: {
selectFrom: vi.fn(),
executeQuery: vi.fn(),
},
}));
vi.mock("@calcom/lib/logger", () => ({
default: {
getSubLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
error: vi.fn(),
}),
},
}));
describe("getHandler", () => {
const mockUser = {
id: 1,
email: "user@example.com",
name: "Test User",
profile: {
organizationId: null,
},
};
const mockPrisma = {} as unknown as PrismaClient;
beforeEach(() => {
vi.clearAllMocks();
});
it("should return bookings successfully", async () => {
const mockBookings = [
{
id: 1,
uid: "booking-1",
startTime: new Date().toISOString(),
endTime: new Date().toISOString(),
rescheduler: null,
eventType: {
recurringEvent: null,
eventTypeColor: null,
price: 0,
currency: "usd",
metadata: {},
},
},
] as any;
vi.mocked(getAllUserBookings).mockResolvedValue({
bookings: mockBookings,
recurringInfo: [],
totalCount: 1,
});
const result = await getHandler({
ctx: {
user: mockUser as any,
prisma: mockPrisma,
},
input: {
filters: {},
limit: 10,
offset: 0,
},
});
expect(result.bookings).toEqual(mockBookings);
expect(result.totalCount).toBe(1);
expect(getAllUserBookings).toHaveBeenCalledWith(
expect.objectContaining({
ctx: expect.objectContaining({
user: expect.objectContaining({
id: mockUser.id,
email: mockUser.email,
orgId: null,
}),
}),
filters: {},
take: 10,
skip: 0,
bookingListingByStatus: ["upcoming"],
})
);
});
});
describe("getBookings - PBAC Permission Checks", () => {
const mockUser = {
id: 1,
email: "user@example.com",
orgId: null,
};
const mockPrisma = {
membership: {
findMany: vi.fn(),
},
user: {
findMany: vi.fn(),
},
eventType: {
findMany: vi.fn(),
},
booking: {
findUnique: vi.fn(),
groupBy: vi.fn(),
},
} as unknown as PrismaClient;
// Create a comprehensive kysely mock that handles all chain methods
const createMockKysely = () => {
const mockQueryBuilder = {
select: vi.fn((arg?: any) => {
// Handle select with callback function
if (typeof arg === "function") {
return mockQueryBuilder;
}
return mockQueryBuilder;
}),
selectAll: vi.fn(() => mockQueryBuilder),
where: vi.fn(() => mockQueryBuilder),
innerJoin: vi.fn(() => mockQueryBuilder),
union: vi.fn(() => mockQueryBuilder),
as: vi.fn(() => mockQueryBuilder),
$if: vi.fn(() => mockQueryBuilder),
orderBy: vi.fn(() => mockQueryBuilder),
limit: vi.fn(() => mockQueryBuilder),
offset: vi.fn(() => mockQueryBuilder),
compile: vi.fn(() => ({ sql: "SELECT * FROM bookings" })),
executeTakeFirst: vi.fn().mockResolvedValue({ bookingCount: 0 }),
execute: vi.fn().mockResolvedValue([]),
};
return {
selectFrom: vi.fn(() => mockQueryBuilder),
executeQuery: vi.fn().mockResolvedValue({ rows: [] }),
} as unknown as Kysely<DB>;
};
let mockKysely: Kysely<DB>;
const mockGetTeamIdsWithPermission = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockGetTeamIdsWithPermission.mockReset();
vi.mocked(PermissionCheckService).mockImplementation(function () {
return {
getTeamIdsWithPermission: mockGetTeamIdsWithPermission,
} as unknown as PermissionCheckService;
});
mockKysely = createMockKysely();
});
describe("PBAC permission checks with userIds filter", () => {
it("should call PermissionCheckService with correct parameters", async () => {
mockGetTeamIdsWithPermission.mockResolvedValue([1]);
mockPrisma.user.findMany = vi.fn().mockResolvedValue([{ id: 2, email: "member@example.com" }]);
mockPrisma.eventType.findMany = vi.fn().mockResolvedValue([]);
mockPrisma.booking.groupBy = vi.fn().mockResolvedValue([]);
await getBookings({
user: mockUser,
prisma: mockPrisma,
kysely: mockKysely,
bookingListingByStatus: ["upcoming"],
filters: {
userIds: [2],
},
take: 10,
skip: 0,
});
expect(mockGetTeamIdsWithPermission).toHaveBeenCalledWith({
userId: 1,
permission: "booking.read",
fallbackRoles: ["ADMIN", "OWNER"],
});
});
it("should throw FORBIDDEN when user doesn't have booking.read permission for filtered userIds", async () => {
// User has booking.read permission for team 1, but user 4 is not in that team
mockGetTeamIdsWithPermission.mockResolvedValue([1]);
// getUserIdsAndEmailsFromTeamIds returns only users from team 1 (ids 2, 3)
mockPrisma.user.findMany = vi.fn().mockResolvedValue([
{ id: 2, email: "member@example.com" },
{ id: 3, email: "member2@example.com" },
]);
mockPrisma.eventType.findMany = vi.fn().mockResolvedValue([]);
mockPrisma.booking.groupBy = vi.fn().mockResolvedValue([]);
const mockQueryBuilder = {
select: vi.fn(() => mockQueryBuilder),
where: vi.fn(() => mockQueryBuilder),
innerJoin: vi.fn(() => mockQueryBuilder),
union: vi.fn(() => mockQueryBuilder),
as: vi.fn(() => ({
select: vi.fn(() => ({
selectAll: vi.fn(() => ({
orderBy: vi.fn(() => ({
limit: vi.fn(() => ({
offset: vi.fn(() => ({
compile: vi.fn(() => ({ sql: "SELECT * FROM bookings" })),
})),
})),
})),
})),
executeTakeFirst: vi.fn().mockResolvedValue({ bookingCount: 0 }),
})),
})),
};
mockKysely.selectFrom = vi.fn(() => mockQueryBuilder as any);
mockKysely.executeQuery = vi.fn().mockResolvedValue({ rows: [] });
await expect(
getBookings({
user: mockUser,
prisma: mockPrisma,
kysely: mockKysely,
bookingListingByStatus: ["upcoming"],
filters: {
userIds: [4], // User 4 is not in team 1
},
take: 10,
skip: 0,
})
).rejects.toMatchObject({
code: "FORBIDDEN",
message: "You do not have permissions to fetch bookings for specified userIds",
});
});
it("should allow access when filtering by own userId", async () => {
mockGetTeamIdsWithPermission.mockResolvedValue([]);
// When userIds filter is provided, getAttendeeEmailsFromUserIdsFilter is called
// which looks up users by the userIds
mockPrisma.user.findMany = vi.fn((args: any) => {
// If looking up by userIds filter, return the user
if (args?.where?.id?.in?.includes(1)) {
return Promise.resolve([{ id: 1, email: "user@example.com" }]) as any;
}
// Otherwise return empty (for getUserIdsAndEmailsFromTeamIds)
return Promise.resolve([]) as any;
});
mockPrisma.eventType.findMany = vi.fn().mockResolvedValue([]);
mockPrisma.booking.groupBy = vi.fn().mockResolvedValue([]);
// User should always be able to access their own bookings
await expect(
getBookings({
user: mockUser,
prisma: mockPrisma,
kysely: mockKysely,
bookingListingByStatus: ["upcoming"],
filters: {
userIds: [1], // Own user ID
},
take: 10,
skip: 0,
})
).resolves.not.toThrow();
});
it("should use fallback ADMIN/OWNER roles when PBAC is not enabled", async () => {
// User is ADMIN in team 1 (fallback role)
mockGetTeamIdsWithPermission.mockResolvedValue([1]);
mockPrisma.user.findMany = vi.fn().mockResolvedValue([{ id: 2, email: "member@example.com" }]);
mockPrisma.eventType.findMany = vi.fn().mockResolvedValue([]);
mockPrisma.booking.groupBy = vi.fn().mockResolvedValue([]);
await expect(
getBookings({
user: mockUser,
prisma: mockPrisma,
kysely: mockKysely,
bookingListingByStatus: ["upcoming"],
filters: {
userIds: [2],
},
take: 10,
skip: 0,
})
).resolves.not.toThrow();
// Verify fallback roles are passed
expect(mockGetTeamIdsWithPermission).toHaveBeenCalledWith({
userId: 1,
permission: "booking.read",
fallbackRoles: ["ADMIN", "OWNER"],
});
});
it("should combine PBAC permissions and ADMIN/OWNER roles", async () => {
// User has booking.read permission for team 1 via PBAC
// User is ADMIN in team 2 (fallback)
mockGetTeamIdsWithPermission.mockResolvedValue([1, 2]);
mockPrisma.user.findMany = vi.fn().mockResolvedValue([
{ id: 2, email: "member-team1@example.com" },
{ id: 3, email: "member-team2@example.com" },
]);
mockPrisma.eventType.findMany = vi.fn().mockResolvedValue([]);
mockPrisma.booking.groupBy = vi.fn().mockResolvedValue([]);
// Should be able to access bookings from both teams
await expect(
getBookings({
user: mockUser,
prisma: mockPrisma,
kysely: mockKysely,
bookingListingByStatus: ["upcoming"],
filters: {
userIds: [2, 3],
},
take: 10,
skip: 0,
})
).resolves.not.toThrow();
});
});
describe("Event type filtering", () => {
it("should get event types from teams where user has booking.read permission", async () => {
mockGetTeamIdsWithPermission.mockResolvedValue([1]);
mockPrisma.user.findMany = vi.fn().mockResolvedValue([]);
mockPrisma.eventType.findMany = vi.fn().mockResolvedValue([{ id: 10 }, { id: 11 }]);
mockPrisma.booking.groupBy = vi.fn().mockResolvedValue([]);
await getBookings({
user: mockUser,
prisma: mockPrisma,
kysely: mockKysely,
bookingListingByStatus: ["upcoming"],
filters: {},
take: 10,
skip: 0,
});
// Verify event types are fetched from team 1
expect(mockPrisma.eventType.findMany).toHaveBeenCalled();
});
});
describe("User IDs and emails retrieval", () => {
it("should get user IDs and emails from teams with booking permission", async () => {
mockGetTeamIdsWithPermission.mockResolvedValue([1, 2]);
mockPrisma.user.findMany = vi.fn().mockResolvedValue([
{ id: 2, email: "user2@example.com" },
{ id: 3, email: "user3@example.com" },
]);
mockPrisma.eventType.findMany = vi.fn().mockResolvedValue([]);
mockPrisma.booking.groupBy = vi.fn().mockResolvedValue([]);
await getBookings({
user: mockUser,
prisma: mockPrisma,
kysely: mockKysely,
bookingListingByStatus: ["upcoming"],
filters: {},
take: 10,
skip: 0,
});
// Verify users are fetched from teams 1 and 2
expect(mockPrisma.user.findMany).toHaveBeenCalled();
});
});
});
@@ -5,6 +5,7 @@ import { jsonObjectFrom, jsonArrayFrom } from "kysely/helpers/postgres";
import dayjs from "@calcom/dayjs";
import getAllUserBookings from "@calcom/features/bookings/lib/getAllUserBookings";
import { isTextFilterValue } from "@calcom/features/data-table/lib/utils";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import type { DB } from "@calcom/kysely";
import kysely from "@calcom/kysely";
import { parseEventTypeColor } from "@calcom/lib/isEventTypeColor";
@@ -13,8 +14,7 @@ import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { PrismaClient } from "@calcom/prisma";
import type { Booking, Prisma, Prisma as PrismaClientType } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import { SchedulingType, BookingStatus, MembershipRole } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
@@ -111,63 +111,39 @@ export async function getBookings({
take: number;
skip: number;
}) {
const membershipIdsWhereUserIsAdminOwner = (
await prisma.membership.findMany({
where: {
userId: user.id,
role: {
in: ["ADMIN", "OWNER"],
},
...(user.orgId && {
OR: [
{
teamId: user.orgId,
},
{
team: {
parentId: user.orgId,
},
},
],
}),
},
select: {
id: true,
},
})
).map((membership) => membership.id);
const permissionCheckService = new PermissionCheckService();
const fallbackRoles: MembershipRole[] = [MembershipRole.ADMIN, MembershipRole.OWNER];
const membershipConditionWhereUserIsAdminOwner = {
some: {
id: { in: membershipIdsWhereUserIsAdminOwner },
},
};
const teamIdsWithBookingPermission = await permissionCheckService.getTeamIdsWithPermission({
userId: user.id,
permission: "booking.read",
fallbackRoles,
orgId: user.orgId ?? undefined,
});
const [
eventTypeIdsFromTeamIdsFilter,
attendeeEmailsFromUserIdsFilter,
eventTypeIdsFromEventTypeIdsFilter,
eventTypeIdsWhereUserIsAdminOrOwner,
userIdsAndEmailsWhereUserIsAdminOrOwner,
eventTypeIdsWhereUserHasBookingPermission,
userIdsAndEmailsWhereUserHasBookingPermission,
] = await Promise.all([
getEventTypeIdsFromTeamIdsFilter(prisma, filters?.teamIds),
getAttendeeEmailsFromUserIdsFilter(prisma, user.email, filters?.userIds),
getEventTypeIdsFromEventTypeIdsFilter(prisma, filters?.eventTypeIds),
getEventTypeIdsWhereUserIsAdminOrOwner(prisma, membershipConditionWhereUserIsAdminOwner),
getUserIdsAndEmailsWhereUserIsAdminOrOwner(prisma, membershipConditionWhereUserIsAdminOwner),
getEventTypeIdsFromTeamIdsFilter(prisma, teamIdsWithBookingPermission),
getUserIdsAndEmailsFromTeamIds(prisma, teamIdsWithBookingPermission),
]);
const bookingQueries: { query: BookingsUnionQuery; tables: (keyof DB)[] }[] = [];
// If user is organization owner/admin, contains organization members emails and ids (organization plan)
// If user is only team owner/admin, contain team members emails and ids (teams plan)
const [userIdsWhereUserIsAdminOrOwner, userEmailsWhereUserIsAdminOrOwner] =
userIdsAndEmailsWhereUserIsAdminOrOwner;
// Get user IDs and emails from teams where user has booking permission
const [allAccessibleUserIds, allAccessibleUserEmails] = userIdsAndEmailsWhereUserHasBookingPermission;
// If userIds filter is provided
if (!!filters?.userIds && filters.userIds.length > 0) {
const areUserIdsWithinUserOrgOrTeam = filters.userIds.every((userId) =>
userIdsWhereUserIsAdminOrOwner.includes(userId)
allAccessibleUserIds.includes(userId)
);
const isCurrentUser = filters.userIds.length === 1 && user.id === filters.userIds[0];
@@ -268,9 +244,8 @@ export async function getBookings({
tables: ["Booking", "Attendee", "BookingSeat"],
});
// 4. Scope depends on `user.orgId`:
// - If Current user is ORG_OWNER/ADMIN so we get bookings where organization members are attendees
// - If Current user is TEAM_OWNER/ADMIN so we get bookings where team members are attendees
if (userEmailsWhereUserIsAdminOrOwner?.length) {
// - If Current user is ORG_OWNER/ADMIN or has booking.read permission, get bookings where organization/team members are attendees
if (allAccessibleUserEmails?.length) {
bookingQueries.push({
query: kysely
.selectFrom("Booking")
@@ -280,14 +255,13 @@ export async function getBookings({
.select("Booking.createdAt")
.select("Booking.updatedAt")
.innerJoin("Attendee", "Attendee.bookingId", "Booking.id")
.where("Attendee.email", "in", userEmailsWhereUserIsAdminOrOwner),
.where("Attendee.email", "in", allAccessibleUserEmails),
tables: ["Booking", "Attendee"],
});
}
// 5. Scope depends on `user.orgId`:
// - If Current user is ORG_OWNER/ADMIN so we get bookings where organization members are attendees via seatsReference
// - If Current user is TEAM_OWNER/ADMIN so we get bookings where team members are attendees via seatsReference
if (userEmailsWhereUserIsAdminOrOwner?.length) {
// - If Current user is ORG_OWNER/ADMIN or has booking.read permission, get bookings where organization/team members are attendees via seatsReference
if (allAccessibleUserEmails?.length) {
bookingQueries.push({
query: kysely
.selectFrom("Booking")
@@ -298,15 +272,14 @@ export async function getBookings({
.select("Booking.updatedAt")
.innerJoin("Attendee", "Attendee.bookingId", "Booking.id")
.innerJoin("BookingSeat", "Attendee.id", "BookingSeat.attendeeId")
.where("Attendee.email", "in", userEmailsWhereUserIsAdminOrOwner),
.where("Attendee.email", "in", allAccessibleUserEmails),
tables: ["Booking", "Attendee", "BookingSeat"],
});
}
// 6. Scope depends on `user.orgId`:
// - If Current user is ORG_OWNER/ADMIN, get booking created for an event type within the organization
// - If Current user is TEAM_OWNER/ADMIN, get bookings created for an event type within the team
if (eventTypeIdsWhereUserIsAdminOrOwner?.length) {
// - If Current user is ORG_OWNER/ADMIN or has booking.read permission, get booking created for an event type within the organization/team
if (eventTypeIdsWhereUserHasBookingPermission?.length) {
bookingQueries.push({
query: kysely
.selectFrom("Booking")
@@ -316,15 +289,14 @@ export async function getBookings({
.select("Booking.createdAt")
.select("Booking.updatedAt")
.innerJoin("EventType", "EventType.id", "Booking.eventTypeId")
.where("Booking.eventTypeId", "in", eventTypeIdsWhereUserIsAdminOrOwner),
.where("Booking.eventTypeId", "in", eventTypeIdsWhereUserHasBookingPermission),
tables: ["Booking", "EventType"],
});
}
// 7. Scope depends on `user.orgId`:
// - If Current user is ORG_OWNER/ADMIN, get bookings created by users within the same organization
// - If Current user is TEAM_OWNER/ADMIN, get bookings created by users within the same organization
if (userIdsWhereUserIsAdminOrOwner?.length) {
// - If Current user is ORG_OWNER/ADMIN or has booking.read permission, get bookings created by users within the same organization/team
if (allAccessibleUserIds?.length) {
bookingQueries.push({
query: kysely
.selectFrom("Booking")
@@ -333,7 +305,7 @@ export async function getBookings({
.select("Booking.endTime")
.select("Booking.createdAt")
.select("Booking.updatedAt")
.where("Booking.userId", "in", userIdsWhereUserIsAdminOrOwner),
.where("Booking.userId", "in", allAccessibleUserIds),
tables: ["Booking"],
});
}
@@ -971,59 +943,26 @@ async function getEventTypeIdsFromEventTypeIdsFilter(prisma: PrismaClient, event
return eventTypeIdsFromDb;
}
async function getEventTypeIdsWhereUserIsAdminOrOwner(
prisma: PrismaClient,
membershipCondition: PrismaClientType.MembershipListRelationFilter
) {
const [directTeamEventTypeIds, parentTeamEventTypeIds] = await Promise.all([
prisma.eventType
.findMany({
where: {
team: {
members: membershipCondition,
},
},
select: {
id: true,
},
})
.then((eventTypes) => eventTypes.map((eventType) => eventType.id)),
prisma.eventType
.findMany({
where: {
parent: {
team: {
members: membershipCondition,
},
},
},
select: {
id: true,
},
})
.then((eventTypes) => eventTypes.map((eventType) => eventType.id)),
]);
return Array.from(new Set([...directTeamEventTypeIds, ...parentTeamEventTypeIds]));
}
/**
* Gets [IDs, Emails] of members where the auth user is team/org admin/owner.
* Gets [IDs, Emails] of members from specified team IDs.
* @param prisma The Prisma client.
* @param membershipCondition Filter containing the team/org ids where user is ADMIN/OWNER
* @returns {Promise<[number[], string[]]>} [UserIDs, UserEmails] for members in the determined scope.
* @param teamIds Array of team IDs to get members from
* @returns {Promise<[number[], string[]]>} [UserIDs, UserEmails] for members in the specified teams.
*/
async function getUserIdsAndEmailsWhereUserIsAdminOrOwner(
async function getUserIdsAndEmailsFromTeamIds(
prisma: PrismaClient,
membershipCondition: PrismaClientType.MembershipListRelationFilter
teamIds: number[]
): Promise<[number[], string[]]> {
if (teamIds.length === 0) {
return [[], []];
}
const users = await prisma.user.findMany({
where: {
teams: {
some: {
team: {
members: membershipCondition,
teamId: {
in: teamIds,
},
},
},