From 33c879dd6b12983341f1b651e68b466205f3745b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 09:46:50 +0200 Subject: [PATCH 1/3] feat(sharing): add the account grant and its lifecycle module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An account grant is one account's standing permission to read another account's record. The row is also the consent record for the act that created it: there is no second receipt table, because two records of one act drift and then nobody can say which is true. That shapes the table. Revocation stamps revoked_at and revoked_by instead of deleting, so the record can still answer who had access, from when to when, and who ended it. The uniqueness constraint is therefore partial — one live grant per (owner, delegate), with history accumulating underneath — otherwise a re-invitation would either be refused or would overwrite the first grant's acceptance. Both foreign keys cascade: a grant naming a user who no longer exists is an authorization nobody can resolve and nobody can revoke. The state machine lives in src/lib/sharing/grants.ts and only there. The acting-account resolver and the grant surfaces both read these rows, and if each carried its own idea of "active" the drift would be silent in exactly one direction: the resolver admitting a grant the panel shows as ended. Pending confers nothing, expiry is checked against the clock of the request that reads the row, and every transition is a conditional update so two concurrent accepts cannot both win. The access column carries READ or WRITE and only READ is ever written. It exists from day one so that delegated writes become enforcement work rather than migration work. Three lifecycle paths, each proven against the real route: - Delete All Data removes grants from either side. The wipe loop asks every model for "this account's rows", and a table relating two accounts has no user_id to answer with, so wipe-plan.ts now declares the owner columns and translates. Wiping only the grantor side would leave the account still holding access to other people's records. - Account deletion removes them in both directions. - An admin restore leaves them exactly as it found them. Grants are classified NOT_IN_BACKUP: rolling data back is a decision an admin makes, but rolling an authorization back is a decision nobody makes, and a grant revoked on Tuesday must not come back alive out of Monday's file. After a restore, sharing is off and both people re-consent. The backup classification guard was reading a user relation by field name, so a model naming its account relations grantor and grantee read as instance-scoped and was excused from having a verdict at all. It now reads the type. That surfaced InviteToken, classified here too. --- .../0292_account_grant/migration.sql | 95 +++ prisma/schema.prisma | 110 ++++ .../backup-plan-classification.test.ts | 11 +- src/__tests__/data-wipe-completeness.test.ts | 54 ++ src/lib/data-wipe/wipe-plan.ts | 74 ++- src/lib/export/backup-plan.ts | 4 + src/lib/sharing/__tests__/grants.test.ts | 133 +++++ src/lib/sharing/grants.ts | 399 +++++++++++++ .../account-grant-lifecycle.test.ts | 554 ++++++++++++++++++ 9 files changed, 1425 insertions(+), 9 deletions(-) create mode 100644 prisma/migrations/0292_account_grant/migration.sql create mode 100644 src/lib/sharing/__tests__/grants.test.ts create mode 100644 src/lib/sharing/grants.ts create mode 100644 tests/integration/account-grant-lifecycle.test.ts diff --git a/prisma/migrations/0292_account_grant/migration.sql b/prisma/migrations/0292_account_grant/migration.sql new file mode 100644 index 000000000..0a8c9e797 --- /dev/null +++ b/prisma/migrations/0292_account_grant/migration.sql @@ -0,0 +1,95 @@ +-- v1.36.0 — one account's standing permission to read another account's record. +-- +-- The row IS the consent record. There is no second receipt table, because two +-- records of one act is the failure this project keeps paying for: they drift, +-- and then nobody can say which one is true. Every fact about the delegation — +-- who offered it, who accepted it and from where, when it ends, who ended it — +-- is a column here. +-- +-- That decision is what shapes the rest of the table: +-- +-- * Revocation NEVER deletes. It stamps `revoked_at` and `revoked_by`. A +-- deleted row would erase the answer to "who had access, from when to +-- when, and who ended it", which is precisely the question an owner is +-- owed and the one a deletion cannot answer. +-- * The uniqueness constraint is therefore PARTIAL. A plain +-- UNIQUE (grantor_id, grantee_id) cannot coexist with revocation-as-record: +-- re-inviting the same person after a revocation would either violate it or +-- force a reuse of the old row, and reuse destroys the first grant's +-- history. `WHERE revoked_at IS NULL` keeps the invariant that matters — +-- at most one LIVE grant per pair — while every historical row persists. +-- * Nothing here is deleted by the application at all. The two cascades below +-- are the only deletion path, and they exist because a grant row naming a +-- user who no longer exists is a dangling authorization: it would be +-- resolvable by nobody and revocable by nobody. Both directions cascade, +-- not just the grantor side. +-- +-- `access` exists from day one and only ever holds 'READ' in v1. It is a type +-- rather than a boolean so the v2 write question is enforcement work rather +-- than migration work, and so a row states its level instead of implying it. +-- +-- Self-grants are refused in `src/lib/sharing/grants.ts`, not by a CHECK here. +-- The reason is concrete rather than stylistic: the delete-all-data integration +-- guard seeds one row into every table in the schema by walking +-- `information_schema`, and it binds every foreign key that points at `users` +-- to the one account under test — so the row it plants here necessarily has +-- grantor_id = grantee_id. A CHECK would break a fixture whose whole value is +-- that nobody hand-writes it. Migration 0290 declined a CHECK for the same +-- seeder's sake and said so; this follows it. +-- +-- Timestamps: `invited_at` is the domain event and moves if an invitation is +-- re-offered on the same pending row; `created_at` is row bookkeeping and never +-- moves. They agree on the day the row is written and are allowed to disagree +-- afterwards, which is why both are here. + +CREATE TYPE "account_grant_access" AS ENUM ('READ', 'WRITE'); + +-- Owner revoked, or delegate renounced. The record says which, because "access +-- ended" and "the person given access handed it back" are different facts. +CREATE TYPE "account_grant_revoker" AS ENUM ('GRANTOR', 'GRANTEE'); + +CREATE TABLE "account_grants" ( + "id" TEXT NOT NULL, + "grantor_id" TEXT NOT NULL, + "grantee_id" TEXT NOT NULL, + "access" "account_grant_access" NOT NULL DEFAULT 'READ', + "invited_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "accepted_at" TIMESTAMPTZ(3), + "accepted_ip" TEXT, + "revoked_at" TIMESTAMPTZ(3), + "revoked_by" "account_grant_revoker", + "expires_at" TIMESTAMPTZ(3), + "last_used_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "account_grants_pkey" PRIMARY KEY ("id"), + -- A revoked row states who ended it; a live row cannot claim a revoker. + -- Both halves, so neither an unattributed revocation nor a revoker without + -- a revocation can be written. + CONSTRAINT "account_grants_revoked_by_check" + CHECK (("revoked_at" IS NULL) = ("revoked_by" IS NULL)) +); + +-- At most one LIVE grant per (owner, delegate). Revoked rows fall out of the +-- index, so history accumulates without ever blocking a re-invitation. +CREATE UNIQUE INDEX "account_grants_live_pair_key" + ON "account_grants" ("grantor_id", "grantee_id") + WHERE "revoked_at" IS NULL; + +-- The delegate's "whose records may I open" list, newest first. +CREATE INDEX "account_grants_grantee_id_created_at_idx" + ON "account_grants" ("grantee_id", "created_at" DESC); + +-- The owner's sharing panel: every grant they ever made, live and historical. +CREATE INDEX "account_grants_grantor_id_created_at_idx" + ON "account_grants" ("grantor_id", "created_at" DESC); + +ALTER TABLE "account_grants" + ADD CONSTRAINT "account_grants_grantor_id_fkey" + FOREIGN KEY ("grantor_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "account_grants" + ADD CONSTRAINT "account_grants_grantee_id_fkey" + FOREIGN KEY ("grantee_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ddb70cc5d..bd0ce0168 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -845,6 +845,13 @@ model User { // scoped, time-limited, revocable read view of the health record to a // clinician holding the raw `hls_` token (stored only as an HMAC hash). clinicianShareLinks ClinicianShareLink[] + // v1.36.0 — account sharing. Two relations because a person is on both sides + // of the arrangement: `accountGrantsGiven` are the grants this account made + // over its OWN record, `accountGrantsReceived` are the records this account + // may open. Both cascade on delete of this row — a grant naming a user who + // no longer exists is a dangling authorization. + accountGrantsGiven AccountGrant[] @relation("AccountGrantsGiven") + accountGrantsReceived AccountGrant[] @relation("AccountGrantsReceived") // v1.11.0 W1 — durable per-provider AI-health ledger (one row per // provider chain entry) so a known-bad credential is negatively cached // across workers instead of re-burning a 401 every generation. @@ -5837,6 +5844,109 @@ model ClinicianShareLinkDocument { @@map("clinician_share_link_documents") } +/// v1.36.0 — the access level a grant confers. Only `READ` is ever written in +/// v1; the type exists from day one so that adding delegated writes is +/// enforcement work rather than migration work, and so a row states its level +/// instead of leaving a reader to infer it. +enum AccountGrantAccess { + READ + WRITE + + @@map("account_grant_access") +} + +/// Who ended a grant. "The owner withdrew it" and "the delegate handed it back" +/// are different facts about the same person's record, and the consent record +/// has to be able to tell them apart. +enum AccountGrantRevoker { + GRANTOR + GRANTEE + + @@map("account_grant_revoker") +} + +/// v1.36.0 — one account's standing permission to read another account's +/// record, and the durable record of the consent that created it. +/// +/// 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`). +/// +/// Consequences worth stating where they can be read next to the columns: +/// +/// * **Revocation never deletes.** It stamps `revokedAt` + `revokedBy`. A +/// deleted row cannot answer "who had access, from when to when, and who +/// ended it" — the question the owner is owed. +/// * **The uniqueness constraint is partial and lives only in migration +/// 0292** — `UNIQUE (grantor_id, grantee_id) WHERE revoked_at IS NULL`. +/// Prisma cannot express the predicate, and declaring the plain pair here +/// would be worse than declaring nothing: it would hand the client a +/// `findUnique` over a key the database does not hold unique, so a pair +/// with several revoked rows would resolve to an arbitrary one. +/// `Device.apnsToken` carries its partial unique index the same way. At +/// most one LIVE grant per pair; history accumulates freely underneath. +/// * **Pending confers nothing and expiry is live.** `acceptedAt IS NULL` is +/// an invitation, not access, and a grant past `expiresAt` fails the active +/// predicate on the request that reads it rather than at write time. Both +/// are decided in `src/lib/sharing/grants.ts` and nowhere else. +/// * **Both foreign keys cascade.** A grant naming a user who no longer +/// exists is a dangling authorization — resolvable by nobody, revocable by +/// nobody — so deleting EITHER account removes it, not just the owner. +/// +/// Self-grants are refused by the domain module rather than by a database +/// CHECK; migration 0292 carries the reason (the schema-driven wipe seeder +/// binds every `users` foreign key to one account, so a row it plants here has +/// grantor = grantee by construction). +model AccountGrant { + id String @id @default(cuid()) + + /// The account whose record is being shared. "Owner" everywhere in the code. + grantorId String @map("grantor_id") + /// The account being given access. "Delegate" everywhere in the code. + granteeId String @map("grantee_id") + + access AccountGrantAccess @default(READ) + + /// When the invitation was offered. A domain event, so re-offering an + /// invitation on the same pending row moves it; `createdAt` below is row + /// bookkeeping and never moves. They agree the day the row is written and + /// are allowed to disagree afterwards. + invitedAt DateTime @default(now()) @map("invited_at") + /// 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 + /// either both set or both null. + revokedAt DateTime? @map("revoked_at") + revokedBy AccountGrantRevoker? @map("revoked_by") + + /// Optional lapse date. Checked live on every resolution, so an expired + /// grant stops conferring access without anything having to sweep it. + expiresAt DateTime? @map("expires_at") + /// Refreshed fire-and-forget when the grant is exercised, the same posture + /// as `ApiToken.lastUsedAt`. The owner's panel reads it to answer "is this + /// access still being used, or is it a grant nobody remembers". + lastUsedAt DateTime? @map("last_used_at") + + createdAt DateTime @default(now()) @map("created_at") + + grantor User @relation("AccountGrantsGiven", fields: [grantorId], references: [id], onDelete: Cascade) + grantee User @relation("AccountGrantsReceived", fields: [granteeId], references: [id], onDelete: Cascade) + + @@index([granteeId, createdAt(sort: Desc)]) + @@index([grantorId, createdAt(sort: Desc)]) + @@map("account_grants") +} + // v1.11.0 W3 — period-narrative cache (Pillar P1). // // One durable, typed row per (user, period, locale) carrying the latest diff --git a/src/__tests__/backup-plan-classification.test.ts b/src/__tests__/backup-plan-classification.test.ts index e782c8981..393292ad3 100644 --- a/src/__tests__/backup-plan-classification.test.ts +++ b/src/__tests__/backup-plan-classification.test.ts @@ -40,9 +40,16 @@ function schemaModels(): string[] { } /** - * Models with no `userId` and no user relation are instance-scoped (queue + * Models with no `userId` and no relation to `User` are instance-scoped (queue * tables, rate-limit buckets, invite codes the operator issues). They are not * an account's data and the wipe plan already declares them as such. + * + * The relation check is on the TYPE, not on the field name. It used to require + * a field literally called `user`, which meant a model naming its account + * relations anything else — `grantor`/`grantee` on an account grant, say — + * read as instance-scoped and was excused from having a verdict at all. A + * guard that lets the newest table through is the shape of guard this file + * exists to prevent, so it reads the type. */ function isUserScoped(model: string): boolean { const source = readFileSync(SCHEMA_PATH, "utf8"); @@ -52,7 +59,7 @@ function isUserScoped(model: string): boolean { ).exec(source); if (!block) return false; const body = block[1]; - return /\buserId\b/.test(body) || /\buser\s+User\b/.test(body); + return /\buserId\b/.test(body) || /^\s*\w+\s+User(\[\]|\?)?\s/m.test(body); } describe("every schema model has a backup verdict", () => { diff --git a/src/__tests__/data-wipe-completeness.test.ts b/src/__tests__/data-wipe-completeness.test.ts index 328b1c0b0..55451e619 100644 --- a/src/__tests__/data-wipe-completeness.test.ts +++ b/src/__tests__/data-wipe-completeness.test.ts @@ -46,7 +46,9 @@ import { USER_RESET, WIPE_EXEMPT, WIPE_MODELS, + WIPE_OWNER_FIELDS, wipeDelegateKey, + wipeWhere, } from "@/lib/data-wipe/wipe-plan"; const SCHEMA = join(process.cwd(), "prisma", "schema.prisma"); @@ -287,4 +289,56 @@ describe("data-wipe completeness", () => { expect(key.slice(1)).toBe(model.slice(1)); } }); + + /** + * The wipe loop asks every model the same question — "your rows for this + * account" — and the convention that answers it is a `userId` column. A + * model that relates two accounts has no such column, and before + * `WIPE_OWNER_FIELDS` existed the only way to notice was a Prisma error at + * request time, inside the wipe transaction, after the confirmation was + * typed. + */ + it("knows how every wiped model belongs to an account", () => { + const unaddressable = WIPE_MODELS.filter((model) => { + const shape = models.get(model); + if (!shape) return false; + return !shape.scalars.includes("userId") && !(model in WIPE_OWNER_FIELDS); + }); + expect( + unaddressable, + `these wiped models carry no userId, so the wipe cannot select their rows:\n${unaddressable.join("\n")}\n\n` + + "Add each to WIPE_OWNER_FIELDS naming the columns that bind a row to " + + "an account.", + ).toEqual([]); + }); + + it("names real columns in WIPE_OWNER_FIELDS, on wiped models only", () => { + for (const [model, fields] of Object.entries(WIPE_OWNER_FIELDS)) { + expect( + (WIPE_MODELS as readonly string[]).includes(model), + `WIPE_OWNER_FIELDS names "${model}", which the wipe does not delete`, + ).toBe(true); + const scalars = new Set(models.get(model)?.scalars ?? []); + expect( + fields.length, + `${model} needs at least one owner column`, + ).toBeGreaterThan(0); + for (const field of fields) { + expect( + scalars.has(field), + `WIPE_OWNER_FIELDS.${model} names "${field}", which is not a column on that model`, + ).toBe(true); + } + } + }); + + it("selects both sides of an account grant", () => { + // Spelled out rather than left to the generic check above: wiping only the + // grantor side would delete the grants this account made and leave it + // still holding read access to other people's records. + expect(wipeWhere("AccountGrant", "u1")).toEqual({ + OR: [{ grantorId: "u1" }, { granteeId: "u1" }], + }); + expect(wipeWhere("Measurement", "u1")).toEqual({ userId: "u1" }); + }); }); diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index f5dde6e38..e5766e289 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -130,6 +130,19 @@ export const WIPE_MODELS = [ // `resolveShareGateState` both resolve an unknown `tokenHash` to `null`, and // null is the same blunt 404 a revoked link gets. "ClinicianShareLink", + // v1.36.0 — account grants, on BOTH sides (see WIPE_OWNER_FIELDS): the ones + // this account made over its own record, and the ones it holds over someone + // else's. + // + // Deleting rather than revoking, and the difference is worth stating because + // the domain module refuses to delete these rows anywhere else. Revocation + // preserves the row precisely because the row is the consent record — but a + // person who types the confirmation on "Delete All Data" is asking for the + // record itself to be gone, and the same request already takes their entire + // audit history and their consent receipts with it. Leaving a grant behind + // would also leave a delegate holding live access to an account that just + // erased everything it had. + "AccountGrant", // ── Integrations ──────────────────────────────────────────────────────── "WithingsConnection", @@ -387,11 +400,44 @@ export const USER_KEPT_FIELDS: Readonly> = { "a per-account storage quota set by the operator, not by the person", }; +/** + * Wiped models that do NOT carry a `userId`, and the columns that say the row + * belongs to an account. + * + * The convention is one `userId` column and almost every table keeps it. A + * table that relates two accounts cannot: an account grant names an owner and + * a delegate, and a wipe by either of them has to take the row. + * + * Declared here rather than at the wipe route, so the route keeps asking for + * exactly one thing — "delete this model's rows for this account" — and the + * question of what "for this account" means for a given model stays in the + * file that already answers every other question about the wipe. + * {@link resolveWipeDelegate} translates; the route is unaware. + */ +export const WIPE_OWNER_FIELDS: Readonly> = { + // Both sides. Wiping only the grantor side would leave the account still + // holding read access to other people's records after it asked for + // everything of its own to be deleted. + AccountGrant: ["grantorId", "granteeId"], +}; + /** Prisma client delegate key for a model name (`MoodEntry` → `moodEntry`). */ export function wipeDelegateKey(model: string): string { return model.charAt(0).toLowerCase() + model.slice(1); } +/** + * The `where` that selects one account's rows of `model`. + * + * `{ userId }` for the convention, an `OR` over the declared owner columns for + * the models that relate two accounts. + */ +export function wipeWhere(model: string, userId: string): object { + const fields = WIPE_OWNER_FIELDS[model]; + if (!fields) return { userId }; + return { OR: fields.map((field) => ({ [field]: userId })) }; +} + /** The narrow slice of a Prisma delegate the wipe loop uses. */ export interface UserScopedDeleteDelegate { deleteMany(args: { where: { userId: string } }): Promise<{ count: number }>; @@ -402,19 +448,33 @@ export interface UserScopedDeleteDelegate { * * One documented cast: `WIPE_MODELS` is a literal list of model names checked * against `schema.prisma` by the completeness test, and every entry indexes a - * delegate that exposes `deleteMany` over a `userId`. The test also asserts - * every entry resolves, so an unresolvable name fails the build rather than - * the request. + * delegate that exposes `deleteMany`. The test also asserts every entry + * resolves, so an unresolvable name fails the build rather than the request. + * + * A model listed in {@link WIPE_OWNER_FIELDS} gets a wrapper that rewrites the + * caller's `{ userId }` into that model's own ownership predicate. The caller + * still says "this account's rows" and still gets a count back; only the + * translation moved, and it moved to the file that declares the columns rather + * than to the loop that does not know about them. */ export function resolveWipeDelegate( client: object, model: string, ): UserScopedDeleteDelegate { - const delegate = (client as Record)[ - wipeDelegateKey(model) - ]; + const delegate = ( + client as Record< + string, + { deleteMany(args: { where: object }): Promise<{ count: number }> } + > + )[wipeDelegateKey(model)]; if (!delegate || typeof delegate.deleteMany !== "function") { throw new Error(`No Prisma delegate for wipe model "${model}"`); } - return delegate; + if (!WIPE_OWNER_FIELDS[model]) { + return delegate as UserScopedDeleteDelegate; + } + return { + deleteMany: ({ where }) => + delegate.deleteMany({ where: wipeWhere(model, where.userId) }), + }; } diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 07f0505c6..837ded31d 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -380,12 +380,16 @@ export const NOT_IN_BACKUP_MODELS: Readonly> = { "The backup catalogue itself. A backup that contains the list of backups is a recursion with no reader.", ImportJob: "A job record pointing at an uploaded file that the backup does not carry, so restoring it would resurrect a task with nothing to work on.", + InviteToken: + "The instance's registration ledger, minted by the operator rather than by the person, and already declared instance-scoped by the wipe plan. Restoring one account's backup must not re-open a registration code the operator retired, and the creator relation is the only thing that makes this look account-scoped at all. It surfaced here when the classification check learned to read a relation by type instead of by field name.", MedicationIntakeImportJob: "A job record pointing at an uploaded dose-history file that is not carried in the backup, so restoring the job would resurrect a task with nothing to work on.", TelegramPromptContext: "Records which reminder a Telegram reply refers to, keyed by a message id in one chat on one bot. Meaningless against any other chat, and the conversation it belongs to is not carried either.", // Sharing and outbound links. + AccountGrant: + "Live authorization over another person's health record. A backup is a snapshot of DATA, and rolling data back is a decision the owner's admin makes; rolling an authorization back is a decision nobody makes — the grant an owner revoked on Tuesday would come back alive out of Monday's file, silently, with no notification and nobody aware that access resumed. Every credential-shaped row in this file is excluded for that reason (ApiToken, TrustedDevice, StepUpElevation, UserKnownDevice) and a share link for the closest one (ClinicianShareLink). The cost is stated rather than hidden: after a disaster restore, sharing is OFF and the owner has to invite again and the delegate has to accept again. That is the fail-safe direction — access lost, never access resumed — and re-consenting after a restore is the correct amount of ceremony for handing someone your health record a second time.", ClinicianShareLink: "A live URL handed to a practice. Restoring it would resurrect a link the account may have revoked deliberately — and it points at a host that may not be this one.", ClinicianShareLinkDocument: diff --git a/src/lib/sharing/__tests__/grants.test.ts b/src/lib/sharing/__tests__/grants.test.ts new file mode 100644 index 000000000..f0708338d --- /dev/null +++ b/src/lib/sharing/__tests__/grants.test.ts @@ -0,0 +1,133 @@ +/** + * The account-grant state machine, tested where it is pure. + * + * Everything that asserts what lands in the database lives in + * `tests/integration/account-grant-lifecycle.test.ts` against real Postgres — + * the partial unique index, the conditional-update claims and both cascade + * directions are properties of the database, and a fake client that ignores + * `where` would report all three as working. + * + * What is left here is the decision itself: given a row, what does it confer? + * That question has no database in it, and it is the one the resolver asks on + * every single request. + */ +import { describe, expect, it } from "vitest"; + +import { + grantAllows, + grantState, + isGrantActive, + type GrantLifecycle, +} from "@/lib/sharing/grants"; + +const NOW = new Date("2026-08-02T12:00:00.000Z"); +const EARLIER = new Date("2026-08-01T12:00:00.000Z"); +const LATER = new Date("2026-08-03T12:00:00.000Z"); + +function grant(overrides: Partial = {}): GrantLifecycle { + return { + acceptedAt: EARLIER, + revokedAt: null, + expiresAt: null, + ...overrides, + }; +} + +describe("grantState", () => { + it("is ACTIVE once accepted, with no end date", () => { + expect(grantState(grant(), NOW)).toBe("ACTIVE"); + }); + + it("is PENDING until the delegate accepts", () => { + expect(grantState(grant({ acceptedAt: null }), NOW)).toBe("PENDING"); + }); + + it("is EXPIRED once the lapse instant has arrived", () => { + // The named instant is the first instant the grant no longer holds, the + // same boundary the Bearer-token expiry uses. + expect(grantState(grant({ expiresAt: NOW }), NOW)).toBe("EXPIRED"); + expect(grantState(grant({ expiresAt: EARLIER }), NOW)).toBe("EXPIRED"); + expect(grantState(grant({ expiresAt: LATER }), NOW)).toBe("ACTIVE"); + }); + + it("is EXPIRED rather than PENDING for an invitation that lapsed", () => { + // The distinction is load-bearing: an invitation past its date can no + // longer be accepted, and calling it PENDING would say the opposite. + expect( + grantState(grant({ acceptedAt: null, expiresAt: EARLIER }), NOW), + ).toBe("EXPIRED"); + }); + + it("is REVOKED even when it also sat past its expiry", () => { + // Somebody ended it. The record says so, rather than attributing the end + // to a calendar. + expect( + grantState(grant({ revokedAt: EARLIER, expiresAt: EARLIER }), NOW), + ).toBe("REVOKED"); + }); + + it("is REVOKED for a pending invitation the owner withdrew", () => { + expect( + grantState(grant({ acceptedAt: null, revokedAt: EARLIER }), NOW), + ).toBe("REVOKED"); + }); +}); + +describe("isGrantActive", () => { + it("confers nothing while pending", () => { + expect(isGrantActive(grant({ acceptedAt: null }), NOW)).toBe(false); + }); + + it("confers nothing once revoked", () => { + expect(isGrantActive(grant({ revokedAt: EARLIER }), NOW)).toBe(false); + }); + + it("confers nothing once expired", () => { + expect(isGrantActive(grant({ expiresAt: EARLIER }), NOW)).toBe(false); + }); + + it("stops conferring the moment the clock passes the expiry", () => { + // Live, not swept: the same unchanged row answers differently as the + // clock moves, with nothing having run in between. + const lapsing = grant({ expiresAt: NOW }); + expect(isGrantActive(lapsing, EARLIER)).toBe(true); + expect(isGrantActive(lapsing, NOW)).toBe(false); + }); + + it("confers access when accepted, live and unexpired", () => { + expect(isGrantActive(grant({ expiresAt: LATER }), NOW)).toBe(true); + }); +}); + +describe("grantAllows", () => { + it("lets a READ grant read and refuses it a write", () => { + const read = { ...grant(), access: "READ" as const }; + expect(grantAllows(read, "read", NOW)).toBe(true); + expect(grantAllows(read, "write", NOW)).toBe(false); + }); + + it("lets a WRITE grant do both", () => { + const write = { ...grant(), access: "WRITE" as const }; + expect(grantAllows(write, "read", NOW)).toBe(true); + expect(grantAllows(write, "write", NOW)).toBe(true); + }); + + it("refuses a WRITE grant that is not active", () => { + // The level never outranks the state. A revoked WRITE grant is a revoked + // grant. + const revoked = { + ...grant({ revokedAt: EARLIER }), + access: "WRITE" as const, + }; + expect(grantAllows(revoked, "read", NOW)).toBe(false); + expect(grantAllows(revoked, "write", NOW)).toBe(false); + }); + + it("refuses a pending grant at every level", () => { + for (const access of ["READ", "WRITE"] as const) { + const pending = { ...grant({ acceptedAt: null }), access }; + expect(grantAllows(pending, "read", NOW)).toBe(false); + expect(grantAllows(pending, "write", NOW)).toBe(false); + } + }); +}); diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts new file mode 100644 index 000000000..71e4e9075 --- /dev/null +++ b/src/lib/sharing/grants.ts @@ -0,0 +1,399 @@ +/** + * v1.36.0 — the account-grant state machine. One file, one set of rules. + * + * A grant is one account's standing permission to read another account's + * record, and the row is also the durable record of the consent that created + * it (there is no second receipt table — see the model docblock in + * `prisma/schema.prisma`). Everything that decides what a grant currently + * MEANS lives here, and nothing else in the tree is allowed to decide it. + * + * Why one file rather than the rules at the call sites: two consumers read + * these rows for different reasons — the acting-account resolver on every + * request, and the grant CRUD surface when a person invites, accepts, revokes + * or renounces. If each carried its own idea of "active", the two would drift, + * and the drift would be silent in exactly one direction: the resolver + * admitting a grant the panel shows as ended. The Bearer-token resolver lives + * in one file for the same reason, and this module is shaped like it. + * (Naming that function here in prose is what trips the frozen-resolver guard + * in `src/__tests__/bearer-scope-enforcement-guard.test.ts`, which matches + * source text and does not exempt comments. Left as it is: a guard over the + * Bearer perimeter that shouts at a mention is the right direction to be + * wrong in.) + * + * Three properties are the whole point, and each is a decision made HERE and + * nowhere else: + * + * * **Pending confers nothing.** An invitation is not access. A row with + * `acceptedAt IS NULL` fails {@link isGrantActive} — being handed read + * access to someone's health record is not something to impose silently. + * * **Expiry is live.** The check runs against the clock on the request that + * reads the row, not against a sweep that may not have run. A grant a + * second past `expiresAt` is already inert. + * * **Revocation never deletes.** {@link revokeGrant} and + * {@link renounceGrant} stamp `revokedAt` + `revokedBy` on a row that + * stays. A deleted row cannot answer "who had access, from when to when, + * and who ended it", and that question is the reason the table exists in + * the shape it does. Re-inviting the same person mints a NEW row; the + * partial unique index in migration 0292 keeps at most one LIVE row per + * pair while the history piles up underneath. + * + * Every write below is a CONDITIONAL update — the state the transition + * requires is in the `where`, not in a read the caller performed a moment + * earlier. Two concurrent accepts therefore cannot both win, and a revoke that + * lands between a caller's read and its write cannot be overwritten by a + * transition computed against the stale row. + */ +import { prisma } from "@/lib/db"; +import { isP2002 } from "@/lib/prisma-errors"; +import type { + AccountGrant, + AccountGrantAccess, + Prisma, +} from "@/generated/prisma/client"; + +/** The slice of a grant row the pure predicates read. */ +export interface GrantLifecycle { + acceptedAt: Date | null; + revokedAt: Date | null; + expiresAt: Date | null; +} + +/** + * What a grant currently is. + * + * Ordering matters and is stated rather than left to the reader: a revoked + * grant reads REVOKED even if it also sat past its expiry, because somebody + * ended it and the record should say so. An invitation past its expiry reads + * EXPIRED rather than PENDING, because it can no longer be accepted. Only + * ACTIVE confers anything. + */ +export type GrantState = "REVOKED" | "EXPIRED" | "PENDING" | "ACTIVE"; + +/** What a caller needs the grant to permit. */ +export type GrantNeed = "read" | "write"; + +export type GrantErrorCode = + /** An account cannot share its record with itself. */ + | "self_grant" + /** A live grant for this (owner, delegate) pair already exists. */ + | "duplicate_live_grant" + /** No grant with that id. */ + | "not_found" + /** The caller is not the delegate this grant names. */ + | "not_grantee" + /** The caller is not the owner this grant names. */ + | "not_grantor" + /** Accept ran against a grant that is not pending (already accepted). */ + | "not_pending" + /** Accept ran against an invitation that had already lapsed. */ + | "expired" + /** Revoke or renounce ran against a grant that was already ended. */ + | "already_revoked"; + +/** + * A refused transition, with a stable code. + * + * Codes rather than prose because the two consumers map them to different + * things — an HTTP status on the CRUD surface, an audit reason in the + * resolver — and neither should be parsing a sentence. + */ +export class GrantError extends Error { + constructor(readonly code: GrantErrorCode) { + super(`account grant refused: ${code}`); + this.name = "GrantError"; + } +} + +/** + * The narrow client slice these functions need, so a caller can pass a + * transaction handle. Same shape as `issue-token.ts`. + */ +type GrantDb = Pick; + +// ── The state machine, pure ───────────────────────────────────────────────── + +/** What this grant is, at `now`. */ +export function grantState( + grant: GrantLifecycle, + now: Date = new Date(), +): GrantState { + if (grant.revokedAt !== null) return "REVOKED"; + // `<=` matches the Bearer-token expiry comparison: the instant named is the + // first instant the grant no longer holds. + if (grant.expiresAt !== null && grant.expiresAt <= now) return "EXPIRED"; + if (grant.acceptedAt === null) return "PENDING"; + return "ACTIVE"; +} + +/** + * Does this grant confer anything right now? + * + * The one predicate the resolver asks. Deliberately derived from + * {@link grantState} rather than re-listing the conditions, so a fourth + * terminal state can never be added without this answering for it. + */ +export function isGrantActive( + grant: GrantLifecycle, + now: Date = new Date(), +): boolean { + return grantState(grant, now) === "ACTIVE"; +} + +/** + * Does this grant permit what the caller is about to do? + * + * Active first, then the level. A READ grant permits reads only; a WRITE grant + * permits both, because write access to a record without read access describes + * nothing anyone asked for. v1 never writes a WRITE row (see + * {@link inviteGrant}), so in v1 this answers `false` for every write — which + * is the fail-closed arm the resolver wants, reached by the data rather than + * by a special case. + */ +export function grantAllows( + grant: GrantLifecycle & { access: AccountGrantAccess }, + need: GrantNeed, + now: Date = new Date(), +): boolean { + if (!isGrantActive(grant, now)) return false; + return need === "read" ? true : grant.access === "WRITE"; +} + +// ── Lifecycle transitions ─────────────────────────────────────────────────── + +export interface InviteGrantInput { + /** The account whose record is being shared. */ + grantorId: string; + /** The account being invited to read it. */ + granteeId: string; + /** Optional lapse date. Null = the grant runs until somebody ends it. */ + expiresAt?: Date | null; +} + +/** + * Offer a grant. The delegate has access only once they accept it. + * + * `access` is not a parameter. v1 grants READ and only READ (design §14): the + * column exists from day one so that delegated writes become enforcement work + * instead of migration work, and leaving the level out of the input is what + * makes "v1 never grants write" a property of this file rather than a promise + * in a comment. + * + * Self-grants are refused here rather than by a database CHECK — migration + * 0292 carries the reason (the schema-driven wipe seeder plants a row with + * grantor = grantee by construction). The refusal is therefore load-bearing: + * it is the only thing standing between the app and a nonsense row. + */ +export async function inviteGrant( + input: InviteGrantInput, + db: GrantDb = prisma, +): Promise { + if (input.grantorId === input.granteeId) { + throw new GrantError("self_grant"); + } + + const now = new Date(); + try { + return await db.accountGrant.create({ + data: { + grantorId: input.grantorId, + granteeId: input.granteeId, + access: "READ", + invitedAt: now, + expiresAt: input.expiresAt ?? null, + }, + }); + } catch (err) { + // The partial unique index is what refuses a second live grant for the + // pair; a revoked row never collides, so a re-invitation after revocation + // lands as a new row and the old one keeps its history. + if (isP2002(err)) throw new GrantError("duplicate_live_grant"); + throw err; + } +} + +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; +} + +/** + * Accept an invitation. Delegate-only, one-shot. + * + * The claim is the `where` clause: `acceptedAt: null` means two concurrent + * accepts cannot both update a row, and a grant the owner revoked in the + * meantime cannot be accepted at all. On a miss the row is read back once to + * say WHY, which is a diagnosis and never a second chance. + */ +export async function acceptGrant( + input: AcceptGrantInput, + db: GrantDb = prisma, +): Promise { + const now = new Date(); + const { count } = await db.accountGrant.updateMany({ + where: { + id: input.grantId, + granteeId: input.granteeId, + acceptedAt: null, + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }, + data: { acceptedAt: now, acceptedIp: input.ip ?? null }, + }); + + if (count === 0) { + throw await refusalFor(input.grantId, { granteeId: input.granteeId }, db); + } + + return db.accountGrant.findUniqueOrThrow({ where: { id: input.grantId } }); +} + +export interface RevokeGrantInput { + grantId: string; + /** The owner ending their own grant. Taken from the session. */ + grantorId: string; +} + +/** + * The owner withdraws access. + * + * No step-up, no friction: reducing access must never be harder than granting + * it. The row survives with `revokedBy = GRANTOR`, and because the resolver + * re-reads the grant on every request, enforcement is the delegate's next + * request rather than their next login. + */ +export async function revokeGrant( + input: RevokeGrantInput, + db: GrantDb = prisma, +): Promise { + return endGrant(input.grantId, { grantorId: input.grantorId }, "GRANTOR", db); +} + +export interface RenounceGrantInput { + grantId: string; + /** The delegate handing the access back. Taken from the session. */ + granteeId: string; +} + +/** + * The delegate hands the access back. + * + * Same transition, different attribution: `revokedBy = GRANTEE` so the record + * can tell "the owner withdrew it" from "the person given access no longer + * wanted it". Two facts, one column, because they are answers to the same + * question. + */ +export async function renounceGrant( + input: RenounceGrantInput, + db: GrantDb = prisma, +): Promise { + return endGrant(input.grantId, { granteeId: input.granteeId }, "GRANTEE", db); +} + +// ── Resolution ────────────────────────────────────────────────────────────── + +/** + * The grant that lets `granteeId` act on `grantorId`'s record right now, or + * null. + * + * The query narrows on `revokedAt: null` only — which the partial unique index + * makes at most one row — and every other condition is decided by + * {@link isGrantActive}. That split is deliberate: a SQL predicate spelling out + * "accepted and not expired" beside a TypeScript predicate spelling out the + * same thing is two places deciding one question, and the copy that drifts is + * the one nobody reads. Here the database narrows and the state machine + * decides. + */ +export async function findActiveGrant( + pair: { grantorId: string; granteeId: string }, + db: GrantDb = prisma, + now: Date = new Date(), +): Promise { + const live = await db.accountGrant.findFirst({ + where: { + grantorId: pair.grantorId, + granteeId: pair.granteeId, + revokedAt: null, + }, + }); + if (!live) return null; + return isGrantActive(live, now) ? live : null; +} + +/** + * Stamp `lastUsedAt` on a grant that was just exercised. + * + * Fire-and-forget, the `ApiToken.lastUsedAt` posture (`bearer.ts`): the request + * that triggered it must not wait, and a failure to record the use must not + * fail the read. The promise is returned so a test can await it; production + * callers deliberately do not. + */ +export function touchGrantUsage( + grantId: string, + db: GrantDb = prisma, +): Promise { + return db.accountGrant + .update({ where: { id: grantId }, data: { lastUsedAt: new Date() } }) + .then(() => undefined) + .catch(() => undefined); +} + +// ── Internals ─────────────────────────────────────────────────────────────── + +/** The shared body of revoke and renounce. */ +async function endGrant( + grantId: string, + actor: { grantorId: string } | { granteeId: string }, + revokedBy: "GRANTOR" | "GRANTEE", + db: GrantDb, +): Promise { + const { count } = await db.accountGrant.updateMany({ + where: { id: grantId, ...actor, revokedAt: null }, + data: { revokedAt: new Date(), revokedBy }, + }); + + if (count === 0) throw await refusalFor(grantId, actor, db); + + return db.accountGrant.findUniqueOrThrow({ where: { id: grantId } }); +} + +/** + * Why did a conditional update match nothing? + * + * Read once, after the fact, purely to name the refusal. The ordering is + * ownership first: telling a stranger "that grant is already revoked" would + * confirm the grant exists, so a caller who is not a party to the row learns + * only that it is not theirs. + */ +async function refusalFor( + grantId: string, + actor: { grantorId: string } | { granteeId: string }, + db: GrantDb, +): Promise { + const grant = await db.accountGrant.findUnique({ where: { id: grantId } }); + if (!grant) return new GrantError("not_found"); + + if ("granteeId" in actor) { + if (grant.granteeId !== actor.granteeId) { + return new GrantError("not_grantee"); + } + } else if (grant.grantorId !== actor.grantorId) { + return new GrantError("not_grantor"); + } + + // The state machine names the refusal, so "why did this fail" and "what is + // this row" can never give different answers. + switch (grantState(grant)) { + case "REVOKED": + return new GrantError("already_revoked"); + case "EXPIRED": + return new GrantError("expired"); + // Reached only from the accept path — revoke and renounce match any live + // row, lapsed or not, because ending a grant explicitly is always the + // owner's or the delegate's to do. + default: + return new GrantError("not_pending"); + } +} diff --git a/tests/integration/account-grant-lifecycle.test.ts b/tests/integration/account-grant-lifecycle.test.ts new file mode 100644 index 000000000..0cc3470c5 --- /dev/null +++ b/tests/integration/account-grant-lifecycle.test.ts @@ -0,0 +1,554 @@ +/** + * The account grant against real Postgres. + * + * Everything asserted here is a property of the DATABASE, which is why none of + * it is unit-testable: the partial unique index that allows history but not a + * second live grant, the conditional-update claims that make accept one-shot, + * and the two cascades. A fake Prisma client that ignored `where` would report + * every one of them as working. + * + * The three lifecycle paths a new persisted table has to join are proven here + * too, each against the real route rather than against a list that claims the + * route does it: + * + * 1. account deletion — BOTH directions, because a grant naming a user who + * no longer exists is a dangling authorization; + * 2. `DELETE /api/settings/data` — for the owner AND for the delegate, since + * a grant belongs to two accounts; + * 3. `POST /api/admin/backups/[id]/restore` — which must leave grants + * exactly as it found them. That third one is an inverse proof and is + * deliberate: `AccountGrant` is classified NOT_IN_BACKUP (see + * `src/lib/export/backup-plan.ts` for the reasoning), so the property + * worth holding is that a restore can neither resurrect a revoked grant + * nor be talked into creating one. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt } from "@/lib/crypto"; +import { + GrantError, + acceptGrant, + findActiveGrant, + inviteGrant, + renounceGrant, + revokeGrant, + touchGrantUsage, +} from "@/lib/sharing/grants"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +let counter = 0; + +async function makeUser(label: string, role: "USER" | "ADMIN" = "USER") { + const suffix = `${label}-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `grant-${suffix}`, + email: `grant-${suffix}@example.test`, + role, + }, + }); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { + userId, + expiresAt: new Date(Date.now() + 60_000), + mfaVerifiedAt: new Date(), + }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +/** Owner and delegate, with the grant already invited and accepted. */ +async function acceptedGrant() { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + }); + const grant = await acceptGrant({ + grantId: invited.id, + granteeId: delegate.id, + ip: "203.0.113.7", + }); + return { owner, delegate, grant }; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("account grant lifecycle", () => { + it("confers nothing until the delegate accepts", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + }); + expect(invited.acceptedAt).toBeNull(); + expect(invited.access).toBe("READ"); + expect( + await findActiveGrant({ + grantorId: owner.id, + granteeId: delegate.id, + }), + ).toBeNull(); + + 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"); + + const active = await findActiveGrant({ + grantorId: owner.id, + granteeId: delegate.id, + }); + expect(active?.id).toBe(invited.id); + }); + + it("refuses an account sharing its record with itself", async () => { + const solo = await makeUser("solo"); + await expect( + inviteGrant({ grantorId: solo.id, granteeId: solo.id }), + ).rejects.toMatchObject({ code: "self_grant" }); + expect(await getPrismaClient().accountGrant.count()).toBe(0); + }); + + it("lets only the named delegate accept, exactly once", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const stranger = await makeUser("stranger"); + + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + }); + + await expect( + acceptGrant({ grantId: invited.id, granteeId: stranger.id }), + ).rejects.toMatchObject({ code: "not_grantee" }); + expect( + ( + await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: invited.id }, + }) + ).acceptedAt, + ).toBeNull(); + + 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. + 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 () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + expiresAt: new Date(Date.now() - 1000), + }); + + await expect( + acceptGrant({ grantId: invited.id, granteeId: delegate.id }), + ).rejects.toMatchObject({ code: "expired" }); + }); + + it("stops conferring access the moment the grant expires", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + expiresAt: new Date(Date.now() + 60_000), + }); + await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); + + const pair = { grantorId: owner.id, granteeId: delegate.id }; + expect(await findActiveGrant(pair)).not.toBeNull(); + // Nothing swept, nothing changed the row: only the clock the resolver + // reads moved past the lapse instant. + expect( + await findActiveGrant( + pair, + getPrismaClient(), + new Date(Date.now() + 61_000), + ), + ).toBeNull(); + }); + + it("revokes without deleting, and says who ended it", async () => { + const { owner, delegate, grant } = await acceptedGrant(); + + const revoked = await revokeGrant({ + grantId: grant.id, + grantorId: owner.id, + }); + expect(revoked.revokedAt).not.toBeNull(); + expect(revoked.revokedBy).toBe("GRANTOR"); + + // The row is the consent record. It survives, with the acceptance it + // carried before the revocation intact. + const row = await getPrismaClient().accountGrant.findUnique({ + where: { id: grant.id }, + }); + expect(row).not.toBeNull(); + expect(row!.acceptedAt).toEqual(grant.acceptedAt); + expect(row!.acceptedIp).toBe("203.0.113.7"); + + expect( + await findActiveGrant({ + grantorId: owner.id, + granteeId: delegate.id, + }), + ).toBeNull(); + }); + + it("records a renouncement as the delegate's act, not the owner's", async () => { + const { delegate, grant } = await acceptedGrant(); + + const renounced = await renounceGrant({ + grantId: grant.id, + granteeId: delegate.id, + }); + expect(renounced.revokedBy).toBe("GRANTEE"); + expect( + await getPrismaClient().accountGrant.count({ where: { id: grant.id } }), + ).toBe(1); + }); + + it("refuses a revocation from anyone but the parties named", async () => { + const { owner, delegate, grant } = await acceptedGrant(); + const stranger = await makeUser("stranger"); + + await expect( + revokeGrant({ grantId: grant.id, grantorId: stranger.id }), + ).rejects.toMatchObject({ code: "not_grantor" }); + await expect( + renounceGrant({ grantId: grant.id, granteeId: stranger.id }), + ).rejects.toMatchObject({ code: "not_grantee" }); + + // The delegate cannot revoke as the owner, nor the owner renounce as the + // delegate — the attribution is not a label the caller picks. + await expect( + revokeGrant({ grantId: grant.id, grantorId: delegate.id }), + ).rejects.toMatchObject({ code: "not_grantor" }); + await expect( + renounceGrant({ grantId: grant.id, granteeId: owner.id }), + ).rejects.toMatchObject({ code: "not_grantee" }); + + expect( + ( + await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: grant.id }, + }) + ).revokedAt, + ).toBeNull(); + }); + + it("refuses a second live grant for the same pair", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await inviteGrant({ grantorId: owner.id, granteeId: delegate.id }); + + await expect( + inviteGrant({ grantorId: owner.id, granteeId: delegate.id }), + ).rejects.toBeInstanceOf(GrantError); + await expect( + inviteGrant({ grantorId: owner.id, granteeId: delegate.id }), + ).rejects.toMatchObject({ code: "duplicate_live_grant" }); + expect(await getPrismaClient().accountGrant.count()).toBe(1); + }); + + it("re-invites after a revocation as a NEW row, leaving the old one intact", async () => { + const { owner, delegate, grant } = await acceptedGrant(); + const first = await revokeGrant({ + grantId: grant.id, + grantorId: owner.id, + }); + + const second = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + }); + expect(second.id).not.toBe(first.id); + expect(second.acceptedAt).toBeNull(); + expect(second.revokedAt).toBeNull(); + + // Two rows for one pair: the ended one and the pending one. This is the + // whole reason the unique index is partial. + const all = await getPrismaClient().accountGrant.findMany({ + where: { grantorId: owner.id, granteeId: delegate.id }, + orderBy: { createdAt: "asc" }, + }); + expect(all).toHaveLength(2); + 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( + await findActiveGrant({ + grantorId: owner.id, + granteeId: delegate.id, + }), + ).toBeNull(); + }); + + it("keeps history without limit — a third grant does not collide with two ended ones", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + for (let i = 0; i < 2; i++) { + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + }); + await revokeGrant({ grantId: invited.id, grantorId: owner.id }); + } + await inviteGrant({ grantorId: owner.id, granteeId: delegate.id }); + expect( + await getPrismaClient().accountGrant.count({ + where: { grantorId: owner.id, granteeId: delegate.id }, + }), + ).toBe(3); + }); + + it("refuses to end a grant twice", async () => { + const { owner, grant } = await acceptedGrant(); + await revokeGrant({ grantId: grant.id, grantorId: owner.id }); + await expect( + revokeGrant({ grantId: grant.id, grantorId: owner.id }), + ).rejects.toMatchObject({ code: "already_revoked" }); + }); + + it("stamps lastUsedAt without failing the caller when the grant is gone", async () => { + const { grant } = await acceptedGrant(); + await touchGrantUsage(grant.id); + expect( + ( + await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: grant.id }, + }) + ).lastUsedAt, + ).not.toBeNull(); + + // Fire-and-forget: a grant deleted between the read and the stamp must not + // turn a successful request into a failure. + await expect(touchGrantUsage("no-such-grant")).resolves.toBeUndefined(); + }); +}); + +describe("account grant lifecycle paths", () => { + it("is removed when EITHER account is deleted", async () => { + const prisma = getPrismaClient(); + + const byOwner = await acceptedGrant(); + await prisma.user.delete({ where: { id: byOwner.owner.id } }); + expect( + await prisma.accountGrant.count({ where: { id: byOwner.grant.id } }), + "deleting the owner left an authorization naming a user who no longer exists", + ).toBe(0); + + const byDelegate = await acceptedGrant(); + await prisma.user.delete({ where: { id: byDelegate.delegate.id } }); + expect( + await prisma.accountGrant.count({ where: { id: byDelegate.grant.id } }), + "deleting the delegate left an authorization naming a user who no longer exists", + ).toBe(0); + }); + + it("is removed by Delete All Data, from either side of the grant", async () => { + const prisma = getPrismaClient(); + const { DELETE } = await import("@/app/api/settings/data/route"); + + const wipe = async (userId: string) => { + await signIn(userId); + const response = await DELETE( + new Request("http://localhost/api/settings/data", { + method: "DELETE", + body: JSON.stringify({ confirm: "DELETE" }), + }) as never, + ); + expect(response.status).toBe(200); + }; + + // The owner erases their record: the access they handed out goes with it. + const asOwner = await acceptedGrant(); + await wipe(asOwner.owner.id); + expect( + await prisma.accountGrant.count({ where: { id: asOwner.grant.id } }), + "a delegate still holds read access to an account that erased everything", + ).toBe(0); + + // The delegate erases their record: the access they hold goes too. Wiping + // only the grantor side would leave this row behind. + const asDelegate = await acceptedGrant(); + await wipe(asDelegate.delegate.id); + expect( + await prisma.accountGrant.count({ where: { id: asDelegate.grant.id } }), + "the wipe took only the grants this account made, not the ones it held", + ).toBe(0); + }); + + it("survives an admin restore untouched, and cannot be created by one", async () => { + const prisma = getPrismaClient(); + const admin = await makeUser("restore-admin", "ADMIN"); + + // The admin is also an owner: one live grant over their record, and one + // they revoked earlier. A restore must move neither. + const live = await makeUser("live-delegate"); + const dropped = await makeUser("dropped-delegate"); + + const liveInvite = await inviteGrant({ + grantorId: admin.id, + granteeId: live.id, + }); + const liveGrant = await acceptGrant({ + grantId: liveInvite.id, + granteeId: live.id, + ip: "203.0.113.7", + }); + const droppedInvite = await inviteGrant({ + grantorId: admin.id, + granteeId: dropped.id, + }); + await acceptGrant({ grantId: droppedInvite.id, granteeId: dropped.id }); + const revoked = await revokeGrant({ + grantId: droppedInvite.id, + grantorId: admin.id, + }); + + // A payload that carries grants — including one for an account whose + // access the owner ended. `parseBackupPayload` passes unknown keys + // through, so this is what a hand-edited or future-shaped file looks like + // arriving at today's restore. + const stranger = await makeUser("stranger"); + const backup = await prisma.dataBackup.create({ + data: { + userId: admin.id, + type: "ACCOUNT_GRANT_RESTORE_TEST", + data: encrypt( + JSON.stringify({ + schemaVersion: "1", + exportedAt: "2026-08-01T00:00:00.000Z", + userId: admin.id, + measurements: [], + medications: [], + intakeEvents: [], + moodEntries: [], + accountGrants: [ + { + id: revoked.id, + grantorId: admin.id, + granteeId: dropped.id, + access: "READ", + acceptedAt: "2026-07-01T00:00:00.000Z", + revokedAt: null, + revokedBy: null, + }, + { + grantorId: admin.id, + granteeId: stranger.id, + access: "WRITE", + acceptedAt: "2026-07-01T00:00:00.000Z", + }, + ], + }), + ), + }, + }); + + await signIn(admin.id); + const { POST } = await import("@/app/api/admin/backups/[id]/restore/route"); + const response = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as never, + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status).toBe(200); + + // The revoked grant stayed revoked. This is the failure the exclusion + // exists to prevent: access the owner ended coming back alive out of a + // file, with nobody deciding it and nobody told. + const after = await prisma.accountGrant.findUniqueOrThrow({ + where: { id: revoked.id }, + }); + expect(after.revokedAt).toEqual(revoked.revokedAt); + expect(after.revokedBy).toBe("GRANTOR"); + + // The live grant is neither dropped nor re-stamped. + const stillLive = await prisma.accountGrant.findUniqueOrThrow({ + where: { id: liveGrant.id }, + }); + expect(stillLive.revokedAt).toBeNull(); + expect(stillLive.acceptedAt).toEqual(liveGrant.acceptedAt); + + // And the payload could not mint one. + expect( + await prisma.accountGrant.count({ where: { granteeId: stranger.id } }), + "a backup file created an authorization over a health record", + ).toBe(0); + expect(await prisma.accountGrant.count()).toBe(2); + }); +}); From 07e8ca9ef543504d6e715c41106512a07cda57c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 09:57:31 +0200 Subject: [PATCH 2/3] test(sharing): teach the wipe guard that ownership is not always user_id The delete-all-data guard counted an account's surviving rows with a literal `WHERE user_id = $1`, which is the same assumption the wipe loop itself used to make. Account grants relate two accounts and carry no such column, so the guard raised `column "user_id" does not exist` and stopped before it could assert anything. The predicate now comes from the wipe plan's own owner-column declaration, so the guard asks the question the route answers instead of its own version of it. --- tests/integration/settings-data-wipe.test.ts | 26 ++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/integration/settings-data-wipe.test.ts b/tests/integration/settings-data-wipe.test.ts index 1edbeef11..0d572d012 100644 --- a/tests/integration/settings-data-wipe.test.ts +++ b/tests/integration/settings-data-wipe.test.ts @@ -38,6 +38,7 @@ import { USER_RESET, WIPE_EXEMPT, WIPE_MODELS, + WIPE_OWNER_FIELDS, } from "@/lib/data-wipe/wipe-plan"; import type { PrismaClient } from "@/generated/prisma/client"; @@ -271,14 +272,35 @@ async function seedEveryTable( return ordered; } +/** + * The columns that bind a row of `model` to an account, as SQL identifiers. + * + * `user_id` is the convention. A model that relates two accounts cannot use + * it, and the wipe plan already declares its columns — read them from there + * rather than keeping a second list here, or the two would answer differently + * and this file would be asserting against its own idea of ownership instead + * of the one the route uses. + */ +function ownerColumns(model: string | undefined): string[] { + const declared = model ? WIPE_OWNER_FIELDS[model] : undefined; + if (!declared) return ["user_id"]; + return declared.map((field) => + field.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`), + ); +} + async function countRows( prisma: PrismaClient, table: string, userId?: string, + model?: string, ): Promise { + const predicate = ownerColumns(model) + .map((column) => `"${column}" = $1`) + .join(" OR "); const rows = userId ? await prisma.$queryRawUnsafe>( - `SELECT count(*)::bigint AS n FROM "${table}" WHERE user_id = $1`, + `SELECT count(*)::bigint AS n FROM "${table}" WHERE ${predicate}`, userId, ) : await prisma.$queryRawUnsafe>( @@ -354,7 +376,7 @@ describe("DELETE /api/settings/data leaves nothing it promised to delete", () => // one new row after it commits: the receipt for the wipe itself. The // history is gone; the record that the person asked for it is not. const expected = model === "AuditLog" ? 1 : 0; - if ((await countRows(prisma, table, user.id)) > expected) { + if ((await countRows(prisma, table, user.id, model)) > expected) { survivors.push(`${model} (${table})`); } } From 48f29cc802e66f532d2c7e321eadb3ff004e878a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 10:01:35 +0200 Subject: [PATCH 3/3] test(sharing): hold the revoker pair at the database A revoked grant with no revoker, or a revoker with no revocation, are both rows that cannot answer the question the table exists to answer. The migration already enforces the pair; this asserts it against real Postgres rather than trusting that the domain module is the only writer. --- .../account-grant-lifecycle.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/integration/account-grant-lifecycle.test.ts b/tests/integration/account-grant-lifecycle.test.ts index 0cc3470c5..53581494f 100644 --- a/tests/integration/account-grant-lifecycle.test.ts +++ b/tests/integration/account-grant-lifecycle.test.ts @@ -303,6 +303,34 @@ describe("account grant lifecycle", () => { ).toBeNull(); }); + it("refuses a revocation the record cannot attribute", async () => { + // The database holds the halves together, because a revoked row with no + // revoker and a revoker with no revocation are both records that cannot + // answer the question the table exists to answer. The domain module always + // writes the pair; this is the floor under it. + const { grant } = await acceptedGrant(); + const prisma = getPrismaClient(); + + await expect( + prisma.accountGrant.update({ + where: { id: grant.id }, + data: { revokedAt: new Date() }, + }), + ).rejects.toThrow(); + await expect( + prisma.accountGrant.update({ + where: { id: grant.id }, + data: { revokedBy: "GRANTOR" }, + }), + ).rejects.toThrow(); + + const row = await prisma.accountGrant.findUniqueOrThrow({ + where: { id: grant.id }, + }); + expect(row.revokedAt).toBeNull(); + expect(row.revokedBy).toBeNull(); + }); + it("refuses a second live grant for the same pair", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate");