Cloud tenant isolation (RLS) — How it works
Overview
On a cloud cell one database holds many tenants, so application-level scoping is no longer the only thing standing between them. This engine adds a second, database-enforced layer: Postgres Row-Level Security policies that filter every tenant-scoped table on a session variable, plus the transaction manager that sets that variable.
It is three classes under core/src/main/java/com/zhenus/uhp/api/core/engines/cloud/, and one
Liquibase changelog:
| Component | Responsibility |
|---|---|
CloudRlsProperties | Binds platform.cloud.rls.enabled. |
TenantScopedTransactionManager | Extends JpaTransactionManager; announces the tenant in doBegin. |
CloudRlsConfiguration | Installs that transaction manager, gated on the flag. |
db/changelog/cloud/changes/001-tenant-row-level-security.yaml | 107 changesets, context: cloud-rls. |
Key rules & invariants
- ⚠⚠ The flag and the
cloud-rlsLiquibase context must be turned on together. The policies filter onapp.current_tenant. With the policies applied and the flag off, every query returns zero rows — every patient record disappearing at once, not a degraded mode. With the flag on and the policies absent, it merely sets a variable nobody reads.cloud-rlsis deliberately not in the default context list, so a facility instance never applies the policies by accident. - ⚠⚠
SET LOCAL, notSET. The pool hands the same physical connection to the next request, and a session-levelSETwould carry one tenant's identity into another tenant's query — a cross-tenant read produced by the very mechanism meant to prevent one.SET LOCALis scoped to the transaction and undone at commit or rollback by the database, with no cleanup code to forget. - The hook is
doBegin, not a filter. The variable has to be set inside the transaction that will use it and after the connection is bound. A filter runs outside any transaction, whereSET LOCALsilently does nothing. - ⚠⚠ Work with no session tenant sets nothing, and therefore reads nothing. That is fail-closed, which is the right default, but it is silent. A scheduled job that legitimately spans tenants must open a transaction per tenant with the context set; it cannot assume it sees everything. The symptom of getting this wrong is an empty result, not an error.
- The transaction manager is only installed on a cloud cell. Replacing it changes the behaviour of
every write in the platform, so a facility instance — one tenant, no policies — must never receive the
bean. Hence
@ConditionalOnProperty(havingValue = "true")with nomatchIfMissing.
⚠⚠ Scope: core's schema only
The 107 changesets cover the tenant-scoped tables in core's public schema. Each domain module
(workforce, imaging, hl7, lab, pharmacy, insurance, erp, reporting, dhis2,
patientportal, providerportal) owns its own schema, and those tables have no policies.
That is safe for a cloud cell composed of core plus the directory, which is what the cloud tier is today. It is not safe for a cloud cell that ships a domain module: those tables would fall back to application-level scoping alone, which is exactly the single layer RLS exists to stop being the only one. A cell that ships a domain module needs policies for that module's schema before it holds more than one tenant's data.
Note also the ordering: app/src/main/resources/db/changelog/platform.db.changelog-master.yaml runs
core's master before every module master, so this changelog cannot see module tables even in
principle — they do not exist yet when it runs. Extending coverage means a changelog per module, not a
longer list here.
Related
- Cell residency — refusing a request for a tenant this cell does not serve.
- Cell directory — which cell holds a tenant.
- PII encryption — the cloud's encryption posture (M29-006).