One clinic cannot see another clinic's data

By Abhishek Dogra, Founder, ElaiorLast verified

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 = $1

The 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

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.