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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user