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: "<impersonated-user-uuid>" }` ## 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
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
<span className="font-medium text-emphasis min-w-[80px]">{t("actor")}</span>
|
||||
<span className="text-default">{log.actor.displayName || log.actor.type}</span>
|
||||
</div>
|
||||
{log.impersonatedBy && (
|
||||
<div className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
|
||||
<span className="font-medium text-emphasis min-w-[80px]">{t("booking_audit_action.actor_impersonated_by", { actor: log.actor.displayName })}</span>
|
||||
<span className="text-default">{log.impersonatedBy.displayName}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
|
||||
<span className="font-medium text-emphasis min-w-[80px]">{t("source")}</span>
|
||||
<span className="font-medium text-emphasis min-w-[80px]">{t("booking_audit_action.source")}</span>
|
||||
<span className="text-default">{log.source}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 py-2 px-3 border-b border-subtle">
|
||||
|
||||
@@ -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<typeof ActorSchema>;
|
||||
export type PiiFreeActor = z.infer<typeof PiiFreeActorSchema>;
|
||||
|
||||
export type UserActor = z.infer<typeof UserActorSchema>;
|
||||
export type GuestActor = z.infer<typeof GuestActorSchema>;
|
||||
export type AttendeeActor = z.infer<typeof AttendeeActorSchema>;
|
||||
export type ActorById = z.infer<typeof ActorByIdSchema>;
|
||||
export type AppActorByCredentialId = z.infer<typeof AppActorByCredentialIdSchema>;
|
||||
export type AppActorBySlug = z.infer<typeof AppActorBySlugSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof BookingAuditContextSchema>;
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<T extends { context: Prisma.JsonValue }>(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<BookingAuditWithActor[]> {
|
||||
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<BookingAuditWithActor[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-1
@@ -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<typeof CreatedAuditActionService.latestFieldsSchema>;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
queueRescheduledAudit(params: {
|
||||
@@ -47,6 +48,7 @@ export interface BookingAuditProducerService {
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
data: z.infer<typeof RescheduledAuditActionService.latestFieldsSchema>;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
queueAcceptedAudit(params: {
|
||||
@@ -56,6 +58,7 @@ export interface BookingAuditProducerService {
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
data: z.infer<typeof AcceptedAuditActionService.latestFieldsSchema>;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
queueCancelledAudit(params: {
|
||||
@@ -65,6 +68,7 @@ export interface BookingAuditProducerService {
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
data: z.infer<typeof CancelledAuditActionService.latestFieldsSchema>;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
queueRescheduleRequestedAudit(params: {
|
||||
@@ -74,6 +78,7 @@ export interface BookingAuditProducerService {
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
data: z.infer<typeof RescheduleRequestedAuditActionService.latestFieldsSchema>;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
queueAttendeeAddedAudit(params: {
|
||||
@@ -83,6 +88,7 @@ export interface BookingAuditProducerService {
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
data: z.infer<typeof AttendeeAddedAuditActionService.latestFieldsSchema>;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
queueHostNoShowUpdatedAudit(params: {
|
||||
@@ -92,6 +98,7 @@ export interface BookingAuditProducerService {
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
data: z.infer<typeof HostNoShowUpdatedAuditActionService.latestFieldsSchema>;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
queueRejectedAudit(params: {
|
||||
@@ -101,6 +108,7 @@ export interface BookingAuditProducerService {
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
data: z.infer<typeof RejectedAuditActionService.latestFieldsSchema>;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
queueAttendeeRemovedAudit(params: {
|
||||
@@ -110,6 +118,7 @@ export interface BookingAuditProducerService {
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
data: z.infer<typeof AttendeeRemovedAuditActionService.latestFieldsSchema>;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
queueReassignmentAudit(params: {
|
||||
@@ -119,6 +128,7 @@ export interface BookingAuditProducerService {
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
data: z.infer<typeof ReassignmentAuditActionService.latestFieldsSchema>;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
queueLocationChangedAudit(params: {
|
||||
@@ -128,6 +138,7 @@ export interface BookingAuditProducerService {
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
data: z.infer<typeof LocationChangedAuditActionService.latestFieldsSchema>;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
queueAttendeeNoShowUpdatedAudit(params: {
|
||||
@@ -137,6 +148,7 @@ export interface BookingAuditProducerService {
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
data: z.infer<typeof AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema>;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
queueSeatBookedAudit(params: {
|
||||
@@ -146,6 +158,7 @@ export interface BookingAuditProducerService {
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
data: z.infer<typeof SeatBookedAuditActionService.latestFieldsSchema>;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
queueSeatRescheduledAudit(params: {
|
||||
@@ -155,6 +168,7 @@ export interface BookingAuditProducerService {
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
data: z.infer<typeof SeatRescheduledAuditActionService.latestFieldsSchema>;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
/**
|
||||
@@ -169,6 +183,7 @@ export interface BookingAuditProducerService {
|
||||
organizationId: number | null;
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
/**
|
||||
@@ -183,6 +198,7 @@ export interface BookingAuditProducerService {
|
||||
organizationId: number | null;
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, unknown>;
|
||||
timestamp: number;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<BookingAudit> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
// 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<void> {
|
||||
// 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));
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
+156
-4
@@ -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<CredentialRepository["findByCredentialId"]>;
|
||||
};
|
||||
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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof SingleBookingAuditTaskConsumerSchema>;
|
||||
@@ -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<typeof BulkBookingAuditTaskConsumerSchema>;
|
||||
|
||||
@@ -39,6 +39,7 @@ export type CreateBookingMeta = {
|
||||
forcedSlug?: string;
|
||||
noEmail?: boolean;
|
||||
traceContext?: TraceContext;
|
||||
impersonatedByUserUuid?: string;
|
||||
} & PlatformParams;
|
||||
|
||||
export type BookingHandlerInput = {
|
||||
|
||||
@@ -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<TAuditData> {
|
||||
@@ -49,6 +51,7 @@ interface BaseBookingEventParams<TAuditData> {
|
||||
auditData: TAuditData;
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
context?: BookingAuditContext;
|
||||
}
|
||||
|
||||
type OnBookingAcceptedParams = BaseBookingEventParams<AcceptedAuditData>;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<typeof ActorSchema>;
|
||||
export type PiiFreeActor = z.infer<typeof PiiFreeActorSchema>;
|
||||
|
||||
type UserActor = z.infer<typeof UserActorSchema>;
|
||||
type GuestActor = z.infer<typeof GuestActorSchema>;
|
||||
type AttendeeActor = z.infer<typeof AttendeeActorSchema>;
|
||||
type ActorById = z.infer<typeof ActorByIdSchema>;
|
||||
type AppActorByCredentialId = z.infer<typeof AppActorByCredentialIdSchema>;
|
||||
type AppActorBySlug = z.infer<typeof AppActorBySlugSchema>;
|
||||
/**
|
||||
* 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";
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."BookingAudit" ADD COLUMN "context" JSONB;
|
||||
@@ -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])
|
||||
|
||||
Vendored
+2
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user