mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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" },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user