The tenant isolation layer for Spring Boot + Postgres.
Your query has no WHERE tenant_id. It returns only your tenant's rows anyway.
@WithTenant("acme")
@Test
void oneTenantCannotSeeAnother() {
assertTenantCannotSee("globex");
}Postgres already has Row-Level Security. We don't reinvent it — the database does the enforcing, which is exactly why this is trustworthy. What TenantLayer does is everything around it, which is where every hand-rolled implementation goes wrong.
- Leak-proof connection wiring. The tenant is published onto every connection handed out of the pool. Not reset on return — set on checkout, unconditionally, because a reset that gets skipped once rides a tenant back into the pool and the next borrower inherits it. Setting on checkout means a connection can never be used carrying a stale tenant. It also costs one round trip instead of two.
- Absence means nothing, not everything. With no tenant bound, the setting is written
as the empty string, and the generated policy guards with
nullif(..., '')so it evaluates to NULL and matches no rows. An unauthenticated request reads zero rows, never everyone's. - Session scope, not
SET LOCAL.SET LOCALonly survives inside an explicit transaction, and plenty of reads run in autocommit. - One-shot policy generation.
RlsPolicyGeneratoremits the SQL for you to review and commit as a migration — including the three things hand-written policies forget:FORCE ROW LEVEL SECURITY(without it the table owner bypasses the policy, and apps very often connect as the owner), thenullifguard, and an index on the tenant column (a policy predicate on an unindexed column turns every read into a sequential scan). - A start-up scan that says which tables are not actually protected. The failure this
exists for is silent: a policy nobody applied, or one a later migration dropped, looks
exactly like a policy that works — until it doesn't.
IsolationCheckerreadspg_classandpg_policieson the connection the application really uses and reports the table with RLS off, the one with a policy but withoutFORCE, and the connection that is a superuser and therefore bypasses every policy on the database. Warn-only by default, so it tells you on the way up rather than refusing to start. - Tenant-scoped entity scanning. Finds which tables are tenant-scoped from Hibernate's
runtime metamodel — by
@TenantId, by column convention, with explicit include/exclude for the shared reference table that happens to carry atenant_idaudit column. - Hibernate discriminator strategy.
@TenantIdsupport wired for you, so Hibernate adds the predicate on reads and stamps the column on writes. With no tenant bound the resolver returns the empty string, so work attempted without a tenant does nothing rather than touching everyone's rows.
Use both layers. They are not alternatives:
| Discriminator | Row-level security | |
|---|---|---|
| JPA queries | filtered | filtered |
Native SQL, JdbcTemplate |
not filtered | filtered |
Bulk update / delete |
partly | filtered |
A psql session on the same credentials |
not filtered | filtered |
- Four resolvers out of the box — HTTP header, subdomain, path segment (
/t/{tenant}/…), and a claim from a Spring-Security-validated JWT. - An ordered chain where order is precedence. Put the signed claim first and a spoofed header is never consulted.
- A pluggable SPI.
TenantResolver<S>is one method. Resolve from an API key, an mTLS certificate, a message attribute — define the bean and the autoconfigured chain backs off. - Strict mode, on by default. No resolvable tenant means 400, never a silent default. The alternative returns an empty result set, which reads as "no data" and sends the caller hunting for a bug in their query rather than their request.
- Unscoped paths are listed, not guessed — health checks,
/error, your login endpoint. - The subdomain resolver refuses ambiguity.
www.app.comdoes not become a tenant namedwww; a bare base domain does not resolve; a multi-label prefix is refused rather than guessed at. - Actor and subject are separate from day one.
TenantScopecarries the subject tenant plus an optional actor, group and region — unused in v0.1, present so the MSP and residency models are a feature later rather than a breaking change to every resolver.
A policy can't help if the tenant never reached the thread that opened the connection. When propagation fails nothing throws — the work runs with no tenant, the query returns zero rows, and it surfaces as missing data.
| Boundary | Handled |
|---|---|
| Servlet requests | automatically, with guaranteed unwind in a finally |
@Async / Spring TaskExecutor |
automatically |
Virtual threads (spring.threads.virtual.enabled) |
automatically |
CompletableFuture, hand-rolled pools |
TenantExecutors |
@Scheduled jobs |
TenantTasks.forEachTenant(…) |
Outbound RestTemplate / RestClient |
automatically |
Outbound WebClient |
automatically |
| Outbound Feign | automatically |
| Kafka produce | automatically, via a record header |
| Kafka consume, including batch listeners | automatically |
| MDC / log context | automatically |
Three of those are harder than they look, and each is a bug we found rather than a feature we imagined:
- Virtual threads silently drop the tenant. Setting
spring.threads.virtual.enabled=truemakes Boot build aSimpleAsyncTaskExecutorinstead of aThreadPoolTaskExecutor, and a task decorator registered only for the pooled one vanishes with it. One property, widely recommended, with no mention of tenancy anywhere near it, and every@Asyncmethod loses its tenant. TenantLayer registers both. - A Kafka listener's danger is a retained tenant, not a lost one. Listener containers
process record after record on one long-lived thread. A record carrying no tenant handled
as whoever came before it is a cross-tenant write, and it would never show up as an
empty result. The context is cleared around every record, whether the listener returned,
threw, or went to an error handler — so retries and recoverers unwind too. Batches span
tenants, so
TenantKafka.runAsRecordTenant(record, …)scopes each record individually and refuses one with no tenant header rather than guessing. CompletableFuture.supplyAsync(…)with no executor runs on the common ForkJoinPool, which Spring has never heard of and cannot decorate.TenantExecutors.supplier(…)/.callable(…)/.runnable(…)capture it, andTenantExecutors.wrap(…)covers a wholeExecutororExecutorServiceincludingsubmit,invokeAllandinvokeAny.
Every decorator captures on the submitting thread and restores the worker's previous scope afterwards — pool threads are reused, and a worker that keeps the last task's tenant is the same leak as a connection that does.
Resolution says which tenant a request claims. That is a different question from whether
the caller is entitled to it, and shipping only the first is how a tenancy layer ends up
trusting X-Tenant-ID from the open internet.
- Membership verification checks the resolved tenant against the authenticated
principal — a token claim listing permitted tenants, or a
TENANT_acmeauthority. A token for acme asking for globex gets 403, and the tenant is never bound, so no connection ever carries it. - Absence of a restriction is not permission. A token with no tenant claim grants nothing. An unauthenticated request is a member of nothing.
- Filter ordering adapts by itself. Both features read the
SecurityContext, so the filter moves after Spring Security's chain automatically. Left at its usual near-first position it would find an empty context on every request and fall through to the header it was added to outrank — and nothing would fail. Isolation would just quietly be back to convention. TenantMembershipVerifieris one method. Back it with mTLS, an internal service token, or a membership table.
- A table-backed registry with status, region, group, a datasource reference and arbitrary JSON metadata. Region and group are there from v0.1 deliberately: a registry is the hardest table to change once it holds production rows.
- Deliberately no RLS on it. It is read during resolution, before any tenant is known. A policy here would hide the registry from the code whose job is to consult it.
- Run work for every tenant —
forEachTenant,mapEachTenant,runAs. Suspended tenants are skipped. One tenant's failure does not cancel the rest: every tenant is attempted, and the failures are aggregated into an exception that names them, in stable order, so the alert says which tenants failed rather than that something did. - The scheduler thread is left as it was found. Schedulers pool threads too.
- Onboarding a tenant is one call, and the order is the point.
TenantProvisioning.onboard(id)writes the registry row, runs that tenant's migrations, runs your hooks with the new tenant bound, and only then marks itACTIVE. Seed data written before a tenant is bound fails the policy under row-level security and has no connection at all under database-per-tenant — which is why hooks run inside the context and can use ordinary repositories. APROVISIONINGstatus exists so a half-built tenant never reads as servable, and onboarding is idempotent, so a failed one is resumable rather than wedged. - Hooks are where your application goes.
TenantProvisioningHook— seed rows, a Stripe customer, a search index, a warmed cache. Ordered, and a hook that throws stops the onboarding rather than leaving a tenant that looks ready and is not. - The registry is operable over HTTP.
/actuator/tenantslists, reads, onboards, changes status and removes. Off unless you both enable and expose it, and it inherits whatever already guards your management endpoints. Creating goes through provisioning, so an operator cannot conjure a tenant that skipped its migrations;PROVISIONINGcannot be set by hand; and delete removes the registry row only — never a tenant's data, which is a decision that belongs in your retention policy, not in an HTTP verb.
- A
tenanttag on every observation, with a cap on what it can cost you. Per-tenant latency is the number you want on the day one customer is slow. The trap is cardinality: one tag value per tenant multiplies every timer in the application by your tenant count, and a tenant id that arrives in a header is attacker-controlled — an unbounded tag is a metrics bill anyone can run up. TenantLayer admits the first 100 tenants (configurable) and folds the rest into__other__, keeps__none__distinct from overflow, and holds the cap under concurrent load. Saturation is itself observable, so you find out that you've outgrown the cap from a metric rather than from an invoice. - The tenant in your logs, via MDC, on every thread the context reaches.
assertTenantCannotSee(other)is two-sided on purpose. "A sees none of B's rows" passes trivially against an empty table, so it first proves on a privileged connection that B's rows genuinely exist, refuses to run with no tenant bound, and refuses when the acting tenant is the one you asked about.@WithTenant("acme")on a test or a class.TenantPostgres— Testcontainers fixtures that hand you two DataSources, because the trap is the connection you test through. Testcontainers gives you a superuser, and superusers bypass RLS outright, so a suite written against it passes whether or not the policy works, including after someone deletes it. You get a least-privileged connection for the code under test and a separate privileged one for seeding.- Helpers to create tenant tables with the policy already correct, or deliberately unprotected ones for testing the discriminator on its own.
Java 17 and 21 · Hibernate 6.6 and 7.0 · Spring Boot 3.5 · Postgres. One dependency and two lines of configuration to start.
Spring Security, Kafka, WebFlux, Feign and Testcontainers are all optional — each unlocks the features above and none is required. The library starts without any of them, and that is a tested guarantee, not an intention.
<dependency>
<groupId>io.tenantlayer</groupId>
<artifactId>tenantlayer-spring-boot-starter</artifactId>
<version>0.4.0</version>
</dependency>tenantlayer.resolvers=JWT,HEADER
tenantlayer.membership.enabled=trueThen generate your policies, apply them as a migration, and connect as a role that is not the table owner. Getting started is the ten-minute version; Row-level security is the part to read before you deploy.
Getting started · Row-level security · Isolation strategies · Tenant resolution · Securing resolution · Context propagation · Tenant registry · Onboarding a tenant · Tenant endpoints · The isolation checker · Metrics · Async & threads · Kafka · Recipes · Testing · Configuration
The full index, including the architecture notes and the troubleshooting guide, is in
docs/, and the same guides are on
tenantlayer.io/docs.
Published on Maven Central under Apache 2.0. The current release is the one in the
dependency snippet above; CHANGELOG.md lists what changed and when. Still
0.x, and honestly so: breaking changes may land in any 0.x release and will always be
listed there.
Every isolation claim in this library has been mutation-tested — the assertion is broken deliberately, verified in the compiled bytecode, to confirm the test goes red. A test that cannot fail is not evidence, and twice in this project a surviving mutant proved a passing test was vacuous. Both are in the history.
What it is still honest to call unfinished:
| Status | |
|---|---|
| ThreadLocal + ScopedValue backing | The storage SPI ships with the ThreadLocal implementation. ScopedValue is a preview API until JDK 25 and shipping it would force --enable-preview on every consumer. See Context storage. |
| A control plane you can look at | The library exposes the seams — registry, provisioning, /actuator/tenants, per-tenant metrics — and an application can drive all of them. There is no hosted UI over the top, and across twenty services there is nothing that aggregates them. Tracked on the roadmap. |
Everything below is exercised by the suite.
| Proves | |
|---|---|
IsolationTest |
The ORM emits no tenant predicate; rows are scoped anyway |
PooledConnectionTest |
A recycled connection fails closed, not open |
AsyncPropagationTest |
The tenant survives a thread boundary |
MembershipVerificationTest |
A token for acme cannot act as globex by setting a header |
DiscriminatorStrategyTest |
@TenantId scopes a table Postgres is not protecting |
SchemaGenerationTest |
The generated policy, applied, actually isolates |
KafkaPropagationTest |
The tenant survives a broker, and does not linger on the listener |
VirtualThreadPropagationTest |
Enabling virtual threads does not drop the tenant |
IsolationCheckerTest |
A table with RLS off, and one with a policy but no FORCE, are both reported |
TenantProvisioningTest |
Hooks run with the tenant bound; a failing hook leaves a resumable state |
TenantsEndpointTest |
The endpoint is absent unless enabled and exposed |
TenantMetricsTest |
The cap holds under concurrency, and overflow is not confused with no-tenant |
ShippedFixtureTest |
The published test fixtures work outside this repo |
Verified on Java 17 and 21, Hibernate 6.6 and 7.0, Spring Boot 3.5.
examples/order-service is an ordinary business service that
consumes TenantLayer as a published dependency, not as source — it exists to answer one
question: does this work for somebody who is not TenantLayer?
Grep it for tenancy and there is none. OrderController takes no tenant parameter and
reads no header; OrderRepository is an empty JpaRepository with no findByTenantId.
The SQL Hibernate emits has no tenant predicate in it. Isolation holds anyway.
It also earns its keep as a test: it caught a NoClassDefFoundError that would have broken
startup for most adopters and that this repo's own 100+ tests structurally could not find,
because a library's optional dependencies are always present on its own test classpath.
export JAVA_HOME=/path/to/jdk-17
mvn test # the library, Hibernate 6
mvn test -Phibernate7 # the library, Hibernate 7 + Jakarta Persistence 3.2
mvn install -DskipTests # then the example, which consumes the artifact
cd examples/order-service && mvn test
On JDK 21+ the jdk21 profile activates automatically and adds the virtual-thread tests.
Tests need Docker — Testcontainers starts Postgres, and Kafka for the messaging tests.
What is planned, and what is already shipped. Everything unbuilt is an open issue — start with a good first issue if you are looking for a way in.
CONTRIBUTING.md. One rule matters more than the rest: every isolation claim is mutation-tested. Break it, watch it fail, then fix it.
Apache 2.0.