Skip to content

Commit e0dc2ba

Browse files
authored
feat: authentication secured event types (calcom#23217)
* feat: EventType bookingRequiresAuthentication column * feat: toggle EventType bookingRequiresAuthentication via api * feat: check bookingRequiresAuthentication when booking * docs: v2 swagger * fix: ts error * refactor: use Forbidden exception instead of Unauthorized * refactor: use findFirst instead of unique * fix: only count accepted memberships * fix: orgs schedules docs * regenerate docs * chore: update platform libraries * fix: unit test * fix: e2e test
1 parent aea6e10 commit e0dc2ba

25 files changed

Lines changed: 810 additions & 17 deletions

File tree

apps/api/v2/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"@axiomhq/winston": "^1.2.0",
3939
"@calcom/platform-constants": "*",
4040
"@calcom/platform-enums": "*",
41-
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.320",
41+
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.321",
4242
"@calcom/platform-types": "*",
4343
"@calcom/platform-utils": "*",
4444
"@calcom/prisma": "*",

apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { BookingSeatModule } from "@/modules/booking-seat/booking-seat.module";
2222
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
2323
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
2424
import { KyselyModule } from "@/modules/kysely/kysely.module";
25+
import { MembershipsModule } from "@/modules/memberships/memberships.module";
2526
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
2627
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
2728
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
@@ -53,6 +54,7 @@ import { Module } from "@nestjs/common";
5354
StripeModule,
5455
TeamsModule,
5556
TeamsEventTypesModule,
57+
MembershipsModule,
5658
],
5759
providers: [
5860
TokensRepository,

apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,14 @@ import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.ser
1313
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
1414
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
1515
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
16+
import {
17+
AuthOptionalUser,
18+
GetOptionalUser,
19+
} from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
1620
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
1721
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
18-
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
1922
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
23+
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
2024
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
2125
import { UsersService } from "@/modules/users/services/users.service";
2226
import { UserWithProfile } from "@/modules/users/users.repository";
@@ -96,6 +100,7 @@ export class BookingsController_2024_08_13 {
96100
) {}
97101

98102
@Post("/")
103+
@UseGuards(OptionalApiAuthGuard)
99104
@ApiOperation({
100105
summary: "Create a booking",
101106
description: `
@@ -138,9 +143,10 @@ export class BookingsController_2024_08_13 {
138143
async createBooking(
139144
@Body(new CreateBookingInputPipe())
140145
body: CreateBookingInput,
141-
@Req() request: Request
146+
@Req() request: Request,
147+
@GetOptionalUser() user: AuthOptionalUser
142148
): Promise<CreateBookingOutput_2024_08_13> {
143-
const booking = await this.bookingsService.createBooking(request, body);
149+
const booking = await this.bookingsService.createBooking(request, body, user);
144150

145151
if (Array.isArray(booking)) {
146152
await this.bookingsService.billBookings(booking);

apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/managed-user-bookings.e2e-spec.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AppModule } from "@/app.module";
33
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
44
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
55
import { Locales } from "@/lib/enums/locales";
6+
import { MembershipsModule } from "@/modules/memberships/memberships.module";
67
import {
78
CreateManagedUserData,
89
CreateManagedUserOutput,
@@ -80,7 +81,7 @@ describe("Managed user bookings 2024-08-13", () => {
8081
beforeAll(async () => {
8182
const moduleRef = await Test.createTestingModule({
8283
providers: [PrismaExceptionFilter, HttpExceptionFilter],
83-
imports: [AppModule, UsersModule],
84+
imports: [AppModule, UsersModule, MembershipsModule],
8485
}).compile();
8586

8687
app = moduleRef.createNestApplication();
@@ -772,6 +773,77 @@ describe("Managed user bookings 2024-08-13", () => {
772773
});
773774
});
774775

776+
describe("event type booking requires authentication", () => {
777+
let eventTypeRequiringAuthenticationId: number;
778+
779+
let body: CreateBookingInput_2024_08_13;
780+
781+
beforeAll(async () => {
782+
const eventTypeRequiringAuthentication = await eventTypesRepositoryFixture.create(
783+
{
784+
title: `event-type-requiring-authentication-${randomString()}`,
785+
slug: `event-type-requiring-authentication-${randomString()}`,
786+
length: 60,
787+
requiresConfirmation: true,
788+
bookingRequiresAuthentication: true,
789+
},
790+
secondManagedUser.user.id
791+
);
792+
eventTypeRequiringAuthenticationId = eventTypeRequiringAuthentication.id;
793+
794+
body = {
795+
start: new Date(Date.UTC(2030, 0, 9, 15, 0, 0)).toISOString(),
796+
eventTypeId: eventTypeRequiringAuthenticationId,
797+
attendee: {
798+
email: "external@example.com",
799+
name: "External Attendee",
800+
timeZone: "Europe/Rome",
801+
},
802+
};
803+
});
804+
805+
it("can't be booked without credentials", async () => {
806+
await request(app.getHttpServer())
807+
.post(`/v2/bookings`)
808+
.send(body)
809+
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
810+
.expect(401);
811+
});
812+
813+
it("can't be booked with managed user credentials who is not admin and not event type owner", async () => {
814+
await request(app.getHttpServer())
815+
.post(`/v2/bookings`)
816+
.send(body)
817+
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
818+
.set("Authorization", `Bearer ${firstManagedUser.accessToken}`)
819+
.expect(403);
820+
});
821+
822+
it("can be booked with managed user credentials who is event type owner", async () => {
823+
const response = await request(app.getHttpServer())
824+
.post(`/v2/bookings`)
825+
.send(body)
826+
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
827+
.set("Authorization", `Bearer ${secondManagedUser.accessToken}`)
828+
.expect(201);
829+
830+
const bookingId = response.body.data.id;
831+
await bookingsRepositoryFixture.deleteById(bookingId);
832+
});
833+
834+
it("can be booked with managed user credentials who is admin", async () => {
835+
const response = await request(app.getHttpServer())
836+
.post(`/v2/bookings`)
837+
.send(body)
838+
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
839+
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
840+
.expect(201);
841+
842+
const bookingId = response.body.data.id;
843+
await bookingsRepositoryFixture.deleteById(bookingId);
844+
});
845+
});
846+
775847
afterAll(async () => {
776848
await userRepositoryFixture.delete(firstManagedUser.user.id);
777849
await userRepositoryFixture.delete(secondManagedUser.user.id);

apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/servi
66
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
77
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
88
import { getPagination } from "@/lib/pagination/pagination";
9+
import { AuthOptionalUser } from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
910
import { BillingService } from "@/modules/billing/services/billing.service";
1011
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
1112
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
13+
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
14+
import { MembershipsService } from "@/modules/memberships/services/memberships.service";
1215
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
1316
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
1417
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
@@ -18,7 +21,14 @@ import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-eve
1821
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
1922
import { UsersService } from "@/modules/users/services/users.service";
2023
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
21-
import { ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
24+
import {
25+
ConflictException,
26+
ForbiddenException,
27+
Injectable,
28+
Logger,
29+
NotFoundException,
30+
UnauthorizedException,
31+
} from "@nestjs/common";
2232
import { BadRequestException } from "@nestjs/common";
2333
import { Request } from "express";
2434
import { DateTime } from "luxon";
@@ -95,10 +105,12 @@ export class BookingsService_2024_08_13 {
95105
private readonly organizationsRepository: OrganizationsRepository,
96106
private readonly teamsRepository: TeamsRepository,
97107
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
108+
private readonly membershipsRepository: MembershipsRepository,
109+
private readonly membershipsService: MembershipsService,
98110
private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13
99111
) {}
100112

101-
async createBooking(request: Request, body: CreateBookingInput) {
113+
async createBooking(request: Request, body: CreateBookingInput, authUser: AuthOptionalUser) {
102114
let bookingTeamEventType = false;
103115
try {
104116
const eventType = await this.getBookedEventType(body);
@@ -108,6 +120,7 @@ export class BookingsService_2024_08_13 {
108120
if (!eventType) {
109121
this.errorsBookingsService.handleEventTypeToBeBookedNotFound(body);
110122
}
123+
await this.checkBookingRequiresAuthenticationSetting(eventType, authUser);
111124

112125
if (eventType.schedulingType === "MANAGED") {
113126
throw new BadRequestException(
@@ -142,6 +155,56 @@ export class BookingsService_2024_08_13 {
142155
}
143156
}
144157

158+
async checkBookingRequiresAuthenticationSetting(
159+
eventType: EventTypeWithOwnerAndTeam,
160+
authUser: AuthOptionalUser
161+
) {
162+
if (!eventType.bookingRequiresAuthentication) return true;
163+
if (!authUser) {
164+
throw new UnauthorizedException(
165+
"checkBookingRequiresAuthentication - request must be authenticated by passing credentials belonging to event type owner, host or team or org admin or owner."
166+
);
167+
}
168+
169+
const authUserId = authUser.id;
170+
const authUserRole = authUser.role;
171+
const eventTypeId = eventType.id;
172+
const teamId = eventType.teamId;
173+
const bookingUserId = eventType.owner?.id || null;
174+
175+
if (authUserRole === "ADMIN") return;
176+
177+
if (bookingUserId === authUserId) return;
178+
179+
if (eventTypeId) {
180+
const [isUserHost, isUserAssigned] = await Promise.all([
181+
this.eventTypesRepository.isUserHostOfEventType(authUserId, eventTypeId),
182+
this.eventTypesRepository.isUserAssignedToEventType(authUserId, eventTypeId),
183+
]);
184+
185+
if (isUserHost || isUserAssigned) return;
186+
}
187+
188+
if (teamId) {
189+
const membership = await this.membershipsRepository.getUserAdminOrOwnerTeamMembership(
190+
authUserId,
191+
teamId
192+
);
193+
if (membership) return;
194+
}
195+
196+
if (
197+
bookingUserId &&
198+
(await this.membershipsService.isUserOrgAdminOrOwnerOfAnotherUser(authUserId, bookingUserId))
199+
) {
200+
return;
201+
}
202+
203+
throw new ForbiddenException(
204+
"checkBookingRequiresAuthentication - user is not authorized to access this event type. User has to be either event type owner, host, team admin or owner or org admin or owner."
205+
);
206+
}
207+
145208
async getBookedEventType(body: CreateBookingInput) {
146209
if (body.eventTypeId) {
147210
return await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(body.eventTypeId);

apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,7 @@ describe("Event types Endpoints", () => {
498498
lightThemeHex: "#fafafa",
499499
},
500500
customName: `{Event type title} between {Organiser} and {Scheduler}`,
501+
bookingRequiresAuthentication: true,
501502
};
502503

503504
return request(app.getHttpServer())
@@ -570,6 +571,7 @@ describe("Event types Endpoints", () => {
570571
];
571572

572573
expect(createdEventType.bookingFields).toEqual(expectedBookingFields);
574+
expect(createdEventType.bookingRequiresAuthentication).toEqual(true);
573575
eventType = responseBody.data;
574576
});
575577
});
@@ -1026,6 +1028,7 @@ describe("Event types Endpoints", () => {
10261028
lightThemeHex: "#fafafa",
10271029
},
10281030
customName: `{Event type title} betweennnnnnnnnnn {Organiser} and {Scheduler}`,
1031+
bookingRequiresAuthentication: false,
10291032
};
10301033

10311034
return request(app.getHttpServer())
@@ -1120,6 +1123,8 @@ describe("Event types Endpoints", () => {
11201123
eventType.color = updatedEventType.color;
11211124
eventType.bookingFields = updatedEventType.bookingFields;
11221125
eventType.calVideoSettings = updatedEventType.calVideoSettings;
1126+
1127+
expect(updatedEventType.bookingRequiresAuthentication).toEqual(false);
11231128
});
11241129
});
11251130

@@ -2028,7 +2033,6 @@ describe("Event types Endpoints", () => {
20282033
const username = name;
20292034
let user: User;
20302035
let legacyEventTypeId1: number;
2031-
let legacyEventTypeId2: number;
20322036

20332037
beforeAll(async () => {
20342038
const moduleRef = await withApiAuth(

apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,4 +132,26 @@ export class EventTypesRepository_2024_06_14 {
132132
async deleteEventType(eventTypeId: number) {
133133
return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } });
134134
}
135+
136+
async isUserHostOfEventType(userId: number, eventTypeId: number) {
137+
const eventType = await this.dbRead.prisma.eventType.findFirst({
138+
where: {
139+
id: eventTypeId,
140+
hosts: { some: { userId: userId } },
141+
},
142+
select: { id: true },
143+
});
144+
return !!eventType;
145+
}
146+
147+
async isUserAssignedToEventType(userId: number, eventTypeId: number) {
148+
const eventType = await this.dbRead.prisma.eventType.findFirst({
149+
where: {
150+
id: eventTypeId,
151+
users: { some: { id: userId } },
152+
},
153+
select: { id: true },
154+
});
155+
return !!eventType;
156+
}
135157
}

apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ type Input = Pick<
9090
| "hideOrganizerEmail"
9191
| "calVideoSettings"
9292
| "hidden"
93+
| "bookingRequiresAuthentication"
9394
>;
9495

9596
@Injectable()
@@ -129,6 +130,7 @@ export class OutputEventTypesService_2024_06_14 {
129130
hideOrganizerEmail,
130131
calVideoSettings,
131132
hidden,
133+
bookingRequiresAuthentication,
132134
} = databaseEventType;
133135

134136
const locations = this.transformLocations(databaseEventType.locations);
@@ -206,6 +208,7 @@ export class OutputEventTypesService_2024_06_14 {
206208
hideOrganizerEmail,
207209
calVideoSettings,
208210
hidden,
211+
bookingRequiresAuthentication,
209212
};
210213
}
211214

apps/api/v2/src/modules/auth/decorators/get-optional-user/get-optional-user.decorator.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.st
22
import { ExecutionContext } from "@nestjs/common";
33
import { createParamDecorator } from "@nestjs/common";
44

5+
export type AuthOptionalUser = ApiAuthGuardUser | null;
6+
57
export const GetOptionalUser = createParamDecorator<
68
keyof ApiAuthGuardUser | (keyof ApiAuthGuardUser)[],
79
ExecutionContext

0 commit comments

Comments
 (0)