feat(core-audit): createAuditErasureHook Payload afterDelete factory

Adds createAuditErasureHook in core-audit/src/hooks/. The factory returns
a CollectionAfterDeleteHook that calls auditLog.eraseSubject() when a
document is deleted. Defaults to "pseudonymize" mode; coerces numeric ids
to string; skips undefined/null/object ids. Barrel at hooks/index.ts.
6 unit tests cover all guard branches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 16:23:08 +02:00
parent 18fddcc45f
commit 270897c550
3 changed files with 107 additions and 0 deletions

View File

@@ -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();
});
});

View File

@@ -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);
}
};
}

View File

@@ -0,0 +1,4 @@
export {
createAuditErasureHook,
type AuditErasureHookOpts,
} from "./audit-erasure-hook";