feat: add bookingRequiresAuthentication validation to 2024-04-15 booking controller (#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>
This commit is contained in:
4 files changed
+215
-2
No files matched your search
@@ -9,6 +9,8 @@ import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/
|
||||
import { InstantBookingModule } from "@/lib/modules/instant-booking.module";
|
||||
import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module";
|
||||
import { RegularBookingModule } from "@/lib/modules/regular-booking.module";
|
||||
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { BillingModule } from "@/modules/billing/billing.module";
|
||||
@@ -55,6 +57,8 @@ import { Module } from "@nestjs/common";
|
||||
AppsRepository,
|
||||
CalendarsRepository,
|
||||
SelectedCalendarsRepository,
|
||||
PrismaEventTypeRepository,
|
||||
PrismaTeamRepository,
|
||||
],
|
||||
controllers: [BookingsController_2024_04_15],
|
||||
})
|
||||
|
||||
@@ -507,6 +507,122 @@ describe("Bookings Endpoints 2024-04-15", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("event type booking requires authentication", () => {
|
||||
let eventTypeRequiringAuthenticationId: number;
|
||||
let unauthorizedUser: User;
|
||||
let unauthorizedUserApiKeyString: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const eventTypeRequiringAuthentication = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `event-type-requiring-authentication-${randomString()}`,
|
||||
slug: `event-type-requiring-authentication-${randomString()}`,
|
||||
length: 60,
|
||||
requiresConfirmation: true,
|
||||
bookingRequiresAuthentication: true,
|
||||
},
|
||||
user.id
|
||||
);
|
||||
eventTypeRequiringAuthenticationId = eventTypeRequiringAuthentication.id;
|
||||
|
||||
const unauthorizedUserEmail = `unauthorized-user-${randomString()}@api.com`;
|
||||
unauthorizedUser = await userRepositoryFixture.create({
|
||||
email: unauthorizedUserEmail,
|
||||
});
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null);
|
||||
unauthorizedUserApiKeyString = keyString;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (unauthorizedUser) {
|
||||
await userRepositoryFixture.deleteByEmail(unauthorizedUser.email);
|
||||
}
|
||||
});
|
||||
|
||||
it("can't be booked without credentials", async () => {
|
||||
const body: CreateBookingInput_2024_04_15 = {
|
||||
start: "2040-05-23T09:30:00.000Z",
|
||||
end: "2040-05-23T10:30:00.000Z",
|
||||
eventTypeId: eventTypeRequiringAuthenticationId,
|
||||
timeZone: "Europe/London",
|
||||
language: "en",
|
||||
metadata: {},
|
||||
hashedLink: "",
|
||||
responses: {
|
||||
name: "External Attendee",
|
||||
email: "external@example.com",
|
||||
location: {
|
||||
value: "link",
|
||||
optionValue: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await request(app.getHttpServer()).post("/v2/bookings").send(body).expect(401);
|
||||
});
|
||||
|
||||
it("can't be booked with unauthorized user credentials", async () => {
|
||||
const body: CreateBookingInput_2024_04_15 = {
|
||||
start: "2040-05-23T10:30:00.000Z",
|
||||
end: "2040-05-23T11:30:00.000Z",
|
||||
eventTypeId: eventTypeRequiringAuthenticationId,
|
||||
timeZone: "Europe/London",
|
||||
language: "en",
|
||||
metadata: {},
|
||||
hashedLink: "",
|
||||
responses: {
|
||||
name: "External Attendee",
|
||||
email: "external@example.com",
|
||||
location: {
|
||||
value: "link",
|
||||
optionValue: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set({ Authorization: `Bearer cal_test_${unauthorizedUserApiKeyString}` })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("can be booked with event type owner credentials", async () => {
|
||||
const body: CreateBookingInput_2024_04_15 = {
|
||||
start: "2040-05-23T11:30:00.000Z",
|
||||
end: "2040-05-23T12:30:00.000Z",
|
||||
eventTypeId: eventTypeRequiringAuthenticationId,
|
||||
timeZone: "Europe/London",
|
||||
language: "en",
|
||||
metadata: {},
|
||||
hashedLink: "",
|
||||
responses: {
|
||||
name: "External Attendee",
|
||||
email: "external@example.com",
|
||||
location: {
|
||||
value: "link",
|
||||
optionValue: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(201);
|
||||
|
||||
const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.id).toBeDefined();
|
||||
|
||||
if (responseBody.data.id) {
|
||||
await bookingsRepositoryFixture.deleteById(responseBody.data.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||
|
||||
@@ -7,6 +7,8 @@ import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/ma
|
||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions";
|
||||
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||
import { InstantBookingCreateService } from "@/lib/services/instant-booking-create.service";
|
||||
import { RecurringBookingService } from "@/lib/services/recurring-booking.service";
|
||||
import { RegularBookingService } from "@/lib/services/regular-booking.service";
|
||||
@@ -38,6 +40,8 @@ import {
|
||||
NotFoundException,
|
||||
UseGuards,
|
||||
BadRequestException,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
@@ -113,7 +117,9 @@ export class BookingsController_2024_04_15 {
|
||||
private readonly usersService: UsersService,
|
||||
private readonly regularBookingService: RegularBookingService,
|
||||
private readonly recurringBookingService: RecurringBookingService,
|
||||
private readonly instantBookingCreateService: InstantBookingCreateService
|
||||
private readonly instantBookingCreateService: InstantBookingCreateService,
|
||||
private readonly eventTypeRepository: PrismaEventTypeRepository,
|
||||
private readonly teamRepository: PrismaTeamRepository
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
@@ -190,6 +196,7 @@ export class BookingsController_2024_04_15 {
|
||||
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
|
||||
const { orgSlug, locationUrl } = body;
|
||||
try {
|
||||
await this.checkBookingRequiresAuthentication(req, body.eventTypeId);
|
||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed);
|
||||
const booking = await this.regularBookingService.createBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
@@ -443,6 +450,48 @@ export class BookingsController_2024_04_15 {
|
||||
return oAuthClientParams.platformClientId;
|
||||
}
|
||||
|
||||
private async checkBookingRequiresAuthentication(req: Request, eventTypeId: number): Promise<void> {
|
||||
const eventType = await this.eventTypeRepository.findByIdIncludeHostsAndTeamMembers({
|
||||
id: eventTypeId,
|
||||
});
|
||||
|
||||
if (!eventType?.bookingRequiresAuthentication) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = await this.getOwnerId(req);
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException(
|
||||
"This event type requires authentication. Please provide valid credentials."
|
||||
);
|
||||
}
|
||||
|
||||
const isEventTypeOwner = eventType.userId === userId;
|
||||
const isHost = eventType.hosts.some((host) => host.userId === userId);
|
||||
const isTeamAdminOrOwner =
|
||||
eventType.team?.members.some((member) => member.userId === userId) ?? false;
|
||||
|
||||
let isOrgAdminOrOwner = false;
|
||||
if (eventType.team?.parentId) {
|
||||
const orgTeam = await this.teamRepository.getTeamByIdIfUserIsAdmin({
|
||||
userId,
|
||||
teamId: eventType.team.parentId,
|
||||
});
|
||||
isOrgAdminOrOwner = !!orgTeam;
|
||||
} else if (eventType.team?.isOrganization) {
|
||||
isOrgAdminOrOwner = isTeamAdminOrOwner;
|
||||
}
|
||||
|
||||
const isAuthorized = isEventTypeOwner || isHost || isTeamAdminOrOwner || isOrgAdminOrOwner;
|
||||
|
||||
if (!isAuthorized) {
|
||||
throw new ForbiddenException(
|
||||
"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."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async getOAuthClientsParams(clientId: string, isEmbed = false): Promise<OAuthRequestParams> {
|
||||
const res = { ...DEFAULT_PLATFORM_PARAMS };
|
||||
|
||||
@@ -502,7 +551,10 @@ export class BookingsController_2024_04_15 {
|
||||
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||
}
|
||||
|
||||
async setPlatformAttendeesEmails(requestBody: any, oAuthClientId: string): Promise<void> {
|
||||
async setPlatformAttendeesEmails(
|
||||
requestBody: { responses?: { email?: string; guests?: string[] } },
|
||||
oAuthClientId: string
|
||||
): Promise<void> {
|
||||
if (requestBody?.responses?.email) {
|
||||
requestBody.responses.email = await this.platformBookingsService.getPlatformAttendeeEmail(
|
||||
requestBody.responses.email,
|
||||
@@ -564,6 +616,9 @@ export class BookingsController_2024_04_15 {
|
||||
|
||||
if (err instanceof Error) {
|
||||
const error = err as Error;
|
||||
if (err instanceof HttpException) {
|
||||
throw new HttpException(err.getResponse(), err.getStatus());
|
||||
}
|
||||
if (Object.values(ErrorCode).includes(error.message as unknown as ErrorCode)) {
|
||||
throw new HttpException(error.message, 400);
|
||||
}
|
||||
|
||||
@@ -1157,6 +1157,44 @@ export class EventTypeRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async findByIdIncludeHostsAndTeamMembers({ id }: { id: number }) {
|
||||
return await this.prismaClient.eventType.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
bookingRequiresAuthentication: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
hosts: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
parentId: true,
|
||||
isOrganization: true,
|
||||
members: {
|
||||
where: {
|
||||
accepted: true,
|
||||
role: {
|
||||
in: ["ADMIN", "OWNER"],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAllByTeamIdIncludeManagedEventTypes({ teamId }: { teamId?: number }) {
|
||||
return await this.prismaClient.eventType.findMany({
|
||||
where: {
|
||||
|
||||
Reference in new issue
Block a user