import type { SanitizedConfig } from "payload"; import type { AuditEntry } from "@repo/core-shared/audit"; import type { IAuditLog } from "./audit-log.interface"; import { pseudonymize } from "./pseudonymize"; type GetPayload = (args: { config: SanitizedConfig }) => Promise<{ create: (args: { collection: string; data: Record }) => Promise; find: (args: { collection: string; where: Record; limit: number; overrideAccess: true; }) => Promise<{ docs: Array<{ id: string | number }> }>; update: (args: { collection: string; id: string | number; data: Record; overrideAccess: true; }) => Promise; delete: (args: { collection: string; where: Record; overrideAccess: true; }) => Promise; }>; /** * Local-cache audit sink: writes entries to the `audit-logs` Payload * collection. The collection is append-only by access-rule * (`update: () => false`); the eraseSubject path uses `overrideAccess: true` * to bypass for the privileged GDPR pseudonymization op. * * The getPayload param is injectable for tests; production callers pass * the real `getPayload` from `payload`. */ export class PayloadAuditLog implements IAuditLog { constructor( private readonly config: SanitizedConfig, private readonly getPayload: GetPayload, ) {} async record(entry: AuditEntry): Promise { const payload = await this.getPayload({ config: this.config }); await payload.create({ collection: "audit-logs", data: { actorId: entry.actorId, actorType: entry.actorType, actorRoles: entry.actorRoles, action: entry.action, resourceType: entry.resource.type, resourceId: entry.resource.id ?? null, changedFields: entry.changedFields ?? null, scopeFeature: entry.scope.feature, scopeEnvironment: entry.scope.environment, scopeTenant: entry.scope.tenant, reason: entry.reason ?? null, correlationId: entry.correlationId ?? null, requestId: entry.requestId ?? null, ipTruncated: entry.from.ipTruncated, userAgent: entry.from.userAgent, containsPii: entry.containsPii, piiCategories: entry.piiCategories ?? null, outcome: entry.outcome, errorCode: entry.errorCode ?? null, }, }); } async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise { const payload = await this.getPayload({ config: this.config }); if (mode === "delete") { await payload.delete({ collection: "audit-logs", where: { actorId: { equals: actorId } }, overrideAccess: true, }); return; } // mode === "pseudonymize" // Fetch all matching docs. Limit is 10_000 — a subject with more than // 10k audit entries will not have all entries pseudonymized in one call. // This is an accepted v1 limitation; callers may loop if needed. const { docs } = await payload.find({ collection: "audit-logs", where: { actorId: { equals: actorId } }, limit: 10_000, overrideAccess: true, }); const pseudonym = pseudonymize(actorId); for (const doc of docs) { await payload.update({ collection: "audit-logs", id: doc.id, data: { actorId: pseudonym }, overrideAccess: true, }); } } }