Skip to main content

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 CHECK policy 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

CommitFailure modeDurable fix
03d11fb5OMR presign read did not enter a transaction, so RLS did not receive app.tenant_id.ResultCommandService.generatePresignedUrls(...) is @Transactional(readOnly = true).
64d7dc2cBackend OMR worker calls missed X-Tenant-Slug.OmrServiceClient forwards the tenant slug to grading-svc.
7917d743MVC async export lost tenant/user/MDC/security context and could hit async-dispatch auth failures.TenantAwareTaskDecoratorConfig, AsyncMvcConfig, and async dispatcher permissions preserve context.
8c03ca33Report job polling could not see tenant-scoped job rows.ReportJobService wraps job reads in tenant-bound transactions.
3d11f768Internal chatbot persistence wrote directly without the transactional service boundary.ChatService owns all conversation/message writes under @Transactional.
db393adcRead 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:

LayerSource pathRequired behavior
Thread contextlibs/common/src/main/java/com/lumie/common/tenant/TenantContextHolder.javaHolds tenant slug and tenant id for the current unit of work.
Transaction bindinglibs/common/src/main/java/com/lumie/common/tenant/RlsTenantContextAspect.javaRuns inside Spring transaction advice and binds tenant id for RLS.
Direct transaction bindinglibs/common/src/main/java/com/lumie/common/tenant/RlsTenantTransactionBinder.javaBinds 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

  1. Identify the execution boundary. Decide whether the failing path is HTTP controller, MVC async, @Async, scheduler, RabbitMQ callback, /internal/**, or TransactionTemplate.
  2. Confirm both tenant slug and tenant id are present. A slug alone is not enough for RLS policies that compare tenant_id to current_setting('app.tenant_id', true).
  3. Check whether the database access happens inside @Transactional or a TransactionTemplate that calls RlsTenantTransactionBinder.
  4. If the failing path is async, verify it runs through TenantAwareTaskDecoratorConfig or explicitly re-establishes context, and confirm Spring Security permits DispatcherType.ASYNC.
  5. 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 with TenantContextHolder.withinContext(...).
  6. 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 @Transactional boundary around tenant-scoped repository access.
  • For programmatic TransactionTemplate flows, call RlsTenantTransactionBinder.bindRequiredTenantToCurrentTransaction() inside the transaction before repository access.
  • For async execution, use a decorated executor or explicitly set and clear TenantContextHolder and UserContextHolder.
  • For backend-to-worker calls, forward X-Tenant-Slug and 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,
  • RlsTenantTransactionBinderIntegrationTest still 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 TransactionTemplate gets 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.