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";
|
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,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 {
|
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";
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ describe("PayloadAuditLog.record", () => {
|
|||||||
await log.record(sample);
|
await log.record(sample);
|
||||||
|
|
||||||
expect(mockCreate).toHaveBeenCalledOnce();
|
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.collection).toBe("audit-logs");
|
||||||
expect(call.data.actorId).toBe("user_1");
|
expect(call.data.actorId).toBe("user_1");
|
||||||
expect(call.data.action).toBe("UPDATE");
|
expect(call.data.action).toBe("UPDATE");
|
||||||
@@ -101,14 +104,21 @@ describe("PayloadAuditLog.eraseSubject", () => {
|
|||||||
// update called for each doc
|
// update called for each doc
|
||||||
expect(mockUpdate).toHaveBeenCalledTimes(2);
|
expect(mockUpdate).toHaveBeenCalledTimes(2);
|
||||||
const updateCalls = mockUpdate.mock.calls as Array<
|
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[0]![0].id).toBe("doc_a");
|
||||||
expect(updateCalls[1]![0].id).toBe("doc_b");
|
expect(updateCalls[1]![0].id).toBe("doc_b");
|
||||||
|
|
||||||
// both updates replace actorId with the same pseudonym
|
// both updates replace actorId with the same pseudonym
|
||||||
const pseudonym = updateCalls[0]![0].data["actorId"] as string;
|
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);
|
expect(updateCalls[1]![0].data["actorId"]).toBe(pseudonym);
|
||||||
|
|
||||||
// overrideAccess bypasses the append-only rule
|
// 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 () => {
|
it("mode='pseudonymize' with no matching docs does not call update", async () => {
|
||||||
const mockFind = vi.fn().mockResolvedValue({ docs: [] });
|
const mockFind = vi.fn().mockResolvedValue({ docs: [] });
|
||||||
const mockUpdate = vi.fn();
|
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);
|
const log = new PayloadAuditLog({} as never, mockGetPayload);
|
||||||
|
|
||||||
await log.eraseSubject("unknown_user", "pseudonymize");
|
await log.eraseSubject("unknown_user", "pseudonymize");
|
||||||
|
|||||||
@@ -21,13 +21,28 @@ describe("pseudonymize", () => {
|
|||||||
expect(result).toMatch(/^erased-/);
|
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 result = pseudonymize("user_42");
|
||||||
const hex = result.slice("erased-".length);
|
const hex = result.slice("erased-".length);
|
||||||
expect(hex).toHaveLength(16);
|
expect(hex).toHaveLength(32);
|
||||||
expect(hex).toMatch(/^[0-9a-f]+$/);
|
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", () => {
|
it("is deterministic — same salt + actorId always yields the same token", () => {
|
||||||
const a = pseudonymize("user_42");
|
const a = pseudonymize("user_42");
|
||||||
const b = pseudonymize("user_42");
|
const b = pseudonymize("user_42");
|
||||||
@@ -53,6 +68,6 @@ describe("pseudonymize", () => {
|
|||||||
delete process.env["AUDIT_PSEUDONYM_SALT"];
|
delete process.env["AUDIT_PSEUDONYM_SALT"];
|
||||||
// Should not throw; just use the fallback.
|
// Should not throw; just use the fallback.
|
||||||
const result = pseudonymize("user_1");
|
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.
|
* 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`)
|
* production binding can pre-validate the var at boot (see `bindAudit`)
|
||||||
* while tests can override it per-test via `process.env`.
|
* 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.
|
* produced with it is recognisable as a dev/test artefact.
|
||||||
*/
|
*/
|
||||||
export function pseudonymize(actorId: string): string {
|
export function pseudonymize(actorId: string): string {
|
||||||
const salt =
|
const key =
|
||||||
process.env["AUDIT_PSEUDONYM_SALT"] ?? "dev-fallback-salt-replace-in-prod";
|
process.env["AUDIT_PSEUDONYM_SALT"] ?? "dev-fallback-salt-replace-in-prod";
|
||||||
const hash = createHash("sha256")
|
const digest = createHmac("sha256", key).update(actorId).digest("hex");
|
||||||
.update(`${salt}:${actorId}`)
|
return `erased-${digest.slice(0, 32)}`;
|
||||||
.digest("hex");
|
|
||||||
return `erased-${hash.slice(0, 16)}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
"@payloadcms/db-postgres": "^3.14.0",
|
"@payloadcms/db-postgres": "^3.14.0",
|
||||||
"@payloadcms/richtext-lexical": "^3.14.0",
|
"@payloadcms/richtext-lexical": "^3.14.0",
|
||||||
"@repo/auth": "workspace:*",
|
"@repo/auth": "workspace:*",
|
||||||
|
"@repo/core-audit": "workspace:*",
|
||||||
|
"@repo/core-shared": "workspace:*",
|
||||||
"@repo/workspaces": "workspace:*",
|
"@repo/workspaces": "workspace:*",
|
||||||
"payload": "^3.14.0"
|
"payload": "^3.14.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,42 @@ describe("payloadConfig composition", () => {
|
|||||||
expect(slugs).toEqual(expect.arrayContaining(["users", "workspaces"]));
|
expect(slugs).toEqual(expect.arrayContaining(["users", "workspaces"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adds the deletedAt tombstone to postDeletion collections (A2)", async () => {
|
||||||
|
const resolved = await config;
|
||||||
|
for (const slug of ["users", "workspaces"]) {
|
||||||
|
const collection = resolved.collections?.find((c) => c.slug === slug);
|
||||||
|
const names =
|
||||||
|
collection?.fields.map((f) => (f as { name?: string }).name) ?? [];
|
||||||
|
expect(names, `collection ${slug}`).toContain("deletedAt");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers a retention purge task per purgeSchedule collection (A3)", async () => {
|
||||||
|
const resolved = await config;
|
||||||
|
const taskSlugs =
|
||||||
|
(
|
||||||
|
resolved.jobs as { tasks?: Array<{ slug: string }> } | undefined
|
||||||
|
)?.tasks?.map((t) => t.slug) ?? [];
|
||||||
|
expect(taskSlugs).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"retention-purge--users",
|
||||||
|
"retention-purge--workspaces",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 no feature globals (none remain)", async () => {
|
it("registers no feature globals (none remain)", 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) ?? [];
|
||||||
|
|||||||
@@ -4,15 +4,46 @@ import { lexicalEditor } from "@payloadcms/richtext-lexical";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { users } from "@repo/auth/cms";
|
import {
|
||||||
|
withRetentionTombstone,
|
||||||
|
buildRetentionPurgeTask,
|
||||||
|
} from "@repo/core-shared/payload";
|
||||||
|
import {
|
||||||
|
auditLogsCollection,
|
||||||
|
createReqScopedAuditErasureHook,
|
||||||
|
} from "@repo/core-audit";
|
||||||
|
import { users as usersBase } from "@repo/auth/cms";
|
||||||
import { workspaces } from "@repo/workspaces/cms";
|
import { workspaces } from "@repo/workspaces/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
|
||||||
|
// 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, workspaces].map(withRetentionTombstone),
|
||||||
|
// Local audit sink (A6) — required for PayloadAuditLog.record() to work.
|
||||||
|
auditLogsCollection,
|
||||||
|
];
|
||||||
|
|
||||||
export default buildConfig({
|
export default buildConfig({
|
||||||
editor: lexicalEditor(),
|
editor: lexicalEditor(),
|
||||||
collections: [users, workspaces],
|
collections,
|
||||||
globals: [],
|
globals: [],
|
||||||
secret: process.env.PAYLOAD_SECRET || "default-secret-change-me",
|
secret: process.env.PAYLOAD_SECRET || "default-secret-change-me",
|
||||||
db: postgresAdapter({
|
db: postgresAdapter({
|
||||||
@@ -22,6 +53,14 @@ export default buildConfig({
|
|||||||
"postgresql://postgres:postgres@localhost:5433/template",
|
"postgresql://postgres:postgres@localhost:5433/template",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
jobs: {
|
||||||
|
// Task definitions for the retention purge (audit finding A3):
|
||||||
|
// registerRetentionPurgeJobs (called from bindAllProduction) enqueues
|
||||||
|
// `retention-purge--<slug>` jobs; these definitions let Payload run them.
|
||||||
|
tasks: collections
|
||||||
|
.filter((c) => Boolean(c.custom?.retention?.purgeSchedule))
|
||||||
|
.map((c) => buildRetentionPurgeTask(c.slug)) as never,
|
||||||
|
},
|
||||||
typescript: {
|
typescript: {
|
||||||
outputFile: path.resolve(dirname, "generated-types.ts"),
|
outputFile: path.resolve(dirname, "generated-types.ts"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,11 +36,13 @@
|
|||||||
"@repo/core-testing": "workspace:*",
|
"@repo/core-testing": "workspace:*",
|
||||||
"@repo/core-typescript": "workspace:*",
|
"@repo/core-typescript": "workspace:*",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
|
"@trpc/client": "^11.18.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@vitest/coverage-v8": "^3.0.0",
|
"@vitest/coverage-v8": "^3.0.0",
|
||||||
"jsdom": "^25.0.0",
|
"jsdom": "^25.0.0",
|
||||||
"payload": "^3.14.0",
|
"payload": "^3.14.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
"superjson": "^2.2.1",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,27 @@ export type ConsentCategory =
|
|||||||
| "marketing"
|
| "marketing"
|
||||||
| (string & {});
|
| (string & {});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The known consent categories (audit finding A12). Untrusted inputs — e.g.
|
||||||
|
* the anonymous banner cookie migrated at sign-up — MUST be validated against
|
||||||
|
* this list before being granted; the open ConsentCategory union is for
|
||||||
|
* first-party code registering custom categories deliberately, not for
|
||||||
|
* client-controlled strings.
|
||||||
|
*/
|
||||||
|
export const KNOWN_CONSENT_CATEGORIES = [
|
||||||
|
"necessary",
|
||||||
|
"functional",
|
||||||
|
"analytics",
|
||||||
|
"marketing",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Type guard for the allow-list above. */
|
||||||
|
export function isKnownConsentCategory(
|
||||||
|
value: string,
|
||||||
|
): value is (typeof KNOWN_CONSENT_CATEGORIES)[number] {
|
||||||
|
return (KNOWN_CONSENT_CATEGORIES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether a subject has granted or denied consent for a category. */
|
/** Whether a subject has granted or denied consent for a category. */
|
||||||
export type ConsentState = "granted" | "denied" | "pending";
|
export type ConsentState = "granted" | "denied" | "pending";
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||||
|
import { createTRPCClient, httpLink } from "@trpc/client";
|
||||||
|
import superjson from "superjson";
|
||||||
import { RecordingConsent } from "@repo/core-testing/instrumentation";
|
import { RecordingConsent } from "@repo/core-testing/instrumentation";
|
||||||
|
import { router } from "@repo/core-shared/trpc/init";
|
||||||
import { consentRouter } from "@/consent.router";
|
import { consentRouter } from "@/consent.router";
|
||||||
import type { ConsentRouterContext } from "@/consent.router";
|
import type { ConsentRouterContext } from "@/consent.router";
|
||||||
import type { IConsent } from "@/consent.interface";
|
import type { IConsent } from "@/consent.interface";
|
||||||
|
import { InMemoryConsent } from "@/in-memory-consent";
|
||||||
|
|
||||||
function makeContext(
|
function makeContext(
|
||||||
consent: RecordingConsent,
|
consent: RecordingConsent,
|
||||||
@@ -155,6 +160,77 @@ describe("consentRouter — auth checks", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("consentRouter — context guard", () => {
|
||||||
|
it("throws INTERNAL_SERVER_ERROR when consentFactory is missing from ctx", async () => {
|
||||||
|
const caller = consentRouter.createCaller({
|
||||||
|
userId: "user-1",
|
||||||
|
} as unknown as ConsentRouterContext);
|
||||||
|
await expect(caller.grant({ category: "analytics" })).rejects.toMatchObject(
|
||||||
|
{
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: expect.stringContaining("consentFactory missing"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("consentRouter — superjson wire round-trip (A10)", () => {
|
||||||
|
// The consent router MUST be built from the shared `t` (which is created
|
||||||
|
// with the superjson transformer). This test drives a real tRPC HTTP
|
||||||
|
// round-trip — client link + fetch adapter — so a transformer mismatch
|
||||||
|
// between the mounted router and the app client fails loudly here.
|
||||||
|
function makeClient(ctx: ConsentRouterContext) {
|
||||||
|
const appLikeRouter = router({ consent: consentRouter });
|
||||||
|
return createTRPCClient<typeof appLikeRouter>({
|
||||||
|
links: [
|
||||||
|
httpLink({
|
||||||
|
url: "http://localhost/api/trpc",
|
||||||
|
transformer: superjson,
|
||||||
|
fetch: (input, init) =>
|
||||||
|
fetchRequestHandler({
|
||||||
|
endpoint: "/api/trpc",
|
||||||
|
req: new Request(input, init as RequestInit),
|
||||||
|
router: appLikeRouter,
|
||||||
|
createContext: () => ctx,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("round-trips grant + getCategories, reviving Date fields", async () => {
|
||||||
|
const consent = new InMemoryConsent();
|
||||||
|
const client = makeClient({
|
||||||
|
userId: "user-1",
|
||||||
|
consentFactory: async () => consent,
|
||||||
|
});
|
||||||
|
|
||||||
|
const grantRes = await client.consent.grant.mutate({
|
||||||
|
category: "analytics",
|
||||||
|
});
|
||||||
|
expect(grantRes).toEqual({ success: true });
|
||||||
|
|
||||||
|
const { categories } = await client.consent.getCategories.query({});
|
||||||
|
expect(categories).toHaveLength(1);
|
||||||
|
expect(categories[0]!.category).toBe("analytics");
|
||||||
|
// superjson revives Dates across the wire; plain JSON would yield a string.
|
||||||
|
expect(categories[0]!.grantedAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips isGranted through the wire", async () => {
|
||||||
|
const consent = new InMemoryConsent();
|
||||||
|
const client = makeClient({
|
||||||
|
userId: "user-1",
|
||||||
|
consentFactory: async () => consent,
|
||||||
|
});
|
||||||
|
await client.consent.grant.mutate({ category: "marketing" });
|
||||||
|
const res = await client.consent.isGranted.query({
|
||||||
|
category: "marketing",
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ granted: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("consentRouter — error passthrough", () => {
|
describe("consentRouter — error passthrough", () => {
|
||||||
it("propagates unmapped errors as INTERNAL_SERVER_ERROR", async () => {
|
it("propagates unmapped errors as INTERNAL_SERVER_ERROR", async () => {
|
||||||
const brokenConsent: IConsent = {
|
const brokenConsent: IConsent = {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { initTRPC } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { t } from "@repo/core-shared/trpc/init";
|
||||||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
||||||
|
|
||||||
import type { ConsentFactory } from "./di/bind-production";
|
import type { ConsentFactory } from "./di/bind-production";
|
||||||
@@ -26,41 +27,57 @@ export type ConsentRouterContext = {
|
|||||||
consentFactory: ConsentFactory;
|
consentFactory: ConsentFactory;
|
||||||
};
|
};
|
||||||
|
|
||||||
const tc = initTRPC.context<ConsentRouterContext>().create();
|
/**
|
||||||
|
* Consent procedures build on the SHARED `t` instance from
|
||||||
const consentProcedure = tc.procedure
|
* `@repo/core-shared/trpc/init` (audit finding A10): the app router is created
|
||||||
|
* with the superjson transformer, and a router built from a private `initTRPC`
|
||||||
|
* without superjson would corrupt every input/output that crosses the wire.
|
||||||
|
*
|
||||||
|
* The shared `t` is context-untyped, so the middleware narrows `ctx` to
|
||||||
|
* `ConsentRouterContext` at runtime — same cast pattern as the dsr router.
|
||||||
|
*/
|
||||||
|
const consentProcedure = t.procedure
|
||||||
.use(defineErrorMiddleware([[UnauthenticatedError, "UNAUTHORIZED"]]))
|
.use(defineErrorMiddleware([[UnauthenticatedError, "UNAUTHORIZED"]]))
|
||||||
.use(async ({ ctx, next }) => {
|
.use(async ({ ctx, next }) => {
|
||||||
if (!ctx.userId) throw new UnauthenticatedError();
|
const { userId, consentFactory } = ctx as Partial<ConsentRouterContext>;
|
||||||
return next();
|
if (!userId) throw new UnauthenticatedError();
|
||||||
|
if (!consentFactory) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message:
|
||||||
|
"consentFactory missing from tRPC context — wire the binding from " +
|
||||||
|
"bindProductionConsent/bindDevSeedConsent into createContext",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next({ ctx: { ...ctx, userId, consentFactory } });
|
||||||
});
|
});
|
||||||
|
|
||||||
export const consentRouter = tc.router({
|
export const consentRouter = t.router({
|
||||||
grant: consentProcedure
|
grant: consentProcedure
|
||||||
.input(grantHandlerInputSchema)
|
.input(grantHandlerInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const consent = await ctx.consentFactory(ctx.userId!);
|
const consent = await ctx.consentFactory(ctx.userId);
|
||||||
return grantHandler(consent, input);
|
return grantHandler(consent, input);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
withdraw: consentProcedure
|
withdraw: consentProcedure
|
||||||
.input(withdrawHandlerInputSchema)
|
.input(withdrawHandlerInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const consent = await ctx.consentFactory(ctx.userId!);
|
const consent = await ctx.consentFactory(ctx.userId);
|
||||||
return withdrawHandler(consent, input);
|
return withdrawHandler(consent, input);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
isGranted: consentProcedure
|
isGranted: consentProcedure
|
||||||
.input(isGrantedHandlerInputSchema)
|
.input(isGrantedHandlerInputSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const consent = await ctx.consentFactory(ctx.userId!);
|
const consent = await ctx.consentFactory(ctx.userId);
|
||||||
return isGrantedHandler(consent, input);
|
return isGrantedHandler(consent, input);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getCategories: consentProcedure
|
getCategories: consentProcedure
|
||||||
.input(z.object({}).strict())
|
.input(z.object({}).strict())
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const consent = await ctx.consentFactory(ctx.userId!);
|
const consent = await ctx.consentFactory(ctx.userId);
|
||||||
return getCategoriesHandler(consent);
|
return getCategoriesHandler(consent);
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ export type {
|
|||||||
UserConsentState,
|
UserConsentState,
|
||||||
ConsentGrantMeta,
|
ConsentGrantMeta,
|
||||||
} from "./consent-types";
|
} from "./consent-types";
|
||||||
|
export {
|
||||||
|
KNOWN_CONSENT_CATEGORIES,
|
||||||
|
isKnownConsentCategory,
|
||||||
|
} from "./consent-types";
|
||||||
export type { IConsent } from "./consent.interface";
|
export type { IConsent } from "./consent.interface";
|
||||||
export type { ConsentChecked } from "./with-consent";
|
export type { ConsentChecked } from "./with-consent";
|
||||||
export { withConsent } from "./with-consent";
|
export { withConsent } from "./with-consent";
|
||||||
|
|||||||
@@ -43,6 +43,21 @@ describe("extractAnonymousConsent", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("extractAnonymousConsent — category allow-list (A12)", () => {
|
||||||
|
it("drops unknown categories from the client-controlled cookie", () => {
|
||||||
|
const result = extractAnonymousConsent(
|
||||||
|
`${CONSENT_COOKIE_NAME}=necessary,evil-injection,analytics`,
|
||||||
|
);
|
||||||
|
expect(result).toEqual(["necessary", "analytics"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when every category is unknown", () => {
|
||||||
|
expect(
|
||||||
|
extractAnonymousConsent(`${CONSENT_COOKIE_NAME}=hax,__proto__`),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("migrateAnonymousConsent", () => {
|
describe("migrateAnonymousConsent", () => {
|
||||||
it("calls IConsent.grant with method signup-migration for each category", async () => {
|
it("calls IConsent.grant with method signup-migration for each category", async () => {
|
||||||
const consent = new RecordingConsent();
|
const consent = new RecordingConsent();
|
||||||
@@ -105,3 +120,17 @@ describe("migrateAnonymousConsent", () => {
|
|||||||
expect(consent.isGranted("marketing")).toBe(true);
|
expect(consent.isGranted("marketing")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("migrateAnonymousConsent — category allow-list (A12)", () => {
|
||||||
|
it("never grants unknown categories even when passed directly", async () => {
|
||||||
|
const consent = new RecordingConsent();
|
||||||
|
await migrateAnonymousConsent({
|
||||||
|
consent,
|
||||||
|
cookieState: ["analytics", "totally-made-up", "marketing"],
|
||||||
|
});
|
||||||
|
expect(consent.grants.map((g) => g.category)).toEqual([
|
||||||
|
"analytics",
|
||||||
|
"marketing",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { ConsentCategory, ConsentGrantMeta } from "./consent-types";
|
import {
|
||||||
|
isKnownConsentCategory,
|
||||||
|
type ConsentCategory,
|
||||||
|
type ConsentGrantMeta,
|
||||||
|
} from "./consent-types";
|
||||||
import type { IConsent } from "./consent.interface";
|
import type { IConsent } from "./consent.interface";
|
||||||
|
|
||||||
/** Cookie name written by the anonymous consent banner. */
|
/** Cookie name written by the anonymous consent banner. */
|
||||||
@@ -11,6 +15,10 @@ export const CONSENT_COOKIE_NAME = "cc_consent";
|
|||||||
*
|
*
|
||||||
* Expected cookie value format: comma-separated category names,
|
* Expected cookie value format: comma-separated category names,
|
||||||
* e.g. "necessary,analytics,marketing".
|
* e.g. "necessary,analytics,marketing".
|
||||||
|
*
|
||||||
|
* The cookie is client-controlled, so values are validated against
|
||||||
|
* KNOWN_CONSENT_CATEGORIES (audit finding A12) — unknown strings are
|
||||||
|
* dropped rather than granted.
|
||||||
*/
|
*/
|
||||||
export function extractAnonymousConsent(
|
export function extractAnonymousConsent(
|
||||||
cookieHeader: string,
|
cookieHeader: string,
|
||||||
@@ -21,7 +29,8 @@ export function extractAnonymousConsent(
|
|||||||
const categories = raw
|
const categories = raw
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((c) => c.trim())
|
.map((c) => c.trim())
|
||||||
.filter(Boolean) as ConsentCategory[];
|
.filter(Boolean)
|
||||||
|
.filter(isKnownConsentCategory) as ConsentCategory[];
|
||||||
return categories.length > 0 ? categories : null;
|
return categories.length > 0 ? categories : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +51,9 @@ export async function migrateAnonymousConsent(opts: {
|
|||||||
const meta: ConsentGrantMeta = { method: "signup-migration" };
|
const meta: ConsentGrantMeta = { method: "signup-migration" };
|
||||||
if (bannerVersion !== undefined) meta.bannerVersion = bannerVersion;
|
if (bannerVersion !== undefined) meta.bannerVersion = bannerVersion;
|
||||||
if (policyVersion !== undefined) meta.policyVersion = policyVersion;
|
if (policyVersion !== undefined) meta.policyVersion = policyVersion;
|
||||||
for (const category of cookieState) {
|
// Defense in depth (A12): even a caller that bypassed
|
||||||
|
// extractAnonymousConsent cannot grant unknown categories.
|
||||||
|
for (const category of cookieState.filter(isKnownConsentCategory)) {
|
||||||
await consent.grant(category, meta);
|
await consent.grant(category, meta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,3 +259,106 @@ describe("PayloadConsent.load — deserializeEntry branches", () => {
|
|||||||
expect(cats[0]!.withdrawnAt).toBeInstanceOf(Date);
|
expect(cats[0]!.withdrawnAt).toBeInstanceOf(Date);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PayloadConsent.persist — read-merge-write (A7)", () => {
|
||||||
|
async function makeTwoConsents() {
|
||||||
|
const mock = makePayloadMock();
|
||||||
|
const a = new PayloadConsent(
|
||||||
|
"user_1",
|
||||||
|
{} as never,
|
||||||
|
new RecordingAuditLog(),
|
||||||
|
mock.getPayload,
|
||||||
|
);
|
||||||
|
const b = new PayloadConsent(
|
||||||
|
"user_1",
|
||||||
|
{} as never,
|
||||||
|
new RecordingAuditLog(),
|
||||||
|
mock.getPayload,
|
||||||
|
);
|
||||||
|
// Both instances hydrate from the SAME empty snapshot — the per-request
|
||||||
|
// cache staleness that caused the lost update.
|
||||||
|
await a.load();
|
||||||
|
await b.load();
|
||||||
|
return { a, b, ...mock };
|
||||||
|
}
|
||||||
|
|
||||||
|
function storedCategories(db: Record<string, unknown[]>): string[] {
|
||||||
|
return (db["user_1"] ?? [])
|
||||||
|
.map((e) => (e as { category: string }).category)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("two interleaved grants from stale caches both survive", async () => {
|
||||||
|
const { a, b, db } = await makeTwoConsents();
|
||||||
|
|
||||||
|
await a.grant("analytics");
|
||||||
|
await b.grant("marketing"); // pre-fix: whole-array write dropped "analytics"
|
||||||
|
|
||||||
|
expect(storedCategories(db)).toEqual(["analytics", "marketing"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a grant and a withdraw on different categories both survive", async () => {
|
||||||
|
const { a, b, db } = await makeTwoConsents();
|
||||||
|
|
||||||
|
await a.grant("analytics");
|
||||||
|
await b.grant("marketing");
|
||||||
|
await a.withdraw("analytics");
|
||||||
|
|
||||||
|
expect(storedCategories(db)).toEqual(["analytics", "marketing"]);
|
||||||
|
const analytics = (
|
||||||
|
db["user_1"] as Array<{ category: string; state: string }>
|
||||||
|
).find((e) => e.category === "analytics");
|
||||||
|
expect(analytics?.state).toBe("denied");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adopts concurrent writers' entries into the local cache after persist", async () => {
|
||||||
|
const { a, b } = await makeTwoConsents();
|
||||||
|
|
||||||
|
await a.grant("analytics");
|
||||||
|
await b.grant("marketing");
|
||||||
|
|
||||||
|
// b re-read the freshest state during persist, so it now sees a's grant.
|
||||||
|
expect(b.isGranted("analytics")).toBe(true);
|
||||||
|
expect(b.isGranted("marketing")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truly concurrent grants both survive when the second read lands after the first write", async () => {
|
||||||
|
const mock = makePayloadMock();
|
||||||
|
const a = new PayloadConsent(
|
||||||
|
"user_1",
|
||||||
|
{} as never,
|
||||||
|
new RecordingAuditLog(),
|
||||||
|
mock.getPayload,
|
||||||
|
);
|
||||||
|
const b = new PayloadConsent(
|
||||||
|
"user_1",
|
||||||
|
{} as never,
|
||||||
|
new RecordingAuditLog(),
|
||||||
|
mock.getPayload,
|
||||||
|
);
|
||||||
|
await a.load();
|
||||||
|
await b.load();
|
||||||
|
|
||||||
|
// Gate b's persist-read until a's write has committed — the ordering the
|
||||||
|
// read-merge-write strategy is designed for. (A same-window overlap is
|
||||||
|
// the documented residual race.)
|
||||||
|
let releaseB: () => void = () => {};
|
||||||
|
const bGate = new Promise<void>((resolve) => {
|
||||||
|
releaseB = resolve;
|
||||||
|
});
|
||||||
|
const originalFindByID = mock.findByID.getMockImplementation()!;
|
||||||
|
let firstPersistRead = true;
|
||||||
|
// a loads+persists first; instrument findByID so b's persist read waits.
|
||||||
|
mock.findByID.mockImplementation(async (args: { id: string }) => {
|
||||||
|
if (!firstPersistRead) await bGate;
|
||||||
|
firstPersistRead = false;
|
||||||
|
return originalFindByID(args);
|
||||||
|
});
|
||||||
|
|
||||||
|
const aDone = a.grant("analytics").then(() => releaseB());
|
||||||
|
const bDone = b.grant("marketing");
|
||||||
|
await Promise.all([aDone, bDone]);
|
||||||
|
|
||||||
|
expect(storedCategories(mock.db)).toEqual(["analytics", "marketing"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export class PayloadConsent implements IConsent {
|
|||||||
method: meta?.method,
|
method: meta?.method,
|
||||||
};
|
};
|
||||||
this.cache.set(category, entry);
|
this.cache.set(category, entry);
|
||||||
await this.persist();
|
await this.persist([category]);
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
actorId: this.userId,
|
actorId: this.userId,
|
||||||
actorType: "user",
|
actorType: "user",
|
||||||
@@ -116,7 +116,7 @@ export class PayloadConsent implements IConsent {
|
|||||||
withdrawnAt: now,
|
withdrawnAt: now,
|
||||||
};
|
};
|
||||||
this.cache.set(category, entry);
|
this.cache.set(category, entry);
|
||||||
await this.persist();
|
await this.persist([category]);
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
actorId: this.userId,
|
actorId: this.userId,
|
||||||
actorType: "user",
|
actorType: "user",
|
||||||
@@ -139,9 +139,54 @@ export class PayloadConsent implements IConsent {
|
|||||||
return Array.from(this.cache.values());
|
return Array.from(this.cache.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persist(): Promise<void> {
|
/**
|
||||||
|
* Read-merge-write persistence (audit finding A7 — lost-update race).
|
||||||
|
*
|
||||||
|
* Payload's `update` on a json field replaces the WHOLE value; there is no
|
||||||
|
* targeted array-element patch. Writing this instance's per-request cache
|
||||||
|
* verbatim would drop any category another request persisted since our
|
||||||
|
* `load()`. Instead we re-read the freshest stored state immediately
|
||||||
|
* before writing and overlay ONLY the categories this call mutated, so
|
||||||
|
* two interleaved writers touching different categories both survive.
|
||||||
|
*
|
||||||
|
* Residual window (documented, accepted): between this read and the write,
|
||||||
|
* a concurrent writer to the SAME category is last-writer-wins, and a
|
||||||
|
* concurrent writer to a different category that lands inside the window
|
||||||
|
* can still be overwritten. Closing it fully needs a DB-level transaction
|
||||||
|
* or JSON-patch support in Payload; for consent state (idempotent,
|
||||||
|
* per-subject, low frequency) read-merge-write is the accepted trade-off.
|
||||||
|
*/
|
||||||
|
private async persist(mutated: ConsentCategory[]): Promise<void> {
|
||||||
const payload = await this.getPayloadFn({ config: this.config });
|
const payload = await this.getPayloadFn({ config: this.config });
|
||||||
const state = Array.from(this.cache.values()).map((entry) => ({
|
|
||||||
|
// Freshest stored state, immediately before the write.
|
||||||
|
const doc = await payload.findByID({
|
||||||
|
collection: "users",
|
||||||
|
id: this.userId,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
const merged = new Map<ConsentCategory, UserConsentState>();
|
||||||
|
const rawState = doc["consentState"];
|
||||||
|
if (Array.isArray(rawState)) {
|
||||||
|
for (const raw of rawState) {
|
||||||
|
if (raw && typeof raw === "object") {
|
||||||
|
const entry = deserializeEntry(raw as Record<string, unknown>);
|
||||||
|
merged.set(entry.category, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay only what this call changed.
|
||||||
|
for (const category of mutated) {
|
||||||
|
const entry = this.cache.get(category);
|
||||||
|
if (entry) merged.set(category, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adopt the merged view locally so isGranted/getCategories reflect
|
||||||
|
// concurrent writers' entries too.
|
||||||
|
this.cache = merged;
|
||||||
|
|
||||||
|
const state = Array.from(merged.values()).map((entry) => ({
|
||||||
category: entry.category,
|
category: entry.category,
|
||||||
state: entry.state,
|
state: entry.state,
|
||||||
grantedAt: entry.grantedAt?.toISOString() ?? null,
|
grantedAt: entry.grantedAt?.toISOString() ?? null,
|
||||||
|
|||||||
@@ -208,15 +208,127 @@ describe("dsrRouter.restrict", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("dsrRouter singleton guard", () => {
|
describe("dsrRouter subject scoping (A1 — IDOR)", () => {
|
||||||
it("throws when procedures are called without a real DsrBinding", async () => {
|
it("rejects a non-admin export for another subject with FORBIDDEN", async () => {
|
||||||
// The singleton uses a Proxy that throws on any binding property access.
|
const binding = makeBinding();
|
||||||
// Procedures access binding lazily, so the Proxy error surfaces at call time.
|
const caller = makeCaller(binding, authenticatedUser);
|
||||||
|
await expect(
|
||||||
|
caller.export({ subjectId: "bob", format: "json" }),
|
||||||
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
expect(binding.dataExport.calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-admin delete for another subject with FORBIDDEN", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, authenticatedUser);
|
||||||
|
await expect(
|
||||||
|
caller.delete({ subjectId: "bob", mode: "soft" }),
|
||||||
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
expect(binding.dataDelete.calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-admin rectify for another subject with FORBIDDEN", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, authenticatedUser);
|
||||||
|
await expect(
|
||||||
|
caller.rectify({
|
||||||
|
subjectId: "bob",
|
||||||
|
collection: "users",
|
||||||
|
field: "name",
|
||||||
|
value: "x",
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
expect(binding.dataRectify.calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-admin restrict for another subject with FORBIDDEN", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, authenticatedUser);
|
||||||
|
await expect(
|
||||||
|
caller.restrict({ subjectId: "bob", granted: true }),
|
||||||
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
expect(binding.processingRestriction.sets).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-admin user without an id acting on any subject", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, { roles: ["user"] });
|
||||||
|
await expect(
|
||||||
|
caller.export({ subjectId: "alice", format: "json" }),
|
||||||
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a non-admin to act on themselves for every operation", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, authenticatedUser);
|
||||||
|
await caller.export({ subjectId: "alice", format: "json" });
|
||||||
|
await caller.delete({ subjectId: "alice", mode: "soft" });
|
||||||
|
await caller.rectify({
|
||||||
|
subjectId: "alice",
|
||||||
|
collection: "users",
|
||||||
|
field: "name",
|
||||||
|
value: "Alice",
|
||||||
|
});
|
||||||
|
await caller.restrict({ subjectId: "alice", granted: true });
|
||||||
|
expect(binding.dataExport.calls).toHaveLength(1);
|
||||||
|
expect(binding.dataDelete.calls).toHaveLength(1);
|
||||||
|
expect(binding.dataRectify.calls).toHaveLength(1);
|
||||||
|
expect(binding.processingRestriction.sets).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an admin to act cross-subject on every operation", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = makeCaller(binding, adminUser);
|
||||||
|
await caller.export({ subjectId: "alice", format: "json" });
|
||||||
|
await caller.delete({ subjectId: "alice", mode: "soft" });
|
||||||
|
await caller.rectify({
|
||||||
|
subjectId: "alice",
|
||||||
|
collection: "users",
|
||||||
|
field: "name",
|
||||||
|
value: "Alice",
|
||||||
|
});
|
||||||
|
await caller.restrict({ subjectId: "alice", granted: true });
|
||||||
|
expect(binding.dataExport.calls).toHaveLength(1);
|
||||||
|
expect(binding.dataDelete.calls).toHaveLength(1);
|
||||||
|
expect(binding.dataRectify.calls).toHaveLength(1);
|
||||||
|
expect(binding.processingRestriction.sets).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dsrRouter singleton (context-time binding, A11)", () => {
|
||||||
|
it("fails loudly when neither ctx.dsrBinding nor a creation binding exists", async () => {
|
||||||
const caller = dsrRouter.createCaller({
|
const caller = dsrRouter.createCaller({
|
||||||
user: authenticatedUser,
|
user: authenticatedUser,
|
||||||
} as Record<string, unknown>);
|
} as Record<string, unknown>);
|
||||||
await expect(
|
await expect(
|
||||||
caller.export({ subjectId: "alice", format: "json" }),
|
caller.export({ subjectId: "alice", format: "json" }),
|
||||||
).rejects.toThrow(/dsrRouter singleton/);
|
).rejects.toMatchObject({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: expect.stringContaining("DsrBinding missing"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves requests when the app provides ctx.dsrBinding", async () => {
|
||||||
|
const binding = makeBinding();
|
||||||
|
const caller = dsrRouter.createCaller({
|
||||||
|
user: authenticatedUser,
|
||||||
|
dsrBinding: binding,
|
||||||
|
} as Record<string, unknown>);
|
||||||
|
const result = await caller.export({ subjectId: "alice", format: "json" });
|
||||||
|
expect(result.subjectId).toBe("alice");
|
||||||
|
expect(binding.dataExport.calls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers ctx.dsrBinding over the creation-time binding", async () => {
|
||||||
|
const creationBinding = makeBinding();
|
||||||
|
const ctxBinding = makeBinding();
|
||||||
|
const router = createDsrRouter(creationBinding as unknown as DsrBinding);
|
||||||
|
const caller = router.createCaller({
|
||||||
|
user: authenticatedUser,
|
||||||
|
dsrBinding: ctxBinding,
|
||||||
|
} as Record<string, unknown>);
|
||||||
|
await caller.export({ subjectId: "alice", format: "json" });
|
||||||
|
expect(ctxBinding.dataExport.calls).toHaveLength(1);
|
||||||
|
expect(creationBinding.dataExport.calls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,6 +97,152 @@ describe("PayloadDataDelete", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stamps the deletedAt tombstone when the collection declares postDeletion retention (A2)", async () => {
|
||||||
|
const config = makeMockConfig([
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
subject: { field: "id", kind: "self" },
|
||||||
|
pii: { email: { exportable: true } },
|
||||||
|
retention: {
|
||||||
|
purgeSchedule: "daily",
|
||||||
|
postDeletion: {
|
||||||
|
duration: "P30D",
|
||||||
|
trigger: "after-deletion",
|
||||||
|
action: "hard-delete",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockPayload.find.mockResolvedValue({
|
||||||
|
docs: [{ id: "alice", email: "a@ex.com" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||||
|
await deleter.deleteSubjectData("alice", "soft");
|
||||||
|
|
||||||
|
expect(mockPayload.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
deletedAt: expect.any(String),
|
||||||
|
processingRestrictedAt: expect.any(String),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT stamp deletedAt without a postDeletion retention policy", async () => {
|
||||||
|
const config = makeMockConfig([
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
subject: { field: "id", kind: "self" },
|
||||||
|
pii: { email: { exportable: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockPayload.find.mockResolvedValue({
|
||||||
|
docs: [{ id: "alice", email: "a@ex.com" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||||
|
await deleter.deleteSubjectData("alice", "soft");
|
||||||
|
|
||||||
|
const updateData = mockPayload.update.mock.calls[0]?.[0]?.data as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(updateData).not.toHaveProperty("deletedAt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stamps deletedAt on owner rows of postDeletion collections", async () => {
|
||||||
|
const config = makeMockConfig([
|
||||||
|
{
|
||||||
|
slug: "orders",
|
||||||
|
custom: {
|
||||||
|
subject: { field: "userId", kind: "owner" },
|
||||||
|
pii: { shippingAddress: { exportable: true } },
|
||||||
|
retention: {
|
||||||
|
purgeSchedule: "daily",
|
||||||
|
postDeletion: {
|
||||||
|
duration: "P90D",
|
||||||
|
trigger: "after-deletion",
|
||||||
|
action: "hard-delete",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockPayload.find.mockResolvedValue({
|
||||||
|
docs: [{ id: "o1", userId: "alice", shippingAddress: "X" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||||
|
await deleter.deleteSubjectData("alice", "soft");
|
||||||
|
|
||||||
|
const updateData = mockPayload.update.mock.calls[0]?.[0]?.data as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(updateData["deletedAt"]).toEqual(expect.any(String));
|
||||||
|
expect(updateData).not.toHaveProperty("processingRestrictedAt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts the auth-injected email field for a users-shaped collection (A5)", async () => {
|
||||||
|
const config = makeMockConfig([
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
subject: { field: "id", kind: "self" },
|
||||||
|
pii: {
|
||||||
|
email: {
|
||||||
|
category: "contact-email",
|
||||||
|
purpose: ["account-authentication"],
|
||||||
|
exportable: true,
|
||||||
|
restrictable: true,
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
category: "identification-username",
|
||||||
|
purpose: ["service-delivery"],
|
||||||
|
exportable: true,
|
||||||
|
restrictable: true,
|
||||||
|
},
|
||||||
|
displayName: {
|
||||||
|
category: "identification-username",
|
||||||
|
purpose: ["service-delivery"],
|
||||||
|
exportable: true,
|
||||||
|
restrictable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockPayload.find.mockResolvedValue({
|
||||||
|
docs: [{ id: "alice", email: "alice@example.com", username: "alice" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||||
|
const cert = await deleter.deleteSubjectData("alice", "soft");
|
||||||
|
|
||||||
|
expect(mockPayload.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
email: null,
|
||||||
|
username: null,
|
||||||
|
displayName: null,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(cert.affected[0]?.fields).toEqual(
|
||||||
|
expect.arrayContaining(["email", "username", "displayName"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("owner role: does NOT set processingRestrictedAt", async () => {
|
it("owner role: does NOT set processingRestrictedAt", async () => {
|
||||||
const config = makeMockConfig([
|
const config = makeMockConfig([
|
||||||
{
|
{
|
||||||
@@ -367,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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -72,6 +72,60 @@ describe("PayloadDataExport", () => {
|
|||||||
expect(bundle.data["users"]?.asReference).toBeUndefined();
|
expect(bundle.data["users"]?.asReference).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exports the auth-injected email field for a users-shaped collection (A5)", async () => {
|
||||||
|
// Mirrors packages/auth users collection: email is auto-added by Payload
|
||||||
|
// `auth: true` and declared only in the collection-level custom.pii map.
|
||||||
|
const config = makeMockConfig([
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
subject: { field: "id", kind: "self" },
|
||||||
|
pii: {
|
||||||
|
email: {
|
||||||
|
category: "contact-email",
|
||||||
|
purpose: ["account-authentication"],
|
||||||
|
exportable: true,
|
||||||
|
restrictable: true,
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
category: "identification-username",
|
||||||
|
purpose: ["service-delivery"],
|
||||||
|
exportable: true,
|
||||||
|
restrictable: true,
|
||||||
|
},
|
||||||
|
displayName: {
|
||||||
|
category: "identification-username",
|
||||||
|
purpose: ["service-delivery"],
|
||||||
|
exportable: true,
|
||||||
|
restrictable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockPayload.find.mockResolvedValue({
|
||||||
|
docs: [
|
||||||
|
{
|
||||||
|
id: "alice",
|
||||||
|
email: "alice@example.com",
|
||||||
|
username: "alice",
|
||||||
|
displayName: "Alice",
|
||||||
|
passwordHash: "secret-hash",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
|
||||||
|
const bundle = await exporter.exportSubjectData("alice", "json");
|
||||||
|
|
||||||
|
const row = bundle.data["users"]!.asSelf![0]!;
|
||||||
|
expect(row["email"]).toBe("alice@example.com");
|
||||||
|
expect(row["username"]).toBe("alice");
|
||||||
|
expect(row["displayName"]).toBe("Alice");
|
||||||
|
expect(row).not.toHaveProperty("passwordHash");
|
||||||
|
});
|
||||||
|
|
||||||
it("happy path — owner role: includes exportable PII fields", async () => {
|
it("happy path — owner role: includes exportable PII fields", async () => {
|
||||||
const config = makeMockConfig([
|
const config = makeMockConfig([
|
||||||
{
|
{
|
||||||
@@ -207,3 +261,103 @@ describe("PayloadDataExport", () => {
|
|||||||
expect(Object.keys(bundle.data)).toEqual(["users", "orders"]);
|
expect(Object.keys(bundle.data)).toEqual(["users", "orders"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PayloadDataExport — audit log in the bundle (A14)", () => {
|
||||||
|
it("populates bundle.auditLog from the audit-logs collection, scoped to the subject", async () => {
|
||||||
|
const auditLog = new RecordingAuditLog();
|
||||||
|
const find = vi.fn(async (args: { collection: string }) => {
|
||||||
|
if (args.collection === "audit-logs") {
|
||||||
|
return {
|
||||||
|
docs: [
|
||||||
|
{
|
||||||
|
id: "log-1",
|
||||||
|
actorId: "alice",
|
||||||
|
actorType: "user",
|
||||||
|
actorRoles: ["author"],
|
||||||
|
action: "EXPORT",
|
||||||
|
resourceType: "subject-data",
|
||||||
|
resourceId: null,
|
||||||
|
changedFields: null,
|
||||||
|
scopeFeature: "core-dsr",
|
||||||
|
scopeEnvironment: "test",
|
||||||
|
scopeTenant: "default",
|
||||||
|
reason: null,
|
||||||
|
correlationId: "corr-1",
|
||||||
|
requestId: null,
|
||||||
|
ipTruncated: "system",
|
||||||
|
userAgent: "system",
|
||||||
|
containsPii: false,
|
||||||
|
piiCategories: null,
|
||||||
|
outcome: "success",
|
||||||
|
errorCode: null,
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { docs: [{ id: "alice", email: "a@ex.com" }] };
|
||||||
|
});
|
||||||
|
const getPayload = vi.fn(async () => ({ find }));
|
||||||
|
const config = {
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
subject: { field: "id", kind: "self" },
|
||||||
|
pii: { email: { exportable: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ slug: "audit-logs" },
|
||||||
|
],
|
||||||
|
} as unknown as SanitizedConfig;
|
||||||
|
|
||||||
|
const exporter = new PayloadDataExport(config, auditLog, getPayload);
|
||||||
|
const bundle = await exporter.exportSubjectData("alice", "json");
|
||||||
|
|
||||||
|
expect(find).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
collection: "audit-logs",
|
||||||
|
where: { actorId: { equals: "alice" } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(bundle.auditLog).toHaveLength(1);
|
||||||
|
const entry = bundle.auditLog![0]!;
|
||||||
|
expect(entry.actorId).toBe("alice");
|
||||||
|
expect(entry.action).toBe("EXPORT");
|
||||||
|
expect(entry.correlationId).toBe("corr-1");
|
||||||
|
expect(entry.at).toEqual(new Date("2026-01-01T00:00:00.000Z"));
|
||||||
|
expect(entry.scope).toEqual({
|
||||||
|
feature: "core-dsr",
|
||||||
|
environment: "test",
|
||||||
|
tenant: "default",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves bundle.auditLog undefined when the audit-logs collection is absent", async () => {
|
||||||
|
const config = makeMockConfig([
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
subject: { field: "id", kind: "self" },
|
||||||
|
pii: { email: { exportable: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const auditLog = new RecordingAuditLog();
|
||||||
|
const mock = makeMockPayload();
|
||||||
|
mock.find.mockResolvedValue({ docs: [{ id: "alice", email: "x" }] });
|
||||||
|
const exporter = new PayloadDataExport(
|
||||||
|
config,
|
||||||
|
auditLog,
|
||||||
|
vi.fn().mockResolvedValue(mock),
|
||||||
|
);
|
||||||
|
const bundle = await exporter.exportSubjectData("alice", "json");
|
||||||
|
expect(bundle.auditLog).toBeUndefined();
|
||||||
|
// no stray find against a non-registered collection
|
||||||
|
expect(
|
||||||
|
mock.find.mock.calls.some(
|
||||||
|
(c) => (c[0] as { collection: string }).collection === "audit-logs",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -29,21 +29,65 @@ const dsrProcedure = t.procedure
|
|||||||
.use(requireAuthenticated)
|
.use(requireAuthenticated)
|
||||||
.use(defineErrorMiddleware([]));
|
.use(defineErrorMiddleware([]));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subject-scope guard (audit finding A1 — DSR IDOR).
|
||||||
|
*
|
||||||
|
* Non-admin callers may act ONLY on themselves: a request whose
|
||||||
|
* `input.subjectId` differs from the authenticated user's id is rejected
|
||||||
|
* with FORBIDDEN (rejected, not silently rewritten, so the mismatch is
|
||||||
|
* visible to the caller). Any cross-subject operation requires the
|
||||||
|
* "admin" role.
|
||||||
|
*/
|
||||||
|
function assertSubjectScope(user: DsrTrpcUser, subjectId: string): void {
|
||||||
|
if (user.roles?.includes("admin")) return;
|
||||||
|
if (user.id !== undefined && user.id === subjectId) return;
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "DSR operations on another subject require the admin role",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function userFromCtx(ctx: object): DsrTrpcUser {
|
||||||
|
return (ctx as { user: DsrTrpcUser }).user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** tRPC context consumed by the DSR router (provided by the app's createContext). */
|
||||||
|
export type DsrRouterContext = {
|
||||||
|
user?: DsrTrpcUser;
|
||||||
|
/** Per-request DSR binding — the app wires bindProductionDsr/bindDevSeedDsr output here. */
|
||||||
|
dsrBinding?: DsrBinding;
|
||||||
|
};
|
||||||
|
|
||||||
|
function bindingFromCtx(ctx: object, fallback?: DsrBinding): DsrBinding {
|
||||||
|
const fromCtx = (ctx as DsrRouterContext).dsrBinding;
|
||||||
|
if (fromCtx) return fromCtx;
|
||||||
|
if (fallback) return fallback;
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message:
|
||||||
|
"DsrBinding missing — provide ctx.dsrBinding from createContext " +
|
||||||
|
"(bindProductionDsr/bindDevSeedDsr) or pass a binding to createDsrRouter",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the DSR tRPC router.
|
* Creates the DSR tRPC router.
|
||||||
*
|
*
|
||||||
* Capture `binding` at router-creation time. Apps that mount this router
|
* The binding is resolved per request from `ctx.dsrBinding` (audit finding
|
||||||
* must pass the `DsrBinding` returned by `bindProductionDsr` or `bindDevSeedDsr`.
|
* A11 — the mounted router must be live, not a dead stub), falling back to
|
||||||
|
* the optional `binding` captured at router-creation time.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
|
* // creation-time binding
|
||||||
* const binding = bindProductionDsr({ config, auditLog });
|
* const binding = bindProductionDsr({ config, auditLog });
|
||||||
* const appRouter = t.router({ ..., dsr: createDsrRouter(binding) });
|
* const appRouter = t.router({ ..., dsr: createDsrRouter(binding) });
|
||||||
|
*
|
||||||
|
* // or context-time binding (what apps mounting the `dsrRouter` singleton do)
|
||||||
|
* createContext: () => ({ user, dsrBinding })
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function createDsrRouter(binding: DsrBinding) {
|
export function createDsrRouter(binding?: DsrBinding) {
|
||||||
// Handlers are created lazily (inside procedure closures) so that the
|
|
||||||
// dsrRouter singleton proxy doesn't trigger at module init time.
|
|
||||||
return t.router({
|
return t.router({
|
||||||
export: dsrProcedure
|
export: dsrProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -54,8 +98,10 @@ export function createDsrRouter(binding: DsrBinding) {
|
|||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const res = await createExportHandler(binding.dataExport)(input);
|
assertSubjectScope(userFromCtx(ctx), input.subjectId);
|
||||||
|
const b = bindingFromCtx(ctx, binding);
|
||||||
|
const res = await createExportHandler(b.dataExport)(input);
|
||||||
return res.body;
|
return res.body;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -69,16 +115,16 @@ export function createDsrRouter(binding: DsrBinding) {
|
|||||||
.strict(),
|
.strict(),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
if (input.mode === "cascade-hard") {
|
const user = userFromCtx(ctx);
|
||||||
const user = (ctx as { user: DsrTrpcUser }).user;
|
assertSubjectScope(user, input.subjectId);
|
||||||
if (!user.roles?.includes("admin")) {
|
if (input.mode === "cascade-hard" && !user.roles?.includes("admin")) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "Admin role required for cascade-hard deletion",
|
message: "Admin role required for cascade-hard deletion",
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const res = await createDeleteHandler(binding.dataDelete)(input);
|
const b = bindingFromCtx(ctx, binding);
|
||||||
|
const res = await createDeleteHandler(b.dataDelete)(input);
|
||||||
return res.body;
|
return res.body;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -93,9 +139,11 @@ export function createDsrRouter(binding: DsrBinding) {
|
|||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
assertSubjectScope(userFromCtx(ctx), input.subjectId);
|
||||||
|
const b = bindingFromCtx(ctx, binding);
|
||||||
// tRPC infers z.unknown() as value?: unknown; cast to assert presence
|
// tRPC infers z.unknown() as value?: unknown; cast to assert presence
|
||||||
const res = await createRectifyHandler(binding.dataRectify)(
|
const res = await createRectifyHandler(b.dataRectify)(
|
||||||
input as RectifyHandlerInput,
|
input as RectifyHandlerInput,
|
||||||
);
|
);
|
||||||
return res.body;
|
return res.body;
|
||||||
@@ -110,30 +158,21 @@ export function createDsrRouter(binding: DsrBinding) {
|
|||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const res = await createRestrictHandler(binding.processingRestriction)(
|
assertSubjectScope(userFromCtx(ctx), input.subjectId);
|
||||||
input,
|
const b = bindingFromCtx(ctx, binding);
|
||||||
);
|
const res = await createRestrictHandler(b.processingRestriction)(input);
|
||||||
return res.body;
|
return res.body;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience singleton for projects with a single DSR binding instance.
|
* Router singleton mounted by the app router. It has no creation-time
|
||||||
* Most callers should use `createDsrRouter(binding)` and pass the binding
|
* binding: every procedure resolves `ctx.dsrBinding`, which the app's
|
||||||
* explicitly. This export exists for type inference (`DsrRouter`) only.
|
* `createContext` supplies per request (A11). Calls without a context
|
||||||
|
* binding fail with INTERNAL_SERVER_ERROR at request time.
|
||||||
*/
|
*/
|
||||||
export const dsrRouter = createDsrRouter(
|
export const dsrRouter = createDsrRouter();
|
||||||
new Proxy({} as DsrBinding, {
|
|
||||||
get(_target, prop) {
|
|
||||||
if (prop === "then") return undefined; // not a Promise
|
|
||||||
throw new Error(
|
|
||||||
`dsrRouter singleton used without providing a DsrBinding. ` +
|
|
||||||
`Use createDsrRouter(binding) instead.`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export type DsrRouter = ReturnType<typeof createDsrRouter>;
|
export type DsrRouter = ReturnType<typeof createDsrRouter>;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ export type { DsrBinding, BindProductionDsrOpts } from "./di/bind-production";
|
|||||||
export { bindDevSeedDsr } from "./di/bind-dev-seed";
|
export { bindDevSeedDsr } from "./di/bind-dev-seed";
|
||||||
|
|
||||||
export { createDsrRouter, dsrRouter } from "./dsr.router";
|
export { createDsrRouter, dsrRouter } from "./dsr.router";
|
||||||
export type { DsrRouter, DsrTrpcUser } from "./dsr.router";
|
export type { DsrRouter, DsrTrpcUser, DsrRouterContext } from "./dsr.router";
|
||||||
|
|
||||||
export type { HandlerResponse } from "./handlers/handler-types";
|
export type { HandlerResponse } from "./handlers/handler-types";
|
||||||
export { createExportHandler } from "./handlers/export-handler";
|
export { createExportHandler } from "./handlers/export-handler";
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { getPayload as _getPayload } from "payload";
|
import { getPayload as _getPayload } from "payload";
|
||||||
import type { SanitizedConfig } from "payload";
|
import type { SanitizedConfig } from "payload";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID, createHmac } from "node:crypto";
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
import type { AuditLogProtocol } from "@repo/core-shared/di";
|
import type { AuditLogProtocol } from "@repo/core-shared/di";
|
||||||
|
import {
|
||||||
|
RETENTION_TOMBSTONE_FIELD,
|
||||||
|
hasPostDeletionPolicy,
|
||||||
|
} from "@repo/core-shared/payload";
|
||||||
import type { IDataDelete } from "./data-delete.interface";
|
import type { IDataDelete } from "./data-delete.interface";
|
||||||
import type {
|
import type {
|
||||||
DeletionMode,
|
DeletionMode,
|
||||||
@@ -35,6 +38,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 } }
|
||||||
@@ -108,6 +120,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(
|
||||||
@@ -142,6 +155,7 @@ export class PayloadDataDelete implements IDataDelete {
|
|||||||
subjectId,
|
subjectId,
|
||||||
correlationId,
|
correlationId,
|
||||||
affected,
|
affected,
|
||||||
|
hasPostDeletionPolicy(collection),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await this.processReferenceRows(
|
await this.processReferenceRows(
|
||||||
@@ -157,6 +171,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,
|
||||||
@@ -175,6 +197,7 @@ export class PayloadDataDelete implements IDataDelete {
|
|||||||
subjectId: string,
|
subjectId: string,
|
||||||
correlationId: string,
|
correlationId: string,
|
||||||
affected: DeletionAffected[],
|
affected: DeletionAffected[],
|
||||||
|
postDeletionPolicy: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const piiMeta = custom.pii ?? {};
|
const piiMeta = custom.pii ?? {};
|
||||||
const exportableFields = Object.entries(piiMeta)
|
const exportableFields = Object.entries(piiMeta)
|
||||||
@@ -183,10 +206,17 @@ export class PayloadDataDelete implements IDataDelete {
|
|||||||
|
|
||||||
if (mode === "soft") {
|
if (mode === "soft") {
|
||||||
const kind = custom.subject?.kind;
|
const kind = custom.subject?.kind;
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
const extraData: Record<string, unknown> =
|
const extraData: Record<string, unknown> =
|
||||||
kind === "self"
|
kind === "self" ? { processingRestrictedAt: nowIso } : {};
|
||||||
? { processingRestrictedAt: new Date().toISOString() }
|
if (postDeletionPolicy) {
|
||||||
: {};
|
// Soft-delete tombstone (A2): collections with a
|
||||||
|
// custom.retention.postDeletion policy get stamped so the retention
|
||||||
|
// purge job can hard-delete/pseudonymize them once the grace period
|
||||||
|
// elapses. Kept separate from processingRestrictedAt — an Art. 18
|
||||||
|
// restriction alone must never trigger the purge.
|
||||||
|
extraData[RETENTION_TOMBSTONE_FIELD] = nowIso;
|
||||||
|
}
|
||||||
await softRedactOwnerRows(
|
await softRedactOwnerRows(
|
||||||
payload,
|
payload,
|
||||||
slug,
|
slug,
|
||||||
@@ -290,9 +320,20 @@ export class PayloadDataDelete implements IDataDelete {
|
|||||||
correlationId: string,
|
correlationId: string,
|
||||||
affected: DeletionAffected[],
|
affected: DeletionAffected[],
|
||||||
): DeletionCertificate {
|
): DeletionCertificate {
|
||||||
|
// Salted, keyed pseudonym (audit finding A13): the certificate used to
|
||||||
|
// hash the raw subjectId with NO salt (truncated to 64 bits), letting a
|
||||||
|
// certificate holder brute-force small id spaces offline. Now
|
||||||
|
// HMAC-SHA256 keyed by the operator secret AUDIT_PSEUDONYM_SALT (the
|
||||||
|
// same secret the audit-log pseudonymizer uses), truncated to 128 bits.
|
||||||
|
// NOTE: this changes tokens on FUTURE certificates only — certificates
|
||||||
|
// issued under the old scheme keep their historical value, and rotating
|
||||||
|
// the key likewise affects only certificates issued afterwards.
|
||||||
|
const certKey =
|
||||||
|
process.env["AUDIT_PSEUDONYM_SALT"] ??
|
||||||
|
"dev-fallback-salt-replace-in-prod";
|
||||||
const certSubjectId =
|
const certSubjectId =
|
||||||
mode === "cascade-hard"
|
mode === "cascade-hard"
|
||||||
? `erased-${createHash("sha256").update(subjectId).digest("hex").slice(0, 16)}`
|
? `erased-${createHmac("sha256", certKey).update(subjectId).digest("hex").slice(0, 32)}`
|
||||||
: subjectId;
|
: subjectId;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getPayload as _getPayload } from "payload";
|
import { getPayload as _getPayload } from "payload";
|
||||||
import type { SanitizedConfig } from "payload";
|
import type { SanitizedConfig } from "payload";
|
||||||
import type { AuditLogProtocol } from "@repo/core-shared/di";
|
import type { AuditLogProtocol } from "@repo/core-shared/di";
|
||||||
|
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||||
import type { IDataExport } from "./data-export.interface";
|
import type { IDataExport } from "./data-export.interface";
|
||||||
import type {
|
import type {
|
||||||
DsrFormat,
|
DsrFormat,
|
||||||
@@ -42,6 +43,54 @@ type PayloadAPI = {
|
|||||||
|
|
||||||
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
|
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
|
||||||
|
|
||||||
|
const AUDIT_LOGS_SLUG = "audit-logs";
|
||||||
|
|
||||||
|
/** Reconstruct an AuditEntry from its flat audit-logs collection row. */
|
||||||
|
function docToAuditEntry(doc: PayloadDoc): AuditEntry {
|
||||||
|
const str = (v: unknown): string => (v == null ? "" : String(v));
|
||||||
|
const opt = (v: unknown): string | undefined =>
|
||||||
|
v == null ? undefined : String(v);
|
||||||
|
const entry: AuditEntry = {
|
||||||
|
actorId: str(doc["actorId"]),
|
||||||
|
actorType: (doc["actorType"] as AuditEntry["actorType"]) ?? "user",
|
||||||
|
actorRoles: Array.isArray(doc["actorRoles"])
|
||||||
|
? (doc["actorRoles"] as string[])
|
||||||
|
: [],
|
||||||
|
action: doc["action"] as AuditEntry["action"],
|
||||||
|
resource: {
|
||||||
|
type: str(doc["resourceType"]),
|
||||||
|
...(doc["resourceId"] != null ? { id: String(doc["resourceId"]) } : {}),
|
||||||
|
},
|
||||||
|
at: new Date(str(doc["createdAt"])),
|
||||||
|
scope: {
|
||||||
|
feature: str(doc["scopeFeature"]),
|
||||||
|
environment: str(doc["scopeEnvironment"]),
|
||||||
|
tenant: str(doc["scopeTenant"]),
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
ipTruncated: str(doc["ipTruncated"]),
|
||||||
|
userAgent: str(doc["userAgent"]),
|
||||||
|
},
|
||||||
|
containsPii: Boolean(doc["containsPii"]),
|
||||||
|
outcome: (doc["outcome"] as AuditEntry["outcome"]) ?? "success",
|
||||||
|
};
|
||||||
|
if (Array.isArray(doc["changedFields"])) {
|
||||||
|
entry.changedFields = doc["changedFields"] as string[];
|
||||||
|
}
|
||||||
|
if (Array.isArray(doc["piiCategories"])) {
|
||||||
|
entry.piiCategories = doc["piiCategories"] as string[];
|
||||||
|
}
|
||||||
|
const reason = opt(doc["reason"]);
|
||||||
|
if (reason) entry.reason = reason;
|
||||||
|
const correlationId = opt(doc["correlationId"]);
|
||||||
|
if (correlationId) entry.correlationId = correlationId;
|
||||||
|
const requestId = opt(doc["requestId"]);
|
||||||
|
if (requestId) entry.requestId = requestId;
|
||||||
|
const errorCode = opt(doc["errorCode"]);
|
||||||
|
if (errorCode) entry.errorCode = errorCode;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Payload-backed IDataExport. Walks all collections annotated with
|
* Payload-backed IDataExport. Walks all collections annotated with
|
||||||
* `custom.subject` linkage, segments rows by role (self/owner vs reference),
|
* `custom.subject` linkage, segments rows by role (self/owner vs reference),
|
||||||
@@ -112,6 +161,21 @@ export class PayloadDataExport implements IDataExport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GDPR Art. 15(1) includes the processing record: populate the subject's
|
||||||
|
// audit-log entries when the local audit sink is registered (audit
|
||||||
|
// finding A14). Scoped strictly to actorId === subjectId; absent
|
||||||
|
// collection (audit core not scaffolded) → field stays undefined.
|
||||||
|
let auditEntries: AuditEntry[] | undefined;
|
||||||
|
if (this.config.collections.some((c) => c.slug === AUDIT_LOGS_SLUG)) {
|
||||||
|
const result = await payload.find({
|
||||||
|
collection: AUDIT_LOGS_SLUG,
|
||||||
|
where: { actorId: { equals: subjectId } },
|
||||||
|
overrideAccess: true,
|
||||||
|
limit: 1000,
|
||||||
|
});
|
||||||
|
auditEntries = result.docs.map(docToAuditEntry);
|
||||||
|
}
|
||||||
|
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
actorId: subjectId,
|
actorId: subjectId,
|
||||||
actorType: "user",
|
actorType: "user",
|
||||||
@@ -136,6 +200,10 @@ export class PayloadDataExport implements IDataExport {
|
|||||||
data,
|
data,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (auditEntries) {
|
||||||
|
bundle.auditLog = auditEntries;
|
||||||
|
}
|
||||||
|
|
||||||
if (format === "json-ld") {
|
if (format === "json-ld") {
|
||||||
bundle["@context"] = USER_DATA_JSONLD_CONTEXT;
|
bundle["@context"] = USER_DATA_JSONLD_CONTEXT;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"./payload": "./src/payload/index.ts",
|
"./payload": "./src/payload/index.ts",
|
||||||
"./trpc/init": "./src/trpc/init.ts",
|
"./trpc/init": "./src/trpc/init.ts",
|
||||||
"./trpc/context": "./src/trpc/context.ts",
|
"./trpc/context": "./src/trpc/context.ts",
|
||||||
|
"./trpc/require-authenticated": "./src/trpc/require-authenticated.ts",
|
||||||
"./trpc/define-error-middleware": "./src/trpc/define-error-middleware.ts",
|
"./trpc/define-error-middleware": "./src/trpc/define-error-middleware.ts",
|
||||||
"./instrumentation": "./src/instrumentation/index.ts",
|
"./instrumentation": "./src/instrumentation/index.ts",
|
||||||
"./instrumentation/otel": "./src/instrumentation/otel/index.ts",
|
"./instrumentation/otel": "./src/instrumentation/otel/index.ts",
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ export {
|
|||||||
buildPurgeHandler,
|
buildPurgeHandler,
|
||||||
registerRetentionPurgeJobs,
|
registerRetentionPurgeJobs,
|
||||||
} from "./retention-purge/retention-purge.job";
|
} from "./retention-purge/retention-purge.job";
|
||||||
|
export {
|
||||||
|
RETENTION_TOMBSTONE_FIELD,
|
||||||
|
hasPostDeletionPolicy,
|
||||||
|
withRetentionTombstone,
|
||||||
|
} from "./retention-purge/tombstone";
|
||||||
|
export {
|
||||||
|
buildRetentionPurgeTask,
|
||||||
|
type RetentionPurgeTask,
|
||||||
|
} from "./retention-purge/task";
|
||||||
export type {
|
export type {
|
||||||
PayloadPurgeApi,
|
PayloadPurgeApi,
|
||||||
GetPayloadFn,
|
GetPayloadFn,
|
||||||
|
|||||||
@@ -377,6 +377,189 @@ describe("buildPurgeHandler — hard-delete", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- buildPurgeHandler — postDeletion grace purge (A2) ----
|
||||||
|
|
||||||
|
describe("buildPurgeHandler — postDeletion grace purge", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
function postDeletionOnlyConfig(
|
||||||
|
action: "hard-delete" | "pseudonymize",
|
||||||
|
fields: MockCollection["fields"] = [],
|
||||||
|
) {
|
||||||
|
return makeConfig([
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
retention: {
|
||||||
|
purgeSchedule: "daily",
|
||||||
|
postDeletion: {
|
||||||
|
action,
|
||||||
|
duration: "P30D",
|
||||||
|
trigger: "after-deletion",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("queries soft-deleted rows by the deletedAt tombstone cutoff", async () => {
|
||||||
|
const { queue } = makeQueue();
|
||||||
|
const payload = makePayloadApi([]);
|
||||||
|
const deps: RetentionPurgeJobDeps = {
|
||||||
|
queue,
|
||||||
|
config: postDeletionOnlyConfig("hard-delete"),
|
||||||
|
getPayload: vi.fn().mockResolvedValue(payload),
|
||||||
|
};
|
||||||
|
await buildPurgeHandler("users", deps)();
|
||||||
|
|
||||||
|
expect(payload.find).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
collection: "users",
|
||||||
|
where: {
|
||||||
|
deletedAt: {
|
||||||
|
less_than: new Date(
|
||||||
|
Date.now() - parseDurationMs("P30D"),
|
||||||
|
).toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hard-deletes rows whose tombstone is past the grace period (postDeletion-only collection)", async () => {
|
||||||
|
const { queue } = makeQueue();
|
||||||
|
const payload = makePayloadApi([
|
||||||
|
{ id: "row-old", deletedAt: "2025-11-01T00:00:00.000Z" }, // 61 days
|
||||||
|
{ id: "row-fresh", deletedAt: "2025-12-25T00:00:00.000Z" }, // 7 days
|
||||||
|
{ id: "row-live" }, // never soft-deleted
|
||||||
|
]);
|
||||||
|
const deps: RetentionPurgeJobDeps = {
|
||||||
|
queue,
|
||||||
|
config: postDeletionOnlyConfig("hard-delete"),
|
||||||
|
getPayload: vi.fn().mockResolvedValue(payload),
|
||||||
|
};
|
||||||
|
await buildPurgeHandler("users", deps)();
|
||||||
|
|
||||||
|
expect(payload.delete).toHaveBeenCalledTimes(1);
|
||||||
|
expect(payload.delete).toHaveBeenCalledWith({
|
||||||
|
collection: "users",
|
||||||
|
id: "row-old",
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pseudonymizes PII fields when postDeletion.action is pseudonymize", async () => {
|
||||||
|
const { queue } = makeQueue();
|
||||||
|
const payload = makePayloadApi([
|
||||||
|
{ id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" },
|
||||||
|
]);
|
||||||
|
const deps: RetentionPurgeJobDeps = {
|
||||||
|
queue,
|
||||||
|
config: postDeletionOnlyConfig("pseudonymize", [
|
||||||
|
{ name: "email", custom: { pii: { category: "contact-email" } } },
|
||||||
|
{ name: "status" },
|
||||||
|
]),
|
||||||
|
getPayload: vi.fn().mockResolvedValue(payload),
|
||||||
|
};
|
||||||
|
await buildPurgeHandler("users", deps)();
|
||||||
|
|
||||||
|
expect(payload.update).toHaveBeenCalledWith({
|
||||||
|
collection: "users",
|
||||||
|
id: "row-old",
|
||||||
|
data: { email: null },
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
expect(payload.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a retention-policy audit entry per purged row", async () => {
|
||||||
|
const { queue } = makeQueue();
|
||||||
|
const { auditLog, record } = makeAuditLog();
|
||||||
|
const payload = makePayloadApi([
|
||||||
|
{ id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" },
|
||||||
|
]);
|
||||||
|
const deps: RetentionPurgeJobDeps = {
|
||||||
|
queue,
|
||||||
|
config: postDeletionOnlyConfig("hard-delete"),
|
||||||
|
getPayload: vi.fn().mockResolvedValue(payload),
|
||||||
|
auditLog,
|
||||||
|
};
|
||||||
|
await buildPurgeHandler("users", deps)();
|
||||||
|
|
||||||
|
expect(record).toHaveBeenCalledTimes(1);
|
||||||
|
expect(record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "DELETE",
|
||||||
|
reason: "retention-policy",
|
||||||
|
resource: { type: "users", id: "row-old" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still runs activeRetention alongside postDeletion", async () => {
|
||||||
|
const { queue } = makeQueue();
|
||||||
|
const payload = makePayloadApi([
|
||||||
|
{ id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" },
|
||||||
|
]);
|
||||||
|
const config = makeConfig([
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
retention: {
|
||||||
|
purgeSchedule: "daily",
|
||||||
|
activeRetention: { duration: "P2Y", trigger: "from-creation" },
|
||||||
|
postDeletion: {
|
||||||
|
action: "hard-delete",
|
||||||
|
duration: "P30D",
|
||||||
|
trigger: "after-deletion",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const deps: RetentionPurgeJobDeps = {
|
||||||
|
queue,
|
||||||
|
config,
|
||||||
|
getPayload: vi.fn().mockResolvedValue(payload),
|
||||||
|
};
|
||||||
|
await buildPurgeHandler("users", deps)();
|
||||||
|
|
||||||
|
// one find per branch: createdAt (activeRetention) + deletedAt (postDeletion)
|
||||||
|
expect(payload.find).toHaveBeenCalledTimes(2);
|
||||||
|
// the fake returns the tombstoned row for both branches: the
|
||||||
|
// activeRetention branch deletes it by date, the postDeletion branch by
|
||||||
|
// tombstone — 2 delete calls for the same doc through different policies.
|
||||||
|
expect(payload.delete).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-enqueues the next cycle for postDeletion-only collections", async () => {
|
||||||
|
const { queue, enqueue } = makeQueue();
|
||||||
|
const payload = makePayloadApi([]);
|
||||||
|
const deps: RetentionPurgeJobDeps = {
|
||||||
|
queue,
|
||||||
|
config: postDeletionOnlyConfig("hard-delete"),
|
||||||
|
getPayload: vi.fn().mockResolvedValue(payload),
|
||||||
|
};
|
||||||
|
await buildPurgeHandler("users", deps)();
|
||||||
|
|
||||||
|
expect(enqueue).toHaveBeenCalledWith(
|
||||||
|
"retention-purge--users",
|
||||||
|
{},
|
||||||
|
{ runAt: new Date("2026-01-02T00:00:00.000Z") },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---- buildPurgeHandler — pseudonymize branch ----
|
// ---- buildPurgeHandler — pseudonymize branch ----
|
||||||
|
|
||||||
describe("buildPurgeHandler — pseudonymize", () => {
|
describe("buildPurgeHandler — pseudonymize", () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { SanitizedConfig } from "payload";
|
import type { SanitizedConfig } from "payload";
|
||||||
import type { IJobQueue } from "../../jobs/job-queue.interface";
|
import type { IJobQueue } from "../../jobs/job-queue.interface";
|
||||||
import type { AuditLogProtocol } from "../../di/bind-protocols";
|
import type { AuditLogProtocol } from "../../di/bind-protocols";
|
||||||
|
import { RETENTION_TOMBSTONE_FIELD } from "./tombstone";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal Payload API surface needed by the retention purge job.
|
* Minimal Payload API surface needed by the retention purge job.
|
||||||
@@ -70,13 +71,19 @@ export function scheduleDelayMs(schedule: string): number {
|
|||||||
return MS_PER_DAY; // "daily" and cron fallback
|
return MS_PER_DAY; // "daily" and cron fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RetentionActionName = "pseudonymize" | "hard-delete";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the purge handler for a single collection. The returned async function
|
* Build the purge handler for a single collection. The returned async function
|
||||||
* is intended to be registered as a Payload job task handler.
|
* is intended to be registered as a Payload job task handler.
|
||||||
*
|
*
|
||||||
* Per run:
|
* Per run:
|
||||||
* 1. Query rows past their activeRetention period.
|
* 1. activeRetention (when declared): query rows past the active period
|
||||||
* 2. Apply postDeletion.action (pseudonymize | hard-delete).
|
* (createdAt/updatedAt) and apply the retention action.
|
||||||
|
* 2. postDeletion (when declared, audit finding A2): query soft-deleted rows
|
||||||
|
* — tombstoned with `deletedAt` by the DSR soft-delete path — whose
|
||||||
|
* tombstone is older than postDeletion.duration, and apply
|
||||||
|
* postDeletion.action (pseudonymize | hard-delete).
|
||||||
* 3. Emit one audit entry per processed row (skipped when auditLog is absent).
|
* 3. Emit one audit entry per processed row (skipped when auditLog is absent).
|
||||||
* 4. Re-enqueue itself for the next purge cycle.
|
* 4. Re-enqueue itself for the next purge cycle.
|
||||||
*
|
*
|
||||||
@@ -101,8 +108,64 @@ export function buildPurgeHandler(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-bind after the guard: narrowing does not flow into hoisted closures.
|
||||||
|
const targetCollection = collection;
|
||||||
|
|
||||||
const taskSlug = `retention-purge--${collectionSlug}`;
|
const taskSlug = `retention-purge--${collectionSlug}`;
|
||||||
|
|
||||||
|
async function applyAction(
|
||||||
|
payload: PayloadPurgeApi,
|
||||||
|
doc: Record<string, unknown>,
|
||||||
|
action: RetentionActionName,
|
||||||
|
reason: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const id = doc["id"] as string | number;
|
||||||
|
|
||||||
|
if (action === "pseudonymize") {
|
||||||
|
const piiFields: Record<string, null> = {};
|
||||||
|
for (const field of targetCollection.fields) {
|
||||||
|
const f = field as { name?: string; custom?: { pii?: unknown } };
|
||||||
|
if (f.name && f.custom?.pii) {
|
||||||
|
piiFields[f.name] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(piiFields).length > 0) {
|
||||||
|
await payload.update({
|
||||||
|
collection: collectionSlug,
|
||||||
|
id,
|
||||||
|
data: piiFields,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await payload.delete({
|
||||||
|
collection: collectionSlug,
|
||||||
|
id,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auditLog) {
|
||||||
|
await auditLog.record({
|
||||||
|
actorId: "system",
|
||||||
|
actorType: "system",
|
||||||
|
actorRoles: [],
|
||||||
|
action: "DELETE",
|
||||||
|
resource: { type: collectionSlug, id: String(id) },
|
||||||
|
at: new Date(),
|
||||||
|
scope: {
|
||||||
|
feature: "core-shared",
|
||||||
|
environment: process.env["NODE_ENV"] ?? "production",
|
||||||
|
tenant: "default",
|
||||||
|
},
|
||||||
|
reason,
|
||||||
|
from: { ipTruncated: "system", userAgent: "background-job" },
|
||||||
|
containsPii: false,
|
||||||
|
outcome: "success",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return async () => {
|
return async () => {
|
||||||
const payload = await getPayload({ config });
|
const payload = await getPayload({ config });
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -123,51 +186,37 @@ export function buildPurgeHandler(
|
|||||||
const action = retention.postDeletion?.action ?? "hard-delete";
|
const action = retention.postDeletion?.action ?? "hard-delete";
|
||||||
|
|
||||||
for (const doc of docs) {
|
for (const doc of docs) {
|
||||||
const id = doc["id"] as string | number;
|
await applyAction(payload, doc, action, "retention-policy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (action === "pseudonymize") {
|
if (retention.postDeletion) {
|
||||||
const piiFields: Record<string, null> = {};
|
// Grace-period purge of soft-deleted rows (A2): the DSR soft delete
|
||||||
for (const field of collection.fields) {
|
// stamps RETENTION_TOMBSTONE_FIELD; once the grace period elapses the
|
||||||
const f = field as { name?: string; custom?: { pii?: unknown } };
|
// declared action runs. Rows are re-checked client-side so a fake or
|
||||||
if (f.name && f.custom?.pii) {
|
// permissive backend can never purge an un-tombstoned/unexpired row.
|
||||||
piiFields[f.name] = null;
|
const { duration, action } = retention.postDeletion;
|
||||||
}
|
const cutoffMs = now - parseDurationMs(duration);
|
||||||
}
|
const cutoff = new Date(cutoffMs).toISOString();
|
||||||
if (Object.keys(piiFields).length > 0) {
|
|
||||||
await payload.update({
|
|
||||||
collection: collectionSlug,
|
|
||||||
id,
|
|
||||||
data: piiFields,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await payload.delete({
|
|
||||||
collection: collectionSlug,
|
|
||||||
id,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (auditLog) {
|
const { docs } = await payload.find({
|
||||||
await auditLog.record({
|
collection: collectionSlug,
|
||||||
actorId: "system",
|
where: { [RETENTION_TOMBSTONE_FIELD]: { less_than: cutoff } },
|
||||||
actorType: "system",
|
limit: 1000,
|
||||||
actorRoles: [],
|
overrideAccess: true,
|
||||||
action: "DELETE",
|
});
|
||||||
resource: { type: collectionSlug, id: String(id) },
|
|
||||||
at: new Date(),
|
const expired = docs.filter((doc) => {
|
||||||
scope: {
|
const tombstone = doc[RETENTION_TOMBSTONE_FIELD];
|
||||||
feature: "core-shared",
|
if (typeof tombstone !== "string" || tombstone.length === 0) {
|
||||||
environment: process.env["NODE_ENV"] ?? "production",
|
return false;
|
||||||
tenant: "default",
|
|
||||||
},
|
|
||||||
reason: "retention-policy",
|
|
||||||
from: { ipTruncated: "system", userAgent: "background-job" },
|
|
||||||
containsPii: false,
|
|
||||||
outcome: "success",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
const tombstoneMs = Date.parse(tombstone);
|
||||||
|
return Number.isFinite(tombstoneMs) && tombstoneMs < cutoffMs;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const doc of expired) {
|
||||||
|
await applyAction(payload, doc, action, "retention-policy");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +238,7 @@ export async function registerRetentionPurgeJobs(
|
|||||||
const { queue, config } = deps;
|
const { queue, config } = deps;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
for (const collection of config.collections) {
|
for (const collection of config.collections ?? []) {
|
||||||
const retention = collection.custom?.retention;
|
const retention = collection.custom?.retention;
|
||||||
if (!retention?.purgeSchedule) continue;
|
if (!retention?.purgeSchedule) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import type { Payload } from "payload";
|
||||||
|
import { buildRetentionPurgeTask } from "@/payload/retention-purge/task";
|
||||||
|
|
||||||
|
function makeFakePayload() {
|
||||||
|
const find = vi.fn().mockResolvedValue({ docs: [] });
|
||||||
|
const jobsQueue = vi.fn().mockResolvedValue({ id: "job-1" });
|
||||||
|
const payload = {
|
||||||
|
config: {
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
custom: {
|
||||||
|
retention: {
|
||||||
|
purgeSchedule: "daily",
|
||||||
|
postDeletion: {
|
||||||
|
duration: "P30D",
|
||||||
|
trigger: "after-deletion",
|
||||||
|
action: "hard-delete",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
find,
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
jobs: { queue: jobsQueue },
|
||||||
|
} as unknown as Payload;
|
||||||
|
return { payload, find, jobsQueue };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildRetentionPurgeTask (A3)", () => {
|
||||||
|
it("uses the retention-purge--<slug> task slug", () => {
|
||||||
|
expect(buildRetentionPurgeTask("users").slug).toBe(
|
||||||
|
"retention-purge--users",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs the purge against req.payload and re-enqueues the next cycle", async () => {
|
||||||
|
const { payload, find, jobsQueue } = makeFakePayload();
|
||||||
|
const task = buildRetentionPurgeTask("users");
|
||||||
|
|
||||||
|
const result = await task.handler({ req: { payload } });
|
||||||
|
|
||||||
|
expect(result).toEqual({ output: {} });
|
||||||
|
// postDeletion branch queried the tombstone field
|
||||||
|
expect(find).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
collection: "users",
|
||||||
|
where: { deletedAt: expect.anything() },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// self-re-enqueue went through the payload job queue
|
||||||
|
expect(jobsQueue).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ task: "retention-purge--users" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
42
packages/core-shared/src/payload/retention-purge/task.ts
Normal file
42
packages/core-shared/src/payload/retention-purge/task.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Payload } from "payload";
|
||||||
|
import { PayloadJobQueue } from "../../jobs/payload-job-queue";
|
||||||
|
import { buildPurgeHandler, type PayloadPurgeApi } from "./retention-purge.job";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal shape of a Payload job-task definition — enough for
|
||||||
|
* `payload.config.ts` `jobs.tasks` composition without dragging the full
|
||||||
|
* generated TaskConfig generics through core-shared.
|
||||||
|
*/
|
||||||
|
export type RetentionPurgeTask = {
|
||||||
|
slug: string;
|
||||||
|
handler: (args: { req: { payload: Payload } }) => Promise<{
|
||||||
|
output: Record<string, never>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the Payload job-task definition for one collection's retention purge
|
||||||
|
* (audit finding A3): `registerRetentionPurgeJobs` enqueues
|
||||||
|
* `retention-purge--<slug>` tasks at boot, and this definition is what makes
|
||||||
|
* Payload able to RUN them. Everything the handler needs comes from the
|
||||||
|
* running instance on `req.payload` (config, local API, job queue for the
|
||||||
|
* self-re-enqueue), so the task can be declared at config-composition time
|
||||||
|
* with no bootstrapping order problems.
|
||||||
|
*/
|
||||||
|
export function buildRetentionPurgeTask(
|
||||||
|
collectionSlug: string,
|
||||||
|
): RetentionPurgeTask {
|
||||||
|
return {
|
||||||
|
slug: `retention-purge--${collectionSlug}`,
|
||||||
|
handler: async ({ req }) => {
|
||||||
|
const payload = req.payload;
|
||||||
|
const run = buildPurgeHandler(collectionSlug, {
|
||||||
|
queue: new PayloadJobQueue(payload),
|
||||||
|
config: payload.config,
|
||||||
|
getPayload: async () => payload as unknown as PayloadPurgeApi,
|
||||||
|
});
|
||||||
|
await run();
|
||||||
|
return { output: {} };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { CollectionConfig } from "payload";
|
||||||
|
import {
|
||||||
|
RETENTION_TOMBSTONE_FIELD,
|
||||||
|
hasPostDeletionPolicy,
|
||||||
|
withRetentionTombstone,
|
||||||
|
} from "@/payload/retention-purge/tombstone";
|
||||||
|
|
||||||
|
function makeCollection(custom?: Record<string, unknown>): CollectionConfig {
|
||||||
|
return {
|
||||||
|
slug: "things",
|
||||||
|
custom,
|
||||||
|
fields: [{ name: "title", type: "text" }],
|
||||||
|
} as CollectionConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const postDeletionRetention = {
|
||||||
|
retention: {
|
||||||
|
purgeSchedule: "daily",
|
||||||
|
postDeletion: {
|
||||||
|
duration: "P30D",
|
||||||
|
trigger: "after-deletion",
|
||||||
|
action: "hard-delete",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("hasPostDeletionPolicy", () => {
|
||||||
|
it("is true when custom.retention.postDeletion is declared", () => {
|
||||||
|
expect(hasPostDeletionPolicy(makeCollection(postDeletionRetention))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false without retention or postDeletion", () => {
|
||||||
|
expect(hasPostDeletionPolicy(makeCollection())).toBe(false);
|
||||||
|
expect(
|
||||||
|
hasPostDeletionPolicy(
|
||||||
|
makeCollection({ retention: { purgeSchedule: "daily" } }),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withRetentionTombstone", () => {
|
||||||
|
it("appends the deletedAt field to postDeletion collections", () => {
|
||||||
|
const result = withRetentionTombstone(
|
||||||
|
makeCollection(postDeletionRetention),
|
||||||
|
);
|
||||||
|
const names = result.fields.map((f) => (f as { name?: string }).name);
|
||||||
|
expect(names).toContain(RETENTION_TOMBSTONE_FIELD);
|
||||||
|
const tombstone = result.fields.find(
|
||||||
|
(f) => (f as { name?: string }).name === RETENTION_TOMBSTONE_FIELD,
|
||||||
|
) as { type?: string; index?: boolean };
|
||||||
|
expect(tombstone.type).toBe("date");
|
||||||
|
expect(tombstone.index).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns collections without a postDeletion policy unchanged", () => {
|
||||||
|
const collection = makeCollection();
|
||||||
|
expect(withRetentionTombstone(collection)).toBe(collection);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate an already-declared tombstone field", () => {
|
||||||
|
const collection = {
|
||||||
|
...makeCollection(postDeletionRetention),
|
||||||
|
fields: [{ name: RETENTION_TOMBSTONE_FIELD, type: "date" }],
|
||||||
|
} as CollectionConfig;
|
||||||
|
const result = withRetentionTombstone(collection);
|
||||||
|
expect(
|
||||||
|
result.fields.filter(
|
||||||
|
(f) => (f as { name?: string }).name === RETENTION_TOMBSTONE_FIELD,
|
||||||
|
),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate the input collection", () => {
|
||||||
|
const collection = makeCollection(postDeletionRetention);
|
||||||
|
const before = collection.fields.length;
|
||||||
|
withRetentionTombstone(collection);
|
||||||
|
expect(collection.fields.length).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { CollectionConfig, Field } from "payload";
|
||||||
|
import type { CollectionRetention } from "../retention-types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field name marking a row as soft-deleted.
|
||||||
|
*
|
||||||
|
* Written by the DSR soft-delete path (`PayloadDataDelete`, mode "soft") on
|
||||||
|
* rows the subject owns, and read by the retention purge job's
|
||||||
|
* `postDeletion` branch: rows whose tombstone is older than
|
||||||
|
* `postDeletion.duration` are purged with the declared action
|
||||||
|
* (hard-delete | pseudonymize).
|
||||||
|
*
|
||||||
|
* Deliberately distinct from `processingRestrictedAt` — an Art. 18
|
||||||
|
* processing restriction is NOT a deletion request and must never feed the
|
||||||
|
* grace-period purge.
|
||||||
|
*/
|
||||||
|
export const RETENTION_TOMBSTONE_FIELD = "deletedAt";
|
||||||
|
|
||||||
|
function collectionRetention(
|
||||||
|
collection: CollectionConfig,
|
||||||
|
): CollectionRetention | undefined {
|
||||||
|
return (collection.custom as { retention?: CollectionRetention } | undefined)
|
||||||
|
?.retention;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the collection declares a postDeletion grace-purge policy. */
|
||||||
|
export function hasPostDeletionPolicy(collection: {
|
||||||
|
custom?: Record<string, unknown>;
|
||||||
|
}): boolean {
|
||||||
|
return Boolean(
|
||||||
|
(collection.custom as { retention?: CollectionRetention } | undefined)
|
||||||
|
?.retention?.postDeletion,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a collection that declares `custom.retention.postDeletion` carries
|
||||||
|
* the soft-delete tombstone field so (a) the DSR soft delete can stamp it and
|
||||||
|
* (b) the purge job can query it. Collections without a postDeletion policy
|
||||||
|
* (or that already define the field) are returned unchanged.
|
||||||
|
*
|
||||||
|
* Apply at config-composition time (core-cms payload.config.ts).
|
||||||
|
*/
|
||||||
|
export function withRetentionTombstone(
|
||||||
|
collection: CollectionConfig,
|
||||||
|
): CollectionConfig {
|
||||||
|
if (!collectionRetention(collection)?.postDeletion) return collection;
|
||||||
|
|
||||||
|
const alreadyDeclared = collection.fields.some(
|
||||||
|
(f) => (f as { name?: string }).name === RETENTION_TOMBSTONE_FIELD,
|
||||||
|
);
|
||||||
|
if (alreadyDeclared) return collection;
|
||||||
|
|
||||||
|
const tombstoneField: Field = {
|
||||||
|
name: RETENTION_TOMBSTONE_FIELD,
|
||||||
|
type: "date",
|
||||||
|
index: true,
|
||||||
|
admin: {
|
||||||
|
hidden: true,
|
||||||
|
description:
|
||||||
|
"Soft-delete tombstone (DSR Art. 17). Rows older than the " +
|
||||||
|
"postDeletion grace period are purged by the retention job.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...collection,
|
||||||
|
fields: [...collection.fields, tombstoneField],
|
||||||
|
};
|
||||||
|
}
|
||||||
89
packages/core-shared/src/trpc/context.test.ts
Normal file
89
packages/core-shared/src/trpc/context.test.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { clientIpFromHeaders, createTrpcContext } from "@/trpc/context";
|
||||||
|
|
||||||
|
describe("clientIpFromHeaders", () => {
|
||||||
|
it("takes the first x-forwarded-for hop", () => {
|
||||||
|
const headers = new Headers({
|
||||||
|
"x-forwarded-for": "203.0.113.7, 10.0.0.1, 10.0.0.2",
|
||||||
|
});
|
||||||
|
expect(clientIpFromHeaders(headers)).toBe("203.0.113.7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims whitespace around the first hop", () => {
|
||||||
|
const headers = new Headers({
|
||||||
|
"x-forwarded-for": " 203.0.113.7 , 10.0.0.1",
|
||||||
|
});
|
||||||
|
expect(clientIpFromHeaders(headers)).toBe("203.0.113.7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to x-real-ip when x-forwarded-for is absent", () => {
|
||||||
|
const headers = new Headers({ "x-real-ip": "198.51.100.4" });
|
||||||
|
expect(clientIpFromHeaders(headers)).toBe("198.51.100.4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when neither header is present", () => {
|
||||||
|
expect(clientIpFromHeaders(new Headers())).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for empty header values", () => {
|
||||||
|
const headers = new Headers({ "x-forwarded-for": " ", "x-real-ip": "" });
|
||||||
|
expect(clientIpFromHeaders(headers)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createTrpcContext", () => {
|
||||||
|
it("attaches the derived clientIp from the request", async () => {
|
||||||
|
const req = new Request("https://example.test/api/trpc", {
|
||||||
|
headers: { "x-forwarded-for": "203.0.113.7" },
|
||||||
|
});
|
||||||
|
await expect(createTrpcContext(req)).resolves.toEqual({
|
||||||
|
clientIp: "203.0.113.7",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("yields an undefined clientIp without a request", async () => {
|
||||||
|
await expect(createTrpcContext()).resolves.toEqual({
|
||||||
|
clientIp: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches the resolved user and mirrors userId (A11)", async () => {
|
||||||
|
const req = new Request("https://example.test/api/trpc");
|
||||||
|
const ctx = await createTrpcContext(req, {
|
||||||
|
resolveUser: async () => ({ id: "user-1", roles: ["admin"] }),
|
||||||
|
});
|
||||||
|
expect(ctx.user).toEqual({ id: "user-1", roles: ["admin"] });
|
||||||
|
expect(ctx.userId).toBe("user-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a null resolver result as anonymous", async () => {
|
||||||
|
const req = new Request("https://example.test/api/trpc");
|
||||||
|
const ctx = await createTrpcContext(req, {
|
||||||
|
resolveUser: async () => null,
|
||||||
|
});
|
||||||
|
expect(ctx.user).toBeUndefined();
|
||||||
|
expect(ctx.userId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a throwing resolver as anonymous instead of failing", async () => {
|
||||||
|
const req = new Request("https://example.test/api/trpc");
|
||||||
|
const ctx = await createTrpcContext(req, {
|
||||||
|
resolveUser: async () => {
|
||||||
|
throw new Error("expired session");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(ctx.user).toBeUndefined();
|
||||||
|
expect(ctx.clientIp).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not invoke the resolver without a request", async () => {
|
||||||
|
let called = false;
|
||||||
|
await createTrpcContext(undefined, {
|
||||||
|
resolveUser: async () => {
|
||||||
|
called = true;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(called).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,69 @@
|
|||||||
export async function createTrpcContext() {
|
/**
|
||||||
return {};
|
* Derive the client IP from reverse-proxy headers.
|
||||||
|
*
|
||||||
|
* TRUST CAVEAT (audit finding B2): `x-forwarded-for` and `x-real-ip` are
|
||||||
|
* ordinary request headers. They are only trustworthy when the app runs
|
||||||
|
* behind a proxy/load balancer that overwrites (or verifiably appends to)
|
||||||
|
* them on every request. Exposed directly to the internet, a client can
|
||||||
|
* spoof them; deployments that need a hard guarantee must read the socket
|
||||||
|
* address at their edge and strip inbound copies of these headers.
|
||||||
|
*
|
||||||
|
* We take the FIRST `x-forwarded-for` entry — the client as reported by the
|
||||||
|
* first (trusted) hop — falling back to `x-real-ip`.
|
||||||
|
*/
|
||||||
|
export function clientIpFromHeaders(headers: Headers): string | undefined {
|
||||||
|
const forwarded = headers.get("x-forwarded-for");
|
||||||
|
const firstHop = forwarded?.split(",")[0]?.trim();
|
||||||
|
if (firstHop) return firstHop;
|
||||||
|
const realIp = headers.get("x-real-ip")?.trim();
|
||||||
|
return realIp || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-resolved authenticated user attached to the tRPC context.
|
||||||
|
* Resolved from the app's session mechanism (never from client input);
|
||||||
|
* `roles` is a snapshot for role-gated procedures (admin checks).
|
||||||
|
*/
|
||||||
|
export type TrpcSessionUser = {
|
||||||
|
id: string;
|
||||||
|
roles: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateTrpcContextOpts = {
|
||||||
|
/**
|
||||||
|
* App-provided session resolver (audit finding A11). Receives the incoming
|
||||||
|
* request and returns the authenticated user, or null/undefined for
|
||||||
|
* anonymous callers. A throwing resolver is treated as anonymous — an
|
||||||
|
* expired or malformed session cookie must not 500 public queries;
|
||||||
|
* procedures that need a user reject with UNAUTHORIZED instead.
|
||||||
|
*/
|
||||||
|
resolveUser?: (req: Request) => Promise<TrpcSessionUser | null | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the per-request tRPC context. Pass the adapter's incoming fetch
|
||||||
|
* `Request` so server-derived fields (`clientIp`, and — when the app supplies
|
||||||
|
* a `resolveUser` — the authenticated `user`) are attached. Procedures must
|
||||||
|
* never trust client-supplied equivalents (B2).
|
||||||
|
*/
|
||||||
|
export async function createTrpcContext(
|
||||||
|
req?: Request,
|
||||||
|
opts: CreateTrpcContextOpts = {},
|
||||||
|
) {
|
||||||
|
let user: TrpcSessionUser | undefined;
|
||||||
|
if (req && opts.resolveUser) {
|
||||||
|
try {
|
||||||
|
user = (await opts.resolveUser(req)) ?? undefined;
|
||||||
|
} catch {
|
||||||
|
user = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
clientIp: req ? clientIpFromHeaders(req.headers) : undefined,
|
||||||
|
user,
|
||||||
|
/** Convenience mirror of `user.id` (consumed by the consent router). */
|
||||||
|
userId: user?.id,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TrpcContext = Awaited<ReturnType<typeof createTrpcContext>>;
|
export type TrpcContext = Awaited<ReturnType<typeof createTrpcContext>>;
|
||||||
|
|||||||
50
packages/core-shared/src/trpc/require-authenticated.test.ts
Normal file
50
packages/core-shared/src/trpc/require-authenticated.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { t } from "@/trpc/init";
|
||||||
|
import {
|
||||||
|
requireAuthenticated,
|
||||||
|
protectedProcedure,
|
||||||
|
} from "@/trpc/require-authenticated";
|
||||||
|
|
||||||
|
const echoRouter = t.router({
|
||||||
|
publicEcho: t.procedure
|
||||||
|
.input(z.object({ value: z.string() }).strict())
|
||||||
|
.query(({ input }) => input.value),
|
||||||
|
protectedEcho: protectedProcedure
|
||||||
|
.input(z.object({ value: z.string() }).strict())
|
||||||
|
.mutation(({ input, ctx }) => ({
|
||||||
|
value: input.value,
|
||||||
|
userId: (ctx as { user: { id: string } }).user.id,
|
||||||
|
})),
|
||||||
|
composedEcho: t.procedure
|
||||||
|
.use(requireAuthenticated)
|
||||||
|
.input(z.object({}).strict())
|
||||||
|
.mutation(() => "ok"),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requireAuthenticated middleware (B7)", () => {
|
||||||
|
it("rejects anonymous callers with UNAUTHORIZED", async () => {
|
||||||
|
const caller = echoRouter.createCaller({});
|
||||||
|
await expect(caller.protectedEcho({ value: "x" })).rejects.toMatchObject({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
await expect(caller.composedEcho({})).rejects.toMatchObject({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through authenticated callers and exposes ctx.user", async () => {
|
||||||
|
const caller = echoRouter.createCaller({
|
||||||
|
user: { id: "user-1", roles: [] },
|
||||||
|
});
|
||||||
|
await expect(caller.protectedEcho({ value: "x" })).resolves.toEqual({
|
||||||
|
value: "x",
|
||||||
|
userId: "user-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves public procedures untouched", async () => {
|
||||||
|
const caller = echoRouter.createCaller({});
|
||||||
|
await expect(caller.publicEcho({ value: "hi" })).resolves.toBe("hi");
|
||||||
|
});
|
||||||
|
});
|
||||||
32
packages/core-shared/src/trpc/require-authenticated.ts
Normal file
32
packages/core-shared/src/trpc/require-authenticated.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { t } from "./init";
|
||||||
|
import type { TrpcSessionUser } from "./context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared authentication guard for MUTATING tRPC procedures (audit finding
|
||||||
|
* B7). Reads the server-resolved `ctx.user` (attached by `createTrpcContext`
|
||||||
|
* via the app's `resolveUser`) and rejects anonymous callers with
|
||||||
|
* UNAUTHORIZED. Read-only queries stay public; each feature opts its
|
||||||
|
* mutations in by composing this middleware into its procedure chain:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* export const blogProtectedProcedure = blogProcedure.use(requireAuthenticated);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* The shared `t` is context-untyped, so the middleware narrows at runtime
|
||||||
|
* (same cast pattern as the dsr/audit routers) and re-publishes `user` into
|
||||||
|
* the downstream ctx with a non-optional type.
|
||||||
|
*/
|
||||||
|
export const requireAuthenticated = t.middleware(({ ctx, next }) => {
|
||||||
|
const user = (ctx as { user?: TrpcSessionUser }).user;
|
||||||
|
if (!user) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Authentication required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next({ ctx: { ...ctx, user } });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Convenience base procedure for apps composing ad-hoc protected routes. */
|
||||||
|
export const protectedProcedure = t.procedure.use(requireAuthenticated);
|
||||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -497,6 +497,12 @@ importers:
|
|||||||
"@repo/auth":
|
"@repo/auth":
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../auth
|
version: link:../auth
|
||||||
|
"@repo/core-audit":
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../core-audit
|
||||||
|
"@repo/core-shared":
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../core-shared
|
||||||
"@repo/workspaces":
|
"@repo/workspaces":
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../workspaces
|
version: link:../workspaces
|
||||||
@@ -547,6 +553,9 @@ importers:
|
|||||||
"@testing-library/react":
|
"@testing-library/react":
|
||||||
specifier: ^16.0.0
|
specifier: ^16.0.0
|
||||||
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
"@trpc/client":
|
||||||
|
specifier: ^11.18.0
|
||||||
|
version: 11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3)
|
||||||
"@types/react":
|
"@types/react":
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.14
|
version: 19.2.14
|
||||||
@@ -562,6 +571,9 @@ importers:
|
|||||||
react:
|
react:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.4
|
version: 19.2.4
|
||||||
|
superjson:
|
||||||
|
specifier: ^2.2.1
|
||||||
|
version: 2.2.6
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.8.0
|
specifier: ^5.8.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|||||||
Reference in New Issue
Block a user