Getting started
1. Add the dependency
<dependency>
<groupId>io.tenantlayer</groupId>
<artifactId>tenantlayer-spring-boot-starter</artifactId>
<version>0.1.0</version>
</dependency>Java 17+, Spring Boot 3.3+, Postgres. Hibernate 6 and 7 are both supported.
2. Configure resolution
tenantlayer.header=X-Tenant-ID
tenantlayer.strict=truestrict=true is the default and should stay that way. A request with no resolvable tenant
is rejected with 400 rather than served with no tenant bound — which would return an empty
result set that reads as "no data" and sends the caller hunting for a bug in their query.
Read Securing resolution before exposing this to the internet. A header is whatever the caller typed. It is safe behind a gateway that overwrites it, and unsafe otherwise.
3. Add the policy to your tables
Generate it rather than writing it by hand:
@Autowired TenantScopedEntityScanner scanner;
@Autowired RlsPolicyGenerator generator;
System.out.println(generator.generate(scanner.scan()));Review the output, commit it as a migration, apply it with Flyway or Liquibase. It is plain SQL — the policies keep working if you remove TenantLayer.
4. Connect as a role that is not the owner
This is the step people skip, and skipping it silently disables everything above.
create role orders_app login password '...';
grant usage on schema public to orders_app;
grant select, insert, update, delete on orders to orders_app;A Postgres superuser bypasses row-level security entirely, and so does the table owner
unless the table has FORCE ROW LEVEL SECURITY (the generator emits it). Connect as a
least-privileged role, or your policies are decorative.
5. Write nothing else
@RestController
@RequestMapping("/orders")
class OrderController {
private final OrderRepository orders;
@GetMapping
List<Order> list() {
return orders.findAll(); // no tenant parameter, no filter, no predicate
}
}findAll() emits select ... from orders with no where tenant_id = ?. The rows come
back scoped because Postgres applies the policy to the connection.
6. Prove it
@Test
@WithTenant("acme")
void oneTenantCannotSeeAnother() {
assertThat(orders.findAll()).isNotEmpty(); // acme can see its own
assertTenantCannotSee("globex"); // and nothing of globex's
}Both halves matter. "Cannot see the other tenant" passes trivially against an empty table;
assertTenantCannotSee refuses to run unless the other tenant genuinely has rows. See
Testing.