Enforce one active consent receipt per kind atomically

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 17:21:30 +02:00
1 parent a2260d5bb8
commit 6a173a89dd
6 files changed
+275 -54

No files matched your search

@@ -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;
+8
View File
@@ -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")
}
+57 -20
View File
@@ -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<Record<string, unknown>> = {}) {
@@ -37,12 +54,21 @@ function row(overrides: Partial<Record<string, unknown>> = {}) {
}
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();
});
});
+110 -8
View File
@@ -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<Record<string, unknown>> = {}) {
@@ -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<typeof row> | 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<unknown> = 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);
});
});
+49 -23
View File
@@ -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<ConsentReceipt> {
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<ConsentReceipt | null> {
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" },
});
});
}
+35 -3
View File
@@ -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<WebConsentGrantResult> {
// 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;
}
}