From db770738bf597828d3cac6c3c0fcde81295617a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 17:25:39 +0200 Subject: [PATCH] Harden consent supersede against backdated grants and dup rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to the active-receipt uniqueness work: - Migration 0159 now revokes all-but-the-newest active row per (user_id, kind) before building the partial unique index. Without the pre-flight dedup the CREATE UNIQUE INDEX would abort on any tenant that already carries duplicate active grants — exactly the tenants the fix targets — and fail the deploy. - createReceipt stamps the superseded row's revoked_at with a server clock instead of the client-supplied signedAt. A backdated signedAt would otherwise mark the prior row revoked before its own signed_at, inverting the audit chain. The new row keeps the client signedAt. --- .../migration.sql | 20 +++++++++++++ src/lib/consent/__tests__/receipts.test.ts | 30 +++++++++++++++++-- src/lib/consent/receipts.ts | 9 +++++- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/prisma/migrations/0159_v11616_consent_active_unique/migration.sql b/prisma/migrations/0159_v11616_consent_active_unique/migration.sql index d17081d82..dae858430 100644 --- a/prisma/migrations/0159_v11616_consent_active_unique/migration.sql +++ b/prisma/migrations/0159_v11616_consent_active_unique/migration.sql @@ -11,6 +11,26 @@ -- 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. + +-- Pre-flight dedup. The bug this migration closes already produces duplicate +-- active rows on affected tenants; `CREATE UNIQUE INDEX` scans existing rows +-- and would abort (23505) on any such pair, failing the whole deploy on +-- exactly the tenants the fix targets. Revoke all-but-the-newest active row +-- per (user_id, kind) first so the index can build. The newest active row +-- (latest `created_at`) is the one `latestActiveReceipt` already returns, so +-- the surviving active grant matches current read behaviour; the superseded +-- duplicates get a `revoked_at` marker and stay in the audit history. +UPDATE "consent_receipts" c + SET "revoked_at" = now() + WHERE "revoked_at" IS NULL + AND EXISTS ( + SELECT 1 FROM "consent_receipts" n + WHERE n."user_id" = c."user_id" + AND n."kind" = c."kind" + AND n."revoked_at" IS NULL + AND n."created_at" > c."created_at" + ); + 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/src/lib/consent/__tests__/receipts.test.ts b/src/lib/consent/__tests__/receipts.test.ts index 9de301f90..d26fa43df 100644 --- a/src/lib/consent/__tests__/receipts.test.ts +++ b/src/lib/consent/__tests__/receipts.test.ts @@ -64,10 +64,12 @@ describe("createReceipt", () => { 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. + // fresh grant doesn't collide with the partial unique index. The + // supersede marker uses a server clock (NOT the client `signedAt`) so a + // backdated grant can't invert the prior row's audit timestamps. expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({ where: { userId: "user-1", kind: "ai_full", revokedAt: null }, - data: { revokedAt: signedAt }, + data: { revokedAt: expect.any(Date) }, }); expect(prisma.consentReceipt.create).toHaveBeenCalledWith({ data: { @@ -81,6 +83,30 @@ describe("createReceipt", () => { // The whole mint runs in a transaction. expect(prisma.$transaction).toHaveBeenCalledTimes(1); }); + + it("uses a server clock (not a backdated client signedAt) for the supersede marker", async () => { + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never); + + // A client backdates the grant well into the past. + const backdated = new Date("2000-01-01T00:00:00.000Z"); + const before = Date.now(); + await createReceipt("user-1", "ai_full", "PDF…", backdated); + const after = Date.now(); + + const supersedeArg = vi.mocked(prisma.consentReceipt.updateMany).mock + .calls[0][0] as { data: { revokedAt: Date } }; + // The prior row's revocation must be stamped "now", never the backdated + // client value, so the audit chain can't be inverted. + expect(supersedeArg.data.revokedAt.getTime()).not.toBe(backdated.getTime()); + expect(supersedeArg.data.revokedAt.getTime()).toBeGreaterThanOrEqual(before); + expect(supersedeArg.data.revokedAt.getTime()).toBeLessThanOrEqual(after); + // The new row still carries the client signedAt. + const createArg = vi.mocked(prisma.consentReceipt.create).mock.calls[0][0]; + expect(createArg.data.signedAt).toEqual(backdated); + }); }); describe("latestActiveReceipt", () => { diff --git a/src/lib/consent/receipts.ts b/src/lib/consent/receipts.ts index a6aedf229..38e1487fd 100644 --- a/src/lib/consent/receipts.ts +++ b/src/lib/consent/receipts.ts @@ -37,13 +37,20 @@ export async function createReceipt( artefact: string, signedAt: Date, ): Promise { + // `signedAt` is the client-supplied wall-clock for the NEW grant and is + // only validated as well-formed ISO-8601 — it can legitimately be in the + // past or out of order with existing rows. The supersede marker on the + // PRIOR active row must NOT use it: a backdated `signedAt` would stamp the + // old row's `revoked_at` earlier than its own `signed_at`, inverting the + // audit chain. Use a server clock for the revocation instead. + const supersededAt = new Date(); 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 }, + data: { revokedAt: supersededAt }, }); return tx.consentReceipt.create({ data: {