diff --git a/README.md b/README.md index 2d15ffb..d1f4549 100755 --- a/README.md +++ b/README.md @@ -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`) | diff --git a/multitenancy/README.md b/multitenancy/README.md new file mode 100644 index 0000000..542ea73 --- /dev/null +++ b/multitenancy/README.md @@ -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. diff --git a/multitenancy/output/00-javap.txt b/multitenancy/output/00-javap.txt new file mode 100644 index 0000000..ac28144 --- /dev/null +++ b/multitenancy/output/00-javap.txt @@ -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 { + 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 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 implements org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider { + 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 unwrap(java.lang.Class); +} + +$ 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); +} + +# does spring-boot-hibernate mention tenants at all? (entries containing 'tenant', case-insensitive) +matching files: 0 diff --git a/multitenancy/output/01-api-shape.txt b/multitenancy/output/01-api-shape.txt new file mode 100644 index 0000000..c726bcf --- /dev/null +++ b/multitenancy/output/01-api-shape.txt @@ -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 + +== 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}) diff --git a/multitenancy/output/02-discriminator.txt b/multitenancy/output/02-discriminator.txt new file mode 100644 index 0000000..b8a7d88 --- /dev/null +++ b/multitenancy/output/02-discriminator.txt @@ -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 diff --git a/multitenancy/output/03-schema-per-tenant.txt b/multitenancy/output/03-schema-per-tenant.txt new file mode 100644 index 0000000..dd8d53f --- /dev/null +++ b/multitenancy/output/03-schema-per-tenant.txt @@ -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 diff --git a/multitenancy/output/04-search-path-leak.txt b/multitenancy/output/04-search-path-leak.txt new file mode 100644 index 0000000..560ed0c --- /dev/null +++ b/multitenancy/output/04-search-path-leak.txt @@ -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 diff --git a/multitenancy/output/05-pool-per-tenant.txt b/multitenancy/output/05-pool-per-tenant.txt new file mode 100644 index 0000000..76d1a20 --- /dev/null +++ b/multitenancy/output/05-pool-per-tenant.txt @@ -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 diff --git a/multitenancy/output/06-http-discriminator.txt b/multitenancy/output/06-http-discriminator.txt new file mode 100644 index 0000000..908589e --- /dev/null +++ b/multitenancy/output/06-http-discriminator.txt @@ -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"} diff --git a/multitenancy/output/07-http-schema.txt b/multitenancy/output/07-http-schema.txt new file mode 100644 index 0000000..4f4c1ee --- /dev/null +++ b/multitenancy/output/07-http-schema.txt @@ -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 HTTP Status 500 – Internal Server Error