diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index 63b6e679e7410e..894fc8fb92c54d 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -54,6 +54,7 @@ async function handler(req: NextApiRequest & { userId?: number; traceContext: Tr hostname: req.headers.host || "", forcedSlug: req.headers["x-cal-force-slug"] as string | undefined, traceContext: req.traceContext, + impersonatedByUserUuid: session?.user?.impersonatedBy?.uuid, }, }); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 6c3adf50dbfe07..61a63f69381c04 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -4164,7 +4164,9 @@ "attendee_no_show_updated": "Attendee No-Show Updated", "type": "Assignment Type", "assignmentType_manual": "Manual Assignment", - "assignmentType_roundRobin": "Round Robin Assignment" + "assignmentType_roundRobin": "Round Robin Assignment", + "actor_impersonated_by": "Impersonator", + "source": "Source" }, "error_loading_booking_logs": "Error loading booking logs", "no_audit_logs_found": "No audit logs found", diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index aaf56f67c6d8b9..dc8ab938aeb52b 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -2208,7 +2208,6 @@ export function mockCrmApp( }[] = []; const eventsCreated: boolean[] = []; - // Mock the CrmServiceMap directly instead of using the old app-store index approach vi.doMock("@calcom/app-store/crm.apps.generated", async (importOriginal) => { const original = await importOriginal(); @@ -2220,16 +2219,17 @@ export function mockCrmApp( createContact() { if (crmData?.createContacts) { contactsCreated = crmData.createContacts; - return Promise.resolve(crmData?.createContacts); + return Promise.resolve(crmData.createContacts); } return Promise.resolve([]); } getContacts(email: string) { if (crmData?.getContacts) { - contactsQueried = crmData?.getContacts; - const contactsOfEmail = contactsQueried.filter((contact) => contact.email === email); - return Promise.resolve(contactsOfEmail); + contactsQueried = crmData.getContacts; + return Promise.resolve( + contactsQueried.filter((c) => c.email === email) + ); } return Promise.resolve([]); } @@ -2244,20 +2244,28 @@ export function mockCrmApp( ...original, CrmServiceMap: { ...original.CrmServiceMap, - [metadataLookupKey]: Promise.resolve({ + // ✅ IMPORTANT: no Promise here + [metadataLookupKey]: { default: MockCrmService, - }), + }, }, }; }); return { - contactsCreated, - contactsQueried, - eventsCreated, + get contactsCreated() { + return contactsCreated; + }, + get contactsQueried() { + return contactsQueried; + }, + get eventsCreated() { + return eventsCreated; + }, }; } + export function getBooker({ name, email, diff --git a/packages/features/auth/lib/getServerSession.ts b/packages/features/auth/lib/getServerSession.ts index 69fd6b063bb69c..a06eab9ca4fa83 100644 --- a/packages/features/auth/lib/getServerSession.ts +++ b/packages/features/auth/lib/getServerSession.ts @@ -124,12 +124,14 @@ export async function getServerSession(options: { }, select: { id: true, + uuid: true, role: true, }, }); if (impersonatedByUser) { session.user.impersonatedBy = { id: impersonatedByUser?.id, + uuid: impersonatedByUser.uuid, role: impersonatedByUser.role, }; } diff --git a/packages/features/booking-audit/client/components/BookingHistory.tsx b/packages/features/booking-audit/client/components/BookingHistory.tsx index 9c487a2c59e4bd..1ba2a0677ef679 100644 --- a/packages/features/booking-audit/client/components/BookingHistory.tsx +++ b/packages/features/booking-audit/client/components/BookingHistory.tsx @@ -43,6 +43,11 @@ type AuditLog = { displayEmail: string | null; displayAvatar: string | null; }; + impersonatedBy?: { + displayName: string; + displayEmail: string | null; + displayAvatar: string | null; + } | null; }; interface BookingLogsFiltersProps { @@ -283,8 +288,14 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) { {t("actor")} {log.actor.displayName || log.actor.type} + {log.impersonatedBy && ( +
+ {t("booking_audit_action.actor_impersonated_by", { actor: log.actor.displayName })} + {log.impersonatedBy.displayName} +
+ )}
- {t("source")} + {t("booking_audit_action.source")} {log.source}
diff --git a/packages/features/booking-audit/lib/dto/types.ts b/packages/features/booking-audit/lib/dto/types.ts new file mode 100644 index 00000000000000..794e57edc5ba51 --- /dev/null +++ b/packages/features/booking-audit/lib/dto/types.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; + +const UserActorSchema = z.object({ + identifiedBy: z.literal("user"), + userUuid: z.string(), +}); + +const AttendeeActorSchema = z.object({ + identifiedBy: z.literal("attendee"), + attendeeId: z.number(), +}); + +const ActorByIdSchema = z.object({ + identifiedBy: z.literal("id"), + id: z.string(), +}); + +const GuestActorSchema = z.object({ + identifiedBy: z.literal("guest"), + email: z.string(), + name: z.string().nullable(), +}); + +const AppActorByCredentialIdSchema = z.object({ + identifiedBy: z.literal("app"), + credentialId: z.number(), +}); + +const AppActorBySlugSchema = z.object({ + identifiedBy: z.literal("appSlug"), + appSlug: z.string(), + name: z.string(), +}); + +export const ActorSchema = z.discriminatedUnion("identifiedBy", [ + ActorByIdSchema, + UserActorSchema, + AttendeeActorSchema, + GuestActorSchema, + AppActorByCredentialIdSchema, + AppActorBySlugSchema, +]); + +export const PiiFreeActorSchema = z.discriminatedUnion("identifiedBy", [ + ActorByIdSchema, + UserActorSchema, + AttendeeActorSchema, +]); + +export type Actor = z.infer; +export type PiiFreeActor = z.infer; + +export type UserActor = z.infer; +export type GuestActor = z.infer; +export type AttendeeActor = z.infer; +export type ActorById = z.infer; +export type AppActorByCredentialId = z.infer; +export type AppActorBySlug = z.infer; + +/** + * Schema for booking audit context - Records things that are common across all actions but could be useful to capture when the action is performed. + * e.g. impersonation, userAgent, ip etc. + * This is separate from action-specific data because impersonation is orthogonal to the action type + */ +export const BookingAuditContextSchema = z.object({ + impersonatedBy: z.string().optional(), +}); + +export type BookingAuditContext = z.infer; + diff --git a/packages/features/booking-audit/lib/makeActor.ts b/packages/features/booking-audit/lib/makeActor.ts new file mode 100644 index 00000000000000..c77c8c7a13c5a4 --- /dev/null +++ b/packages/features/booking-audit/lib/makeActor.ts @@ -0,0 +1,83 @@ +import type { UserActor, GuestActor, AttendeeActor, ActorById, AppActorByCredentialId, AppActorBySlug } from "./dto/types"; + +const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000"; + +/** + * Creates an Actor representing a User by UUID + */ +export function makeUserActor(userUuid: string): UserActor { + return { + identifiedBy: "user", + userUuid, + }; +} + +export function makeGuestActor({ email, name }: { email: string, name: string | null }): GuestActor { + return { + identifiedBy: "guest", + email, + name: name ?? null, + }; +} + +/** + * Creates an Actor representing the System + * System actors must be referenced by ID (requires migration) + */ +export function makeSystemActor(): ActorById { + return { + identifiedBy: "id", + id: SYSTEM_ACTOR_ID, + }; +} + + +/** + * Creates an Actor by existing actor ID + */ +export function makeActorById(id: string): ActorById { + return { + identifiedBy: "id", + id, + }; +} + +/** + * Creates an Actor representing an Attendee by attendee ID + */ +export function makeAttendeeActor(attendeeId: number): AttendeeActor { + return { + identifiedBy: "attendee", + attendeeId, + }; +} + +/** + * Creates an Actor representing an App by credential ID (preferred) + * The credentialId uniquely identifies which app instance (e.g., which Stripe account) + * App name and slug are derived from the credential at display time + */ +export function makeAppActor(params: { credentialId: number }): AppActorByCredentialId { + return { + identifiedBy: "app", + credentialId: params.credentialId, + }; +} + +/** + * Creates an Actor representing an App by app slug (fallback) + * Used when credentialId is not available or for apps not yet migrated + * App actors use @app.internal email convention + */ +export function makeAppActorUsingSlug(params: { appSlug: string; name: string }): AppActorBySlug { + return { + identifiedBy: "appSlug", + appSlug: params.appSlug, + name: params.name, + }; +} + +export function buildActorEmail({ identifier, actorType }: { identifier: string, actorType: "system" | "guest" | "app" }): string { + return `${identifier}@${actorType}.internal`; +} + diff --git a/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts b/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts index 8bd67a3024b945..e8cc8bd2fd515c 100644 --- a/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts +++ b/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts @@ -1,6 +1,7 @@ import type { JsonValue } from "@calcom/types/Json"; import type { AuditActorType } from "./IAuditActorRepository"; import type { ActionSource } from "../types/actionSource"; +import type { BookingAuditContext } from "../dto/types"; export type BookingAuditType = "RECORD_CREATED" | "RECORD_UPDATED" | "RECORD_DELETED" @@ -21,6 +22,7 @@ export type BookingAuditCreateInput = { timestamp: Date; source: ActionSource; operationId: string; + context?: BookingAuditContext; } type BookingAudit = { @@ -38,6 +40,7 @@ type BookingAudit = { } export type BookingAuditWithActor = BookingAudit & { + context: BookingAuditContext | null; actor: { id: string; type: AuditActorType; diff --git a/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts b/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts index fac6111eb0ead8..f037c756af70f5 100644 --- a/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts +++ b/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts @@ -1,6 +1,8 @@ import type { PrismaClient } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; import type { IBookingAuditRepository, BookingAuditCreateInput, BookingAuditWithActor } from "./IBookingAuditRepository"; +import { BookingAuditContextSchema } from "../dto/types"; type Dependencies = { prismaClient: PrismaClient; @@ -30,6 +32,7 @@ const safeBookingAuditSelect = { source: true, operationId: true, data: true, + context: true, createdAt: true, updatedAt: true, } as const; @@ -37,8 +40,15 @@ const safeBookingAuditSelect = { export class PrismaBookingAuditRepository implements IBookingAuditRepository { constructor(private readonly deps: Dependencies) { } + private parsed(auditLog: T) { + return { + ...auditLog, + context: auditLog.context ? BookingAuditContextSchema.parse(auditLog.context) : null, + }; + } + async create(bookingAudit: BookingAuditCreateInput) { - return this.deps.prismaClient.bookingAudit.create({ + const created = await this.deps.prismaClient.bookingAudit.create({ data: { bookingUid: bookingAudit.bookingUid, actorId: bookingAudit.actorId, @@ -48,8 +58,11 @@ export class PrismaBookingAuditRepository implements IBookingAuditRepository { source: bookingAudit.source, operationId: bookingAudit.operationId, data: bookingAudit.data === null ? undefined : bookingAudit.data, + context: bookingAudit.context ?? undefined, }, }); + + return this.parsed(created); } async createMany(bookingAudits: BookingAuditCreateInput[]) { @@ -63,13 +76,14 @@ export class PrismaBookingAuditRepository implements IBookingAuditRepository { source: bookingAudit.source, operationId: bookingAudit.operationId, data: bookingAudit.data === null ? undefined : bookingAudit.data, + context: bookingAudit.context === undefined ? undefined : bookingAudit.context, })), }); return { count: result.count }; } async findAllForBooking(bookingUid: string): Promise { - return this.deps.prismaClient.bookingAudit.findMany({ + const results = await this.deps.prismaClient.bookingAudit.findMany({ where: { bookingUid, }, @@ -83,10 +97,12 @@ export class PrismaBookingAuditRepository implements IBookingAuditRepository { timestamp: "desc", }, }); + + return results.map(this.parsed); } async findRescheduledLogsOfBooking(bookingUid: string): Promise { - return this.deps.prismaClient.bookingAudit.findMany({ + const results = await this.deps.prismaClient.bookingAudit.findMany({ where: { bookingUid, action: "RESCHEDULED", @@ -99,6 +115,8 @@ export class PrismaBookingAuditRepository implements IBookingAuditRepository { }, orderBy: { timestamp: "desc" }, }); + + return results.map(this.parsed); } } diff --git a/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts b/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts index d54a5bd7eb9ca6..cb9927a24eed83 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { Actor } from "../../../bookings/lib/types/actor"; +import type { Actor, BookingAuditContext } from "../dto/types"; import type { ActionSource } from "../types/actionSource"; import { AcceptedAuditActionService } from "../actions/AcceptedAuditActionService"; import { AttendeeAddedAuditActionService } from "../actions/AttendeeAddedAuditActionService"; @@ -38,6 +38,7 @@ export interface BookingAuditProducerService { source: ActionSource; operationId?: string | null; data: z.infer; + context?: BookingAuditContext; }): Promise; queueRescheduledAudit(params: { @@ -47,6 +48,7 @@ export interface BookingAuditProducerService { source: ActionSource; operationId?: string | null; data: z.infer; + context?: BookingAuditContext; }): Promise; queueAcceptedAudit(params: { @@ -56,6 +58,7 @@ export interface BookingAuditProducerService { source: ActionSource; operationId?: string | null; data: z.infer; + context?: BookingAuditContext; }): Promise; queueCancelledAudit(params: { @@ -65,6 +68,7 @@ export interface BookingAuditProducerService { source: ActionSource; operationId?: string | null; data: z.infer; + context?: BookingAuditContext; }): Promise; queueRescheduleRequestedAudit(params: { @@ -74,6 +78,7 @@ export interface BookingAuditProducerService { source: ActionSource; operationId?: string | null; data: z.infer; + context?: BookingAuditContext; }): Promise; queueAttendeeAddedAudit(params: { @@ -83,6 +88,7 @@ export interface BookingAuditProducerService { source: ActionSource; operationId?: string | null; data: z.infer; + context?: BookingAuditContext; }): Promise; queueHostNoShowUpdatedAudit(params: { @@ -92,6 +98,7 @@ export interface BookingAuditProducerService { source: ActionSource; operationId?: string | null; data: z.infer; + context?: BookingAuditContext; }): Promise; queueRejectedAudit(params: { @@ -101,6 +108,7 @@ export interface BookingAuditProducerService { source: ActionSource; operationId?: string | null; data: z.infer; + context?: BookingAuditContext; }): Promise; queueAttendeeRemovedAudit(params: { @@ -110,6 +118,7 @@ export interface BookingAuditProducerService { source: ActionSource; operationId?: string | null; data: z.infer; + context?: BookingAuditContext; }): Promise; queueReassignmentAudit(params: { @@ -119,6 +128,7 @@ export interface BookingAuditProducerService { source: ActionSource; operationId?: string | null; data: z.infer; + context?: BookingAuditContext; }): Promise; queueLocationChangedAudit(params: { @@ -128,6 +138,7 @@ export interface BookingAuditProducerService { source: ActionSource; operationId?: string | null; data: z.infer; + context?: BookingAuditContext; }): Promise; queueAttendeeNoShowUpdatedAudit(params: { @@ -137,6 +148,7 @@ export interface BookingAuditProducerService { source: ActionSource; operationId?: string | null; data: z.infer; + context?: BookingAuditContext; }): Promise; queueSeatBookedAudit(params: { @@ -146,6 +158,7 @@ export interface BookingAuditProducerService { source: ActionSource; operationId?: string | null; data: z.infer; + context?: BookingAuditContext; }): Promise; queueSeatRescheduledAudit(params: { @@ -155,6 +168,7 @@ export interface BookingAuditProducerService { source: ActionSource; operationId?: string | null; data: z.infer; + context?: BookingAuditContext; }): Promise; /** @@ -169,6 +183,7 @@ export interface BookingAuditProducerService { organizationId: number | null; source: ActionSource; operationId?: string | null; + context?: BookingAuditContext; }): Promise; /** @@ -183,6 +198,7 @@ export interface BookingAuditProducerService { organizationId: number | null; source: ActionSource; operationId?: string | null; + context?: BookingAuditContext; }): Promise; } diff --git a/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts b/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts index f0417c9409c678..66440d3adea976 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts @@ -4,7 +4,7 @@ import logger from "@calcom/lib/logger"; import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface"; import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; -import type { PiiFreeActor } from "../../../bookings/lib/types/actor"; +import type { PiiFreeActor, BookingAuditContext } from "../dto/types"; import type { SingleBookingAuditTaskConsumerPayload, BulkBookingAuditTaskConsumerPayload, @@ -33,6 +33,7 @@ type CreateBookingAuditInput = { operationId: string; data: JsonValue; timestamp: Date; // Required: actual time of the booking change (business event) + context?: BookingAuditContext; }; type BookingAudit = { @@ -84,7 +85,7 @@ export class BookingAuditTaskConsumer { * Process single booking Audit Task */ async processAuditTask(payload: SingleBookingAuditTaskConsumerPayload, taskId: string): Promise { - const { action, bookingUid, actor, organizationId, data, timestamp, source, operationId } = payload; + const { action, bookingUid, actor, organizationId, data, timestamp, source, operationId, context } = payload; if (!await this.shouldProcessAudit({ organizationId, @@ -96,14 +97,14 @@ export class BookingAuditTaskConsumer { const dataInLatestFormat = await this.migrateIfNeeded({ action, data, payload, taskId }); - await this.onBookingAction({ bookingUid, actor, action, source, operationId, data: dataInLatestFormat, timestamp }); + await this.onBookingAction({ bookingUid, actor, action, source, operationId, data: dataInLatestFormat, timestamp, context }); } /** * Process Bulk bookings Audit Task */ async processBulkAuditTask(payload: BulkBookingAuditTaskConsumerPayload, taskId: string): Promise { - const { bookings, action, actor, organizationId, timestamp, source, operationId } = payload; + const { bookings, action, actor, organizationId, timestamp, source, operationId, context } = payload; if (!await this.shouldProcessAudit({ organizationId, @@ -127,6 +128,7 @@ export class BookingAuditTaskConsumer { source, operationId, timestamp, + context, }); } @@ -319,6 +321,7 @@ export class BookingAuditTaskConsumer { action: input.action, source: input.source, timestamp: input.timestamp, + context: input.context, })); return this.bookingAuditRepository.create({ @@ -330,6 +333,7 @@ export class BookingAuditTaskConsumer { timestamp: input.timestamp, operationId: input.operationId, data: input.data ?? null, + context: input.context, }); } @@ -382,8 +386,9 @@ export class BookingAuditTaskConsumer { source: ActionSource; operationId: string; timestamp: number; + context?: BookingAuditContext; }): Promise { - const { bookings, actor, action, source, operationId, timestamp } = params; + const { bookings, actor, action, source, operationId, timestamp, context } = params; const actorId = await this.resolveActorId(actor); const recordType = this.getRecordType({ action }); @@ -400,6 +405,7 @@ export class BookingAuditTaskConsumer { operationId, data: versionedData as JsonValue, timestamp: new Date(timestamp), + context, }; }); @@ -418,8 +424,9 @@ export class BookingAuditTaskConsumer { operationId: string; data: Record; timestamp: number; + context?: BookingAuditContext; }): Promise { - const { bookingUid, actor, action, source, operationId, data, timestamp } = params; + const { bookingUid, actor, action, source, operationId, data, timestamp, context } = params; const actionService = this.actionServiceRegistry.getActionService(action); const versionedData = actionService.getVersionedData(data); const actorId = await this.resolveActorId(actor); @@ -435,6 +442,7 @@ export class BookingAuditTaskConsumer { // versionedData is { version: number; fields: unknown } which is JsonValue-compatible data: versionedData as JsonValue, timestamp: new Date(timestamp), + context, }); } } diff --git a/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts b/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts index 03cfb4186379bb..5821bab4f135c8 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts @@ -7,7 +7,8 @@ import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.s import type { BookingAuditAction } from "../types/bookingAuditTask"; import type { ActionSource } from "../types/actionSource"; -import { makeActorById, type PiiFreeActor, type Actor, buildActorEmail } from "../../../bookings/lib/types/actor"; +import type { PiiFreeActor, Actor, BookingAuditContext } from "../dto/types"; +import { makeActorById, buildActorEmail } from "../makeActor"; import type { IAuditActorRepository } from "../repository/IAuditActorRepository"; import { AcceptedAuditActionService } from "../actions/AcceptedAuditActionService"; import { AttendeeAddedAuditActionService } from "../actions/AttendeeAddedAuditActionService"; @@ -98,6 +99,7 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe source: ActionSource; operationId?: string | null; data: unknown; + context?: BookingAuditContext; }): Promise { // Skip queueing for non-organization bookings if (params.organizationId === null) { @@ -123,6 +125,7 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe source: params.source, operationId, data: params.data, + context: params.context, }); } catch (error) { this.log.error(`Error while queueing ${params.action} audit`, safeStringify(error)); @@ -335,6 +338,7 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe action: string; source: ActionSource; operationId?: string | null; + context?: BookingAuditContext; }): Promise { // Skip queueing for non-organization bookings if (params.organizationId === null) { @@ -359,6 +363,7 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe action: params.action as BookingAuditAction, source: params.source, operationId, + context: params.context, }); } catch (error) { this.log.error(`Error while queueing bulk ${params.action} audit`, safeStringify(error)); diff --git a/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts b/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts index e480b2345ec6ec..cb7f3dfafdaff8 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts @@ -12,6 +12,7 @@ import type { TranslationWithParams } from "../actions/IAuditActionService"; import type { ActionSource } from "../types/actionSource"; import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService"; import { getAppNameFromSlug } from "../getAppNameFromSlug"; +import type { BookingAuditContext } from "../dto/types"; interface BookingAuditViewerServiceDeps { bookingAuditRepository: IBookingAuditRepository; @@ -46,6 +47,11 @@ type EnrichedAuditLog = { displayEmail: string | null; displayAvatar: string | null; }; + impersonatedBy?: { + displayName: string; + displayEmail: string | null; + displayAvatar: string | null; + } | null; }; export type DisplayBookingAuditLog = EnrichedAuditLog; @@ -150,6 +156,8 @@ export class BookingAuditViewerService { ? actionService.getDisplayFields(parsedData) : null; + const impersonatedBy = await this.enrichImpersonator(log.context); + return { id: log.id, bookingUid: log.bookingUid, @@ -173,6 +181,7 @@ export class BookingAuditViewerService { displayEmail: enrichedActor.displayEmail, displayAvatar: enrichedActor.displayAvatar, }, + impersonatedBy, }; } /** @@ -231,6 +240,31 @@ export class BookingAuditViewerService { }; } + private async enrichImpersonator(context: BookingAuditContext | null): Promise<{ + displayName: string; + displayEmail: string | null; + displayAvatar: string | null; + } | null> { + if (!context?.impersonatedBy) { + return null; + } + + const impersonatorUser = await this.userRepository.findByUuid({ uuid: context.impersonatedBy }); + if (!impersonatorUser) { + return { + displayName: "Deleted User", + displayEmail: null, + displayAvatar: null, + }; + } + + return { + displayName: impersonatorUser.name || impersonatorUser.email, + displayEmail: impersonatorUser.email, + displayAvatar: impersonatorUser.avatarUrl || null, + }; + } + /** * Enrich actor information with user details if userUuid exists */ diff --git a/packages/features/booking-audit/lib/service/__tests__/BookingAuditViewerService.test.ts b/packages/features/booking-audit/lib/service/__tests__/BookingAuditViewerService.test.ts index 50d6be6ccaa9aa..5b29a95e236ad9 100644 --- a/packages/features/booking-audit/lib/service/__tests__/BookingAuditViewerService.test.ts +++ b/packages/features/booking-audit/lib/service/__tests__/BookingAuditViewerService.test.ts @@ -4,6 +4,7 @@ import { PermissionCheckService } from "@calcom/features/pbac/services/permissio import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; +import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository"; import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository"; import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service"; @@ -11,11 +12,13 @@ import { BookingAuditViewerService } from "../BookingAuditViewerService"; import { BookingAuditPermissionError, BookingAuditErrorCode } from "../BookingAuditAccessService"; import type { IBookingAuditRepository, BookingAuditWithActor, BookingAuditAction, BookingAuditType } from "../../repository/IBookingAuditRepository"; import type { AuditActorType } from "../../repository/IAuditActorRepository"; +import type { BookingAuditContext } from "../../dto/types"; vi.mock("@calcom/features/pbac/services/permission-check.service"); vi.mock("@calcom/features/users/repositories/UserRepository"); vi.mock("@calcom/features/bookings/repositories/BookingRepository"); vi.mock("@calcom/features/membership/repositories/MembershipRepository"); +vi.mock("@calcom/features/credentials/repositories/CredentialRepository"); const createMockTeamBooking = (overrides?: { userId?: number; @@ -51,6 +54,7 @@ const createMockAuditLog = ( actorType: AuditActorType; actorUserUuid: string | null; actorName: string | null; + context: BookingAuditContext | null; }> ): BookingAuditWithActor => ({ id: overrides?.id ?? "audit-log-1", @@ -64,11 +68,13 @@ const createMockAuditLog = ( data: overrides?.data ?? { version: 1, fields: { startTime: 1705315200000, endTime: 1705318800000, status: "ACCEPTED" } }, source: "WEBAPP" as const, operationId: "operation-id-123", + context: (overrides && "context" in overrides ? overrides.context : null) as BookingAuditContext | null, actor: { id: overrides?.actorId ?? "actor-1", type: overrides?.actorType ?? "USER" as const, userUuid: (overrides && "actorUserUuid" in overrides ? overrides.actorUserUuid : "user-uuid-123") as string | null, attendeeId: null, + credentialId: null, name: (overrides && "actorName" in overrides ? overrides.actorName : "John Doe") as string | null, createdAt: new Date("2024-01-01T00:00:00Z"), }, @@ -105,6 +111,9 @@ describe("BookingAuditViewerService - Integration Tests", () => { let mockAttendeeRepository: { findById: Mock; }; + let mockCredentialRepository: { + findByCredentialId: Mock; + }; let mockLog: { error: Mock; }; @@ -140,14 +149,19 @@ describe("BookingAuditViewerService - Integration Tests", () => { findById: vi.fn(), }; + mockCredentialRepository = { + findByCredentialId: vi.fn(), + }; + mockLog = { error: vi.fn(), }; - vi.mocked(BookingRepository).mockImplementation(function() { return mockBookingRepository as unknown as BookingRepository; }); - vi.mocked(UserRepository).mockImplementation(function() { return mockUserRepository as unknown as UserRepository; }); - vi.mocked(MembershipRepository).mockImplementation(function() { return mockMembershipRepository as unknown as MembershipRepository; }); - vi.mocked(PermissionCheckService).mockImplementation(function() { return mockPermissionCheckService as unknown as PermissionCheckService; }); + vi.mocked(BookingRepository).mockImplementation(function () { return mockBookingRepository as unknown as BookingRepository; }); + vi.mocked(UserRepository).mockImplementation(function () { return mockUserRepository as unknown as UserRepository; }); + vi.mocked(MembershipRepository).mockImplementation(function () { return mockMembershipRepository as unknown as MembershipRepository; }); + vi.mocked(PermissionCheckService).mockImplementation(function () { return mockPermissionCheckService as unknown as PermissionCheckService; }); + vi.mocked(CredentialRepository).mockImplementation(function () { return mockCredentialRepository as unknown as CredentialRepository; }); service = new BookingAuditViewerService({ bookingAuditRepository: mockBookingAuditRepository as unknown as IBookingAuditRepository, @@ -155,6 +169,7 @@ describe("BookingAuditViewerService - Integration Tests", () => { bookingRepository: mockBookingRepository as unknown as BookingRepository, membershipRepository: mockMembershipRepository as unknown as MembershipRepository, attendeeRepository: mockAttendeeRepository as unknown as IAttendeeRepository, + credentialRepository: mockCredentialRepository as unknown as CredentialRepository, log: mockLog as unknown as ISimpleLogger, }); }); @@ -888,5 +903,142 @@ describe("BookingAuditViewerService - Integration Tests", () => { expect(result.auditLogs[0].actionDisplayTitle.components).toBeUndefined(); }); }); + + describe("when handling impersonatedBy context", () => { + beforeEach(() => { + mockBookingRepository.findByUidIncludeEventType.mockResolvedValue( + createMockTeamBooking({ teamId: 100 }) + ); + mockPermissionCheckService.checkPermission.mockResolvedValue(true); + }); + + it("should enrich impersonatedBy when user exists", async () => { + const mockLog = createMockAuditLog({ + context: { impersonatedBy: "impersonator-uuid-456" }, + }); + const impersonatorUser = createMockUser({ + id: 456, + name: "Admin User", + email: "admin@example.com", + avatarUrl: "https://example.com/admin-avatar.jpg", + }); + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); + mockUserRepository.findByUuid.mockResolvedValueOnce(createMockUser()); // For actor + mockUserRepository.findByUuid.mockResolvedValueOnce(impersonatorUser); // For impersonator + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "UTC", + organizationId: 200, + }); + + expect(mockUserRepository.findByUuid).toHaveBeenCalledWith({ uuid: "impersonator-uuid-456" }); + expect(result.auditLogs[0].impersonatedBy).toMatchObject({ + displayName: "Admin User", + displayEmail: "admin@example.com", + displayAvatar: "https://example.com/admin-avatar.jpg", + }); + }); + + it("should use email as displayName when impersonator name is null", async () => { + const mockLog = createMockAuditLog({ + context: { impersonatedBy: "impersonator-uuid-456" }, + }); + const impersonatorUser = createMockUser({ + id: 456, + name: null, + email: "admin@example.com", + avatarUrl: null, + }); + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); + mockUserRepository.findByUuid.mockResolvedValueOnce(createMockUser()); // For actor + mockUserRepository.findByUuid.mockResolvedValueOnce(impersonatorUser); // For impersonator + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "UTC", + organizationId: 200, + }); + + expect(result.auditLogs[0].impersonatedBy).toMatchObject({ + displayName: "admin@example.com", + displayEmail: "admin@example.com", + displayAvatar: null, + }); + }); + + it("should show 'Deleted User' when impersonator user not found", async () => { + const mockLog = createMockAuditLog({ + context: { impersonatedBy: "impersonator-uuid-456" }, + }); + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); + mockUserRepository.findByUuid.mockResolvedValueOnce(createMockUser()); // For actor + mockUserRepository.findByUuid.mockResolvedValueOnce(null); // For impersonator (not found) + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "UTC", + organizationId: 200, + }); + + expect(mockUserRepository.findByUuid).toHaveBeenCalledWith({ uuid: "impersonator-uuid-456" }); + expect(result.auditLogs[0].impersonatedBy).toMatchObject({ + displayName: "Deleted User", + displayEmail: null, + displayAvatar: null, + }); + }); + + it("should return null when context is null", async () => { + const mockLog = createMockAuditLog({ + context: null, + }); + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); + mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "UTC", + organizationId: 200, + }); + + expect(result.auditLogs[0].impersonatedBy).toBeNull(); + // Should not call findByUuid for impersonator when context is null + expect(mockUserRepository.findByUuid).toHaveBeenCalledTimes(1); // Only for actor + }); + + it("should return null when impersonatedBy is not in context", async () => { + const mockLog = createMockAuditLog({ + context: {}, + }); + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); + mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "UTC", + organizationId: 200, + }); + + expect(result.auditLogs[0].impersonatedBy).toBeNull(); + // Should not call findByUuid for impersonator when impersonatedBy is not in context + expect(mockUserRepository.findByUuid).toHaveBeenCalledTimes(1); // Only for actor + }); + }); }); }); diff --git a/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts b/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts index c79e99b30af006..ecaa8835228171 100644 --- a/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts +++ b/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts @@ -5,7 +5,7 @@ import { BookingStatus, MembershipRole } from "@calcom/prisma/enums"; import type { BookingAuditTaskConsumer } from "./BookingAuditTaskConsumer"; import type { BookingAuditViewerService } from "./BookingAuditViewerService"; -import { makeUserActor } from "../../../bookings/lib/types/actor"; +import { makeUserActor } from "../makeActor"; import { getBookingAuditTaskConsumer } from "../../di/BookingAuditTaskConsumer.container"; import { getBookingAuditViewerService } from "../../di/BookingAuditViewerService.container"; @@ -338,6 +338,48 @@ describe("Booking Audit Integration", () => { expect(auditLog.actor.userUuid).toBe(testData.owner.uuid); }); + it("should include impersonator details when context has impersonatedBy", async () => { + // Create a second user to act as impersonator + const impersonator = await createTestUser({ name: "Admin Impersonator" }); + + const actor = makeUserActor(testData.owner.uuid); + + await bookingAuditTaskConsumer.onBookingAction({ + bookingUid: testData.booking.uid, + actor, + action: "CREATED", + source: "WEBAPP", + operationId: `op-${Date.now()}`, + data: { + startTime: testData.booking.startTime.getTime(), + endTime: testData.booking.endTime.getTime(), + status: testData.booking.status, + }, + timestamp: Date.now(), + context: { + impersonatedBy: impersonator.uuid, + }, + }); + + const result = await bookingAuditViewerService.getAuditLogsForBooking({ + bookingUid: testData.booking.uid, + userId: testData.owner.id, + userEmail: testData.owner.email, + userTimeZone: "UTC", + organizationId: testData.organization.id, + }); + + expect(result.auditLogs).toHaveLength(1); + + const auditLog = result.auditLogs[0]; + expect(auditLog.impersonatedBy).toBeDefined(); + expect(auditLog.impersonatedBy?.displayName).toBe("Admin Impersonator"); + expect(auditLog.impersonatedBy?.displayEmail).toBe(impersonator.email); + + // Cleanup impersonator user + await prisma.user.delete({ where: { id: impersonator.id } }); + }); + it.skip("should deny access to unauthorized users viewing audit logs", async () => { const actor = makeUserActor(testData.owner.uuid); @@ -407,6 +449,7 @@ describe("Booking Audit Integration", () => { await bookingAuditTaskConsumer.processBulkAuditTask( { + isBulk: true, bookings: [ { bookingUid: testData.booking.uid, diff --git a/packages/features/booking-audit/lib/types/bookingAuditTask.ts b/packages/features/booking-audit/lib/types/bookingAuditTask.ts index d98849e4525132..732419625005e6 100644 --- a/packages/features/booking-audit/lib/types/bookingAuditTask.ts +++ b/packages/features/booking-audit/lib/types/bookingAuditTask.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { PiiFreeActorSchema } from "../../../bookings/lib/types/actor"; +import { PiiFreeActorSchema, BookingAuditContextSchema } from "../dto/types"; import { ActionSourceSchema } from "./actionSource"; /** @@ -42,6 +42,7 @@ export const SingleBookingAuditTaskConsumerSchema = z.object({ action: BookingAuditActionSchema, source: ActionSourceSchema.default("UNKNOWN"), operationId: z.string(), + context: BookingAuditContextSchema.optional(), }); export type SingleBookingAuditTaskConsumerPayload = z.infer; @@ -62,6 +63,7 @@ export const BulkBookingAuditTaskConsumerSchema = z.object({ action: BookingAuditActionSchema, source: ActionSourceSchema.default("UNKNOWN"), operationId: z.string(), + context: BookingAuditContextSchema.optional(), }); export type BulkBookingAuditTaskConsumerPayload = z.infer; diff --git a/packages/features/bookings/lib/dto/types.d.ts b/packages/features/bookings/lib/dto/types.d.ts index 75e6efc10bd05a..47161ca3e79095 100644 --- a/packages/features/bookings/lib/dto/types.d.ts +++ b/packages/features/bookings/lib/dto/types.d.ts @@ -39,6 +39,7 @@ export type CreateBookingMeta = { forcedSlug?: string; noEmail?: boolean; traceContext?: TraceContext; + impersonatedByUserUuid?: string; } & PlatformParams; export type BookingHandlerInput = { diff --git a/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts b/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts index 9fb2666e14731c..ee8974b0173d8b 100644 --- a/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts +++ b/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts @@ -17,7 +17,7 @@ import type { ActionSource } from "@calcom/features/booking-audit/lib/types/acti import type { HashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService"; import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service"; import { safeStringify } from "@calcom/lib/safeStringify"; -import type { Actor } from "../types/actor"; +import type { Actor, BookingAuditContext } from "@calcom/features/booking-audit/lib/dto/types"; import type { BookingCreatedPayload, BookingRescheduledPayload } from "./types"; interface BookingEventHandlerDeps { @@ -32,6 +32,7 @@ interface OnBookingCreatedParams { auditData: CreatedAuditData; source: ActionSource; operationId?: string | null; + context?: BookingAuditContext; } interface OnBookingRescheduledParams { @@ -40,6 +41,7 @@ interface OnBookingRescheduledParams { auditData: RescheduledAuditData; source: ActionSource; operationId?: string | null; + context?: BookingAuditContext; } interface BaseBookingEventParams { @@ -49,6 +51,7 @@ interface BaseBookingEventParams { auditData: TAuditData; source: ActionSource; operationId?: string | null; + context?: BookingAuditContext; } type OnBookingAcceptedParams = BaseBookingEventParams; @@ -74,7 +77,7 @@ export class BookingEventHandlerService { } async onBookingCreated(params: OnBookingCreatedParams) { - const { payload, actor, auditData, source, operationId } = params; + const { payload, actor, auditData, source, operationId, context } = params; this.log.debug("onBookingCreated", safeStringify(payload)); if (payload.config.isDryRun) { return; @@ -87,11 +90,12 @@ export class BookingEventHandlerService { source, operationId, data: auditData, + context, }); } async onBookingRescheduled(params: OnBookingRescheduledParams) { - const { payload, actor, auditData, source, operationId } = params; + const { payload, actor, auditData, source, operationId, context } = params; this.log.debug("onBookingRescheduled", safeStringify(payload)); if (payload.config.isDryRun) { return; @@ -106,6 +110,7 @@ export class BookingEventHandlerService { source, operationId, data: auditData, + context, }); } @@ -139,7 +144,7 @@ export class BookingEventHandlerService { } async onBookingAccepted(params: OnBookingAcceptedParams) { - const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params; await this.bookingAuditProducerService.queueAcceptedAudit({ bookingUid, actor, @@ -147,11 +152,12 @@ export class BookingEventHandlerService { source, operationId, data: auditData, + context, }); } async onBookingCancelled(params: OnBookingCancelledParams) { - const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params; await this.bookingAuditProducerService.queueCancelledAudit({ bookingUid, actor, @@ -159,11 +165,12 @@ export class BookingEventHandlerService { source, operationId, data: auditData, + context, }); } async onRescheduleRequested(params: OnRescheduleRequestedParams) { - const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params; await this.bookingAuditProducerService.queueRescheduleRequestedAudit({ bookingUid, actor, @@ -171,11 +178,12 @@ export class BookingEventHandlerService { source, operationId, data: auditData, + context, }); } async onAttendeeAdded(params: OnAttendeeAddedParams) { - const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params; await this.bookingAuditProducerService.queueAttendeeAddedAudit({ bookingUid, actor, @@ -183,11 +191,12 @@ export class BookingEventHandlerService { source, operationId, data: auditData, + context, }); } async onHostNoShowUpdated(params: OnHostNoShowUpdatedParams) { - const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params; await this.bookingAuditProducerService.queueHostNoShowUpdatedAudit({ bookingUid, actor, @@ -195,11 +204,12 @@ export class BookingEventHandlerService { source, operationId, data: auditData, + context, }); } async onBookingRejected(params: OnBookingRejectedParams) { - const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params; await this.bookingAuditProducerService.queueRejectedAudit({ bookingUid, actor, @@ -207,11 +217,12 @@ export class BookingEventHandlerService { source, operationId, data: auditData, + context, }); } async onAttendeeRemoved(params: OnAttendeeRemovedParams) { - const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params; await this.bookingAuditProducerService.queueAttendeeRemovedAudit({ bookingUid, actor, @@ -219,11 +230,12 @@ export class BookingEventHandlerService { source, operationId, data: auditData, + context, }); } async onReassignment(params: OnReassignmentParams) { - const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params; await this.bookingAuditProducerService.queueReassignmentAudit({ bookingUid, actor, @@ -231,11 +243,12 @@ export class BookingEventHandlerService { source, operationId, data: auditData, + context, }); } async onLocationChanged(params: OnLocationChangedParams) { - const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params; await this.bookingAuditProducerService.queueLocationChangedAudit({ bookingUid, actor, @@ -243,11 +256,12 @@ export class BookingEventHandlerService { source, operationId, data: auditData, + context, }); } async onAttendeeNoShowUpdated(params: OnAttendeeNoShowUpdatedParams) { - const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params; await this.bookingAuditProducerService.queueAttendeeNoShowUpdatedAudit({ bookingUid, actor, @@ -255,11 +269,12 @@ export class BookingEventHandlerService { source, operationId, data: auditData, + context, }); } async onSeatBooked(params: OnSeatBookedParams) { - const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params; await this.bookingAuditProducerService.queueSeatBookedAudit({ bookingUid, actor, @@ -267,11 +282,12 @@ export class BookingEventHandlerService { source, operationId, data: auditData, + context, }); } async onSeatRescheduled(params: OnSeatRescheduledParams) { - const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params; await this.bookingAuditProducerService.queueSeatRescheduledAudit({ bookingUid, actor, @@ -279,6 +295,7 @@ export class BookingEventHandlerService { source, operationId, data: auditData, + context, }); } @@ -295,8 +312,9 @@ export class BookingEventHandlerService { organizationId: number | null; operationId?: string | null; source: ActionSource; + context?: BookingAuditContext; }) { - const { bookings, actor, organizationId, operationId, source } = params; + const { bookings, actor, organizationId, operationId, source, context } = params; await this.bookingAuditProducerService.queueBulkAcceptedAudit({ bookings: bookings.map((booking) => ({ bookingUid: booking.bookingUid, @@ -306,6 +324,7 @@ export class BookingEventHandlerService { organizationId, source, operationId, + context, }); } @@ -322,8 +341,9 @@ export class BookingEventHandlerService { organizationId: number | null; operationId?: string | null; source: ActionSource; + context?: BookingAuditContext; }) { - const { bookings, actor, organizationId, operationId, source } = params; + const { bookings, actor, organizationId, operationId, source, context } = params; await this.bookingAuditProducerService.queueBulkCancelledAudit({ bookings: bookings.map((booking) => ({ bookingUid: booking.bookingUid, @@ -333,6 +353,7 @@ export class BookingEventHandlerService { organizationId, source, operationId, + context, }); } } diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 12696f00faeed9..79f608adb14159 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -128,7 +128,7 @@ import { validateEventLength } from "../handleNewBooking/validateEventLength"; import handleSeats from "../handleSeats/handleSeats"; import type { IBookingService } from "../interfaces/IBookingService"; import { isWithinMinimumRescheduleNotice } from "../reschedule/isWithinMinimumRescheduleNotice"; -import { makeGuestActor } from "../types/actor"; +import { makeGuestActor } from "@calcom/features/booking-audit/lib/makeActor"; const translator = short(); diff --git a/packages/features/bookings/lib/types/actor.ts b/packages/features/bookings/lib/types/actor.ts deleted file mode 100644 index 0f613c4b142719..00000000000000 --- a/packages/features/bookings/lib/types/actor.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { z } from "zod"; - -const UserActorSchema = z.object({ - identifiedBy: z.literal("user"), - userUuid: z.string(), -}); - -const AttendeeActorSchema = z.object({ - identifiedBy: z.literal("attendee"), - attendeeId: z.number(), -}); - -const ActorByIdSchema = z.object({ - identifiedBy: z.literal("id"), - id: z.string(), -}); - -const GuestActorSchema = z.object({ - identifiedBy: z.literal("guest"), - email: z.string(), - name: z.string().nullable(), -}); - -const AppActorByCredentialIdSchema = z.object({ - identifiedBy: z.literal("app"), - credentialId: z.number(), -}); - -const AppActorBySlugSchema = z.object({ - identifiedBy: z.literal("appSlug"), - appSlug: z.string(), - name: z.string(), -}); - -export const ActorSchema = z.discriminatedUnion("identifiedBy", [ - ActorByIdSchema, - UserActorSchema, - AttendeeActorSchema, - GuestActorSchema, - AppActorByCredentialIdSchema, - AppActorBySlugSchema, -]); - -export const PiiFreeActorSchema = z.discriminatedUnion("identifiedBy", [ - ActorByIdSchema, - UserActorSchema, - AttendeeActorSchema, -]); - -export type Actor = z.infer; -export type PiiFreeActor = z.infer; - -type UserActor = z.infer; -type GuestActor = z.infer; -type AttendeeActor = z.infer; -type ActorById = z.infer; -type AppActorByCredentialId = z.infer; -type AppActorBySlug = z.infer; -/** - * Creates an Actor representing a User by UUID - */ -export function makeUserActor(userUuid: string): UserActor { - return { - identifiedBy: "user", - userUuid, - }; -} - -export function makeGuestActor({ email, name }: { email: string, name: string | null }): GuestActor { - return { - identifiedBy: "guest", - email, - name: name ?? null, - }; -} - -/** - * Creates an Actor representing the System - * System actors must be referenced by ID (requires migration) - */ -export function makeSystemActor(): ActorById { - return { - identifiedBy: "id", - id: SYSTEM_ACTOR_ID, - }; -} - - -/** - * Creates an Actor by existing actor ID - */ -export function makeActorById(id: string): ActorById { - return { - identifiedBy: "id", - id, - }; -} - -/** - * Creates an Actor representing an Attendee by attendee ID - */ -export function makeAttendeeActor(attendeeId: number): AttendeeActor { - return { - identifiedBy: "attendee", - attendeeId, - }; -} - -/** - * Creates an Actor representing an App by credential ID (preferred) - * The credentialId uniquely identifies which app instance (e.g., which Stripe account) - * App name and slug are derived from the credential at display time - */ -export function makeAppActor(params: { credentialId: number }): AppActorByCredentialId { - return { - identifiedBy: "app", - credentialId: params.credentialId, - }; -} - -/** - * Creates an Actor representing an App by app slug (fallback) - * Used when credentialId is not available or for apps not yet migrated - * App actors use @app.internal email convention - */ -export function makeAppActorUsingSlug(params: { appSlug: string; name: string }): AppActorBySlug { - return { - identifiedBy: "appSlug", - appSlug: params.appSlug, - name: params.name, - }; -} - -export function buildActorEmail({ identifier, actorType }: { identifier: string, actorType: "system" | "guest" | "app" }): string { - return `${identifier}@${actorType}.internal`; -} - -export const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000"; \ No newline at end of file diff --git a/packages/features/ee/impersonation/lib/ImpersonationProvider.ts b/packages/features/ee/impersonation/lib/ImpersonationProvider.ts index 2f601252b4835a..c095c0e3cd625e 100644 --- a/packages/features/ee/impersonation/lib/ImpersonationProvider.ts +++ b/packages/features/ee/impersonation/lib/ImpersonationProvider.ts @@ -87,6 +87,7 @@ const auditAndReturnNextUser = async ( }, select: { id: true, + uuid: true, role: true, }, }); @@ -95,8 +96,9 @@ const auditAndReturnNextUser = async ( return { ...obj, impersonatedBy: { - id: impersonatedByUser?.id, - role: impersonatedByUser?.role, + id: impersonatedByUser.id, + uuid: impersonatedByUser.uuid, + role: impersonatedByUser.role, }, }; } diff --git a/packages/prisma/migrations/20251224092336_add_context_audit_action/migration.sql b/packages/prisma/migrations/20251224092336_add_context_audit_action/migration.sql new file mode 100644 index 00000000000000..4693eae322bad9 --- /dev/null +++ b/packages/prisma/migrations/20251224092336_add_context_audit_action/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."BookingAudit" ADD COLUMN "context" JSONB; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 92db9d2f9077ab..5a27213308bc73 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -2813,6 +2813,11 @@ model BookingAudit { data Json? + // Context for across action types concerns like impersonation, ip, userAgent etc. + // This is separate from `data` because it isn't related to action type but makes sense with every action + // BookingAuditContextSchema + context Json? + @@index([actorId]) @@index([bookingUid]) @@index([timestamp]) diff --git a/packages/types/next-auth.d.ts b/packages/types/next-auth.d.ts index faaff924ae79aa..3df6fc68eb5b61 100644 --- a/packages/types/next-auth.d.ts +++ b/packages/types/next-auth.d.ts @@ -24,6 +24,7 @@ declare module "next-auth" { completedOnboarding?: boolean; impersonatedBy?: { id: number; + uuid: string; role: PrismaUser["role"]; }; belongsToActiveTeam?: boolean; @@ -58,6 +59,7 @@ declare module "next-auth/jwt" { role?: UserPermissionRole | "INACTIVE_ADMIN" | null; impersonatedBy?: { id: number; + uuid: string; role: PrismaUser["role"]; }; belongsToActiveTeam?: boolean;