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..53581494f --- /dev/null +++ b/tests/integration/account-grant-lifecycle.test.ts @@ -0,0 +1,582 @@ +/** + * 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 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"); + 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); + }); +}); 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})`); } }