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>
224 lines
7.3 KiB
TypeScript
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,
|
|
};
|
|
}
|