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:
@@ -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`) |
|
||||
| [`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 |
|
||||
| [`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`) |
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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})
|
||||
@@ -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"}
|
||||
@@ -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...
|
||||
@@ -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>
|
||||
Executable
+32
@@ -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; }
|
||||
}
|
||||
+42
@@ -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());
|
||||
}
|
||||
}
|
||||
+5
@@ -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> { }
|
||||
+65
@@ -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 ? "" : "¤tSchema=" + 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);
|
||||
}
|
||||
}
|
||||
+59
@@ -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); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user