diff --git a/packages/core-audit/src/audit-logs-collection.test.ts b/packages/core-audit/src/audit-logs-collection.test.ts index 02c7ff4..68eb6fa 100644 --- a/packages/core-audit/src/audit-logs-collection.test.ts +++ b/packages/core-audit/src/audit-logs-collection.test.ts @@ -2,6 +2,26 @@ 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"); }); diff --git a/packages/core-audit/src/audit-logs-collection.ts b/packages/core-audit/src/audit-logs-collection.ts index c90b74c..143aeb3 100644 --- a/packages/core-audit/src/audit-logs-collection.ts +++ b/packages/core-audit/src/audit-logs-collection.ts @@ -44,6 +44,9 @@ export const auditLogsCollection: CollectionConfig = { { name: "action", type: "select", + // 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", @@ -51,6 +54,10 @@ export const auditLogsCollection: CollectionConfig = { "DELETE", "EXPORT", "PERMISSION_CHANGE", + "CONSENT_GRANT", + "CONSENT_WITHDRAW", + "RESTRICT", + "UNRESTRICT", ], required: true, index: true, diff --git a/packages/core-audit/src/hooks/audit-erasure-hook.test.ts b/packages/core-audit/src/hooks/audit-erasure-hook.test.ts index 6b21a09..b29735e 100644 --- a/packages/core-audit/src/hooks/audit-erasure-hook.test.ts +++ b/packages/core-audit/src/hooks/audit-erasure-hook.test.ts @@ -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 { @@ -66,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(); + }); +}); diff --git a/packages/core-audit/src/hooks/audit-erasure-hook.ts b/packages/core-audit/src/hooks/audit-erasure-hook.ts index c6001ed..ee9f85c 100644 --- a/packages/core-audit/src/hooks/audit-erasure-hook.ts +++ b/packages/core-audit/src/hooks/audit-erasure-hook.ts @@ -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); + }; +} diff --git a/packages/core-audit/src/hooks/index.ts b/packages/core-audit/src/hooks/index.ts index 11dcd14..168eda4 100644 --- a/packages/core-audit/src/hooks/index.ts +++ b/packages/core-audit/src/hooks/index.ts @@ -1,6 +1,8 @@ export { createAuditErasureHook, + createReqScopedAuditErasureHook, type AuditErasureHookOpts, + type ReqScopedAuditErasureHookOpts, } from "./audit-erasure-hook"; export { createAuditAfterReadHook, diff --git a/packages/core-audit/src/index.ts b/packages/core-audit/src/index.ts index a6d1edb..b04912b 100644 --- a/packages/core-audit/src/index.ts +++ b/packages/core-audit/src/index.ts @@ -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"; diff --git a/packages/core-audit/src/payload-audit-log.test.ts b/packages/core-audit/src/payload-audit-log.test.ts index 797157b..a128bea 100644 --- a/packages/core-audit/src/payload-audit-log.test.ts +++ b/packages/core-audit/src/payload-audit-log.test.ts @@ -118,7 +118,7 @@ describe("PayloadAuditLog.eraseSubject", () => { // 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 diff --git a/packages/core-audit/src/pseudonymize.test.ts b/packages/core-audit/src/pseudonymize.test.ts index 66d737c..97d4c93 100644 --- a/packages/core-audit/src/pseudonymize.test.ts +++ b/packages/core-audit/src/pseudonymize.test.ts @@ -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}$/); }); }); diff --git a/packages/core-audit/src/pseudonymize.ts b/packages/core-audit/src/pseudonymize.ts index 939d413..76db4d9 100644 --- a/packages/core-audit/src/pseudonymize.ts +++ b/packages/core-audit/src/pseudonymize.ts @@ -1,20 +1,34 @@ -import { createHash } from "node:crypto"; +import { createHmac } from "node:crypto"; /** * Produces a stable, irreversible token for a GDPR-erased actorId. * - * Format: `erased-` + * Format: `erased-` — + * 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)}`; } diff --git a/packages/core-cms/package.json b/packages/core-cms/package.json index 6c79204..929e7dc 100644 --- a/packages/core-cms/package.json +++ b/packages/core-cms/package.json @@ -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:*", "payload": "^3.14.0" }, "devDependencies": { diff --git a/packages/core-cms/src/payload.config.test.ts b/packages/core-cms/src/payload.config.test.ts index 76d1d92..e950cee 100644 --- a/packages/core-cms/src/payload.config.test.ts +++ b/packages/core-cms/src/payload.config.test.ts @@ -8,6 +8,39 @@ describe("payloadConfig composition", () => { expect(slugs).toEqual(expect.arrayContaining(["users"])); }); + it("adds the deletedAt tombstone to postDeletion collections (A2)", async () => { + const resolved = await config; + for (const slug of ["users"]) { + 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"]), + ); + }); + + 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) ?? []; diff --git a/packages/core-cms/src/payload.config.ts b/packages/core-cms/src/payload.config.ts index bdbef54..534387e 100644 --- a/packages/core-cms/src/payload.config.ts +++ b/packages/core-cms/src/payload.config.ts @@ -4,14 +4,45 @@ 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"; 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].map(withRetentionTombstone), + // Local audit sink (A6) — required for PayloadAuditLog.record() to work. + auditLogsCollection, +]; + export default buildConfig({ editor: lexicalEditor(), - collections: [users], + collections, globals: [], secret: process.env.PAYLOAD_SECRET || "default-secret-change-me", db: postgresAdapter({ @@ -21,6 +52,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--` 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"), }, diff --git a/packages/core-consent/package.json b/packages/core-consent/package.json index fc0d3f6..22a4755 100644 --- a/packages/core-consent/package.json +++ b/packages/core-consent/package.json @@ -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" } diff --git a/packages/core-consent/src/consent-types.ts b/packages/core-consent/src/consent-types.ts index 053dc4b..afdeec5 100644 --- a/packages/core-consent/src/consent-types.ts +++ b/packages/core-consent/src/consent-types.ts @@ -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"; diff --git a/packages/core-consent/src/consent.router.test.ts b/packages/core-consent/src/consent.router.test.ts index 080c0fe..1c81cf3 100644 --- a/packages/core-consent/src/consent.router.test.ts +++ b/packages/core-consent/src/consent.router.test.ts @@ -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({ + 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 = { diff --git a/packages/core-consent/src/consent.router.ts b/packages/core-consent/src/consent.router.ts index 9ecc8c9..a0750cd 100644 --- a/packages/core-consent/src/consent.router.ts +++ b/packages/core-consent/src/consent.router.ts @@ -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().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; + 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); }), }); diff --git a/packages/core-consent/src/index.ts b/packages/core-consent/src/index.ts index de08935..f7dc110 100644 --- a/packages/core-consent/src/index.ts +++ b/packages/core-consent/src/index.ts @@ -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"; diff --git a/packages/core-consent/src/migration.test.ts b/packages/core-consent/src/migration.test.ts index 2236061..42d7897 100644 --- a/packages/core-consent/src/migration.test.ts +++ b/packages/core-consent/src/migration.test.ts @@ -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", + ]); + }); +}); diff --git a/packages/core-consent/src/migration.ts b/packages/core-consent/src/migration.ts index b3f13d5..f4205f7 100644 --- a/packages/core-consent/src/migration.ts +++ b/packages/core-consent/src/migration.ts @@ -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); } } diff --git a/packages/core-consent/src/payload-consent.test.ts b/packages/core-consent/src/payload-consent.test.ts index dfb2ea9..2d56c0f 100644 --- a/packages/core-consent/src/payload-consent.test.ts +++ b/packages/core-consent/src/payload-consent.test.ts @@ -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[] { + 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((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"]); + }); +}); diff --git a/packages/core-consent/src/payload-consent.ts b/packages/core-consent/src/payload-consent.ts index 7394061..4c544c9 100644 --- a/packages/core-consent/src/payload-consent.ts +++ b/packages/core-consent/src/payload-consent.ts @@ -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 { + /** + * 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 { 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(); + const rawState = doc["consentState"]; + if (Array.isArray(rawState)) { + for (const raw of rawState) { + if (raw && typeof raw === "object") { + const entry = deserializeEntry(raw as Record); + 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, diff --git a/packages/core-dsr/src/__tests__/dsr.router.test.ts b/packages/core-dsr/src/__tests__/dsr.router.test.ts index 063642b..7eb137b 100644 --- a/packages/core-dsr/src/__tests__/dsr.router.test.ts +++ b/packages/core-dsr/src/__tests__/dsr.router.test.ts @@ -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); 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); + 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); + await caller.export({ subjectId: "alice", format: "json" }); + expect(ctxBinding.dataExport.calls).toHaveLength(1); + expect(creationBinding.dataExport.calls).toHaveLength(0); }); }); diff --git a/packages/core-dsr/src/__tests__/payload-data-delete.test.ts b/packages/core-dsr/src/__tests__/payload-data-delete.test.ts index 01d6c7b..d9986e0 100644 --- a/packages/core-dsr/src/__tests__/payload-data-delete.test.ts +++ b/packages/core-dsr/src/__tests__/payload-data-delete.test.ts @@ -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(); + }); +}); diff --git a/packages/core-dsr/src/__tests__/payload-data-export.test.ts b/packages/core-dsr/src/__tests__/payload-data-export.test.ts index e0de423..699fafd 100644 --- a/packages/core-dsr/src/__tests__/payload-data-export.test.ts +++ b/packages/core-dsr/src/__tests__/payload-data-export.test.ts @@ -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); + }); +}); diff --git a/packages/core-dsr/src/di/bind-production.ts b/packages/core-dsr/src/di/bind-production.ts index dced427..d8d3394 100644 --- a/packages/core-dsr/src/di/bind-production.ts +++ b/packages/core-dsr/src/di/bind-production.ts @@ -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, diff --git a/packages/core-dsr/src/dsr.router.ts b/packages/core-dsr/src/dsr.router.ts index 2600baa..915c128 100644 --- a/packages/core-dsr/src/dsr.router.ts +++ b/packages/core-dsr/src/dsr.router.ts @@ -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; diff --git a/packages/core-dsr/src/index.ts b/packages/core-dsr/src/index.ts index 15c5aa0..44bc795 100644 --- a/packages/core-dsr/src/index.ts +++ b/packages/core-dsr/src/index.ts @@ -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"; diff --git a/packages/core-dsr/src/payload-data-delete.ts b/packages/core-dsr/src/payload-data-delete.ts index 6513a13..1b3c243 100644 --- a/packages/core-dsr/src/payload-data-delete.ts +++ b/packages/core-dsr/src/payload-data-delete.ts @@ -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; +/** + * 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; +}; + function buildWhere(field: string, subjectId: string): Record { 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 { 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 = - 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 { diff --git a/packages/core-dsr/src/payload-data-export.ts b/packages/core-dsr/src/payload-data-export.ts index 647ea63..158f8ed 100644 --- a/packages/core-dsr/src/payload-data-export.ts +++ b/packages/core-dsr/src/payload-data-export.ts @@ -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; +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; } diff --git a/packages/core-shared/package.json b/packages/core-shared/package.json index 6dca7da..c5ff8f3 100644 --- a/packages/core-shared/package.json +++ b/packages/core-shared/package.json @@ -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", diff --git a/packages/core-shared/src/payload/index.ts b/packages/core-shared/src/payload/index.ts index 900c133..1df24e5 100644 --- a/packages/core-shared/src/payload/index.ts +++ b/packages/core-shared/src/payload/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, diff --git a/packages/core-shared/src/payload/retention-purge/retention-purge.job.test.ts b/packages/core-shared/src/payload/retention-purge/retention-purge.job.test.ts index 942a852..4bd6cce 100644 --- a/packages/core-shared/src/payload/retention-purge/retention-purge.job.test.ts +++ b/packages/core-shared/src/payload/retention-purge/retention-purge.job.test.ts @@ -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", () => { diff --git a/packages/core-shared/src/payload/retention-purge/retention-purge.job.ts b/packages/core-shared/src/payload/retention-purge/retention-purge.job.ts index 5405fc4..b84a193 100644 --- a/packages/core-shared/src/payload/retention-purge/retention-purge.job.ts +++ b/packages/core-shared/src/payload/retention-purge/retention-purge.job.ts @@ -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, + action: RetentionActionName, + reason: string, + ): Promise { + const id = doc["id"] as string | number; + + if (action === "pseudonymize") { + const piiFields: Record = {}; + 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 = {}; - 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; diff --git a/packages/core-shared/src/payload/retention-purge/task.test.ts b/packages/core-shared/src/payload/retention-purge/task.test.ts new file mode 100644 index 0000000..259dfc5 --- /dev/null +++ b/packages/core-shared/src/payload/retention-purge/task.test.ts @@ -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-- 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" }), + ); + }); +}); diff --git a/packages/core-shared/src/payload/retention-purge/task.ts b/packages/core-shared/src/payload/retention-purge/task.ts new file mode 100644 index 0000000..f54e356 --- /dev/null +++ b/packages/core-shared/src/payload/retention-purge/task.ts @@ -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; + }>; +}; + +/** + * Build the Payload job-task definition for one collection's retention purge + * (audit finding A3): `registerRetentionPurgeJobs` enqueues + * `retention-purge--` 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: {} }; + }, + }; +} diff --git a/packages/core-shared/src/payload/retention-purge/tombstone.test.ts b/packages/core-shared/src/payload/retention-purge/tombstone.test.ts new file mode 100644 index 0000000..c670937 --- /dev/null +++ b/packages/core-shared/src/payload/retention-purge/tombstone.test.ts @@ -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): 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); + }); +}); diff --git a/packages/core-shared/src/payload/retention-purge/tombstone.ts b/packages/core-shared/src/payload/retention-purge/tombstone.ts new file mode 100644 index 0000000..0d23858 --- /dev/null +++ b/packages/core-shared/src/payload/retention-purge/tombstone.ts @@ -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; +}): 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], + }; +} diff --git a/packages/core-shared/src/trpc/context.test.ts b/packages/core-shared/src/trpc/context.test.ts new file mode 100644 index 0000000..ff55ea0 --- /dev/null +++ b/packages/core-shared/src/trpc/context.test.ts @@ -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); + }); +}); diff --git a/packages/core-shared/src/trpc/context.ts b/packages/core-shared/src/trpc/context.ts index d38a5cb..8f21cac 100644 --- a/packages/core-shared/src/trpc/context.ts +++ b/packages/core-shared/src/trpc/context.ts @@ -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; +}; + +/** + * 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>; diff --git a/packages/core-shared/src/trpc/require-authenticated.test.ts b/packages/core-shared/src/trpc/require-authenticated.test.ts new file mode 100644 index 0000000..e7d3993 --- /dev/null +++ b/packages/core-shared/src/trpc/require-authenticated.test.ts @@ -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"); + }); +}); diff --git a/packages/core-shared/src/trpc/require-authenticated.ts b/packages/core-shared/src/trpc/require-authenticated.ts new file mode 100644 index 0000000..c9fc888 --- /dev/null +++ b/packages/core-shared/src/trpc/require-authenticated.ts @@ -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); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3e79f7..24affeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -448,6 +448,12 @@ importers: "@repo/auth": specifier: workspace:* version: link:../auth + "@repo/core-audit": + specifier: workspace:* + version: link:../core-audit + "@repo/core-shared": + specifier: workspace:* + version: link:../core-shared payload: specifier: ^3.14.0 version: 3.81.0(graphql@16.13.2)(typescript@5.9.3) @@ -495,6 +501,9 @@ importers: "@testing-library/react": specifier: ^16.0.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + "@trpc/client": + specifier: ^11.18.0 + version: 11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3) "@types/react": specifier: ^19.0.0 version: 19.2.14 @@ -510,6 +519,9 @@ importers: react: specifier: ^19.0.0 version: 19.2.4 + superjson: + specifier: ^2.2.1 + version: 2.2.6 typescript: specifier: ^5.8.0 version: 5.9.3