feat(core-cms): register audit-logs + wire GDPR audit erasure

The audit-logs collection was never registered (record() would throw),
bindAudit/createAuditErasureHook were unused, and DSR cascade-hard never
touched the audit trail (audit finding A6). core-cms now registers the
collection and wires a req-scoped afterDelete erasure hook on users;
bindAllProduction binds the audit log into consent/DSR; cascade-hard
pseudonymizes the subject's audit entries; the action select accepts
the full AuditAction enum so consent/DSR entries pass validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 18:08:28 +02:00
parent 7b0c2ea590
commit a2be5d5488
13 changed files with 300 additions and 10 deletions

View File

@@ -513,3 +513,67 @@ describe("PayloadDataDelete", () => {
});
});
});
describe("cascade-hard audit erasure (A6)", () => {
it("calls auditErasure.eraseSubject with pseudonymize after cascade-hard", async () => {
const auditLog = new RecordingAuditLog();
const mockPayload = {
find: vi.fn().mockResolvedValue({ docs: [{ id: "alice" }] }),
update: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
};
const eraseSubject = vi.fn().mockResolvedValue(undefined);
const config = {
collections: [
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
},
},
],
} as unknown as SanitizedConfig;
const deleter = new PayloadDataDelete(
config,
auditLog,
vi.fn().mockResolvedValue(mockPayload),
{ eraseSubject },
);
await deleter.deleteSubjectData("alice", "cascade-hard");
expect(eraseSubject).toHaveBeenCalledWith("alice", "pseudonymize");
});
it("does not erase audit entries on soft delete", async () => {
const auditLog = new RecordingAuditLog();
const mockPayload = {
find: vi.fn().mockResolvedValue({ docs: [{ id: "alice" }] }),
update: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
};
const eraseSubject = vi.fn().mockResolvedValue(undefined);
const config = {
collections: [
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
},
},
],
} as unknown as SanitizedConfig;
const deleter = new PayloadDataDelete(
config,
auditLog,
vi.fn().mockResolvedValue(mockPayload),
{ eraseSubject },
);
await deleter.deleteSubjectData("alice", "soft");
expect(eraseSubject).not.toHaveBeenCalled();
});
});

View File

@@ -5,7 +5,7 @@ import type { IDataDelete } from "../data-delete.interface";
import type { IDataRectify } from "../data-rectify.interface";
import type { IProcessingRestriction } from "../processing-restriction.interface";
import { PayloadDataExport } from "../payload-data-export";
import { PayloadDataDelete } from "../payload-data-delete";
import { PayloadDataDelete, type AuditErasure } from "../payload-data-delete";
import { PayloadDataRectify } from "../payload-data-rectify";
import { PayloadProcessingRestriction } from "../payload-processing-restriction";
@@ -19,6 +19,12 @@ export type DsrBinding = {
export type BindProductionDsrOpts = {
config: SanitizedConfig;
auditLog?: AuditLogProtocol;
/**
* Privileged audit-erasure surface (core-audit's IAuditLog satisfies it).
* When present, cascade-hard deletions pseudonymize the subject's
* audit-log entries (A6).
*/
auditErasure?: AuditErasure;
};
const noopAuditLog: AuditLogProtocol = { record: async () => {} };
@@ -34,7 +40,12 @@ export function bindProductionDsr(opts: BindProductionDsrOpts): DsrBinding {
const auditLog = opts.auditLog ?? noopAuditLog;
return {
dataExport: new PayloadDataExport(opts.config, auditLog),
dataDelete: new PayloadDataDelete(opts.config, auditLog),
dataDelete: new PayloadDataDelete(
opts.config,
auditLog,
undefined,
opts.auditErasure,
),
dataRectify: new PayloadDataRectify(opts.config, auditLog),
processingRestriction: new PayloadProcessingRestriction(
opts.config,

View File

@@ -24,6 +24,7 @@ export type {
export { PayloadDataExport } from "./payload-data-export";
export { PayloadDataDelete } from "./payload-data-delete";
export type { AuditErasure } from "./payload-data-delete";
export { PayloadDataRectify } from "./payload-data-rectify";
export { PayloadProcessingRestriction } from "./payload-processing-restriction";

View File

@@ -39,6 +39,15 @@ type PayloadAPI = {
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
/**
* Privileged audit-erasure surface (structural subset of core-audit's
* IAuditLog — core-dsr must not depend on the optional audit package).
* Wired by the app binder; used on the cascade-hard path (A6).
*/
export type AuditErasure = {
eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void>;
};
function buildWhere(field: string, subjectId: string): Record<string, unknown> {
return field === "id"
? { id: { equals: subjectId } }
@@ -112,6 +121,7 @@ export class PayloadDataDelete implements IDataDelete {
private readonly config: SanitizedConfig,
private readonly auditLog: AuditLogProtocol,
private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload,
private readonly auditErasure?: AuditErasure,
) {}
async deleteSubjectData(
@@ -162,6 +172,14 @@ export class PayloadDataDelete implements IDataDelete {
}
}
if (mode === "cascade-hard" && this.auditErasure) {
// Erase the subject's audit-log linkage (A6): pseudonymize rather than
// delete so the audit trail keeps its shape for compliance sampling.
// The users afterDelete hook covers Payload-initiated deletes; this
// covers the DSR cascade explicitly and is idempotent with the hook.
await this.auditErasure.eraseSubject(subjectId, "pseudonymize");
}
return this.buildCertificate(
subjectId,
mode,