From fd390f9d432d19c97bff360904a90fbcf75b64ae Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Mon, 10 Nov 2025 19:20:48 -0300 Subject: [PATCH] fix: add authorization checks to booking reassignment endpoints (#25054) --- .../controllers/bookings.controller.ts | 8 +- .../e2e/emails/team-emails.e2e-spec.ts | 35 +++++---- .../e2e/reassign-bookings.e2e-spec.ts | 78 ++++++++++++++++--- .../repositories/bookings.repository.ts | 11 +++ .../2024-08-13/services/bookings.service.ts | 47 +++++++++-- 5 files changed, 139 insertions(+), 40 deletions(-) diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts index 7c15255c8b..ec866c37f5 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts @@ -405,9 +405,9 @@ export class BookingsController_2024_08_13 { }) async reassignBooking( @Param("bookingUid") bookingUid: string, - @GetUser() user: ApiAuthGuardUser + @GetUser() reassignedByUser: ApiAuthGuardUser ): Promise { - const booking = await this.bookingsService.reassignBooking(bookingUid, user); + const booking = await this.bookingsService.reassignBooking(bookingUid, reassignedByUser); return { status: SUCCESS_STATUS, @@ -430,13 +430,13 @@ export class BookingsController_2024_08_13 { async reassignBookingToUser( @Param("bookingUid") bookingUid: string, @Param("userId") userId: number, - @GetUser("id") reassignedById: number, + @GetUser() reassignedByUser: ApiAuthGuardUser, @Body() body: ReassignToUserBookingInput_2024_08_13 ): Promise { const booking = await this.bookingsService.reassignBookingToUser( bookingUid, userId, - reassignedById, + reassignedByUser, body ); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/emails/team-emails.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/emails/team-emails.e2e-spec.ts index 7c76163627..dc704baf9e 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/emails/team-emails.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/emails/team-emails.e2e-spec.ts @@ -12,6 +12,7 @@ import { INestApplication } from "@nestjs/common"; import { NestExpressApplication } from "@nestjs/platform-express"; import { Test } from "@nestjs/testing"; import * as request from "supertest"; +import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture"; import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture"; import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; @@ -20,7 +21,6 @@ import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repo import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; import { randomString } from "test/utils/randomString"; -import { withApiAuth } from "test/utils/withApiAuth"; import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; import { @@ -72,6 +72,7 @@ type EmailSetup = { team: Team; member1: User; member2: User; + member1ApiKey: string; collectiveEventType: { id: number }; roundRobinEventType: { id: number }; }; @@ -89,25 +90,21 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => { let profileRepositoryFixture: ProfileRepositoryFixture; let membershipsRepositoryFixture: MembershipRepositoryFixture; let hostsRepositoryFixture: HostsRepositoryFixture; + let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; // Setup data for tests let emailsEnabledSetup: EmailSetup; let emailsDisabledSetup: EmailSetup; - const authEmail = "team-emails-2024-08-13-user-admin@example.com"; - // Utility function to check response data type - const responseDataIsBooking = (data: any): data is BookingOutput_2024_08_13 => { - return !Array.isArray(data) && typeof data === "object" && data && "id" in data; + const responseDataIsBooking = (data: unknown): data is BookingOutput_2024_08_13 => { + return !Array.isArray(data) && data !== null && typeof data === "object" && data && "id" in data; }; beforeAll(async () => { - const moduleRef = await withApiAuth( - authEmail, - Test.createTestingModule({ - imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], - }) - ) + const moduleRef = await Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) .overrideGuard(PermissionsGuard) .useValue({ canActivate: () => true, @@ -122,6 +119,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => { profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef); + apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef); schedulesService = moduleRef.get(SchedulesService_2024_04_15); // Create a base organization for all tests @@ -131,11 +129,6 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => { emailsEnabledSetup = await setupTestEnvironment(true); emailsDisabledSetup = await setupTestEnvironment(false); - await userRepositoryFixture.create({ - email: authEmail, - organization: { connect: { id: organization.id } }, - }); - app = moduleRef.createNestApplication(); bootstrap(app as NestExpressApplication); await app.init(); @@ -173,10 +166,15 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => { const collectiveEvent = await createEventType("COLLECTIVE", team.id, [member1.id, member2.id]); const roundRobinEvent = await createEventType("ROUND_ROBIN", team.id, [member1.id, member2.id]); + // Create API key for member1 to use in authorized tests + const { keyString } = await apiKeysRepositoryFixture.createApiKey(member1.id, null); + const member1ApiKey = `cal_test_${keyString}`; + return { team, member1, member2, + member1ApiKey, collectiveEventType: { id: collectiveEvent.id }, roundRobinEventType: { id: roundRobinEvent.id }, }; @@ -351,6 +349,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => { : emailsDisabledSetup.member1.id; const manualReassignResponse = await request(app.getHttpServer()) .post(`/v2/bookings/${rescheduledBookingUid}/reassign/${reassignToId}`) + .set("Authorization", `Bearer ${emailsDisabledSetup.member1ApiKey}`) .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); expect(manualReassignResponse.status).toBe(200); @@ -359,6 +358,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => { // --- 4. Automatic Reassign --- const autoReassignResponse = await request(app.getHttpServer()) .post(`/v2/bookings/${rescheduledBookingUid}/reassign`) + .set("Authorization", `Bearer ${emailsDisabledSetup.member1ApiKey}`) .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); expect(autoReassignResponse.status).toBe(200); @@ -484,6 +484,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => { const manualReassignResponse = await request(app.getHttpServer()) .post(`/v2/bookings/${rescheduledBookingUid}/reassign/${reassignToId}`) + .set("Authorization", `Bearer ${emailsEnabledSetup.member1ApiKey}`) .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); expect(manualReassignResponse.status).toBe(200); @@ -499,6 +500,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => { jest.clearAllMocks(); const autoReassignResponse = await request(app.getHttpServer()) .post(`/v2/bookings/${rescheduledBookingUid}/reassign`) + .set("Authorization", `Bearer ${emailsEnabledSetup.member1ApiKey}`) .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); expect(autoReassignResponse.status).toBe(200); @@ -523,7 +525,6 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => { afterAll(async () => { // Clean up database records await teamRepositoryFixture.delete(organization.id); - await userRepositoryFixture.deleteByEmail(authEmail); await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.member1.email); await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.member2.email); await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.member1.email); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/reassign-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/reassign-bookings.e2e-spec.ts index 935eb01c2b..68f877b124 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/reassign-bookings.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/reassign-bookings.e2e-spec.ts @@ -1,6 +1,7 @@ import { bootstrap } from "@/app"; import { AppModule } from "@/app.module"; import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output"; +import { BOOKING_REASSIGN_PERMISSION_ERROR } from "@/ee/bookings/2024-08-13/services/bookings.service"; import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; @@ -11,6 +12,7 @@ import { INestApplication } from "@nestjs/common"; import { NestExpressApplication } from "@nestjs/platform-express"; import { Test } from "@nestjs/testing"; import * as request from "supertest"; +import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture"; import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture"; @@ -21,15 +23,11 @@ import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repo import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; import { randomString } from "test/utils/randomString"; -import { withApiAuth } from "test/utils/withApiAuth"; - - import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; import type { CreateBookingInput_2024_08_13 } from "@calcom/platform-types"; import type { Booking, User, PlatformOAuthClient, Team } from "@calcom/prisma/client"; - describe("Bookings Endpoints 2024-08-13", () => { describe("Reassign bookings", () => { let app: INestApplication; @@ -47,6 +45,7 @@ describe("Bookings Endpoints 2024-08-13", () => { let hostsRepositoryFixture: HostsRepositoryFixture; let organizationsRepositoryFixture: OrganizationRepositoryFixture; let profileRepositoryFixture: ProfileRepositoryFixture; + let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; const teamUserEmail = `reassign-bookings-2024-08-13-user1-${randomString()}@api.com`; const teamUserEmail2 = `reassign-bookings-2024-08-13-user2-${randomString()}@api.com`; @@ -54,6 +53,8 @@ describe("Bookings Endpoints 2024-08-13", () => { let teamUser1: User; let teamUser2: User; let teamUser3: User; + let teamUser1ApiKey: string; + let teamUser2ApiKey: string; let teamRoundRobinEventTypeId: number; let teamRoundRobinFixedHostEventTypeId: number; @@ -69,12 +70,9 @@ describe("Bookings Endpoints 2024-08-13", () => { let rescheduleReasonBookingInitialHostId: number; beforeAll(async () => { - const moduleRef = await withApiAuth( - teamUserEmail, - Test.createTestingModule({ - imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], - }) - ) + const moduleRef = await Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) .overrideGuard(PermissionsGuard) .useValue({ canActivate: () => true, @@ -90,6 +88,7 @@ describe("Bookings Endpoints 2024-08-13", () => { profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef); + apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef); schedulesService = moduleRef.get(SchedulesService_2024_04_15); organization = await organizationsRepositoryFixture.create({ @@ -126,6 +125,12 @@ describe("Bookings Endpoints 2024-08-13", () => { name: `reassign-bookings-2024-08-13-user3-${randomString()}`, }); + const { keyString } = await apiKeysRepositoryFixture.createApiKey(teamUser1.id, null); + teamUser1ApiKey = `cal_test_${keyString}`; + + const { keyString: keyString2 } = await apiKeysRepositoryFixture.createApiKey(teamUser2.id, null); + teamUser2ApiKey = `cal_test_${keyString2}`; + const userSchedule: CreateScheduleInput_2024_04_15 = { name: `reassign-bookings-2024-08-13-schedule-${randomString()}`, timeZone: "Europe/Rome", @@ -473,6 +478,7 @@ describe("Bookings Endpoints 2024-08-13", () => { return request(app.getHttpServer()) .post(`/v2/bookings/${roundRobinBooking.uid}/reassign`) + .set("Authorization", `Bearer ${teamUser1ApiKey}`) .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) .expect(200) .then(async (response) => { @@ -512,6 +518,7 @@ describe("Bookings Endpoints 2024-08-13", () => { return request(app.getHttpServer()) .post(`/v2/bookings/${roundRobinBooking.uid}/reassign/${teamUser1.id}`) .send(body) + .set("Authorization", `Bearer ${teamUser1ApiKey}`) .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) .expect(200) .then(async (response) => { @@ -562,6 +569,7 @@ describe("Bookings Endpoints 2024-08-13", () => { return request(app.getHttpServer()) .post(`/v2/bookings/${bookingUid}/reassign`) + .set("Authorization", `Bearer ${teamUser2ApiKey}`) .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) .expect(200) .then(async (response) => { @@ -611,6 +619,7 @@ describe("Bookings Endpoints 2024-08-13", () => { return request(app.getHttpServer()) .post(`/v2/bookings/${bookingUid}/reassign/${teamUser3.id}`) + .set("Authorization", `Bearer ${teamUser1ApiKey}`) .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) .expect(200) .then(async (response) => { @@ -669,6 +678,7 @@ describe("Bookings Endpoints 2024-08-13", () => { return request(app.getHttpServer()) .post(`/v2/bookings/${bookingUid}/reassign/${reassignToHostId}`) + .set("Authorization", `Bearer ${teamUser1ApiKey}`) .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) .expect(200) .then(async (response) => { @@ -704,6 +714,7 @@ describe("Bookings Endpoints 2024-08-13", () => { return request(app.getHttpServer()) .post(`/v2/bookings/${bookingUid}/reassign`) + .set("Authorization", `Bearer ${teamUser1ApiKey}`) .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) .expect(200) .then(async (response) => { @@ -724,6 +735,51 @@ describe("Bookings Endpoints 2024-08-13", () => { }); }); + it("should return 403 when unauthorized user tries to reassign booking", async () => { + const unauthorizedUserEmail = `fake-user-${randomString()}@api.com`; + const unauthorizedUser = await userRepositoryFixture.create({ + email: unauthorizedUserEmail, + locale: "en", + name: `fake-user-${randomString()}`, + }); + + const { keyString } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null); + const unauthorizedApiKeyString = `cal_test_${keyString}`; + + const response = await request(app.getHttpServer()) + .post(`/v2/bookings/${roundRobinBooking.uid}/reassign`) + .set("Authorization", `Bearer ${unauthorizedApiKeyString}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(403); + + expect(response.body.error.message).toBe(BOOKING_REASSIGN_PERMISSION_ERROR); + + await userRepositoryFixture.deleteByEmail(unauthorizedUserEmail); + }); + + it("should return 403 when unauthorized user tries to reassign booking to specific user", async () => { + const unauthorizedUserEmail = `fake-user-${randomString()}@api.com`; + const unauthorizedUser = await userRepositoryFixture.create({ + email: unauthorizedUserEmail, + locale: "en", + name: `fake-user-${randomString()}`, + }); + + const { keyString } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null); + const unauthorizedApiKeyString = `cal_test_${keyString}`; + + const response = await request(app.getHttpServer()) + .post(`/v2/bookings/${roundRobinBooking.uid}/reassign/${teamUser2.id}`) + .send({ reason: "Testing unauthorized access" }) + .set("Authorization", `Bearer ${unauthorizedApiKeyString}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(403); + + expect(response.body.error.message).toBe(BOOKING_REASSIGN_PERMISSION_ERROR); + + await userRepositoryFixture.deleteByEmail(unauthorizedUserEmail); + }); + async function createOAuthClient(organizationId: number) { const data = { logo: "logo-url", @@ -749,4 +805,4 @@ describe("Bookings Endpoints 2024-08-13", () => { await app.close(); }); }); -}); \ No newline at end of file +}); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/repositories/bookings.repository.ts b/apps/api/v2/src/ee/bookings/2024-08-13/repositories/bookings.repository.ts index 4c6f74700e..248a5edbed 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/repositories/bookings.repository.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/repositories/bookings.repository.ts @@ -72,6 +72,17 @@ export class BookingsRepository_2024_08_13 { }); } + async getByUidWithEventType(bookingUid: string) { + return this.dbRead.prisma.booking.findUnique({ + where: { + uid: bookingUid, + }, + include: { + eventType: true, + }, + }); + } + async getByUidWithUser(bookingUid: string) { return this.dbRead.prisma.booking.findUnique({ where: { diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts index 526a6733ff..bca52edae3 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts @@ -23,7 +23,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository"; import { TeamsRepository } from "@/modules/teams/teams/teams.repository"; import { UsersService } from "@/modules/users/services/users.service"; -import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository"; +import { UsersRepository } from "@/modules/users/users.repository"; import { ConflictException, ForbiddenException, @@ -38,6 +38,8 @@ import { Request } from "express"; import { DateTime } from "luxon"; import { z } from "zod"; +export const BOOKING_REASSIGN_PERMISSION_ERROR = "You do not have permission to reassign this booking"; + import { getTranslation, getAllUserBookings, @@ -992,19 +994,34 @@ export class BookingsService_2024_08_13 { }); } - async reassignBooking(bookingUid: string, requestUser: UserWithProfile) { - const booking = await this.bookingsRepository.getByUid(bookingUid); + async reassignBooking(bookingUid: string, reassignedByUser: ApiAuthGuardUser) { + const booking = await this.bookingsRepository.getByUidWithEventType(bookingUid); if (!booking) { throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`); } + if (!booking.eventType) { + throw new BadRequestException( + `Event type with id=${booking.eventTypeId} was not found in the database` + ); + } + + const isAllowed = await this.eventTypeAccessService.userIsEventTypeAdminOrOwner( + reassignedByUser, + booking.eventType + ); + + if (!isAllowed) { + throw new ForbiddenException(BOOKING_REASSIGN_PERMISSION_ERROR); + } + const platformClientParams = booking.eventTypeId ? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId) : undefined; const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true; - const profile = this.usersService.getUserMainProfile(requestUser); + const profile = this.usersService.getUserMainProfile(reassignedByUser); try { await roundRobinReassignment({ @@ -1012,7 +1029,7 @@ export class BookingsService_2024_08_13 { orgId: profile?.organizationId || null, emailsEnabled, platformClientParams, - reassignedById: requestUser.id, + reassignedById: reassignedByUser.id, }); } catch (error) { if (error instanceof Error) { @@ -1036,13 +1053,27 @@ export class BookingsService_2024_08_13 { async reassignBookingToUser( bookingUid: string, newUserId: number, - reassignedById: number, + reassignedByUser: ApiAuthGuardUser, body: ReassignToUserBookingInput_2024_08_13 ) { - const booking = await this.bookingsRepository.getByUid(bookingUid); + const booking = await this.bookingsRepository.getByUidWithEventType(bookingUid); if (!booking) { throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`); } + if (!booking.eventType) { + throw new BadRequestException( + `Event type with id=${booking.eventTypeId} was not found in the database` + ); + } + + const isAllowed = await this.eventTypeAccessService.userIsEventTypeAdminOrOwner( + reassignedByUser, + booking.eventType + ); + + if (!isAllowed) { + throw new ForbiddenException(BOOKING_REASSIGN_PERMISSION_ERROR); + } const user = await this.usersRepository.findByIdWithProfile(newUserId); if (!user) { @@ -1063,7 +1094,7 @@ export class BookingsService_2024_08_13 { newUserId, orgId: profile?.organizationId || null, reassignReason: body.reason, - reassignedById, + reassignedById: reassignedByUser.id, emailsEnabled, platformClientParams, });