Skip to content

Commit 95120e1

Browse files
feat: add bookingRequiresAuthentication validation to 2024-04-15 booking controller (calcom#24735)
* feat: add bookingRequiresAuthentication validation to 2024-04-15 booking controller - Add checkBookingRequiresAuthentication method to validate authentication requirements - Check if user is event type owner, host, team admin/owner, or org admin/owner - Add comprehensive e2e tests for bookingRequiresAuthentication feature - Ensure parity with 2024-08-13 controller implementation - Fix type issue in setPlatformAttendeesEmails method Co-Authored-By: morgan@cal.com <morgan@cal.com> * refactor: move Prisma calls to repository pattern - Add findByIdIncludeHostsAndTeamMembers method to EventTypeRepository - Inject PrismaEventTypeRepository and PrismaTeamRepository into controller - Replace direct Prisma calls with repository methods in checkBookingRequiresAuthentication - Use getTeamByIdIfUserIsAdmin for org admin/owner check - Add repositories to BookingsModule_2024_04_15 providers Co-Authored-By: morgan@cal.com <morgan@cal.com> * handle httpException in handleBookingErrors * test: add test case for authenticated but unauthorized user booking - Create second user who is not authorized to book the event type - Verify that authenticated user without proper permissions receives 403 Forbidden - Test validates that bookingRequiresAuthentication properly checks authorization levels - Cleanup unauthorized user in afterAll hook Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: add accepted filter to team members and handle org-owned event types Addresses PR comments from cubic-dev-ai and @ThyMinimalDev: 1. Add accepted: true filter to team.members query - Prevents pending team invitations from being treated as authorized - Also filter by role to only fetch ADMIN and OWNER roles - Reduces payload size and improves query performance 2. Add isOrganization field to team select - Enables proper handling of org-owned event types 3. Update authorization logic for org-owned event types - Handle case where team.isOrganization is true with no parent - Ensure org admins/owners are properly authorized for org-owned events - Matches behavior of 2024-08-13 controller Changes: - packages/features/eventtypes/repositories/eventTypeRepository.ts: * Add where clause to members query with accepted: true and role filter * Add isOrganization: true to team select - apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts: * Update authorization logic to handle org-owned event types * Check if team.isOrganization is true when no parentId exists Co-Authored-By: morgan@cal.com <morgan@cal.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent ee00f0d commit 95120e1

4 files changed

Lines changed: 215 additions & 2 deletions

File tree

apps/api/v2/src/ee/bookings/2024-04-15/bookings.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/
99
import { InstantBookingModule } from "@/lib/modules/instant-booking.module";
1010
import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module";
1111
import { RegularBookingModule } from "@/lib/modules/regular-booking.module";
12+
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
13+
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
1214
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
1315
import { AppsRepository } from "@/modules/apps/apps.repository";
1416
import { BillingModule } from "@/modules/billing/billing.module";
@@ -55,6 +57,8 @@ import { Module } from "@nestjs/common";
5557
AppsRepository,
5658
CalendarsRepository,
5759
SelectedCalendarsRepository,
60+
PrismaEventTypeRepository,
61+
PrismaTeamRepository,
5862
],
5963
controllers: [BookingsController_2024_04_15],
6064
})

apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.e2e-spec.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,122 @@ describe("Bookings Endpoints 2024-04-15", () => {
507507
});
508508
});
509509

510+
describe("event type booking requires authentication", () => {
511+
let eventTypeRequiringAuthenticationId: number;
512+
let unauthorizedUser: User;
513+
let unauthorizedUserApiKeyString: string;
514+
515+
beforeAll(async () => {
516+
const eventTypeRequiringAuthentication = await eventTypesRepositoryFixture.create(
517+
{
518+
title: `event-type-requiring-authentication-${randomString()}`,
519+
slug: `event-type-requiring-authentication-${randomString()}`,
520+
length: 60,
521+
requiresConfirmation: true,
522+
bookingRequiresAuthentication: true,
523+
},
524+
user.id
525+
);
526+
eventTypeRequiringAuthenticationId = eventTypeRequiringAuthentication.id;
527+
528+
const unauthorizedUserEmail = `unauthorized-user-${randomString()}@api.com`;
529+
unauthorizedUser = await userRepositoryFixture.create({
530+
email: unauthorizedUserEmail,
531+
});
532+
const { keyString } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null);
533+
unauthorizedUserApiKeyString = keyString;
534+
});
535+
536+
afterAll(async () => {
537+
if (unauthorizedUser) {
538+
await userRepositoryFixture.deleteByEmail(unauthorizedUser.email);
539+
}
540+
});
541+
542+
it("can't be booked without credentials", async () => {
543+
const body: CreateBookingInput_2024_04_15 = {
544+
start: "2040-05-23T09:30:00.000Z",
545+
end: "2040-05-23T10:30:00.000Z",
546+
eventTypeId: eventTypeRequiringAuthenticationId,
547+
timeZone: "Europe/London",
548+
language: "en",
549+
metadata: {},
550+
hashedLink: "",
551+
responses: {
552+
name: "External Attendee",
553+
email: "external@example.com",
554+
location: {
555+
value: "link",
556+
optionValue: "",
557+
},
558+
},
559+
};
560+
561+
await request(app.getHttpServer()).post("/v2/bookings").send(body).expect(401);
562+
});
563+
564+
it("can't be booked with unauthorized user credentials", async () => {
565+
const body: CreateBookingInput_2024_04_15 = {
566+
start: "2040-05-23T10:30:00.000Z",
567+
end: "2040-05-23T11:30:00.000Z",
568+
eventTypeId: eventTypeRequiringAuthenticationId,
569+
timeZone: "Europe/London",
570+
language: "en",
571+
metadata: {},
572+
hashedLink: "",
573+
responses: {
574+
name: "External Attendee",
575+
email: "external@example.com",
576+
location: {
577+
value: "link",
578+
optionValue: "",
579+
},
580+
},
581+
};
582+
583+
await request(app.getHttpServer())
584+
.post("/v2/bookings")
585+
.send(body)
586+
.set({ Authorization: `Bearer cal_test_${unauthorizedUserApiKeyString}` })
587+
.expect(403);
588+
});
589+
590+
it("can be booked with event type owner credentials", async () => {
591+
const body: CreateBookingInput_2024_04_15 = {
592+
start: "2040-05-23T11:30:00.000Z",
593+
end: "2040-05-23T12:30:00.000Z",
594+
eventTypeId: eventTypeRequiringAuthenticationId,
595+
timeZone: "Europe/London",
596+
language: "en",
597+
metadata: {},
598+
hashedLink: "",
599+
responses: {
600+
name: "External Attendee",
601+
email: "external@example.com",
602+
location: {
603+
value: "link",
604+
optionValue: "",
605+
},
606+
},
607+
};
608+
609+
const response = await request(app.getHttpServer())
610+
.post("/v2/bookings")
611+
.send(body)
612+
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
613+
.expect(201);
614+
615+
const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
616+
expect(responseBody.status).toEqual(SUCCESS_STATUS);
617+
expect(responseBody.data).toBeDefined();
618+
expect(responseBody.data.id).toBeDefined();
619+
620+
if (responseBody.data.id) {
621+
await bookingsRepositoryFixture.deleteById(responseBody.data.id);
622+
}
623+
});
624+
});
625+
510626
afterAll(async () => {
511627
await userRepositoryFixture.deleteByEmail(user.email);
512628
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);

apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/ma
77
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
88
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
99
import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions";
10+
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
11+
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
1012
import { InstantBookingCreateService } from "@/lib/services/instant-booking-create.service";
1113
import { RecurringBookingService } from "@/lib/services/recurring-booking.service";
1214
import { RegularBookingService } from "@/lib/services/regular-booking.service";
@@ -38,6 +40,8 @@ import {
3840
NotFoundException,
3941
UseGuards,
4042
BadRequestException,
43+
UnauthorizedException,
44+
ForbiddenException,
4145
} from "@nestjs/common";
4246
import { ConfigService } from "@nestjs/config";
4347
import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
@@ -113,7 +117,9 @@ export class BookingsController_2024_04_15 {
113117
private readonly usersService: UsersService,
114118
private readonly regularBookingService: RegularBookingService,
115119
private readonly recurringBookingService: RecurringBookingService,
116-
private readonly instantBookingCreateService: InstantBookingCreateService
120+
private readonly instantBookingCreateService: InstantBookingCreateService,
121+
private readonly eventTypeRepository: PrismaEventTypeRepository,
122+
private readonly teamRepository: PrismaTeamRepository
117123
) {}
118124

119125
@Get("/")
@@ -190,6 +196,7 @@ export class BookingsController_2024_04_15 {
190196
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
191197
const { orgSlug, locationUrl } = body;
192198
try {
199+
await this.checkBookingRequiresAuthentication(req, body.eventTypeId);
193200
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed);
194201
const booking = await this.regularBookingService.createBooking({
195202
bookingData: bookingRequest.body,
@@ -443,6 +450,48 @@ export class BookingsController_2024_04_15 {
443450
return oAuthClientParams.platformClientId;
444451
}
445452

453+
private async checkBookingRequiresAuthentication(req: Request, eventTypeId: number): Promise<void> {
454+
const eventType = await this.eventTypeRepository.findByIdIncludeHostsAndTeamMembers({
455+
id: eventTypeId,
456+
});
457+
458+
if (!eventType?.bookingRequiresAuthentication) {
459+
return;
460+
}
461+
462+
const userId = await this.getOwnerId(req);
463+
464+
if (!userId) {
465+
throw new UnauthorizedException(
466+
"This event type requires authentication. Please provide valid credentials."
467+
);
468+
}
469+
470+
const isEventTypeOwner = eventType.userId === userId;
471+
const isHost = eventType.hosts.some((host) => host.userId === userId);
472+
const isTeamAdminOrOwner =
473+
eventType.team?.members.some((member) => member.userId === userId) ?? false;
474+
475+
let isOrgAdminOrOwner = false;
476+
if (eventType.team?.parentId) {
477+
const orgTeam = await this.teamRepository.getTeamByIdIfUserIsAdmin({
478+
userId,
479+
teamId: eventType.team.parentId,
480+
});
481+
isOrgAdminOrOwner = !!orgTeam;
482+
} else if (eventType.team?.isOrganization) {
483+
isOrgAdminOrOwner = isTeamAdminOrOwner;
484+
}
485+
486+
const isAuthorized = isEventTypeOwner || isHost || isTeamAdminOrOwner || isOrgAdminOrOwner;
487+
488+
if (!isAuthorized) {
489+
throw new ForbiddenException(
490+
"You are not authorized to book this event type. You must be the event type owner, a host, a team admin/owner, or an organization admin/owner."
491+
);
492+
}
493+
}
494+
446495
private async getOAuthClientsParams(clientId: string, isEmbed = false): Promise<OAuthRequestParams> {
447496
const res = { ...DEFAULT_PLATFORM_PARAMS };
448497

@@ -502,7 +551,10 @@ export class BookingsController_2024_04_15 {
502551
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
503552
}
504553

505-
async setPlatformAttendeesEmails(requestBody: any, oAuthClientId: string): Promise<void> {
554+
async setPlatformAttendeesEmails(
555+
requestBody: { responses?: { email?: string; guests?: string[] } },
556+
oAuthClientId: string
557+
): Promise<void> {
506558
if (requestBody?.responses?.email) {
507559
requestBody.responses.email = await this.platformBookingsService.getPlatformAttendeeEmail(
508560
requestBody.responses.email,
@@ -564,6 +616,9 @@ export class BookingsController_2024_04_15 {
564616

565617
if (err instanceof Error) {
566618
const error = err as Error;
619+
if (err instanceof HttpException) {
620+
throw new HttpException(err.getResponse(), err.getStatus());
621+
}
567622
if (Object.values(ErrorCode).includes(error.message as unknown as ErrorCode)) {
568623
throw new HttpException(error.message, 400);
569624
}

packages/features/eventtypes/repositories/eventTypeRepository.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1157,6 +1157,44 @@ export class EventTypeRepository {
11571157
};
11581158
}
11591159

1160+
async findByIdIncludeHostsAndTeamMembers({ id }: { id: number }) {
1161+
return await this.prismaClient.eventType.findUnique({
1162+
where: {
1163+
id,
1164+
},
1165+
select: {
1166+
id: true,
1167+
bookingRequiresAuthentication: true,
1168+
userId: true,
1169+
teamId: true,
1170+
hosts: {
1171+
select: {
1172+
userId: true,
1173+
},
1174+
},
1175+
team: {
1176+
select: {
1177+
id: true,
1178+
parentId: true,
1179+
isOrganization: true,
1180+
members: {
1181+
where: {
1182+
accepted: true,
1183+
role: {
1184+
in: ["ADMIN", "OWNER"],
1185+
},
1186+
},
1187+
select: {
1188+
userId: true,
1189+
role: true,
1190+
},
1191+
},
1192+
},
1193+
},
1194+
},
1195+
});
1196+
}
1197+
11601198
async findAllByTeamIdIncludeManagedEventTypes({ teamId }: { teamId?: number }) {
11611199
return await this.prismaClient.eventType.findMany({
11621200
where: {

0 commit comments

Comments
 (0)