Files
agentic-dev/packages/core-audit/src/hooks/audit-erasure-hook.ts
Danijel Martinek 318a69e780 fix(compliance): port DSR/consent/audit/retention audit fixes
Ports the upstream compliance-core audit fixes onto the kept core-dsr,
core-consent, core-audit, core-cms and core-shared packages (pristine
template state here, so taken to the fixed end-state):

- core-dsr: scope DSR operations to the caller's own subject (A11);
  include the subject's audit trail in exports; resolve the per-request
  binding from ctx instead of a throwing singleton proxy.
- core-consent: build the consent router from the shared superjson
  transformer (A10); merge per-category on persist instead of replacing;
  validate migrated categories against an allow-list.
- core-audit: keyed 128-bit pseudonyms + salted DSR certificate; add the
  audit-logs collection and the req-scoped GDPR audit-erasure afterDelete
  hook (A6).
- core-shared: grace-purge soft-deleted rows via a retention-purge task +
  tombstone field and boot registration (A2/A3); add the
  require-authenticated tRPC helper; derive clientIp + resolve the session
  user in createTrpcContext (B2/A11).
- core-cms: register audit-logs, wire the audit-erasure hook and
  retention-purge tasks; adapted to our collection set (users, workspaces).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
2026-07-12 23:54:14 +02:00

74 lines
2.8 KiB
TypeScript

import type { CollectionAfterDeleteHook } from "payload";
import type { IAuditLog } from "../audit-log.interface";
import { PayloadAuditLog } from "../payload-audit-log";
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);
}
};
}
export type ReqScopedAuditErasureHookOpts = {
/** Erasure mode — see AuditErasureHookOpts. Defaults to "pseudonymize". */
mode?: "pseudonymize" | "delete";
};
/**
* Variant of `createAuditErasureHook` for config-composition time (audit
* finding A6): a Payload collection config is built before any `IAuditLog`
* can exist (binding the audit log needs the built config), so this hook
* constructs a `PayloadAuditLog` lazily from the running instance on
* `req.payload` when the delete fires. No-ops when the `audit-logs`
* collection is not registered.
*/
export function createReqScopedAuditErasureHook(
opts: ReqScopedAuditErasureHookOpts = {},
): CollectionAfterDeleteHook {
const mode = opts.mode ?? "pseudonymize";
return async ({ doc, req }) => {
if (typeof doc.id !== "string" && typeof doc.id !== "number") return;
const payload = req.payload;
// `slug as string`: apps with generated CollectionSlug types narrow slug
// to their registered union, which need not include "audit-logs".
const hasAuditCollection = payload.config.collections?.some(
(c) => (c.slug as string) === "audit-logs",
);
if (!hasAuditCollection) return;
const auditLog = new PayloadAuditLog(
payload.config,
async () => payload as never,
);
await auditLog.eraseSubject(String(doc.id), mode);
};
}