RLS Tenant Context Missing
Use this guide when a backend path has the correct tenant slug at the HTTP or message boundary, but tenant-scoped database rows still appear invisible or inserts fail under Postgres RLS.
This page is the operational runbook for failures that are discussed more broadly in the RLS migration, chatbot cutover, report-job, and SMS retrospectives.
Symptoms
- A row exists for the tenant, but a backend read returns 404 or an empty result.
- A worker callback, internal endpoint, scheduler, export, or MVC async response loses tenant state.
- A write fails an RLS
WITH CHECKpolicy even though the request carried the expected tenant slug. - A path works in an ordinary controller request but fails in
@Async,StreamingResponseBody,TransactionTemplate, or/internal/**.
Known Failure History
| Commit | Failure mode | Durable fix |
|---|---|---|
03d11fb5 | OMR presign read did not enter a transaction, so RLS did not receive app.tenant_id. | ResultCommandService.generatePresignedUrls(...) is @Transactional(readOnly = true). |
64d7dc2c | Backend OMR worker calls missed X-Tenant-Slug. | OmrServiceClient forwards the tenant slug to grading-svc. |
7917d743 | MVC async export lost tenant/user/MDC/security context and could hit async-dispatch auth failures. | TenantAwareTaskDecoratorConfig, AsyncMvcConfig, and async dispatcher permissions preserve context. |
8c03ca33 | Report job polling could not see tenant-scoped job rows. | ReportJobService wraps job reads in tenant-bound transactions. |
3d11f768 | Internal chatbot persistence wrote directly without the transactional service boundary. | ChatService owns all conversation/message writes under @Transactional. |
db393adc | Read receipt and file download tracking had RLS gaps. | V65__repair_read_receipt_and_file_download_rls.sql repaired tenant policies and services re-bound tenant reads. |
Current Runtime Contract
The active contract has three layers:
| Layer | Source path | Required behavior |
|---|---|---|
| Thread context | libs/common/src/main/java/com/lumie/common/tenant/TenantContextHolder.java | Holds tenant slug and tenant id for the current unit of work. |
| Transaction binding | libs/common/src/main/java/com/lumie/common/tenant/RlsTenantContextAspect.java | Runs inside Spring transaction advice and binds tenant id for RLS. |
| Direct transaction binding | libs/common/src/main/java/com/lumie/common/tenant/RlsTenantTransactionBinder.java | Binds tenant id manually for programmatic TransactionTemplate flows. |
The binding uses:
SELECT set_config('app.tenant_id', ?, true)
The final true makes the value transaction-local. That is PgBouncer transaction-pool compatible, but it also means a repository call outside an active transaction will not reliably carry RLS state.
Diagnosis
- Identify the execution boundary. Decide whether the failing path is HTTP controller, MVC async,
@Async, scheduler, RabbitMQ callback,/internal/**, orTransactionTemplate. - Confirm both tenant slug and tenant id are present. A slug alone is not enough for RLS policies that compare
tenant_idtocurrent_setting('app.tenant_id', true). - Check whether the database access happens inside
@Transactionalor aTransactionTemplatethat callsRlsTenantTransactionBinder. - If the failing path is async, verify it runs through
TenantAwareTaskDecoratorConfigor explicitly re-establishes context, and confirm Spring Security permitsDispatcherType.ASYNC. - If the failing path is
/internal/**, verify the request body or trusted header carries tenant slug and tenant id, and the controller wraps service calls withTenantContextHolder.withinContext(...). - Check logs for false negatives such as
RESULT_NOT_FOUND,Tenant context not set,Tenant ID is not set in context, or RLS policy violations.
Useful source checks:
cd lumie-backend
rg -n "withinContext|bindRequiredTenantToCurrentTransaction|@Transactional|X-Tenant-Slug" \
app/src/main/java modules libs
Fix
- For ordinary service methods, add the correct
@Transactionalboundary around tenant-scoped repository access. - For programmatic
TransactionTemplateflows, callRlsTenantTransactionBinder.bindRequiredTenantToCurrentTransaction()inside the transaction before repository access. - For async execution, use a decorated executor or explicitly set and clear
TenantContextHolderandUserContextHolder. - For backend-to-worker calls, forward
X-Tenant-Slugand include tenant id in callback payloads when the callback must rehydrate RLS context. - For internal chatbot and similar callback surfaces, keep persistence in a transactional application service instead of writing directly from the controller.
Verification Evidence
Collect evidence at both source and runtime levels:
- the failing path has tenant slug and tenant id before database access,
- repository calls run inside an active transaction,
RlsTenantTransactionBinderIntegrationTeststill proves transaction-local cleanup,- the affected endpoint or callback can read the existing tenant row,
- cross-tenant reads remain invisible when the tenant id changes or is absent.
Prevention
- Treat tenant slug as routing context and tenant id as the RLS enforcement value. Most runtime paths need both.
- Do not assume request-thread context survives async execution.
- Do not assume
TransactionTemplategets the RLS aspect automatically. - Keep external I/O outside database transactions, but re-enter a short tenant-bound transaction for every database mutation.
- When a tenant row "disappears", check the transaction/context boundary before changing repository queries.