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