fix(compliance): port DSR/consent/audit/retention audit fixes

Ports the upstream compliance-core audit fixes onto the kept core-dsr,
core-consent, core-audit, core-cms and core-shared packages (pristine
template state here, so taken to the fixed end-state):

- core-dsr: scope DSR operations to the caller's own subject (A11);
  include the subject's audit trail in exports; resolve the per-request
  binding from ctx instead of a throwing singleton proxy.
- core-consent: build the consent router from the shared superjson
  transformer (A10); merge per-category on persist instead of replacing;
  validate migrated categories against an allow-list.
- core-audit: keyed 128-bit pseudonyms + salted DSR certificate; add the
  audit-logs collection and the req-scoped GDPR audit-erasure afterDelete
  hook (A6).
- core-shared: grace-purge soft-deleted rows via a retention-purge task +
  tombstone field and boot registration (A2/A3); add the
  require-authenticated tRPC helper; derive clientIp + resolve the session
  user in createTrpcContext (B2/A11).
- core-cms: register audit-logs, wire the audit-erasure hook and
  retention-purge tasks; adapted to our collection set (users, workspaces).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
2026-07-12 23:54:14 +02:00
parent ac0bf80eca
commit 318a69e780
42 changed files with 2102 additions and 138 deletions

View File

@@ -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");

View File

@@ -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,
},

View File

@@ -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();
});
});

View File

@@ -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,37 @@ 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;
// `slug as string`: apps with generated CollectionSlug types narrow slug
// to their registered union, which need not include "audit-logs".
const hasAuditCollection = payload.config.collections?.some(
(c) => (c.slug as string) === "audit-logs",
);
if (!hasAuditCollection) return;
const auditLog = new PayloadAuditLog(
payload.config,
async () => payload as never,
);
await auditLog.eraseSubject(String(doc.id), mode);
};
}

View File

@@ -1,6 +1,8 @@
export {
createAuditErasureHook,
createReqScopedAuditErasureHook,
type AuditErasureHookOpts,
type ReqScopedAuditErasureHookOpts,
} from "./audit-erasure-hook";
export {
createAuditAfterReadHook,

View File

@@ -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";

View File

@@ -25,7 +25,10 @@ describe("PayloadAuditLog.record", () => {
await log.record(sample);
expect(mockCreate).toHaveBeenCalledOnce();
const call = mockCreate.mock.calls[0]![0] as { collection: string; data: Record<string, unknown> };
const call = mockCreate.mock.calls[0]![0] as {
collection: string;
data: Record<string, unknown>;
};
expect(call.collection).toBe("audit-logs");
expect(call.data.actorId).toBe("user_1");
expect(call.data.action).toBe("UPDATE");
@@ -101,14 +104,21 @@ describe("PayloadAuditLog.eraseSubject", () => {
// update called for each doc
expect(mockUpdate).toHaveBeenCalledTimes(2);
const updateCalls = mockUpdate.mock.calls as Array<
[{ collection: string; id: string; data: Record<string, unknown>; overrideAccess: boolean }]
[
{
collection: string;
id: string;
data: Record<string, unknown>;
overrideAccess: boolean;
},
]
>;
expect(updateCalls[0]![0].id).toBe("doc_a");
expect(updateCalls[1]![0].id).toBe("doc_b");
// both updates replace actorId with the same pseudonym
const pseudonym = updateCalls[0]![0].data["actorId"] as string;
expect(pseudonym).toMatch(/^erased-[0-9a-f]{16}$/);
expect(pseudonym).toMatch(/^erased-[0-9a-f]{32}$/);
expect(updateCalls[1]![0].data["actorId"]).toBe(pseudonym);
// overrideAccess bypasses the append-only rule
@@ -118,7 +128,9 @@ describe("PayloadAuditLog.eraseSubject", () => {
it("mode='pseudonymize' with no matching docs does not call update", async () => {
const mockFind = vi.fn().mockResolvedValue({ docs: [] });
const mockUpdate = vi.fn();
const mockGetPayload = vi.fn().mockResolvedValue({ find: mockFind, update: mockUpdate });
const mockGetPayload = vi
.fn()
.mockResolvedValue({ find: mockFind, update: mockUpdate });
const log = new PayloadAuditLog({} as never, mockGetPayload);
await log.eraseSubject("unknown_user", "pseudonymize");

View File

@@ -21,13 +21,28 @@ describe("pseudonymize", () => {
expect(result).toMatch(/^erased-/);
});
it("produces exactly 16 hex chars after the prefix", () => {
it("produces exactly 32 hex chars (128 bits) after the prefix (A13)", () => {
const result = pseudonymize("user_42");
const hex = result.slice("erased-".length);
expect(hex).toHaveLength(16);
expect(hex).toHaveLength(32);
expect(hex).toMatch(/^[0-9a-f]+$/);
});
it("matches HMAC-SHA256(key, actorId) - keyed, not a bare hash (A13)", async () => {
const { createHmac, createHash } = await import("node:crypto");
const expected = createHmac("sha256", "test-salt-1")
.update("user_42")
.digest("hex")
.slice(0, 32);
expect(pseudonymize("user_42")).toBe(`erased-` + expected);
// and it must NOT be the legacy unkeyed sha256("salt:id") scheme
const legacy = createHash("sha256")
.update("test-salt-1:user_42")
.digest("hex")
.slice(0, 32);
expect(pseudonymize("user_42")).not.toBe(`erased-` + legacy);
});
it("is deterministic — same salt + actorId always yields the same token", () => {
const a = pseudonymize("user_42");
const b = pseudonymize("user_42");
@@ -53,6 +68,6 @@ describe("pseudonymize", () => {
delete process.env["AUDIT_PSEUDONYM_SALT"];
// Should not throw; just use the fallback.
const result = pseudonymize("user_1");
expect(result).toMatch(/^erased-[0-9a-f]{16}$/);
expect(result).toMatch(/^erased-[0-9a-f]{32}$/);
});
});

View File

@@ -1,22 +1,34 @@
import { createHash } from "node:crypto";
import { createHmac } from "node:crypto";
/**
* Produces a stable, irreversible token for a GDPR-erased actorId.
*
* Format: `erased-<first-16-hex-chars-of-sha256(salt:actorId)>`
* Format: `erased-<first-32-hex-chars-of-HMAC-SHA256(key, actorId)>`
* 128 bits of a KEYED digest (audit finding A13). The previous scheme was an
* unkeyed `sha256("salt:actorId")` truncated to 64 bits, which invited both
* brute-force reversal of small id spaces and birthday collisions.
*
* The salt is read from `AUDIT_PSEUDONYM_SALT` env at call time so that
* The HMAC key is read from `AUDIT_PSEUDONYM_SALT` at call time so that
* production binding can pre-validate the var at boot (see `bindAudit`)
* while tests can override it per-test via `process.env`.
*
* Fallback salt is intentionally weak and labelled so that any token
* Rotation expectations (documented, by design):
* - Rotating the key changes pseudonyms produced FROM THEN ON only. Audit
* rows already pseudonymized keep tokens derived from the previous key;
* nothing re-keys stored rows, so a subject's pre- and post-rotation
* tokens no longer correlate. That linkage break is acceptable — the
* token's only job is severing PII linkage, not long-term correlation.
* - Re-erasing a subject after rotation still works: erasure matches rows
* by the REAL actorId, not by a previous pseudonym.
* - Rotate by replacing the env value (e.g. `openssl rand -hex 32`); keep
* retired keys only if you have an explicit need to re-correlate old rows.
*
* Fallback key is intentionally weak and labelled so that any token
* produced with it is recognisable as a dev/test artefact.
*/
export function pseudonymize(actorId: string): string {
const salt =
const key =
process.env["AUDIT_PSEUDONYM_SALT"] ?? "dev-fallback-salt-replace-in-prod";
const hash = createHash("sha256")
.update(`${salt}:${actorId}`)
.digest("hex");
return `erased-${hash.slice(0, 16)}`;
const digest = createHmac("sha256", key).update(actorId).digest("hex");
return `erased-${digest.slice(0, 32)}`;
}