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:
65
packages/core-audit/src/hooks/audit-erasure-hook.test.ts
Normal file
65
packages/core-audit/src/hooks/audit-erasure-hook.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
38
packages/core-audit/src/hooks/audit-erasure-hook.ts
Normal file
38
packages/core-audit/src/hooks/audit-erasure-hook.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
4
packages/core-audit/src/hooks/index.ts
Normal file
4
packages/core-audit/src/hooks/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createAuditErasureHook,
|
||||
type AuditErasureHookOpts,
|
||||
} from "./audit-erasure-hook";
|
||||
Reference in New Issue
Block a user