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:
@@ -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<string, (() => 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");
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export {
|
||||
createAuditErasureHook,
|
||||
createReqScopedAuditErasureHook,
|
||||
type AuditErasureHookOpts,
|
||||
type ReqScopedAuditErasureHookOpts,
|
||||
} from "./audit-erasure-hook";
|
||||
export {
|
||||
createAuditAfterReadHook,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) ?? [];
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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