Harden consent supersede against backdated grants and dup rows

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 17:25:39 +02:00
1 parent e2f37bbe40
commit db770738bf
3 files changed
+56 -3

No files matched your search

@@ -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;
+28 -2
View File
@@ -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", () => {
+8 -1
View File
@@ -37,13 +37,20 @@ export async function createReceipt(
artefact: string,
signedAt: Date,
): Promise<ConsentReceipt> {
// `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: {