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 }; }