diff --git a/packages/features/credentials/services/CredentialAccessService.test.ts b/packages/features/credentials/services/CredentialAccessService.test.ts new file mode 100644 index 0000000000..69852b0d4e --- /dev/null +++ b/packages/features/credentials/services/CredentialAccessService.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, test, vi, beforeEach } from "vitest"; + +import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository"; +import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; +import { HttpError } from "@calcom/lib/http-error"; +import { prisma } from "@calcom/prisma"; + +import { CredentialAccessService } from "./CredentialAccessService"; + +vi.mock("@calcom/prisma", () => { + return { + prisma: { + user: { + findUnique: vi.fn(), + }, + }, + }; +}); + +vi.mock("@calcom/features/credentials/repositories/CredentialRepository", () => { + return { + CredentialRepository: { + findFirstByIdWithKeyAndUser: vi.fn(), + }, + }; +}); + +vi.mock("@calcom/features/users/repositories/UserRepository", () => { + return { + UserRepository: vi.fn().mockImplementation(() => ({ + getUserOrganizationAndTeams: vi.fn(), + })), + }; +}); + +describe("CredentialAccessService", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("should allow access when credential belongs to logged-in user", async () => { + const credentialId = 1; + const loggedInUserId = 100; + const bookingOwnerId = 200; + + vi.mocked(CredentialRepository.findFirstByIdWithKeyAndUser).mockResolvedValue({ + id: credentialId, + userId: loggedInUserId, + teamId: null, + type: "zoom_video", + appId: "zoom", + key: {}, + invalid: false, + user: { email: "user@example.com" }, + delegatedTo: null, + delegatedToId: null, + delegationCredentialId: null, + } as any); + + const service = new CredentialAccessService(); + await expect( + service.ensureAccessible({ + credentialId, + loggedInUserId, + bookingOwnerId, + }) + ).resolves.not.toThrow(); + }); + + test("should allow access when credential belongs to booking owner", async () => { + const credentialId = 1; + const loggedInUserId = 100; + const bookingOwnerId = 200; + + vi.mocked(CredentialRepository.findFirstByIdWithKeyAndUser).mockResolvedValue({ + id: credentialId, + userId: bookingOwnerId, + teamId: null, + type: "zoom_video", + appId: "zoom", + key: {}, + invalid: false, + user: { email: "owner@example.com" }, + delegatedTo: null, + delegatedToId: null, + delegationCredentialId: null, + } as any); + + const service = new CredentialAccessService(); + await expect( + service.ensureAccessible({ + credentialId, + loggedInUserId, + bookingOwnerId, + }) + ).resolves.not.toThrow(); + }); + + test("should allow access when credential belongs to team the logged-in user belongs to", async () => { + const credentialId = 1; + const loggedInUserId = 100; + const bookingOwnerId = 200; + const teamId = 50; + + vi.mocked(CredentialRepository.findFirstByIdWithKeyAndUser).mockResolvedValue({ + id: credentialId, + userId: null, + teamId: teamId, + type: "zoom_video", + appId: "zoom", + key: {}, + invalid: false, + user: null, + delegatedTo: null, + delegatedToId: null, + delegationCredentialId: null, + } as any); + + const mockUserRepo = { + getUserOrganizationAndTeams: vi.fn().mockResolvedValue({ + organizationId: null, + teams: [{ teamId: teamId }], + }), + }; + + vi.mocked(UserRepository).mockImplementation(() => mockUserRepo as any); + + const service = new CredentialAccessService(); + await expect( + service.ensureAccessible({ + credentialId, + loggedInUserId, + bookingOwnerId, + }) + ).resolves.not.toThrow(); + }); + + test("should allow access when credential belongs to team the booking owner belongs to", async () => { + const credentialId = 1; + const loggedInUserId = 100; + const bookingOwnerId = 200; + const teamId = 50; + + vi.mocked(CredentialRepository.findFirstByIdWithKeyAndUser).mockResolvedValue({ + id: credentialId, + userId: null, + teamId: teamId, + type: "zoom_video", + appId: "zoom", + key: {}, + invalid: false, + user: null, + delegatedTo: null, + delegatedToId: null, + delegationCredentialId: null, + } as any); + + const mockUserRepo = { + getUserOrganizationAndTeams: vi + .fn() + .mockResolvedValueOnce({ + organizationId: null, + teams: [], + }) + .mockResolvedValueOnce({ + organizationId: null, + teams: [{ teamId: teamId }], + }), + }; + + vi.mocked(UserRepository).mockImplementation(() => mockUserRepo as any); + + const service = new CredentialAccessService(); + await expect( + service.ensureAccessible({ + credentialId, + loggedInUserId, + bookingOwnerId, + }) + ).resolves.not.toThrow(); + }); + + test("should allow access when credential belongs to organization the logged-in user belongs to", async () => { + const credentialId = 1; + const loggedInUserId = 100; + const bookingOwnerId = 200; + const orgId = 50; + + vi.mocked(CredentialRepository.findFirstByIdWithKeyAndUser).mockResolvedValue({ + id: credentialId, + userId: null, + teamId: orgId, + type: "zoom_video", + appId: "zoom", + key: {}, + invalid: false, + user: null, + delegatedTo: null, + delegatedToId: null, + delegationCredentialId: null, + } as any); + + const mockUserRepo = { + getUserOrganizationAndTeams: vi.fn().mockResolvedValue({ + organizationId: orgId, + teams: [], + }), + }; + + vi.mocked(UserRepository).mockImplementation(() => mockUserRepo as any); + + const service = new CredentialAccessService(); + await expect( + service.ensureAccessible({ + credentialId, + loggedInUserId, + bookingOwnerId, + }) + ).resolves.not.toThrow(); + }); + + test("should throw NOT_FOUND when credential does not exist", async () => { + const credentialId = 1; + const loggedInUserId = 100; + const bookingOwnerId = 200; + + vi.mocked(CredentialRepository.findFirstByIdWithKeyAndUser).mockResolvedValue(null); + + const service = new CredentialAccessService(); + const error = await service + .ensureAccessible({ + credentialId, + loggedInUserId, + bookingOwnerId, + }) + .catch((e) => e); + + expect(error).toBeInstanceOf(HttpError); + expect(error.statusCode).toBe(404); + expect(error.message).toBe("Credential not found"); + }); + + test("should throw FORBIDDEN when credential is not accessible", async () => { + const credentialId = 1; + const loggedInUserId = 100; + const bookingOwnerId = 200; + const otherUserId = 300; + + vi.mocked(CredentialRepository.findFirstByIdWithKeyAndUser).mockResolvedValue({ + id: credentialId, + userId: otherUserId, + teamId: null, + type: "zoom_video", + appId: "zoom", + key: {}, + invalid: false, + user: { email: "other@example.com" }, + delegatedTo: null, + delegatedToId: null, + delegationCredentialId: null, + } as any); + + const mockUserRepo = { + getUserOrganizationAndTeams: vi + .fn() + .mockResolvedValueOnce({ + organizationId: null, + teams: [], + }) + .mockResolvedValueOnce({ + organizationId: null, + teams: [], + }), + }; + + vi.mocked(UserRepository).mockImplementation(() => mockUserRepo as any); + + const service = new CredentialAccessService(); + const error = await service + .ensureAccessible({ + credentialId, + loggedInUserId, + bookingOwnerId, + }) + .catch((e) => e); + + expect(error).toBeInstanceOf(HttpError); + expect(error.statusCode).toBe(403); + expect(error.message).toBe("You do not have access to this credential"); + }); + + test("should handle null bookingOwnerId", async () => { + const credentialId = 1; + const loggedInUserId = 100; + const bookingOwnerId = null; + + vi.mocked(CredentialRepository.findFirstByIdWithKeyAndUser).mockResolvedValue({ + id: credentialId, + userId: loggedInUserId, + teamId: null, + type: "zoom_video", + appId: "zoom", + key: {}, + invalid: false, + user: { email: "user@example.com" }, + delegatedTo: null, + delegatedToId: null, + delegationCredentialId: null, + } as any); + + const service = new CredentialAccessService(); + await expect( + service.ensureAccessible({ + credentialId, + loggedInUserId, + bookingOwnerId, + }) + ).resolves.not.toThrow(); + }); +}); diff --git a/packages/features/credentials/services/CredentialAccessService.ts b/packages/features/credentials/services/CredentialAccessService.ts new file mode 100644 index 0000000000..8b4c21d83f --- /dev/null +++ b/packages/features/credentials/services/CredentialAccessService.ts @@ -0,0 +1,130 @@ +import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository"; +import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; +import { HttpError } from "@calcom/lib/http-error"; +import type { PrismaClient } from "@calcom/prisma"; +import { prisma } from "@calcom/prisma"; + +type CredentialAccessInput = { + credentialId: number; + loggedInUserId: number; + bookingOwnerId: number | null; +}; + +type UserTeamsData = { + organizationId: number | null; + teams: Array<{ teamId: number }>; +} | null; + +export class CredentialAccessService { + private readonly userRepository: UserRepository; + + constructor(private readonly prismaClient: PrismaClient = prisma) { + this.userRepository = new UserRepository(this.prismaClient); + } + + /** + * Ensures that a credential is accessible by the logged-in user or booking owner. + */ + async ensureAccessible(input: CredentialAccessInput): Promise { + const credential = await this.checkCredentialExists(input.credentialId); + + if (this.checkUserOwnership(credential, input.loggedInUserId)) { + return; + } + + if (this.checkBookingOwnerOwnership(credential, input.bookingOwnerId)) { + return; + } + + await this.checkTeamAccess(credential, input.loggedInUserId, input.bookingOwnerId); + } + + private async checkCredentialExists(credentialId: number) { + const credential = await CredentialRepository.findFirstByIdWithKeyAndUser({ + id: credentialId, + }); + + if (!credential) { + throw new HttpError({ + statusCode: 404, + message: "Credential not found", + }); + } + + return credential; + } + + private checkUserOwnership( + credential: Awaited>, + loggedInUserId: number + ): boolean { + return credential?.userId === loggedInUserId; + } + + private checkBookingOwnerOwnership( + credential: Awaited>, + bookingOwnerId: number | null + ): boolean { + return bookingOwnerId !== null && credential?.userId === bookingOwnerId; + } + + private async checkTeamAccess( + credential: NonNullable>>, + loggedInUserId: number, + bookingOwnerId: number | null + ): Promise { + if (!credential.teamId) { + this.throwForbiddenError(); + } + + const [loggedInUserTeams, bookingOwnerTeams] = await this.getUserTeamsData( + loggedInUserId, + bookingOwnerId + ); + + if (this.hasTeamAccess(credential.teamId, loggedInUserTeams)) { + return; + } + + if (this.hasTeamAccess(credential.teamId, bookingOwnerTeams)) { + return; + } + + this.throwForbiddenError(); + } + + private async getUserTeamsData( + loggedInUserId: number, + bookingOwnerId: number | null + ): Promise<[UserTeamsData, UserTeamsData]> { + return Promise.all([ + this.userRepository.getUserOrganizationAndTeams({ userId: loggedInUserId }), + bookingOwnerId + ? this.userRepository.getUserOrganizationAndTeams({ userId: bookingOwnerId }) + : Promise.resolve(null), + ]); + } + + private hasTeamAccess(teamId: number, userTeams: UserTeamsData): boolean { + if (!userTeams) { + return false; + } + + const teamIds = this.getTeamIdsForUser(userTeams); + return teamIds.includes(teamId); + } + + private getTeamIdsForUser(userTeams: NonNullable): number[] { + return [ + ...(userTeams.organizationId ? [userTeams.organizationId] : []), + ...(userTeams.teams || []).map((t) => t.teamId), + ]; + } + + private throwForbiddenError(): never { + throw new HttpError({ + statusCode: 403, + message: "You do not have access to this credential", + }); + } +} diff --git a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts index 52f840d095..203947232a 100644 --- a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts @@ -7,6 +7,7 @@ import { sendLocationChangeEmailsAndSMS } from "@calcom/emails/email-manager"; import EventManager from "@calcom/features/bookings/lib/EventManager"; import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository"; +import { CredentialAccessService } from "@calcom/features/credentials/services/CredentialAccessService"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser"; import { buildCalEventFromBooking } from "@calcom/lib/buildCalEventFromBooking"; @@ -125,15 +126,26 @@ async function updateBookingLocationInDb({ async function getAllCredentialsIncludeServiceAccountKey({ user, conferenceCredentialId, + bookingOwnerId, }: { user: { id: number; email: string }; conferenceCredentialId: number | null; + bookingOwnerId: number | null; }) { const credentials = await getUsersCredentialsIncludeServiceAccountKey(user); let conferenceCredential; if (conferenceCredentialId) { + // Validate that the credential is accessible before fetching it + const credentialAccessService = new CredentialAccessService(); + await credentialAccessService.ensureAccessible({ + credentialId: conferenceCredentialId, + loggedInUserId: user.id, + bookingOwnerId, + }); + + // Now fetch the credential with the key conferenceCredential = await CredentialRepository.findFirstByIdWithKeyAndUser({ id: conferenceCredentialId, }); @@ -266,7 +278,11 @@ export async function editLocationHandler({ ctx, input }: EditLocationOptions) { const eventManager = new EventManager({ ...ctx.user, - credentials: await getAllCredentialsIncludeServiceAccountKey({ user: ctx.user, conferenceCredentialId }), + credentials: await getAllCredentialsIncludeServiceAccountKey({ + user: ctx.user, + conferenceCredentialId, + bookingOwnerId: booking.userId, + }), }); const updatedResult = await updateLocationInConnectedAppForBooking({