From fca818a275ea8437d8ea3fba626dab9456510c65 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Tue, 6 Jan 2026 11:14:28 +0530 Subject: [PATCH 1/2] test: fix unit test flake (#25557) * fix: revalidate teams cache after accepting invite via token When a user accepts a team invite via token on the /teams page, the teams cache was not being invalidated. This caused the page to show stale data (empty list) while the sidebar correctly showed the teams. This fix adds a call to revalidateTeamsList() after processing an invite token, ensuring the cache is invalidated and fresh data is fetched immediately. Co-Authored-By: anik@cal.com * fix: revalidate teams cache after creating team in onboarding flow Co-Authored-By: anik@cal.com * Remove comments on teams cache revalidation Removed comments about revalidating teams cache after invite processing. * fix unit test flake * Remove unused import in useCreateTeam hook Removed unused import for revalidateTeamsList. * Remove unused import for revalidateTeamsList --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../utils/bookingScenario/bookingScenario.ts | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) 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, From a056c3217e848e16274793323ce488ce380008dd Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Tue, 6 Jan 2026 16:16:18 +0530 Subject: [PATCH 2/2] chore: Add impersonation context support to booking audit (#26014) ## What does this PR do? Adds infrastructure to track impersonation context in booking audit records and displays it in the UI. When an admin impersonates another user and performs booking actions, the audit system now: - Records the **admin** as the actor (who actually performed the action) - Stores the **impersonated user's UUID** in a separate `context` field - Displays **"Impersonated By"** in the booking logs UI when viewing audit details This separation ensures audit trail integrity (the admin is accountable) while preserving full context about whose account was being used. ### Changes - Added `uuid` to `impersonatedBy` session object for actor identification - Added `uuid` to top-level `User` type in next-auth types for session enrichment - Added `context Json?` field to `BookingAudit` Prisma model - Added `BookingAuditContextSchema` for type-safe context handling with `actingAsUserUuid` field - Updated producer service interface and implementation to pass context through all queue methods - Updated consumer service to persist context to database - Updated repository to store and fetch context in BookingAudit records - Added `impersonatedBy` field to `EnrichedAuditLog` type in `BookingAuditViewerService` - Added `enrichImpersonationContext` method to resolve impersonated user details - Updated booking logs UI to display "Impersonated By" in expanded details - Added `impersonated_by` translation key Link to Devin run: https://app.devin.ai/sessions/3f1252527aef4ead9401bdf055c0817b Requested by: hariom@cal.com (@hariombalhara) ## Mandatory Tasks (DO NOT REMOVE) - [x] I have self-reviewed the code (A decent size PR without self-review might be rejected). - [x] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). N/A - internal infrastructure change - [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works. ## How should this be tested? 1. Verify type checks pass: `yarn type-check:ci --force` 2. Verify existing audit tests still pass: `TZ=UTC yarn test` 3. To fully test impersonation context display: - Have an admin impersonate a user - Perform a booking action (create, cancel, reschedule) - Navigate to the booking's audit logs - Expand the details for the action - Verify "Impersonated By" row appears showing the impersonated user's name - Verify the BookingAudit record has: - `actorId` pointing to the admin's AuditActor - `context` containing `{ actingAsUserUuid: "" }` ## Human Review Checklist - [ ] Verify the `context` field schema design is appropriate for future extensibility - [ ] Confirm the `uuid` addition to User type in next-auth doesn't break existing auth flows - [ ] Check that the optional `context` parameter doesn't break existing queue method callers - [ ] Verify no migration file is needed (or if squashing is handled separately) - [ ] Verify the UI displays "Impersonated By" correctly when impersonation context is present - [ ] Confirm `enrichImpersonationContext` handles edge cases (null context, invalid context, deleted user) ## Checklist - [x] My code follows the style guidelines of this project - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have checked if my changes generate no new warnings --- apps/web/pages/api/book/event.ts | 1 + apps/web/public/static/locales/en/common.json | 4 +- .../features/auth/lib/getServerSession.ts | 2 + .../client/components/BookingHistory.tsx | 13 +- .../features/booking-audit/lib/dto/types.ts | 70 ++++++++ .../features/booking-audit/lib/makeActor.ts | 83 +++++++++ .../lib/repository/IBookingAuditRepository.ts | 3 + .../PrismaBookingAuditRepository.ts | 24 ++- .../BookingAuditProducerService.interface.ts | 18 +- .../lib/service/BookingAuditTaskConsumer.ts | 20 ++- .../BookingAuditTaskerProducerService.ts | 7 +- .../lib/service/BookingAuditViewerService.ts | 34 ++++ .../BookingAuditViewerService.test.ts | 160 +++++++++++++++++- .../service/booking-audit.integration-test.ts | 45 ++++- .../lib/types/bookingAuditTask.ts | 4 +- packages/features/bookings/lib/dto/types.d.ts | 1 + .../BookingEventHandlerService.ts | 55 ++++-- .../lib/service/RegularBookingService.ts | 2 +- packages/features/bookings/lib/types/actor.ts | 138 --------------- .../lib/ImpersonationProvider.ts | 6 +- .../migration.sql | 2 + packages/prisma/schema.prisma | 5 + packages/types/next-auth.d.ts | 2 + 23 files changed, 522 insertions(+), 177 deletions(-) create mode 100644 packages/features/booking-audit/lib/dto/types.ts create mode 100644 packages/features/booking-audit/lib/makeActor.ts delete mode 100644 packages/features/bookings/lib/types/actor.ts create mode 100644 packages/prisma/migrations/20251224092336_add_context_audit_action/migration.sql 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/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;