diff --git a/apps/web-next/src/server/bind-production.ts b/apps/web-next/src/server/bind-production.ts index 5928c32..591d84a 100644 --- a/apps/web-next/src/server/bind-production.ts +++ b/apps/web-next/src/server/bind-production.ts @@ -172,7 +172,12 @@ export async function bindAllProduction(): Promise { config: resolvedConfig, auditLog, }); - const dsrBinding = bindProductionDsr({ config: resolvedConfig, auditLog }); + const dsrBinding = bindProductionDsr({ + config: resolvedConfig, + auditLog, + // cascade-hard deletions pseudonymize the subject's audit trail (A6) + auditErasure: auditLog, + }); complianceBindings = { consentFactory, dsrBinding, auditLog }; const ctx: BindProductionContext = { diff --git a/packages/core-audit/src/audit-logs-collection.test.ts b/packages/core-audit/src/audit-logs-collection.test.ts index cd105e1..68eb6fa 100644 --- a/packages/core-audit/src/audit-logs-collection.test.ts +++ b/packages/core-audit/src/audit-logs-collection.test.ts @@ -2,17 +2,42 @@ import { describe, it, expect } from "vitest"; import { auditLogsCollection } from "./audit-logs-collection"; describe("auditLogsCollection", () => { + it("accepts every AuditAction enum value (A6)", () => { + const action = ( + auditLogsCollection.fields as Array<{ name: string; options?: string[] }> + ).find((f) => f.name === "action"); + expect(action?.options).toEqual( + expect.arrayContaining([ + "VIEW", + "CREATE", + "UPDATE", + "DELETE", + "EXPORT", + "PERMISSION_CHANGE", + "CONSENT_GRANT", + "CONSENT_WITHDRAW", + "RESTRICT", + "UNRESTRICT", + ]), + ); + }); + it("uses slug 'audit-logs'", () => { expect(auditLogsCollection.slug).toBe("audit-logs"); }); it("is append-only (update: () => false)", () => { - const access = auditLogsCollection.access as Record boolean) | undefined>; + const access = auditLogsCollection.access as Record< + string, + (() => boolean) | undefined + >; expect(access["update"]?.()).toBe(false); }); it("has the required fields", () => { - const fieldNames = (auditLogsCollection.fields as Array<{ name: string }>).map((f) => f.name); + const fieldNames = ( + auditLogsCollection.fields as Array<{ name: string }> + ).map((f) => f.name); // WHO expect(fieldNames).toContain("actorId"); expect(fieldNames).toContain("actorType"); diff --git a/packages/core-audit/src/audit-logs-collection.ts b/packages/core-audit/src/audit-logs-collection.ts index 7e6123b..143aeb3 100644 --- a/packages/core-audit/src/audit-logs-collection.ts +++ b/packages/core-audit/src/audit-logs-collection.ts @@ -44,7 +44,21 @@ export const auditLogsCollection: CollectionConfig = { { name: "action", type: "select", - options: ["VIEW", "CREATE", "UPDATE", "DELETE", "EXPORT", "PERMISSION_CHANGE"], + // Mirrors the AuditAction enum in @repo/core-shared/audit — the DSR and + // consent cores record RESTRICT/UNRESTRICT/CONSENT_* entries, so the + // select must accept every enum value or record() fails validation (A6). + options: [ + "VIEW", + "CREATE", + "UPDATE", + "DELETE", + "EXPORT", + "PERMISSION_CHANGE", + "CONSENT_GRANT", + "CONSENT_WITHDRAW", + "RESTRICT", + "UNRESTRICT", + ], required: true, index: true, }, diff --git a/packages/core-audit/src/hooks/audit-erasure-hook.test.ts b/packages/core-audit/src/hooks/audit-erasure-hook.test.ts index 0b7fb4c..b29735e 100644 --- a/packages/core-audit/src/hooks/audit-erasure-hook.test.ts +++ b/packages/core-audit/src/hooks/audit-erasure-hook.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, vi } from "vitest"; -import { createAuditErasureHook } from "./audit-erasure-hook"; +import { + createAuditErasureHook, + createReqScopedAuditErasureHook, +} from "./audit-erasure-hook"; import type { IAuditLog } from "../audit-log.interface"; function makeAuditLog(): IAuditLog { @@ -25,7 +28,10 @@ describe("createAuditErasureHook", () => { const auditLog = makeAuditLog(); const hook = createAuditErasureHook({ auditLog }); await hook(hookArgs("user_1") as never); - expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_1", "pseudonymize"); + expect(auditLog.eraseSubject).toHaveBeenCalledWith( + "user_1", + "pseudonymize", + ); }); it("respects explicit mode='delete'", async () => { @@ -63,3 +69,78 @@ describe("createAuditErasureHook", () => { expect(auditLog.eraseSubject).not.toHaveBeenCalled(); }); }); + +describe("createReqScopedAuditErasureHook (A6)", () => { + function makeReqPayload(withAuditCollection: boolean) { + const find = vi.fn().mockResolvedValue({ docs: [{ id: "log-1" }] }); + const update = vi.fn().mockResolvedValue({}); + const del = vi.fn().mockResolvedValue({}); + const payload = { + config: { + collections: withAuditCollection ? [{ slug: "audit-logs" }] : [], + }, + find, + update, + delete: del, + }; + return { payload, find, update, del }; + } + + function reqHookArgs(id: unknown, payload: unknown) { + return { + doc: { id }, + req: { payload } as never, + id: String(id), + collection: {} as never, + context: {}, + }; + } + + it("pseudonymizes the deleted subject's audit entries via req.payload", async () => { + const { payload, find, update } = makeReqPayload(true); + const hook = createReqScopedAuditErasureHook(); + await hook(reqHookArgs("user_1", payload) as never); + + expect(find).toHaveBeenCalledWith( + expect.objectContaining({ + collection: "audit-logs", + where: { actorId: { equals: "user_1" } }, + }), + ); + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + collection: "audit-logs", + id: "log-1", + data: { actorId: expect.stringMatching(/^erased-/) }, + }), + ); + }); + + it("respects mode='delete'", async () => { + const { payload, del } = makeReqPayload(true); + const hook = createReqScopedAuditErasureHook({ mode: "delete" }); + await hook(reqHookArgs("user_2", payload) as never); + expect(del).toHaveBeenCalledWith( + expect.objectContaining({ + collection: "audit-logs", + where: { actorId: { equals: "user_2" } }, + }), + ); + }); + + it("no-ops when the audit-logs collection is not registered", async () => { + const { payload, find, update, del } = makeReqPayload(false); + const hook = createReqScopedAuditErasureHook(); + await hook(reqHookArgs("user_1", payload) as never); + expect(find).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expect(del).not.toHaveBeenCalled(); + }); + + it("skips invalid doc ids", async () => { + const { payload, find } = makeReqPayload(true); + const hook = createReqScopedAuditErasureHook(); + await hook(reqHookArgs(undefined, payload) as never); + expect(find).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core-audit/src/hooks/audit-erasure-hook.ts b/packages/core-audit/src/hooks/audit-erasure-hook.ts index c6001ed..8bb6025 100644 --- a/packages/core-audit/src/hooks/audit-erasure-hook.ts +++ b/packages/core-audit/src/hooks/audit-erasure-hook.ts @@ -1,5 +1,6 @@ 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. */ @@ -36,3 +37,35 @@ export function createAuditErasureHook( } }; } + +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; + const hasAuditCollection = payload.config.collections?.some( + (c) => c.slug === "audit-logs", + ); + if (!hasAuditCollection) return; + const auditLog = new PayloadAuditLog( + payload.config, + async () => payload as never, + ); + await auditLog.eraseSubject(String(doc.id), mode); + }; +} diff --git a/packages/core-audit/src/hooks/index.ts b/packages/core-audit/src/hooks/index.ts index 11dcd14..168eda4 100644 --- a/packages/core-audit/src/hooks/index.ts +++ b/packages/core-audit/src/hooks/index.ts @@ -1,6 +1,8 @@ export { createAuditErasureHook, + createReqScopedAuditErasureHook, type AuditErasureHookOpts, + type ReqScopedAuditErasureHookOpts, } from "./audit-erasure-hook"; export { createAuditAfterReadHook, diff --git a/packages/core-audit/src/index.ts b/packages/core-audit/src/index.ts index a6d1edb..b04912b 100644 --- a/packages/core-audit/src/index.ts +++ b/packages/core-audit/src/index.ts @@ -16,7 +16,9 @@ export { AUDIT_SYMBOLS } from "./di/symbols"; export { pseudonymize } from "./pseudonymize"; export { createAuditErasureHook, + createReqScopedAuditErasureHook, type AuditErasureHookOpts, + type ReqScopedAuditErasureHookOpts, } from "./hooks/audit-erasure-hook"; // VIEW capture export { createAuditAfterReadHook, type AuditAfterReadHookOpts } from "./hooks"; diff --git a/packages/core-cms/src/payload.config.test.ts b/packages/core-cms/src/payload.config.test.ts index 9d3c6b3..2322f08 100644 --- a/packages/core-cms/src/payload.config.test.ts +++ b/packages/core-cms/src/payload.config.test.ts @@ -35,6 +35,18 @@ describe("payloadConfig composition", () => { ); }); + it("registers the audit-logs collection (A6)", async () => { + const resolved = await config; + const slugs = resolved.collections?.map((c) => c.slug) ?? []; + expect(slugs).toContain("audit-logs"); + }); + + it("wires the audit erasure afterDelete hook on users (A6)", async () => { + const resolved = await config; + const users = resolved.collections?.find((c) => c.slug === "users"); + expect(users?.hooks?.afterDelete?.length ?? 0).toBeGreaterThan(0); + }); + it("registers all feature globals", async () => { const resolved = await config; const slugs = resolved.globals?.map((g) => g.slug) ?? []; diff --git a/packages/core-cms/src/payload.config.ts b/packages/core-cms/src/payload.config.ts index 86c68f5..c4260fd 100644 --- a/packages/core-cms/src/payload.config.ts +++ b/packages/core-cms/src/payload.config.ts @@ -8,7 +8,11 @@ import { withRetentionTombstone, buildRetentionPurgeTask, } from "@repo/core-shared/payload"; -import { users } from "@repo/auth/cms"; +import { + auditLogsCollection, + createReqScopedAuditErasureHook, +} from "@repo/core-audit"; +import { users as usersBase } from "@repo/auth/cms"; import { articles } from "@repo/blog/cms"; import { media } from "@repo/media/cms"; import { pages, siteSettings } from "@repo/marketing-pages/cms"; @@ -17,10 +21,28 @@ import { header } from "@repo/navigation/cms"; const filename = fileURLToPath(import.meta.url); const dirname = path.dirname(filename); +// GDPR audit erasure (audit finding A6): when a users row is hard-deleted +// (admin expunge, DSR cascade-hard, retention purge), pseudonymize that +// subject's audit-log entries so the trail keeps its shape without PII linkage. +const users = { + ...usersBase, + hooks: { + ...usersBase.hooks, + afterDelete: [ + ...(usersBase.hooks?.afterDelete ?? []), + createReqScopedAuditErasureHook(), + ], + }, +}; + // Collections declaring custom.retention.postDeletion get the soft-delete // tombstone field (`deletedAt`) so the DSR soft delete can stamp rows and the // retention purge job can grace-purge them (audit finding A2). -const collections = [users, articles, pages, media].map(withRetentionTombstone); +const collections = [ + ...[users, articles, pages, media].map(withRetentionTombstone), + // Local audit sink (A6) — required for PayloadAuditLog.record() to work. + auditLogsCollection, +]; export default buildConfig({ editor: lexicalEditor(), diff --git a/packages/core-dsr/src/__tests__/payload-data-delete.test.ts b/packages/core-dsr/src/__tests__/payload-data-delete.test.ts index 908d36f..d9986e0 100644 --- a/packages/core-dsr/src/__tests__/payload-data-delete.test.ts +++ b/packages/core-dsr/src/__tests__/payload-data-delete.test.ts @@ -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(); + }); +}); diff --git a/packages/core-dsr/src/di/bind-production.ts b/packages/core-dsr/src/di/bind-production.ts index dced427..d8d3394 100644 --- a/packages/core-dsr/src/di/bind-production.ts +++ b/packages/core-dsr/src/di/bind-production.ts @@ -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, diff --git a/packages/core-dsr/src/index.ts b/packages/core-dsr/src/index.ts index a61c2b5..44bc795 100644 --- a/packages/core-dsr/src/index.ts +++ b/packages/core-dsr/src/index.ts @@ -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"; diff --git a/packages/core-dsr/src/payload-data-delete.ts b/packages/core-dsr/src/payload-data-delete.ts index 096aa42..eed93b8 100644 --- a/packages/core-dsr/src/payload-data-delete.ts +++ b/packages/core-dsr/src/payload-data-delete.ts @@ -39,6 +39,15 @@ type PayloadAPI = { type GetPayload = (args: { config: SanitizedConfig }) => Promise; +/** + * 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; +}; + function buildWhere(field: string, subjectId: string): Record { 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,