Files
HealthLog/docs/self-hosting/account-sharing.md
T
Marc-André Bombeck 12f8265fdb docs: correct claims and anchors the code no longer backs
Fix two dead anchors (the MCP trust model, the README quick start) and the
statements that no longer match the app: a guardian can connect a managed
profile's services, the environment module is on by default and reaches the
air-quality host too, its readable location columns were dropped in 0409,
settings sections carry their current names, the Withings card lives on
/settings/integrations, the TrueNAS start period matches the image, the
deploy check names HEALTHLOG_PROD_URL, and the docs index describes what
self-hosting/ and integrations/ hold.
2026-10-25 12:12:15 +01:00

373 lines
23 KiB
Markdown

# 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.
Since v1.37.0 there is a second arrangement beside it: a **managed
profile**, a health record for somebody who has no account at all. That
half is described further down, under "Records with no login".
This page describes what these arrangements do, what they deliberately do
not do, and which of the instance's existing trust properties they leave
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
what the access admits, 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
An invitation carries two answers: how much of the record it opens, and
what may be done inside it.
### How much of the record
Either the entire record, which is what sharing has always meant and what
every grant written before v1.37.0 carries, or a set of named sections
chosen when the invitation is written:
| Section | What it covers |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| Readings | Weight, blood pressure, pulse, glucose, sleep, workouts and custom metrics |
| Medications | The medication list, its schedules, doses taken, side effects and stock |
| Lab results | Lab results and the analytes behind them |
| Health background | Allergies, family history, the facts in the health profile, visits and the practices behind them, and the vaccination record |
| Illness | Illness episodes and the day logs kept under them |
| Mood and mind | Mood entries and the mental-health questionnaire history |
| Cycle | Cycle tracking |
| Documents | Everything in the document vault |
A section that was not shared is refused exactly the way a record nobody
shared is, so the shape of what was held back is not readable from
outside. Sections are route families and nothing finer: there is no row
filtering, no field filtering and no redaction inside a section that WAS
shared. The invitation screen says this in as many words, because a note
or a document name inside one section can be about any other.
Aggregate surfaces (the dashboard overview, the health score, the daily
digest, every insight) read across sections, so a grant naming sections
never opens one. A figure derived from part of a record reads as a figure
about the person, and it would not be one. Whole-record grants do not all
open them equally either: the daily digest and the dashboard's snapshot
answer at read, while the health score, the dashboard's summary and the
insights need manage.
A stored scope never grows. A section added by a later release is not in
a set somebody ticked before it existed.
### What may be done inside it
Three levels, fixed when the invitation is written. Raising one means
revoking and inviting again, because widening a grant in place would carry
a consent the other person never gave.
| Level | Adds |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Read | Read what the grant opens, and change nothing. |
| Write | Add entries: a reading, a lab result, an analyte, an illness entry, a side effect, a medication, marking a dose taken or skipped, a visit, a practice, and a vaccination dose. Editing, deleting and restoring stay with you. |
| Manage | Change and remove entries, including ones the owner wrote; record the health background; read the insights generated from the record. Always the entire record. |
Some entries are creates only **manage** reaches, and the split runs by
entry rather than by section: an allergy, a family-history entry, a mood
entry, a screener, a cycle day, a water entry and a preventive-care
reminder are all created at manage, in sections a write grant can
otherwise add to. Nothing in the document vault is writable at any level.
One entry needs two sections rather than a higher level. Planning the
booster a vaccination suggests files it as a preventive-care reminder,
which lives under Readings, so a grant that opens Health background alone
logs the dose and is refused the booster. Logging the dose is unaffected
either way, and the app does not offer the booster to a grant that cannot
plan it.
Offering **manage** asks the owner for a fresh second factor if they have
one enrolled, which makes it a browser-only act either way: a Bearer
caller is refused before anything else happens and told to use a browser.
Accepting it has no such restriction. Manage never carries a section
scope, because "they can do anything, but only to part of you" promises a
boundary an edit cannot keep.
Everything a manager does is written to the trail under their own name,
with a verb naming the act rather than a generic "made a change".
**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 |
| Full exports, the encrypted archive, clinician share links | A one-click copy of everything, or a new door that outlives the access |
| Asking an AI provider anything | AI processing of the owner's data, under the owner's own consent |
| Modules, thresholds, units, the record's language and timezone | Configuration of the account, not content of the record |
| 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 |
One export is the exception, and it is deliberate: the doctor report
(`POST /api/export/health-record`) answers at manage on a whole-record
grant. It renders a prepared artefact from a selection the caller declares
rather than handing the record over whole, which is the difference the row
above is drawing. The full exports and the passphrase-encrypted archive
stay with the owner.
This table is about a grant between two accounts. A managed profile is
the one place where some of it is deliberately different, because a record
with no login has nobody else to keep its own configuration: see "Records
with no login" below for exactly which part of it a guardian may reach.
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 a manage grant can READ
insights that were already generated, and nothing a delegate does at any
level causes an outbound provider call or queues a generation job. The
owner's own path is unchanged and is the control that proves the
difference.
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.
## Records with no login
A **managed profile** is a health record for somebody who does not sign
in: a child, or an adult who is cared for. It is a real account row with
no password, no passkey and no e-mail address, and it is never presented
as something to log in as.
- It is created from **Settings → Shared access**, under a fresh second
factor. A passkey counts as one: a guardian who signs in with a passkey,
or confirms it in the dialog the app opens, gets through. An account with
neither a second factor nor a passkey is refused rather than waved
through, because the act creates a permanent management relationship
over a person who cannot object to it.
- Creation takes a display name, optionally a real date of birth, and the
language and timezone the record's own days and reminders are measured
in. A year on its own is never turned into the first of January. The
language belongs to the record, not to the guardian reading it, so the
guardian's own screens stay in their own language.
- The creator becomes the first **guardian** in the same transaction, over
an ordinary accepted MANAGE grant with the profile as grantor. If either
half fails, neither happens: there is no window in which the profile
exists with nobody looking after it.
- A second guardian is invited and accepts exactly like any other
invitation. An accepted guardian grant does not expire; the invitation
token still can.
- **A managed profile can never be left guardian-less.** Revoking,
handing back, deleting a guardian's own account and deleting the
profile all take the same lock on the profile and re-check the count
inside it, so two people cannot both leave at once. The last guardian is
refused, with a message naming the two ways out: add another guardian,
or delete the profile under a fresh second factor. Deleting the profile
deletes everything recorded for it.
- A guardian may keep that record's settings: modules, units, thresholds,
its language and timezone, and its notification preferences. A guardian
can also connect and disconnect the record's Withings, WHOOP, Oura, Polar,
Strava and Google Health from its integrations page; the data lands in the
profile's record, and a profile without its own provider app uses the
guardian's. Fitbit (no new connections), Nightscout and Dawarich cannot be
connected for a profile. Sync, the connection test and provider
credentials are not guardian surfaces: once connected, the record syncs on
its own schedule, and their deep links land on the shared-record
unavailable page rather than on a raw refusal.
- **Its reminders go to its guardians.** A record with no login has no
channels of its own, so medication reminders, measurement reminders,
safety-floor alerts and low-stock alerts fan out to every active
guardian, in that guardian's own language and over the channels that
guardian already configured. Nothing else fans out; every other event
stays on the path it was already on. The payloads carry no take or skip
action, on any channel, so a notification about somebody else's record
cannot be acted on from a lock screen. One guardian suppressing
server-side reminders on their own device does not suppress anybody
else's.
- Delivery attempts record both the record the message is about and the
person it was sent to, so the admin notification diagnostic can answer
"who was this for" and "whose record was it about" separately.
### Handing a record over to its person
Since v1.42 a guardian can hand a managed profile over to the person it
describes, so the record becomes their own account with its whole history.
Nothing is copied or moved; the profile's row gets credentials, loses the
managed marker, and stops fanning reminders out to its guardians.
- A guardian with a fresh second factor creates a one-time link (valid for
one, seven or fourteen days, shown once) and proposes, for each guardian,
no access, view only or manage. Only a keyed hash of the link is stored,
at most one link per profile is open, and any guardian can withdraw it
without a second factor.
- The person opens the link, sees the record's name, the expiry and who
keeps which access, and chooses how they sign in. The claim runs as one
transaction: the link is consumed, the proposals are applied, open
invitations the record offered are withdrawn, and the disclaimer,
onboarding and AI consent are reset so the person gives them themselves.
After the first sign-in they confirm or change each guardian's access.
- How the person signs in depends on the instance:
| Instance | Ways in |
| --------------------------------- | -------------------------- |
| No SSO configured | Password |
| SSO configured, `OIDC_ONLY` unset | Password or single sign-on |
| `OIDC_ONLY="true"` | Single sign-on only |
The guardian's panel and the claim page both show which ways are open.
- **Through single sign-on** (v1.43) the person picks a username and
continues to the identity provider. When they come back, the provider's
`(issuer, sub)` pair is bound to the record in the same transaction, the
address is taken from the provider only when it asserts
`email_verified: true` (otherwise the account has no address), and no
password is set. Refused, with the record left exactly as it was:
a provider identity that already signs in to an account on this instance
(one identity, one account; the refusal is audited on that account as
`auth.oidc.claim_conflict`), a verified address another account holds,
a username taken in the meantime, a link that was used, withdrawn,
replaced or expired while the person was at the provider, and a browser
that is signed in to another account.
- The link never appears in a URL the server builds: the claim page sends it
in request bodies only, and the single-sign-on leg carries a sealed,
ten-minute, HttpOnly cookie that names the link by its id. The `/claim/`
path and `hlp_` tokens are scrubbed from logs.
- The iOS app does not claim. The person claims in a browser and then signs
in to the app as usual (with SSO, through the app's own SSO sign-in).
Audit rows: `managed_profile.handover.created` and `.revoked` on the record,
`managed_profile.claim_sso.started` when a single-sign-on claim starts,
`managed_profile.claimed` and `auth.claim` (with `method: "password"` or
`"sso"`) when it lands, and `managed_profile.handed_over` on each guardian's
own account.
## 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. The
query is bounded to that window, so the sentence is the real limit of the
list rather than a caption over an unbounded one.
Two things the list does not claim, both worth reading before treating it
as an answer:
- **An empty list is not proof that nobody opened the record.** It means
nobody did so within the window, and that any earlier row has been
purged.
- **The list is capped.** It returns at most the hundred most recent rows
inside the window; when it hits that ceiling it says so under the
heading. A busy household can reach the cap in far less time than the
retention window, and the oldest line on screen is then the oldest line
shown, not the oldest that exists.
## 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.
The same is true of managed profiles.
Four 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. Deleting the last guardian's account is
refused while the managed profile would survive it, so an account
deletion cannot strand a record.
- **A managed profile is a user row and appears as one.** It is listed in
the admin user list like any other account, with no e-mail address
beside it and an internal username nobody is ever asked to type. No
login path will accept it: there is no password hash to verify against
and no address to look it up by.
- **The second-factor requirement is not the same on both features, and
the difference is deliberate.** Creating a managed profile, deleting
one, handing it over and inviting a second guardian require a second
factor unconditionally, and a passkey counts as one there: an account
with neither is refused and told to set one up, because those acts
create or end a permanent relationship over somebody who cannot object.
Offering manage access on an ordinary invitation asks for a fresh factor
only from an account with TOTP or a security key, so
an instance where nobody has enrolled a factor can still use every level
of ordinary sharing. All of them are browser-only either way.