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

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

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

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

View File

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

View File

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

View File

@@ -1,9 +1,14 @@
import { describe, it, expect, beforeEach } from "vitest";
import { TRPCError } from "@trpc/server";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { createTRPCClient, httpLink } from "@trpc/client";
import superjson from "superjson";
import { RecordingConsent } from "@repo/core-testing/instrumentation";
import { router } from "@repo/core-shared/trpc/init";
import { consentRouter } from "@/consent.router";
import type { ConsentRouterContext } from "@/consent.router";
import type { IConsent } from "@/consent.interface";
import { InMemoryConsent } from "@/in-memory-consent";
function makeContext(
consent: RecordingConsent,
@@ -155,6 +160,77 @@ describe("consentRouter — auth checks", () => {
});
});
describe("consentRouter — context guard", () => {
it("throws INTERNAL_SERVER_ERROR when consentFactory is missing from ctx", async () => {
const caller = consentRouter.createCaller({
userId: "user-1",
} as unknown as ConsentRouterContext);
await expect(caller.grant({ category: "analytics" })).rejects.toMatchObject(
{
code: "INTERNAL_SERVER_ERROR",
message: expect.stringContaining("consentFactory missing"),
},
);
});
});
describe("consentRouter — superjson wire round-trip (A10)", () => {
// The consent router MUST be built from the shared `t` (which is created
// with the superjson transformer). This test drives a real tRPC HTTP
// round-trip — client link + fetch adapter — so a transformer mismatch
// between the mounted router and the app client fails loudly here.
function makeClient(ctx: ConsentRouterContext) {
const appLikeRouter = router({ consent: consentRouter });
return createTRPCClient<typeof appLikeRouter>({
links: [
httpLink({
url: "http://localhost/api/trpc",
transformer: superjson,
fetch: (input, init) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req: new Request(input, init as RequestInit),
router: appLikeRouter,
createContext: () => ctx,
}),
}),
],
});
}
it("round-trips grant + getCategories, reviving Date fields", async () => {
const consent = new InMemoryConsent();
const client = makeClient({
userId: "user-1",
consentFactory: async () => consent,
});
const grantRes = await client.consent.grant.mutate({
category: "analytics",
});
expect(grantRes).toEqual({ success: true });
const { categories } = await client.consent.getCategories.query({});
expect(categories).toHaveLength(1);
expect(categories[0]!.category).toBe("analytics");
// superjson revives Dates across the wire; plain JSON would yield a string.
expect(categories[0]!.grantedAt).toBeInstanceOf(Date);
});
it("round-trips isGranted through the wire", async () => {
const consent = new InMemoryConsent();
const client = makeClient({
userId: "user-1",
consentFactory: async () => consent,
});
await client.consent.grant.mutate({ category: "marketing" });
const res = await client.consent.isGranted.query({
category: "marketing",
});
expect(res).toEqual({ granted: true });
});
});
describe("consentRouter — error passthrough", () => {
it("propagates unmapped errors as INTERNAL_SERVER_ERROR", async () => {
const brokenConsent: IConsent = {

View File

@@ -1,5 +1,6 @@
import { initTRPC } from "@trpc/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { t } from "@repo/core-shared/trpc/init";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import type { ConsentFactory } from "./di/bind-production";
@@ -26,41 +27,57 @@ export type ConsentRouterContext = {
consentFactory: ConsentFactory;
};
const tc = initTRPC.context<ConsentRouterContext>().create();
const consentProcedure = tc.procedure
/**
* Consent procedures build on the SHARED `t` instance from
* `@repo/core-shared/trpc/init` (audit finding A10): the app router is created
* with the superjson transformer, and a router built from a private `initTRPC`
* without superjson would corrupt every input/output that crosses the wire.
*
* The shared `t` is context-untyped, so the middleware narrows `ctx` to
* `ConsentRouterContext` at runtime — same cast pattern as the dsr router.
*/
const consentProcedure = t.procedure
.use(defineErrorMiddleware([[UnauthenticatedError, "UNAUTHORIZED"]]))
.use(async ({ ctx, next }) => {
if (!ctx.userId) throw new UnauthenticatedError();
return next();
const { userId, consentFactory } = ctx as Partial<ConsentRouterContext>;
if (!userId) throw new UnauthenticatedError();
if (!consentFactory) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
"consentFactory missing from tRPC context — wire the binding from " +
"bindProductionConsent/bindDevSeedConsent into createContext",
});
}
return next({ ctx: { ...ctx, userId, consentFactory } });
});
export const consentRouter = tc.router({
export const consentRouter = t.router({
grant: consentProcedure
.input(grantHandlerInputSchema)
.mutation(async ({ ctx, input }) => {
const consent = await ctx.consentFactory(ctx.userId!);
const consent = await ctx.consentFactory(ctx.userId);
return grantHandler(consent, input);
}),
withdraw: consentProcedure
.input(withdrawHandlerInputSchema)
.mutation(async ({ ctx, input }) => {
const consent = await ctx.consentFactory(ctx.userId!);
const consent = await ctx.consentFactory(ctx.userId);
return withdrawHandler(consent, input);
}),
isGranted: consentProcedure
.input(isGrantedHandlerInputSchema)
.query(async ({ ctx, input }) => {
const consent = await ctx.consentFactory(ctx.userId!);
const consent = await ctx.consentFactory(ctx.userId);
return isGrantedHandler(consent, input);
}),
getCategories: consentProcedure
.input(z.object({}).strict())
.query(async ({ ctx }) => {
const consent = await ctx.consentFactory(ctx.userId!);
const consent = await ctx.consentFactory(ctx.userId);
return getCategoriesHandler(consent);
}),
});

View File

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

View File

@@ -43,6 +43,21 @@ describe("extractAnonymousConsent", () => {
});
});
describe("extractAnonymousConsent — category allow-list (A12)", () => {
it("drops unknown categories from the client-controlled cookie", () => {
const result = extractAnonymousConsent(
`${CONSENT_COOKIE_NAME}=necessary,evil-injection,analytics`,
);
expect(result).toEqual(["necessary", "analytics"]);
});
it("returns null when every category is unknown", () => {
expect(
extractAnonymousConsent(`${CONSENT_COOKIE_NAME}=hax,__proto__`),
).toBeNull();
});
});
describe("migrateAnonymousConsent", () => {
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",
]);
});
});

View File

@@ -1,4 +1,8 @@
import type { ConsentCategory, ConsentGrantMeta } from "./consent-types";
import {
isKnownConsentCategory,
type ConsentCategory,
type ConsentGrantMeta,
} from "./consent-types";
import type { IConsent } from "./consent.interface";
/** 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);
}
}

View File

@@ -259,3 +259,106 @@ describe("PayloadConsent.load — deserializeEntry branches", () => {
expect(cats[0]!.withdrawnAt).toBeInstanceOf(Date);
});
});
describe("PayloadConsent.persist — read-merge-write (A7)", () => {
async function makeTwoConsents() {
const mock = makePayloadMock();
const a = new PayloadConsent(
"user_1",
{} as never,
new RecordingAuditLog(),
mock.getPayload,
);
const b = new PayloadConsent(
"user_1",
{} as never,
new RecordingAuditLog(),
mock.getPayload,
);
// Both instances hydrate from the SAME empty snapshot — the per-request
// cache staleness that caused the lost update.
await a.load();
await b.load();
return { a, b, ...mock };
}
function storedCategories(db: Record<string, unknown[]>): string[] {
return (db["user_1"] ?? [])
.map((e) => (e as { category: string }).category)
.sort();
}
it("two interleaved grants from stale caches both survive", async () => {
const { a, b, db } = await makeTwoConsents();
await a.grant("analytics");
await b.grant("marketing"); // pre-fix: whole-array write dropped "analytics"
expect(storedCategories(db)).toEqual(["analytics", "marketing"]);
});
it("a grant and a withdraw on different categories both survive", async () => {
const { a, b, db } = await makeTwoConsents();
await a.grant("analytics");
await b.grant("marketing");
await a.withdraw("analytics");
expect(storedCategories(db)).toEqual(["analytics", "marketing"]);
const analytics = (
db["user_1"] as Array<{ category: string; state: string }>
).find((e) => e.category === "analytics");
expect(analytics?.state).toBe("denied");
});
it("adopts concurrent writers' entries into the local cache after persist", async () => {
const { a, b } = await makeTwoConsents();
await a.grant("analytics");
await b.grant("marketing");
// b re-read the freshest state during persist, so it now sees a's grant.
expect(b.isGranted("analytics")).toBe(true);
expect(b.isGranted("marketing")).toBe(true);
});
it("truly concurrent grants both survive when the second read lands after the first write", async () => {
const mock = makePayloadMock();
const a = new PayloadConsent(
"user_1",
{} as never,
new RecordingAuditLog(),
mock.getPayload,
);
const b = new PayloadConsent(
"user_1",
{} as never,
new RecordingAuditLog(),
mock.getPayload,
);
await a.load();
await b.load();
// Gate b's persist-read until a's write has committed — the ordering the
// read-merge-write strategy is designed for. (A same-window overlap is
// the documented residual race.)
let releaseB: () => void = () => {};
const bGate = new Promise<void>((resolve) => {
releaseB = resolve;
});
const originalFindByID = mock.findByID.getMockImplementation()!;
let firstPersistRead = true;
// a loads+persists first; instrument findByID so b's persist read waits.
mock.findByID.mockImplementation(async (args: { id: string }) => {
if (!firstPersistRead) await bGate;
firstPersistRead = false;
return originalFindByID(args);
});
const aDone = a.grant("analytics").then(() => releaseB());
const bDone = b.grant("marketing");
await Promise.all([aDone, bDone]);
expect(storedCategories(mock.db)).toEqual(["analytics", "marketing"]);
});
});

View File

@@ -87,7 +87,7 @@ export class PayloadConsent implements IConsent {
method: meta?.method,
};
this.cache.set(category, entry);
await this.persist();
await this.persist([category]);
await this.auditLog.record({
actorId: this.userId,
actorType: "user",
@@ -116,7 +116,7 @@ export class PayloadConsent implements IConsent {
withdrawnAt: now,
};
this.cache.set(category, entry);
await this.persist();
await this.persist([category]);
await this.auditLog.record({
actorId: this.userId,
actorType: "user",
@@ -139,9 +139,54 @@ export class PayloadConsent implements IConsent {
return Array.from(this.cache.values());
}
private async persist(): Promise<void> {
/**
* Read-merge-write persistence (audit finding A7 — lost-update race).
*
* Payload's `update` on a json field replaces the WHOLE value; there is no
* targeted array-element patch. Writing this instance's per-request cache
* verbatim would drop any category another request persisted since our
* `load()`. Instead we re-read the freshest stored state immediately
* before writing and overlay ONLY the categories this call mutated, so
* two interleaved writers touching different categories both survive.
*
* Residual window (documented, accepted): between this read and the write,
* a concurrent writer to the SAME category is last-writer-wins, and a
* concurrent writer to a different category that lands inside the window
* can still be overwritten. Closing it fully needs a DB-level transaction
* or JSON-patch support in Payload; for consent state (idempotent,
* per-subject, low frequency) read-merge-write is the accepted trade-off.
*/
private async persist(mutated: ConsentCategory[]): Promise<void> {
const payload = await this.getPayloadFn({ config: this.config });
const state = Array.from(this.cache.values()).map((entry) => ({
// Freshest stored state, immediately before the write.
const doc = await payload.findByID({
collection: "users",
id: this.userId,
overrideAccess: true,
});
const merged = new Map<ConsentCategory, UserConsentState>();
const rawState = doc["consentState"];
if (Array.isArray(rawState)) {
for (const raw of rawState) {
if (raw && typeof raw === "object") {
const entry = deserializeEntry(raw as Record<string, unknown>);
merged.set(entry.category, entry);
}
}
}
// Overlay only what this call changed.
for (const category of mutated) {
const entry = this.cache.get(category);
if (entry) merged.set(category, entry);
}
// Adopt the merged view locally so isGranted/getCategories reflect
// concurrent writers' entries too.
this.cache = merged;
const state = Array.from(merged.values()).map((entry) => ({
category: entry.category,
state: entry.state,
grantedAt: entry.grantedAt?.toISOString() ?? null,