Add multitenancy module: Hibernate 7 @TenantId vs schema-per-tenant (shared pool + search_path, pool per tenant), JWT tenant filter, isolation failure modes on PostgreSQL 17 (Hibernate 7.4.14.Final, Spring Boot 4.1.2)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b4VQVEJWetTT1dYzyJ
This commit is contained in:
2026-10-29 05:52:26 +00:00
co-authored by Claude Sonnet 5.5
parent ef2d2aa8ea
commit 98f4ed7411
45 changed files with 1820 additions and 0 deletions
+1
View File
@@ -347,3 +347,4 @@ Modules with their own `pom.xml` that run plain Hibernate without the Spring Boo
| [`locking`](locking/) | Optimistic (`@Version`, retries) vs pessimistic (`PESSIMISTIC_WRITE`, `NOWAIT`, `SKIP LOCKED`) locking, lost updates and deadlocks on H2 (Hibernate `7.4.14.Final`) | | [`locking`](locking/) | Optimistic (`@Version`, retries) vs pessimistic (`PESSIMISTIC_WRITE`, `NOWAIT`, `SKIP LOCKED`) locking, lost updates and deadlocks on H2 (Hibernate `7.4.14.Final`) |
| [`soft-delete`](soft-delete/) | `@SoftDelete` (`DELETED` / `ACTIVE` / `TIMESTAMP`) vs `@SQLDelete` + `@SQLRestriction`: generated SQL, restore, unique constraints, associations, native-query bypass and Spring Data JPA, on H2 and PostgreSQL 17 (Hibernate `7.4.14.Final`; unlike the others this module uses the Spring Boot 4.1.1 parent, for the Spring Data tests) | | [`soft-delete`](soft-delete/) | `@SoftDelete` (`DELETED` / `ACTIVE` / `TIMESTAMP`) vs `@SQLDelete` + `@SQLRestriction`: generated SQL, restore, unique constraints, associations, native-query bypass and Spring Data JPA, on H2 and PostgreSQL 17 (Hibernate `7.4.14.Final`; unlike the others this module uses the Spring Boot 4.1.1 parent, for the Spring Data tests) |
| [`migration-5-to-7`](migration-5-to-7/) | Hibernate `5.6.15.Final` vs `7.4.14.Final`: jar diff, Hibernate 5 code compiled against both, schema defaults, `hbm2ddl` validation of a Hibernate 5 schema, and cheat-sheet snippets | | [`migration-5-to-7`](migration-5-to-7/) | Hibernate `5.6.15.Final` vs `7.4.14.Final`: jar diff, Hibernate 5 code compiled against both, schema defaults, `hbm2ddl` validation of a Hibernate 5 schema, and cheat-sheet snippets |
| [`multitenancy`](multitenancy/) | Hibernate 7 multi-tenancy on Spring Boot 4.1.2: `@TenantId` discriminator vs schema-per-tenant (shared pool with `search_path`, and pool per tenant), tenant from a JWT claim, the `search_path` leak and the no-tenant failure modes, on PostgreSQL 17 (Hibernate `7.4.14.Final`) |
+47
View File
@@ -0,0 +1,47 @@
# multitenancy
Companion module for the ankurm.com article **Multi-Tenancy with Hibernate 7 and Spring Boot 4: `@TenantId` vs Schema-per-Tenant**
(https://ankurm.com/hibernate-multi-tenancy-spring-boot-tenantid-vs-schema/).
Every claim in the article comes from a test here; each test writes what it observed to `output/`. All tests run against a real
PostgreSQL 17 container started by Testcontainers (needs a Docker daemon).
## Versions
| Component | Version | Notes |
|---|---|---|
| Hibernate ORM | `7.4.14.Final` | newest 7.x GA on Maven Central on 2026-10-29; overrides the `7.4.13.Final` Spring Boot 4.1.2 manages |
| Spring Boot | `4.1.2` | parent POM |
| Spring Security (JOSE only) | `7.1.2` | `spring-security-oauth2-jose`, Nimbus JWT; no security filter chain |
| PostgreSQL / driver | `17.11` / `42.7.14` | `postgres:17-alpine`; Testcontainers `2.0.5` |
| Java | `25` (Temurin 25.0.5+8) | |
## Run
```bash
./scripts/run-all.sh # javap dump + mvn clean test; regenerates everything in output/
```
## Layout
| Package | What |
|---|---|
| `tenant` | `TenantContext` (ThreadLocal), `JwtTenantFilter`, `TenantIdResolver` (Hibernate resolver), `TenantHibernateConfig`, `TenantNames` allow-list |
| `discriminator` | `DiscriminatorApp`, `Invoice` with `@TenantId`, repository, controller |
| `schema` | `SchemaApp`, `Invoice` (no tenant column), `SearchPathConnectionProvider` (shared pool; also the deliberately leaky variant), `PoolPerTenantConnectionProvider`, `SchemaProvisioner` |
## Output
| File | What it shows |
|---|---|
| `00-javap.txt` | `javap` of the tenancy types in the shipped jars; Boot's `HibernatePropertiesCustomizer` package |
| `01-api-shape.txt` | packages, generics and setting names by reflection |
| `02-discriminator.txt` | `@TenantId` DDL and SQL, cross-tenant `findById`, bulk HQL, native-SQL bypass, no-tenant and mismatched-tenant failures |
| `03-schema-per-tenant.txt` | schema-per-tenant on one shared pool: SQL, overlapping ids, ghost tenant, hostile name, pool connection count |
| `04-search-path-leak.txt` | a provider that forgets to reset `search_path` vs the correct one |
| `05-pool-per-tenant.txt` | one Hikari pool per tenant: connections per pool in `pg_stat_activity` |
| `06-http-discriminator.txt` / `07-http-schema.txt` | JWT to tenant over real HTTP: 401 / 403 / 404 / 200 cases |
## Not covered (not run)
Row-level security, database-per-tenant (separate servers), L2 cache with multi-tenancy, Envers, Flyway per schema, JWKS/asymmetric
keys, `@Async` / virtual-thread propagation of the tenant, and any load or latency measurement.
+64
View File
@@ -0,0 +1,64 @@
# javap of the shipped hibernate-core-7.4.14.Final.jar
$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.annotations.TenantId
Compiled from "TenantId.java"
public interface org.hibernate.annotations.TenantId extends java.lang.annotation.Annotation {
}
$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.context.spi.CurrentTenantIdentifierResolver
Compiled from "CurrentTenantIdentifierResolver.java"
public interface org.hibernate.context.spi.CurrentTenantIdentifierResolver<T> {
public abstract T resolveCurrentTenantIdentifier();
public abstract boolean validateExistingCurrentSessions();
public default boolean isRoot(T);
}
$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider
Compiled from "MultiTenantConnectionProvider.java"
public interface org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider<T> extends org.hibernate.service.Service, org.hibernate.service.spi.Wrapped {
public abstract java.sql.Connection getAnyConnection() throws java.sql.SQLException;
public abstract void releaseAnyConnection(java.sql.Connection) throws java.sql.SQLException;
public abstract java.sql.Connection getConnection(T) throws java.sql.SQLException;
public default java.sql.Connection getReadOnlyConnection(T) throws java.sql.SQLException;
public abstract void releaseConnection(T, java.sql.Connection) throws java.sql.SQLException;
public default void releaseReadOnlyConnection(T, java.sql.Connection) throws java.sql.SQLException;
public abstract boolean supportsAggressiveRelease();
public default boolean handlesConnectionSchema();
public default boolean handlesConnectionReadOnly();
public default org.hibernate.engine.jdbc.connections.spi.DatabaseConnectionInfo getDatabaseConnectionInfo(org.hibernate.dialect.Dialect);
}
$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.engine.jdbc.connections.spi.AbstractMultiTenantConnectionProvider
Compiled from "AbstractMultiTenantConnectionProvider.java"
public abstract class org.hibernate.engine.jdbc.connections.spi.AbstractMultiTenantConnectionProvider<T> implements org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider<T> {
public org.hibernate.engine.jdbc.connections.spi.AbstractMultiTenantConnectionProvider();
protected abstract org.hibernate.engine.jdbc.connections.spi.ConnectionProvider getAnyConnectionProvider();
protected abstract org.hibernate.engine.jdbc.connections.spi.ConnectionProvider selectConnectionProvider(T);
public java.sql.Connection getAnyConnection() throws java.sql.SQLException;
public void releaseAnyConnection(java.sql.Connection) throws java.sql.SQLException;
public java.sql.Connection getConnection(T) throws java.sql.SQLException;
public void releaseConnection(T, java.sql.Connection) throws java.sql.SQLException;
public boolean supportsAggressiveRelease();
public boolean isUnwrappableAs(java.lang.Class<?>);
public <X> X unwrap(java.lang.Class<X>);
}
$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.cfg.MultiTenancySettings
Compiled from "MultiTenancySettings.java"
public interface org.hibernate.cfg.MultiTenancySettings {
public static final java.lang.String MULTI_TENANT_CONNECTION_PROVIDER;
public static final java.lang.String MULTI_TENANT_IDENTIFIER_RESOLVER;
public static final java.lang.String TENANT_IDENTIFIER_TO_USE_FOR_ANY_KEY;
public static final java.lang.String MULTI_TENANT_SCHEMA_MAPPER;
public static final java.lang.String MULTI_TENANT_CREDENTIALS_MAPPER;
}
# javap of spring-boot-hibernate-4.1.2.jar
$ javap -cp spring-boot-hibernate-4.1.2.jar org.springframework.boot.hibernate.autoconfigure.HibernatePropertiesCustomizer
Compiled from "HibernatePropertiesCustomizer.java"
public interface org.springframework.boot.hibernate.autoconfigure.HibernatePropertiesCustomizer {
public abstract void customize(java.util.Map<java.lang.String, java.lang.Object>);
}
# does spring-boot-hibernate mention tenants at all? (entries containing 'tenant', case-insensitive)
matching files: 0
+20
View File
@@ -0,0 +1,20 @@
# Hibernate 7.4.14.Final, reflected from the classpath
== packages
TenantId -> org.hibernate.annotations.TenantId
CurrentTenantIdentifierResolver -> org.hibernate.context.spi.CurrentTenantIdentifierResolver
MultiTenantConnectionProvider -> org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider
HibernatePropertiesCustomizer -> org.springframework.boot.hibernate.autoconfigure.HibernatePropertiesCustomizer
== generics
CurrentTenantIdentifierResolver type parameters: [T]
MultiTenantConnectionProvider type parameters: [T]
TenantIdResolver implements org.hibernate.context.spi.CurrentTenantIdentifierResolver<java.lang.String>
== setting names (org.hibernate.cfg.MultiTenancySettings)
MULTI_TENANT_IDENTIFIER_RESOLVER = hibernate.tenant_identifier_resolver
MULTI_TENANT_CONNECTION_PROVIDER = hibernate.multi_tenant_connection_provider
TENANT_IDENTIFIER_TO_USE_FOR_ANY_KEY = hibernate.multi_tenant.datasource.identifier_for_any
== @TenantId targets
@java.lang.annotation.Target({METHOD, FIELD})
+43
View File
@@ -0,0 +1,43 @@
# @TenantId on PostgreSQL (PostgreSQL 17.11), Hibernate 7.4.14.Final
== DDL Hibernate generated (read back from the catalog)
column amount numeric nullable=YES
column id bigint nullable=NO
column number character varying nullable=YES
column tenant_id character varying nullable=NO
unique UNIQUE (tenant_id, number)
== insert: nobody sets tenant_id in Java
-- as acme: save(INV-1)
sql> insert into invoice (amount,number,tenant_id) values (?,?,?)
entity.tenantId after save = acme
-- as globex: save(INV-1) - same invoice number, allowed by UNIQUE(tenant_id, number)
sql> insert into invoice (amount,number,tenant_id) values (?,?,?)
ids: acme INV-1=1, globex INV-1=2 (one IDENTITY column shared by all tenants)
== reads are filtered
-- as acme: findAll()
sql> select i1_0.id,i1_0.amount,i1_0.number,i1_0.tenant_id from invoice i1_0 where i1_0.tenant_id = ?
rows = 2, numbers = [INV-1, INV-2]
-- as globex: findById(acme's id 1)
sql> select i1_0.id,i1_0.amount,i1_0.number,i1_0.tenant_id from invoice i1_0 where i1_0.id=? and i1_0.tenant_id = ?
result = Optional.empty
== bulk HQL is scoped too
-- as acme: update Invoice set amount = 0
sql> update invoice i1_0 set amount=0 where i1_0.tenant_id = ?
rows updated = 2 (globex's row untouched)
== what @TenantId does NOT cover: native SQL
-- as acme: native query select count(*) from invoice
result = 3 (acme owns 2 rows; the native query saw every tenant)
== no tenant set
-- strict resolver (throws when TenantContext is empty): repo.findAll() with no tenant
org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction <- caused by com.ankurm.multitenancy.tenant.MissingTenantException: no tenant in TenantContext: refusing to open a tenant-scoped session
-- lenient resolver (returns null): repo.findAll() with no tenant
org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction <- caused by org.hibernate.HibernateException: SessionFactory configured for multi-tenancy, but no tenant identifier specified
== writing a row for a different tenant than the session's
-- as acme: new Invoice with tenantId manually set to globex, saveAndFlush
org.springframework.dao.DataIntegrityViolationException: assigned tenant id differs from current tenant id [globex != acme] for entity com.ankurm.multitenancy.discriminator.Invoice.tenantId <- caused by org.hibernate.PropertyValueException: assigned tenant id differs from current tenant id [globex != acme] for entity com.ankurm.multitenancy.discriminator.Invoice.tenantId
@@ -0,0 +1,38 @@
# schema-per-tenant, ONE shared pool + search_path, PostgreSQL (PostgreSQL 17.11), Hibernate 7.4.14.Final
== provisioning (the application's job, not Hibernate's)
schemas created: acme, globex, hooli, initech, stark, umbrella
-- provision("x; drop schema public cascade")
java.lang.IllegalArgumentException: not an acceptable tenant identifier
== the SQL Hibernate sends is unqualified; the connection decides which schema it hits
-- as acme: save(INV-1)
sql> insert into invoice (amount,number) values (?,?)
-- as acme: findAll()
sql> select i1_0.id,i1_0.amount,i1_0.number from invoice i1_0
rows = 2, numbers = [INV-1, INV-2]
-- as acme, inside the transaction: select current_setting('search_path')
acme
== cross-tenant reads: blocked by the schema, but ids overlap
ids: acme INV-1=1, acme INV-2=2, globex INV-9=1 (each schema has its own identity column)
-- as globex: findById(1) -> INV-9 (acme's id 1 is also globex's id 1: a different row)
-- as globex: findById(2) -> Optional.empty (acme's INV-2 is unreachable)
== native SQL needs no workaround here
-- as acme: native select count(*) from invoice -> 2 (acme's schema only; globex's row is not counted)
== failure modes
-- no tenant set: repo.findAll()
org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction <- caused by com.ankurm.multitenancy.tenant.MissingTenantException: no tenant in TenantContext: refusing to open a tenant-scoped session
-- valid-looking tenant with no schema (ghostco): repo.findAll()
org.springframework.dao.InvalidDataAccessResourceUsageException: JDBC exception executing SQL [ERROR: relation "invoice" does not exist <- caused by org.hibernate.exception.SQLGrammarException: JDBC exception executing SQL [ERROR: relation "invoice" does not exist <- caused by org.postgresql.util.PSQLException: ERROR: relation "invoice" does not exist
-- hostile tenant string reaching the provider: repo.findAll()
org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction <- caused by java.lang.IllegalArgumentException: not an acceptable tenant identifier
== after all that work: what does an unrelated component see on the pool?
-- direct DataSource connection: show search_path -> "$user", public
== connections held by the shared pool after exercising 6 tenants
config: spring.datasource.hikari.maximum-pool-size=5, 6 tenants touched
pg_stat_activity rows with application_name='shared-pool': 5
@@ -0,0 +1,10 @@
# a provider that does not reset search_path on release (tenancy.mode=leaky), pool size 1
== Hibernate finished a request as acme and gave the connection back; now an unrelated component borrows it
-- direct DataSource connection, tenant context empty
show search_path -> acme
select count(*) from invoice -> 1 (acme's data, read with no tenant at all)
== the same experiment with the correct provider (resetOnRelease=true), pool size 1, provider called the way Hibernate calls it
-- after getConnection("acme") + releaseConnection("acme", ..): show search_path -> "$user", public
-- select count(*) from invoice -> java.lang.IllegalStateException: org.postgresql.util.PSQLException: ERROR: relation "invoice" does not exist <- caused by org.postgresql.util.PSQLException: ERROR: relation "invoice" does not exist
@@ -0,0 +1,15 @@
# schema-per-tenant, ONE HikariCP POOL PER TENANT, PostgreSQL (PostgreSQL 17.11)
== connections per pool, read from pg_stat_activity
config: tenancy.pool.max-size=3 (minimumIdle defaults to the same), 6 tenants touched, tenancy.mode=pool-per-tenant
pool-acme 3 connections
pool-any 3 connections
pool-globex 3 connections
pool-hooli 3 connections
pool-initech 3 connections
pool-stark 3 connections
pool-umbrella 3 connections
total across tenant pools: 21
pools created by the provider: 7 (6 tenants + 1 'any' pool used by Hibernate at bootstrap)
server max_connections: 100
search_path of an acme pool connection: acme
@@ -0,0 +1,41 @@
# JWT -> filter -> TenantContext -> @TenantId, real HTTP against the discriminator application
== the filter: who gets in
-- no Authorization header
GET /invoices -> HTTP 401 {"error":"missing bearer token"}
-- garbage token
GET /invoices -> HTTP 401 {"error":"invalid token"}
-- token signed with a different secret
GET /invoices -> HTTP 401 {"error":"invalid token"}
-- expired token (exp = 10 minutes ago)
GET /invoices -> HTTP 401 {"error":"invalid token"}
-- valid signature, no tenant_id claim
GET /invoices -> HTTP 403 {"error":"token carries no valid tenant"}
-- valid signature, tenant_id = "x; drop schema public cascade"
GET /invoices -> HTTP 403 {"error":"token carries no valid tenant"}
-- valid signature, tenant_id = "public" (reserved name)
GET /invoices -> HTTP 403 {"error":"token carries no valid tenant"}
== two tenants, same URL, different data
-- acme creates INV-1
POST /invoices -> HTTP 201 {"id":1,"number":"INV-1","amount":100.00,"tenant":"acme"}
-- acme creates INV-2
POST /invoices -> HTTP 201 {"id":2,"number":"INV-2","amount":250.00,"tenant":"acme"}
-- globex creates INV-9
POST /invoices -> HTTP 201 {"id":3,"number":"INV-9","amount":999.00,"tenant":"globex"}
-- acme lists
GET /invoices -> HTTP 200 [{"id":1,"number":"INV-1","amount":100.00,"tenant":"acme"},{"id":2,"number":"INV-2","amount":250.00,"tenant":"acme"}]
-- globex lists
GET /invoices -> HTTP 200 [{"id":3,"number":"INV-9","amount":999.00,"tenant":"globex"}]
== cross-tenant read by id
-- globex asks for acme's INV-2 by id (2)
GET /invoices/2 -> HTTP 404
-- acme asks for the same id
GET /invoices/2 -> HTTP 200 {"id":2,"number":"INV-2","amount":250.00,"tenant":"acme"}
== a well-formed tenant nobody has onboarded
-- token for tenant "ghostco": GET /invoices
GET /invoices -> HTTP 200 []
-- token for tenant "ghostco": POST /invoices
POST /invoices -> HTTP 201 {"id":4,"number":"INV-G","amount":1.00,"tenant":"ghostco"}
+42
View File
@@ -0,0 +1,42 @@
# JWT -> filter -> TenantContext -> schema-per-tenant (shared pool + search_path), real HTTP
provisioned schemas: acme, globex (ghostco deliberately not provisioned)
== the filter: who gets in
-- no Authorization header
GET /invoices -> HTTP 401 {"error":"missing bearer token"}
-- garbage token
GET /invoices -> HTTP 401 {"error":"invalid token"}
-- token signed with a different secret
GET /invoices -> HTTP 401 {"error":"invalid token"}
-- expired token (exp = 10 minutes ago)
GET /invoices -> HTTP 401 {"error":"invalid token"}
-- valid signature, no tenant_id claim
GET /invoices -> HTTP 403 {"error":"token carries no valid tenant"}
-- valid signature, tenant_id = "x; drop schema public cascade"
GET /invoices -> HTTP 403 {"error":"token carries no valid tenant"}
-- valid signature, tenant_id = "public" (reserved name)
GET /invoices -> HTTP 403 {"error":"token carries no valid tenant"}
== two tenants, same URL, different data
-- acme creates INV-1
POST /invoices -> HTTP 201 {"id":1,"number":"INV-1","amount":100.00,"tenant":"acme"}
-- acme creates INV-2
POST /invoices -> HTTP 201 {"id":2,"number":"INV-2","amount":250.00,"tenant":"acme"}
-- globex creates INV-9
POST /invoices -> HTTP 201 {"id":1,"number":"INV-9","amount":999.00,"tenant":"globex"}
-- acme lists
GET /invoices -> HTTP 200 [{"id":1,"number":"INV-1","amount":100.00,"tenant":"acme"},{"id":2,"number":"INV-2","amount":250.00,"tenant":"acme"}]
-- globex lists
GET /invoices -> HTTP 200 [{"id":1,"number":"INV-9","amount":999.00,"tenant":"globex"}]
== cross-tenant read by id
-- globex asks for acme's INV-2 by id (2)
GET /invoices/2 -> HTTP 404
-- acme asks for the same id
GET /invoices/2 -> HTTP 200 {"id":2,"number":"INV-2","amount":250.00,"tenant":"acme"}
== a well-formed tenant nobody has onboarded
-- token for tenant "ghostco": GET /invoices
GET /invoices -> HTTP 500 <!doctype html><html lang="en"><head><title>HTTP Status 500 – Internal Server Error</title><style type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1...
-- token for tenant "ghostco": POST /invoices
POST /invoices -> HTTP 500 <!doctype html><html lang="en"><head><title>HTTP Status 500 – Internal Server Error</title><style type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1...
+73
View File
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>multitenancy</artifactId>
<version>1.0.0</version>
<name>multitenancy</name>
<description>Hibernate 7 multi-tenancy on Spring Boot 4: @TenantId (discriminator) vs schema-per-tenant (shared pool with search_path, and pool per tenant), tenant taken from a JWT claim</description>
<properties>
<java.version>25</java.version>
<!-- Boot 4.1.2 manages 7.4.13.Final; this module overrides to the newest 7.x GA on Maven Central (checked 2026-10-29). -->
<hibernate.version>7.4.14.Final</hibernate.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- Only the JWT decoder/encoder (Nimbus). No Spring Security filter chain: the tenant filter is hand-written, see JwtTenantFilter. -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>-Xmx768m</argLine>
<forkCount>1</forkCount>
</configuration>
</plugin>
</plugins>
</build>
</project>
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Regenerates every file under output/. Needs JDK 25, Maven 3.9 with access to Maven Central, and a running Docker daemon
# (Testcontainers starts one postgres:17-alpine container shared by all tests).
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p output
CP_FILE="$(mktemp)"
mvn -B -q dependency:build-classpath -Dmdep.outputFile="$CP_FILE" >/dev/null
JAR="$(tr ':' '\n' < "$CP_FILE" | grep 'hibernate-core-')"
BOOT_HIB="$(tr ':' '\n' < "$CP_FILE" | grep 'spring-boot-hibernate-')"
{
echo "# javap of the shipped $(basename "$JAR")"
for c in org.hibernate.annotations.TenantId org.hibernate.context.spi.CurrentTenantIdentifierResolver \
org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider \
org.hibernate.engine.jdbc.connections.spi.AbstractMultiTenantConnectionProvider \
org.hibernate.cfg.MultiTenancySettings; do
echo; echo "\$ javap -cp $(basename "$JAR") $c"
javap -cp "$JAR" "$c" 2>&1 | grep -v '^Picked up'
done
echo; echo "# javap of $(basename "$BOOT_HIB")"
echo "\$ javap -cp $(basename "$BOOT_HIB") org.springframework.boot.hibernate.autoconfigure.HibernatePropertiesCustomizer"
javap -cp "$BOOT_HIB" org.springframework.boot.hibernate.autoconfigure.HibernatePropertiesCustomizer 2>&1 | grep -v '^Picked up'
echo; echo "# does spring-boot-hibernate mention tenants at all? (entries containing 'tenant', case-insensitive)"
rm -rf "$CP_FILE.d"; mkdir "$CP_FILE.d"; (cd "$CP_FILE.d" && unzip -q "$BOOT_HIB")
echo "matching files: $(grep -rli tenant "$CP_FILE.d" | wc -l)"
rm -rf "$CP_FILE.d"
} > output/00-javap.txt
rm -f "$CP_FILE"
mvn -B clean test
ls -1 output
@@ -0,0 +1,8 @@
package com.ankurm.multitenancy.discriminator;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/** Discriminator multi-tenancy: one database, one schema, one table, a tenant_id column maintained by Hibernate. */
@SpringBootApplication(scanBasePackages = {
"com.ankurm.multitenancy.discriminator", "com.ankurm.multitenancy.tenant" })
public class DiscriminatorApp { }
@@ -0,0 +1,40 @@
package com.ankurm.multitenancy.discriminator;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.math.BigDecimal;
import org.hibernate.annotations.TenantId;
/**
* {@code @TenantId} makes Hibernate (a) write the resolver's value into tenant_id on insert and (b) add
* {@code tenant_id = ?} to every HQL / Criteria / find / bulk statement it generates. It does nothing for native SQL.
* Note the unique constraint: it must include tenant_id, otherwise tenant B cannot reuse tenant A's invoice number.
*/
@Entity
@Table(name = "invoice", uniqueConstraints = @UniqueConstraint(columnNames = { "tenant_id", "number" }))
public class Invoice {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@TenantId
private String tenantId;
private String number;
private BigDecimal amount;
protected Invoice() { }
public Invoice(String number, BigDecimal amount) { this.number = number; this.amount = amount; }
public Long getId() { return id; }
public String getTenantId() { return tenantId; }
public void setTenantId(String tenantId) { this.tenantId = tenantId; }
public String getNumber() { return number; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
}
@@ -0,0 +1,42 @@
package com.ankurm.multitenancy.discriminator;
import com.ankurm.multitenancy.tenant.TenantContext;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/invoices")
public class InvoiceController {
public record InvoiceRequest(String number, BigDecimal amount) { }
public record InvoiceView(Long id, String number, BigDecimal amount, String tenant) { }
private final InvoiceRepository repo;
public InvoiceController(InvoiceRepository repo) { this.repo = repo; }
@PostMapping
ResponseEntity<InvoiceView> create(@RequestBody InvoiceRequest req) {
Invoice saved = repo.save(new Invoice(req.number(), req.amount()));
return ResponseEntity.status(201).body(view(saved));
}
@GetMapping
List<InvoiceView> list() { return repo.findAll().stream().map(InvoiceController::view).toList(); }
@GetMapping("/{id}")
ResponseEntity<InvoiceView> one(@PathVariable Long id) {
return repo.findById(id).map(i -> ResponseEntity.ok(view(i))).orElse(ResponseEntity.notFound().build());
}
private static InvoiceView view(Invoice i) {
return new InvoiceView(i.getId(), i.getNumber(), i.getAmount(), TenantContext.get());
}
}
@@ -0,0 +1,5 @@
package com.ankurm.multitenancy.discriminator;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InvoiceRepository extends JpaRepository<Invoice, Long> { }
@@ -0,0 +1,28 @@
package com.ankurm.multitenancy.schema;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
/** No tenant column, no annotation: the tenant is whichever schema the connection points at. */
@Entity
@Table(name = "invoice")
public class Invoice {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String number;
private BigDecimal amount;
protected Invoice() { }
public Invoice(String number, BigDecimal amount) { this.number = number; this.amount = amount; }
public Long getId() { return id; }
public String getNumber() { return number; }
public BigDecimal getAmount() { return amount; }
}
@@ -0,0 +1,42 @@
package com.ankurm.multitenancy.schema;
import com.ankurm.multitenancy.tenant.TenantContext;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/invoices")
public class InvoiceController {
public record InvoiceRequest(String number, BigDecimal amount) { }
public record InvoiceView(Long id, String number, BigDecimal amount, String tenant) { }
private final InvoiceRepository repo;
public InvoiceController(InvoiceRepository repo) { this.repo = repo; }
@PostMapping
ResponseEntity<InvoiceView> create(@RequestBody InvoiceRequest req) {
Invoice saved = repo.save(new Invoice(req.number(), req.amount()));
return ResponseEntity.status(201).body(view(saved));
}
@GetMapping
List<InvoiceView> list() { return repo.findAll().stream().map(InvoiceController::view).toList(); }
@GetMapping("/{id}")
ResponseEntity<InvoiceView> one(@PathVariable Long id) {
return repo.findById(id).map(i -> ResponseEntity.ok(view(i))).orElse(ResponseEntity.notFound().build());
}
private static InvoiceView view(Invoice i) {
return new InvoiceView(i.getId(), i.getNumber(), i.getAmount(), TenantContext.get());
}
}
@@ -0,0 +1,5 @@
package com.ankurm.multitenancy.schema;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InvoiceRepository extends JpaRepository<Invoice, Long> { }
@@ -0,0 +1,65 @@
package com.ankurm.multitenancy.schema;
import com.ankurm.multitenancy.tenant.TenantNames;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider;
import org.hibernate.service.UnknownUnwrapTypeException;
/**
* One HikariCP pool per tenant, created on first use. Each pool's connections are opened with
* {@code currentSchema=<tenant>} in the JDBC URL, so the search_path is fixed for the connection's whole life and
* nothing needs resetting. {@code ApplicationName} makes every pool identifiable in pg_stat_activity.
*/
public class PoolPerTenantConnectionProvider implements MultiTenantConnectionProvider<String>, AutoCloseable {
private final String baseUrl;
private final String user;
private final String password;
private final int maxPoolSize;
private final Map<String, HikariDataSource> pools = new ConcurrentHashMap<>();
public PoolPerTenantConnectionProvider(String baseUrl, String user, String password, int maxPoolSize) {
this.baseUrl = baseUrl;
this.user = user;
this.password = password;
this.maxPoolSize = maxPoolSize;
}
private HikariDataSource pool(String key, String schemaOrNull) {
return pools.computeIfAbsent(key, k -> {
HikariConfig c = new HikariConfig();
String sep = baseUrl.contains("?") ? "&" : "?";
c.setJdbcUrl(baseUrl + sep + "ApplicationName=pool-" + k + (schemaOrNull == null ? "" : "&currentSchema=" + schemaOrNull));
c.setUsername(user);
c.setPassword(password);
c.setMaximumPoolSize(maxPoolSize); // minimumIdle defaults to maximumPoolSize: Hikari opens that many eagerly
c.setPoolName("tenant-" + k);
return new HikariDataSource(c);
});
}
@Override public Connection getAnyConnection() throws SQLException { return pool("any", null).getConnection(); }
@Override public void releaseAnyConnection(Connection c) throws SQLException { c.close(); }
@Override
public Connection getConnection(String tenant) throws SQLException {
return pool(TenantNames.requireValid(tenant), tenant).getConnection();
}
@Override public void releaseConnection(String tenant, Connection c) throws SQLException { c.close(); }
@Override public boolean supportsAggressiveRelease() { return false; }
@Override public boolean isUnwrappableAs(Class<?> type) { return false; }
@Override public <T> T unwrap(Class<T> type) { throw new UnknownUnwrapTypeException(type); }
public int poolCount() { return pools.size(); }
@Override public void close() { pools.values().forEach(HikariDataSource::close); pools.clear(); }
}
@@ -0,0 +1,8 @@
package com.ankurm.multitenancy.schema;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/** Schema-per-tenant: one database, one schema per tenant, the same table in each. The entity has no tenant column. */
@SpringBootApplication(scanBasePackages = {
"com.ankurm.multitenancy.schema", "com.ankurm.multitenancy.tenant" })
public class SchemaApp { }
@@ -0,0 +1,32 @@
package com.ankurm.multitenancy.schema;
import com.ankurm.multitenancy.tenant.TenantNames;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import javax.sql.DataSource;
import org.springframework.stereotype.Component;
/**
* Creates a tenant's schema and tables. Hibernate's hbm2ddl cannot do this per tenant, so onboarding owns it; in a real
* system this is a Flyway/Liquibase run per schema. The name is validated by shape and then quoted.
*/
@Component
public class SchemaProvisioner {
private final DataSource dataSource;
public SchemaProvisioner(DataSource dataSource) { this.dataSource = dataSource; }
public void provision(String tenant) {
String s = TenantNames.quoted(tenant);
try (Connection c = dataSource.getConnection(); Statement st = c.createStatement()) {
st.execute("create schema if not exists " + s);
st.execute("create table if not exists " + s + ".invoice ("
+ "id bigint generated by default as identity primary key, "
+ "number varchar(40) not null unique, "
+ "amount numeric(38,2) not null)");
} catch (SQLException e) {
throw new IllegalStateException("could not provision tenant", e);
}
}
}
@@ -0,0 +1,37 @@
package com.ankurm.multitenancy.schema;
import javax.sql.DataSource;
import org.hibernate.cfg.MultiTenancySettings;
import org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.hibernate.autoconfigure.HibernatePropertiesCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** Picks the connection strategy with {@code tenancy.mode}: search-path (default), leaky (broken on purpose), pool-per-tenant. */
@Configuration
public class SchemaTenancyConfig {
@Bean
@ConditionalOnProperty(name = "tenancy.mode", havingValue = "search-path", matchIfMissing = true)
SearchPathConnectionProvider searchPathProvider(DataSource ds) { return new SearchPathConnectionProvider(ds, true); }
@Bean
@ConditionalOnProperty(name = "tenancy.mode", havingValue = "leaky")
SearchPathConnectionProvider leakyProvider(DataSource ds) { return new SearchPathConnectionProvider(ds, false); }
@Bean
@ConditionalOnProperty(name = "tenancy.mode", havingValue = "pool-per-tenant")
PoolPerTenantConnectionProvider poolPerTenantProvider(
@Value("${spring.datasource.url}") String url,
@Value("${spring.datasource.username}") String user,
@Value("${spring.datasource.password}") String password,
@Value("${tenancy.pool.max-size:3}") int maxSize) {
return new PoolPerTenantConnectionProvider(url, user, password, maxSize);
}
@Bean
HibernatePropertiesCustomizer connectionProviderCustomizer(MultiTenantConnectionProvider<String> provider) {
return props -> props.put(MultiTenancySettings.MULTI_TENANT_CONNECTION_PROVIDER, provider);
}
}
@@ -0,0 +1,59 @@
package com.ankurm.multitenancy.schema;
import com.ankurm.multitenancy.tenant.TenantNames;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import javax.sql.DataSource;
import org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider;
import org.hibernate.service.UnknownUnwrapTypeException;
/**
* ONE connection pool for every tenant. On checkout the connection's search_path is pointed at the tenant's schema
* (pgjdbc's {@code Connection.setSchema} runs {@code SET SESSION search_path TO '<schema>'}); on release it is put back.
* {@code resetOnRelease=false} is the broken variant used by the leak demonstration: the next borrower of that physical
* connection inherits the previous tenant's search_path.
*/
public class SearchPathConnectionProvider implements MultiTenantConnectionProvider<String> {
private final DataSource dataSource;
private final boolean resetOnRelease;
public SearchPathConnectionProvider(DataSource dataSource, boolean resetOnRelease) {
this.dataSource = dataSource;
this.resetOnRelease = resetOnRelease;
}
/** Used by Hibernate at bootstrap (dialect/metadata) and for work not tied to a tenant. */
@Override public Connection getAnyConnection() throws SQLException { return dataSource.getConnection(); }
@Override public void releaseAnyConnection(Connection c) throws SQLException { c.close(); }
@Override
public Connection getConnection(String tenant) throws SQLException {
Connection c = dataSource.getConnection();
try {
c.setSchema(TenantNames.requireValid(tenant));
} catch (SQLException | RuntimeException e) {
c.close();
throw e;
}
return c;
}
@Override
public void releaseConnection(String tenant, Connection c) throws SQLException {
try {
if (resetOnRelease) {
try (Statement st = c.createStatement()) { st.execute("RESET search_path"); }
}
} finally {
c.close();
}
}
@Override public boolean supportsAggressiveRelease() { return false; }
@Override public boolean isUnwrappableAs(Class<?> type) { return false; }
@Override public <T> T unwrap(Class<T> type) { throw new UnknownUnwrapTypeException(type); }
}
@@ -0,0 +1,29 @@
package com.ankurm.multitenancy.tenant;
import java.nio.charset.StandardCharsets;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
/**
* Verifies HS256 tokens with a shared secret so the demo needs no identity provider. In production you would point a
* resource server at your IdP's JWKS endpoint (asymmetric keys) and keep the same filter.
*/
@Configuration
public class JwtConfig {
@Bean
SecretKey jwtSecretKey(@Value("${tenancy.jwt.secret}") String secret) {
return new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
}
@Bean
JwtDecoder jwtDecoder(SecretKey jwtSecretKey) {
// The Nimbus processor built here checks signature, algorithm = HS256, exp and nbf (60 s clock skew by default).
return NimbusJwtDecoder.withSecretKey(jwtSecretKey).macAlgorithm(MacAlgorithm.HS256).build();
}
}
@@ -0,0 +1,67 @@
package com.ankurm.multitenancy.tenant;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* Reads {@code Authorization: Bearer <jwt>}, verifies it, takes the tenant from one claim and puts it in
* {@link TenantContext} for the duration of the request. The finally block matters: servlet threads are pooled, so a
* tenant that is not cleared is inherited by the next request that lands on the same thread.
*
* <p>This is deliberately NOT a full security setup (no authorization rules, no CSRF story, every path needs a token).
* 401 = no/invalid token. 403 = valid token whose tenant claim is missing or has an unacceptable shape.
*/
@Component
public class JwtTenantFilter extends OncePerRequestFilter {
private final JwtDecoder decoder;
private final String claim;
public JwtTenantFilter(JwtDecoder decoder, @Value("${tenancy.jwt.claim:tenant_id}") String claim) {
this.decoder = decoder;
this.claim = claim;
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String header = req.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
reject(res, 401, "missing bearer token");
return;
}
Jwt jwt;
try {
jwt = decoder.decode(header.substring("Bearer ".length()).trim());
} catch (JwtException e) {
reject(res, 401, "invalid token");
return;
}
String tenant = jwt.getClaimAsString(claim);
if (!TenantNames.isValid(tenant)) {
reject(res, 403, "token carries no valid tenant");
return;
}
TenantContext.set(tenant);
try {
chain.doFilter(req, res);
} finally {
TenantContext.clear();
}
}
private static void reject(HttpServletResponse res, int status, String message) throws IOException {
res.setStatus(status);
res.setContentType("application/json");
res.getOutputStream().write(("{\"error\":\"" + message + "\"}").getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,6 @@
package com.ankurm.multitenancy.tenant;
/** Thrown by {@link TenantIdResolver} when a database session is opened with no tenant in {@link TenantContext}. */
public class MissingTenantException extends IllegalStateException {
public MissingTenantException() { super("no tenant in TenantContext: refusing to open a tenant-scoped session"); }
}
@@ -0,0 +1,32 @@
package com.ankurm.multitenancy.tenant;
import java.util.function.Supplier;
/**
* Holds "who is calling" for the current thread. The servlet filter sets it, Hibernate's resolver reads it.
* A ThreadLocal is the simplest correct carrier for a blocking servlet stack; it does NOT follow work onto other
* threads (@Async, CompletableFuture, reactive pipelines) - see the post for what to do there.
*/
public final class TenantContext {
private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();
private TenantContext() { }
/** The current tenant, or {@code null} when none is set. */
public static String get() { return CURRENT.get(); }
public static void set(String tenant) { CURRENT.set(tenant); }
public static void clear() { CURRENT.remove(); }
/** Runs {@code work} as {@code tenant} and restores whatever was set before. */
public static <T> T runAs(String tenant, Supplier<T> work) {
String previous = CURRENT.get();
CURRENT.set(tenant);
try {
return work.get();
} finally {
if (previous == null) CURRENT.remove(); else CURRENT.set(previous);
}
}
}
@@ -0,0 +1,18 @@
package com.ankurm.multitenancy.tenant;
import org.hibernate.cfg.MultiTenancySettings;
import org.springframework.boot.hibernate.autoconfigure.HibernatePropertiesCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* The one line that connects the resolver to Hibernate. In Spring Boot 4 the customizer interface lives in
* {@code org.springframework.boot.hibernate.autoconfigure} (it was {@code ...orm.jpa} in Boot 3).
*/
@Configuration
public class TenantHibernateConfig {
@Bean
HibernatePropertiesCustomizer tenantResolverCustomizer(TenantIdResolver resolver) {
return props -> props.put(MultiTenancySettings.MULTI_TENANT_IDENTIFIER_RESOLVER, resolver);
}
}
@@ -0,0 +1,28 @@
package com.ankurm.multitenancy.tenant;
import org.hibernate.context.spi.CurrentTenantIdentifierResolver;
import org.springframework.stereotype.Component;
/**
* Hibernate 7's {@link CurrentTenantIdentifierResolver} is generic: {@code <T>} is the tenant identifier type
* (here String). Hibernate calls it every time a Session is opened. Registered with
* {@link TenantHibernateConfig}; Spring Boot does not pick it up from the context by itself.
*/
@Component
public class TenantIdResolver implements CurrentTenantIdentifierResolver<String> {
/** true = fail loudly when no tenant is set (the default and the only sane production setting). false = return null. */
private volatile boolean strict = true;
public void setStrict(boolean strict) { this.strict = strict; }
@Override
public String resolveCurrentTenantIdentifier() {
String tenant = TenantContext.get();
if (tenant == null && strict) throw new MissingTenantException();
return tenant;
}
/** true: Hibernate checks that an already-open current Session still belongs to the tenant being resolved. */
@Override
public boolean validateExistingCurrentSessions() { return true; }
}
@@ -0,0 +1,27 @@
package com.ankurm.multitenancy.tenant;
import java.util.Set;
import java.util.regex.Pattern;
/**
* Allow-list for tenant identifiers. The identifier ends up as a PostgreSQL schema name (schema-per-tenant) or a
* JDBC URL parameter (pool-per-tenant), so it is validated by shape instead of being escaped and hoped for.
*/
public final class TenantNames {
private static final Pattern SHAPE = Pattern.compile("[a-z][a-z0-9_]{2,30}");
private static final Set<String> RESERVED = Set.of("public", "information_schema", "pg_catalog", "pg_toast");
private TenantNames() { }
public static boolean isValid(String tenant) {
return tenant != null && SHAPE.matcher(tenant).matches() && !RESERVED.contains(tenant) && !tenant.startsWith("pg_");
}
public static String requireValid(String tenant) {
if (!isValid(tenant)) throw new IllegalArgumentException("not an acceptable tenant identifier");
return tenant;
}
/** Double-quoted identifier, only ever called on a value that passed {@link #requireValid}. */
public static String quoted(String tenant) { return '"' + requireValid(tenant) + '"'; }
}
@@ -0,0 +1,3 @@
# Intentionally small. Datasource, tenancy.mode and tenancy.jwt.secret are supplied by the tests (or your environment).
spring.jpa.open-in-view=true
tenancy.jwt.claim=tenant_id
@@ -0,0 +1,42 @@
package com.ankurm.multitenancy;
import static org.assertj.core.api.Assertions.assertThat;
import java.lang.reflect.ParameterizedType;
import org.hibernate.annotations.TenantId;
import org.hibernate.cfg.MultiTenancySettings;
import org.hibernate.context.spi.CurrentTenantIdentifierResolver;
import org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider;
import org.junit.jupiter.api.Test;
import org.springframework.boot.hibernate.autoconfigure.HibernatePropertiesCustomizer;
import com.ankurm.multitenancy.tenant.TenantIdResolver;
/** What the shipped jars say about the API, by reflection. Complements output/00-javap.txt. */
class A01_ApiShapeTest {
@Test
void apiShape() throws Exception {
Transcript t = new Transcript("01-api-shape.txt");
t.line("# Hibernate " + org.hibernate.Version.getVersionString() + ", reflected from the classpath");
t.heading("packages");
t.line("TenantId -> " + TenantId.class.getName());
t.line("CurrentTenantIdentifierResolver -> " + CurrentTenantIdentifierResolver.class.getName());
t.line("MultiTenantConnectionProvider -> " + MultiTenantConnectionProvider.class.getName());
t.line("HibernatePropertiesCustomizer -> " + HibernatePropertiesCustomizer.class.getName());
t.heading("generics");
t.line("CurrentTenantIdentifierResolver type parameters: " + java.util.Arrays.toString(CurrentTenantIdentifierResolver.class.getTypeParameters()));
t.line("MultiTenantConnectionProvider type parameters: " + java.util.Arrays.toString(MultiTenantConnectionProvider.class.getTypeParameters()));
ParameterizedType pt = (ParameterizedType) TenantIdResolver.class.getGenericInterfaces()[0];
t.line("TenantIdResolver implements " + pt.getTypeName());
t.heading("setting names (org.hibernate.cfg.MultiTenancySettings)");
t.line("MULTI_TENANT_IDENTIFIER_RESOLVER = " + MultiTenancySettings.MULTI_TENANT_IDENTIFIER_RESOLVER);
t.line("MULTI_TENANT_CONNECTION_PROVIDER = " + MultiTenancySettings.MULTI_TENANT_CONNECTION_PROVIDER);
t.line("TENANT_IDENTIFIER_TO_USE_FOR_ANY_KEY = " + MultiTenancySettings.TENANT_IDENTIFIER_TO_USE_FOR_ANY_KEY);
t.heading("@TenantId targets");
t.line(TenantId.class.getAnnotation(java.lang.annotation.Target.class).toString());
t.save();
assertThat(MultiTenancySettings.MULTI_TENANT_IDENTIFIER_RESOLVER).isEqualTo("hibernate.tenant_identifier_resolver");
assertThat(MultiTenancySettings.MULTI_TENANT_CONNECTION_PROVIDER).isEqualTo("hibernate.multi_tenant_connection_provider");
assertThat(CurrentTenantIdentifierResolver.class.getTypeParameters()).hasSize(1);
assertThat(pt.getActualTypeArguments()[0]).isEqualTo(String.class);
}
}
@@ -0,0 +1,150 @@
package com.ankurm.multitenancy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.ankurm.multitenancy.discriminator.DiscriminatorApp;
import com.ankurm.multitenancy.discriminator.Invoice;
import com.ankurm.multitenancy.discriminator.InvoiceRepository;
import com.ankurm.multitenancy.tenant.MissingTenantException;
import com.ankurm.multitenancy.tenant.TenantContext;
import com.ankurm.multitenancy.tenant.TenantIdResolver;
import jakarta.persistence.EntityManager;
import java.math.BigDecimal;
import java.sql.Connection;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.transaction.support.TransactionTemplate;
@SpringBootTest(classes = DiscriminatorApp.class, properties = {
"tenancy.jwt.secret=" + Tokens.SECRET,
"spring.jpa.hibernate.ddl-auto=create",
"spring.jpa.properties.hibernate.session_factory.statement_inspector=com.ankurm.multitenancy.SqlLog" })
class A02_DiscriminatorTest {
static final String URL = Pg.newDatabase("disc_unit");
@DynamicPropertySource
static void db(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", () -> URL);
r.add("spring.datasource.username", Pg::user);
r.add("spring.datasource.password", Pg::password);
}
@Autowired InvoiceRepository repo;
@Autowired TransactionTemplate tx;
@Autowired EntityManager em;
@Autowired TenantIdResolver resolver;
<T> T as(String tenant, Supplier<T> work) { return TenantContext.runAs(tenant, () -> tx.execute(s -> work.get())); }
@Test
void discriminatorBehaviour() throws Exception {
Transcript t = new Transcript("02-discriminator.txt");
t.line("# @TenantId on PostgreSQL (" + Pg.version() + "), Hibernate " + org.hibernate.Version.getVersionString());
t.heading("DDL Hibernate generated (read back from the catalog)");
try (Connection cn = Pg.connect(URL); var rs = cn.createStatement().executeQuery(
"select column_name, data_type, is_nullable from information_schema.columns where table_name='invoice' order by ordinal_position")) {
while (rs.next()) t.line("column " + rs.getString(1) + " " + rs.getString(2) + " nullable=" + rs.getString(3));
}
try (Connection cn = Pg.connect(URL); var rs = cn.createStatement().executeQuery(
"select pg_get_constraintdef(oid) from pg_constraint where conrelid='invoice'::regclass and contype='u'")) {
while (rs.next()) t.line("unique " + rs.getString(1));
}
t.heading("insert: nobody sets tenant_id in Java");
SqlLog.clear();
Invoice a1 = as("acme", () -> repo.save(new Invoice("INV-1", new BigDecimal("100.00"))));
t.step("as acme: save(INV-1)", SqlLog.take());
t.line(" entity.tenantId after save = " + a1.getTenantId());
assertThat(a1.getTenantId()).isEqualTo("acme");
Invoice g1 = as("globex", () -> repo.save(new Invoice("INV-1", new BigDecimal("999.00"))));
t.step("as globex: save(INV-1) - same invoice number, allowed by UNIQUE(tenant_id, number)", SqlLog.take());
as("acme", () -> repo.save(new Invoice("INV-2", new BigDecimal("250.00"))));
SqlLog.clear();
t.line(" ids: acme INV-1=" + a1.getId() + ", globex INV-1=" + g1.getId() + " (one IDENTITY column shared by all tenants)");
t.heading("reads are filtered");
SqlLog.clear();
List<Invoice> mine = as("acme", repo::findAll);
t.step("as acme: findAll()", SqlLog.take());
t.line(" rows = " + mine.size() + ", numbers = " + mine.stream().map(Invoice::getNumber).toList());
assertThat(mine).hasSize(2).allMatch(i -> i.getTenantId().equals("acme"));
SqlLog.clear();
var foreign = as("globex", () -> repo.findById(a1.getId()));
t.step("as globex: findById(acme's id " + a1.getId() + ")", SqlLog.take());
t.line(" result = " + (foreign.isPresent() ? "FOUND (isolation broken)" : "Optional.empty"));
assertThat(foreign).isEmpty();
t.heading("bulk HQL is scoped too");
SqlLog.clear();
int updated = as("acme", () -> em.unwrap(org.hibernate.Session.class).createMutationQuery("update Invoice set amount = 0").executeUpdate());
t.step("as acme: update Invoice set amount = 0", SqlLog.take());
t.line(" rows updated = " + updated + " (globex's row untouched)");
assertThat(updated).isEqualTo(2);
assertThat(as("globex", () -> repo.findById(g1.getId()).orElseThrow().getAmount())).isEqualByComparingTo("999.00");
t.heading("what @TenantId does NOT cover: native SQL");
SqlLog.clear();
Object total = as("acme", () -> em.createNativeQuery("select count(*) from invoice").getSingleResult());
t.line("-- as acme: native query select count(*) from invoice");
t.line(" result = " + total + " (acme owns 2 rows; the native query saw every tenant)");
assertThat(((Number) total).intValue()).isEqualTo(3);
t.heading("no tenant set");
SqlLog.clear();
TenantContext.clear();
Throwable strictFailure = catchThrowable(() -> tx.execute(s -> repo.findAll()));
t.line("-- strict resolver (throws when TenantContext is empty): repo.findAll() with no tenant");
t.line(" " + chain(strictFailure));
assertThat(rootCauses(strictFailure)).anyMatch(e -> e instanceof MissingTenantException);
resolver.setStrict(false);
try {
Throwable lenientFailure = catchThrowable(() -> tx.execute(s -> repo.findAll()));
t.line("-- lenient resolver (returns null): repo.findAll() with no tenant");
t.line(" " + (lenientFailure == null ? "NO EXCEPTION" : chain(lenientFailure)));
assertThat(lenientFailure).isNotNull();
} finally {
resolver.setStrict(true);
}
t.heading("writing a row for a different tenant than the session's");
Throwable mismatch = catchThrowable(() -> as("acme", () -> {
Invoice sneaky = new Invoice("INV-X", BigDecimal.ONE);
sneaky.setTenantId("globex");
return repo.saveAndFlush(sneaky);
}));
t.line("-- as acme: new Invoice with tenantId manually set to globex, saveAndFlush");
t.line(" " + (mismatch == null ? "NO EXCEPTION" : chain(mismatch)));
assertThat(mismatch).isNotNull();
t.save();
}
static Throwable catchThrowable(Runnable r) {
try { r.run(); return null; } catch (Throwable e) { return e; }
}
static List<Throwable> rootCauses(Throwable e) {
List<Throwable> all = new ArrayList<>();
for (Throwable c = e; c != null; c = c.getCause()) all.add(c);
return all;
}
static String chain(Throwable e) {
StringBuilder sb = new StringBuilder();
for (Throwable c : rootCauses(e)) {
if (sb.length() > 0) sb.append(" <- caused by ");
sb.append(c.getClass().getName()).append(": ").append(String.valueOf(c.getMessage()).split("\n")[0]);
}
return sb.toString();
}
}
@@ -0,0 +1,131 @@
package com.ankurm.multitenancy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.ankurm.multitenancy.schema.Invoice;
import com.ankurm.multitenancy.schema.InvoiceRepository;
import com.ankurm.multitenancy.schema.SchemaApp;
import com.ankurm.multitenancy.schema.SchemaProvisioner;
import com.ankurm.multitenancy.tenant.MissingTenantException;
import com.ankurm.multitenancy.tenant.TenantContext;
import jakarta.persistence.EntityManager;
import java.math.BigDecimal;
import java.sql.Connection;
import java.util.List;
import java.util.function.Supplier;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.transaction.support.TransactionTemplate;
@SpringBootTest(classes = SchemaApp.class, properties = {
"tenancy.jwt.secret=" + Tokens.SECRET,
"tenancy.mode=search-path",
"spring.jpa.hibernate.ddl-auto=none",
"spring.datasource.hikari.maximum-pool-size=5",
"spring.jpa.properties.hibernate.session_factory.statement_inspector=com.ankurm.multitenancy.SqlLog" })
class A03_SchemaPerTenantTest {
static final String URL = Pg.newDatabase("schema_unit");
static final String POOLED_URL = URL + "?ApplicationName=shared-pool";
@DynamicPropertySource
static void db(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", () -> POOLED_URL);
r.add("spring.datasource.username", Pg::user);
r.add("spring.datasource.password", Pg::password);
}
@Autowired InvoiceRepository repo;
@Autowired TransactionTemplate tx;
@Autowired EntityManager em;
@Autowired SchemaProvisioner provisioner;
@Autowired DataSource ds;
<T> T as(String tenant, Supplier<T> work) { return TenantContext.runAs(tenant, () -> tx.execute(s -> work.get())); }
@Test
void schemaPerTenantWithSharedPool() throws Exception {
Transcript t = new Transcript("03-schema-per-tenant.txt");
t.line("# schema-per-tenant, ONE shared pool + search_path, PostgreSQL (" + Pg.version() + "), Hibernate " + org.hibernate.Version.getVersionString());
t.heading("provisioning (the application's job, not Hibernate's)");
List<String> tenants = List.of("acme", "globex", "initech", "umbrella", "hooli", "stark");
tenants.forEach(provisioner::provision);
try (Connection cn = Pg.connect(URL)) {
t.line("schemas created: " + Util.scalar(cn,
"select string_agg(schema_name, ', ' order by schema_name) from information_schema.schemata where schema_name !~ '^(pg_|information_schema|public)'"));
}
t.line("-- provision(\"x; drop schema public cascade\")");
Throwable inj = Util.catchThrowable(() -> provisioner.provision("x; drop schema public cascade"));
t.line(" " + Util.chain(inj));
assertThat(inj).isInstanceOf(IllegalArgumentException.class);
t.heading("the SQL Hibernate sends is unqualified; the connection decides which schema it hits");
SqlLog.clear();
Invoice a1 = as("acme", () -> repo.save(new Invoice("INV-1", new BigDecimal("100.00"))));
t.step("as acme: save(INV-1)", SqlLog.take());
as("acme", () -> repo.save(new Invoice("INV-2", new BigDecimal("250.00"))));
Invoice g1 = as("globex", () -> repo.save(new Invoice("INV-9", new BigDecimal("999.00"))));
SqlLog.clear();
List<Invoice> mine = as("acme", repo::findAll);
t.step("as acme: findAll()", SqlLog.take());
t.line(" rows = " + mine.size() + ", numbers = " + mine.stream().map(Invoice::getNumber).toList());
String sp = as("acme", () -> em.createNativeQuery("select current_setting('search_path')").getSingleResult().toString());
t.line("-- as acme, inside the transaction: select current_setting('search_path')");
t.line(" " + sp);
assertThat(sp).contains("acme");
assertThat(mine).hasSize(2);
t.heading("cross-tenant reads: blocked by the schema, but ids overlap");
t.line("ids: acme INV-1=" + a1.getId() + ", acme INV-2=" + (a1.getId() + 1) + ", globex INV-9=" + g1.getId() + " (each schema has its own identity column)");
var viaOwn = as("globex", () -> repo.findById(g1.getId()));
t.line("-- as globex: findById(" + g1.getId() + ") -> " + viaOwn.map(Invoice::getNumber).orElse("empty") + " (acme's id " + a1.getId() + " is also globex's id " + g1.getId() + ": a different row)");
var missing = as("globex", () -> repo.findById(a1.getId() + 1));
t.line("-- as globex: findById(" + (a1.getId() + 1) + ") -> " + missing.map(Invoice::getNumber).orElse("Optional.empty") + " (acme's INV-2 is unreachable)");
assertThat(viaOwn.orElseThrow().getNumber()).isEqualTo("INV-9");
assertThat(missing).isEmpty();
t.heading("native SQL needs no workaround here");
Object total = as("acme", () -> em.createNativeQuery("select count(*) from invoice").getSingleResult());
t.line("-- as acme: native select count(*) from invoice -> " + total + " (acme's schema only; globex's row is not counted)");
assertThat(((Number) total).intValue()).isEqualTo(2);
t.heading("failure modes");
TenantContext.clear();
Throwable none = Util.catchThrowable(() -> tx.execute(s -> repo.findAll()));
t.line("-- no tenant set: repo.findAll()");
t.line(" " + Util.chain(none));
assertThat(Util.causes(none)).anyMatch(e -> e instanceof MissingTenantException);
Throwable ghost = Util.catchThrowable(() -> as("ghostco", () -> repo.findAll()));
t.line("-- valid-looking tenant with no schema (ghostco): repo.findAll()");
t.line(" " + Util.chain(ghost));
assertThat(ghost).isNotNull();
Throwable bad = Util.catchThrowable(() -> as("x; drop schema public cascade", () -> repo.findAll()));
t.line("-- hostile tenant string reaching the provider: repo.findAll()");
t.line(" " + Util.chain(bad));
assertThat(Util.causes(bad)).anyMatch(e -> e instanceof IllegalArgumentException);
t.heading("after all that work: what does an unrelated component see on the pool?");
try (Connection cn = ds.getConnection()) {
t.line("-- direct DataSource connection: show search_path -> " + Util.scalar(cn, "show search_path"));
assertThat(Util.scalar(cn, "show search_path")).doesNotContain("acme").doesNotContain("globex");
}
t.heading("connections held by the shared pool after exercising " + tenants.size() + " tenants");
for (String tenant : tenants) as(tenant, () -> repo.count());
Thread.sleep(1500);
try (Connection cn = Pg.connect(URL)) {
String n = Util.scalar(cn, "select count(*) from pg_stat_activity where datname = current_database() and application_name = 'shared-pool'");
t.line("config: spring.datasource.hikari.maximum-pool-size=5, " + tenants.size() + " tenants touched");
t.line("pg_stat_activity rows with application_name='shared-pool': " + n);
assertThat(Integer.parseInt(n)).isLessThanOrEqualTo(5);
}
t.save();
}
}
@@ -0,0 +1,82 @@
package com.ankurm.multitenancy;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.multitenancy.schema.Invoice;
import com.ankurm.multitenancy.schema.InvoiceRepository;
import com.ankurm.multitenancy.schema.SchemaApp;
import com.ankurm.multitenancy.schema.SchemaProvisioner;
import com.ankurm.multitenancy.schema.SearchPathConnectionProvider;
import com.ankurm.multitenancy.tenant.TenantContext;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.math.BigDecimal;
import java.sql.Connection;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.transaction.support.TransactionTemplate;
/** The classic search_path bug: a provider that forgets to reset the connection when Hibernate gives it back. */
@SpringBootTest(classes = SchemaApp.class, properties = {
"tenancy.jwt.secret=" + Tokens.SECRET,
"tenancy.mode=leaky",
"spring.jpa.hibernate.ddl-auto=none",
"spring.datasource.hikari.maximum-pool-size=1" })
class A04_LeakTest {
static final String URL = Pg.newDatabase("leak_unit");
@DynamicPropertySource
static void db(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", () -> URL);
r.add("spring.datasource.username", Pg::user);
r.add("spring.datasource.password", Pg::password);
}
@Autowired InvoiceRepository repo;
@Autowired TransactionTemplate tx;
@Autowired SchemaProvisioner provisioner;
@Autowired DataSource ds;
@Test
void leakyProviderLetsTheNextBorrowerInheritTheTenant() throws Exception {
Transcript t = new Transcript("04-search-path-leak.txt");
t.line("# a provider that does not reset search_path on release (tenancy.mode=leaky), pool size 1");
provisioner.provision("acme");
TenantContext.runAs("acme", () -> tx.execute(s -> repo.save(new Invoice("INV-1", new BigDecimal("100.00")))));
t.heading("Hibernate finished a request as acme and gave the connection back; now an unrelated component borrows it");
try (Connection cn = ds.getConnection()) {
String sp = Util.scalar(cn, "show search_path");
String rows = Util.scalar(cn, "select count(*) from invoice");
t.line("-- direct DataSource connection, tenant context empty");
t.line(" show search_path -> " + sp);
t.line(" select count(*) from invoice -> " + rows + " (acme's data, read with no tenant at all)");
assertThat(sp).contains("acme");
assertThat(rows).isEqualTo("1");
}
t.heading("the same experiment with the correct provider (resetOnRelease=true), pool size 1, provider called the way Hibernate calls it");
HikariConfig c = new HikariConfig();
c.setJdbcUrl(URL);
c.setUsername(Pg.user());
c.setPassword(Pg.password());
c.setMaximumPoolSize(1);
try (HikariDataSource single = new HikariDataSource(c)) {
SearchPathConnectionProvider good = new SearchPathConnectionProvider(single, true);
Connection used = good.getConnection("acme");
good.releaseConnection("acme", used);
try (Connection cn = single.getConnection()) {
String sp = Util.scalar(cn, "show search_path");
t.line("-- after getConnection(\"acme\") + releaseConnection(\"acme\", ..): show search_path -> " + sp);
assertThat(sp).doesNotContain("acme");
Throwable e = Util.catchThrowable(() -> Util.scalar(cn, "select count(*) from invoice"));
t.line("-- select count(*) from invoice -> " + Util.chain(e).split("\n")[0]);
assertThat(e).isNotNull();
}
}
t.save();
}
}
@@ -0,0 +1,82 @@
package com.ankurm.multitenancy;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.multitenancy.schema.Invoice;
import com.ankurm.multitenancy.schema.InvoiceRepository;
import com.ankurm.multitenancy.schema.PoolPerTenantConnectionProvider;
import com.ankurm.multitenancy.schema.SchemaApp;
import com.ankurm.multitenancy.schema.SchemaProvisioner;
import com.ankurm.multitenancy.tenant.TenantContext;
import java.math.BigDecimal;
import java.sql.Connection;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.transaction.support.TransactionTemplate;
@SpringBootTest(classes = SchemaApp.class, properties = {
"tenancy.jwt.secret=" + Tokens.SECRET,
"tenancy.mode=pool-per-tenant",
"tenancy.pool.max-size=3",
"spring.jpa.hibernate.ddl-auto=none",
"spring.datasource.hikari.maximum-pool-size=2" })
class A05_PoolPerTenantTest {
static final String URL = Pg.newDatabase("pools_unit");
@DynamicPropertySource
static void db(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", () -> URL);
r.add("spring.datasource.username", Pg::user);
r.add("spring.datasource.password", Pg::password);
}
@Autowired InvoiceRepository repo;
@Autowired TransactionTemplate tx;
@Autowired SchemaProvisioner provisioner;
@Autowired PoolPerTenantConnectionProvider provider;
@Test
void onePoolPerTenant() throws Exception {
Transcript t = new Transcript("05-pool-per-tenant.txt");
t.line("# schema-per-tenant, ONE HikariCP POOL PER TENANT, PostgreSQL (" + Pg.version() + ")");
List<String> tenants = List.of("acme", "globex", "initech", "umbrella", "hooli", "stark");
tenants.forEach(provisioner::provision);
for (String tenant : tenants) {
TenantContext.runAs(tenant, () -> tx.execute(s -> repo.save(new Invoice("INV-1", new BigDecimal("1.00")))));
}
TenantContext.runAs("acme", () -> tx.execute(s -> repo.save(new Invoice("INV-2", new BigDecimal("2.00")))));
// Hikari fills each pool up to minimumIdle (= maximumPoolSize) in the background; wait until the count stops moving.
int previous = -1, now = 0;
for (int i = 0; i < 20 && now != previous; i++) {
previous = now;
Thread.sleep(1000);
try (Connection cn = Pg.connect(URL)) {
now = Integer.parseInt(Util.scalar(cn, "select count(*) from pg_stat_activity where datname = current_database() and application_name like 'pool-%'"));
}
}
t.heading("connections per pool, read from pg_stat_activity");
t.line("config: tenancy.pool.max-size=3 (minimumIdle defaults to the same), " + tenants.size() + " tenants touched, tenancy.mode=pool-per-tenant");
try (Connection cn = Pg.connect(URL); var st = cn.createStatement();
var rs = st.executeQuery("select application_name, count(*), max(current_setting('max_connections')) from pg_stat_activity "
+ "where datname = current_database() and application_name like 'pool-%' group by 1 order by 1")) {
while (rs.next()) t.line(String.format("%-16s %s connections", rs.getString(1), rs.getString(2)));
}
t.line("total across tenant pools: " + now);
t.line("pools created by the provider: " + provider.poolCount() + " (" + tenants.size() + " tenants + 1 'any' pool used by Hibernate at bootstrap)");
try (Connection cn = Pg.connect(URL)) {
t.line("server max_connections: " + Util.scalar(cn, "show max_connections"));
t.line("search_path of an acme pool connection: " + TenantContext.runAs("acme", () -> tx.execute(s -> {
try (Connection c = provider.getConnection("acme")) { return Util.scalar(c, "show search_path"); }
catch (Exception e) { throw new IllegalStateException(e); }
})));
}
assertThat(provider.poolCount()).isEqualTo(tenants.size() + 1);
assertThat(now).isGreaterThan(5);
t.save();
}
}
@@ -0,0 +1,32 @@
package com.ankurm.multitenancy;
import com.ankurm.multitenancy.discriminator.DiscriminatorApp;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
@SpringBootTest(classes = DiscriminatorApp.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
"tenancy.jwt.secret=" + Tokens.SECRET,
"spring.jpa.hibernate.ddl-auto=create" })
class A06_HttpDiscriminatorTest {
static final String URL = Pg.newDatabase("http_disc");
@DynamicPropertySource
static void db(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", () -> URL);
r.add("spring.datasource.username", Pg::user);
r.add("spring.datasource.password", Pg::password);
}
@LocalServerPort int port;
@Test
void jwtToTenantOverHttp() throws Exception {
Transcript t = new Transcript("06-http-discriminator.txt");
t.line("# JWT -> filter -> TenantContext -> @TenantId, real HTTP against the discriminator application");
new HttpFlow(port, t).run(false);
t.save();
}
}
@@ -0,0 +1,39 @@
package com.ankurm.multitenancy;
import com.ankurm.multitenancy.schema.SchemaApp;
import com.ankurm.multitenancy.schema.SchemaProvisioner;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
@SpringBootTest(classes = SchemaApp.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
"tenancy.jwt.secret=" + Tokens.SECRET,
"tenancy.mode=search-path",
"spring.jpa.hibernate.ddl-auto=none" })
class A07_HttpSchemaTest {
static final String URL = Pg.newDatabase("http_schema");
@DynamicPropertySource
static void db(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", () -> URL);
r.add("spring.datasource.username", Pg::user);
r.add("spring.datasource.password", Pg::password);
}
@LocalServerPort int port;
@Autowired SchemaProvisioner provisioner;
@Test
void jwtToTenantOverHttp() throws Exception {
Transcript t = new Transcript("07-http-schema.txt");
t.line("# JWT -> filter -> TenantContext -> schema-per-tenant (shared pool + search_path), real HTTP");
provisioner.provision("acme");
provisioner.provision("globex");
t.line("provisioned schemas: acme, globex (ghostco deliberately not provisioned)");
new HttpFlow(port, t).run(true);
t.save();
}
}
@@ -0,0 +1,82 @@
package com.ankurm.multitenancy;
import static org.assertj.core.api.Assertions.assertThat;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** The same HTTP scenario run against either application: tokens in, status codes and bodies out. */
final class HttpFlow {
private final HttpClient client = HttpClient.newHttpClient();
private final String base;
private final Transcript t;
HttpFlow(int port, Transcript t) { this.base = "http://localhost:" + port; this.t = t; }
record Res(int status, String body) { }
Res call(String label, String method, String path, String token, String json) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(base + path));
if (token != null) b.header("Authorization", "Bearer " + token);
if (json != null) b.header("Content-Type", "application/json");
b.method(method, json == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(json));
HttpResponse<String> r = client.send(b.build(), HttpResponse.BodyHandlers.ofString());
String body = r.body() == null ? "" : r.body().replaceAll("\\s+", " ");
if (body.length() > 160) body = body.substring(0, 160) + "...";
t.line("-- " + label);
t.line(" " + method + " " + path + " -> HTTP " + r.statusCode() + " " + body);
return new Res(r.statusCode(), r.body());
}
static long idOf(Res r) {
Matcher m = Pattern.compile("\"id\":(\\d+)").matcher(r.body());
assertThat(m.find()).isTrue();
return Long.parseLong(m.group(1));
}
void run(boolean tenantsMustBeProvisioned) throws Exception {
String acme = Tokens.mint("acme");
String globex = Tokens.mint("globex");
t.heading("the filter: who gets in");
assertThat(call("no Authorization header", "GET", "/invoices", null, null).status()).isEqualTo(401);
assertThat(call("garbage token", "GET", "/invoices", "not.a.jwt", null).status()).isEqualTo(401);
assertThat(call("token signed with a different secret", "GET", "/invoices",
Tokens.mint("some-other-secret-some-other-secret-0123456789", "acme", Duration.ofMinutes(5)), null).status()).isEqualTo(401);
assertThat(call("expired token (exp = 10 minutes ago)", "GET", "/invoices",
Tokens.mint(Tokens.SECRET, "acme", Duration.ofMinutes(-10)), null).status()).isEqualTo(401);
assertThat(call("valid signature, no tenant_id claim", "GET", "/invoices", Tokens.mint(Tokens.SECRET, null, Duration.ofMinutes(5)), null).status()).isEqualTo(403);
assertThat(call("valid signature, tenant_id = \"x; drop schema public cascade\"", "GET", "/invoices",
Tokens.mint(Tokens.SECRET, "x; drop schema public cascade", Duration.ofMinutes(5)), null).status()).isEqualTo(403);
assertThat(call("valid signature, tenant_id = \"public\" (reserved name)", "GET", "/invoices",
Tokens.mint(Tokens.SECRET, "public", Duration.ofMinutes(5)), null).status()).isEqualTo(403);
t.heading("two tenants, same URL, different data");
Res a = call("acme creates INV-1", "POST", "/invoices", acme, "{\"number\":\"INV-1\",\"amount\":100.00}");
assertThat(a.status()).isEqualTo(201);
Res a2 = call("acme creates INV-2", "POST", "/invoices", acme, "{\"number\":\"INV-2\",\"amount\":250.00}");
assertThat(call("globex creates INV-9", "POST", "/invoices", globex, "{\"number\":\"INV-9\",\"amount\":999.00}").status()).isEqualTo(201);
Res la = call("acme lists", "GET", "/invoices", acme, null);
assertThat(la.body()).contains("INV-1").contains("INV-2").doesNotContain("INV-9");
Res lg = call("globex lists", "GET", "/invoices", globex, null);
assertThat(lg.body()).contains("INV-9").doesNotContain("INV-1").doesNotContain("INV-2");
t.heading("cross-tenant read by id");
long acmeSecond = idOf(a2);
assertThat(call("globex asks for acme's INV-2 by id (" + acmeSecond + ")", "GET", "/invoices/" + acmeSecond, globex, null).status()).isEqualTo(404);
assertThat(call("acme asks for the same id", "GET", "/invoices/" + acmeSecond, acme, null).status()).isEqualTo(200);
t.heading("a well-formed tenant nobody has onboarded");
Res ghost = call("token for tenant \"ghostco\": GET /invoices", "GET", "/invoices", Tokens.mint("ghostco"), null);
if (tenantsMustBeProvisioned) assertThat(ghost.status()).isEqualTo(500);
else assertThat(ghost.status()).isEqualTo(200);
Res ghostWrite = call("token for tenant \"ghostco\": POST /invoices", "POST", "/invoices", Tokens.mint("ghostco"), "{\"number\":\"INV-G\",\"amount\":1.00}");
if (tenantsMustBeProvisioned) assertThat(ghostWrite.status()).isEqualTo(500);
else assertThat(ghostWrite.status()).isEqualTo(201);
}
}
@@ -0,0 +1,50 @@
package com.ankurm.multitenancy;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;
import org.testcontainers.postgresql.PostgreSQLContainer;
/** One PostgreSQL 17 container for the whole test run; every test class gets its own empty database inside it. */
public final class Pg {
public static final String IMAGE = "postgres:17-alpine";
private static PostgreSQLContainer container;
private Pg() { }
private static synchronized PostgreSQLContainer c() {
if (container == null) {
container = new PostgreSQLContainer(IMAGE);
container.start();
Runtime.getRuntime().addShutdownHook(new Thread(() -> container.stop()));
}
return container;
}
/** Creates database {@code name} and returns its JDBC URL (no parameters). */
public static String newDatabase(String name) {
PostgreSQLContainer p = c();
try (Connection cn = DriverManager.getConnection(p.getJdbcUrl(), p.getUsername(), p.getPassword());
Statement st = cn.createStatement()) {
st.execute("create database " + name);
} catch (SQLException e) { throw new IllegalStateException(e); }
return p.getJdbcUrl().replaceFirst("/[^/?]+(\\?.*)?$", "/" + name);
}
public static String user() { return c().getUsername(); }
public static String password() { return c().getPassword(); }
public static Connection connect(String url) {
try { return DriverManager.getConnection(url, user(), password()); }
catch (SQLException e) { throw new IllegalStateException(e); }
}
public static String version() {
try (Connection cn = connect(c().getJdbcUrl()); var rs = cn.createStatement().executeQuery("select version()")) {
rs.next();
return rs.getString(1).split(" on ")[0];
} catch (SQLException e) { throw new IllegalStateException(e); }
}
}
@@ -0,0 +1,17 @@
package com.ankurm.multitenancy;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import org.hibernate.resource.jdbc.spi.StatementInspector;
/** Hibernate statement inspector that records every SQL string. Registered by the tests via a Hibernate property. */
public class SqlLog implements StatementInspector {
private static final List<String> LOG = new CopyOnWriteArrayList<>();
@Override
public String inspect(String sql) { LOG.add(sql); return sql; }
public static void clear() { LOG.clear(); }
public static List<String> take() { List<String> copy = List.copyOf(LOG); LOG.clear(); return copy; }
}
@@ -0,0 +1,32 @@
package com.ankurm.multitenancy;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
/** Stands in for the identity provider: mints HS256 tokens. */
public final class Tokens {
public static final String SECRET = "demo-secret-demo-secret-demo-secret-0123456789";
private Tokens() { }
public static String mint(String secret, String tenantOrNull, Duration validFor) {
SecretKey key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
NimbusJwtEncoder encoder = NimbusJwtEncoder.withSecretKey(key).build();
Instant now = Instant.now();
// The encoder refuses exp <= iat, so an already-expired token needs an iat in the past as well.
Instant issued = validFor.isNegative() ? now.plus(validFor.multipliedBy(2)) : now;
JwtClaimsSet.Builder claims = JwtClaimsSet.builder().subject("user-1").issuedAt(issued).expiresAt(now.plus(validFor));
if (tenantOrNull != null) claims.claim("tenant_id", tenantOrNull);
return encoder.encode(JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), claims.build())).getTokenValue();
}
public static String mint(String tenant) { return mint(SECRET, tenant, Duration.ofMinutes(5)); }
}
@@ -0,0 +1,33 @@
package com.ankurm.multitenancy;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/** Collects what a test observed and writes it to {@code output/NN-name.txt}; the tests assert the same facts. */
public final class Transcript {
private final StringBuilder sb = new StringBuilder();
private final String file;
public Transcript(String file) { this.file = file; }
public Transcript line(String s) { sb.append(s).append('\n'); return this; }
public Transcript heading(String s) { return line("").line("== " + s); }
/** A step: the label, then every SQL statement Hibernate issued during it. */
public Transcript step(String label, List<String> sql) {
line("-- " + label);
if (sql.isEmpty()) line(" (no SQL)");
for (String s : sql) line(" sql> " + s.replaceAll("\\s+", " ").trim());
return this;
}
public void save() throws IOException {
Path out = Path.of("output", file);
Files.createDirectories(out.getParent());
Files.writeString(out, sb.toString(), StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,41 @@
package com.ankurm.multitenancy;
import java.sql.Connection;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
/** Small helpers shared by the tests. */
final class Util {
private Util() { }
static Throwable catchThrowable(Runnable r) {
try { r.run(); return null; } catch (Throwable e) { return e; }
}
static List<Throwable> causes(Throwable e) {
List<Throwable> all = new ArrayList<>();
for (Throwable c = e; c != null; c = c.getCause()) all.add(c);
return all;
}
static String chain(Throwable e) {
StringBuilder sb = new StringBuilder();
for (Throwable c : causes(e)) {
if (sb.length() > 0) sb.append(" <- caused by ");
sb.append(c.getClass().getName()).append(": ").append(String.valueOf(c.getMessage()).split("\n")[0]);
}
return sb.toString();
}
static String scalar(Connection cn, String sql) {
try (var st = cn.createStatement(); var rs = st.executeQuery(sql)) {
rs.next();
return rs.getString(1);
} catch (java.sql.SQLException e) { throw new IllegalStateException(e); }
}
static <T> T tryOr(Supplier<T> s, java.util.function.Function<Throwable, T> onError) {
try { return s.get(); } catch (Throwable e) { return onError.apply(e); }
}
}