From a463b1fa0101ccc007a2c86e453a352b4577efca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 16:36:12 +0200 Subject: [PATCH] Stop recording the address a delegate accepts a grant from A consent record has to answer who consented and when, and the grant row already does. It does not have to answer from where, and an address kept for as long as both accounts exist is personal data held past the point it evidences anything. Nothing read the column, which makes it exactly the write-only field scripts/audit-column-readers.ts exists to surface. The acceptance stays evidenced: audit_logs records it with the caller's address under that table's own retention. The column never reached the wire, so the grant view and the OpenAPI contract are unchanged. Written before the feature shipped, so migration 0295 drops an empty column. --- .../0295_drop_grant_accepted_ip/migration.sql | 10 ++++++++++ prisma/schema.prisma | 17 ++++++++++------- src/app/api/account/grants/[id]/accept/route.ts | 12 +++++++----- src/lib/sharing/grants.ts | 4 +--- .../integration/account-grant-lifecycle.test.ts | 15 +++++---------- .../integration/acting-account-resolver.test.ts | 1 - 6 files changed, 33 insertions(+), 26 deletions(-) create mode 100644 prisma/migrations/0295_drop_grant_accepted_ip/migration.sql diff --git a/prisma/migrations/0295_drop_grant_accepted_ip/migration.sql b/prisma/migrations/0295_drop_grant_accepted_ip/migration.sql new file mode 100644 index 000000000..d30af6a61 --- /dev/null +++ b/prisma/migrations/0295_drop_grant_accepted_ip/migration.sql @@ -0,0 +1,10 @@ +-- v1.36.0 — drop the address the delegate accepted from. +-- +-- A consent record has to say who consented and when. It does not need to say +-- from where, and an address kept for as long as both accounts exist is +-- personal data held well past the point it answers anything. Nothing reads +-- the column; the acceptance is still recorded in `audit_logs` with its IP, +-- under that table's own retention. +-- +-- Written before the feature shipped, so there is no data to migrate. +ALTER TABLE "account_grants" DROP COLUMN "accepted_ip"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 82928d26c..78c96f7d7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5924,9 +5924,16 @@ enum AccountGrantRevoker { /// The row is BOTH. There is no separate receipt table: two records of one act /// drift, and then nobody can say which is true. So every fact about the /// delegation is a column here — who offered it (`grantorId`, `invitedAt`), who -/// accepted and from where (`granteeId`, `acceptedAt`, `acceptedIp`), when it -/// lapses (`expiresAt`), when it was last exercised (`lastUsedAt`), and who -/// ended it (`revokedAt`, `revokedBy`). +/// accepted it (`granteeId`, `acceptedAt`), when it lapses (`expiresAt`), when +/// it was last exercised (`lastUsedAt`), and who ended it (`revokedAt`, +/// `revokedBy`). +/// +/// What is deliberately NOT a column: the address the delegate accepted from. +/// A consent record has to say who consented and when, and this one does. It +/// does not need to say from where, and an address kept for as long as both +/// accounts exist is personal data held past the point it answers anything. +/// `AuditLog` still records the acceptance with its IP under that table's own +/// retention. /// /// Consequences worth stating where they can be read next to the columns: /// @@ -5971,10 +5978,6 @@ model AccountGrant { /// When the delegate accepted. NULL = pending, which confers nothing. /// One-shot: an accepted grant can never be accepted again. acceptedAt DateTime? @map("accepted_at") - /// The delegate's IP at acceptance, recorded plaintext exactly as - /// `AuditLog.ipAddress` is. Being handed someone's health record is an act - /// the consent record should be able to place. - acceptedIp String? @map("accepted_ip") /// When access ended, and who ended it. Written together, never cleared — /// a re-invitation is a NEW row. The database enforces that the two are diff --git a/src/app/api/account/grants/[id]/accept/route.ts b/src/app/api/account/grants/[id]/accept/route.ts index 3aaa872e3..a03df2c9f 100644 --- a/src/app/api/account/grants/[id]/accept/route.ts +++ b/src/app/api/account/grants/[id]/accept/route.ts @@ -7,10 +7,13 @@ * row with `acceptedAt IS NULL` and why this route is the only thing that can * set it. * - * The acceptance is also the consent record. `acceptedAt` and `acceptedIp` are - * stamped here and never anywhere else, on the same row that carries who - * offered the access and when it ended — one row, one history, no second - * receipt table to disagree with it. + * The acceptance is also the consent record. `acceptedAt` is stamped here and + * never anywhere else, on the same row that carries who offered the access and + * when it ended — one row, one history, no second receipt table to disagree + * with it. The address the delegate accepted from is deliberately not part of + * it: who and when is what a consent record has to answer, and an address kept + * for the life of both accounts answers nothing further. The audit row below + * still carries it, under `audit_logs`' own retention. * * Not delegable: bare `requireAuth()` refuses under a switch, so nobody can * accept an invitation while acting as somebody else. The delegate is the @@ -60,7 +63,6 @@ export const POST = apiHandler( const grant = await acceptGrant({ grantId: id, granteeId: user.id, - ip, }); const grantor = await prisma.user.findUniqueOrThrow({ diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts index 1ce413c87..fb387b469 100644 --- a/src/lib/sharing/grants.ts +++ b/src/lib/sharing/grants.ts @@ -216,8 +216,6 @@ export interface AcceptGrantInput { grantId: string; /** Taken from the authenticated session — never from a request body. */ granteeId: string; - /** The delegate's IP at acceptance, as the audit trail records one. */ - ip?: string | null; } /** @@ -241,7 +239,7 @@ export async function acceptGrant( revokedAt: null, OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], }, - data: { acceptedAt: now, acceptedIp: input.ip ?? null }, + data: { acceptedAt: now }, }); if (count === 0) { diff --git a/tests/integration/account-grant-lifecycle.test.ts b/tests/integration/account-grant-lifecycle.test.ts index 53581494f..908c240f4 100644 --- a/tests/integration/account-grant-lifecycle.test.ts +++ b/tests/integration/account-grant-lifecycle.test.ts @@ -102,7 +102,6 @@ async function acceptedGrant() { const grant = await acceptGrant({ grantId: invited.id, granteeId: delegate.id, - ip: "203.0.113.7", }); return { owner, delegate, grant }; } @@ -134,10 +133,12 @@ describe("account grant lifecycle", () => { const accepted = await acceptGrant({ grantId: invited.id, granteeId: delegate.id, - ip: "203.0.113.7", }); expect(accepted.acceptedAt).not.toBeNull(); - expect(accepted.acceptedIp).toBe("203.0.113.7"); + // The consent record says who and when, and deliberately not from where. + // A column re-added and written again would fail here rather than pass + // unnoticed, which is the point of asserting an absence. + expect(accepted).not.toHaveProperty("acceptedIp"); const active = await findActiveGrant({ grantorId: owner.id, @@ -178,23 +179,20 @@ describe("account grant lifecycle", () => { const first = await acceptGrant({ grantId: invited.id, granteeId: delegate.id, - ip: "203.0.113.7", }); await expect( acceptGrant({ grantId: invited.id, granteeId: delegate.id, - ip: "198.51.100.4", }), ).rejects.toMatchObject({ code: "not_pending" }); // The second attempt did not re-stamp the acceptance: the consent record - // still says when and from where the delegate actually agreed. + // still says when the delegate actually agreed. const row = await getPrismaClient().accountGrant.findUniqueOrThrow({ where: { id: invited.id }, }); expect(row.acceptedAt).toEqual(first.acceptedAt); - expect(row.acceptedIp).toBe("203.0.113.7"); }); it("cannot accept an invitation that already lapsed", async () => { @@ -251,7 +249,6 @@ describe("account grant lifecycle", () => { }); expect(row).not.toBeNull(); expect(row!.acceptedAt).toEqual(grant.acceptedAt); - expect(row!.acceptedIp).toBe("203.0.113.7"); expect( await findActiveGrant({ @@ -370,7 +367,6 @@ describe("account grant lifecycle", () => { expect(all[0].id).toBe(first.id); expect(all[0].revokedAt).toEqual(first.revokedAt); expect(all[0].revokedBy).toBe("GRANTOR"); - expect(all[0].acceptedIp).toBe("203.0.113.7"); // And the resolver still answers with the live row only. expect( @@ -492,7 +488,6 @@ describe("account grant lifecycle paths", () => { const liveGrant = await acceptGrant({ grantId: liveInvite.id, granteeId: live.id, - ip: "203.0.113.7", }); const droppedInvite = await inviteGrant({ grantorId: admin.id, diff --git a/tests/integration/acting-account-resolver.test.ts b/tests/integration/acting-account-resolver.test.ts index a718a7bdd..6335cdf7e 100644 --- a/tests/integration/acting-account-resolver.test.ts +++ b/tests/integration/acting-account-resolver.test.ts @@ -157,7 +157,6 @@ async function household() { const grant = await acceptGrant({ grantId: invited.id, granteeId: delegate.id, - ip: "203.0.113.7", }); return { owner, delegate, grant }; }