diff --git a/packages/core-audit/src/hooks/audit-erasure-hook.test.ts b/packages/core-audit/src/hooks/audit-erasure-hook.test.ts new file mode 100644 index 0000000..0b7fb4c --- /dev/null +++ b/packages/core-audit/src/hooks/audit-erasure-hook.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi } from "vitest"; +import { createAuditErasureHook } from "./audit-erasure-hook"; +import type { IAuditLog } from "../audit-log.interface"; + +function makeAuditLog(): IAuditLog { + return { + record: vi.fn().mockResolvedValue(undefined), + eraseSubject: vi.fn().mockResolvedValue(undefined), + }; +} + +/** Minimal CollectionAfterDeleteHook args shape (only `doc` matters here). */ +function hookArgs(id: unknown) { + return { + doc: { id }, + req: {} as never, + id: String(id), + collection: {} as never, + context: {}, + }; +} + +describe("createAuditErasureHook", () => { + it("defaults to 'pseudonymize' mode", async () => { + const auditLog = makeAuditLog(); + const hook = createAuditErasureHook({ auditLog }); + await hook(hookArgs("user_1") as never); + expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_1", "pseudonymize"); + }); + + it("respects explicit mode='delete'", async () => { + const auditLog = makeAuditLog(); + const hook = createAuditErasureHook({ auditLog, mode: "delete" }); + await hook(hookArgs("user_2") as never); + expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_2", "delete"); + }); + + it("coerces numeric id to string", async () => { + const auditLog = makeAuditLog(); + const hook = createAuditErasureHook({ auditLog }); + await hook(hookArgs(42) as never); + expect(auditLog.eraseSubject).toHaveBeenCalledWith("42", "pseudonymize"); + }); + + it("skips when doc.id is undefined", async () => { + const auditLog = makeAuditLog(); + const hook = createAuditErasureHook({ auditLog }); + await hook(hookArgs(undefined) as never); + expect(auditLog.eraseSubject).not.toHaveBeenCalled(); + }); + + it("skips when doc.id is null", async () => { + const auditLog = makeAuditLog(); + const hook = createAuditErasureHook({ auditLog }); + await hook(hookArgs(null) as never); + expect(auditLog.eraseSubject).not.toHaveBeenCalled(); + }); + + it("skips when doc.id is an object", async () => { + const auditLog = makeAuditLog(); + const hook = createAuditErasureHook({ auditLog }); + await hook(hookArgs({ nested: true }) as never); + expect(auditLog.eraseSubject).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core-audit/src/hooks/audit-erasure-hook.ts b/packages/core-audit/src/hooks/audit-erasure-hook.ts new file mode 100644 index 0000000..c6001ed --- /dev/null +++ b/packages/core-audit/src/hooks/audit-erasure-hook.ts @@ -0,0 +1,38 @@ +import type { CollectionAfterDeleteHook } from "payload"; +import type { IAuditLog } from "../audit-log.interface"; + +export type AuditErasureHookOpts = { + /** The audit log impl that will perform the erasure. */ + auditLog: IAuditLog; + /** + * Erasure mode. Defaults to "pseudonymize" — the softer option that + * retains the audit trail shape while removing PII linkage. Use + * "delete" only when the data-subject specifically requests hard removal. + */ + mode?: "pseudonymize" | "delete"; +}; + +/** + * Payload `afterDelete` hook factory for GDPR erasure. + * + * Wire this on any collection whose `id` doubles as an audit subject + * (e.g., the users collection). When Payload deletes a document, the + * hook calls `auditLog.eraseSubject(String(doc.id), mode)`, removing + * or pseudonymizing all audit entries recorded for that actor. + * + * The hook has no schema-specific knowledge — it works on any collection + * that stores the subject identifier as its document `id`. + * + * Non-string, non-numeric ids are silently skipped (safe guard against + * undefined/null that Payload may produce in edge cases). + */ +export function createAuditErasureHook( + opts: AuditErasureHookOpts, +): CollectionAfterDeleteHook { + const mode = opts.mode ?? "pseudonymize"; + return async ({ doc }) => { + if (typeof doc.id === "string" || typeof doc.id === "number") { + await opts.auditLog.eraseSubject(String(doc.id), mode); + } + }; +} diff --git a/packages/core-audit/src/hooks/index.ts b/packages/core-audit/src/hooks/index.ts new file mode 100644 index 0000000..40883f5 --- /dev/null +++ b/packages/core-audit/src/hooks/index.ts @@ -0,0 +1,4 @@ +export { + createAuditErasureHook, + type AuditErasureHookOpts, +} from "./audit-erasure-hook";