diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9d530ebae..0a47f82c4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12432,6 +12432,259 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/account/grants: + get: + tags: + - Account sharing + summary: List grants in both directions + description: Every grant this account has given or received, ended ones included. Refused while acting on another account. + responses: + "200": + description: Grants given and received. + content: + application/json: + schema: + $ref: "#/components/schemas/AccountGrantListEnvelope" + "401": *a1 + "403": &a20 + description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another + account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the + account they named. The latter is byte-identical for an account that does not exist and one that granted + nothing — the refusal is not an enumeration oracle. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + post: + tags: + - Account sharing + summary: Invite an account to read this record + description: Offers read access to somebody already registered on this instance; the grant confers nothing until they + accept it. An identifier that names no account answers 404 — a deliberate disclosure to an authenticated caller + on a household instance, rate-limited to 10 invitations an hour, and the alternative (a silent pending row for a + mistyped username) is worse. Refused while acting on another account, so a delegate can neither invite nor + re-delegate. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + identifier: + type: string + minLength: 1 + maxLength: 255 + expiresAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + required: + - identifier + responses: + "201": + description: The pending grant. + content: + application/json: + schema: + $ref: "#/components/schemas/AccountGrantEnvelope" + "401": *a1 + "403": *a20 + "404": + description: No account on this instance carries that identifier. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: "That account already holds a live grant on this record (`meta.errorCode: sharing.invite.duplicate`). + Re-inviting somebody whose access was revoked succeeds and mints a new row; the old one stays as history." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: "Validation failed, or the invitation named the caller's own account (`meta.errorCode: + sharing.invite.self`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": + description: Invitation rate limit exceeded. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/account/grants/{id}/accept: + post: + tags: + - Account sharing + summary: Accept an invitation + description: "The delegate's own act, and the consent record: it stamps the acceptance time and the accepting IP on the + grant row. One-shot and delegate-only — the invited account comes from the session, so an invitation addressed + to somebody else answers 404 rather than confirming it exists." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The now-active grant. + content: + application/json: + schema: + $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" + "401": *a1 + "403": *a20 + "404": + description: No such invitation for this account. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: Already accepted. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "410": + description: The invitation lapsed or was withdrawn. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/account/grants/{id}: + delete: + tags: + - Account sharing + summary: Withdraw access (owner) + description: "Ends a grant on the caller's own record. No step-up and no confirmation step — reducing access must never + be harder than granting it. Enforcement is the delegate's next request, not their next login, and the same + transaction puts every browser session of theirs that was inside the record back into their own account. Nothing + is deleted: the row survives with `revokedAt` and `revokedBy: GRANTOR`." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The ended grant, and what the session cleanup did. + content: + application/json: + schema: + $ref: "#/components/schemas/RevokedAccountGrantEnvelope" + "401": *a1 + "403": *a20 + "404": + description: No such grant on this account's record. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: That access had already ended. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/account/grants/{id}/renounce: + post: + tags: + - Account sharing + summary: Hand access back (delegate) + description: "The same transition as the owner's revoke, attributed the other way (`revokedBy: GRANTEE`) so the record + can tell a withdrawal from a handover. Same session cleanup. Refused while acting on the record being renounced + — switch back first (one request), which keeps grant management non-delegable without exceptions." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The ended grant, and what the session cleanup did. + content: + application/json: + schema: + $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" + "401": *a1 + "403": *a20 + "404": + description: No such grant held by this account. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: That access had already ended. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/account/switch: + post: + tags: + - Account sharing + summary: Act on a granted record, or return to your own + description: "Cookie transport only; a Bearer caller gets 400 (`meta.errorCode: sharing.switch.wrong_transport`) and + should send the `X-HealthLog-Account` header on the requests it wants scoped instead. The grant is validated + here so the client gets an honest refusal immediately, but the stamp authorises nothing on its own — every + following request re-checks the grant. `accountId: null` clears the switch and always works, including from + inside a switched session: this endpoint is the way out." + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + accountId: + anyOf: + - type: string + minLength: 1 + maxLength: 64 + - type: "null" + required: + - accountId + responses: + "200": + description: The account this session is now acting on, or null. + content: + application/json: + schema: + $ref: "#/components/schemas/AccountSwitchEnvelope" + "400": + description: Sent over the Bearer transport, which carries its selector per request instead. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "401": *a1 + "403": *a20 + "422": + description: Validation failed. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": *a4 components: schemas: EncryptedExportRequest: @@ -36305,6 +36558,302 @@ components: - data - error additionalProperties: false + AccountGrantListEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/AccountGrantList" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + AccountGrantList: + type: object + properties: + given: + type: array + items: + $ref: "#/components/schemas/AccountGrant" + received: + type: array + items: + $ref: "#/components/schemas/AccountGrant" + required: + - given + - received + additionalProperties: false + description: "Both directions at once: `given` are the grants this account has offered on its own record, `received` are + the ones offered to it. Capped at 100 rows per direction, newest first." + AccountGrant: + type: object + properties: + id: + type: string + account: + $ref: "#/components/schemas/AccountGrantParty" + access: + type: string + enum: + - READ + - WRITE + state: + $ref: "#/components/schemas/AccountGrantState" + invitedAt: + type: string + acceptedAt: + anyOf: + - type: string + - type: "null" + expiresAt: + anyOf: + - type: string + - type: "null" + lastUsedAt: + anyOf: + - type: string + - type: "null" + revokedAt: + anyOf: + - type: string + - type: "null" + revokedBy: + anyOf: + - type: string + enum: + - GRANTOR + - GRANTEE + - type: "null" + required: + - id + - account + - access + - state + - invitedAt + - acceptedAt + - expiresAt + - lastUsedAt + - revokedAt + - revokedBy + additionalProperties: false + description: "One grant, from either side. The row is also the consent record — who offered the access, when it was + accepted, when and by whom it ended — so ended grants stay in the list with `state: REVOKED` rather than + disappearing. `revokedBy` distinguishes the owner withdrawing access from the delegate handing it back. + `lastUsedAt` mirrors API-token semantics: the last time the grant was actually exercised." + AccountGrantParty: + type: object + properties: + id: + type: string + username: + type: string + displayName: + anyOf: + - type: string + - type: "null" + required: + - id + - username + - displayName + additionalProperties: false + description: "The account on the other end of a grant. E-mail addresses are deliberately not published: a grant is not a + way to collect the other party's contact details." + AccountGrantState: + type: string + enum: + - PENDING + - ACTIVE + - EXPIRED + - REVOKED + description: "What the grant is right now, resolved server-side against the request clock. Only ACTIVE confers anything. + Ordering is stated rather than implied: a revoked grant reads REVOKED even if it also sat past its expiry, and + an unaccepted invitation past its expiry reads EXPIRED rather than PENDING because it can no longer be accepted. + Clients render this value and never re-derive it from the timestamps." + AccountGrantEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/AccountGrant" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + AcceptedAccountGrantEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/AccountGrant" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + RevokedAccountGrantEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/EndedAccountGrant" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + EndedAccountGrant: + type: object + properties: + id: + type: string + account: + $ref: "#/components/schemas/AccountGrantParty" + access: + type: string + enum: + - READ + - WRITE + state: + $ref: "#/components/schemas/AccountGrantState" + invitedAt: + type: string + acceptedAt: + anyOf: + - type: string + - type: "null" + expiresAt: + anyOf: + - type: string + - type: "null" + lastUsedAt: + anyOf: + - type: string + - type: "null" + revokedAt: + anyOf: + - type: string + - type: "null" + revokedBy: + anyOf: + - type: string + enum: + - GRANTOR + - GRANTEE + - type: "null" + sessionsCleared: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: How many of the delegate's browser sessions were sitting inside the record and were put back into their own + account, in the same transaction that ended the grant. + required: + - id + - account + - access + - state + - invitedAt + - acceptedAt + - expiresAt + - lastUsedAt + - revokedAt + - revokedBy + - sessionsCleared + additionalProperties: false + description: "A grant that has just been ended, plus what the cleanup did. Nothing is deleted: the row survives as the + consent record and DELETE names the act, not the storage." + RenouncedAccountGrantEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/EndedAccountGrant" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + AccountSwitchEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/AccountSwitchState" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + AccountSwitchState: + type: object + properties: + actingAs: + anyOf: + - type: object + properties: + accountId: + type: string + username: + type: string + displayName: + anyOf: + - type: string + - type: "null" + access: + type: string + enum: + - READ + - WRITE + required: + - accountId + - username + - displayName + - access + additionalProperties: false + - type: "null" + required: + - actingAs + additionalProperties: false + description: The account this session is now acting on, or null when it is back in its own. The stamp is a selector and + not a permission — the grant is re-checked on every subsequent request, so a revocation lands on the next one + rather than at the next login. MedicationScheduleOutput: type: object properties: diff --git a/prisma/migrations/0294_audit_log_actor/migration.sql b/prisma/migrations/0294_audit_log_actor/migration.sql new file mode 100644 index 000000000..8f95829a9 --- /dev/null +++ b/prisma/migrations/0294_audit_log_actor/migration.sql @@ -0,0 +1,66 @@ +-- v1.36.0 — who acted, when it was not the person the row is filed under. +-- +-- The audit trail has answered one question until now: what happened to this +-- account. Delegation splits it in two — what happened to this account, and +-- who was at the keyboard — and a single `user_id` cannot hold both answers. +-- +-- NULL is the whole design of this column. Every row written before today +-- means "this account acted for itself", and that reading has to survive: a +-- backfill, a default, or a threading rule that helpfully stamps the caller's +-- own id would rewrite the meaning of every historical row at once, silently, +-- with nothing to compare against afterwards. So the column arrives nullable, +-- unbackfilled, and the code that fills it (`src/lib/auth/audit.ts`) fills it +-- only when the request actually resolved an acting account. +-- +-- A delegated action files under the OWNER, with the delegate here. The owner's +-- record was the thing touched, and "what happened to my data" is the owner's +-- question to ask. +-- +-- No foreign key, deliberately, and against the grain of `user_id` right above +-- it. `user_id` is ON DELETE SET NULL because an erased account should drop out +-- of its own history. Applying that rule here would not erase, it would falsify: +-- nulling the actor turns "the delegate opened this record" into "the owner +-- opened this record", which is a sentence the trail would then assert about a +-- day it did not happen. An id that no longer resolves to a user reads as a +-- deleted account and is honest about it. The 365-day retention purge +-- (`src/lib/jobs/audit-log-cleanup.ts`) bounds how long the id lives either way. + +ALTER TABLE "audit_logs" ADD COLUMN "actor_user_id" TEXT; + +-- The owner's "who touched my record" view: their rows, delegated ones only, +-- newest first. Every existing index on this table leads with `user_id` and +-- then `action` or `created_at`, so without this one that read scans an +-- account's entire history — on the table the schema itself calls the +-- highest-churn one — to surface the few rows that carry an actor. +CREATE INDEX "audit_logs_user_id_actor_user_id_created_at_idx" + ON "audit_logs" ("user_id", "actor_user_id", "created_at" DESC); + +-- Delegated reads coalesce to one row per (owner, delegate, day). +-- +-- Without this, a delegate paging through a record writes an audit row per +-- request, on the table that is already the busiest one here, and the owner's +-- activity view becomes a wall of identical lines nobody can read. One row a +-- day answers the question the owner is actually asking — "was she in my record +-- yesterday" — and the counter inside it answers "how much". +-- +-- The uniqueness is what makes the coalescing atomic rather than a read +-- followed by a hopeful write: the writer INSERTs and lets the index send it +-- into the ON CONFLICT arm, so two concurrent delegated reads cannot both +-- decide the day's row is missing. +-- +-- The day is a bare cast because `created_at` on this table is TIMESTAMP +-- WITHOUT TIME ZONE holding UTC — the whole application writes it that way, so +-- `::date` is already the UTC day and needs no conversion. It also has to be +-- bare: an index expression must be IMMUTABLE, and any form that converts a +-- zone at read time (`AT TIME ZONE`, casting through timestamptz) is only +-- STABLE, because its answer depends on a session setting an operator can +-- change. Postgres refuses to index it, which is the right refusal: a day +-- boundary that moves with a container's TZ would split or merge days the +-- trail has already recorded. +-- +-- Partial on the action so it constrains exactly the rows the writer emits and +-- nothing else: `auth.login` rows carry no actor, and two of them on one day +-- for one account are entirely ordinary. +CREATE UNIQUE INDEX "audit_logs_delegated_access_day_key" + ON "audit_logs" ("user_id", "actor_user_id", (("created_at")::date)) + WHERE "action" = 'sharing.record.accessed'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 467dc5386..82928d26c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4483,8 +4483,31 @@ model AppSettings { // ─── Audit Log ───────────────────────────────────────────── model AuditLog { - id String @id @default(cuid()) - userId String? @map("user_id") + id String @id @default(cuid()) + userId String? @map("user_id") + + /// v1.36.0 — who acted, when that is not the person the row is filed under. + /// + /// NULL means the account acted for itself, which is what every row written + /// before this column existed means and what almost every row written after + /// it will mean. The threading in `src/lib/auth/audit.ts` fills it only when + /// the request resolved an acting account, so a self-action can never grow + /// an actor id by accident — if it could, the reading of every historical + /// row would change under us. + /// + /// A delegated action files under the OWNER (`userId`) with the delegate + /// here: it is the owner's record that was touched, and the owner's view of + /// "what happened to my data" has to contain it. + /// + /// Deliberately NOT a foreign key. `userId` above is ON DELETE SET NULL, and + /// the same rule here would rewrite history rather than erase it — nulling + /// the actor turns "your daughter opened your record" into "you opened your + /// record" the moment her account is deleted. An id that no longer resolves + /// to a person renders as a deleted account; an id that silently became NULL + /// renders as a lie. The 365-day retention purge bounds how long either + /// lives. + actorUserId String? @map("actor_user_id") + action String // e.g. "auth.login", "measurement.create", "export.csv" details String? // JSON string, no sensitive data ipAddress String? @map("ip_address") @@ -4512,6 +4535,12 @@ model AuditLog { // scan and could not finish inside statement_timeout on an instance with // enough history. See migration 0276. @@index([createdAt]) + // v1.36.0 — the owner's "who touched my record" view reads + // `{ userId, actorUserId: { not: null } }` newest first. Every index above + // leads with `userId` and then `action` or `createdAt`, so that read would + // have to filter the whole of an account's history to find the handful of + // delegated rows in it. See migration 0294. + @@index([userId, actorUserId, createdAt(sort: Desc)]) @@map("audit_logs") } diff --git a/src/__tests__/acting-account-boundary-guard.test.ts b/src/__tests__/acting-account-boundary-guard.test.ts index 53ca21e44..3d9690e76 100644 --- a/src/__tests__/acting-account-boundary-guard.test.ts +++ b/src/__tests__/acting-account-boundary-guard.test.ts @@ -78,6 +78,11 @@ describe("the acting-account carrier is read in one place", () => { "lib/auth/session.ts", // The resolver. The only thing that acts on the value. "lib/api-handler.ts", + // v1.36.0 — the only thing that WRITES it: the switch endpoint sets it + // through here, ending a grant clears it through here. The routes + // themselves call these two functions and never name the column, which is + // what keeps this list from growing one entry per surface. + "lib/sharing/acting-session.ts", ].sort(); it("no other file reads or writes the carrier column", () => { @@ -91,9 +96,18 @@ describe("the acting-account carrier is read in one place", () => { it("the selector header is named in one place", () => { // A route that read the header itself would be deciding an authorisation // question the resolver exists to answer once. + // + // The OpenAPI table is the one other file allowed to say the name, and it + // is not a reader: it publishes the header to the clients that have to + // send it. Listed explicitly rather than exempted by pattern, so a route + // that starts parsing the header cannot hide behind a rule about which + // directories are documentation. const namers = filesMatching(/x-healthlog-account/i); expect(namers.length).toBeGreaterThan(0); - expect(namers).toEqual(["lib/api-handler.ts"]); + expect(namers).toEqual([ + "lib/api-handler.ts", + "lib/openapi/routes/account-sharing.ts", + ]); }); }); @@ -133,3 +147,106 @@ describe("the cookie-only helpers cannot see the acting account", () => { expect(callers).toBe(2); }); }); + +describe("grant management is not delegable by construction", () => { + /** + * A delegate must never grant, widen, or transfer access. The way that is + * true here is not a re-delegation check inside the handlers — a check is a + * line somebody can move — but the mode the handlers resolve in: bare + * `requireAuth()` refuses outright while a switch is active, so there is no + * code path through these routes that runs as somebody else at all. + * + * The switch endpoint is the deliberate exception and lives elsewhere: it is + * an actor surface because it is the way back out of a switch, and it grants + * nothing. + */ + const GRANT_ROUTES = [ + "app/api/account/grants/route.ts", + "app/api/account/grants/[id]/route.ts", + "app/api/account/grants/[id]/accept/route.ts", + "app/api/account/grants/[id]/renounce/route.ts", + ]; + + it("every grant route exists and resolves through bare requireAuth", () => { + // Non-zero proof: a renamed or moved route must fail here rather than + // leave the loop below with nothing to check. + const found = sourceFiles().filter((p) => GRANT_ROUTES.includes(p)); + expect(found.sort()).toEqual([...GRANT_ROUTES].sort()); + + for (const rel of GRANT_ROUTES) { + const src = read(rel); + expect(src, `${rel} must resolve auth`).toMatch(/await requireAuth\(\)/); + expect(src, `${rel} must not honour a switch`).not.toContain( + "requireRecordAuth", + ); + expect(src, `${rel} must not declare an actor surface`).not.toContain( + "requireActorAuth", + ); + } + }); + + it("no grant route takes a party to the grant from the request body", () => { + // Both ends of a grant come from the session and from the row. A body + // field naming either would be the caller deciding an authorisation + // question, which is the one thing no route here is allowed to do. + for (const rel of GRANT_ROUTES) { + const src = read(rel); + expect(src).not.toMatch(/parsed\.data\.(grantorId|granteeId|userId)/); + } + }); +}); + +describe("ending a grant and clearing the switch are one act", () => { + /** + * Two statements of the same fact. Split apart, a revocation that stamped + * the row but not the sessions leaves a delegate's browser inside a record + * it can no longer read — every request refused, the banner still naming the + * owner, nothing on screen able to explain it. + * + * The behavioural half of this is + * `tests/integration/sharing-lifecycle.test.ts`, which asserts the session + * row after a revoke. This half holds the atomicity, which no test can + * observe from outside. + */ + function privateFunctionBody(src: string, name: string): string { + const start = src.indexOf(`async function ${name}(`); + if (start === -1) return ""; + const open = src.indexOf("{", src.indexOf(")", start)); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === "{") depth++; + else if (src[i] === "}") { + depth -= 1; + if (depth === 0) return src.slice(open, i + 1); + } + } + return ""; + } + + it("runs the transition and the cleanup in one transaction", () => { + const body = privateFunctionBody( + read("lib/sharing/grants.ts"), + "endAndClear", + ); + // Non-zero proof: a renamed helper fails here rather than asserting + // against an empty string, which would pass every `not.toContain` below + // and most of what a reader assumes this test covers. + expect(body.length).toBeGreaterThan(0); + expect(body).toContain("$transaction"); + expect(body).toContain("clearActingSessions"); + }); + + it("routes an ending through the cleanup, never through the bare transition", () => { + for (const rel of [ + "app/api/account/grants/[id]/route.ts", + "app/api/account/grants/[id]/renounce/route.ts", + ]) { + const src = read(rel); + expect(src).toMatch(/(revoke|renounce)GrantAndClearSwitch/); + // The bare transitions leave sessions behind on purpose — they exist so + // they can run inside somebody else's transaction. A route reaching for + // one has skipped the cleanup. + expect(src).not.toMatch(/\b(revokeGrant|renounceGrant)\(/); + } + }); +}); diff --git a/src/__tests__/delegable-surface-guard.test.ts b/src/__tests__/delegable-surface-guard.test.ts index 74e8f7652..526090ab1 100644 --- a/src/__tests__/delegable-surface-guard.test.ts +++ b/src/__tests__/delegable-surface-guard.test.ts @@ -250,21 +250,23 @@ const DELEGABLE_ROUTES: Record = {}; * switch, because they serve the caller's own account and nothing of the * owner's. * - * Also empty today, and for the same reason: the resolver exists, its members - * do not yet. The intended first five — the account bootstrap payload, the - * switch endpoint itself, logout, the native refresh route, and the locale - * read — are the surfaces a switched session needs in order to stay usable and - * to switch back. None of them exists in this shape yet, and naming them here - * before they do would freeze a guess. + * The intended members are the surfaces a switched session needs in order to + * stay usable and to get back out: the account bootstrap payload, the switch + * endpoint, logout, the native refresh route, the locale read. One of them + * exists so far. The rest arrive as their own diffs here; naming them before + * they exist would freeze a guess. */ -const ACTOR_ROUTES: Record = {}; +const ACTOR_ROUTES: Record = { + "app/api/account/switch/route.ts": + "The way back out. Every other route refuses under a switch, so if this one did too a browser could enter a record and never leave it. It reads and writes exactly one row — the caller's own session — renders nothing of the owner's, and grants nothing: the account it stamps is validated against a live grant first, and the stamp is re-checked on every request after.", +}; /** * Entries across both lists. Pinned so that the reason-loop below cannot stay - * a formality by accident: it runs zero times today, and the day it stops - * running zero times somebody has to say so here. + * a formality by accident: every addition has to be counted here as well as + * listed above, which is one more place a careless admission has to pass. */ -const FROZEN_ENTRY_COUNT = 0; +const FROZEN_ENTRY_COUNT = 1; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -502,10 +504,8 @@ describe("every frozen entry carries a reason", () => { expect(reason.trim().length, `${rel} has no reason`).toBeGreaterThan(0); } - // Both lists are empty, so the loop above runs zero times and proves - // nothing today. Said out loud rather than left to be discovered: this - // count is what forces the next person adding an entry to notice that the - // reason check has been dormant. + // The loop above now runs, once. The count stays because it is what makes + // an addition visible twice — in the list and here — rather than once. expect(entries.length).toBe(FROZEN_ENTRY_COUNT); }); }); diff --git a/src/app/api/account/grants/[id]/accept/route.ts b/src/app/api/account/grants/[id]/accept/route.ts new file mode 100644 index 000000000..3aaa872e3 --- /dev/null +++ b/src/app/api/account/grants/[id]/accept/route.ts @@ -0,0 +1,121 @@ +/** + * `POST /api/account/grants/[id]/accept` — the delegate accepts an invitation. + * + * The handshake exists because being handed read access to somebody's health + * record is not something to impose silently: an invitation confers nothing + * until the person named on it says yes, which is why `isGrantActive` fails a + * row with `acceptedAt IS NULL` and why this route is the only thing that can + * set it. + * + * The acceptance is also the consent record. `acceptedAt` and `acceptedIp` are + * stamped here and never anywhere else, on the same row that carries who + * offered the access and when it ended — one row, one history, no second + * receipt table to disagree with it. + * + * Not delegable: bare `requireAuth()` refuses under a switch, so nobody can + * accept an invitation while acting as somebody else. The delegate is the + * session user, never a body field, and `acceptGrant` puts that id in the + * `where` of a conditional update — so an invitation addressed to another + * account matches nothing and is refused as not-yours rather than accepted by + * the wrong person. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { GRANT_PARTY_SELECT, toGrantView } from "@/lib/sharing/grant-view"; +import { acceptGrant, GrantError } from "@/lib/sharing/grants"; + +type RouteParams = { params: Promise<{ id: string }> }; + +/** + * Thirty a minute. Accepting is a one-shot transition per row, so the only + * traffic this bounds is somebody guessing grant ids — which the conditional + * update already refuses; the limit just stops them doing it quickly. + */ +const ACCEPT_LIMIT = 30; +const ACCEPT_WINDOW_MS = 60 * 1000; + +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `sharing:accept:${user.id}`, + ACCEPT_LIMIT, + ACCEPT_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many attempts, try again later", 429); + } + + const { id } = await params; + const ip = getClientIp(request); + + try { + const grant = await acceptGrant({ + grantId: id, + granteeId: user.id, + ip, + }); + + const grantor = await prisma.user.findUniqueOrThrow({ + where: { id: grant.grantorId }, + select: GRANT_PARTY_SELECT, + }); + + // Filed under the delegate, because accepting is the delegate's own act + // and `actorUserId` NULL means exactly that. The owner's side of this + // fact is not an audit row at all — it is `acceptedAt` on the grant, + // which their panel reads. + await auditLog("sharing.grant.accepted", { + userId: user.id, + details: { grantId: grant.id, grantorId: grant.grantorId }, + ipAddress: ip, + }).catch(() => {}); + + annotate({ + action: { name: "sharing.grant.accepted" }, + meta: { grant_id: grant.id }, + }); + + return apiSuccess(toGrantView(grant, grantor)); + } catch (err) { + if (err instanceof GrantError) return acceptErrorResponse(err); + throw err; + } + }, +); + +/** + * Why the acceptance was refused. + * + * `not_found` and `not_grantee` deliberately answer the same 404: an + * invitation addressed to somebody else is, as far as this caller is + * concerned, not there. Confirming it exists would tell a stranger that a + * particular grant id is real and that two accounts share a record. + */ +function acceptErrorResponse(err: GrantError): Response { + switch (err.code) { + case "expired": + return apiError("That invitation has lapsed", 410, { + errorCode: "sharing.accept.expired", + }); + case "already_revoked": + return apiError("That invitation was withdrawn", 410, { + errorCode: "sharing.accept.revoked", + }); + case "not_pending": + return apiError("That invitation was already accepted", 409, { + errorCode: "sharing.accept.not_pending", + }); + default: + return apiError("Invitation not found", 404, { + errorCode: "sharing.accept.not_found", + }); + } +} diff --git a/src/app/api/account/grants/[id]/renounce/route.ts b/src/app/api/account/grants/[id]/renounce/route.ts new file mode 100644 index 000000000..5ab130a1d --- /dev/null +++ b/src/app/api/account/grants/[id]/renounce/route.ts @@ -0,0 +1,93 @@ +/** + * `POST /api/account/grants/[id]/renounce` — the delegate hands the access back. + * + * The same transition as the owner's revoke, attributed to the other party: + * `revokedBy = GRANTEE`, so the record can tell "she withdrew it" from "he + * decided he no longer wanted it". Two facts, one column, because they are + * answers to the same question and a trail that conflated them would misread + * an ordinary handover as a fallout. + * + * Its own verb rather than a second meaning for DELETE. The owner's revoke and + * the delegate's renunciation look identical in the database and are entirely + * different acts; letting one endpoint mean both would put a `revokedBy` + * decision inside a caller-identity check, which is exactly the sort of branch + * that gets simplified later by somebody who does not know why it was there. + * + * Frictionless for the same reason as revocation, and cleaned up in the same + * transaction: every session of theirs sitting inside that record leaves with + * the grant, including the ones on other devices. + * + * Not delegable: bare `requireAuth()`, so this cannot be done while acting as + * somebody else, and the delegate is the session user rather than a body field. + * That costs a browser already inside the record one extra request — switch + * out, then renounce — and the exchange is worth it. Grant management is the + * one surface where a delegate acting as somebody else must have no reach at + * all, and "except this one endpoint, which only ever reduces" is precisely + * the kind of exception that gets widened by somebody who reads the exception + * and not the reason. Ending the access is still one click; the client makes + * the switch-back call on the way. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { GRANT_PARTY_SELECT, toGrantView } from "@/lib/sharing/grant-view"; +import { GrantError, renounceGrantAndClearSwitch } from "@/lib/sharing/grants"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + try { + const { grant, sessionsCleared } = await renounceGrantAndClearSwitch({ + grantId: id, + granteeId: user.id, + }); + + const grantor = await prisma.user.findUniqueOrThrow({ + where: { id: grant.grantorId }, + select: GRANT_PARTY_SELECT, + }); + + await auditLog("sharing.grant.renounced", { + userId: user.id, + details: { + grantId: grant.id, + grantorId: grant.grantorId, + sessionsCleared, + }, + ipAddress: getClientIp(request), + }).catch(() => {}); + + annotate({ + action: { name: "sharing.grant.renounced" }, + meta: { grant_id: grant.id, sessions_cleared: sessionsCleared }, + }); + + return apiSuccess({ + ...toGrantView(grant, grantor), + sessionsCleared, + }); + } catch (err) { + if (err instanceof GrantError) return renounceErrorResponse(err); + throw err; + } + }, +); + +function renounceErrorResponse(err: GrantError): Response { + if (err.code === "already_revoked") { + return apiError("That access has already ended", 409, { + errorCode: "sharing.renounce.already_ended", + }); + } + return apiError("Grant not found", 404, { + errorCode: "sharing.renounce.not_found", + }); +} diff --git a/src/app/api/account/grants/[id]/route.ts b/src/app/api/account/grants/[id]/route.ts new file mode 100644 index 000000000..0fceca8b0 --- /dev/null +++ b/src/app/api/account/grants/[id]/route.ts @@ -0,0 +1,97 @@ +/** + * `DELETE /api/account/grants/[id]` — the owner withdraws access. + * + * **No step-up, no typed confirmation, no second factor, no ceremony.** That + * is a decision, not an omission: reducing access must never be harder than + * granting it was. Everything else in this product that guards a destructive + * act guards it because the act is hard to undo — this one is the undo. A + * person who wants their health record closed to somebody gets it closed on + * the first click, and if they change their mind the invitation costs one + * request to send again. + * + * Enforcement is the delegate's next request, not their next login: the + * resolver re-reads the grant every time, so seconds, not sessions. What this + * route adds on top is that the delegate's browser leaves too — the same + * transaction clears the acting-account carrier on every session of theirs + * pointing at this record, so a delegate sitting inside it lands back in their + * own account instead of on a wall of 403s under a banner still naming the + * owner. + * + * Despite the verb, nothing is deleted. `revokeGrant` stamps `revokedAt` and + * `revokedBy` on a row that stays, because "who had access, from when to when, + * and who ended it" is a question a deleted row cannot answer. DELETE is the + * right HTTP verb for "end this thing"; it is not a promise about storage. + * + * Not delegable: bare `requireAuth()` refuses under a switch, and the owner is + * the session user, so a delegate can neither revoke somebody else's grant nor + * hand their own to a third party. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { GRANT_PARTY_SELECT, toGrantView } from "@/lib/sharing/grant-view"; +import { GrantError, revokeGrantAndClearSwitch } from "@/lib/sharing/grants"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + try { + const { grant, sessionsCleared } = await revokeGrantAndClearSwitch({ + grantId: id, + grantorId: user.id, + }); + + const grantee = await prisma.user.findUniqueOrThrow({ + where: { id: grant.granteeId }, + select: GRANT_PARTY_SELECT, + }); + + await auditLog("sharing.grant.revoked", { + userId: user.id, + details: { + grantId: grant.id, + granteeId: grant.granteeId, + sessionsCleared, + }, + ipAddress: getClientIp(request), + }).catch(() => {}); + + annotate({ + action: { name: "sharing.grant.revoked" }, + meta: { grant_id: grant.id, sessions_cleared: sessionsCleared }, + }); + + return apiSuccess({ + ...toGrantView(grant, grantee), + sessionsCleared, + }); + } catch (err) { + if (err instanceof GrantError) return revokeErrorResponse(err); + throw err; + } + }, +); + +/** + * A grant that is not this caller's to end reads as absent, for the same + * reason it does on the accept path: a distinguishable refusal would confirm + * that a given grant id exists. + */ +function revokeErrorResponse(err: GrantError): Response { + if (err.code === "already_revoked") { + return apiError("That access has already ended", 409, { + errorCode: "sharing.revoke.already_ended", + }); + } + return apiError("Grant not found", 404, { + errorCode: "sharing.revoke.not_found", + }); +} diff --git a/src/app/api/account/grants/route.ts b/src/app/api/account/grants/route.ts new file mode 100644 index 000000000..8d5c785a8 --- /dev/null +++ b/src/app/api/account/grants/route.ts @@ -0,0 +1,184 @@ +/** + * `/api/account/grants` — the owner's side of account sharing. + * + * `GET` lists both directions at once: the grants this account has given, and + * the grants it has received. One endpoint rather than two because they are + * one question — "who can see my record, and whose can I see" — and because a + * client that has to call twice will eventually render half an answer. + * + * `POST` offers a grant to somebody already registered on this instance. + * + * **Neither is delegable, and that is structural.** Both resolve through bare + * `requireAuth()`, which refuses outright while the caller is acting on + * another account. So a delegate cannot invite anybody into the record they + * were given access to, cannot widen their own grant, and cannot pass it on: + * the route does not have a re-delegation check that somebody could soften, it + * has no code path that runs under a switch at all. The grantor is then taken + * from the session on top of that — never from the body — so even if the mode + * changed, the grant it wrote would be the caller's own record. + * + * On the enumeration question, stated plainly rather than left implicit: an + * invitation to an unknown identifier is refused with "no such account", which + * tells the inviter whether that person exists here. That is a disclosure, it + * is deliberate, and the alternative is worse — an invite that silently goes + * nowhere for a mistyped username leaves the owner staring at a pending row + * that will never be accepted. The instance is a household, the caller is + * already authenticated, and the rate limit below bounds how fast the surface + * can be walked. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { GRANT_PARTY_SELECT, toGrantView } from "@/lib/sharing/grant-view"; +import { GrantError, inviteGrant } from "@/lib/sharing/grants"; +import { inviteGrantSchema } from "@/lib/validations/account-sharing"; + +/** + * Ten invitations an hour, per account. + * + * Generous for the household case (nobody invites their third family member + * twice a minute) and tight enough that the account-existence disclosure above + * cannot be turned into a user-directory dump. + */ +const INVITE_LIMIT = 10; +const INVITE_WINDOW_MS = 60 * 60 * 1000; + +/** How much history a list call returns per direction. */ +const MAX_GRANTS_PER_DIRECTION = 100; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "sharing.grants.list" } }); + + const [given, received] = await Promise.all([ + prisma.accountGrant.findMany({ + where: { grantorId: user.id }, + orderBy: { createdAt: "desc" }, + take: MAX_GRANTS_PER_DIRECTION, + include: { grantee: { select: GRANT_PARTY_SELECT } }, + }), + prisma.accountGrant.findMany({ + where: { granteeId: user.id }, + orderBy: { createdAt: "desc" }, + take: MAX_GRANTS_PER_DIRECTION, + include: { grantor: { select: GRANT_PARTY_SELECT } }, + }), + ]); + + // Revoked and expired rows come back too. They are the consent record — "who + // had access, from when to when, and who ended it" is the question the table + // exists to answer, and a list that quietly dropped the ended rows would + // leave the panel unable to answer it. `state` says which are live. + const now = new Date(); + return apiSuccess({ + given: given.map((g) => toGrantView(g, g.grantee, now)), + received: received.map((g) => toGrantView(g, g.grantor, now)), + }); +}); + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `sharing:invite:${user.id}`, + INVITE_LIMIT, + INVITE_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many invitations, try again later", 429); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 8 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = inviteGrantSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "sharing.invite.invalid", + }); + } + + const { identifier, expiresAt } = parsed.data; + + // Either column, case-insensitively — the same lookup the login form does, + // so the name somebody signs in with is the name they can be invited by. + const invitee = await prisma.user.findFirst({ + where: { + OR: [ + { email: { equals: identifier, mode: "insensitive" } }, + { username: { equals: identifier, mode: "insensitive" } }, + ], + }, + select: GRANT_PARTY_SELECT, + }); + if (!invitee) { + return apiError("No account with that name or e-mail", 404); + } + + try { + // `inviteGrant` decides everything about the row: READ and only READ, the + // self-grant refusal, and the one-live-grant-per-pair rule the partial + // unique index backs. None of it is re-decided here. + const grant = await inviteGrant({ + grantorId: user.id, + granteeId: invitee.id, + expiresAt: expiresAt ? new Date(expiresAt) : null, + }); + + await auditLog("sharing.grant.invited", { + userId: user.id, + details: { grantId: grant.id, granteeId: invitee.id }, + ipAddress: getClientIp(request), + }).catch(() => {}); + + annotate({ + action: { name: "sharing.grant.invited" }, + meta: { + grant_id: grant.id, + expires: expiresAt !== null && expiresAt !== undefined, + }, + }); + + return apiSuccess(toGrantView(grant, invitee), 201); + } catch (err) { + if (err instanceof GrantError) return grantErrorResponse(err); + throw err; + } +}); + +/** + * A refused transition, as an HTTP answer. + * + * The mapping lives here rather than inside the domain module because status + * codes are a transport concern and the state machine is not — it hands back a + * stable code and lets each consumer say what that means on its own wire. + */ +function grantErrorResponse(err: GrantError): Response { + switch (err.code) { + case "self_grant": + return apiError("An account cannot be shared with itself", 422, { + errorCode: "sharing.invite.self", + }); + case "duplicate_live_grant": + return apiError("That account already has access", 409, { + errorCode: "sharing.invite.duplicate", + }); + default: + return apiError("Invitation refused", 422, { + errorCode: "sharing.invite.refused", + }); + } +} diff --git a/src/app/api/account/switch/route.ts b/src/app/api/account/switch/route.ts new file mode 100644 index 000000000..bcd721fca --- /dev/null +++ b/src/app/api/account/switch/route.ts @@ -0,0 +1,129 @@ +/** + * `POST /api/account/switch` — point this browser at a record, or back at its own. + * + * Cookie transport only. The Bearer carrier is a per-request header, and that + * difference is not an inconsistency to be smoothed over: stamping a switch on + * a long-lived token would make the credential itself ambient authority, and + * the token has to keep meaning "this person" for as long as it exists. A + * Bearer caller reaching this endpoint has misunderstood its own transport, so + * it is refused rather than quietly ignored. + * + * An actor surface — `requireActorAuth()` — and it has to be, because this is + * the way back. Under the fail-closed default every route refuses while a + * switch is on; if this one did too, a switched browser could enter a record + * and never leave it, which turns a read-only convenience into a trap. So the + * mode is declared, and the declaration is narrow: the only row this handler + * touches is the caller's own session. + * + * What it writes is a SELECTOR, not a permission. The grant is validated here + * before the stamp so a client gets an immediate, honest refusal instead of a + * switch that appears to work and 403s on the next page — but the stamp itself + * authorises nothing, and the resolver re-checks the grant on every request + * that follows. A value stranded here by a revocation seconds later confers + * exactly nothing. + */ +import { NextRequest } from "next/server"; + +import { + apiHandler, + requireActorAuth, + SharingAccessDeniedError, +} from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { pointSessionAt } from "@/lib/sharing/acting-session"; +import { findActiveGrant } from "@/lib/sharing/grants"; +import { switchAccountSchema } from "@/lib/validations/account-sharing"; + +export const POST = apiHandler(async (request: NextRequest) => { + const auth = await requireActorAuth(); + + if (auth.authMethod !== "cookie") { + return apiError( + "Account switching is a browser-session feature; send the account selector header instead", + 400, + { errorCode: "sharing.switch.wrong_transport" }, + ); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = switchAccountSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "sharing.switch.invalid", + }); + } + + const { accountId } = parsed.data; + const ip = getClientIp(request); + + if (accountId === null) { + await pointSessionAt(auth.session.id, auth.user.id, null); + await auditLog("sharing.switch.off", { + userId: auth.user.id, + details: {}, + ipAddress: ip, + }).catch(() => {}); + annotate({ action: { name: "sharing.switch.off" } }); + return apiSuccess({ actingAs: null }); + } + + // The same predicate the resolver uses, on the same table, with no second + // idea of "active" anywhere in this file. If this route decided for itself, + // it would drift from the resolver in exactly one direction: a switch the + // panel accepts and the next request refuses, or worse, the reverse. + const grant = await findActiveGrant({ + grantorId: accountId, + granteeId: auth.user.id, + }); + if (!grant) { + // The flat refusal, with no reason on the wire. An account that does not + // exist and an account that granted nothing are the same empty row from + // the same query, so a caller learns nothing about who has an account here. + await auditLog("sharing.access.denied", { + userId: auth.user.id, + details: { reason: "switch_no_active_grant", target: accountId }, + ipAddress: ip, + }).catch(() => {}); + throw new SharingAccessDeniedError(); + } + + const owner = await prisma.user.findUniqueOrThrow({ + where: { id: grant.grantorId }, + select: { id: true, username: true, displayName: true }, + }); + + await pointSessionAt(auth.session.id, auth.user.id, owner.id); + + await auditLog("sharing.switch.on", { + userId: auth.user.id, + details: { grantId: grant.id, grantorId: owner.id }, + ipAddress: ip, + }).catch(() => {}); + + annotate({ + action: { name: "sharing.switch.on" }, + meta: { grant_id: grant.id }, + }); + + return apiSuccess({ + actingAs: { + accountId: owner.id, + username: owner.username, + displayName: owner.displayName, + access: grant.access, + }, + }); +}); diff --git a/src/lib/__tests__/acting-account-resolver.test.ts b/src/lib/__tests__/acting-account-resolver.test.ts index fe8e624dc..0bb2e006d 100644 --- a/src/lib/__tests__/acting-account-resolver.test.ts +++ b/src/lib/__tests__/acting-account-resolver.test.ts @@ -86,6 +86,10 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "hash") })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), + // v1.36.0 — the resolver also writes the owner's day-coalesced access row. + // Stubbed here because what it writes is a question about rows, answered in + // `tests/integration/sharing-audit-actor.test.ts` against a real database. + recordDelegatedAccess: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index a1a1ad5c5..e4899cbcb 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -7,7 +7,7 @@ import { annotate, eventStorage, getEvent } from "./logging/context"; import { emitIfSampled } from "./logging/transports"; import { redactOptional, redactSecrets } from "./logging/redact"; import { getSession } from "./auth/session"; -import { auditLog } from "./auth/audit"; +import { auditLog, recordDelegatedAccess } from "./auth/audit"; import { resolveBearerToken, BearerAuthError, @@ -879,6 +879,12 @@ export async function requireRecordAuth( // Fire-and-forget, the `ApiToken.lastUsedAt` posture: the read must not wait // on the bookkeeping, and the bookkeeping failing must not fail the read. void touchGrantUsage(grant.id); + // The owner's copy of the same fact, and the one they can read: a day- + // coalesced row on their own record saying who opened it. It lands here + // rather than in the handlers because a route cannot forget what it never + // had to remember — every delegated request in the product passes through + // this line, and only requests that got past the grant check reach it. + void recordDelegatedAccess(owner.id, auth.user.id); return { session: auth.session, diff --git a/src/lib/auth/__tests__/audit.test.ts b/src/lib/auth/__tests__/audit.test.ts index 262316f15..ae4a8b4fe 100644 --- a/src/lib/auth/__tests__/audit.test.ts +++ b/src/lib/auth/__tests__/audit.test.ts @@ -66,6 +66,9 @@ describe("auditLog", () => { userId: "user-7", details: JSON.stringify({ reason: "passkey", browser: "firefox" }), ipAddress: "203.0.113.5", + // v1.36.0 — no acting account on this request, so the row says the + // account acted for itself. NULL is the statement, not a gap. + actorUserId: null, }); // Location is intentionally not set on insert — it's filled later by the // geo lookup. The schema column itself is not in the create payload. @@ -81,6 +84,7 @@ describe("auditLog", () => { userId: null, details: null, ipAddress: null, + actorUserId: null, }); // No IP → no geo lookup enqueued. expect(lookupIpGeo).not.toHaveBeenCalled(); diff --git a/src/lib/auth/audit.ts b/src/lib/auth/audit.ts index d25710273..9263147c4 100644 --- a/src/lib/auth/audit.ts +++ b/src/lib/auth/audit.ts @@ -1,5 +1,49 @@ import { prisma } from "@/lib/db"; import { lookupIpGeo } from "@/lib/geo"; +import { getEvent } from "@/lib/logging/context"; + +/** + * v1.36.0 — the action a delegated request records against the record it read. + * + * One row per (owner, delegate, day), enforced by the partial unique index in + * migration 0294. The name is part of the contract: the index is partial on + * exactly this string, so changing it here without changing it there turns the + * coalescing off and nothing fails. + */ +export const DELEGATED_ACCESS_ACTION = "sharing.record.accessed"; + +/** + * Who is acting, when the row is not theirs. + * + * Read from the wide-event context rather than from an argument, because an + * argument is something ~600 audit call sites can forget and this one cannot + * be forgotten safely: a delegated action recorded with no actor is + * indistinguishable from the owner having done it themselves. + * + * Returns null — "acted for themselves" — unless all three hold: + * + * * the request resolved an acting account (`acting_as` is stamped, which + * only `requireRecordAuth` does and only after a live grant); + * * the actor is not that account (a switch into one's own record is not a + * delegation and has nothing to attribute); + * * the row is being filed under the account being ACTED ON. A delegable + * route that deliberately writes a row about the delegate — under the + * delegate's own id — is describing the delegate's own act, and stamping + * the actor there would say "somebody else did this to you" about a person + * acting on themselves. + * + * Every other path — every self-action, every refusal, every background job — + * falls through to null, which is what every row written before this column + * existed already means. + */ +function actingActorFor(filedUnder: string | null): string | null { + const auth = getEvent()?.getAuth(); + const owner = auth?.acting_as ?? null; + const actor = auth?.user_id ?? null; + if (owner === null || actor === null) return null; + if (owner === actor) return null; + return filedUnder === owner ? actor : null; +} export async function auditLog( action: string, @@ -9,10 +53,12 @@ export async function auditLog( ipAddress?: string | null; } = {}, ) { + const userId = opts.userId ?? null; const entry = await prisma.auditLog.create({ data: { action, - userId: opts.userId ?? null, + userId, + actorUserId: actingActorFor(userId), details: opts.details ? JSON.stringify(opts.details) : null, ipAddress: opts.ipAddress ?? null, }, @@ -53,3 +99,62 @@ export async function auditLog( }); } } + +/** + * v1.36.0 — record that a delegate opened an owner's record today. + * + * One row per (owner, delegate, day), with a counter inside it. The alternative + * — a row per delegated request — would put a new hot stripe on the table this + * schema already calls the highest-churn one, and would hand the owner an + * activity view made of thousands of identical lines, which is a way of + * answering nothing. "She was in my record on Tuesday, 40 times" is the answer + * somebody actually wants. + * + * The coalescing is an INSERT that expects to lose. Two delegated reads racing + * inside the same second both try to create the day's row and the unique index + * sends the loser into the ON CONFLICT arm; a read-then-write would let both + * decide the row was missing and leave a duplicate that the index would then + * refuse anyway. `created_at` is written as an explicit UTC instant because + * the column is TIMESTAMP WITHOUT TIME ZONE and a bare `NOW()` would be + * converted through whatever timezone the connection happens to carry — the + * day key in the index is a plain cast of this column, so a row landing an + * hour off would land on the wrong day of the owner's trail. + * + * The action string appears twice in the statement below — once as the row's + * value, once as a literal in the conflict predicate, because Postgres has to + * prove the predicate matches the partial index and cannot do that through a + * bind parameter. They must agree, and the integration test that asserts a + * second read produces no second row is what fails when they stop agreeing. + * + * Fire-and-forget by the `ApiToken.lastUsedAt` posture: a delegated read must + * not wait on its own bookkeeping, and bookkeeping that fails must not turn a + * successful read into a 500. The promise is returned so a test can await it. + */ +export async function recordDelegatedAccess( + ownerId: string, + actorId: string, +): Promise { + try { + await prisma.$executeRaw` + INSERT INTO audit_logs (id, user_id, actor_user_id, action, details, created_at) + VALUES ( + gen_random_uuid()::text, + ${ownerId}, + ${actorId}, + ${DELEGATED_ACCESS_ACTION}, + '{"accesses":1}', + (NOW() AT TIME ZONE 'UTC') + ) + ON CONFLICT ("user_id", "actor_user_id", (("created_at")::date)) + WHERE "action" = 'sharing.record.accessed' + DO UPDATE SET details = jsonb_set( + COALESCE(audit_logs.details::jsonb, '{}'::jsonb), + '{accesses}', + to_jsonb(COALESCE((audit_logs.details::jsonb ->> 'accesses')::int, 0) + 1) + )::text + `; + } catch { + // Bookkeeping. The read it describes has already succeeded, and a failure + // to describe it must not undo that. + } +} diff --git a/src/lib/logging/event-builder.ts b/src/lib/logging/event-builder.ts index 8852ee469..63b49d483 100644 --- a/src/lib/logging/event-builder.ts +++ b/src/lib/logging/event-builder.ts @@ -223,6 +223,21 @@ export class WideEventBuilder { return this.event.http?.method; } + /** + * Who authenticated, and whose record they are acting on. + * + * v1.36.0 — read by `auditLog()`, which needs both halves and must not be + * handed either one by a call site. The event is already the one place that + * knows them: `setAuth` stamps the actor at authentication, `setActingAs` + * stamps the owner when the resolver substitutes one, and both run before any + * handler body can write an audit row. A copy of this fact threaded through + * function arguments would be a second answer to "who is doing this", and the + * routes that forgot to pass it would be the ones nobody reviewed. + */ + getAuth(): WideEvent["auth"] { + return this.event.auth; + } + getRequestId(): string { return this.event.request_id!; } diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts new file mode 100644 index 000000000..61e705fde --- /dev/null +++ b/src/lib/openapi/routes/account-sharing.ts @@ -0,0 +1,330 @@ +/** + * OpenAPI route table — account sharing (v1.36.0). + * + * The lifecycle of one account's standing permission to read another's record: + * the owner invites, the delegate accepts, either side ends it, and a browser + * points itself at a record it has been granted. + * + * Two contract facts a client has to know and cannot derive: + * + * * `POST /api/account/switch` is a browser-session feature. The Bearer + * transport carries its account selector in the `X-HealthLog-Account` + * header, per request, and gets a 400 from this endpoint — a token must + * never accumulate switch state, because the credential has to keep + * meaning "this person" for as long as it exists. + * * Every route here refuses while the caller is acting on another account + * (403, `meta.errorCode: "sharing.not_permitted"`), with the single + * exception of the switch endpoint, which is the way back out. A delegate + * cannot invite, widen, transfer, or end anybody's access from inside a + * record they were given. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + */ +import type { ZodOpenApiObject } from "zod-openapi"; +import { z } from "zod/v4"; + +import { + inviteGrantSchema, + switchAccountSchema, +} from "@/lib/validations/account-sharing"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; + +inviteGrantSchema.meta({ + id: "AccountGrantInvite", + description: + "Offer read access to another account on this instance. `identifier` is the invitee's username or e-mail, matched case-insensitively — the same identifier they sign in with. `expiresAt` is optional; omitted or null means the grant runs until somebody ends it. The access level is not a parameter: v1 grants READ only.", +}); + +switchAccountSchema.meta({ + id: "AccountSwitchRequest", + description: + "Point this browser session at an account, or (with `accountId: null`) back at its own. Cookie transport only.", +}); + +const grantParty = z + .object({ + id: z.string(), + username: z.string(), + displayName: z.string().nullable(), + }) + .meta({ + id: "AccountGrantParty", + description: + "The account on the other end of a grant. E-mail addresses are deliberately not published: a grant is not a way to collect the other party's contact details.", + }); + +const grantState = z.enum(["PENDING", "ACTIVE", "EXPIRED", "REVOKED"]).meta({ + id: "AccountGrantState", + description: + "What the grant is right now, resolved server-side against the request clock. Only ACTIVE confers anything. Ordering is stated rather than implied: a revoked grant reads REVOKED even if it also sat past its expiry, and an unaccepted invitation past its expiry reads EXPIRED rather than PENDING because it can no longer be accepted. Clients render this value and never re-derive it from the timestamps.", +}); + +const grantView = z + .object({ + id: z.string(), + account: grantParty, + access: z.enum(["READ", "WRITE"]), + state: grantState, + invitedAt: z.string(), + acceptedAt: z.string().nullable(), + expiresAt: z.string().nullable(), + lastUsedAt: z.string().nullable(), + revokedAt: z.string().nullable(), + revokedBy: z.enum(["GRANTOR", "GRANTEE"]).nullable(), + }) + .meta({ + id: "AccountGrant", + description: + "One grant, from either side. The row is also the consent record — who offered the access, when it was accepted, when and by whom it ended — so ended grants stay in the list with `state: REVOKED` rather than disappearing. `revokedBy` distinguishes the owner withdrawing access from the delegate handing it back. `lastUsedAt` mirrors API-token semantics: the last time the grant was actually exercised.", + }); + +const endedGrantView = grantView + .extend({ + sessionsCleared: z + .number() + .int() + .describe( + "How many of the delegate's browser sessions were sitting inside the record and were put back into their own account, in the same transaction that ended the grant.", + ), + }) + .meta({ + id: "EndedAccountGrant", + description: + "A grant that has just been ended, plus what the cleanup did. Nothing is deleted: the row survives as the consent record and DELETE names the act, not the storage.", + }); + +const grantListResponse = z + .object({ + given: z.array(grantView), + received: z.array(grantView), + }) + .meta({ + id: "AccountGrantList", + description: + "Both directions at once: `given` are the grants this account has offered on its own record, `received` are the ones offered to it. Capped at 100 rows per direction, newest first.", + }); + +const switchResponse = z + .object({ + actingAs: z + .object({ + accountId: z.string(), + username: z.string(), + displayName: z.string().nullable(), + access: z.enum(["READ", "WRITE"]), + }) + .nullable(), + }) + .meta({ + id: "AccountSwitchState", + description: + "The account this session is now acting on, or null when it is back in its own. The stamp is a selector and not a permission — the grant is re-checked on every subsequent request, so a revocation lands on the next one rather than at the next login.", + }); + +const sharingRefusal = { + description: + "Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted nothing — the refusal is not an enumeration oracle.", + content: { "application/json": { schema: errorEnvelope } }, +}; + +export const accountSharingPaths: NonNullable = { + "/api/account/grants": { + get: { + tags: ["Account sharing"], + summary: "List grants in both directions", + description: + "Every grant this account has given or received, ended ones included. Refused while acting on another account.", + responses: { + ...stdResponses, + "200": { + description: "Grants given and received.", + content: { + "application/json": { + schema: dataEnvelope( + grantListResponse, + "AccountGrantListEnvelope", + ), + }, + }, + }, + "403": sharingRefusal, + }, + }, + post: { + tags: ["Account sharing"], + summary: "Invite an account to read this record", + description: + "Offers read access to somebody already registered on this instance; the grant confers nothing until they accept it. An identifier that names no account answers 404 — a deliberate disclosure to an authenticated caller on a household instance, rate-limited to 10 invitations an hour, and the alternative (a silent pending row for a mistyped username) is worse. Refused while acting on another account, so a delegate can neither invite nor re-delegate.", + requestBody: { + required: true, + content: { "application/json": { schema: inviteGrantSchema } }, + }, + responses: { + ...stdResponses, + "201": { + description: "The pending grant.", + content: { + "application/json": { + schema: dataEnvelope(grantView, "AccountGrantEnvelope"), + }, + }, + }, + "403": sharingRefusal, + "404": { + description: "No account on this instance carries that identifier.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: + "That account already holds a live grant on this record (`meta.errorCode: sharing.invite.duplicate`). Re-inviting somebody whose access was revoked succeeds and mints a new row; the old one stays as history.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "422": { + description: + "Validation failed, or the invitation named the caller's own account (`meta.errorCode: sharing.invite.self`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: "Invitation rate limit exceeded.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/account/grants/{id}/accept": { + post: { + tags: ["Account sharing"], + summary: "Accept an invitation", + description: + "The delegate's own act, and the consent record: it stamps the acceptance time and the accepting IP on the grant row. One-shot and delegate-only — the invited account comes from the session, so an invitation addressed to somebody else answers 404 rather than confirming it exists.", + requestParams: { + path: z.object({ id: z.string() }), + }, + responses: { + ...stdResponses, + "200": { + description: "The now-active grant.", + content: { + "application/json": { + schema: dataEnvelope(grantView, "AcceptedAccountGrantEnvelope"), + }, + }, + }, + "403": sharingRefusal, + "404": { + description: "No such invitation for this account.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: "Already accepted.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "410": { + description: "The invitation lapsed or was withdrawn.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/account/grants/{id}": { + delete: { + tags: ["Account sharing"], + summary: "Withdraw access (owner)", + description: + "Ends a grant on the caller's own record. No step-up and no confirmation step — reducing access must never be harder than granting it. Enforcement is the delegate's next request, not their next login, and the same transaction puts every browser session of theirs that was inside the record back into their own account. Nothing is deleted: the row survives with `revokedAt` and `revokedBy: GRANTOR`.", + requestParams: { + path: z.object({ id: z.string() }), + }, + responses: { + ...stdResponses, + "200": { + description: "The ended grant, and what the session cleanup did.", + content: { + "application/json": { + schema: dataEnvelope( + endedGrantView, + "RevokedAccountGrantEnvelope", + ), + }, + }, + }, + "403": sharingRefusal, + "404": { + description: "No such grant on this account's record.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: "That access had already ended.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/account/grants/{id}/renounce": { + post: { + tags: ["Account sharing"], + summary: "Hand access back (delegate)", + description: + "The same transition as the owner's revoke, attributed the other way (`revokedBy: GRANTEE`) so the record can tell a withdrawal from a handover. Same session cleanup. Refused while acting on the record being renounced — switch back first (one request), which keeps grant management non-delegable without exceptions.", + requestParams: { + path: z.object({ id: z.string() }), + }, + responses: { + ...stdResponses, + "200": { + description: "The ended grant, and what the session cleanup did.", + content: { + "application/json": { + schema: dataEnvelope( + endedGrantView, + "RenouncedAccountGrantEnvelope", + ), + }, + }, + }, + "403": sharingRefusal, + "404": { + description: "No such grant held by this account.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: "That access had already ended.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/account/switch": { + post: { + tags: ["Account sharing"], + summary: "Act on a granted record, or return to your own", + description: + "Cookie transport only; a Bearer caller gets 400 (`meta.errorCode: sharing.switch.wrong_transport`) and should send the `X-HealthLog-Account` header on the requests it wants scoped instead. The grant is validated here so the client gets an honest refusal immediately, but the stamp authorises nothing on its own — every following request re-checks the grant. `accountId: null` clears the switch and always works, including from inside a switched session: this endpoint is the way out.", + requestBody: { + required: true, + content: { "application/json": { schema: switchAccountSchema } }, + }, + responses: { + ...stdResponses, + "200": { + description: "The account this session is now acting on, or null.", + content: { + "application/json": { + schema: dataEnvelope(switchResponse, "AccountSwitchEnvelope"), + }, + }, + }, + "400": { + description: + "Sent over the Bearer transport, which carries its selector per request instead.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "403": sharingRefusal, + "422": { + description: "Validation failed.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 4c88063c9..6e09c83bf 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -21,6 +21,7 @@ */ import type { ZodOpenApiObject } from "zod-openapi"; +import { accountSharingPaths } from "./account-sharing"; import { adminDiagnosticPaths, adminInvitePaths } from "./admin"; import { allergyPaths } from "./allergies"; import { authPaths } from "./auth"; @@ -115,6 +116,9 @@ export const openApiPaths: NonNullable = { // registry entirely, now documented as the reference optimistic-concurrency // contract (appended, spread order is load-bearing). ...dashboardWidgetPaths, + // v1.36.0 — account sharing: the grant lifecycle and the switch endpoint + // (appended, spread order is load-bearing). + ...accountSharingPaths, }; export const openApiComponents: NonNullable = { diff --git a/src/lib/sharing/acting-session.ts b/src/lib/sharing/acting-session.ts new file mode 100644 index 000000000..062fdfe49 --- /dev/null +++ b/src/lib/sharing/acting-session.ts @@ -0,0 +1,74 @@ +/** + * v1.36.0 — the only writer of `Session.actingAsUserId`. + * + * The column is the cookie transport's acting-account carrier. Two things + * write it and they are opposite acts: the switch endpoint points a browser + * session at a record, and the end of a grant points every affected session + * back at its own. Both live here rather than at their call sites so the + * carrier has one writer, the same way it has one reader (the resolver in + * `src/lib/api-handler.ts`). The structural guard in + * `src/__tests__/acting-account-boundary-guard.test.ts` holds that shape: any + * other file that so much as names the column fails it. + * + * Nothing here authorises anything. `pointSessionAt` writes an id its caller + * has already proven, and the resolver re-checks the grant on every request + * regardless — a value stranded here by a grant that has since ended confers + * exactly nothing. The clearing below is therefore housekeeping, not + * enforcement: it exists so a delegate's browser lands back in its own account + * instead of on a wall of 403s. Enforcement already happened when the grant + * row was stamped. + */ +import { prisma } from "@/lib/db"; +import type { Prisma } from "@/generated/prisma/client"; + +/** + * The narrow client slice these functions need, so a caller can hand them a + * transaction handle. Same shape as `grants.ts` uses for `accountGrant`. + */ +type SessionDb = Pick; + +/** + * Point one browser session at an account's record, or (with `null`) back at + * its own. + * + * Scoped by session id AND the caller's own user id. The id alone would be + * enough in every code path that exists today; pinning the owner as well means + * a future caller that gets a session id from somewhere less trustworthy than + * `requireActorAuth` cannot use this to steer somebody else's browser. + */ +export async function pointSessionAt( + sessionId: string, + userId: string, + actingAsUserId: string | null, + db: SessionDb = prisma, +): Promise { + await db.session.updateMany({ + where: { id: sessionId, userId }, + data: { actingAsUserId }, + }); +} + +/** + * Drop every session of `granteeId` that is currently acting as `grantorId`. + * + * Called inside the transaction that ends a grant, which is the point: the + * grant row and the sessions pointing at it stop being true in one atomic + * step. Split across two statements, a revocation that failed halfway would + * leave a browser sitting inside a record it no longer has access to — every + * request refused, the banner still claiming otherwise, and nothing in the UI + * able to explain it. + * + * Returns the number of sessions cleared, so the caller can put it on the wide + * event: "revoked, and dropped her out of it" is a different fact from + * "revoked", and the owner pressing the button deserves to know which happened. + */ +export async function clearActingSessions( + pair: { grantorId: string; granteeId: string }, + db: SessionDb = prisma, +): Promise { + const { count } = await db.session.updateMany({ + where: { userId: pair.granteeId, actingAsUserId: pair.grantorId }, + data: { actingAsUserId: null }, + }); + return count; +} diff --git a/src/lib/sharing/grant-view.ts b/src/lib/sharing/grant-view.ts new file mode 100644 index 000000000..d07ed6553 --- /dev/null +++ b/src/lib/sharing/grant-view.ts @@ -0,0 +1,68 @@ +/** + * v1.36.0 — how a grant row reaches a client. + * + * One shape for every sharing route, because five endpoints each shaping the + * same row their own way is five chances for the panel to render "active" on a + * row the resolver has already stopped honouring. + * + * `state` is the important field and it is not stored anywhere: it comes from + * `grantState()`, the same function the resolver's own predicate is built on. + * A serialiser that derived it here from `revokedAt`/`acceptedAt` would be the + * second decider this feature spends most of its design avoiding — and it + * would drift in the dangerous direction, showing a grant as ended while the + * resolver still admitted it, or the reverse. + * + * What is NOT published: the other party's e-mail. The owner typed an + * identifier to invite somebody and does not need it echoed; the delegate + * never asked for the owner's. Handing every party to a grant a durable + * address for every other party is reach the grant was never meant to confer. + */ +import { grantState, type GrantState } from "@/lib/sharing/grants"; +import type { AccountGrant } from "@/generated/prisma/client"; + +/** The other party to a grant, as far as anyone is told about them. */ +export interface GrantParty { + id: string; + username: string; + displayName: string | null; +} + +export interface GrantView { + id: string; + /** The account on the other end: the delegate on a given grant, the owner on a received one. */ + account: GrantParty; + access: AccountGrant["access"]; + state: GrantState; + invitedAt: string; + acceptedAt: string | null; + expiresAt: string | null; + lastUsedAt: string | null; + revokedAt: string | null; + revokedBy: AccountGrant["revokedBy"]; +} + +/** The columns a party needs, so a caller can `select` exactly them. */ +export const GRANT_PARTY_SELECT = { + id: true, + username: true, + displayName: true, +} as const; + +export function toGrantView( + grant: AccountGrant, + account: GrantParty, + now: Date = new Date(), +): GrantView { + return { + id: grant.id, + account, + access: grant.access, + state: grantState(grant, now), + invitedAt: grant.invitedAt.toISOString(), + acceptedAt: grant.acceptedAt?.toISOString() ?? null, + expiresAt: grant.expiresAt?.toISOString() ?? null, + lastUsedAt: grant.lastUsedAt?.toISOString() ?? null, + revokedAt: grant.revokedAt?.toISOString() ?? null, + revokedBy: grant.revokedBy, + }; +} diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts index 71e4e9075..1ce413c87 100644 --- a/src/lib/sharing/grants.ts +++ b/src/lib/sharing/grants.ts @@ -45,6 +45,7 @@ */ import { prisma } from "@/lib/db"; import { isP2002 } from "@/lib/prisma-errors"; +import { clearActingSessions } from "@/lib/sharing/acting-session"; import type { AccountGrant, AccountGrantAccess, @@ -292,6 +293,57 @@ export async function renounceGrant( return endGrant(input.grantId, { granteeId: input.granteeId }, "GRANTEE", db); } +/** + * What ending a grant did: the row as it now stands, and how many of the + * delegate's browser sessions were sitting inside the record when it happened. + */ +export interface EndedGrant { + grant: AccountGrant; + sessionsCleared: number; +} + +/** + * The owner withdraws access, and any browser already inside the record leaves + * with it. + * + * This is the verb the sharing panel calls; {@link revokeGrant} is the state + * transition underneath it. They are separate because the transition has to be + * usable inside somebody else's transaction, and because a caller who takes the + * transition alone should have to notice they are leaving sessions behind. + * + * One transaction, and the reason is a specific failure: the grant row and the + * sessions pointing at it are two statements of the same fact, and a revocation + * that stamped the row but not the sessions would leave the delegate's browser + * inside a record it can no longer read — every request 403, the banner still + * saying "viewing her record", nothing on screen able to account for it. The + * resolver would refuse correctly and the person would still be looking at a + * lie. + * + * Still no step-up, no confirmation ceremony, nothing that makes this slower + * than granting was. Reducing access must never be harder than giving it: the + * person ending access to their own health record is the one party here whose + * intent needs no verification. + */ +export async function revokeGrantAndClearSwitch( + input: RevokeGrantInput, +): Promise { + return endAndClear((tx) => revokeGrant(input, tx)); +} + +/** + * The delegate hands the access back, and their own browser leaves the record. + * + * Same cleanup, and for a sharper reason than on the revoke side: the session + * being cleared is almost certainly the one making this very request, because + * renouncing is a thing people do while looking at the record they are + * renouncing. + */ +export async function renounceGrantAndClearSwitch( + input: RenounceGrantInput, +): Promise { + return endAndClear((tx) => renounceGrant(input, tx)); +} + // ── Resolution ────────────────────────────────────────────────────────────── /** @@ -342,6 +394,26 @@ export function touchGrantUsage( // ── Internals ─────────────────────────────────────────────────────────────── +/** + * Run an ending transition and its session cleanup as one unit. + * + * The transition runs first and throws on a refusal, which rolls the whole + * thing back — so a revoke somebody was not entitled to make cannot clear a + * session as a side effect of being refused. + */ +async function endAndClear( + transition: (tx: Prisma.TransactionClient) => Promise, +): Promise { + return prisma.$transaction(async (tx) => { + const grant = await transition(tx); + const sessionsCleared = await clearActingSessions( + { grantorId: grant.grantorId, granteeId: grant.granteeId }, + tx, + ); + return { grant, sessionsCleared }; + }); +} + /** The shared body of revoke and renounce. */ async function endGrant( grantId: string, diff --git a/src/lib/validations/account-sharing.ts b/src/lib/validations/account-sharing.ts new file mode 100644 index 000000000..293291bcd --- /dev/null +++ b/src/lib/validations/account-sharing.ts @@ -0,0 +1,60 @@ +/** + * v1.36.0 — request shapes for the account-sharing lifecycle. + * + * Four verbs are exercised through these routes (invite, accept, end, switch) + * and only two of them carry a body worth validating. What is NOT here is as + * deliberate as what is: + * + * * No `access` level. v1 grants READ and only READ, and the level is not a + * parameter anywhere in the stack — `inviteGrant` hardcodes it. A field + * here would be an input the domain module then ignores, which is the + * shape of a promise nobody kept. + * * No `userId`, `grantorId` or `granteeId` on any body. Both sides of a + * grant come from the authenticated session and the row itself. A body + * field naming a party would be an authorization decision made by the + * caller. + */ +import { z } from "zod/v4"; + +/** + * How long a value naming an account may be. + * + * Matches the resolver's own selector bound (`MAX_SELECTOR_LENGTH`, 64): ids + * are 25-character cuids, and a longer string names nothing that could exist. + */ +const MAX_ACCOUNT_ID_LENGTH = 64; + +/** + * An e-mail address or a username, whichever the owner knows the person by. + * + * The same identifier the login form accepts, matched the same way (either + * column, case-insensitively), because asking somebody to invite a housemate + * by a different name than the one that housemate types to sign in is a + * support ticket waiting to happen. + * + * The 255 bound is the e-mail column's practical ceiling; anything past it is + * refused before it reaches a query. + */ +export const inviteGrantSchema = z.object({ + identifier: z.string().trim().min(1).max(255), + /** + * Optional lapse date. Absent or null means the grant runs until somebody + * ends it — the common household case, where an expiry the owner has to + * remember to renew is worse than one that does not exist. + */ + expiresAt: z.iso.datetime({ offset: true }).nullable().optional(), +}); + +export type InviteGrantBody = z.infer; + +/** + * The switch body. `null` is the whole point of the field rather than an + * omission: clearing the switch and setting one are the same act pointed in + * two directions, and a separate "unswitch" route would be a second place to + * get the session write right. + */ +export const switchAccountSchema = z.object({ + accountId: z.string().min(1).max(MAX_ACCOUNT_ID_LENGTH).nullable(), +}); + +export type SwitchAccountBody = z.infer; diff --git a/tests/integration/sharing-audit-actor.test.ts b/tests/integration/sharing-audit-actor.test.ts new file mode 100644 index 000000000..422e87c25 --- /dev/null +++ b/tests/integration/sharing-audit-actor.test.ts @@ -0,0 +1,337 @@ +/** + * What the audit trail records for a delegated action, and for a self action. + * + * The whole feature rests on one reading: `actorUserId` NULL means the account + * acted for itself. Every row written before the column existed means that, + * and every row written after it will mostly mean it too — so a threading rule + * that stamps an actor where none was acting does not add information, it + * changes what several years of history says. That reading is what these tests + * hold, against real rows. + * + * Round trips through the real routes, for the reason that cost this project a + * release: a unit stub proves the code ran, not what landed. Here the question + * is only ever "which row exists afterwards, and what is in its columns". + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NextRequest, NextResponse } from "next/server"; + +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), +})); + +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { auditLog, DELEGATED_ACCESS_ACTION } from "@/lib/auth/audit"; +import { acceptGrant, inviteGrant } from "@/lib/sharing/grants"; + +let counter = 0; + +async function makeUser(label: string) { + const suffix = `${label}-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `audit-${suffix}`, + email: `audit-${suffix}@example.test`, + role: "USER", + }, + }); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +/** + * A delegable route that writes an audit row the way a migrated route does: + * `userId` from the resolved data scope, and nothing about the actor, because + * the actor is not the handler's business. + */ +const delegableAction: (request: NextRequest) => Promise = apiHandler( + async () => { + const { user } = await requireRecordAuth("read"); + await auditLog("measurement.read", { + userId: user.id, + details: { via: "test" }, + }); + return NextResponse.json({ data: { ok: true }, error: null }); + }, +); + +/** The same handler's self-action case: no switch, same code path. */ +function act(): Promise { + return delegableAction( + new NextRequest("http://localhost/api/test/delegable", { method: "GET" }), + ); +} + +async function household() { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + }); + await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); + const session = await signIn(delegate.id); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: owner.id }, + }); + return { owner, delegate, session }; +} + +async function rowsFor(action: string) { + return getPrismaClient().auditLog.findMany({ + where: { action }, + orderBy: { createdAt: "asc" }, + }); +} + +/** The resolver's access row is fire-and-forget; give it a tick to land. */ +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 80)); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("who the trail says did it", () => { + it("files a delegated action under the owner, naming the delegate", async () => { + const { owner, delegate } = await household(); + + expect((await act()).status).toBe(200); + + const rows = await rowsFor("measurement.read"); + expect(rows).toHaveLength(1); + expect(rows[0].userId).toBe(owner.id); + expect(rows[0].actorUserId).toBe(delegate.id); + }); + + it("leaves the actor NULL when somebody acts on their own record", async () => { + // The load-bearing one. NULL is not "we did not know" — it is the + // statement every historical row already makes, and the same handler, + // unswitched, has to keep making it. + const solo = await makeUser("solo"); + await signIn(solo.id); + + expect((await act()).status).toBe(200); + + const rows = await rowsFor("measurement.read"); + expect(rows).toHaveLength(1); + expect(rows[0].userId).toBe(solo.id); + expect(rows[0].actorUserId).toBeNull(); + }); + + it("leaves the actor NULL on a row a delegate writes about themselves", async () => { + // A delegated request that files under the DELEGATE describes the + // delegate's own act. Stamping the actor there would say "somebody else + // did this to you" about a person acting on themselves. + const { delegate } = await household(); + + const handler: (request: NextRequest) => Promise = apiHandler( + async () => { + const { actor } = await requireRecordAuth("read"); + await auditLog("delegate.own.act", { userId: actor.id }); + return NextResponse.json({ data: { ok: true }, error: null }); + }, + ); + await handler( + new NextRequest("http://localhost/api/test/delegable", { method: "GET" }), + ); + + const rows = await rowsFor("delegate.own.act"); + expect(rows).toHaveLength(1); + expect(rows[0].userId).toBe(delegate.id); + expect(rows[0].actorUserId).toBeNull(); + }); + + it("leaves the actor NULL on a refused delegation", async () => { + // The refusal row already exists (the resolver writes it). It is the + // caller's own act — an attempt they made as themselves — and it must not + // acquire an actor just because the request mentioned another account. + const delegate = await makeUser("delegate"); + const stranger = await makeUser("stranger"); + const session = await signIn(delegate.id); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: stranger.id }, + }); + + expect((await act()).status).toBe(403); + await settle(); + + const rows = await rowsFor("sharing.access.denied"); + expect(rows).toHaveLength(1); + expect(rows[0].userId).toBe(delegate.id); + expect(rows[0].actorUserId).toBeNull(); + }); +}); + +describe("delegated reads coalesce to one row a day", () => { + it("writes one row for many reads, and counts them", async () => { + const { owner, delegate } = await household(); + + await act(); + await act(); + await act(); + await settle(); + + const rows = await rowsFor(DELEGATED_ACCESS_ACTION); + expect(rows).toHaveLength(1); + expect(rows[0].userId).toBe(owner.id); + expect(rows[0].actorUserId).toBe(delegate.id); + expect(JSON.parse(rows[0].details!)).toEqual({ accesses: 3 }); + }); + + it("keeps a second delegate's reads on their own row", async () => { + // Coalescing is per (owner, delegate, day). Two delegates in one record on + // one day are two rows — merging them would answer "somebody was in your + // record" when the owner asked "who". + const { owner, delegate } = await household(); + const second = await makeUser("second"); + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: second.id, + }); + await acceptGrant({ grantId: invited.id, granteeId: second.id }); + + await act(); + const secondSession = await signIn(second.id); + await getPrismaClient().session.update({ + where: { id: secondSession.id }, + data: { actingAsUserId: owner.id }, + }); + await act(); + await settle(); + + const rows = await rowsFor(DELEGATED_ACCESS_ACTION); + expect(rows).toHaveLength(2); + expect(rows.map((r) => r.actorUserId).sort()).toEqual( + [delegate.id, second.id].sort(), + ); + expect(rows.every((r) => r.userId === owner.id)).toBe(true); + }); + + it("starts a new row on the next day", async () => { + const { owner, delegate } = await household(); + + await act(); + await settle(); + + // Age today's row by a day. The coalescing key is the row's own date, so + // this is exactly what tomorrow looks like to the next read — and if the + // key were "the latest row" rather than the day, the next read would still + // find this one and the trail would grow one row per delegate forever. + await getPrismaClient().$executeRaw` + UPDATE audit_logs + SET created_at = created_at - INTERVAL '1 day' + WHERE action = ${DELEGATED_ACCESS_ACTION} + `; + + await act(); + await settle(); + + const rows = await rowsFor(DELEGATED_ACCESS_ACTION); + expect(rows).toHaveLength(2); + expect(rows.every((r) => r.userId === owner.id)).toBe(true); + expect(rows.every((r) => r.actorUserId === delegate.id)).toBe(true); + }); + + it("writes nothing at all when nobody is delegating", async () => { + const solo = await makeUser("solo"); + await signIn(solo.id); + + await act(); + await settle(); + + expect(await rowsFor(DELEGATED_ACCESS_ACTION)).toHaveLength(0); + }); +}); + +describe("the lifecycle verbs leave their own trail", () => { + it("records the invitation, the acceptance and the revocation", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + + await signIn(owner.id); + const { POST } = await import("@/app/api/account/grants/route"); + const invited = await POST( + new NextRequest("http://localhost/api/account/grants", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ identifier: delegate.username }), + }), + ); + const grantId = (await invited.json()).data.id; + + await signIn(delegate.id); + const accept = await import("@/app/api/account/grants/[id]/accept/route"); + await accept.POST( + new NextRequest(`http://localhost/api/account/grants/${grantId}/accept`, { + method: "POST", + headers: { "content-type": "application/json" }, + }), + { params: Promise.resolve({ id: grantId }) }, + ); + + await signIn(owner.id); + const revokeRoute = await import("@/app/api/account/grants/[id]/route"); + await revokeRoute.DELETE( + new NextRequest(`http://localhost/api/account/grants/${grantId}`, { + method: "DELETE", + headers: { "content-type": "application/json" }, + }), + { params: Promise.resolve({ id: grantId }) }, + ); + + const trail = await getPrismaClient().auditLog.findMany({ + where: { action: { startsWith: "sharing.grant." } }, + orderBy: { createdAt: "asc" }, + }); + expect(trail.map((r) => r.action)).toEqual([ + "sharing.grant.invited", + "sharing.grant.accepted", + "sharing.grant.revoked", + ]); + // Each row files under whoever performed it, acting as themselves. The + // owner's side of the acceptance is not an audit row at all — it is + // `acceptedAt` on the grant, which is the durable consent record. + expect(trail.map((r) => r.userId)).toEqual([ + owner.id, + delegate.id, + owner.id, + ]); + expect(trail.every((r) => r.actorUserId === null)).toBe(true); + }); +}); diff --git a/tests/integration/sharing-lifecycle.test.ts b/tests/integration/sharing-lifecycle.test.ts new file mode 100644 index 000000000..1ea9748c3 --- /dev/null +++ b/tests/integration/sharing-lifecycle.test.ts @@ -0,0 +1,616 @@ +/** + * The five verbs of account sharing, over the real routes, against real + * Postgres. + * + * Invite, accept, switch, revoke, renounce. Every one of them is a question + * about which rows exist afterwards, which is why none of it is unit-tested + * here: a mocked Prisma can show that the route called what it meant to call, + * and this feature has exactly one failure mode worth catching — the call that + * was made and did not land. The revocation cleanup is the sharpest case. A + * response body saying `sessionsCleared: 1` proves nothing; the session row + * still pointing at the owner's record is the defect. + * + * The delegable read handler is assembled here rather than imported because no + * shipped route declares `requireRecordAuth` yet — the frozen list lands empty + * on purpose and routes migrate into it later. Everything beneath it is real: + * the real wrapper, the real resolver, the real grant module, the real + * database. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NextRequest, NextResponse } from "next/server"; + +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), +})); + +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { hashToken } from "@/lib/auth/hmac"; +import { prisma } from "@/lib/db"; + +let counter = 0; + +async function makeUser(label: string) { + const suffix = `${label}-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `share-${suffix}`, + email: `share-${suffix}@example.test`, + displayName: `Share ${suffix}`, + role: "USER", + }, + }); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +async function mintToken(userId: string): Promise { + const raw = `hlk_${userId}-${counter++}`.padEnd(20, "0"); + await getPrismaClient().apiToken.create({ + data: { + userId, + name: "sharing-lifecycle-test", + tokenHash: hashToken(raw), + permissions: ["*"], + }, + }); + return raw; +} + +function jsonRequest(url: string, method: string, body?: unknown): NextRequest { + return new NextRequest(url, { + method, + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +// ── the routes under test ─────────────────────────────────────────────────── + +async function listGrants(): Promise { + const { GET } = await import("@/app/api/account/grants/route"); + return GET(); +} + +async function invite( + identifier: string, + expiresAt?: string | null, +): Promise { + const { POST } = await import("@/app/api/account/grants/route"); + return POST( + jsonRequest("http://localhost/api/account/grants", "POST", { + identifier, + ...(expiresAt === undefined ? {} : { expiresAt }), + }), + ); +} + +async function accept(grantId: string): Promise { + const { POST } = await import("@/app/api/account/grants/[id]/accept/route"); + return POST( + jsonRequest( + `http://localhost/api/account/grants/${grantId}/accept`, + "POST", + ), + { params: Promise.resolve({ id: grantId }) }, + ); +} + +async function revoke(grantId: string): Promise { + const { DELETE } = await import("@/app/api/account/grants/[id]/route"); + return DELETE( + jsonRequest(`http://localhost/api/account/grants/${grantId}`, "DELETE"), + { params: Promise.resolve({ id: grantId }) }, + ); +} + +async function renounce(grantId: string): Promise { + const { POST } = await import("@/app/api/account/grants/[id]/renounce/route"); + return POST( + jsonRequest( + `http://localhost/api/account/grants/${grantId}/renounce`, + "POST", + ), + { params: Promise.resolve({ id: grantId }) }, + ); +} + +async function switchTo(accountId: string | null): Promise { + const { POST } = await import("@/app/api/account/switch/route"); + return POST( + jsonRequest("http://localhost/api/account/switch", "POST", { accountId }), + ); +} + +/** A delegable read, exactly as a migrated route builds one. */ +const delegableRead: (request: NextRequest) => Promise = apiHandler( + async () => { + const { user, actor } = await requireRecordAuth("read"); + const rows = await prisma.measurement.findMany({ + where: { userId: user.id }, + orderBy: { value: "asc" }, + }); + return NextResponse.json({ + data: { + scopeUserId: user.id, + actorUserId: actor.id, + values: rows.map((r) => r.value), + }, + error: null, + }); + }, +); + +function readRecord(): Promise { + return delegableRead( + new NextRequest("http://localhost/api/test/delegable", { method: "GET" }), + ); +} + +async function seedWeight(userId: string, value: number) { + return getPrismaClient().measurement.create({ + data: { + userId, + type: "WEIGHT", + value, + unit: "kg", + measuredAt: new Date(), + source: "MANUAL", + }, + }); +} + +/** The acting-account column on one session row, read straight from the table. */ +async function actingAsOf(sessionId: string): Promise { + const row = await getPrismaClient().session.findUniqueOrThrow({ + where: { id: sessionId }, + select: { actingAsUserId: true }, + }); + return row.actingAsUserId; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("the handshake, end to end", () => { + it("carries a household from invitation to revocation and leaves the right rows", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedWeight(owner.id, 81); + await seedWeight(delegate.id, 64); + + // ── the owner invites ── + await signIn(owner.id); + const invited = await invite(delegate.username); + expect(invited.status).toBe(201); + const grantView = (await invited.json()).data; + expect(grantView.state).toBe("PENDING"); + expect(grantView.access).toBe("READ"); + expect(grantView.account.id).toBe(delegate.id); + + // Pending confers nothing: the delegate cannot switch into a record they + // have only been offered. + const delegateSession = await signIn(delegate.id); + expect((await switchTo(owner.id)).status).toBe(403); + expect(await actingAsOf(delegateSession.id)).toBeNull(); + + // ── the delegate accepts ── + const accepted = await accept(grantView.id); + expect(accepted.status).toBe(200); + expect((await accepted.json()).data.state).toBe("ACTIVE"); + + const acceptedRow = await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: grantView.id }, + }); + expect(acceptedRow.acceptedAt).not.toBeNull(); + + // ── the delegate switches in and reads the owner's record ── + const switched = await switchTo(owner.id); + expect(switched.status).toBe(200); + expect((await switched.json()).data.actingAs.accountId).toBe(owner.id); + expect(await actingAsOf(delegateSession.id)).toBe(owner.id); + + const read = await readRecord(); + expect(read.status).toBe(200); + const readBody = (await read.json()).data; + expect(readBody.scopeUserId).toBe(owner.id); + expect(readBody.actorUserId).toBe(delegate.id); + // The delegate's own 64 kg row is seeded and must not appear. An empty + // list would satisfy a weaker assertion while hiding the exact failure + // this feature exists to avoid. + expect(readBody.values).toEqual([81]); + + // ── the owner revokes ── + await signIn(owner.id); + const revoked = await revoke(grantView.id); + expect(revoked.status).toBe(200); + expect((await revoked.json()).data.sessionsCleared).toBe(1); + + // The row survives as the consent record rather than being deleted. + const revokedRow = await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: grantView.id }, + }); + expect(revokedRow.revokedAt).not.toBeNull(); + expect(revokedRow.revokedBy).toBe("GRANTOR"); + expect(revokedRow.acceptedAt).not.toBeNull(); + + // The session row, not the response body. A cleanup that ran everywhere + // except the database would look identical from the outside. + expect(await actingAsOf(delegateSession.id)).toBeNull(); + + // And the delegate's next request is refused on the grant, independently + // of the session having been cleared. + cookieJar.set("healthlog_session", delegateSession.id); + await getPrismaClient().session.update({ + where: { id: delegateSession.id }, + data: { actingAsUserId: owner.id }, + }); + const afterRevoke = await readRecord(); + expect(afterRevoke.status).toBe(403); + expect((await afterRevoke.json()).meta.errorCode).toBe( + "sharing.access.denied", + ); + }); + + it("drops the delegate's other sessions when they renounce", async () => { + // Two browsers, the way a person actually has them: a phone sitting inside + // the owner's record and a laptop in their own account. Renouncing from + // the laptop has to evict the phone — a cleanup that only reached the + // session making the request would leave the other one inside a record + // whose grant no longer exists. + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + + await signIn(owner.id); + const grantId = (await (await invite(delegate.email!)).json()).data.id; + + const phone = await signIn(delegate.id); + await accept(grantId); + await switchTo(owner.id); + expect(await actingAsOf(phone.id)).toBe(owner.id); + + const laptop = await signIn(delegate.id); + const gone = await renounce(grantId); + expect(gone.status).toBe(200); + expect((await gone.json()).data.sessionsCleared).toBe(1); + expect(await actingAsOf(phone.id)).toBeNull(); + expect(await actingAsOf(laptop.id)).toBeNull(); + + const row = await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: grantId }, + }); + expect(row.revokedBy).toBe("GRANTEE"); + }); + + it("refuses a renunciation made from inside the record itself", async () => { + // Grant management is non-delegable without exception, and the delegate's + // own renounce is not carved out of that. The browser switches back first + // — one request, which the client makes on the way — and the fail-closed + // default stays a default rather than a default with an exception. + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await signIn(owner.id); + const grantId = (await (await invite(delegate.username)).json()).data.id; + + const session = await signIn(delegate.id); + await accept(grantId); + await switchTo(owner.id); + + const refused = await renounce(grantId); + expect(refused.status).toBe(403); + expect((await refused.json()).meta.errorCode).toBe("sharing.not_permitted"); + + // Out of the record, then out of the grant, both instant. + expect((await switchTo(null)).status).toBe(200); + expect((await renounce(grantId)).status).toBe(200); + expect(await actingAsOf(session.id)).toBeNull(); + const row = await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: grantId }, + }); + expect(row.revokedBy).toBe("GRANTEE"); + }); + + it("clears only the sessions pointing at the record that ended", async () => { + // Two owners, one delegate with a browser in each. Ending one grant must + // not evict the other — a cleanup scoped to the delegate alone would pass + // every single-household test and quietly sign people out of a record + // nobody revoked. + const ownerA = await makeUser("owner-a"); + const ownerB = await makeUser("owner-b"); + const delegate = await makeUser("delegate"); + + await signIn(ownerA.id); + const grantA = (await (await invite(delegate.username)).json()).data.id; + await signIn(ownerB.id); + const grantB = (await (await invite(delegate.username)).json()).data.id; + + const sessionA = await signIn(delegate.id); + await accept(grantA); + await switchTo(ownerA.id); + const sessionB = await signIn(delegate.id); + await accept(grantB); + await switchTo(ownerB.id); + + await signIn(ownerA.id); + await revoke(grantA); + + expect(await actingAsOf(sessionA.id)).toBeNull(); + expect(await actingAsOf(sessionB.id)).toBe(ownerB.id); + }); +}); + +describe("who may do what to a grant", () => { + async function pendingGrant() { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await signIn(owner.id); + const id = (await (await invite(delegate.username)).json()).data.id; + return { owner, delegate, id }; + } + + it("refuses an acceptance by anybody but the invited account", async () => { + const { id } = await pendingGrant(); + const stranger = await makeUser("stranger"); + await signIn(stranger.id); + + const response = await accept(id); + expect(response.status).toBe(404); + const row = await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id }, + }); + expect(row.acceptedAt).toBeNull(); + }); + + it("refuses a revocation by the delegate — renounce is their verb", async () => { + const { delegate, id } = await pendingGrant(); + await signIn(delegate.id); + + expect((await revoke(id)).status).toBe(404); + const row = await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id }, + }); + expect(row.revokedAt).toBeNull(); + }); + + it("refuses a renunciation by the owner — revoke is theirs", async () => { + const { owner, id } = await pendingGrant(); + await signIn(owner.id); + + expect((await renounce(id)).status).toBe(404); + const row = await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id }, + }); + expect(row.revokedAt).toBeNull(); + }); + + it("refuses a second live invitation and allows one after a revocation", async () => { + const { delegate, id } = await pendingGrant(); + + const second = await invite(delegate.username); + expect(second.status).toBe(409); + expect((await second.json()).meta.errorCode).toBe( + "sharing.invite.duplicate", + ); + + await revoke(id); + const third = await invite(delegate.username); + expect(third.status).toBe(201); + const newId = (await third.json()).data.id; + expect(newId).not.toBe(id); + + // The history piles up rather than being reused: the first row is still + // there, still revoked, with its own dates. + const rows = await getPrismaClient().accountGrant.findMany({ + orderBy: { createdAt: "asc" }, + }); + expect(rows).toHaveLength(2); + expect(rows[0].id).toBe(id); + expect(rows[0].revokedAt).not.toBeNull(); + expect(rows[1].revokedAt).toBeNull(); + }); + + it("refuses an account sharing with itself", async () => { + const owner = await makeUser("owner"); + await signIn(owner.id); + + const response = await invite(owner.username); + expect(response.status).toBe(422); + expect((await response.json()).meta.errorCode).toBe("sharing.invite.self"); + expect(await getPrismaClient().accountGrant.count()).toBe(0); + }); + + it("says so when the identifier names nobody on this instance", async () => { + const owner = await makeUser("owner"); + await signIn(owner.id); + + const response = await invite("nobody-here@example.test"); + expect(response.status).toBe(404); + expect(await getPrismaClient().accountGrant.count()).toBe(0); + }); +}); + +describe("grant management cannot be reached from inside a switch", () => { + /** + * The property the design states as "a delegate must never grant, widen, or + * transfer". Not enforced by a re-delegation check in the handler — by the + * mode: bare `requireAuth()` refuses outright while a switch is active, so + * there is no path through these routes that runs as somebody else. + */ + async function switchedDelegate() { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const third = await makeUser("third"); + await signIn(owner.id); + const id = (await (await invite(delegate.username)).json()).data.id; + await signIn(delegate.id); + await accept(id); + await switchTo(owner.id); + return { owner, delegate, third, id }; + } + + it("refuses an invitation issued while acting as somebody else", async () => { + const { third } = await switchedDelegate(); + + const response = await invite(third.username); + expect(response.status).toBe(403); + expect((await response.json()).meta.errorCode).toBe( + "sharing.not_permitted", + ); + // One grant in the table: the delegate's own. Nothing was widened. + expect(await getPrismaClient().accountGrant.count()).toBe(1); + }); + + it("refuses the grant list and the revoke while acting as somebody else", async () => { + const { id } = await switchedDelegate(); + + expect((await listGrants()).status).toBe(403); + expect((await revoke(id)).status).toBe(403); + const row = await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id }, + }); + expect(row.revokedAt).toBeNull(); + }); + + it("still lets the switched session switch back", async () => { + const { delegate } = await switchedDelegate(); + const session = await getPrismaClient().session.findFirstOrThrow({ + where: { userId: delegate.id, actingAsUserId: { not: null } }, + }); + + const back = await switchTo(null); + expect(back.status).toBe(200); + expect((await back.json()).data.actingAs).toBeNull(); + expect(await actingAsOf(session.id)).toBeNull(); + }); +}); + +describe("the switch endpoint", () => { + it("refuses a switch into a grant that is not active", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await signIn(owner.id); + const lapsing = ( + await ( + await invite( + delegate.username, + new Date(Date.now() + 300).toISOString(), + ) + ).json() + ).data.id; + + const session = await signIn(delegate.id); + await accept(lapsing); + await new Promise((resolve) => setTimeout(resolve, 400)); + + const response = await switchTo(owner.id); + expect(response.status).toBe(403); + expect((await response.json()).meta.errorCode).toBe( + "sharing.access.denied", + ); + expect(await actingAsOf(session.id)).toBeNull(); + }); + + it("answers a nonexistent account and an ungranting one with the same bytes", async () => { + const stranger = await makeUser("stranger"); + const caller = await makeUser("caller"); + await signIn(caller.id); + + const unknown = await switchTo("does-not-exist-at-all"); + const ungranting = await switchTo(stranger.id); + + expect(unknown.status).toBe(ungranting.status); + expect(await unknown.text()).toBe(await ungranting.text()); + }); + + it("refuses over Bearer, where the carrier is the header", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await signIn(owner.id); + const id = (await (await invite(delegate.username)).json()).data.id; + const session = await signIn(delegate.id); + await accept(id); + + cookieJar.clear(); + headerJar.set("authorization", `Bearer ${await mintToken(delegate.id)}`); + const response = await switchTo(owner.id); + expect(response.status).toBe(400); + expect((await response.json()).meta.errorCode).toBe( + "sharing.switch.wrong_transport", + ); + // Nothing was stamped on the browser session that happens to exist. + expect(await actingAsOf(session.id)).toBeNull(); + }); +}); + +describe("the grant list", () => { + it("shows both directions with the state the resolver would agree with", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const other = await makeUser("other"); + + await signIn(owner.id); + const given = (await (await invite(delegate.username)).json()).data.id; + + await signIn(other.id); + const received = (await (await invite(owner.username)).json()).data.id; + + await signIn(owner.id); + const body = (await (await listGrants()).json()).data; + + expect(body.given.map((g: { id: string }) => g.id)).toEqual([given]); + expect(body.given[0].account.id).toBe(delegate.id); + expect(body.given[0].state).toBe("PENDING"); + expect(body.received.map((g: { id: string }) => g.id)).toEqual([received]); + expect(body.received[0].account.id).toBe(other.id); + + // No e-mail addresses in either direction: a grant is not a way to collect + // the other party's contact details. + expect(JSON.stringify(body)).not.toContain("@example.test"); + }); + + it("keeps ended grants in the list, marked as ended", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await signIn(owner.id); + const id = (await (await invite(delegate.username)).json()).data.id; + await revoke(id); + + const body = (await (await listGrants()).json()).data; + expect(body.given).toHaveLength(1); + expect(body.given[0].state).toBe("REVOKED"); + expect(body.given[0].revokedBy).toBe("GRANTOR"); + }); +});