Files
HealthLog/prisma/migrations/0292_account_grant/migration.sql
T
Marc-André Bombeck 33c879dd6b feat(sharing): add the account grant and its lifecycle module
An account grant is one account's standing permission to read another
account's record. The row is also the consent record for the act that
created it: there is no second receipt table, because two records of one
act drift and then nobody can say which is true.

That shapes the table. Revocation stamps revoked_at and revoked_by
instead of deleting, so the record can still answer who had access, from
when to when, and who ended it. The uniqueness constraint is therefore
partial — one live grant per (owner, delegate), with history accumulating
underneath — otherwise a re-invitation would either be refused or would
overwrite the first grant's acceptance. Both foreign keys cascade: a
grant naming a user who no longer exists is an authorization nobody can
resolve and nobody can revoke.

The state machine lives in src/lib/sharing/grants.ts and only there. The
acting-account resolver and the grant surfaces both read these rows, and
if each carried its own idea of "active" the drift would be silent in
exactly one direction: the resolver admitting a grant the panel shows as
ended. Pending confers nothing, expiry is checked against the clock of
the request that reads the row, and every transition is a conditional
update so two concurrent accepts cannot both win.

The access column carries READ or WRITE and only READ is ever written.
It exists from day one so that delegated writes become enforcement work
rather than migration work.

Three lifecycle paths, each proven against the real route:

- Delete All Data removes grants from either side. The wipe loop asks
  every model for "this account's rows", and a table relating two
  accounts has no user_id to answer with, so wipe-plan.ts now declares
  the owner columns and translates. Wiping only the grantor side would
  leave the account still holding access to other people's records.
- Account deletion removes them in both directions.
- An admin restore leaves them exactly as it found them. Grants are
  classified NOT_IN_BACKUP: rolling data back is a decision an admin
  makes, but rolling an authorization back is a decision nobody makes,
  and a grant revoked on Tuesday must not come back alive out of
  Monday's file. After a restore, sharing is off and both people
  re-consent.

The backup classification guard was reading a user relation by field
name, so a model naming its account relations grantor and grantee read
as instance-scoped and was excused from having a verdict at all. It now
reads the type. That surfaced InviteToken, classified here too.
2026-08-02 10:03:04 +02:00

96 lines
4.8 KiB
SQL

-- 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;