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:
@@ -172,7 +172,12 @@ export async function bindAllProduction(): Promise<void> {
|
|||||||
config: resolvedConfig,
|
config: resolvedConfig,
|
||||||
auditLog,
|
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 };
|
complianceBindings = { consentFactory, dsrBinding, auditLog };
|
||||||
|
|
||||||
const ctx: BindProductionContext = {
|
const ctx: BindProductionContext = {
|
||||||
|
|||||||
@@ -2,17 +2,42 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { auditLogsCollection } from "./audit-logs-collection";
|
import { auditLogsCollection } from "./audit-logs-collection";
|
||||||
|
|
||||||
describe("auditLogsCollection", () => {
|
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'", () => {
|
it("uses slug 'audit-logs'", () => {
|
||||||
expect(auditLogsCollection.slug).toBe("audit-logs");
|
expect(auditLogsCollection.slug).toBe("audit-logs");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is append-only (update: () => false)", () => {
|
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);
|
expect(access["update"]?.()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has the required fields", () => {
|
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
|
// WHO
|
||||||
expect(fieldNames).toContain("actorId");
|
expect(fieldNames).toContain("actorId");
|
||||||
expect(fieldNames).toContain("actorType");
|
expect(fieldNames).toContain("actorType");
|
||||||
|
|||||||
@@ -44,7 +44,21 @@ export const auditLogsCollection: CollectionConfig = {
|
|||||||
{
|
{
|
||||||
name: "action",
|
name: "action",
|
||||||
type: "select",
|
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,
|
required: true,
|
||||||
index: true,
|
index: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
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";
|
import type { IAuditLog } from "../audit-log.interface";
|
||||||
|
|
||||||
function makeAuditLog(): IAuditLog {
|
function makeAuditLog(): IAuditLog {
|
||||||
@@ -25,7 +28,10 @@ describe("createAuditErasureHook", () => {
|
|||||||
const auditLog = makeAuditLog();
|
const auditLog = makeAuditLog();
|
||||||
const hook = createAuditErasureHook({ auditLog });
|
const hook = createAuditErasureHook({ auditLog });
|
||||||
await hook(hookArgs("user_1") as never);
|
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 () => {
|
it("respects explicit mode='delete'", async () => {
|
||||||
@@ -63,3 +69,78 @@ describe("createAuditErasureHook", () => {
|
|||||||
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
|
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 { CollectionAfterDeleteHook } from "payload";
|
||||||
import type { IAuditLog } from "../audit-log.interface";
|
import type { IAuditLog } from "../audit-log.interface";
|
||||||
|
import { PayloadAuditLog } from "../payload-audit-log";
|
||||||
|
|
||||||
export type AuditErasureHookOpts = {
|
export type AuditErasureHookOpts = {
|
||||||
/** The audit log impl that will perform the erasure. */
|
/** 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 {
|
export {
|
||||||
createAuditErasureHook,
|
createAuditErasureHook,
|
||||||
|
createReqScopedAuditErasureHook,
|
||||||
type AuditErasureHookOpts,
|
type AuditErasureHookOpts,
|
||||||
|
type ReqScopedAuditErasureHookOpts,
|
||||||
} from "./audit-erasure-hook";
|
} from "./audit-erasure-hook";
|
||||||
export {
|
export {
|
||||||
createAuditAfterReadHook,
|
createAuditAfterReadHook,
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ export { AUDIT_SYMBOLS } from "./di/symbols";
|
|||||||
export { pseudonymize } from "./pseudonymize";
|
export { pseudonymize } from "./pseudonymize";
|
||||||
export {
|
export {
|
||||||
createAuditErasureHook,
|
createAuditErasureHook,
|
||||||
|
createReqScopedAuditErasureHook,
|
||||||
type AuditErasureHookOpts,
|
type AuditErasureHookOpts,
|
||||||
|
type ReqScopedAuditErasureHookOpts,
|
||||||
} from "./hooks/audit-erasure-hook";
|
} from "./hooks/audit-erasure-hook";
|
||||||
// VIEW capture
|
// VIEW capture
|
||||||
export { createAuditAfterReadHook, type AuditAfterReadHookOpts } from "./hooks";
|
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 () => {
|
it("registers all feature globals", async () => {
|
||||||
const resolved = await config;
|
const resolved = await config;
|
||||||
const slugs = resolved.globals?.map((g) => g.slug) ?? [];
|
const slugs = resolved.globals?.map((g) => g.slug) ?? [];
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import {
|
|||||||
withRetentionTombstone,
|
withRetentionTombstone,
|
||||||
buildRetentionPurgeTask,
|
buildRetentionPurgeTask,
|
||||||
} from "@repo/core-shared/payload";
|
} 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 { articles } from "@repo/blog/cms";
|
||||||
import { media } from "@repo/media/cms";
|
import { media } from "@repo/media/cms";
|
||||||
import { pages, siteSettings } from "@repo/marketing-pages/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 filename = fileURLToPath(import.meta.url);
|
||||||
const dirname = path.dirname(filename);
|
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
|
// Collections declaring custom.retention.postDeletion get the soft-delete
|
||||||
// tombstone field (`deletedAt`) so the DSR soft delete can stamp rows and the
|
// tombstone field (`deletedAt`) so the DSR soft delete can stamp rows and the
|
||||||
// retention purge job can grace-purge them (audit finding A2).
|
// 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({
|
export default buildConfig({
|
||||||
editor: lexicalEditor(),
|
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 { IDataRectify } from "../data-rectify.interface";
|
||||||
import type { IProcessingRestriction } from "../processing-restriction.interface";
|
import type { IProcessingRestriction } from "../processing-restriction.interface";
|
||||||
import { PayloadDataExport } from "../payload-data-export";
|
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 { PayloadDataRectify } from "../payload-data-rectify";
|
||||||
import { PayloadProcessingRestriction } from "../payload-processing-restriction";
|
import { PayloadProcessingRestriction } from "../payload-processing-restriction";
|
||||||
|
|
||||||
@@ -19,6 +19,12 @@ export type DsrBinding = {
|
|||||||
export type BindProductionDsrOpts = {
|
export type BindProductionDsrOpts = {
|
||||||
config: SanitizedConfig;
|
config: SanitizedConfig;
|
||||||
auditLog?: AuditLogProtocol;
|
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 () => {} };
|
const noopAuditLog: AuditLogProtocol = { record: async () => {} };
|
||||||
@@ -34,7 +40,12 @@ export function bindProductionDsr(opts: BindProductionDsrOpts): DsrBinding {
|
|||||||
const auditLog = opts.auditLog ?? noopAuditLog;
|
const auditLog = opts.auditLog ?? noopAuditLog;
|
||||||
return {
|
return {
|
||||||
dataExport: new PayloadDataExport(opts.config, auditLog),
|
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),
|
dataRectify: new PayloadDataRectify(opts.config, auditLog),
|
||||||
processingRestriction: new PayloadProcessingRestriction(
|
processingRestriction: new PayloadProcessingRestriction(
|
||||||
opts.config,
|
opts.config,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type {
|
|||||||
|
|
||||||
export { PayloadDataExport } from "./payload-data-export";
|
export { PayloadDataExport } from "./payload-data-export";
|
||||||
export { PayloadDataDelete } from "./payload-data-delete";
|
export { PayloadDataDelete } from "./payload-data-delete";
|
||||||
|
export type { AuditErasure } from "./payload-data-delete";
|
||||||
export { PayloadDataRectify } from "./payload-data-rectify";
|
export { PayloadDataRectify } from "./payload-data-rectify";
|
||||||
export { PayloadProcessingRestriction } from "./payload-processing-restriction";
|
export { PayloadProcessingRestriction } from "./payload-processing-restriction";
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ type PayloadAPI = {
|
|||||||
|
|
||||||
type GetPayload = (args: { config: SanitizedConfig }) => Promise<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> {
|
function buildWhere(field: string, subjectId: string): Record<string, unknown> {
|
||||||
return field === "id"
|
return field === "id"
|
||||||
? { id: { equals: subjectId } }
|
? { id: { equals: subjectId } }
|
||||||
@@ -112,6 +121,7 @@ export class PayloadDataDelete implements IDataDelete {
|
|||||||
private readonly config: SanitizedConfig,
|
private readonly config: SanitizedConfig,
|
||||||
private readonly auditLog: AuditLogProtocol,
|
private readonly auditLog: AuditLogProtocol,
|
||||||
private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload,
|
private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload,
|
||||||
|
private readonly auditErasure?: AuditErasure,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async deleteSubjectData(
|
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(
|
return this.buildCertificate(
|
||||||
subjectId,
|
subjectId,
|
||||||
mode,
|
mode,
|
||||||
|
|||||||
Reference in New Issue
Block a user