mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge pull request #690 from MBombeck/work/sharing-client
Account sharing, the part people touch
This commit is contained in:
60 files changed
+5648
-174
No files matched your search
+6
-1
@@ -26,7 +26,12 @@ notes, and machine-readable specs that ship alongside the source:
|
||||
`OIDC_ONLY` consequences, and the break-glass runbook. Also
|
||||
[`self-hosting/geolite2.md`](./self-hosting/geolite2.md) — bringing your
|
||||
own offline GeoLite2 databases via `GEOLITE2_DIR` instead of sending
|
||||
login IPs to a third-party lookup.
|
||||
login IPs to a third-party lookup. And
|
||||
[`self-hosting/account-sharing.md`](./self-hosting/account-sharing.md) —
|
||||
letting one account on the instance open another's health record: what
|
||||
the person given access can and cannot reach, what revocation does and
|
||||
does not undo, and which trust properties of the instance are unchanged
|
||||
by it.
|
||||
- [`migration/`](./migration/) — release-by-release migration notes.
|
||||
Read the entry for the version you're upgrading from.
|
||||
- [`audit/`](./audit/) — per-release audit summaries archived for
|
||||
|
||||
+253
-27
@@ -12432,6 +12432,61 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/me:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
summary: The signed-in account, and what sharing lets it do
|
||||
description: "Keeps answering while the browser is acting on another record — the switcher, the banner naming whose
|
||||
record is open, and the way back out all read it, so a refusal here would strand a switched session. It is
|
||||
therefore an ACTOR surface: it always serves the caller's own rows, and a request that attaches
|
||||
`X-HealthLog-Account` is refused with 403 `sharing.not_permitted` rather than quietly answered. A client that
|
||||
wants a record's data sends the selector on the read that needs it, never on this call."
|
||||
responses:
|
||||
"200":
|
||||
description: The account payload.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AccountPayloadEnvelope"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: "A selector header was attached to an actor surface (`meta.errorCode: sharing.not_permitted`)."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/account/activity:
|
||||
get:
|
||||
tags:
|
||||
- Account sharing
|
||||
summary: Who opened this record, and when
|
||||
description: "Every audit row filed under this account that somebody else performed, newest first — one row per delegate
|
||||
per day with a count, rather than one per request. `retentionDays` rides the response because audit history is
|
||||
purged on a schedule the operator configures: a view that showed the rows without the window would imply a
|
||||
completeness it does not have, and clients must state the number the server sends rather than assume the 365-day
|
||||
default. Not delegable: a grant is not an introduction to the other people in the household."
|
||||
responses:
|
||||
"200":
|
||||
description: The activity, and how far back it can see.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RecordActivityEnvelope"
|
||||
"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
|
||||
/api/account/grants:
|
||||
get:
|
||||
tags:
|
||||
@@ -12446,15 +12501,7 @@ paths:
|
||||
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"
|
||||
"403": *a20
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
post:
|
||||
@@ -36558,6 +36605,186 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
AccountPayloadEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/AccountPayload"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
AccountPayload:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
accountAccess:
|
||||
$ref: "#/components/schemas/AccountAccess"
|
||||
required:
|
||||
- id
|
||||
- username
|
||||
- accountAccess
|
||||
additionalProperties: {}
|
||||
description: "The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do.
|
||||
Additional properties are the preference fields this spec does not yet enumerate. Under an active switch this
|
||||
payload still describes the CALLER — their preferences, their modules, their identity — because display
|
||||
preferences belong to the person at the keyboard rather than to the record they are reading."
|
||||
AccountAccess:
|
||||
type: object
|
||||
properties:
|
||||
accounts:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AccountAccessEntry"
|
||||
description: Every record this caller may open, newest grant first.
|
||||
active:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/AccountAccessEntry"
|
||||
- type: "null"
|
||||
description: "The record this session is inside right now, resolved to a full entry rather than an id to look up — so a
|
||||
banner can name the person without joining two fields. Null when the caller is in their own record, and
|
||||
ALWAYS null on the Bearer transport: a token carries its selector per request and this endpoint refuses
|
||||
one."
|
||||
canSwitch:
|
||||
type: boolean
|
||||
description: Whether there is anywhere to switch to. Bind it directly.
|
||||
required:
|
||||
- accounts
|
||||
- active
|
||||
- canSwitch
|
||||
additionalProperties: false
|
||||
description: "Account sharing, resolved. This block is the ONLY source of switchability and writability: the server
|
||||
publishes what the caller may do and the client renders it. A client that computed either from grant data would
|
||||
be a second program deciding one person's access to a health record, and the two answer differently the first
|
||||
time an expiry or a revocation appears. Always present — an account nobody has shared with gets an empty list,
|
||||
not a missing field."
|
||||
AccountAccessEntry:
|
||||
type: object
|
||||
properties:
|
||||
accountId:
|
||||
type: string
|
||||
description: The account whose record it is — the value POST /api/account/switch takes, and the value the
|
||||
X-HealthLog-Account header carries.
|
||||
username:
|
||||
type: string
|
||||
displayName:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
access:
|
||||
type: string
|
||||
enum:
|
||||
- read
|
||||
- write
|
||||
canWrite:
|
||||
type: boolean
|
||||
description: Whether this caller may CHANGE that record. Resolved server-side; `false` for every grant in v1. Render it;
|
||||
never derive it.
|
||||
required:
|
||||
- accountId
|
||||
- username
|
||||
- displayName
|
||||
- access
|
||||
- canWrite
|
||||
additionalProperties: false
|
||||
description: "One account this caller may act on. No avatar is published: the avatar bytes are owner-scoped, so a URL
|
||||
here would resolve to a refusal. Clients paint their initials fallback."
|
||||
RecordActivityEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/RecordActivity"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
RecordActivity:
|
||||
type: object
|
||||
properties:
|
||||
entries:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/RecordActivityEntry"
|
||||
retentionDays:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: How far back this feed can see, in days. Resolved from the instance's audit retention setting (365 by
|
||||
default, operator-configurable), NOT a constant — an instance running a 90-day window sends 90. Show it
|
||||
beside the rows.
|
||||
required:
|
||||
- entries
|
||||
- retentionDays
|
||||
additionalProperties: false
|
||||
description: The owner's record of what somebody else did in their record, bounded by the instance's audit retention.
|
||||
RecordActivityEntry:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
actor:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/AccountGrantParty"
|
||||
- type: "null"
|
||||
description: Who did it. Null when that account has since been deleted — the audit column is deliberately not a foreign
|
||||
key, so history is not rewritten when somebody leaves. Render it as a deleted account; a blank would read as
|
||||
though the owner had done it themselves.
|
||||
action:
|
||||
type: string
|
||||
accesses:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
description: How many times that delegate opened the record on that day, for a coalesced read row. Null for anything
|
||||
else — a single act has no count, and a 1 there would invite adding up numbers that mean different things.
|
||||
createdAt:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- actor
|
||||
- action
|
||||
- accesses
|
||||
- createdAt
|
||||
additionalProperties: false
|
||||
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."
|
||||
AccountGrantListEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -36647,24 +36874,6 @@ components:
|
||||
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:
|
||||
@@ -37272,6 +37481,23 @@ components:
|
||||
- showEvidenceByDefault
|
||||
- defaultWindow
|
||||
additionalProperties: false
|
||||
parameters:
|
||||
AccountSelector:
|
||||
name: X-HealthLog-Account
|
||||
in: header
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
maxLength: 64
|
||||
description: "Act on another account's record for THIS request. Bearer transport only — the browser session carries its
|
||||
switch on the session row, and sending this header alongside a cookie is refused rather than ignored. The value
|
||||
is an `accountId` from `accountAccess.accounts` on GET /api/auth/me. It is a SELECTOR and not a permission: the
|
||||
grant is re-checked on every request, so a revocation lands on the next one rather than at the next login, and a
|
||||
selector naming an account that granted nothing is refused with 403 `sharing.access.denied` — byte-identical to
|
||||
one naming an account that does not exist, so the refusal is not an enumeration oracle. NEVER attach it to the
|
||||
sync engine, device or notification registration, or any auth/refresh call: those are about the person holding
|
||||
the token, not the record being read, and the account payload refuses the header outright for exactly that
|
||||
reason."
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# Sharing a health record with another account
|
||||
|
||||
Two accounts on the same instance can be connected so that one of them
|
||||
can open the other's health record. A parent and an adult child, two
|
||||
partners, somebody keeping an eye on a relative's blood pressure — the
|
||||
case this exists for is a household, not an organisation.
|
||||
|
||||
This page describes what that connection does, what it deliberately does
|
||||
not do, and which of the instance's existing trust properties it leaves
|
||||
exactly as they were.
|
||||
|
||||
## The shape of it
|
||||
|
||||
One account **shares**; the other is **given access**. In the code and in
|
||||
the settings copy these are the owner and the person they invited.
|
||||
|
||||
- The owner opens **Settings → Shared access** and invites somebody by
|
||||
the username or e-mail address that person signs in with. They need an
|
||||
account on this instance already; there is no invite-by-e-mail flow.
|
||||
- **The invitation confers nothing.** Until the other person accepts it,
|
||||
it is an offer sitting in their own settings. Being handed read access
|
||||
to somebody's health record is not something the product imposes
|
||||
silently, and the acceptance — with its timestamp and the accepting IP —
|
||||
is what the row records.
|
||||
- Once accepted, the record shows up in that person's user menu under
|
||||
"Open a record". Opening it reloads the app into the owner's record.
|
||||
- A banner across the top of every page names whose record is open and
|
||||
says it is read-only, with a button back to the person's own. It is
|
||||
loud on purpose: somebody who forgets they are switched will log their
|
||||
own reading into another person's record, and there is no undo for an
|
||||
entry that was never theirs.
|
||||
- Either side can end it at any time. The owner withdraws access; the
|
||||
other person hands it back. Both are one click with no second factor
|
||||
and no typed confirmation — reducing access must never be harder than
|
||||
granting it was.
|
||||
|
||||
## What the other person can and cannot do
|
||||
|
||||
**Read the health record.** Measurements, medications, mood, labs, the
|
||||
illness journal, preventive-care reminders, documents. In this release
|
||||
that is the whole of it: every grant is read-only. The write level exists
|
||||
in the data model so that adding it later is enforcement work rather than
|
||||
a migration, and no invitation can create one today.
|
||||
|
||||
**Nothing about the account around the record.** This is the line the
|
||||
whole feature is built on, and it is drawn on the server:
|
||||
|
||||
| Not reachable | Why it would matter |
|
||||
| -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| Password, passkeys, second factor, recovery codes | Silent takeover, surviving any revocation |
|
||||
| Sessions, trusted devices, sign-out-everywhere | Ending or enumerating the owner's logins |
|
||||
| API tokens, the MCP connector | A durable credential that outlives the access |
|
||||
| Connected services (Withings, Fitbit, WHOOP, Google Health, Polar, Oura, Strava, Nightscout) | Re-pointing a sync, or feeding somebody else's wearable into this record |
|
||||
| Notification channels and push devices | The owner's health alerts redirected to another phone or chat |
|
||||
| Exports, the health-record archive, clinician share links | A one-click copy of everything, or a new door that outlives the access |
|
||||
| Insights and the Coach | AI processing of the owner's data, under the owner's own consent |
|
||||
| Sharing itself | Inviting somebody else, widening the access, or passing it on |
|
||||
| Locale, theme, dashboard layout | Presentation belongs to the person, not to the record |
|
||||
|
||||
The last two rows are worth a sentence each. Sharing is not
|
||||
transferable: somebody given access cannot invite a third person, cannot
|
||||
raise their own level, and cannot hand it on. And display preferences
|
||||
stay with the person at the keyboard, so reading somebody else's record
|
||||
does not change your own theme, language or dashboard.
|
||||
|
||||
The AI row is the one that looks arbitrary and is not. Server-managed AI
|
||||
features send the record's data to a provider under a consent the record's
|
||||
OWNER gave, for their own use. Somebody else triggering that would create
|
||||
a consent-shaped act the owner never made, so those surfaces stay
|
||||
owner-only.
|
||||
|
||||
These are not checks that a page forgot to render. Every route in the
|
||||
product refuses by default while a browser is acting on another account;
|
||||
a route becomes reachable only by declaring it, and the set of declared
|
||||
routes is frozen by a test.
|
||||
|
||||
## Revocation, and what it cannot undo
|
||||
|
||||
Ending access takes effect on the other person's **next request**, not at
|
||||
their next login. The grant is checked against the database on every
|
||||
single request, so there is no cached verdict to wait out. If their
|
||||
browser is sitting inside the record when the owner ends it, the same
|
||||
database transaction puts that browser back into its own account — they
|
||||
land at home rather than on a wall of refusals.
|
||||
|
||||
What revocation cannot do is take back what was already seen. A person
|
||||
who had read access could have read, remembered, screenshotted or written
|
||||
down anything in the record. The product can close the door; it cannot
|
||||
reach through it. Nothing in the interface implies otherwise, and neither
|
||||
does this page.
|
||||
|
||||
Ending access never deletes the record of it. The row stays with the
|
||||
dates and with who ended it, because "who had access, from when to when,
|
||||
and who ended it" is a question a deleted row cannot answer. Re-inviting
|
||||
the same person creates a new row beside the old one.
|
||||
|
||||
## Seeing what happened
|
||||
|
||||
**Settings → Shared access** answers three questions for the owner, at
|
||||
any time: who can open this record, since when, and when they last did.
|
||||
Below that, a list of the days somebody else opened it and how many times
|
||||
— one line per person per day, rather than a line per request.
|
||||
|
||||
That list is bounded by the instance's audit retention, and the page says
|
||||
so with the actual number rather than a hardcoded one. The default is 365
|
||||
days (`AUDIT_LOG_RETENTION_DAYS`); an operator who shortens it shortens
|
||||
this view too, and the sentence under the heading changes to match. An
|
||||
empty list means nobody opened the record within that window — not that
|
||||
nobody ever did.
|
||||
|
||||
## What this does not change about the instance
|
||||
|
||||
Stated plainly, because a sharing feature is exactly the moment somebody
|
||||
re-reads the trust model:
|
||||
|
||||
- **The operator could always read the database.** Anyone with server or
|
||||
database access, and anyone with an admin account on this instance, can
|
||||
reach health data directly. That was true before sharing existed and is
|
||||
unchanged by it. On a household instance the operator is usually one of
|
||||
the people using it; on an instance hosting other people, it is worth
|
||||
saying out loud.
|
||||
- **Encryption at rest is instance-keyed, not per-user.** The encrypted
|
||||
columns are protected by the key in `ENCRYPTION_KEYS`, which belongs to
|
||||
the instance. Sharing does not weaken that, and it never did protect one
|
||||
account's data from the operator of the same instance.
|
||||
- **AI processing stays under the record owner's consent**, because the AI
|
||||
surfaces are not part of what sharing covers.
|
||||
|
||||
## For operators
|
||||
|
||||
Nothing to configure. Sharing is available on every instance, needs no
|
||||
environment variable, and does nothing until one account invites another.
|
||||
|
||||
Two operational notes:
|
||||
|
||||
- **Invitations disclose whether a username exists.** An invitation to a
|
||||
name nobody uses is refused with "no account with that name". That is
|
||||
deliberate: the alternative is a silent pending row that will never be
|
||||
accepted because the owner mistyped. The caller is already
|
||||
authenticated and the endpoint allows ten invitations an hour per
|
||||
account, which bounds how fast the surface could be walked.
|
||||
- **Deleting an account** removes the grants it is party to. The audit
|
||||
rows naming that account as the one who opened somebody's record stay,
|
||||
and render as "a deleted account" — nulling them would turn "your
|
||||
daughter opened your record" into "you opened your record", which is
|
||||
worse than an unresolvable id.
|
||||
Reference in new issue
Block a user