Skip to main content

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:

ComponentResponsibility
CloudRlsPropertiesBinds platform.cloud.rls.enabled.
TenantScopedTransactionManagerExtends JpaTransactionManager; announces the tenant in doBegin.
CloudRlsConfigurationInstalls that transaction manager, gated on the flag.
db/changelog/cloud/changes/001-tenant-row-level-security.yaml107 changesets, context: cloud-rls.

Key rules & invariants

  • ⚠⚠ The flag and the cloud-rls Liquibase context must be turned on together. The policies filter on app.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-rls is deliberately not in the default context list, so a facility instance never applies the policies by accident.
  • ⚠⚠ SET LOCAL, not SET. The pool hands the same physical connection to the next request, and a session-level SET would carry one tenant's identity into another tenant's query — a cross-tenant read produced by the very mechanism meant to prevent one. SET LOCAL is 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, where SET LOCAL silently 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 no matchIfMissing.

⚠⚠ 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.