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:
@@ -36,11 +36,13 @@
|
||||
"@repo/core-testing": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@trpc/client": "^11.18.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"payload": "^3.14.0",
|
||||
"react": "^19.0.0",
|
||||
"superjson": "^2.2.1",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
|
||||
@@ -10,6 +10,27 @@ export type ConsentCategory =
|
||||
| "marketing"
|
||||
| (string & {});
|
||||
|
||||
/**
|
||||
* The known consent categories (audit finding A12). Untrusted inputs — e.g.
|
||||
* the anonymous banner cookie migrated at sign-up — MUST be validated against
|
||||
* this list before being granted; the open ConsentCategory union is for
|
||||
* first-party code registering custom categories deliberately, not for
|
||||
* client-controlled strings.
|
||||
*/
|
||||
export const KNOWN_CONSENT_CATEGORIES = [
|
||||
"necessary",
|
||||
"functional",
|
||||
"analytics",
|
||||
"marketing",
|
||||
] as const;
|
||||
|
||||
/** Type guard for the allow-list above. */
|
||||
export function isKnownConsentCategory(
|
||||
value: string,
|
||||
): value is (typeof KNOWN_CONSENT_CATEGORIES)[number] {
|
||||
return (KNOWN_CONSENT_CATEGORIES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Whether a subject has granted or denied consent for a category. */
|
||||
export type ConsentState = "granted" | "denied" | "pending";
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import { createTRPCClient, httpLink } from "@trpc/client";
|
||||
import superjson from "superjson";
|
||||
import { RecordingConsent } from "@repo/core-testing/instrumentation";
|
||||
import { router } from "@repo/core-shared/trpc/init";
|
||||
import { consentRouter } from "@/consent.router";
|
||||
import type { ConsentRouterContext } from "@/consent.router";
|
||||
import type { IConsent } from "@/consent.interface";
|
||||
import { InMemoryConsent } from "@/in-memory-consent";
|
||||
|
||||
function makeContext(
|
||||
consent: RecordingConsent,
|
||||
@@ -155,6 +160,77 @@ describe("consentRouter — auth checks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("consentRouter — context guard", () => {
|
||||
it("throws INTERNAL_SERVER_ERROR when consentFactory is missing from ctx", async () => {
|
||||
const caller = consentRouter.createCaller({
|
||||
userId: "user-1",
|
||||
} as unknown as ConsentRouterContext);
|
||||
await expect(caller.grant({ category: "analytics" })).rejects.toMatchObject(
|
||||
{
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: expect.stringContaining("consentFactory missing"),
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("consentRouter — superjson wire round-trip (A10)", () => {
|
||||
// The consent router MUST be built from the shared `t` (which is created
|
||||
// with the superjson transformer). This test drives a real tRPC HTTP
|
||||
// round-trip — client link + fetch adapter — so a transformer mismatch
|
||||
// between the mounted router and the app client fails loudly here.
|
||||
function makeClient(ctx: ConsentRouterContext) {
|
||||
const appLikeRouter = router({ consent: consentRouter });
|
||||
return createTRPCClient<typeof appLikeRouter>({
|
||||
links: [
|
||||
httpLink({
|
||||
url: "http://localhost/api/trpc",
|
||||
transformer: superjson,
|
||||
fetch: (input, init) =>
|
||||
fetchRequestHandler({
|
||||
endpoint: "/api/trpc",
|
||||
req: new Request(input, init as RequestInit),
|
||||
router: appLikeRouter,
|
||||
createContext: () => ctx,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it("round-trips grant + getCategories, reviving Date fields", async () => {
|
||||
const consent = new InMemoryConsent();
|
||||
const client = makeClient({
|
||||
userId: "user-1",
|
||||
consentFactory: async () => consent,
|
||||
});
|
||||
|
||||
const grantRes = await client.consent.grant.mutate({
|
||||
category: "analytics",
|
||||
});
|
||||
expect(grantRes).toEqual({ success: true });
|
||||
|
||||
const { categories } = await client.consent.getCategories.query({});
|
||||
expect(categories).toHaveLength(1);
|
||||
expect(categories[0]!.category).toBe("analytics");
|
||||
// superjson revives Dates across the wire; plain JSON would yield a string.
|
||||
expect(categories[0]!.grantedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("round-trips isGranted through the wire", async () => {
|
||||
const consent = new InMemoryConsent();
|
||||
const client = makeClient({
|
||||
userId: "user-1",
|
||||
consentFactory: async () => consent,
|
||||
});
|
||||
await client.consent.grant.mutate({ category: "marketing" });
|
||||
const res = await client.consent.isGranted.query({
|
||||
category: "marketing",
|
||||
});
|
||||
expect(res).toEqual({ granted: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("consentRouter — error passthrough", () => {
|
||||
it("propagates unmapped errors as INTERNAL_SERVER_ERROR", async () => {
|
||||
const brokenConsent: IConsent = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { initTRPC } from "@trpc/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { t } from "@repo/core-shared/trpc/init";
|
||||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
||||
|
||||
import type { ConsentFactory } from "./di/bind-production";
|
||||
@@ -26,41 +27,57 @@ export type ConsentRouterContext = {
|
||||
consentFactory: ConsentFactory;
|
||||
};
|
||||
|
||||
const tc = initTRPC.context<ConsentRouterContext>().create();
|
||||
|
||||
const consentProcedure = tc.procedure
|
||||
/**
|
||||
* Consent procedures build on the SHARED `t` instance from
|
||||
* `@repo/core-shared/trpc/init` (audit finding A10): the app router is created
|
||||
* with the superjson transformer, and a router built from a private `initTRPC`
|
||||
* without superjson would corrupt every input/output that crosses the wire.
|
||||
*
|
||||
* The shared `t` is context-untyped, so the middleware narrows `ctx` to
|
||||
* `ConsentRouterContext` at runtime — same cast pattern as the dsr router.
|
||||
*/
|
||||
const consentProcedure = t.procedure
|
||||
.use(defineErrorMiddleware([[UnauthenticatedError, "UNAUTHORIZED"]]))
|
||||
.use(async ({ ctx, next }) => {
|
||||
if (!ctx.userId) throw new UnauthenticatedError();
|
||||
return next();
|
||||
const { userId, consentFactory } = ctx as Partial<ConsentRouterContext>;
|
||||
if (!userId) throw new UnauthenticatedError();
|
||||
if (!consentFactory) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message:
|
||||
"consentFactory missing from tRPC context — wire the binding from " +
|
||||
"bindProductionConsent/bindDevSeedConsent into createContext",
|
||||
});
|
||||
}
|
||||
return next({ ctx: { ...ctx, userId, consentFactory } });
|
||||
});
|
||||
|
||||
export const consentRouter = tc.router({
|
||||
export const consentRouter = t.router({
|
||||
grant: consentProcedure
|
||||
.input(grantHandlerInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const consent = await ctx.consentFactory(ctx.userId!);
|
||||
const consent = await ctx.consentFactory(ctx.userId);
|
||||
return grantHandler(consent, input);
|
||||
}),
|
||||
|
||||
withdraw: consentProcedure
|
||||
.input(withdrawHandlerInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const consent = await ctx.consentFactory(ctx.userId!);
|
||||
const consent = await ctx.consentFactory(ctx.userId);
|
||||
return withdrawHandler(consent, input);
|
||||
}),
|
||||
|
||||
isGranted: consentProcedure
|
||||
.input(isGrantedHandlerInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const consent = await ctx.consentFactory(ctx.userId!);
|
||||
const consent = await ctx.consentFactory(ctx.userId);
|
||||
return isGrantedHandler(consent, input);
|
||||
}),
|
||||
|
||||
getCategories: consentProcedure
|
||||
.input(z.object({}).strict())
|
||||
.query(async ({ ctx }) => {
|
||||
const consent = await ctx.consentFactory(ctx.userId!);
|
||||
const consent = await ctx.consentFactory(ctx.userId);
|
||||
return getCategoriesHandler(consent);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,10 @@ export type {
|
||||
UserConsentState,
|
||||
ConsentGrantMeta,
|
||||
} from "./consent-types";
|
||||
export {
|
||||
KNOWN_CONSENT_CATEGORIES,
|
||||
isKnownConsentCategory,
|
||||
} from "./consent-types";
|
||||
export type { IConsent } from "./consent.interface";
|
||||
export type { ConsentChecked } from "./with-consent";
|
||||
export { withConsent } from "./with-consent";
|
||||
|
||||
@@ -43,6 +43,21 @@ describe("extractAnonymousConsent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractAnonymousConsent — category allow-list (A12)", () => {
|
||||
it("drops unknown categories from the client-controlled cookie", () => {
|
||||
const result = extractAnonymousConsent(
|
||||
`${CONSENT_COOKIE_NAME}=necessary,evil-injection,analytics`,
|
||||
);
|
||||
expect(result).toEqual(["necessary", "analytics"]);
|
||||
});
|
||||
|
||||
it("returns null when every category is unknown", () => {
|
||||
expect(
|
||||
extractAnonymousConsent(`${CONSENT_COOKIE_NAME}=hax,__proto__`),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateAnonymousConsent", () => {
|
||||
it("calls IConsent.grant with method signup-migration for each category", async () => {
|
||||
const consent = new RecordingConsent();
|
||||
@@ -105,3 +120,17 @@ describe("migrateAnonymousConsent", () => {
|
||||
expect(consent.isGranted("marketing")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateAnonymousConsent — category allow-list (A12)", () => {
|
||||
it("never grants unknown categories even when passed directly", async () => {
|
||||
const consent = new RecordingConsent();
|
||||
await migrateAnonymousConsent({
|
||||
consent,
|
||||
cookieState: ["analytics", "totally-made-up", "marketing"],
|
||||
});
|
||||
expect(consent.grants.map((g) => g.category)).toEqual([
|
||||
"analytics",
|
||||
"marketing",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ConsentCategory, ConsentGrantMeta } from "./consent-types";
|
||||
import {
|
||||
isKnownConsentCategory,
|
||||
type ConsentCategory,
|
||||
type ConsentGrantMeta,
|
||||
} from "./consent-types";
|
||||
import type { IConsent } from "./consent.interface";
|
||||
|
||||
/** Cookie name written by the anonymous consent banner. */
|
||||
@@ -11,6 +15,10 @@ export const CONSENT_COOKIE_NAME = "cc_consent";
|
||||
*
|
||||
* Expected cookie value format: comma-separated category names,
|
||||
* e.g. "necessary,analytics,marketing".
|
||||
*
|
||||
* The cookie is client-controlled, so values are validated against
|
||||
* KNOWN_CONSENT_CATEGORIES (audit finding A12) — unknown strings are
|
||||
* dropped rather than granted.
|
||||
*/
|
||||
export function extractAnonymousConsent(
|
||||
cookieHeader: string,
|
||||
@@ -21,7 +29,8 @@ export function extractAnonymousConsent(
|
||||
const categories = raw
|
||||
.split(",")
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean) as ConsentCategory[];
|
||||
.filter(Boolean)
|
||||
.filter(isKnownConsentCategory) as ConsentCategory[];
|
||||
return categories.length > 0 ? categories : null;
|
||||
}
|
||||
|
||||
@@ -42,7 +51,9 @@ export async function migrateAnonymousConsent(opts: {
|
||||
const meta: ConsentGrantMeta = { method: "signup-migration" };
|
||||
if (bannerVersion !== undefined) meta.bannerVersion = bannerVersion;
|
||||
if (policyVersion !== undefined) meta.policyVersion = policyVersion;
|
||||
for (const category of cookieState) {
|
||||
// Defense in depth (A12): even a caller that bypassed
|
||||
// extractAnonymousConsent cannot grant unknown categories.
|
||||
for (const category of cookieState.filter(isKnownConsentCategory)) {
|
||||
await consent.grant(category, meta);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,3 +259,106 @@ describe("PayloadConsent.load — deserializeEntry branches", () => {
|
||||
expect(cats[0]!.withdrawnAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PayloadConsent.persist — read-merge-write (A7)", () => {
|
||||
async function makeTwoConsents() {
|
||||
const mock = makePayloadMock();
|
||||
const a = new PayloadConsent(
|
||||
"user_1",
|
||||
{} as never,
|
||||
new RecordingAuditLog(),
|
||||
mock.getPayload,
|
||||
);
|
||||
const b = new PayloadConsent(
|
||||
"user_1",
|
||||
{} as never,
|
||||
new RecordingAuditLog(),
|
||||
mock.getPayload,
|
||||
);
|
||||
// Both instances hydrate from the SAME empty snapshot — the per-request
|
||||
// cache staleness that caused the lost update.
|
||||
await a.load();
|
||||
await b.load();
|
||||
return { a, b, ...mock };
|
||||
}
|
||||
|
||||
function storedCategories(db: Record<string, unknown[]>): string[] {
|
||||
return (db["user_1"] ?? [])
|
||||
.map((e) => (e as { category: string }).category)
|
||||
.sort();
|
||||
}
|
||||
|
||||
it("two interleaved grants from stale caches both survive", async () => {
|
||||
const { a, b, db } = await makeTwoConsents();
|
||||
|
||||
await a.grant("analytics");
|
||||
await b.grant("marketing"); // pre-fix: whole-array write dropped "analytics"
|
||||
|
||||
expect(storedCategories(db)).toEqual(["analytics", "marketing"]);
|
||||
});
|
||||
|
||||
it("a grant and a withdraw on different categories both survive", async () => {
|
||||
const { a, b, db } = await makeTwoConsents();
|
||||
|
||||
await a.grant("analytics");
|
||||
await b.grant("marketing");
|
||||
await a.withdraw("analytics");
|
||||
|
||||
expect(storedCategories(db)).toEqual(["analytics", "marketing"]);
|
||||
const analytics = (
|
||||
db["user_1"] as Array<{ category: string; state: string }>
|
||||
).find((e) => e.category === "analytics");
|
||||
expect(analytics?.state).toBe("denied");
|
||||
});
|
||||
|
||||
it("adopts concurrent writers' entries into the local cache after persist", async () => {
|
||||
const { a, b } = await makeTwoConsents();
|
||||
|
||||
await a.grant("analytics");
|
||||
await b.grant("marketing");
|
||||
|
||||
// b re-read the freshest state during persist, so it now sees a's grant.
|
||||
expect(b.isGranted("analytics")).toBe(true);
|
||||
expect(b.isGranted("marketing")).toBe(true);
|
||||
});
|
||||
|
||||
it("truly concurrent grants both survive when the second read lands after the first write", async () => {
|
||||
const mock = makePayloadMock();
|
||||
const a = new PayloadConsent(
|
||||
"user_1",
|
||||
{} as never,
|
||||
new RecordingAuditLog(),
|
||||
mock.getPayload,
|
||||
);
|
||||
const b = new PayloadConsent(
|
||||
"user_1",
|
||||
{} as never,
|
||||
new RecordingAuditLog(),
|
||||
mock.getPayload,
|
||||
);
|
||||
await a.load();
|
||||
await b.load();
|
||||
|
||||
// Gate b's persist-read until a's write has committed — the ordering the
|
||||
// read-merge-write strategy is designed for. (A same-window overlap is
|
||||
// the documented residual race.)
|
||||
let releaseB: () => void = () => {};
|
||||
const bGate = new Promise<void>((resolve) => {
|
||||
releaseB = resolve;
|
||||
});
|
||||
const originalFindByID = mock.findByID.getMockImplementation()!;
|
||||
let firstPersistRead = true;
|
||||
// a loads+persists first; instrument findByID so b's persist read waits.
|
||||
mock.findByID.mockImplementation(async (args: { id: string }) => {
|
||||
if (!firstPersistRead) await bGate;
|
||||
firstPersistRead = false;
|
||||
return originalFindByID(args);
|
||||
});
|
||||
|
||||
const aDone = a.grant("analytics").then(() => releaseB());
|
||||
const bDone = b.grant("marketing");
|
||||
await Promise.all([aDone, bDone]);
|
||||
|
||||
expect(storedCategories(mock.db)).toEqual(["analytics", "marketing"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ export class PayloadConsent implements IConsent {
|
||||
method: meta?.method,
|
||||
};
|
||||
this.cache.set(category, entry);
|
||||
await this.persist();
|
||||
await this.persist([category]);
|
||||
await this.auditLog.record({
|
||||
actorId: this.userId,
|
||||
actorType: "user",
|
||||
@@ -116,7 +116,7 @@ export class PayloadConsent implements IConsent {
|
||||
withdrawnAt: now,
|
||||
};
|
||||
this.cache.set(category, entry);
|
||||
await this.persist();
|
||||
await this.persist([category]);
|
||||
await this.auditLog.record({
|
||||
actorId: this.userId,
|
||||
actorType: "user",
|
||||
@@ -139,9 +139,54 @@ export class PayloadConsent implements IConsent {
|
||||
return Array.from(this.cache.values());
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
/**
|
||||
* Read-merge-write persistence (audit finding A7 — lost-update race).
|
||||
*
|
||||
* Payload's `update` on a json field replaces the WHOLE value; there is no
|
||||
* targeted array-element patch. Writing this instance's per-request cache
|
||||
* verbatim would drop any category another request persisted since our
|
||||
* `load()`. Instead we re-read the freshest stored state immediately
|
||||
* before writing and overlay ONLY the categories this call mutated, so
|
||||
* two interleaved writers touching different categories both survive.
|
||||
*
|
||||
* Residual window (documented, accepted): between this read and the write,
|
||||
* a concurrent writer to the SAME category is last-writer-wins, and a
|
||||
* concurrent writer to a different category that lands inside the window
|
||||
* can still be overwritten. Closing it fully needs a DB-level transaction
|
||||
* or JSON-patch support in Payload; for consent state (idempotent,
|
||||
* per-subject, low frequency) read-merge-write is the accepted trade-off.
|
||||
*/
|
||||
private async persist(mutated: ConsentCategory[]): Promise<void> {
|
||||
const payload = await this.getPayloadFn({ config: this.config });
|
||||
const state = Array.from(this.cache.values()).map((entry) => ({
|
||||
|
||||
// Freshest stored state, immediately before the write.
|
||||
const doc = await payload.findByID({
|
||||
collection: "users",
|
||||
id: this.userId,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const merged = new Map<ConsentCategory, UserConsentState>();
|
||||
const rawState = doc["consentState"];
|
||||
if (Array.isArray(rawState)) {
|
||||
for (const raw of rawState) {
|
||||
if (raw && typeof raw === "object") {
|
||||
const entry = deserializeEntry(raw as Record<string, unknown>);
|
||||
merged.set(entry.category, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay only what this call changed.
|
||||
for (const category of mutated) {
|
||||
const entry = this.cache.get(category);
|
||||
if (entry) merged.set(category, entry);
|
||||
}
|
||||
|
||||
// Adopt the merged view locally so isGranted/getCategories reflect
|
||||
// concurrent writers' entries too.
|
||||
this.cache = merged;
|
||||
|
||||
const state = Array.from(merged.values()).map((entry) => ({
|
||||
category: entry.category,
|
||||
state: entry.state,
|
||||
grantedAt: entry.grantedAt?.toISOString() ?? null,
|
||||
|
||||
Reference in New Issue
Block a user