Merge pull request #686 from MBombeck/work/sharing-grant-model

The account grant model and its lifecycle
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-02 10:24:53 +02:00
commit bf9bd924b2
10 files changed
+1477 -11

No files matched your search

@@ -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;
+110
View File
@@ -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
@@ -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", () => {
@@ -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" });
});
});
+67 -7
View File
@@ -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<Record<string, string>> = {
"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<Record<string, readonly string[]>> = {
// 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<string, UserScopedDeleteDelegate>)[
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) }),
};
}
+4
View File
@@ -380,12 +380,16 @@ export const NOT_IN_BACKUP_MODELS: Readonly<Record<string, string>> = {
"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:
+133
View File
@@ -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> = {}): 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);
}
});
});
+399
View File
@@ -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<Prisma.TransactionClient, "accountGrant">;
// ── 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<AccountGrant> {
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<AccountGrant> {
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<AccountGrant> {
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<AccountGrant> {
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<AccountGrant | null> {
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<void> {
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<AccountGrant> {
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<GrantError> {
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");
}
}
@@ -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);
});
});
+24 -2
View File
@@ -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<number> {
const predicate = ownerColumns(model)
.map((column) => `"${column}" = $1`)
.join(" OR ");
const rows = userId
? await prisma.$queryRawUnsafe<Array<{ n: bigint }>>(
`SELECT count(*)::bigint AS n FROM "${table}" WHERE user_id = $1`,
`SELECT count(*)::bigint AS n FROM "${table}" WHERE ${predicate}`,
userId,
)
: await prisma.$queryRawUnsafe<Array<{ n: bigint }>>(
@@ -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})`);
}
}