mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge pull request #686 from MBombeck/work/sharing-grant-model
The account grant model and its lifecycle
This commit is contained in:
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;
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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) }),
|
||||
};
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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})`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user