Multi Tenancy
Lumie uses shared-schema PostgreSQL multi-tenancy. Tenant isolation is enforced
with Row Level Security in the public schema, not with one schema per academy.
This page is a reference document for the RLS model, the code that binds it,
and the failure modes that matter when adding or changing data access.
Source Paths
| Path | Role |
|---|---|
app/src/main/resources/db/migration/public/V18__rls_baseline.sql | Shared-schema RLS baseline and schema-per-tenant retirement |
app/src/main/resources/db/migration/public/V65__repair_read_receipt_and_file_download_rls.sql | Example forward repair for tables that missed tenant-safe constraints or policies |
libs/common/src/main/java/com/lumie/common/tenant/RlsTenantContextAspect.java | Invokes RLS binding for declarative @Transactional entry points |
libs/common/src/main/java/com/lumie/common/tenant/RlsTenantTransactionBinder.java | Binds app.tenant_id to the active transaction |
libs/common/src/main/java/com/lumie/common/tenant/TenantContextHolder.java | Holds tenant slug and tenant ID on the current thread |
libs/common/src/main/java/com/lumie/common/domain/TenantScopedEntity.java | Auto-populates tenant_id for most tenant-scoped aggregates |
app/src/main/java/com/lumie/app/config/RuntimeDbRoleGuard.java | Fails startup if the runtime DB role can bypass RLS |
app/src/test/java/com/lumie/app/migration/MigrationsRlsIntegrationTest.java | Integration test for migration-chain health and hostile-tenant isolation |
Storage Model
Tenant-scoped tables
- live in
public - carry
tenant_id BIGINT NOT NULL - enable
ROW LEVEL SECURITY - force
FORCE ROW LEVEL SECURITY - define a
tenant_isolationpolicy comparingtenant_idtocurrent_setting('app.tenant_id', true)
Platform-scoped tables
These remain cross-tenant by design and do not use RLS for runtime reads:
tenantsplansevent_publicationshedlock- billing platform tables created in
V28__billing_platform_tables.sql
Special schema
V27__langgraph_schema.sql creates a dedicated langgraph schema for chatbot
checkpoint persistence. That is a technical worker-state exception, not a
tenant-data storage model.
The Load-Bearing Predicate
The migrations use the canonical policy shape. Source anchor:
app/src/main/resources/db/migration/public/V18__rls_baseline.sql.
using (tenant_id = nullif(current_setting('app.tenant_id', true), '')::bigint)
with check (tenant_id = nullif(current_setting('app.tenant_id', true), '')::bigint)
The Java side binds that value after a Spring transaction is active. Source
anchor:
libs/common/src/main/java/com/lumie/common/tenant/RlsTenantTransactionBinder.java.
SELECT set_config('app.tenant_id', ?, true)
RlsTenantTransactionBinder owns this statement. The binder refuses to run
without an active transaction, so is_local=true always means PostgreSQL
clears the setting automatically on commit or rollback.
Runtime Flow
Why Slug And ID Are Separate
tenantSlugis the product-facing identifier used in URLs, JWT claims, logs, object keys, and worker headers.tenantIdis the database-facing identifier used intenant_idcolumns and RLS predicates.
Setting only the slug is insufficient for data access. Source anchor:
libs/common/src/main/java/com/lumie/common/tenant/TenantContextHolder.java.
This is why code that re-enters tenant context should use:
TenantContextHolder.withinContext(slug, tenantId, action)
instead of only setTenant(slug).
Where Context Must Be Re-Established
Current code restores tenant context explicitly in these boundary cases:
examRabbitMQ callback listenershomepagepublic lookup aftercustomId -> tenantresolution/internal/**HMAC-authenticated requests- Spring Modulith listeners that run after the original request thread is gone
- scheduler loops over
TenantService.listActiveTenants() - AI worker callbacks and tool execution paths
Entity And Table Patterns
Most tenant-scoped aggregates extend TenantScopedEntity, which writes
tenant_id at @PrePersist.
Some tables still manage tenant_id directly instead of using the base class.
A current example is modules/staff/domain/entity/StaffPermission.java, which
sets tenant_id explicitly in its own @PrePersist.
Safety Guards And Known Failure Modes
- If
TenantContextHolderhas no tenant ID, tenant-scoped rows are invisible. - If any path tries to bind
app.tenant_idwithout an active transaction,RlsTenantTransactionBinderfails fast before repository access. Without that guard, the GUC could disappear after one statement and RLS would stop seeing the intended tenant. - If a new tenant-scoped table is added without
tenant_id, RLS enablement,FORCE ROW LEVEL SECURITY, and thetenant_isolationpolicy, the table is a cross-tenant leak. - If the runtime DB role has
SUPERUSERorBYPASSRLS, RLS is silently bypassed;RuntimeDbRoleGuardblocks startup to prevent that. V65__repair_read_receipt_and_file_download_rls.sqlshows a real repair pattern for tables that landed without tenant-safe constraints or policies.
Read-Only Replica Does Not Change The Tenancy Model
The backend's read/write split changes which Hikari pool is used, not how tenancy works:
- write and non-transactional code -> primary pool
@Transactional(readOnly = true)-> readonly pool- both pools still rely on the same
app.tenant_idbinding and runtime role
Approved programmatic transaction paths follow the same rule by calling
RlsTenantTransactionBinder inside the transaction before tenant-scoped
queries. The AI module's dedicated readOnlyJdbcTemplate still calls
set_config('app.tenant_id', ?, true) explicitly for raw SQL reads; treat that
as a legacy exception, not a pattern for new data access.
Verification Commands
cd /Users/bluemayne/Projects/Lumie/lumie-backend
./gradlew integrationTest
./gradlew :app:test
./gradlew :libs:common:test
Most relevant tests:
app/src/test/java/com/lumie/app/migration/MigrationsRlsIntegrationTest.javaapp/src/test/java/com/lumie/app/config/RlsTenantTransactionBinderIntegrationTest.javaapp/src/test/java/com/lumie/app/config/RuntimeDbRoleGuardTest.javaapp/src/test/java/com/lumie/app/SmsDispatchServiceWiringTest.javalibs/common/src/test/java/com/lumie/common/tenant/RlsTenantContextAspectIntegrationTest.java