mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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;
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user