One clinic cannot see another clinic's data
Two clinics using Elaior share one server and one database. This page sets out which part of that separation the database holds, which part a check before the answer holds, and which part is held by a filter somebody typed into a query. The third is why there is a limits section.
A portal cookie is not a weaker staff session. It is not a session at all
Staff and patients hold different cookies, elaior_session and elaior_patient, and each is validated by its own function reading its own table. A portal token presented to a staff endpoint does not resolve to a lesser account, it resolves to nothing.
SELECT "userId", "expiresAt", "lastSeenAt" FROM "Session" WHERE id = $1
SELECT "accountId", "expiresAt", "lastSeenAt" FROM "PatientSession" WHERE id = $1The two tables cannot describe the same kind of identity even by accident: Session.userId is a foreign key to User, PatientSession.accountId is a foreign key to PatientAccount. A named test plants the same raw token in both tables and shows each validator resolving only its own owner, in both directions.
The primary key of a session row is the sha256 of the cookie value. The raw token, 32 bytes of cryptographic random output in unpadded base64url, is never written down. Both validators fail closed: no row, an expired row, or an unreachable database all produce signed out.
The clinic id is a lookup key, never a filter
When a request names a clinic, that value is used to look up a Membership row for the caller. The join starts at Membership and reaches Clinic through it, so a caller with no membership never causes the Clinic table to be read at all. No such clinic, not your clinic, and your membership was deactivated are one answer from one indexed query.
The assistant inside the app is not a second door onto this. It dispatches through the same Invoke as an HTTP request: one package variable, assigned to the dispatcher when the router is built and called by the assistant with the context the request already resolved. Invoke takes that context rather than a user id and a clinic id, so it cannot forget a parameter it does not accept.
A foreign id must fail identically to an id that does not exist
Answering not found for an id that never existed and forbidden for one belonging to somebody else turns an endpoint into a directory of the database. One test makes 22 comparisons, each run twice, once with a neighbouring clinic's real id and once with an id nobody owns, and requires the two answers to be the same string. Eleven further named cross tenant tests cover the consult, billing, extract, storage, assistant, queue, settings and realtime paths.
What stops the server booting
- A policy row naming an action no handler registered stops the boot, checked before the database is even opened. Eleven rows are exempt because they gate REST routes served outside the dispatcher.
- An action that is neither a read nor a mutation, or is both, stops the boot: the plan gate has no safe default for a name it cannot categorise. Counted statically: 169 registered actions, 107 mutations, 62 reads, no overlap.
- Registering a handler whose action has no policy row is a panic, and so is registering the same name twice. The matrix is flat: five static roles, no hierarchy, no implicit admin bypass, and a row nobody wrote denies everyone.
The patient portal is the same idea, subtracted
The patient side holds no clinic id, no role and no membership. The household is recomputed on every request from the phone number on the session: every alive patient row bearing that phone, at a clinic with the portal switched on. Nothing is cached, so deleting a patient row or switching the portal off takes effect on the next request. A patient id in the URL filters that household rather than authorizing anything.
Limits: where this stops being structural
Everything above is either held by the database or by a check that runs before the answer. Everything below is a place where the separation depends on somebody having written the filter, or where it does not apply at all.
- Clinic isolation is not structural at the database. No row level security, no ORM injecting a tenant filter, no composite foreign key tying a child row to its parent's clinic. It is 414 hand written clinicId predicates across 67 non test Go files. The filter is written in the SQL and the tests are what hold it.
- Nothing statically checks that a query is scoped. No linter and no test reads the SQL for a missing filter, and the boot assertions check the action registry, not query text. The failure mode is on record: nine reads quoting deleted rows were found one screen at a time.
- Some tenancy checks read a row by primary key and compare its clinic in Go rather than filtering in the WHERE clause. The print routes work this way, so an edit dropping that comparison would leak the row instead of returning nothing.
- Four of the five print URLs consult the policy matrix. The fifth, the printer calibration sample, is gated on a hardcoded admin or doctor pair. It carries no patient data, but every route reads the same matrix is not true.
- The plan expired gate refuses writes and keeps every read, at one point in the action pipeline. Print, export, files, the identity endpoint, the live stream, the portal's own writes and the operator console never reach it. It also rests on a hand maintained list of which names are writes.
- Hashing the session token defends a database dump, not a stolen cookie. The hash is a bare unsalted sha256 and the cookie is a bearer string: whoever holds it holds the session for its full 30 days for staff or 90 days for the portal.
- A session with no clinic pinned, or pinned to a membership row deleted outright rather than deactivated, adopts the user's oldest active membership and writes that choice back. Deliberate, but not the same guarantee as never moving clinics.
- The portal's household is the phone number, deliberately. Register that number to a different patient and the new patient's records become visible to whoever holds the portal session on it. There is no per patient consent step inside a household.
- The portal reads clinical data through the staff reader using a synthetic admin actor. Isolation rests on the clinic id and patient id coming from the household, not on that role.
- The platform operator crosses every clinic boundary by design, on an ordinary staff cookie, and a clinic's audit trail is readable only there. Isolation is between clinics, not between a clinic and the operator.
- The web app's route gating is display only: the proxy tests that a cookie string is present. Useful for not showing a doctor the billing screen, worth nothing as a boundary.
- Two tests in the authorization package contradict the code they guard: one compares the 180 row Go matrix against a frozen 168 row seed from a deleted TypeScript stack, and the other still expects reading the audit trail to be allowed inside a clinic.
- Nothing here was executed. Every statement is read from the source, the schema dump and the test bodies, and the counted figures were confirmed by counting rather than by booting the server.