mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
fix(consent): close the receipt mint race, rate-limit, and share the back link
Mint the web AI-consent receipt inside a serializable transaction with an in-transaction recheck, backed by a partial unique index over one active receipt per user and kind, so two concurrent mounts can no longer leave two active grants; a duplicate maps to a no-op mint. Supersede an active row against the server clock rather than the client-supplied timestamp so a backdated grant cannot invert the audit chain, and revoke through a single atomic update. Rate-limit the consent endpoints per user. Extract the hand-rolled sub-page back link into one component and adopt it across the four pages that duplicated it.
This commit is contained in:
commit
555240cd28
19 files changed
+780
-176
No files matched your search
@@ -0,0 +1,36 @@
|
||||
-- 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.
|
||||
|
||||
-- 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;
|
||||
@@ -3363,6 +3363,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")
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user