Files
agentic-dev/packages/core-consent/src/payload-consent.ts
Danijel Martinek 08cf939e1f fix(core-consent): merge per-category on persist instead of replacing
PayloadConsent.persist() wrote the WHOLE consentState array from a
per-request cache, so two interleaved grant/withdraw requests dropped
one another's categories (audit finding A7). Persist now re-reads the
freshest stored state immediately before writing, overlays ONLY the
mutated categories, and adopts the merged view locally. The residual
same-window race is documented in the method doc — Payload json fields
have no targeted array patch, so read-merge-write is the trade-off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00

224 lines
7.3 KiB
TypeScript

import { getPayload as _getPayload } from "payload";
import type { SanitizedConfig } from "payload";
import type { AuditLogProtocol } from "@repo/core-shared/di";
import type { IConsent } from "./consent.interface";
import type {
ConsentCategory,
ConsentGrantMeta,
ConsentState,
UserConsentState,
} from "./consent-types";
type PayloadAPI = {
findByID(args: {
collection: string;
id: string;
overrideAccess: true;
}): Promise<Record<string, unknown>>;
update(args: {
collection: string;
id: string;
data: Record<string, unknown>;
overrideAccess: true;
}): Promise<unknown>;
};
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
/**
* Payload-backed IConsent. Reads and writes the `users.consentState` JSON
* field on the Payload `users` collection. Emits CONSENT_GRANT /
* CONSENT_WITHDRAW audit entries via the injected auditLog.
*
* Instantiated per-user: the userId is provided at construction time.
* Call `load()` before first use in production to hydrate the in-memory
* cache from Payload. Mutations (grant / withdraw) update the cache
* synchronously and persist to Payload asynchronously in the same call.
*
* The getPayload param is injectable for tests; production code omits it
* and gets the real `getPayload` from `payload`.
*/
export class PayloadConsent implements IConsent {
private cache = new Map<ConsentCategory, UserConsentState>();
constructor(
private readonly userId: string,
private readonly config: SanitizedConfig,
private readonly auditLog: AuditLogProtocol,
private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload,
) {}
/** Hydrate the in-memory cache from Payload. */
async load(): Promise<void> {
const payload = await this.getPayloadFn({ config: this.config });
const doc = await payload.findByID({
collection: "users",
id: this.userId,
overrideAccess: true,
});
const rawState = doc["consentState"];
if (!Array.isArray(rawState)) return;
this.cache.clear();
for (const raw of rawState) {
if (raw && typeof raw === "object") {
const entry = deserializeEntry(raw as Record<string, unknown>);
this.cache.set(entry.category, entry);
}
}
}
isGranted(category: ConsentCategory): boolean {
return this.cache.get(category)?.state === "granted";
}
async grant(
category: ConsentCategory,
meta?: ConsentGrantMeta,
): Promise<void> {
const now = new Date();
const existing = this.cache.get(category);
const entry: UserConsentState = {
category,
state: "granted",
grantedAt: now,
withdrawnAt: existing?.withdrawnAt,
bannerVersion: meta?.bannerVersion,
policyVersion: meta?.policyVersion,
method: meta?.method,
};
this.cache.set(category, entry);
await this.persist([category]);
await this.auditLog.record({
actorId: this.userId,
actorType: "user",
actorRoles: [],
action: "CONSENT_GRANT",
resource: { type: "consent", id: category },
at: now,
scope: {
feature: "core-consent",
environment: process.env["NODE_ENV"] ?? "development",
tenant: "default",
},
from: { ipTruncated: "system", userAgent: "system" },
containsPii: false,
outcome: "success",
});
}
async withdraw(category: ConsentCategory): Promise<void> {
const now = new Date();
const existing = this.cache.get(category);
const entry: UserConsentState = {
...existing,
category,
state: "denied",
withdrawnAt: now,
};
this.cache.set(category, entry);
await this.persist([category]);
await this.auditLog.record({
actorId: this.userId,
actorType: "user",
actorRoles: [],
action: "CONSENT_WITHDRAW",
resource: { type: "consent", id: category },
at: now,
scope: {
feature: "core-consent",
environment: process.env["NODE_ENV"] ?? "development",
tenant: "default",
},
from: { ipTruncated: "system", userAgent: "system" },
containsPii: false,
outcome: "success",
});
}
getCategories(): UserConsentState[] {
return Array.from(this.cache.values());
}
/**
* 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 });
// 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,
withdrawnAt: entry.withdrawnAt?.toISOString() ?? null,
bannerVersion: entry.bannerVersion ?? null,
policyVersion: entry.policyVersion ?? null,
method: entry.method ?? null,
}));
await payload.update({
collection: "users",
id: this.userId,
data: { consentState: state },
overrideAccess: true,
});
}
}
function deserializeEntry(raw: Record<string, unknown>): UserConsentState {
return {
category: String(raw["category"]) as ConsentCategory,
state: (raw["state"] as ConsentState) ?? "pending",
grantedAt: raw["grantedAt"]
? new Date(raw["grantedAt"] as string)
: undefined,
withdrawnAt: raw["withdrawnAt"]
? new Date(raw["withdrawnAt"] as string)
: undefined,
bannerVersion:
raw["bannerVersion"] != null ? String(raw["bannerVersion"]) : undefined,
policyVersion:
raw["policyVersion"] != null ? String(raw["policyVersion"]) : undefined,
method: raw["method"] != null ? String(raw["method"]) : undefined,
};
}