fix(compliance): port DSR/consent/audit/retention audit fixes
Ports the upstream compliance-core audit fixes onto the kept core-dsr, core-consent, core-audit, core-cms and core-shared packages (pristine template state here, so taken to the fixed end-state): - core-dsr: scope DSR operations to the caller's own subject (A11); include the subject's audit trail in exports; resolve the per-request binding from ctx instead of a throwing singleton proxy. - core-consent: build the consent router from the shared superjson transformer (A10); merge per-category on persist instead of replacing; validate migrated categories against an allow-list. - core-audit: keyed 128-bit pseudonyms + salted DSR certificate; add the audit-logs collection and the req-scoped GDPR audit-erasure afterDelete hook (A6). - core-shared: grace-purge soft-deleted rows via a retention-purge task + tombstone field and boot registration (A2/A3); add the require-authenticated tRPC helper; derive clientIp + resolve the session user in createTrpcContext (B2/A11). - core-cms: register audit-logs, wire the audit-erasure hook and retention-purge tasks; adapted to our collection set (users, workspaces). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
@@ -2,17 +2,42 @@ import { describe, it, expect } from "vitest";
|
||||
import { auditLogsCollection } from "./audit-logs-collection";
|
||||
|
||||
describe("auditLogsCollection", () => {
|
||||
it("accepts every AuditAction enum value (A6)", () => {
|
||||
const action = (
|
||||
auditLogsCollection.fields as Array<{ name: string; options?: string[] }>
|
||||
).find((f) => f.name === "action");
|
||||
expect(action?.options).toEqual(
|
||||
expect.arrayContaining([
|
||||
"VIEW",
|
||||
"CREATE",
|
||||
"UPDATE",
|
||||
"DELETE",
|
||||
"EXPORT",
|
||||
"PERMISSION_CHANGE",
|
||||
"CONSENT_GRANT",
|
||||
"CONSENT_WITHDRAW",
|
||||
"RESTRICT",
|
||||
"UNRESTRICT",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses slug 'audit-logs'", () => {
|
||||
expect(auditLogsCollection.slug).toBe("audit-logs");
|
||||
});
|
||||
|
||||
it("is append-only (update: () => false)", () => {
|
||||
const access = auditLogsCollection.access as Record<string, (() => boolean) | undefined>;
|
||||
const access = auditLogsCollection.access as Record<
|
||||
string,
|
||||
(() => boolean) | undefined
|
||||
>;
|
||||
expect(access["update"]?.()).toBe(false);
|
||||
});
|
||||
|
||||
it("has the required fields", () => {
|
||||
const fieldNames = (auditLogsCollection.fields as Array<{ name: string }>).map((f) => f.name);
|
||||
const fieldNames = (
|
||||
auditLogsCollection.fields as Array<{ name: string }>
|
||||
).map((f) => f.name);
|
||||
// WHO
|
||||
expect(fieldNames).toContain("actorId");
|
||||
expect(fieldNames).toContain("actorType");
|
||||
|
||||
@@ -44,7 +44,21 @@ export const auditLogsCollection: CollectionConfig = {
|
||||
{
|
||||
name: "action",
|
||||
type: "select",
|
||||
options: ["VIEW", "CREATE", "UPDATE", "DELETE", "EXPORT", "PERMISSION_CHANGE"],
|
||||
// Mirrors the AuditAction enum in @repo/core-shared/audit — the DSR and
|
||||
// consent cores record RESTRICT/UNRESTRICT/CONSENT_* entries, so the
|
||||
// select must accept every enum value or record() fails validation (A6).
|
||||
options: [
|
||||
"VIEW",
|
||||
"CREATE",
|
||||
"UPDATE",
|
||||
"DELETE",
|
||||
"EXPORT",
|
||||
"PERMISSION_CHANGE",
|
||||
"CONSENT_GRANT",
|
||||
"CONSENT_WITHDRAW",
|
||||
"RESTRICT",
|
||||
"UNRESTRICT",
|
||||
],
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createAuditErasureHook } from "./audit-erasure-hook";
|
||||
import {
|
||||
createAuditErasureHook,
|
||||
createReqScopedAuditErasureHook,
|
||||
} from "./audit-erasure-hook";
|
||||
import type { IAuditLog } from "../audit-log.interface";
|
||||
|
||||
function makeAuditLog(): IAuditLog {
|
||||
@@ -25,7 +28,10 @@ describe("createAuditErasureHook", () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditErasureHook({ auditLog });
|
||||
await hook(hookArgs("user_1") as never);
|
||||
expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_1", "pseudonymize");
|
||||
expect(auditLog.eraseSubject).toHaveBeenCalledWith(
|
||||
"user_1",
|
||||
"pseudonymize",
|
||||
);
|
||||
});
|
||||
|
||||
it("respects explicit mode='delete'", async () => {
|
||||
@@ -63,3 +69,78 @@ describe("createAuditErasureHook", () => {
|
||||
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createReqScopedAuditErasureHook (A6)", () => {
|
||||
function makeReqPayload(withAuditCollection: boolean) {
|
||||
const find = vi.fn().mockResolvedValue({ docs: [{ id: "log-1" }] });
|
||||
const update = vi.fn().mockResolvedValue({});
|
||||
const del = vi.fn().mockResolvedValue({});
|
||||
const payload = {
|
||||
config: {
|
||||
collections: withAuditCollection ? [{ slug: "audit-logs" }] : [],
|
||||
},
|
||||
find,
|
||||
update,
|
||||
delete: del,
|
||||
};
|
||||
return { payload, find, update, del };
|
||||
}
|
||||
|
||||
function reqHookArgs(id: unknown, payload: unknown) {
|
||||
return {
|
||||
doc: { id },
|
||||
req: { payload } as never,
|
||||
id: String(id),
|
||||
collection: {} as never,
|
||||
context: {},
|
||||
};
|
||||
}
|
||||
|
||||
it("pseudonymizes the deleted subject's audit entries via req.payload", async () => {
|
||||
const { payload, find, update } = makeReqPayload(true);
|
||||
const hook = createReqScopedAuditErasureHook();
|
||||
await hook(reqHookArgs("user_1", payload) as never);
|
||||
|
||||
expect(find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
collection: "audit-logs",
|
||||
where: { actorId: { equals: "user_1" } },
|
||||
}),
|
||||
);
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
collection: "audit-logs",
|
||||
id: "log-1",
|
||||
data: { actorId: expect.stringMatching(/^erased-/) },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("respects mode='delete'", async () => {
|
||||
const { payload, del } = makeReqPayload(true);
|
||||
const hook = createReqScopedAuditErasureHook({ mode: "delete" });
|
||||
await hook(reqHookArgs("user_2", payload) as never);
|
||||
expect(del).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
collection: "audit-logs",
|
||||
where: { actorId: { equals: "user_2" } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("no-ops when the audit-logs collection is not registered", async () => {
|
||||
const { payload, find, update, del } = makeReqPayload(false);
|
||||
const hook = createReqScopedAuditErasureHook();
|
||||
await hook(reqHookArgs("user_1", payload) as never);
|
||||
expect(find).not.toHaveBeenCalled();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(del).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips invalid doc ids", async () => {
|
||||
const { payload, find } = makeReqPayload(true);
|
||||
const hook = createReqScopedAuditErasureHook();
|
||||
await hook(reqHookArgs(undefined, payload) as never);
|
||||
expect(find).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CollectionAfterDeleteHook } from "payload";
|
||||
import type { IAuditLog } from "../audit-log.interface";
|
||||
import { PayloadAuditLog } from "../payload-audit-log";
|
||||
|
||||
export type AuditErasureHookOpts = {
|
||||
/** The audit log impl that will perform the erasure. */
|
||||
@@ -36,3 +37,37 @@ export function createAuditErasureHook(
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type ReqScopedAuditErasureHookOpts = {
|
||||
/** Erasure mode — see AuditErasureHookOpts. Defaults to "pseudonymize". */
|
||||
mode?: "pseudonymize" | "delete";
|
||||
};
|
||||
|
||||
/**
|
||||
* Variant of `createAuditErasureHook` for config-composition time (audit
|
||||
* finding A6): a Payload collection config is built before any `IAuditLog`
|
||||
* can exist (binding the audit log needs the built config), so this hook
|
||||
* constructs a `PayloadAuditLog` lazily from the running instance on
|
||||
* `req.payload` when the delete fires. No-ops when the `audit-logs`
|
||||
* collection is not registered.
|
||||
*/
|
||||
export function createReqScopedAuditErasureHook(
|
||||
opts: ReqScopedAuditErasureHookOpts = {},
|
||||
): CollectionAfterDeleteHook {
|
||||
const mode = opts.mode ?? "pseudonymize";
|
||||
return async ({ doc, req }) => {
|
||||
if (typeof doc.id !== "string" && typeof doc.id !== "number") return;
|
||||
const payload = req.payload;
|
||||
// `slug as string`: apps with generated CollectionSlug types narrow slug
|
||||
// to their registered union, which need not include "audit-logs".
|
||||
const hasAuditCollection = payload.config.collections?.some(
|
||||
(c) => (c.slug as string) === "audit-logs",
|
||||
);
|
||||
if (!hasAuditCollection) return;
|
||||
const auditLog = new PayloadAuditLog(
|
||||
payload.config,
|
||||
async () => payload as never,
|
||||
);
|
||||
await auditLog.eraseSubject(String(doc.id), mode);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export {
|
||||
createAuditErasureHook,
|
||||
createReqScopedAuditErasureHook,
|
||||
type AuditErasureHookOpts,
|
||||
type ReqScopedAuditErasureHookOpts,
|
||||
} from "./audit-erasure-hook";
|
||||
export {
|
||||
createAuditAfterReadHook,
|
||||
|
||||
@@ -16,7 +16,9 @@ export { AUDIT_SYMBOLS } from "./di/symbols";
|
||||
export { pseudonymize } from "./pseudonymize";
|
||||
export {
|
||||
createAuditErasureHook,
|
||||
createReqScopedAuditErasureHook,
|
||||
type AuditErasureHookOpts,
|
||||
type ReqScopedAuditErasureHookOpts,
|
||||
} from "./hooks/audit-erasure-hook";
|
||||
// VIEW capture
|
||||
export { createAuditAfterReadHook, type AuditAfterReadHookOpts } from "./hooks";
|
||||
|
||||
@@ -25,7 +25,10 @@ describe("PayloadAuditLog.record", () => {
|
||||
await log.record(sample);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledOnce();
|
||||
const call = mockCreate.mock.calls[0]![0] as { collection: string; data: Record<string, unknown> };
|
||||
const call = mockCreate.mock.calls[0]![0] as {
|
||||
collection: string;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(call.collection).toBe("audit-logs");
|
||||
expect(call.data.actorId).toBe("user_1");
|
||||
expect(call.data.action).toBe("UPDATE");
|
||||
@@ -101,14 +104,21 @@ describe("PayloadAuditLog.eraseSubject", () => {
|
||||
// update called for each doc
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(2);
|
||||
const updateCalls = mockUpdate.mock.calls as Array<
|
||||
[{ collection: string; id: string; data: Record<string, unknown>; overrideAccess: boolean }]
|
||||
[
|
||||
{
|
||||
collection: string;
|
||||
id: string;
|
||||
data: Record<string, unknown>;
|
||||
overrideAccess: boolean;
|
||||
},
|
||||
]
|
||||
>;
|
||||
expect(updateCalls[0]![0].id).toBe("doc_a");
|
||||
expect(updateCalls[1]![0].id).toBe("doc_b");
|
||||
|
||||
// both updates replace actorId with the same pseudonym
|
||||
const pseudonym = updateCalls[0]![0].data["actorId"] as string;
|
||||
expect(pseudonym).toMatch(/^erased-[0-9a-f]{16}$/);
|
||||
expect(pseudonym).toMatch(/^erased-[0-9a-f]{32}$/);
|
||||
expect(updateCalls[1]![0].data["actorId"]).toBe(pseudonym);
|
||||
|
||||
// overrideAccess bypasses the append-only rule
|
||||
@@ -118,7 +128,9 @@ describe("PayloadAuditLog.eraseSubject", () => {
|
||||
it("mode='pseudonymize' with no matching docs does not call update", async () => {
|
||||
const mockFind = vi.fn().mockResolvedValue({ docs: [] });
|
||||
const mockUpdate = vi.fn();
|
||||
const mockGetPayload = vi.fn().mockResolvedValue({ find: mockFind, update: mockUpdate });
|
||||
const mockGetPayload = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ find: mockFind, update: mockUpdate });
|
||||
const log = new PayloadAuditLog({} as never, mockGetPayload);
|
||||
|
||||
await log.eraseSubject("unknown_user", "pseudonymize");
|
||||
|
||||
@@ -21,13 +21,28 @@ describe("pseudonymize", () => {
|
||||
expect(result).toMatch(/^erased-/);
|
||||
});
|
||||
|
||||
it("produces exactly 16 hex chars after the prefix", () => {
|
||||
it("produces exactly 32 hex chars (128 bits) after the prefix (A13)", () => {
|
||||
const result = pseudonymize("user_42");
|
||||
const hex = result.slice("erased-".length);
|
||||
expect(hex).toHaveLength(16);
|
||||
expect(hex).toHaveLength(32);
|
||||
expect(hex).toMatch(/^[0-9a-f]+$/);
|
||||
});
|
||||
|
||||
it("matches HMAC-SHA256(key, actorId) - keyed, not a bare hash (A13)", async () => {
|
||||
const { createHmac, createHash } = await import("node:crypto");
|
||||
const expected = createHmac("sha256", "test-salt-1")
|
||||
.update("user_42")
|
||||
.digest("hex")
|
||||
.slice(0, 32);
|
||||
expect(pseudonymize("user_42")).toBe(`erased-` + expected);
|
||||
// and it must NOT be the legacy unkeyed sha256("salt:id") scheme
|
||||
const legacy = createHash("sha256")
|
||||
.update("test-salt-1:user_42")
|
||||
.digest("hex")
|
||||
.slice(0, 32);
|
||||
expect(pseudonymize("user_42")).not.toBe(`erased-` + legacy);
|
||||
});
|
||||
|
||||
it("is deterministic — same salt + actorId always yields the same token", () => {
|
||||
const a = pseudonymize("user_42");
|
||||
const b = pseudonymize("user_42");
|
||||
@@ -53,6 +68,6 @@ describe("pseudonymize", () => {
|
||||
delete process.env["AUDIT_PSEUDONYM_SALT"];
|
||||
// Should not throw; just use the fallback.
|
||||
const result = pseudonymize("user_1");
|
||||
expect(result).toMatch(/^erased-[0-9a-f]{16}$/);
|
||||
expect(result).toMatch(/^erased-[0-9a-f]{32}$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Produces a stable, irreversible token for a GDPR-erased actorId.
|
||||
*
|
||||
* Format: `erased-<first-16-hex-chars-of-sha256(salt:actorId)>`
|
||||
* Format: `erased-<first-32-hex-chars-of-HMAC-SHA256(key, actorId)>` —
|
||||
* 128 bits of a KEYED digest (audit finding A13). The previous scheme was an
|
||||
* unkeyed `sha256("salt:actorId")` truncated to 64 bits, which invited both
|
||||
* brute-force reversal of small id spaces and birthday collisions.
|
||||
*
|
||||
* The salt is read from `AUDIT_PSEUDONYM_SALT` env at call time so that
|
||||
* The HMAC key is read from `AUDIT_PSEUDONYM_SALT` at call time so that
|
||||
* production binding can pre-validate the var at boot (see `bindAudit`)
|
||||
* while tests can override it per-test via `process.env`.
|
||||
*
|
||||
* Fallback salt is intentionally weak and labelled so that any token
|
||||
* Rotation expectations (documented, by design):
|
||||
* - Rotating the key changes pseudonyms produced FROM THEN ON only. Audit
|
||||
* rows already pseudonymized keep tokens derived from the previous key;
|
||||
* nothing re-keys stored rows, so a subject's pre- and post-rotation
|
||||
* tokens no longer correlate. That linkage break is acceptable — the
|
||||
* token's only job is severing PII linkage, not long-term correlation.
|
||||
* - Re-erasing a subject after rotation still works: erasure matches rows
|
||||
* by the REAL actorId, not by a previous pseudonym.
|
||||
* - Rotate by replacing the env value (e.g. `openssl rand -hex 32`); keep
|
||||
* retired keys only if you have an explicit need to re-correlate old rows.
|
||||
*
|
||||
* Fallback key is intentionally weak and labelled so that any token
|
||||
* produced with it is recognisable as a dev/test artefact.
|
||||
*/
|
||||
export function pseudonymize(actorId: string): string {
|
||||
const salt =
|
||||
const key =
|
||||
process.env["AUDIT_PSEUDONYM_SALT"] ?? "dev-fallback-salt-replace-in-prod";
|
||||
const hash = createHash("sha256")
|
||||
.update(`${salt}:${actorId}`)
|
||||
.digest("hex");
|
||||
return `erased-${hash.slice(0, 16)}`;
|
||||
const digest = createHmac("sha256", key).update(actorId).digest("hex");
|
||||
return `erased-${digest.slice(0, 32)}`;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"@payloadcms/db-postgres": "^3.14.0",
|
||||
"@payloadcms/richtext-lexical": "^3.14.0",
|
||||
"@repo/auth": "workspace:*",
|
||||
"@repo/core-audit": "workspace:*",
|
||||
"@repo/core-shared": "workspace:*",
|
||||
"@repo/workspaces": "workspace:*",
|
||||
"payload": "^3.14.0"
|
||||
},
|
||||
|
||||
@@ -8,6 +8,42 @@ describe("payloadConfig composition", () => {
|
||||
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 () => {
|
||||
const resolved = await config;
|
||||
const slugs = resolved.globals?.map((g) => g.slug) ?? [];
|
||||
|
||||
@@ -4,15 +4,46 @@ import { lexicalEditor } from "@payloadcms/richtext-lexical";
|
||||
import path from "node:path";
|
||||
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";
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
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({
|
||||
editor: lexicalEditor(),
|
||||
collections: [users, workspaces],
|
||||
collections,
|
||||
globals: [],
|
||||
secret: process.env.PAYLOAD_SECRET || "default-secret-change-me",
|
||||
db: postgresAdapter({
|
||||
@@ -22,6 +53,14 @@ export default buildConfig({
|
||||
"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: {
|
||||
outputFile: path.resolve(dirname, "generated-types.ts"),
|
||||
},
|
||||
|
||||
@@ -36,11 +36,13 @@
|
||||
"@repo/core-testing": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@trpc/client": "^11.18.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"payload": "^3.14.0",
|
||||
"react": "^19.0.0",
|
||||
"superjson": "^2.2.1",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
|
||||
@@ -10,6 +10,27 @@ export type ConsentCategory =
|
||||
| "marketing"
|
||||
| (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. */
|
||||
export type ConsentState = "granted" | "denied" | "pending";
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
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 { router } from "@repo/core-shared/trpc/init";
|
||||
import { consentRouter } from "@/consent.router";
|
||||
import type { ConsentRouterContext } from "@/consent.router";
|
||||
import type { IConsent } from "@/consent.interface";
|
||||
import { InMemoryConsent } from "@/in-memory-consent";
|
||||
|
||||
function makeContext(
|
||||
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", () => {
|
||||
it("propagates unmapped errors as INTERNAL_SERVER_ERROR", async () => {
|
||||
const brokenConsent: IConsent = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { initTRPC } from "@trpc/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { t } from "@repo/core-shared/trpc/init";
|
||||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
||||
|
||||
import type { ConsentFactory } from "./di/bind-production";
|
||||
@@ -26,41 +27,57 @@ export type ConsentRouterContext = {
|
||||
consentFactory: ConsentFactory;
|
||||
};
|
||||
|
||||
const tc = initTRPC.context<ConsentRouterContext>().create();
|
||||
|
||||
const consentProcedure = tc.procedure
|
||||
/**
|
||||
* Consent procedures build on the SHARED `t` instance from
|
||||
* `@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(async ({ ctx, next }) => {
|
||||
if (!ctx.userId) throw new UnauthenticatedError();
|
||||
return next();
|
||||
const { userId, consentFactory } = ctx as Partial<ConsentRouterContext>;
|
||||
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
|
||||
.input(grantHandlerInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const consent = await ctx.consentFactory(ctx.userId!);
|
||||
const consent = await ctx.consentFactory(ctx.userId);
|
||||
return grantHandler(consent, input);
|
||||
}),
|
||||
|
||||
withdraw: consentProcedure
|
||||
.input(withdrawHandlerInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const consent = await ctx.consentFactory(ctx.userId!);
|
||||
const consent = await ctx.consentFactory(ctx.userId);
|
||||
return withdrawHandler(consent, input);
|
||||
}),
|
||||
|
||||
isGranted: consentProcedure
|
||||
.input(isGrantedHandlerInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const consent = await ctx.consentFactory(ctx.userId!);
|
||||
const consent = await ctx.consentFactory(ctx.userId);
|
||||
return isGrantedHandler(consent, input);
|
||||
}),
|
||||
|
||||
getCategories: consentProcedure
|
||||
.input(z.object({}).strict())
|
||||
.query(async ({ ctx }) => {
|
||||
const consent = await ctx.consentFactory(ctx.userId!);
|
||||
const consent = await ctx.consentFactory(ctx.userId);
|
||||
return getCategoriesHandler(consent);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,10 @@ export type {
|
||||
UserConsentState,
|
||||
ConsentGrantMeta,
|
||||
} from "./consent-types";
|
||||
export {
|
||||
KNOWN_CONSENT_CATEGORIES,
|
||||
isKnownConsentCategory,
|
||||
} from "./consent-types";
|
||||
export type { IConsent } from "./consent.interface";
|
||||
export type { ConsentChecked } from "./with-consent";
|
||||
export { withConsent } from "./with-consent";
|
||||
|
||||
@@ -43,6 +43,21 @@ describe("extractAnonymousConsent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractAnonymousConsent — category allow-list (A12)", () => {
|
||||
it("drops unknown categories from the client-controlled cookie", () => {
|
||||
const result = extractAnonymousConsent(
|
||||
`${CONSENT_COOKIE_NAME}=necessary,evil-injection,analytics`,
|
||||
);
|
||||
expect(result).toEqual(["necessary", "analytics"]);
|
||||
});
|
||||
|
||||
it("returns null when every category is unknown", () => {
|
||||
expect(
|
||||
extractAnonymousConsent(`${CONSENT_COOKIE_NAME}=hax,__proto__`),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateAnonymousConsent", () => {
|
||||
it("calls IConsent.grant with method signup-migration for each category", async () => {
|
||||
const consent = new RecordingConsent();
|
||||
@@ -105,3 +120,17 @@ describe("migrateAnonymousConsent", () => {
|
||||
expect(consent.isGranted("marketing")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateAnonymousConsent — category allow-list (A12)", () => {
|
||||
it("never grants unknown categories even when passed directly", async () => {
|
||||
const consent = new RecordingConsent();
|
||||
await migrateAnonymousConsent({
|
||||
consent,
|
||||
cookieState: ["analytics", "totally-made-up", "marketing"],
|
||||
});
|
||||
expect(consent.grants.map((g) => g.category)).toEqual([
|
||||
"analytics",
|
||||
"marketing",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ConsentCategory, ConsentGrantMeta } from "./consent-types";
|
||||
import {
|
||||
isKnownConsentCategory,
|
||||
type ConsentCategory,
|
||||
type ConsentGrantMeta,
|
||||
} from "./consent-types";
|
||||
import type { IConsent } from "./consent.interface";
|
||||
|
||||
/** 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,
|
||||
* 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(
|
||||
cookieHeader: string,
|
||||
@@ -21,7 +29,8 @@ export function extractAnonymousConsent(
|
||||
const categories = raw
|
||||
.split(",")
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean) as ConsentCategory[];
|
||||
.filter(Boolean)
|
||||
.filter(isKnownConsentCategory) as ConsentCategory[];
|
||||
return categories.length > 0 ? categories : null;
|
||||
}
|
||||
|
||||
@@ -42,7 +51,9 @@ export async function migrateAnonymousConsent(opts: {
|
||||
const meta: ConsentGrantMeta = { method: "signup-migration" };
|
||||
if (bannerVersion !== undefined) meta.bannerVersion = bannerVersion;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,3 +259,106 @@ describe("PayloadConsent.load — deserializeEntry branches", () => {
|
||||
expect(cats[0]!.withdrawnAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PayloadConsent.persist — read-merge-write (A7)", () => {
|
||||
async function makeTwoConsents() {
|
||||
const mock = makePayloadMock();
|
||||
const a = new PayloadConsent(
|
||||
"user_1",
|
||||
{} as never,
|
||||
new RecordingAuditLog(),
|
||||
mock.getPayload,
|
||||
);
|
||||
const b = new PayloadConsent(
|
||||
"user_1",
|
||||
{} as never,
|
||||
new RecordingAuditLog(),
|
||||
mock.getPayload,
|
||||
);
|
||||
// Both instances hydrate from the SAME empty snapshot — the per-request
|
||||
// cache staleness that caused the lost update.
|
||||
await a.load();
|
||||
await b.load();
|
||||
return { a, b, ...mock };
|
||||
}
|
||||
|
||||
function storedCategories(db: Record<string, unknown[]>): string[] {
|
||||
return (db["user_1"] ?? [])
|
||||
.map((e) => (e as { category: string }).category)
|
||||
.sort();
|
||||
}
|
||||
|
||||
it("two interleaved grants from stale caches both survive", async () => {
|
||||
const { a, b, db } = await makeTwoConsents();
|
||||
|
||||
await a.grant("analytics");
|
||||
await b.grant("marketing"); // pre-fix: whole-array write dropped "analytics"
|
||||
|
||||
expect(storedCategories(db)).toEqual(["analytics", "marketing"]);
|
||||
});
|
||||
|
||||
it("a grant and a withdraw on different categories both survive", async () => {
|
||||
const { a, b, db } = await makeTwoConsents();
|
||||
|
||||
await a.grant("analytics");
|
||||
await b.grant("marketing");
|
||||
await a.withdraw("analytics");
|
||||
|
||||
expect(storedCategories(db)).toEqual(["analytics", "marketing"]);
|
||||
const analytics = (
|
||||
db["user_1"] as Array<{ category: string; state: string }>
|
||||
).find((e) => e.category === "analytics");
|
||||
expect(analytics?.state).toBe("denied");
|
||||
});
|
||||
|
||||
it("adopts concurrent writers' entries into the local cache after persist", async () => {
|
||||
const { a, b } = await makeTwoConsents();
|
||||
|
||||
await a.grant("analytics");
|
||||
await b.grant("marketing");
|
||||
|
||||
// b re-read the freshest state during persist, so it now sees a's grant.
|
||||
expect(b.isGranted("analytics")).toBe(true);
|
||||
expect(b.isGranted("marketing")).toBe(true);
|
||||
});
|
||||
|
||||
it("truly concurrent grants both survive when the second read lands after the first write", async () => {
|
||||
const mock = makePayloadMock();
|
||||
const a = new PayloadConsent(
|
||||
"user_1",
|
||||
{} as never,
|
||||
new RecordingAuditLog(),
|
||||
mock.getPayload,
|
||||
);
|
||||
const b = new PayloadConsent(
|
||||
"user_1",
|
||||
{} as never,
|
||||
new RecordingAuditLog(),
|
||||
mock.getPayload,
|
||||
);
|
||||
await a.load();
|
||||
await b.load();
|
||||
|
||||
// Gate b's persist-read until a's write has committed — the ordering the
|
||||
// read-merge-write strategy is designed for. (A same-window overlap is
|
||||
// the documented residual race.)
|
||||
let releaseB: () => void = () => {};
|
||||
const bGate = new Promise<void>((resolve) => {
|
||||
releaseB = resolve;
|
||||
});
|
||||
const originalFindByID = mock.findByID.getMockImplementation()!;
|
||||
let firstPersistRead = true;
|
||||
// a loads+persists first; instrument findByID so b's persist read waits.
|
||||
mock.findByID.mockImplementation(async (args: { id: string }) => {
|
||||
if (!firstPersistRead) await bGate;
|
||||
firstPersistRead = false;
|
||||
return originalFindByID(args);
|
||||
});
|
||||
|
||||
const aDone = a.grant("analytics").then(() => releaseB());
|
||||
const bDone = b.grant("marketing");
|
||||
await Promise.all([aDone, bDone]);
|
||||
|
||||
expect(storedCategories(mock.db)).toEqual(["analytics", "marketing"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ export class PayloadConsent implements IConsent {
|
||||
method: meta?.method,
|
||||
};
|
||||
this.cache.set(category, entry);
|
||||
await this.persist();
|
||||
await this.persist([category]);
|
||||
await this.auditLog.record({
|
||||
actorId: this.userId,
|
||||
actorType: "user",
|
||||
@@ -116,7 +116,7 @@ export class PayloadConsent implements IConsent {
|
||||
withdrawnAt: now,
|
||||
};
|
||||
this.cache.set(category, entry);
|
||||
await this.persist();
|
||||
await this.persist([category]);
|
||||
await this.auditLog.record({
|
||||
actorId: this.userId,
|
||||
actorType: "user",
|
||||
@@ -139,9 +139,54 @@ export class PayloadConsent implements IConsent {
|
||||
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 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,
|
||||
state: entry.state,
|
||||
grantedAt: entry.grantedAt?.toISOString() ?? null,
|
||||
|
||||
@@ -208,15 +208,127 @@ describe("dsrRouter.restrict", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("dsrRouter singleton guard", () => {
|
||||
it("throws when procedures are called without a real DsrBinding", async () => {
|
||||
// The singleton uses a Proxy that throws on any binding property access.
|
||||
// Procedures access binding lazily, so the Proxy error surfaces at call time.
|
||||
describe("dsrRouter subject scoping (A1 — IDOR)", () => {
|
||||
it("rejects a non-admin export for another subject with FORBIDDEN", async () => {
|
||||
const binding = makeBinding();
|
||||
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({
|
||||
user: authenticatedUser,
|
||||
} as Record<string, unknown>);
|
||||
await expect(
|
||||
caller.export({ subjectId: "alice", format: "json" }),
|
||||
).rejects.toThrow(/dsrRouter singleton/);
|
||||
).rejects.toMatchObject({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: expect.stringContaining("DsrBinding missing"),
|
||||
});
|
||||
});
|
||||
|
||||
it("serves requests when the app provides ctx.dsrBinding", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = dsrRouter.createCaller({
|
||||
user: authenticatedUser,
|
||||
dsrBinding: binding,
|
||||
} as Record<string, unknown>);
|
||||
const result = await caller.export({ subjectId: "alice", format: "json" });
|
||||
expect(result.subjectId).toBe("alice");
|
||||
expect(binding.dataExport.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("prefers ctx.dsrBinding over the creation-time binding", async () => {
|
||||
const creationBinding = makeBinding();
|
||||
const ctxBinding = makeBinding();
|
||||
const router = createDsrRouter(creationBinding as unknown as DsrBinding);
|
||||
const caller = router.createCaller({
|
||||
user: authenticatedUser,
|
||||
dsrBinding: ctxBinding,
|
||||
} as Record<string, unknown>);
|
||||
await caller.export({ subjectId: "alice", format: "json" });
|
||||
expect(ctxBinding.dataExport.calls).toHaveLength(1);
|
||||
expect(creationBinding.dataExport.calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,6 +97,152 @@ describe("PayloadDataDelete", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("stamps the deletedAt tombstone when the collection declares postDeletion retention (A2)", async () => {
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
subject: { field: "id", kind: "self" },
|
||||
pii: { email: { exportable: true } },
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
postDeletion: {
|
||||
duration: "P30D",
|
||||
trigger: "after-deletion",
|
||||
action: "hard-delete",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockPayload.find.mockResolvedValue({
|
||||
docs: [{ id: "alice", email: "a@ex.com" }],
|
||||
});
|
||||
|
||||
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||
await deleter.deleteSubjectData("alice", "soft");
|
||||
|
||||
expect(mockPayload.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
deletedAt: expect.any(String),
|
||||
processingRestrictedAt: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT stamp deletedAt without a postDeletion retention policy", async () => {
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
subject: { field: "id", kind: "self" },
|
||||
pii: { email: { exportable: true } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockPayload.find.mockResolvedValue({
|
||||
docs: [{ id: "alice", email: "a@ex.com" }],
|
||||
});
|
||||
|
||||
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||
await deleter.deleteSubjectData("alice", "soft");
|
||||
|
||||
const updateData = mockPayload.update.mock.calls[0]?.[0]?.data as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(updateData).not.toHaveProperty("deletedAt");
|
||||
});
|
||||
|
||||
it("stamps deletedAt on owner rows of postDeletion collections", async () => {
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
slug: "orders",
|
||||
custom: {
|
||||
subject: { field: "userId", kind: "owner" },
|
||||
pii: { shippingAddress: { exportable: true } },
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
postDeletion: {
|
||||
duration: "P90D",
|
||||
trigger: "after-deletion",
|
||||
action: "hard-delete",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockPayload.find.mockResolvedValue({
|
||||
docs: [{ id: "o1", userId: "alice", shippingAddress: "X" }],
|
||||
});
|
||||
|
||||
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||
await deleter.deleteSubjectData("alice", "soft");
|
||||
|
||||
const updateData = mockPayload.update.mock.calls[0]?.[0]?.data as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(updateData["deletedAt"]).toEqual(expect.any(String));
|
||||
expect(updateData).not.toHaveProperty("processingRestrictedAt");
|
||||
});
|
||||
|
||||
it("redacts the auth-injected email field for a users-shaped collection (A5)", async () => {
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
subject: { field: "id", kind: "self" },
|
||||
pii: {
|
||||
email: {
|
||||
category: "contact-email",
|
||||
purpose: ["account-authentication"],
|
||||
exportable: true,
|
||||
restrictable: true,
|
||||
},
|
||||
username: {
|
||||
category: "identification-username",
|
||||
purpose: ["service-delivery"],
|
||||
exportable: true,
|
||||
restrictable: true,
|
||||
},
|
||||
displayName: {
|
||||
category: "identification-username",
|
||||
purpose: ["service-delivery"],
|
||||
exportable: true,
|
||||
restrictable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockPayload.find.mockResolvedValue({
|
||||
docs: [{ id: "alice", email: "alice@example.com", username: "alice" }],
|
||||
});
|
||||
|
||||
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||
const cert = await deleter.deleteSubjectData("alice", "soft");
|
||||
|
||||
expect(mockPayload.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
email: null,
|
||||
username: null,
|
||||
displayName: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(cert.affected[0]?.fields).toEqual(
|
||||
expect.arrayContaining(["email", "username", "displayName"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("owner role: does NOT set processingRestrictedAt", async () => {
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
@@ -367,3 +513,67 @@ describe("PayloadDataDelete", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cascade-hard audit erasure (A6)", () => {
|
||||
it("calls auditErasure.eraseSubject with pseudonymize after cascade-hard", async () => {
|
||||
const auditLog = new RecordingAuditLog();
|
||||
const mockPayload = {
|
||||
find: vi.fn().mockResolvedValue({ docs: [{ id: "alice" }] }),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
delete: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
const eraseSubject = vi.fn().mockResolvedValue(undefined);
|
||||
const config = {
|
||||
collections: [
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
subject: { field: "id", kind: "self" },
|
||||
pii: { email: { exportable: true } },
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as SanitizedConfig;
|
||||
|
||||
const deleter = new PayloadDataDelete(
|
||||
config,
|
||||
auditLog,
|
||||
vi.fn().mockResolvedValue(mockPayload),
|
||||
{ eraseSubject },
|
||||
);
|
||||
await deleter.deleteSubjectData("alice", "cascade-hard");
|
||||
|
||||
expect(eraseSubject).toHaveBeenCalledWith("alice", "pseudonymize");
|
||||
});
|
||||
|
||||
it("does not erase audit entries on soft delete", async () => {
|
||||
const auditLog = new RecordingAuditLog();
|
||||
const mockPayload = {
|
||||
find: vi.fn().mockResolvedValue({ docs: [{ id: "alice" }] }),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
delete: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
const eraseSubject = vi.fn().mockResolvedValue(undefined);
|
||||
const config = {
|
||||
collections: [
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
subject: { field: "id", kind: "self" },
|
||||
pii: { email: { exportable: true } },
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as SanitizedConfig;
|
||||
|
||||
const deleter = new PayloadDataDelete(
|
||||
config,
|
||||
auditLog,
|
||||
vi.fn().mockResolvedValue(mockPayload),
|
||||
{ eraseSubject },
|
||||
);
|
||||
await deleter.deleteSubjectData("alice", "soft");
|
||||
|
||||
expect(eraseSubject).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,6 +72,60 @@ describe("PayloadDataExport", () => {
|
||||
expect(bundle.data["users"]?.asReference).toBeUndefined();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
@@ -207,3 +261,103 @@ describe("PayloadDataExport", () => {
|
||||
expect(Object.keys(bundle.data)).toEqual(["users", "orders"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PayloadDataExport — audit log in the bundle (A14)", () => {
|
||||
it("populates bundle.auditLog from the audit-logs collection, scoped to the subject", async () => {
|
||||
const auditLog = new RecordingAuditLog();
|
||||
const find = vi.fn(async (args: { collection: string }) => {
|
||||
if (args.collection === "audit-logs") {
|
||||
return {
|
||||
docs: [
|
||||
{
|
||||
id: "log-1",
|
||||
actorId: "alice",
|
||||
actorType: "user",
|
||||
actorRoles: ["author"],
|
||||
action: "EXPORT",
|
||||
resourceType: "subject-data",
|
||||
resourceId: null,
|
||||
changedFields: null,
|
||||
scopeFeature: "core-dsr",
|
||||
scopeEnvironment: "test",
|
||||
scopeTenant: "default",
|
||||
reason: null,
|
||||
correlationId: "corr-1",
|
||||
requestId: null,
|
||||
ipTruncated: "system",
|
||||
userAgent: "system",
|
||||
containsPii: false,
|
||||
piiCategories: null,
|
||||
outcome: "success",
|
||||
errorCode: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { docs: [{ id: "alice", email: "a@ex.com" }] };
|
||||
});
|
||||
const getPayload = vi.fn(async () => ({ find }));
|
||||
const config = {
|
||||
collections: [
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
subject: { field: "id", kind: "self" },
|
||||
pii: { email: { exportable: true } },
|
||||
},
|
||||
},
|
||||
{ slug: "audit-logs" },
|
||||
],
|
||||
} as unknown as SanitizedConfig;
|
||||
|
||||
const exporter = new PayloadDataExport(config, auditLog, getPayload);
|
||||
const bundle = await exporter.exportSubjectData("alice", "json");
|
||||
|
||||
expect(find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
collection: "audit-logs",
|
||||
where: { actorId: { equals: "alice" } },
|
||||
}),
|
||||
);
|
||||
expect(bundle.auditLog).toHaveLength(1);
|
||||
const entry = bundle.auditLog![0]!;
|
||||
expect(entry.actorId).toBe("alice");
|
||||
expect(entry.action).toBe("EXPORT");
|
||||
expect(entry.correlationId).toBe("corr-1");
|
||||
expect(entry.at).toEqual(new Date("2026-01-01T00:00:00.000Z"));
|
||||
expect(entry.scope).toEqual({
|
||||
feature: "core-dsr",
|
||||
environment: "test",
|
||||
tenant: "default",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves bundle.auditLog undefined when the audit-logs collection is absent", async () => {
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
subject: { field: "id", kind: "self" },
|
||||
pii: { email: { exportable: true } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
const auditLog = new RecordingAuditLog();
|
||||
const mock = makeMockPayload();
|
||||
mock.find.mockResolvedValue({ docs: [{ id: "alice", email: "x" }] });
|
||||
const exporter = new PayloadDataExport(
|
||||
config,
|
||||
auditLog,
|
||||
vi.fn().mockResolvedValue(mock),
|
||||
);
|
||||
const bundle = await exporter.exportSubjectData("alice", "json");
|
||||
expect(bundle.auditLog).toBeUndefined();
|
||||
// no stray find against a non-registered collection
|
||||
expect(
|
||||
mock.find.mock.calls.some(
|
||||
(c) => (c[0] as { collection: string }).collection === "audit-logs",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { IDataDelete } from "../data-delete.interface";
|
||||
import type { IDataRectify } from "../data-rectify.interface";
|
||||
import type { IProcessingRestriction } from "../processing-restriction.interface";
|
||||
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 { PayloadProcessingRestriction } from "../payload-processing-restriction";
|
||||
|
||||
@@ -19,6 +19,12 @@ export type DsrBinding = {
|
||||
export type BindProductionDsrOpts = {
|
||||
config: SanitizedConfig;
|
||||
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 () => {} };
|
||||
@@ -34,7 +40,12 @@ export function bindProductionDsr(opts: BindProductionDsrOpts): DsrBinding {
|
||||
const auditLog = opts.auditLog ?? noopAuditLog;
|
||||
return {
|
||||
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),
|
||||
processingRestriction: new PayloadProcessingRestriction(
|
||||
opts.config,
|
||||
|
||||
@@ -29,21 +29,65 @@ const dsrProcedure = t.procedure
|
||||
.use(requireAuthenticated)
|
||||
.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.
|
||||
*
|
||||
* Capture `binding` at router-creation time. Apps that mount this router
|
||||
* must pass the `DsrBinding` returned by `bindProductionDsr` or `bindDevSeedDsr`.
|
||||
* The binding is resolved per request from `ctx.dsrBinding` (audit finding
|
||||
* A11 — the mounted router must be live, not a dead stub), falling back to
|
||||
* the optional `binding` captured at router-creation time.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // creation-time binding
|
||||
* const binding = bindProductionDsr({ config, auditLog });
|
||||
* 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) {
|
||||
// Handlers are created lazily (inside procedure closures) so that the
|
||||
// dsrRouter singleton proxy doesn't trigger at module init time.
|
||||
export function createDsrRouter(binding?: DsrBinding) {
|
||||
return t.router({
|
||||
export: dsrProcedure
|
||||
.input(
|
||||
@@ -54,8 +98,10 @@ export function createDsrRouter(binding: DsrBinding) {
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const res = await createExportHandler(binding.dataExport)(input);
|
||||
.query(async ({ ctx, input }) => {
|
||||
assertSubjectScope(userFromCtx(ctx), input.subjectId);
|
||||
const b = bindingFromCtx(ctx, binding);
|
||||
const res = await createExportHandler(b.dataExport)(input);
|
||||
return res.body;
|
||||
}),
|
||||
|
||||
@@ -69,16 +115,16 @@ export function createDsrRouter(binding: DsrBinding) {
|
||||
.strict(),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.mode === "cascade-hard") {
|
||||
const user = (ctx as { user: DsrTrpcUser }).user;
|
||||
if (!user.roles?.includes("admin")) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Admin role required for cascade-hard deletion",
|
||||
});
|
||||
}
|
||||
const user = userFromCtx(ctx);
|
||||
assertSubjectScope(user, input.subjectId);
|
||||
if (input.mode === "cascade-hard" && !user.roles?.includes("admin")) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Admin role required for cascade-hard deletion",
|
||||
});
|
||||
}
|
||||
const res = await createDeleteHandler(binding.dataDelete)(input);
|
||||
const b = bindingFromCtx(ctx, binding);
|
||||
const res = await createDeleteHandler(b.dataDelete)(input);
|
||||
return res.body;
|
||||
}),
|
||||
|
||||
@@ -93,9 +139,11 @@ export function createDsrRouter(binding: DsrBinding) {
|
||||
})
|
||||
.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
|
||||
const res = await createRectifyHandler(binding.dataRectify)(
|
||||
const res = await createRectifyHandler(b.dataRectify)(
|
||||
input as RectifyHandlerInput,
|
||||
);
|
||||
return res.body;
|
||||
@@ -110,30 +158,21 @@ export function createDsrRouter(binding: DsrBinding) {
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const res = await createRestrictHandler(binding.processingRestriction)(
|
||||
input,
|
||||
);
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
assertSubjectScope(userFromCtx(ctx), input.subjectId);
|
||||
const b = bindingFromCtx(ctx, binding);
|
||||
const res = await createRestrictHandler(b.processingRestriction)(input);
|
||||
return res.body;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience singleton for projects with a single DSR binding instance.
|
||||
* Most callers should use `createDsrRouter(binding)` and pass the binding
|
||||
* explicitly. This export exists for type inference (`DsrRouter`) only.
|
||||
* Router singleton mounted by the app router. It has no creation-time
|
||||
* binding: every procedure resolves `ctx.dsrBinding`, which the app's
|
||||
* `createContext` supplies per request (A11). Calls without a context
|
||||
* binding fail with INTERNAL_SERVER_ERROR at request time.
|
||||
*/
|
||||
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 const dsrRouter = createDsrRouter();
|
||||
|
||||
export type DsrRouter = ReturnType<typeof createDsrRouter>;
|
||||
|
||||
@@ -24,6 +24,7 @@ export type {
|
||||
|
||||
export { PayloadDataExport } from "./payload-data-export";
|
||||
export { PayloadDataDelete } from "./payload-data-delete";
|
||||
export type { AuditErasure } from "./payload-data-delete";
|
||||
export { PayloadDataRectify } from "./payload-data-rectify";
|
||||
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 { 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 { createExportHandler } from "./handlers/export-handler";
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { getPayload as _getPayload } from "payload";
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash } from "node:crypto";
|
||||
import { randomUUID, createHmac } from "node:crypto";
|
||||
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 {
|
||||
DeletionMode,
|
||||
@@ -35,6 +38,15 @@ type 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> {
|
||||
return field === "id"
|
||||
? { id: { equals: subjectId } }
|
||||
@@ -108,6 +120,7 @@ export class PayloadDataDelete implements IDataDelete {
|
||||
private readonly config: SanitizedConfig,
|
||||
private readonly auditLog: AuditLogProtocol,
|
||||
private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload,
|
||||
private readonly auditErasure?: AuditErasure,
|
||||
) {}
|
||||
|
||||
async deleteSubjectData(
|
||||
@@ -142,6 +155,7 @@ export class PayloadDataDelete implements IDataDelete {
|
||||
subjectId,
|
||||
correlationId,
|
||||
affected,
|
||||
hasPostDeletionPolicy(collection),
|
||||
);
|
||||
} else {
|
||||
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(
|
||||
subjectId,
|
||||
mode,
|
||||
@@ -175,6 +197,7 @@ export class PayloadDataDelete implements IDataDelete {
|
||||
subjectId: string,
|
||||
correlationId: string,
|
||||
affected: DeletionAffected[],
|
||||
postDeletionPolicy: boolean,
|
||||
): Promise<void> {
|
||||
const piiMeta = custom.pii ?? {};
|
||||
const exportableFields = Object.entries(piiMeta)
|
||||
@@ -183,10 +206,17 @@ export class PayloadDataDelete implements IDataDelete {
|
||||
|
||||
if (mode === "soft") {
|
||||
const kind = custom.subject?.kind;
|
||||
const nowIso = new Date().toISOString();
|
||||
const extraData: Record<string, unknown> =
|
||||
kind === "self"
|
||||
? { processingRestrictedAt: new Date().toISOString() }
|
||||
: {};
|
||||
kind === "self" ? { processingRestrictedAt: nowIso } : {};
|
||||
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(
|
||||
payload,
|
||||
slug,
|
||||
@@ -290,9 +320,20 @@ export class PayloadDataDelete implements IDataDelete {
|
||||
correlationId: string,
|
||||
affected: DeletionAffected[],
|
||||
): 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 =
|
||||
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;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getPayload as _getPayload } from "payload";
|
||||
import type { SanitizedConfig } from "payload";
|
||||
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 {
|
||||
DsrFormat,
|
||||
@@ -42,6 +43,54 @@ type 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
|
||||
* `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({
|
||||
actorId: subjectId,
|
||||
actorType: "user",
|
||||
@@ -136,6 +200,10 @@ export class PayloadDataExport implements IDataExport {
|
||||
data,
|
||||
};
|
||||
|
||||
if (auditEntries) {
|
||||
bundle.auditLog = auditEntries;
|
||||
}
|
||||
|
||||
if (format === "json-ld") {
|
||||
bundle["@context"] = USER_DATA_JSONLD_CONTEXT;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"./payload": "./src/payload/index.ts",
|
||||
"./trpc/init": "./src/trpc/init.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",
|
||||
"./instrumentation": "./src/instrumentation/index.ts",
|
||||
"./instrumentation/otel": "./src/instrumentation/otel/index.ts",
|
||||
|
||||
@@ -25,6 +25,15 @@ export {
|
||||
buildPurgeHandler,
|
||||
registerRetentionPurgeJobs,
|
||||
} 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 {
|
||||
PayloadPurgeApi,
|
||||
GetPayloadFn,
|
||||
|
||||
@@ -377,6 +377,189 @@ describe("buildPurgeHandler — hard-delete", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---- buildPurgeHandler — postDeletion grace purge (A2) ----
|
||||
|
||||
describe("buildPurgeHandler — postDeletion grace purge", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function postDeletionOnlyConfig(
|
||||
action: "hard-delete" | "pseudonymize",
|
||||
fields: MockCollection["fields"] = [],
|
||||
) {
|
||||
return makeConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
postDeletion: {
|
||||
action,
|
||||
duration: "P30D",
|
||||
trigger: "after-deletion",
|
||||
},
|
||||
},
|
||||
},
|
||||
fields,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
it("queries soft-deleted rows by the deletedAt tombstone cutoff", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const payload = makePayloadApi([]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config: postDeletionOnlyConfig("hard-delete"),
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await buildPurgeHandler("users", deps)();
|
||||
|
||||
expect(payload.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
collection: "users",
|
||||
where: {
|
||||
deletedAt: {
|
||||
less_than: new Date(
|
||||
Date.now() - parseDurationMs("P30D"),
|
||||
).toISOString(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("hard-deletes rows whose tombstone is past the grace period (postDeletion-only collection)", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const payload = makePayloadApi([
|
||||
{ id: "row-old", deletedAt: "2025-11-01T00:00:00.000Z" }, // 61 days
|
||||
{ id: "row-fresh", deletedAt: "2025-12-25T00:00:00.000Z" }, // 7 days
|
||||
{ id: "row-live" }, // never soft-deleted
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config: postDeletionOnlyConfig("hard-delete"),
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await buildPurgeHandler("users", deps)();
|
||||
|
||||
expect(payload.delete).toHaveBeenCalledTimes(1);
|
||||
expect(payload.delete).toHaveBeenCalledWith({
|
||||
collection: "users",
|
||||
id: "row-old",
|
||||
overrideAccess: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("pseudonymizes PII fields when postDeletion.action is pseudonymize", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const payload = makePayloadApi([
|
||||
{ id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" },
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config: postDeletionOnlyConfig("pseudonymize", [
|
||||
{ name: "email", custom: { pii: { category: "contact-email" } } },
|
||||
{ name: "status" },
|
||||
]),
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await buildPurgeHandler("users", deps)();
|
||||
|
||||
expect(payload.update).toHaveBeenCalledWith({
|
||||
collection: "users",
|
||||
id: "row-old",
|
||||
data: { email: null },
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(payload.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records a retention-policy audit entry per purged row", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const { auditLog, record } = makeAuditLog();
|
||||
const payload = makePayloadApi([
|
||||
{ id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" },
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config: postDeletionOnlyConfig("hard-delete"),
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
auditLog,
|
||||
};
|
||||
await buildPurgeHandler("users", deps)();
|
||||
|
||||
expect(record).toHaveBeenCalledTimes(1);
|
||||
expect(record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "DELETE",
|
||||
reason: "retention-policy",
|
||||
resource: { type: "users", id: "row-old" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("still runs activeRetention alongside postDeletion", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const payload = makePayloadApi([
|
||||
{ id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" },
|
||||
]);
|
||||
const config = makeConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
activeRetention: { duration: "P2Y", trigger: "from-creation" },
|
||||
postDeletion: {
|
||||
action: "hard-delete",
|
||||
duration: "P30D",
|
||||
trigger: "after-deletion",
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [],
|
||||
},
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config,
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await buildPurgeHandler("users", deps)();
|
||||
|
||||
// one find per branch: createdAt (activeRetention) + deletedAt (postDeletion)
|
||||
expect(payload.find).toHaveBeenCalledTimes(2);
|
||||
// the fake returns the tombstoned row for both branches: the
|
||||
// activeRetention branch deletes it by date, the postDeletion branch by
|
||||
// tombstone — 2 delete calls for the same doc through different policies.
|
||||
expect(payload.delete).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("re-enqueues the next cycle for postDeletion-only collections", async () => {
|
||||
const { queue, enqueue } = makeQueue();
|
||||
const payload = makePayloadApi([]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config: postDeletionOnlyConfig("hard-delete"),
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await buildPurgeHandler("users", deps)();
|
||||
|
||||
expect(enqueue).toHaveBeenCalledWith(
|
||||
"retention-purge--users",
|
||||
{},
|
||||
{ runAt: new Date("2026-01-02T00:00:00.000Z") },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- buildPurgeHandler — pseudonymize branch ----
|
||||
|
||||
describe("buildPurgeHandler — pseudonymize", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import type { IJobQueue } from "../../jobs/job-queue.interface";
|
||||
import type { AuditLogProtocol } from "../../di/bind-protocols";
|
||||
import { RETENTION_TOMBSTONE_FIELD } from "./tombstone";
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
type RetentionActionName = "pseudonymize" | "hard-delete";
|
||||
|
||||
/**
|
||||
* Build the purge handler for a single collection. The returned async function
|
||||
* is intended to be registered as a Payload job task handler.
|
||||
*
|
||||
* Per run:
|
||||
* 1. Query rows past their activeRetention period.
|
||||
* 2. Apply postDeletion.action (pseudonymize | hard-delete).
|
||||
* 1. activeRetention (when declared): query rows past the active period
|
||||
* (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).
|
||||
* 4. Re-enqueue itself for the next purge cycle.
|
||||
*
|
||||
@@ -101,8 +108,64 @@ export function buildPurgeHandler(
|
||||
);
|
||||
}
|
||||
|
||||
// Re-bind after the guard: narrowing does not flow into hoisted closures.
|
||||
const targetCollection = collection;
|
||||
|
||||
const taskSlug = `retention-purge--${collectionSlug}`;
|
||||
|
||||
async function applyAction(
|
||||
payload: PayloadPurgeApi,
|
||||
doc: Record<string, unknown>,
|
||||
action: RetentionActionName,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
const id = doc["id"] as string | number;
|
||||
|
||||
if (action === "pseudonymize") {
|
||||
const piiFields: Record<string, null> = {};
|
||||
for (const field of targetCollection.fields) {
|
||||
const f = field as { name?: string; custom?: { pii?: unknown } };
|
||||
if (f.name && f.custom?.pii) {
|
||||
piiFields[f.name] = null;
|
||||
}
|
||||
}
|
||||
if (Object.keys(piiFields).length > 0) {
|
||||
await payload.update({
|
||||
collection: collectionSlug,
|
||||
id,
|
||||
data: piiFields,
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await payload.delete({
|
||||
collection: collectionSlug,
|
||||
id,
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (auditLog) {
|
||||
await auditLog.record({
|
||||
actorId: "system",
|
||||
actorType: "system",
|
||||
actorRoles: [],
|
||||
action: "DELETE",
|
||||
resource: { type: collectionSlug, id: String(id) },
|
||||
at: new Date(),
|
||||
scope: {
|
||||
feature: "core-shared",
|
||||
environment: process.env["NODE_ENV"] ?? "production",
|
||||
tenant: "default",
|
||||
},
|
||||
reason,
|
||||
from: { ipTruncated: "system", userAgent: "background-job" },
|
||||
containsPii: false,
|
||||
outcome: "success",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return async () => {
|
||||
const payload = await getPayload({ config });
|
||||
const now = Date.now();
|
||||
@@ -123,51 +186,37 @@ export function buildPurgeHandler(
|
||||
const action = retention.postDeletion?.action ?? "hard-delete";
|
||||
|
||||
for (const doc of docs) {
|
||||
const id = doc["id"] as string | number;
|
||||
await applyAction(payload, doc, action, "retention-policy");
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "pseudonymize") {
|
||||
const piiFields: Record<string, null> = {};
|
||||
for (const field of collection.fields) {
|
||||
const f = field as { name?: string; custom?: { pii?: unknown } };
|
||||
if (f.name && f.custom?.pii) {
|
||||
piiFields[f.name] = null;
|
||||
}
|
||||
}
|
||||
if (Object.keys(piiFields).length > 0) {
|
||||
await payload.update({
|
||||
collection: collectionSlug,
|
||||
id,
|
||||
data: piiFields,
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await payload.delete({
|
||||
collection: collectionSlug,
|
||||
id,
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
if (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();
|
||||
|
||||
if (auditLog) {
|
||||
await auditLog.record({
|
||||
actorId: "system",
|
||||
actorType: "system",
|
||||
actorRoles: [],
|
||||
action: "DELETE",
|
||||
resource: { type: collectionSlug, id: String(id) },
|
||||
at: new Date(),
|
||||
scope: {
|
||||
feature: "core-shared",
|
||||
environment: process.env["NODE_ENV"] ?? "production",
|
||||
tenant: "default",
|
||||
},
|
||||
reason: "retention-policy",
|
||||
from: { ipTruncated: "system", userAgent: "background-job" },
|
||||
containsPii: false,
|
||||
outcome: "success",
|
||||
});
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +238,7 @@ export async function registerRetentionPurgeJobs(
|
||||
const { queue, config } = deps;
|
||||
const now = Date.now();
|
||||
|
||||
for (const collection of config.collections) {
|
||||
for (const collection of config.collections ?? []) {
|
||||
const retention = collection.custom?.retention;
|
||||
if (!retention?.purgeSchedule) continue;
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { Payload } from "payload";
|
||||
import { buildRetentionPurgeTask } from "@/payload/retention-purge/task";
|
||||
|
||||
function makeFakePayload() {
|
||||
const find = vi.fn().mockResolvedValue({ docs: [] });
|
||||
const jobsQueue = vi.fn().mockResolvedValue({ id: "job-1" });
|
||||
const payload = {
|
||||
config: {
|
||||
collections: [
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
postDeletion: {
|
||||
duration: "P30D",
|
||||
trigger: "after-deletion",
|
||||
action: "hard-delete",
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
find,
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
jobs: { queue: jobsQueue },
|
||||
} as unknown as Payload;
|
||||
return { payload, find, jobsQueue };
|
||||
}
|
||||
|
||||
describe("buildRetentionPurgeTask (A3)", () => {
|
||||
it("uses the retention-purge--<slug> task slug", () => {
|
||||
expect(buildRetentionPurgeTask("users").slug).toBe(
|
||||
"retention-purge--users",
|
||||
);
|
||||
});
|
||||
|
||||
it("runs the purge against req.payload and re-enqueues the next cycle", async () => {
|
||||
const { payload, find, jobsQueue } = makeFakePayload();
|
||||
const task = buildRetentionPurgeTask("users");
|
||||
|
||||
const result = await task.handler({ req: { payload } });
|
||||
|
||||
expect(result).toEqual({ output: {} });
|
||||
// postDeletion branch queried the tombstone field
|
||||
expect(find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
collection: "users",
|
||||
where: { deletedAt: expect.anything() },
|
||||
}),
|
||||
);
|
||||
// self-re-enqueue went through the payload job queue
|
||||
expect(jobsQueue).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ task: "retention-purge--users" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
42
packages/core-shared/src/payload/retention-purge/task.ts
Normal file
42
packages/core-shared/src/payload/retention-purge/task.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Payload } from "payload";
|
||||
import { PayloadJobQueue } from "../../jobs/payload-job-queue";
|
||||
import { buildPurgeHandler, type PayloadPurgeApi } from "./retention-purge.job";
|
||||
|
||||
/**
|
||||
* Minimal shape of a Payload job-task definition — enough for
|
||||
* `payload.config.ts` `jobs.tasks` composition without dragging the full
|
||||
* generated TaskConfig generics through core-shared.
|
||||
*/
|
||||
export type RetentionPurgeTask = {
|
||||
slug: string;
|
||||
handler: (args: { req: { payload: Payload } }) => Promise<{
|
||||
output: Record<string, never>;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the Payload job-task definition for one collection's retention purge
|
||||
* (audit finding A3): `registerRetentionPurgeJobs` enqueues
|
||||
* `retention-purge--<slug>` tasks at boot, and this definition is what makes
|
||||
* Payload able to RUN them. Everything the handler needs comes from the
|
||||
* running instance on `req.payload` (config, local API, job queue for the
|
||||
* self-re-enqueue), so the task can be declared at config-composition time
|
||||
* with no bootstrapping order problems.
|
||||
*/
|
||||
export function buildRetentionPurgeTask(
|
||||
collectionSlug: string,
|
||||
): RetentionPurgeTask {
|
||||
return {
|
||||
slug: `retention-purge--${collectionSlug}`,
|
||||
handler: async ({ req }) => {
|
||||
const payload = req.payload;
|
||||
const run = buildPurgeHandler(collectionSlug, {
|
||||
queue: new PayloadJobQueue(payload),
|
||||
config: payload.config,
|
||||
getPayload: async () => payload as unknown as PayloadPurgeApi,
|
||||
});
|
||||
await run();
|
||||
return { output: {} };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { CollectionConfig } from "payload";
|
||||
import {
|
||||
RETENTION_TOMBSTONE_FIELD,
|
||||
hasPostDeletionPolicy,
|
||||
withRetentionTombstone,
|
||||
} from "@/payload/retention-purge/tombstone";
|
||||
|
||||
function makeCollection(custom?: Record<string, unknown>): CollectionConfig {
|
||||
return {
|
||||
slug: "things",
|
||||
custom,
|
||||
fields: [{ name: "title", type: "text" }],
|
||||
} as CollectionConfig;
|
||||
}
|
||||
|
||||
const postDeletionRetention = {
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
postDeletion: {
|
||||
duration: "P30D",
|
||||
trigger: "after-deletion",
|
||||
action: "hard-delete",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("hasPostDeletionPolicy", () => {
|
||||
it("is true when custom.retention.postDeletion is declared", () => {
|
||||
expect(hasPostDeletionPolicy(makeCollection(postDeletionRetention))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("is false without retention or postDeletion", () => {
|
||||
expect(hasPostDeletionPolicy(makeCollection())).toBe(false);
|
||||
expect(
|
||||
hasPostDeletionPolicy(
|
||||
makeCollection({ retention: { purgeSchedule: "daily" } }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withRetentionTombstone", () => {
|
||||
it("appends the deletedAt field to postDeletion collections", () => {
|
||||
const result = withRetentionTombstone(
|
||||
makeCollection(postDeletionRetention),
|
||||
);
|
||||
const names = result.fields.map((f) => (f as { name?: string }).name);
|
||||
expect(names).toContain(RETENTION_TOMBSTONE_FIELD);
|
||||
const tombstone = result.fields.find(
|
||||
(f) => (f as { name?: string }).name === RETENTION_TOMBSTONE_FIELD,
|
||||
) as { type?: string; index?: boolean };
|
||||
expect(tombstone.type).toBe("date");
|
||||
expect(tombstone.index).toBe(true);
|
||||
});
|
||||
|
||||
it("returns collections without a postDeletion policy unchanged", () => {
|
||||
const collection = makeCollection();
|
||||
expect(withRetentionTombstone(collection)).toBe(collection);
|
||||
});
|
||||
|
||||
it("does not duplicate an already-declared tombstone field", () => {
|
||||
const collection = {
|
||||
...makeCollection(postDeletionRetention),
|
||||
fields: [{ name: RETENTION_TOMBSTONE_FIELD, type: "date" }],
|
||||
} as CollectionConfig;
|
||||
const result = withRetentionTombstone(collection);
|
||||
expect(
|
||||
result.fields.filter(
|
||||
(f) => (f as { name?: string }).name === RETENTION_TOMBSTONE_FIELD,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not mutate the input collection", () => {
|
||||
const collection = makeCollection(postDeletionRetention);
|
||||
const before = collection.fields.length;
|
||||
withRetentionTombstone(collection);
|
||||
expect(collection.fields.length).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { CollectionConfig, Field } from "payload";
|
||||
import type { CollectionRetention } from "../retention-types";
|
||||
|
||||
/**
|
||||
* Field name marking a row as soft-deleted.
|
||||
*
|
||||
* Written by the DSR soft-delete path (`PayloadDataDelete`, mode "soft") on
|
||||
* rows the subject owns, and read by the retention purge job's
|
||||
* `postDeletion` branch: rows whose tombstone is older than
|
||||
* `postDeletion.duration` are purged with the declared action
|
||||
* (hard-delete | pseudonymize).
|
||||
*
|
||||
* Deliberately distinct from `processingRestrictedAt` — an Art. 18
|
||||
* processing restriction is NOT a deletion request and must never feed the
|
||||
* grace-period purge.
|
||||
*/
|
||||
export const RETENTION_TOMBSTONE_FIELD = "deletedAt";
|
||||
|
||||
function collectionRetention(
|
||||
collection: CollectionConfig,
|
||||
): CollectionRetention | undefined {
|
||||
return (collection.custom as { retention?: CollectionRetention } | undefined)
|
||||
?.retention;
|
||||
}
|
||||
|
||||
/** True when the collection declares a postDeletion grace-purge policy. */
|
||||
export function hasPostDeletionPolicy(collection: {
|
||||
custom?: Record<string, unknown>;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
(collection.custom as { retention?: CollectionRetention } | undefined)
|
||||
?.retention?.postDeletion,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a collection that declares `custom.retention.postDeletion` carries
|
||||
* the soft-delete tombstone field so (a) the DSR soft delete can stamp it and
|
||||
* (b) the purge job can query it. Collections without a postDeletion policy
|
||||
* (or that already define the field) are returned unchanged.
|
||||
*
|
||||
* Apply at config-composition time (core-cms payload.config.ts).
|
||||
*/
|
||||
export function withRetentionTombstone(
|
||||
collection: CollectionConfig,
|
||||
): CollectionConfig {
|
||||
if (!collectionRetention(collection)?.postDeletion) return collection;
|
||||
|
||||
const alreadyDeclared = collection.fields.some(
|
||||
(f) => (f as { name?: string }).name === RETENTION_TOMBSTONE_FIELD,
|
||||
);
|
||||
if (alreadyDeclared) return collection;
|
||||
|
||||
const tombstoneField: Field = {
|
||||
name: RETENTION_TOMBSTONE_FIELD,
|
||||
type: "date",
|
||||
index: true,
|
||||
admin: {
|
||||
hidden: true,
|
||||
description:
|
||||
"Soft-delete tombstone (DSR Art. 17). Rows older than the " +
|
||||
"postDeletion grace period are purged by the retention job.",
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...collection,
|
||||
fields: [...collection.fields, tombstoneField],
|
||||
};
|
||||
}
|
||||
89
packages/core-shared/src/trpc/context.test.ts
Normal file
89
packages/core-shared/src/trpc/context.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { clientIpFromHeaders, createTrpcContext } from "@/trpc/context";
|
||||
|
||||
describe("clientIpFromHeaders", () => {
|
||||
it("takes the first x-forwarded-for hop", () => {
|
||||
const headers = new Headers({
|
||||
"x-forwarded-for": "203.0.113.7, 10.0.0.1, 10.0.0.2",
|
||||
});
|
||||
expect(clientIpFromHeaders(headers)).toBe("203.0.113.7");
|
||||
});
|
||||
|
||||
it("trims whitespace around the first hop", () => {
|
||||
const headers = new Headers({
|
||||
"x-forwarded-for": " 203.0.113.7 , 10.0.0.1",
|
||||
});
|
||||
expect(clientIpFromHeaders(headers)).toBe("203.0.113.7");
|
||||
});
|
||||
|
||||
it("falls back to x-real-ip when x-forwarded-for is absent", () => {
|
||||
const headers = new Headers({ "x-real-ip": "198.51.100.4" });
|
||||
expect(clientIpFromHeaders(headers)).toBe("198.51.100.4");
|
||||
});
|
||||
|
||||
it("returns undefined when neither header is present", () => {
|
||||
expect(clientIpFromHeaders(new Headers())).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for empty header values", () => {
|
||||
const headers = new Headers({ "x-forwarded-for": " ", "x-real-ip": "" });
|
||||
expect(clientIpFromHeaders(headers)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTrpcContext", () => {
|
||||
it("attaches the derived clientIp from the request", async () => {
|
||||
const req = new Request("https://example.test/api/trpc", {
|
||||
headers: { "x-forwarded-for": "203.0.113.7" },
|
||||
});
|
||||
await expect(createTrpcContext(req)).resolves.toEqual({
|
||||
clientIp: "203.0.113.7",
|
||||
});
|
||||
});
|
||||
|
||||
it("yields an undefined clientIp without a request", async () => {
|
||||
await expect(createTrpcContext()).resolves.toEqual({
|
||||
clientIp: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches the resolved user and mirrors userId (A11)", async () => {
|
||||
const req = new Request("https://example.test/api/trpc");
|
||||
const ctx = await createTrpcContext(req, {
|
||||
resolveUser: async () => ({ id: "user-1", roles: ["admin"] }),
|
||||
});
|
||||
expect(ctx.user).toEqual({ id: "user-1", roles: ["admin"] });
|
||||
expect(ctx.userId).toBe("user-1");
|
||||
});
|
||||
|
||||
it("treats a null resolver result as anonymous", async () => {
|
||||
const req = new Request("https://example.test/api/trpc");
|
||||
const ctx = await createTrpcContext(req, {
|
||||
resolveUser: async () => null,
|
||||
});
|
||||
expect(ctx.user).toBeUndefined();
|
||||
expect(ctx.userId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats a throwing resolver as anonymous instead of failing", async () => {
|
||||
const req = new Request("https://example.test/api/trpc");
|
||||
const ctx = await createTrpcContext(req, {
|
||||
resolveUser: async () => {
|
||||
throw new Error("expired session");
|
||||
},
|
||||
});
|
||||
expect(ctx.user).toBeUndefined();
|
||||
expect(ctx.clientIp).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not invoke the resolver without a request", async () => {
|
||||
let called = false;
|
||||
await createTrpcContext(undefined, {
|
||||
resolveUser: async () => {
|
||||
called = true;
|
||||
return null;
|
||||
},
|
||||
});
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,69 @@
|
||||
export async function createTrpcContext() {
|
||||
return {};
|
||||
/**
|
||||
* Derive the client IP from reverse-proxy headers.
|
||||
*
|
||||
* TRUST CAVEAT (audit finding B2): `x-forwarded-for` and `x-real-ip` are
|
||||
* ordinary request headers. They are only trustworthy when the app runs
|
||||
* behind a proxy/load balancer that overwrites (or verifiably appends to)
|
||||
* them on every request. Exposed directly to the internet, a client can
|
||||
* spoof them; deployments that need a hard guarantee must read the socket
|
||||
* address at their edge and strip inbound copies of these headers.
|
||||
*
|
||||
* We take the FIRST `x-forwarded-for` entry — the client as reported by the
|
||||
* first (trusted) hop — falling back to `x-real-ip`.
|
||||
*/
|
||||
export function clientIpFromHeaders(headers: Headers): string | undefined {
|
||||
const forwarded = headers.get("x-forwarded-for");
|
||||
const firstHop = forwarded?.split(",")[0]?.trim();
|
||||
if (firstHop) return firstHop;
|
||||
const realIp = headers.get("x-real-ip")?.trim();
|
||||
return realIp || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-resolved authenticated user attached to the tRPC context.
|
||||
* Resolved from the app's session mechanism (never from client input);
|
||||
* `roles` is a snapshot for role-gated procedures (admin checks).
|
||||
*/
|
||||
export type TrpcSessionUser = {
|
||||
id: string;
|
||||
roles: string[];
|
||||
};
|
||||
|
||||
export type CreateTrpcContextOpts = {
|
||||
/**
|
||||
* App-provided session resolver (audit finding A11). Receives the incoming
|
||||
* request and returns the authenticated user, or null/undefined for
|
||||
* anonymous callers. A throwing resolver is treated as anonymous — an
|
||||
* expired or malformed session cookie must not 500 public queries;
|
||||
* procedures that need a user reject with UNAUTHORIZED instead.
|
||||
*/
|
||||
resolveUser?: (req: Request) => Promise<TrpcSessionUser | null | undefined>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the per-request tRPC context. Pass the adapter's incoming fetch
|
||||
* `Request` so server-derived fields (`clientIp`, and — when the app supplies
|
||||
* a `resolveUser` — the authenticated `user`) are attached. Procedures must
|
||||
* never trust client-supplied equivalents (B2).
|
||||
*/
|
||||
export async function createTrpcContext(
|
||||
req?: Request,
|
||||
opts: CreateTrpcContextOpts = {},
|
||||
) {
|
||||
let user: TrpcSessionUser | undefined;
|
||||
if (req && opts.resolveUser) {
|
||||
try {
|
||||
user = (await opts.resolveUser(req)) ?? undefined;
|
||||
} catch {
|
||||
user = undefined;
|
||||
}
|
||||
}
|
||||
return {
|
||||
clientIp: req ? clientIpFromHeaders(req.headers) : undefined,
|
||||
user,
|
||||
/** Convenience mirror of `user.id` (consumed by the consent router). */
|
||||
userId: user?.id,
|
||||
};
|
||||
}
|
||||
|
||||
export type TrpcContext = Awaited<ReturnType<typeof createTrpcContext>>;
|
||||
|
||||
50
packages/core-shared/src/trpc/require-authenticated.test.ts
Normal file
50
packages/core-shared/src/trpc/require-authenticated.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { t } from "@/trpc/init";
|
||||
import {
|
||||
requireAuthenticated,
|
||||
protectedProcedure,
|
||||
} from "@/trpc/require-authenticated";
|
||||
|
||||
const echoRouter = t.router({
|
||||
publicEcho: t.procedure
|
||||
.input(z.object({ value: z.string() }).strict())
|
||||
.query(({ input }) => input.value),
|
||||
protectedEcho: protectedProcedure
|
||||
.input(z.object({ value: z.string() }).strict())
|
||||
.mutation(({ input, ctx }) => ({
|
||||
value: input.value,
|
||||
userId: (ctx as { user: { id: string } }).user.id,
|
||||
})),
|
||||
composedEcho: t.procedure
|
||||
.use(requireAuthenticated)
|
||||
.input(z.object({}).strict())
|
||||
.mutation(() => "ok"),
|
||||
});
|
||||
|
||||
describe("requireAuthenticated middleware (B7)", () => {
|
||||
it("rejects anonymous callers with UNAUTHORIZED", async () => {
|
||||
const caller = echoRouter.createCaller({});
|
||||
await expect(caller.protectedEcho({ value: "x" })).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
await expect(caller.composedEcho({})).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes through authenticated callers and exposes ctx.user", async () => {
|
||||
const caller = echoRouter.createCaller({
|
||||
user: { id: "user-1", roles: [] },
|
||||
});
|
||||
await expect(caller.protectedEcho({ value: "x" })).resolves.toEqual({
|
||||
value: "x",
|
||||
userId: "user-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves public procedures untouched", async () => {
|
||||
const caller = echoRouter.createCaller({});
|
||||
await expect(caller.publicEcho({ value: "hi" })).resolves.toBe("hi");
|
||||
});
|
||||
});
|
||||
32
packages/core-shared/src/trpc/require-authenticated.ts
Normal file
32
packages/core-shared/src/trpc/require-authenticated.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { t } from "./init";
|
||||
import type { TrpcSessionUser } from "./context";
|
||||
|
||||
/**
|
||||
* Shared authentication guard for MUTATING tRPC procedures (audit finding
|
||||
* B7). Reads the server-resolved `ctx.user` (attached by `createTrpcContext`
|
||||
* via the app's `resolveUser`) and rejects anonymous callers with
|
||||
* UNAUTHORIZED. Read-only queries stay public; each feature opts its
|
||||
* mutations in by composing this middleware into its procedure chain:
|
||||
*
|
||||
* ```ts
|
||||
* export const blogProtectedProcedure = blogProcedure.use(requireAuthenticated);
|
||||
* ```
|
||||
*
|
||||
* The shared `t` is context-untyped, so the middleware narrows at runtime
|
||||
* (same cast pattern as the dsr/audit routers) and re-publishes `user` into
|
||||
* the downstream ctx with a non-optional type.
|
||||
*/
|
||||
export const requireAuthenticated = t.middleware(({ ctx, next }) => {
|
||||
const user = (ctx as { user?: TrpcSessionUser }).user;
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Authentication required",
|
||||
});
|
||||
}
|
||||
return next({ ctx: { ...ctx, user } });
|
||||
});
|
||||
|
||||
/** Convenience base procedure for apps composing ad-hoc protected routes. */
|
||||
export const protectedProcedure = t.procedure.use(requireAuthenticated);
|
||||
Reference in New Issue
Block a user