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

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

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

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

View File

@@ -2,17 +2,42 @@ import { describe, it, expect } from "vitest";
import { auditLogsCollection } from "./audit-logs-collection"; 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");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,34 @@
import { createHash } from "node:crypto"; import { createHmac } from "node:crypto";
/** /**
* Produces a stable, irreversible token for a GDPR-erased actorId. * 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)}`;
} }

View File

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

View File

@@ -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) ?? [];

View File

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

View File

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

View File

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

View File

@@ -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 = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 b = bindingFromCtx(ctx, binding);
const res = await createDeleteHandler(binding.dataDelete)(input); 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>;

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

@@ -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", () => {

View File

@@ -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,33 +108,22 @@ 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}`;
return async () => { async function applyAction(
const payload = await getPayload({ config }); payload: PayloadPurgeApi,
const now = Date.now(); doc: Record<string, unknown>,
action: RetentionActionName,
if (retention.activeRetention) { reason: string,
const { duration, trigger } = retention.activeRetention; ): Promise<void> {
const retentionMs = parseDurationMs(duration);
const cutoff = new Date(now - retentionMs).toISOString();
const dateField = trigger === "from-creation" ? "createdAt" : "updatedAt";
const { docs } = await payload.find({
collection: collectionSlug,
where: { [dateField]: { less_than: cutoff } },
limit: 1000,
overrideAccess: true,
});
const action = retention.postDeletion?.action ?? "hard-delete";
for (const doc of docs) {
const id = doc["id"] as string | number; const id = doc["id"] as string | number;
if (action === "pseudonymize") { if (action === "pseudonymize") {
const piiFields: Record<string, null> = {}; const piiFields: Record<string, null> = {};
for (const field of collection.fields) { for (const field of targetCollection.fields) {
const f = field as { name?: string; custom?: { pii?: unknown } }; const f = field as { name?: string; custom?: { pii?: unknown } };
if (f.name && f.custom?.pii) { if (f.name && f.custom?.pii) {
piiFields[f.name] = null; piiFields[f.name] = null;
@@ -162,13 +158,66 @@ export function buildPurgeHandler(
environment: process.env["NODE_ENV"] ?? "production", environment: process.env["NODE_ENV"] ?? "production",
tenant: "default", tenant: "default",
}, },
reason: "retention-policy", reason,
from: { ipTruncated: "system", userAgent: "background-job" }, from: { ipTruncated: "system", userAgent: "background-job" },
containsPii: false, containsPii: false,
outcome: "success", outcome: "success",
}); });
} }
} }
return async () => {
const payload = await getPayload({ config });
const now = Date.now();
if (retention.activeRetention) {
const { duration, trigger } = retention.activeRetention;
const retentionMs = parseDurationMs(duration);
const cutoff = new Date(now - retentionMs).toISOString();
const dateField = trigger === "from-creation" ? "createdAt" : "updatedAt";
const { docs } = await payload.find({
collection: collectionSlug,
where: { [dateField]: { less_than: cutoff } },
limit: 1000,
overrideAccess: true,
});
const action = retention.postDeletion?.action ?? "hard-delete";
for (const doc of docs) {
await applyAction(payload, doc, action, "retention-policy");
}
}
if (retention.postDeletion) {
// Grace-period purge of soft-deleted rows (A2): the DSR soft delete
// stamps RETENTION_TOMBSTONE_FIELD; once the grace period elapses the
// declared action runs. Rows are re-checked client-side so a fake or
// permissive backend can never purge an un-tombstoned/unexpired row.
const { duration, action } = retention.postDeletion;
const cutoffMs = now - parseDurationMs(duration);
const cutoff = new Date(cutoffMs).toISOString();
const { docs } = await payload.find({
collection: collectionSlug,
where: { [RETENTION_TOMBSTONE_FIELD]: { less_than: cutoff } },
limit: 1000,
overrideAccess: true,
});
const expired = docs.filter((doc) => {
const tombstone = doc[RETENTION_TOMBSTONE_FIELD];
if (typeof tombstone !== "string" || tombstone.length === 0) {
return false;
}
const tombstoneMs = Date.parse(tombstone);
return Number.isFinite(tombstoneMs) && tombstoneMs < cutoffMs;
});
for (const doc of expired) {
await applyAction(payload, doc, action, "retention-policy");
}
} }
const delay = scheduleDelayMs(retention.purgeSchedule); const delay = scheduleDelayMs(retention.purgeSchedule);
@@ -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;

View File

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

View 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: {} };
},
};
}

View File

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

View File

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

View 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);
});
});

View File

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

View 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");
});
});

View 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
View File

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