Skip to content

Commit 7ff2eaf

Browse files
eunjae-leedevin-ai-integration[bot]claude
authored
fix: consolidate booking access checks into doesUserIdHaveAccessToBooking (calcom#28071)
* fix: align checkBookingAccessWithPBAC with listing logic for personal event types Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: align permission strings with doesUserIdHaveAccessToBooking granular permissions Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * change implementation * remove unused code * test: add getBookingDetails tests for personal event type booking access Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: remove unused MembershipRole import Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * test: convert BookingDetailsService tests to integration tests Replace unit tests with mocks by integration tests using real database. Tests verify org/team admin access to personal event type bookings. - Org admin viewing team member's personal event booking (fails on main, passes on PR) - Team admin viewing team member's personal event booking (fails on main, passes on PR) - Non-admin denied access (passes on both) - Owner viewing own booking (passes on both) - Non-existent booking returns Forbidden (passes on both) Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * test: refactor integration tests to use repositories instead of direct prisma calls Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * test: replace direct prisma usage with TestXXXRepository pattern in integration tests Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * test: use production repositories (OrganizationRepository, BookingRepository, UserRepository) instead of test repos for setup Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * test: inline test repository logic into integration test file The TestXXXRepository classes were thin wrappers around single prisma calls with barely any logic, so the indirection wasn't justified. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d3dad19 commit 7ff2eaf

6 files changed

Lines changed: 284 additions & 108 deletions

File tree

packages/features/bookings/repositories/BookingRepository.ts

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,7 @@ import { withReporting } from "@calcom/lib/sentryWrapper";
22
import type { PrismaClient } from "@calcom/prisma";
33
import type { Booking, Prisma } from "@calcom/prisma/client";
44
import { BookingStatus, RRTimestampBasis } from "@calcom/prisma/enums";
5-
import {
6-
bookingAuthorizationCheckSelect,
7-
bookingDetailsSelect,
8-
bookingMinimalSelect,
9-
} from "@calcom/prisma/selects/booking";
5+
import { bookingDetailsSelect, bookingMinimalSelect } from "@calcom/prisma/selects/booking";
106
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
117
import { workflowSelect } from "../../ee/workflows/lib/getAllWorkflows";
128
import type {
@@ -674,15 +670,6 @@ export class BookingRepository implements IBookingRepository {
674670
});
675671
}
676672

677-
async findByUidForAuthorizationCheck({ bookingUid }: { bookingUid: string }) {
678-
return await this.prismaClient.booking.findUnique({
679-
where: {
680-
uid: bookingUid,
681-
},
682-
select: bookingAuthorizationCheckSelect,
683-
});
684-
}
685-
686673
async findByUidForDetails({ bookingUid }: { bookingUid: string }) {
687674
return await this.prismaClient.booking.findUnique({
688675
where: {

packages/features/bookings/services/BookingAccessService.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import { describe, it, expect, beforeEach, vi } from "vitest";
2-
31
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
42
import type { PrismaClient } from "@calcom/prisma";
53
import { MembershipRole } from "@calcom/prisma/enums";
6-
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
75
import { BookingRepository } from "../repositories/BookingRepository";
86
import { BookingAccessService } from "./BookingAccessService";
97

@@ -341,4 +339,5 @@ describe("BookingAccessService", () => {
341339
});
342340
});
343341
});
342+
344343
});

packages/features/bookings/services/BookingAccessService.ts

Lines changed: 0 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { PermissionCheckService } from "@calcom/features/pbac/services/permissio
22
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
33
import type { PrismaClient } from "@calcom/prisma";
44
import { MembershipRole } from "@calcom/prisma/enums";
5-
65
import { BookingRepository } from "../repositories/BookingRepository";
76

87
type BookingForAccessCheck = NonNullable<Awaited<ReturnType<BookingRepository["findByUidIncludeEventType"]>>>;
@@ -131,62 +130,4 @@ export class BookingAccessService {
131130

132131
return false;
133132
}
134-
135-
/**
136-
* Checks if a user has access to view booking details using:
137-
* 1. Owner/host check
138-
* 2. PBAC permission check for team bookings
139-
*/
140-
async checkBookingAccessWithPBAC({
141-
userId,
142-
bookingUid,
143-
}: {
144-
userId: number;
145-
bookingUid: string;
146-
}): Promise<boolean> {
147-
const bookingRepo = new BookingRepository(this.prismaClient);
148-
149-
const booking = await bookingRepo.findByUidForAuthorizationCheck({ bookingUid });
150-
151-
if (!booking) {
152-
return false;
153-
}
154-
155-
// Check 1: User is the owner of the booking
156-
const isOwner = booking.userId === userId;
157-
if (isOwner) {
158-
return true;
159-
}
160-
161-
// Check 2: User is a host (checking eventType.users and eventType.hosts)
162-
const attendeeEmails = new Set(booking.attendees?.map((attendee) => attendee.email) || []);
163-
164-
const isHostViaEventTypeUsers = booking.eventType?.users?.some(
165-
(user) => user.id === userId && attendeeEmails.has(user.email)
166-
);
167-
168-
const isHostViaEventTypeHosts = booking.eventType?.hosts?.some(
169-
(host) => host.user?.id === userId && attendeeEmails.has(host.user.email)
170-
);
171-
172-
if (isHostViaEventTypeUsers || isHostViaEventTypeHosts) {
173-
return true;
174-
}
175-
176-
// Check 3: PBAC permission check for team bookings
177-
if (!booking.eventType?.teamId) {
178-
// No team associated with booking and user is not owner/host
179-
return false;
180-
}
181-
182-
const permissionCheckService = new PermissionCheckService();
183-
const hasPermission = await permissionCheckService.checkPermission({
184-
userId,
185-
teamId: booking.eventType.teamId,
186-
permission: "booking.read",
187-
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
188-
});
189-
190-
return hasPermission;
191-
}
192133
}
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
2+
3+
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
4+
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
5+
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
6+
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
7+
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
8+
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
9+
import { prisma } from "@calcom/prisma";
10+
import { BookingStatus, CreationSource, MembershipRole } from "@calcom/prisma/enums";
11+
12+
import { BookingDetailsService } from "./BookingDetailsService";
13+
14+
/**
15+
* Integration tests for BookingDetailsService.getBookingDetails
16+
*
17+
* These tests verify that org/team admins can view booking details
18+
* for bookings made by team members using personal event types (no teamId).
19+
*
20+
* On main (before the fix), the access check (checkBookingAccessWithPBAC) returns false
21+
* for personal event type bookings when the viewer is not the owner/host — even if
22+
* they are an org/team admin. These tests fail on main, proving the bug.
23+
*
24+
* On the PR branch, getBookingDetails uses doesUserIdHaveAccessToBooking which
25+
* correctly checks org/team admin access for personal event type bookings.
26+
*/
27+
28+
// Track resource IDs for cleanup
29+
const createdBookingIds: number[] = [];
30+
const createdTeamIds: number[] = [];
31+
const createdUserIds: number[] = [];
32+
33+
describe("BookingDetailsService (Integration Tests)", () => {
34+
const timestamp = Date.now();
35+
36+
const orgRepo = new OrganizationRepository({ prismaClient: prisma });
37+
const bookingRepo = new BookingRepository(prisma);
38+
const userRepo = new UserRepository(prisma);
39+
const eventTypeRepo = new EventTypeRepository(prisma);
40+
const teamRepo = new TeamRepository(prisma);
41+
42+
// Users
43+
let bookingOwnerId: number;
44+
let orgAdminId: number;
45+
let teamAdminId: number;
46+
let regularUserId: number;
47+
48+
// Org and team
49+
let orgId: number;
50+
let teamId: number;
51+
52+
// Booking
53+
let personalBookingUid: string;
54+
55+
beforeAll(async () => {
56+
// 1. Create an organization via OrganizationRepository
57+
const org = await orgRepo.create({
58+
name: `Test Org ${timestamp}`,
59+
slug: `test-org-${timestamp}`,
60+
isOrganizationConfigured: true,
61+
isOrganizationAdminReviewed: true,
62+
autoAcceptEmail: `test-${timestamp}.com`,
63+
seats: null,
64+
pricePerSeat: null,
65+
isPlatform: false,
66+
logoUrl: null,
67+
bio: null,
68+
brandColor: null,
69+
bannerUrl: null,
70+
});
71+
orgId = org.id;
72+
createdTeamIds.push(org.id);
73+
74+
// 2. Create a team within the org (no production TeamRepository.create() exists)
75+
const team = await prisma.team.create({
76+
data: {
77+
name: `Test Team ${timestamp}`,
78+
slug: `test-team-${timestamp}`,
79+
parentId: org.id,
80+
},
81+
select: { id: true },
82+
});
83+
teamId = team.id;
84+
createdTeamIds.push(team.id);
85+
86+
// 3. Create booking owner WITH organizationId via UserRepository
87+
// (eliminates need for separate updateOrganizationId call)
88+
const bookingOwner = await userRepo.create({
89+
email: `booking-owner-${timestamp}@test.com`,
90+
username: `booking-owner-${timestamp}`,
91+
organizationId: orgId,
92+
creationSource: CreationSource.WEBAPP,
93+
locked: false,
94+
});
95+
bookingOwnerId = bookingOwner.id;
96+
createdUserIds.push(bookingOwner.id);
97+
98+
// 4. Create other users via UserRepository
99+
const orgAdmin = await userRepo.create({
100+
email: `org-admin-${timestamp}@test.com`,
101+
username: `org-admin-${timestamp}`,
102+
organizationId: null,
103+
creationSource: CreationSource.WEBAPP,
104+
locked: false,
105+
});
106+
orgAdminId = orgAdmin.id;
107+
createdUserIds.push(orgAdmin.id);
108+
109+
const teamAdmin = await userRepo.create({
110+
email: `team-admin-${timestamp}@test.com`,
111+
username: `team-admin-${timestamp}`,
112+
organizationId: null,
113+
creationSource: CreationSource.WEBAPP,
114+
locked: false,
115+
});
116+
teamAdminId = teamAdmin.id;
117+
createdUserIds.push(teamAdmin.id);
118+
119+
const regularUser = await userRepo.create({
120+
email: `regular-user-${timestamp}@test.com`,
121+
username: `regular-user-${timestamp}`,
122+
organizationId: null,
123+
creationSource: CreationSource.WEBAPP,
124+
locked: false,
125+
});
126+
regularUserId = regularUser.id;
127+
createdUserIds.push(regularUser.id);
128+
129+
// 5. Create memberships via MembershipRepository (static method)
130+
await MembershipRepository.create({
131+
userId: bookingOwnerId,
132+
teamId: orgId,
133+
role: MembershipRole.MEMBER,
134+
accepted: true,
135+
});
136+
137+
await MembershipRepository.create({
138+
userId: bookingOwnerId,
139+
teamId: teamId,
140+
role: MembershipRole.MEMBER,
141+
accepted: true,
142+
});
143+
144+
await MembershipRepository.create({
145+
userId: orgAdminId,
146+
teamId: orgId,
147+
role: MembershipRole.ADMIN,
148+
accepted: true,
149+
});
150+
151+
await MembershipRepository.create({
152+
userId: teamAdminId,
153+
teamId: teamId,
154+
role: MembershipRole.ADMIN,
155+
accepted: true,
156+
});
157+
158+
// 6. Create a personal event type (no teamId) via EventTypeRepository
159+
const personalEventType = await eventTypeRepo.create({
160+
title: `Personal Event ${timestamp}`,
161+
slug: `personal-event-${timestamp}`,
162+
length: 30,
163+
userId: bookingOwnerId,
164+
});
165+
166+
// 7. Create a booking on the personal event type via BookingRepository
167+
personalBookingUid = `personal-booking-${timestamp}`;
168+
const booking = await bookingRepo.createBookingForManagedEventReassignment({
169+
uid: personalBookingUid,
170+
userId: bookingOwnerId,
171+
userPrimaryEmail: `booking-owner-${timestamp}@test.com`,
172+
eventTypeId: personalEventType.id,
173+
title: `Personal Booking ${timestamp}`,
174+
description: null,
175+
startTime: new Date("2026-03-01T10:00:00.000Z"),
176+
endTime: new Date("2026-03-01T10:30:00.000Z"),
177+
status: BookingStatus.ACCEPTED,
178+
location: null,
179+
smsReminderNumber: null,
180+
idempotencyKey: `idempotency-${timestamp}`,
181+
iCalUID: `ical-${timestamp}@test.com`,
182+
iCalSequence: 0,
183+
attendees: [{ email: "attendee@test.com", name: "Test Attendee", timeZone: "UTC", locale: "en" }],
184+
});
185+
createdBookingIds.push(booking.id);
186+
});
187+
188+
afterAll(async () => {
189+
// Clean up bookings and attendees
190+
if (createdBookingIds.length > 0) {
191+
await prisma.attendee.deleteMany({ where: { bookingId: { in: createdBookingIds } } });
192+
await prisma.booking.deleteMany({ where: { id: { in: createdBookingIds } } });
193+
}
194+
195+
// Delete personal event types not associated with a team
196+
if (createdUserIds.length > 0) {
197+
await prisma.eventType.deleteMany({ where: { userId: { in: createdUserIds } } });
198+
}
199+
200+
// Delete users (memberships, profiles, schedules, availability)
201+
// Must happen before team deletion so user.organizationId FK is removed
202+
if (createdUserIds.length > 0) {
203+
await prisma.membership.deleteMany({ where: { userId: { in: createdUserIds } } });
204+
await prisma.profile.deleteMany({ where: { userId: { in: createdUserIds } } });
205+
for (const userId of createdUserIds) {
206+
await prisma.availability.deleteMany({ where: { Schedule: { userId } } });
207+
await prisma.schedule.deleteMany({ where: { userId } });
208+
await prisma.user.delete({ where: { id: userId } });
209+
}
210+
}
211+
212+
// TeamRepository.deleteById handles remaining memberships + managed event types
213+
for (const id of [...createdTeamIds].reverse()) {
214+
await teamRepo.deleteById({ id });
215+
}
216+
});
217+
218+
describe("getBookingDetails - personal event type bookings", () => {
219+
it("should allow the booking owner to view their own booking details", async () => {
220+
const service = new BookingDetailsService(prisma);
221+
222+
const result = await service.getBookingDetails({
223+
userId: bookingOwnerId,
224+
bookingUid: personalBookingUid,
225+
});
226+
227+
expect(result).toBeDefined();
228+
expect(result.rescheduledToBooking).toBeNull();
229+
expect(result.previousBooking).toBeNull();
230+
});
231+
232+
it("should allow an org admin to view booking details for a team member's personal event type booking", async () => {
233+
const service = new BookingDetailsService(prisma);
234+
235+
const result = await service.getBookingDetails({
236+
userId: orgAdminId,
237+
bookingUid: personalBookingUid,
238+
});
239+
240+
expect(result).toBeDefined();
241+
expect(result.rescheduledToBooking).toBeNull();
242+
expect(result.previousBooking).toBeNull();
243+
});
244+
245+
it("should allow a team admin to view booking details for a team member's personal event type booking", async () => {
246+
const service = new BookingDetailsService(prisma);
247+
248+
const result = await service.getBookingDetails({
249+
userId: teamAdminId,
250+
bookingUid: personalBookingUid,
251+
});
252+
253+
expect(result).toBeDefined();
254+
expect(result.rescheduledToBooking).toBeNull();
255+
expect(result.previousBooking).toBeNull();
256+
});
257+
258+
it("should deny access to a non-admin user viewing another user's personal event type booking", async () => {
259+
const service = new BookingDetailsService(prisma);
260+
261+
await expect(
262+
service.getBookingDetails({
263+
userId: regularUserId,
264+
bookingUid: personalBookingUid,
265+
})
266+
).rejects.toThrow("You do not have permission to view this booking");
267+
});
268+
269+
it("should throw when booking does not exist", async () => {
270+
const service = new BookingDetailsService(prisma);
271+
272+
await expect(
273+
service.getBookingDetails({
274+
userId: bookingOwnerId,
275+
bookingUid: "non-existent-booking-uid",
276+
})
277+
).rejects.toThrow("You do not have permission to view this booking");
278+
});
279+
});
280+
});

0 commit comments

Comments
 (0)