From 6a173a89ddb7fa54f15f6697b0f135a45de6b727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 17:21:30 +0200 Subject: [PATCH] Enforce one active consent receipt per kind atomically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two concurrent web-grant heals could each pass the read-then-create gap and insert a duplicate active ai_full receipt; the revoke path then had to chase one row at a time. Wrap the web-grant mint in a Serializable transaction with an in-transaction re-check, and add migration 0159 — a partial unique index over (user_id, kind) WHERE revoked_at IS NULL — as the structural backstop. createReceipt now supersedes any active row of the same kind before appending, and revokeLatest flips revoked_at via a single atomic updateMany, so the append-only audit chain is preserved while at most one active row ever exists. --- .../migration.sql | 16 +++ prisma/schema.prisma | 8 ++ src/lib/consent/__tests__/receipts.test.ts | 77 +++++++++--- src/lib/consent/__tests__/web-grant.test.ts | 118 ++++++++++++++++-- src/lib/consent/receipts.ts | 72 +++++++---- src/lib/consent/web-grant.ts | 38 +++++- 6 files changed, 275 insertions(+), 54 deletions(-) create mode 100644 prisma/migrations/0159_v11616_consent_active_unique/migration.sql diff --git a/prisma/migrations/0159_v11616_consent_active_unique/migration.sql b/prisma/migrations/0159_v11616_consent_active_unique/migration.sql new file mode 100644 index 000000000..d17081d82 --- /dev/null +++ b/prisma/migrations/0159_v11616_consent_active_unique/migration.sql @@ -0,0 +1,16 @@ +-- v1.16.16 — at most one active consent receipt per (user, kind). +-- +-- The web-grant heal mint reads `latestActiveReceipt` then `createReceipt` +-- non-atomically. Two concurrent web mounts could each see "no active +-- receipt" and both insert an active `ai_full` row, leaving duplicate +-- active grants the revoke path then has to chase one at a time. +-- +-- The application wraps the mint in a transaction with a re-check, but the +-- structural guarantee lives here: a partial unique index over +-- (user_id, kind) restricted to non-revoked rows. Revoked rows carry a +-- non-NULL `revoked_at` and fall outside the predicate, so the append-only +-- grant/revoke history is unaffected — only two *active* rows for the same +-- (user, kind) collide. +CREATE UNIQUE INDEX IF NOT EXISTS "consent_receipts_user_id_kind_active_key" + ON "consent_receipts" ("user_id", "kind") + WHERE "revoked_at" IS NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6a09c701b..f88f0e403 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3354,6 +3354,14 @@ model ConsentReceipt { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, createdAt(sort: Desc)]) + // `consent_receipts_user_id_kind_active_key` is a partial UNIQUE index + // over (user_id, kind) `WHERE revoked_at IS NULL` — see migration 0159. + // It enforces at most one *active* receipt per (user, kind) so two + // concurrent web-grant heals can't both mint an active row. Prisma can't + // express a partial-predicate unique in the schema language, so it's + // introduced via raw SQL and intentionally omitted here. The append-only + // history (revoked rows) stays unconstrained because revoked rows carry a + // non-NULL `revoked_at` and fall outside the predicate. @@map("consent_receipts") } diff --git a/src/lib/consent/__tests__/receipts.test.ts b/src/lib/consent/__tests__/receipts.test.ts index 85cee22a8..9de301f90 100644 --- a/src/lib/consent/__tests__/receipts.test.ts +++ b/src/lib/consent/__tests__/receipts.test.ts @@ -1,15 +1,25 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -vi.mock("@/lib/db", () => ({ - prisma: { - consentReceipt: { - create: vi.fn(), - findFirst: vi.fn(), - findMany: vi.fn(), - update: vi.fn(), +// `createReceipt` + `revokeLatest` run their supersede/revoke logic inside +// a transaction. Run the callback against the same mock proxy. The Prisma +// `$transaction` signature is generic; the mock takes a loose callback. +type TxFn = (tx: unknown) => unknown; + +vi.mock("@/lib/db", () => { + const consentReceipt = { + create: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }; + return { + prisma: { + consentReceipt, + $transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })), }, - }, -})); + }; +}); import { prisma } from "@/lib/db"; import { @@ -19,8 +29,15 @@ import { revokeLatest, } from "../receipts"; +const $transaction = vi.mocked(prisma.$transaction) as unknown as { + mockImplementation: (impl: (fn: TxFn) => unknown) => void; +}; + beforeEach(() => { vi.resetAllMocks(); + $transaction.mockImplementation((fn: TxFn) => + fn({ consentReceipt: prisma.consentReceipt }), + ); }); function row(overrides: Partial> = {}) { @@ -37,12 +54,21 @@ function row(overrides: Partial> = {}) { } describe("createReceipt", () => { - it("inserts a row scoped to (userId, kind) with the artefact + signedAt", async () => { + it("supersedes any active row then inserts scoped to (userId, kind)", async () => { + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ + count: 0, + } as never); vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never); const signedAt = new Date("2026-05-18T10:00:00.000Z"); const result = await createReceipt("user-1", "ai_full", "PDF…", signedAt); + // Revokes any currently-active receipt of the same kind first, so the + // fresh grant doesn't collide with the partial unique index. + expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({ + where: { userId: "user-1", kind: "ai_full", revokedAt: null }, + data: { revokedAt: signedAt }, + }); expect(prisma.consentReceipt.create).toHaveBeenCalledWith({ data: { userId: "user-1", @@ -52,6 +78,8 @@ describe("createReceipt", () => { }, }); expect(result.id).toBe("rcpt-1"); + // The whole mint runs in a transaction. + expect(prisma.$transaction).toHaveBeenCalledTimes(1); }); }); @@ -96,28 +124,37 @@ describe("latestActiveReceiptsByKind", () => { }); describe("revokeLatest", () => { - it("flips revokedAt on the latest active row and returns the updated row", async () => { - vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(row() as never); - vi.mocked(prisma.consentReceipt.update).mockResolvedValue( - row({ revokedAt: new Date("2026-05-18T11:00:00.000Z") }) as never, + it("atomically revokes the active row via updateMany and returns it", async () => { + const now = new Date("2026-05-18T11:00:00.000Z"); + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue( + row({ revokedAt: now }) as never, ); - const now = new Date("2026-05-18T11:00:00.000Z"); const result = await revokeLatest("user-1", "ai_full", now); - expect(prisma.consentReceipt.update).toHaveBeenCalledWith({ - where: { id: "rcpt-1" }, + expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({ + where: { userId: "user-1", kind: "ai_full", revokedAt: null }, data: { revokedAt: now }, }); + // Re-reads the just-revoked row so the audit log keeps the receipt id. + expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({ + where: { userId: "user-1", kind: "ai_full", revokedAt: now }, + orderBy: { createdAt: "desc" }, + }); expect(result?.revokedAt).toEqual(now); }); - it("returns null without writing when no active receipt exists", async () => { - vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null); + it("returns null without re-reading when no active receipt exists", async () => { + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ + count: 0, + } as never); const result = await revokeLatest("user-1", "ai_full"); expect(result).toBeNull(); - expect(prisma.consentReceipt.update).not.toHaveBeenCalled(); + expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/consent/__tests__/web-grant.test.ts b/src/lib/consent/__tests__/web-grant.test.ts index e20b04be4..a5fc8037e 100644 --- a/src/lib/consent/__tests__/web-grant.test.ts +++ b/src/lib/consent/__tests__/web-grant.test.ts @@ -1,19 +1,40 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -vi.mock("@/lib/db", () => ({ - prisma: { - consentReceipt: { - create: vi.fn(), - findFirst: vi.fn(), +// The web-grant mint runs its re-check + insert inside a transaction. The +// mock runs the callback against the same `prisma` proxy so the in-tx +// reads/writes share the test's mock surface. The Prisma `$transaction` +// signature is generic; the mock takes a loose callback and casts. +type TxFn = (tx: unknown) => unknown; + +vi.mock("@/lib/db", () => { + const consentReceipt = { + create: vi.fn(), + findFirst: vi.fn(), + updateMany: vi.fn(), + }; + return { + prisma: { + consentReceipt, + $transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })), }, - }, -})); + }; +}); import { prisma } from "@/lib/db"; import { ensureWebAiConsentReceipt } from "../web-grant"; +const $transaction = vi.mocked(prisma.$transaction) as unknown as { + mockImplementation: (impl: (fn: TxFn) => unknown) => void; + mockReset: () => void; +}; + beforeEach(() => { vi.resetAllMocks(); + // `resetAllMocks` clears the `$transaction` pass-through implementation; + // re-arm it so the mint callback still runs against the mock surface. + $transaction.mockImplementation((fn: TxFn) => + fn({ consentReceipt: prisma.consentReceipt }), + ); }); function row(overrides: Partial> = {}) { @@ -38,11 +59,14 @@ describe("ensureWebAiConsentReceipt", () => { const result = await ensureWebAiConsentReceipt("user-1", now); expect(result.minted).toBe(true); - // Reads the active master grant first. + // Reads the active master grant first (fast-path + in-tx re-check both + // hit the same predicate). expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({ where: { userId: "user-1", kind: "ai_full", revokedAt: null }, orderBy: { createdAt: "desc" }, }); + // The mint is wrapped in a transaction (TOCTOU close). + expect(prisma.$transaction).toHaveBeenCalledTimes(1); // Mints ai_full with a web-source artefact + the supplied timestamp. const createArg = vi.mocked(prisma.consentReceipt.create).mock.calls[0][0]; expect(createArg.data.userId).toBe("user-1"); @@ -60,5 +84,83 @@ describe("ensureWebAiConsentReceipt", () => { expect(result.minted).toBe(false); expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); + // No transaction is opened on the already-granted fast path. + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("re-checks inside the transaction: a row that appears between the fast-path read and the mint short-circuits the insert", async () => { + // Fast-path read sees nothing; the in-tx re-check sees a row a + // concurrent grant inserted. The mint must no-op rather than insert a + // second active row. + vi.mocked(prisma.consentReceipt.findFirst) + .mockResolvedValueOnce(null) // fast path + .mockResolvedValueOnce(row() as never); // in-tx re-check + + const result = await ensureWebAiConsentReceipt("user-1"); + + expect(result.minted).toBe(false); + expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); + }); + + it("maps the partial-unique violation (P2002) to a no-op when two grants race to the insert", async () => { + // Both reads miss (the re-check loses the race too); the partial unique + // index rejects the second insert. The structural backstop must surface + // as success-shaped — the user ends with exactly one active receipt. + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null); + vi.mocked(prisma.consentReceipt.create).mockRejectedValue({ + code: "P2002", + } as never); + + const result = await ensureWebAiConsentReceipt("user-1"); + + expect(result.minted).toBe(false); + }); + + it("rethrows non-P2002 errors", async () => { + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null); + vi.mocked(prisma.consentReceipt.create).mockRejectedValue( + new Error("connection reset") as never, + ); + + await expect(ensureWebAiConsentReceipt("user-1")).rejects.toThrow( + "connection reset", + ); + }); +}); + +describe("ensureWebAiConsentReceipt — two concurrent grants", () => { + it("yields exactly one active receipt across parallel mounts", async () => { + // Model the race at the DB layer: a single shared "active row" slot. + // Both callers pass the fast-path read (slot empty). The transaction + // serialises their re-checks: the first sees the empty slot and + // inserts; the second sees the now-filled slot and no-ops. + let activeRow: ReturnType | null = null; + + vi.mocked(prisma.consentReceipt.findFirst).mockImplementation((async () => + activeRow) as never); + vi.mocked(prisma.consentReceipt.create).mockImplementation((async () => { + activeRow = row(); + return activeRow; + }) as never); + // Serialise the two transactions so the second observes the first's + // committed insert (Serializable isolation in production). + let chain: Promise = Promise.resolve(); + $transaction.mockImplementation((fn: TxFn) => { + const run = chain.then(() => + fn({ consentReceipt: prisma.consentReceipt }), + ); + chain = run.catch(() => {}); + return run; + }); + + const [a, b] = await Promise.all([ + ensureWebAiConsentReceipt("user-1"), + ensureWebAiConsentReceipt("user-1"), + ]); + + // Exactly one mint, exactly one row inserted. + const mintedCount = [a, b].filter((r) => r.minted).length; + expect(mintedCount).toBe(1); + expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1); }); }); diff --git a/src/lib/consent/receipts.ts b/src/lib/consent/receipts.ts index 4883f4700..a6aedf229 100644 --- a/src/lib/consent/receipts.ts +++ b/src/lib/consent/receipts.ts @@ -19,9 +19,17 @@ import type { ConsentKind } from "@/lib/validations/consent"; export type ConsentReceipt = ConsentReceiptModel; /** - * Insert a fresh consent receipt for (userId, kind). Always appends; - * never collides with an earlier row. The caller has already - * Zod-validated `artefact` (≤ 64 KB) and `signedAt` (ISO-8601). + * Insert a fresh consent receipt for (userId, kind). The caller has + * already Zod-validated `artefact` (≤ 64 KB) and `signedAt` (ISO-8601). + * + * v1.16.16 — the partial unique index `consent_receipts_user_id_kind_ + * active_key` (`WHERE revoked_at IS NULL`, migration 0159) caps the table + * at one *active* receipt per (user, kind). A plain re-grant of an already + * active kind would otherwise collide, so the mint runs in a transaction + * that first revokes any active row of the same kind, then appends the new + * one. The append-only audit chain is preserved — the superseded row keeps + * its history with a `revoked_at` marker — and the constraint is satisfied + * because at most one active row ever exists. */ export async function createReceipt( userId: string, @@ -29,13 +37,22 @@ export async function createReceipt( artefact: string, signedAt: Date, ): Promise { - return prisma.consentReceipt.create({ - data: { - userId, - kind, - artefact, - signedAt, - }, + return prisma.$transaction(async (tx) => { + // Supersede any currently-active receipt of this kind so the fresh + // grant doesn't collide with the partial unique index. `updateMany` + // over the active predicate is a no-op when none is active. + await tx.consentReceipt.updateMany({ + where: { userId, kind, revokedAt: null }, + data: { revokedAt: signedAt }, + }); + return tx.consentReceipt.create({ + data: { + userId, + kind, + artefact, + signedAt, + }, + }); }); } @@ -81,25 +98,34 @@ export async function latestActiveReceiptsByKind( } /** - * Mark the latest active receipt for (userId, kind) as revoked. - * Returns the updated row, or `null` if no active receipt exists. + * Mark the active receipt for (userId, kind) as revoked. Returns the + * updated row, or `null` if no active receipt exists. * - * Implementation note: a two-step "read latest → update by id" is - * race-tolerant for the single-user revoke flow (iOS toggle is a - * per-device action; no concurrent revoke from a second device is - * plausible inside the same millisecond). If two devices race we end - * up writing `revokedAt` to the same row twice with identical - * timestamps — semantically idempotent. + * v1.16.16 — the partial unique index guarantees at most one active row + * per (user, kind), so a single atomic `updateMany` over the active + * predicate revokes "the" active receipt without a separate read. Two + * concurrent revokes converge: the first flips `revoked_at`, the second's + * predicate no longer matches and writes nothing. The follow-up read + * returns the row the caller (audit log) needs; it is `null` only when no + * active receipt existed at all. */ export async function revokeLatest( userId: string, kind: ConsentKind, now: Date = new Date(), ): Promise { - const latest = await latestActiveReceipt(userId, kind); - if (!latest) return null; - return prisma.consentReceipt.update({ - where: { id: latest.id }, - data: { revokedAt: now }, + return prisma.$transaction(async (tx) => { + const updated = await tx.consentReceipt.updateMany({ + where: { userId, kind, revokedAt: null }, + data: { revokedAt: now }, + }); + if (updated.count === 0) return null; + // Re-read the just-revoked row (revoked at exactly `now`) so the audit + // trail keeps the receipt id. The active predicate guaranteed a single + // row, so this resolves it unambiguously. + return tx.consentReceipt.findFirst({ + where: { userId, kind, revokedAt: now }, + orderBy: { createdAt: "desc" }, + }); }); } diff --git a/src/lib/consent/web-grant.ts b/src/lib/consent/web-grant.ts index 084b10055..b61cb9f66 100644 --- a/src/lib/consent/web-grant.ts +++ b/src/lib/consent/web-grant.ts @@ -21,7 +21,10 @@ * timestamp + source, which is the GDPR Art. 7 audit signal the legal team * needs). It stays well under the 64 KB artefact cap. */ -import { createReceipt, latestActiveReceipt } from "@/lib/consent/receipts"; +import { prisma } from "@/lib/db"; +import { Prisma } from "@/generated/prisma/client"; +import { isP2002 } from "@/lib/prisma-errors"; +import { latestActiveReceipt } from "@/lib/consent/receipts"; import type { ConsentReceipt } from "@/lib/consent/receipts"; /** Outcome of an idempotent web-grant call. */ @@ -42,6 +45,9 @@ export async function ensureWebAiConsentReceipt( userId: string, now: Date = new Date(), ): Promise { + // Fast path: a cheap read outside the transaction short-circuits the + // common already-granted case (every mount after the first) without + // opening a transaction. const existing = await latestActiveReceipt(userId, "ai_full"); if (existing) return { minted: false }; @@ -52,6 +58,32 @@ export async function ensureWebAiConsentReceipt( note: "In-app affirmative AI consent granted via the web client.", }); - const receipt = await createReceipt(userId, "ai_full", artefact, now); - return { minted: true, receipt }; + // Two web mounts can pass the fast-path read concurrently and both reach + // the mint. The transaction re-checks under a Serializable isolation so a + // second mint sees the first's row and no-ops; the partial unique index + // (migration 0159, `WHERE revoked_at IS NULL`) is the structural backstop + // if the re-check still races. A unique violation from the backstop means + // a concurrent grant already won — that is success-shaped (the user ends + // up with exactly one active receipt), so we map it to `{ minted: false }`. + try { + return await prisma.$transaction( + async (tx) => { + const inTx = await tx.consentReceipt.findFirst({ + where: { userId, kind: "ai_full", revokedAt: null }, + orderBy: { createdAt: "desc" }, + }); + if (inTx) return { minted: false } as const; + + const receipt = await tx.consentReceipt.create({ + data: { userId, kind: "ai_full", artefact, signedAt: now }, + }); + return { minted: true, receipt } as const; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ); + } catch (err) { + // Partial-unique violation — a concurrent grant landed first. + if (isP2002(err)) return { minted: false }; + throw err; + } }