mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
This commit is contained in:
1 parent
0d4b79d91b
commit
a463b1fa01
6 files changed
+33
-26
No files matched your search
@@ -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";
|
||||
+10
-7
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user