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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b4VQVEJWetTT1dYzyJ
This commit is contained in:
2026-10-29 05:52:26 +00:00
co-authored by Claude Sonnet 5.5
parent ef2d2aa8ea
commit 98f4ed7411
45 changed files with 1820 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
# javap of the shipped hibernate-core-7.4.14.Final.jar
$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.annotations.TenantId
Compiled from "TenantId.java"
public interface org.hibernate.annotations.TenantId extends java.lang.annotation.Annotation {
}
$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.context.spi.CurrentTenantIdentifierResolver
Compiled from "CurrentTenantIdentifierResolver.java"
public interface org.hibernate.context.spi.CurrentTenantIdentifierResolver<T> {
public abstract T resolveCurrentTenantIdentifier();
public abstract boolean validateExistingCurrentSessions();
public default boolean isRoot(T);
}
$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider
Compiled from "MultiTenantConnectionProvider.java"
public interface org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider<T> extends org.hibernate.service.Service, org.hibernate.service.spi.Wrapped {
public abstract java.sql.Connection getAnyConnection() throws java.sql.SQLException;
public abstract void releaseAnyConnection(java.sql.Connection) throws java.sql.SQLException;
public abstract java.sql.Connection getConnection(T) throws java.sql.SQLException;
public default java.sql.Connection getReadOnlyConnection(T) throws java.sql.SQLException;
public abstract void releaseConnection(T, java.sql.Connection) throws java.sql.SQLException;
public default void releaseReadOnlyConnection(T, java.sql.Connection) throws java.sql.SQLException;
public abstract boolean supportsAggressiveRelease();
public default boolean handlesConnectionSchema();
public default boolean handlesConnectionReadOnly();
public default org.hibernate.engine.jdbc.connections.spi.DatabaseConnectionInfo getDatabaseConnectionInfo(org.hibernate.dialect.Dialect);
}
$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.engine.jdbc.connections.spi.AbstractMultiTenantConnectionProvider
Compiled from "AbstractMultiTenantConnectionProvider.java"
public abstract class org.hibernate.engine.jdbc.connections.spi.AbstractMultiTenantConnectionProvider<T> implements org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider<T> {
public org.hibernate.engine.jdbc.connections.spi.AbstractMultiTenantConnectionProvider();
protected abstract org.hibernate.engine.jdbc.connections.spi.ConnectionProvider getAnyConnectionProvider();
protected abstract org.hibernate.engine.jdbc.connections.spi.ConnectionProvider selectConnectionProvider(T);
public java.sql.Connection getAnyConnection() throws java.sql.SQLException;
public void releaseAnyConnection(java.sql.Connection) throws java.sql.SQLException;
public java.sql.Connection getConnection(T) throws java.sql.SQLException;
public void releaseConnection(T, java.sql.Connection) throws java.sql.SQLException;
public boolean supportsAggressiveRelease();
public boolean isUnwrappableAs(java.lang.Class<?>);
public <X> X unwrap(java.lang.Class<X>);
}
$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.cfg.MultiTenancySettings
Compiled from "MultiTenancySettings.java"
public interface org.hibernate.cfg.MultiTenancySettings {
public static final java.lang.String MULTI_TENANT_CONNECTION_PROVIDER;
public static final java.lang.String MULTI_TENANT_IDENTIFIER_RESOLVER;
public static final java.lang.String TENANT_IDENTIFIER_TO_USE_FOR_ANY_KEY;
public static final java.lang.String MULTI_TENANT_SCHEMA_MAPPER;
public static final java.lang.String MULTI_TENANT_CREDENTIALS_MAPPER;
}
# javap of spring-boot-hibernate-4.1.2.jar
$ javap -cp spring-boot-hibernate-4.1.2.jar org.springframework.boot.hibernate.autoconfigure.HibernatePropertiesCustomizer
Compiled from "HibernatePropertiesCustomizer.java"
public interface org.springframework.boot.hibernate.autoconfigure.HibernatePropertiesCustomizer {
public abstract void customize(java.util.Map<java.lang.String, java.lang.Object>);
}
# does spring-boot-hibernate mention tenants at all? (entries containing 'tenant', case-insensitive)
matching files: 0
+20
View File
@@ -0,0 +1,20 @@
# Hibernate 7.4.14.Final, reflected from the classpath
== packages
TenantId -> org.hibernate.annotations.TenantId
CurrentTenantIdentifierResolver -> org.hibernate.context.spi.CurrentTenantIdentifierResolver
MultiTenantConnectionProvider -> org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider
HibernatePropertiesCustomizer -> org.springframework.boot.hibernate.autoconfigure.HibernatePropertiesCustomizer
== generics
CurrentTenantIdentifierResolver type parameters: [T]
MultiTenantConnectionProvider type parameters: [T]
TenantIdResolver implements org.hibernate.context.spi.CurrentTenantIdentifierResolver<java.lang.String>
== setting names (org.hibernate.cfg.MultiTenancySettings)
MULTI_TENANT_IDENTIFIER_RESOLVER = hibernate.tenant_identifier_resolver
MULTI_TENANT_CONNECTION_PROVIDER = hibernate.multi_tenant_connection_provider
TENANT_IDENTIFIER_TO_USE_FOR_ANY_KEY = hibernate.multi_tenant.datasource.identifier_for_any
== @TenantId targets
@java.lang.annotation.Target({METHOD, FIELD})
+43
View File
@@ -0,0 +1,43 @@
# @TenantId on PostgreSQL (PostgreSQL 17.11), Hibernate 7.4.14.Final
== DDL Hibernate generated (read back from the catalog)
column amount numeric nullable=YES
column id bigint nullable=NO
column number character varying nullable=YES
column tenant_id character varying nullable=NO
unique UNIQUE (tenant_id, number)
== insert: nobody sets tenant_id in Java
-- as acme: save(INV-1)
sql> insert into invoice (amount,number,tenant_id) values (?,?,?)
entity.tenantId after save = acme
-- as globex: save(INV-1) - same invoice number, allowed by UNIQUE(tenant_id, number)
sql> insert into invoice (amount,number,tenant_id) values (?,?,?)
ids: acme INV-1=1, globex INV-1=2 (one IDENTITY column shared by all tenants)
== reads are filtered
-- as acme: findAll()
sql> select i1_0.id,i1_0.amount,i1_0.number,i1_0.tenant_id from invoice i1_0 where i1_0.tenant_id = ?
rows = 2, numbers = [INV-1, INV-2]
-- as globex: findById(acme's id 1)
sql> select i1_0.id,i1_0.amount,i1_0.number,i1_0.tenant_id from invoice i1_0 where i1_0.id=? and i1_0.tenant_id = ?
result = Optional.empty
== bulk HQL is scoped too
-- as acme: update Invoice set amount = 0
sql> update invoice i1_0 set amount=0 where i1_0.tenant_id = ?
rows updated = 2 (globex's row untouched)
== what @TenantId does NOT cover: native SQL
-- as acme: native query select count(*) from invoice
result = 3 (acme owns 2 rows; the native query saw every tenant)
== no tenant set
-- strict resolver (throws when TenantContext is empty): repo.findAll() with no tenant
org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction <- caused by com.ankurm.multitenancy.tenant.MissingTenantException: no tenant in TenantContext: refusing to open a tenant-scoped session
-- lenient resolver (returns null): repo.findAll() with no tenant
org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction <- caused by org.hibernate.HibernateException: SessionFactory configured for multi-tenancy, but no tenant identifier specified
== writing a row for a different tenant than the session's
-- as acme: new Invoice with tenantId manually set to globex, saveAndFlush
org.springframework.dao.DataIntegrityViolationException: assigned tenant id differs from current tenant id [globex != acme] for entity com.ankurm.multitenancy.discriminator.Invoice.tenantId <- caused by org.hibernate.PropertyValueException: assigned tenant id differs from current tenant id [globex != acme] for entity com.ankurm.multitenancy.discriminator.Invoice.tenantId
@@ -0,0 +1,38 @@
# schema-per-tenant, ONE shared pool + search_path, PostgreSQL (PostgreSQL 17.11), Hibernate 7.4.14.Final
== provisioning (the application's job, not Hibernate's)
schemas created: acme, globex, hooli, initech, stark, umbrella
-- provision("x; drop schema public cascade")
java.lang.IllegalArgumentException: not an acceptable tenant identifier
== the SQL Hibernate sends is unqualified; the connection decides which schema it hits
-- as acme: save(INV-1)
sql> insert into invoice (amount,number) values (?,?)
-- as acme: findAll()
sql> select i1_0.id,i1_0.amount,i1_0.number from invoice i1_0
rows = 2, numbers = [INV-1, INV-2]
-- as acme, inside the transaction: select current_setting('search_path')
acme
== cross-tenant reads: blocked by the schema, but ids overlap
ids: acme INV-1=1, acme INV-2=2, globex INV-9=1 (each schema has its own identity column)
-- as globex: findById(1) -> INV-9 (acme's id 1 is also globex's id 1: a different row)
-- as globex: findById(2) -> Optional.empty (acme's INV-2 is unreachable)
== native SQL needs no workaround here
-- as acme: native select count(*) from invoice -> 2 (acme's schema only; globex's row is not counted)
== failure modes
-- no tenant set: repo.findAll()
org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction <- caused by com.ankurm.multitenancy.tenant.MissingTenantException: no tenant in TenantContext: refusing to open a tenant-scoped session
-- valid-looking tenant with no schema (ghostco): repo.findAll()
org.springframework.dao.InvalidDataAccessResourceUsageException: JDBC exception executing SQL [ERROR: relation "invoice" does not exist <- caused by org.hibernate.exception.SQLGrammarException: JDBC exception executing SQL [ERROR: relation "invoice" does not exist <- caused by org.postgresql.util.PSQLException: ERROR: relation "invoice" does not exist
-- hostile tenant string reaching the provider: repo.findAll()
org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction <- caused by java.lang.IllegalArgumentException: not an acceptable tenant identifier
== after all that work: what does an unrelated component see on the pool?
-- direct DataSource connection: show search_path -> "$user", public
== connections held by the shared pool after exercising 6 tenants
config: spring.datasource.hikari.maximum-pool-size=5, 6 tenants touched
pg_stat_activity rows with application_name='shared-pool': 5
@@ -0,0 +1,10 @@
# a provider that does not reset search_path on release (tenancy.mode=leaky), pool size 1
== Hibernate finished a request as acme and gave the connection back; now an unrelated component borrows it
-- direct DataSource connection, tenant context empty
show search_path -> acme
select count(*) from invoice -> 1 (acme's data, read with no tenant at all)
== the same experiment with the correct provider (resetOnRelease=true), pool size 1, provider called the way Hibernate calls it
-- after getConnection("acme") + releaseConnection("acme", ..): show search_path -> "$user", public
-- select count(*) from invoice -> java.lang.IllegalStateException: org.postgresql.util.PSQLException: ERROR: relation "invoice" does not exist <- caused by org.postgresql.util.PSQLException: ERROR: relation "invoice" does not exist
@@ -0,0 +1,15 @@
# schema-per-tenant, ONE HikariCP POOL PER TENANT, PostgreSQL (PostgreSQL 17.11)
== connections per pool, read from pg_stat_activity
config: tenancy.pool.max-size=3 (minimumIdle defaults to the same), 6 tenants touched, tenancy.mode=pool-per-tenant
pool-acme 3 connections
pool-any 3 connections
pool-globex 3 connections
pool-hooli 3 connections
pool-initech 3 connections
pool-stark 3 connections
pool-umbrella 3 connections
total across tenant pools: 21
pools created by the provider: 7 (6 tenants + 1 'any' pool used by Hibernate at bootstrap)
server max_connections: 100
search_path of an acme pool connection: acme
@@ -0,0 +1,41 @@
# JWT -> filter -> TenantContext -> @TenantId, real HTTP against the discriminator application
== the filter: who gets in
-- no Authorization header
GET /invoices -> HTTP 401 {"error":"missing bearer token"}
-- garbage token
GET /invoices -> HTTP 401 {"error":"invalid token"}
-- token signed with a different secret
GET /invoices -> HTTP 401 {"error":"invalid token"}
-- expired token (exp = 10 minutes ago)
GET /invoices -> HTTP 401 {"error":"invalid token"}
-- valid signature, no tenant_id claim
GET /invoices -> HTTP 403 {"error":"token carries no valid tenant"}
-- valid signature, tenant_id = "x; drop schema public cascade"
GET /invoices -> HTTP 403 {"error":"token carries no valid tenant"}
-- valid signature, tenant_id = "public" (reserved name)
GET /invoices -> HTTP 403 {"error":"token carries no valid tenant"}
== two tenants, same URL, different data
-- acme creates INV-1
POST /invoices -> HTTP 201 {"id":1,"number":"INV-1","amount":100.00,"tenant":"acme"}
-- acme creates INV-2
POST /invoices -> HTTP 201 {"id":2,"number":"INV-2","amount":250.00,"tenant":"acme"}
-- globex creates INV-9
POST /invoices -> HTTP 201 {"id":3,"number":"INV-9","amount":999.00,"tenant":"globex"}
-- acme lists
GET /invoices -> HTTP 200 [{"id":1,"number":"INV-1","amount":100.00,"tenant":"acme"},{"id":2,"number":"INV-2","amount":250.00,"tenant":"acme"}]
-- globex lists
GET /invoices -> HTTP 200 [{"id":3,"number":"INV-9","amount":999.00,"tenant":"globex"}]
== cross-tenant read by id
-- globex asks for acme's INV-2 by id (2)
GET /invoices/2 -> HTTP 404
-- acme asks for the same id
GET /invoices/2 -> HTTP 200 {"id":2,"number":"INV-2","amount":250.00,"tenant":"acme"}
== a well-formed tenant nobody has onboarded
-- token for tenant "ghostco": GET /invoices
GET /invoices -> HTTP 200 []
-- token for tenant "ghostco": POST /invoices
POST /invoices -> HTTP 201 {"id":4,"number":"INV-G","amount":1.00,"tenant":"ghostco"}
+42
View File
@@ -0,0 +1,42 @@
# JWT -> filter -> TenantContext -> schema-per-tenant (shared pool + search_path), real HTTP
provisioned schemas: acme, globex (ghostco deliberately not provisioned)
== the filter: who gets in
-- no Authorization header
GET /invoices -> HTTP 401 {"error":"missing bearer token"}
-- garbage token
GET /invoices -> HTTP 401 {"error":"invalid token"}
-- token signed with a different secret
GET /invoices -> HTTP 401 {"error":"invalid token"}
-- expired token (exp = 10 minutes ago)
GET /invoices -> HTTP 401 {"error":"invalid token"}
-- valid signature, no tenant_id claim
GET /invoices -> HTTP 403 {"error":"token carries no valid tenant"}
-- valid signature, tenant_id = "x; drop schema public cascade"
GET /invoices -> HTTP 403 {"error":"token carries no valid tenant"}
-- valid signature, tenant_id = "public" (reserved name)
GET /invoices -> HTTP 403 {"error":"token carries no valid tenant"}
== two tenants, same URL, different data
-- acme creates INV-1
POST /invoices -> HTTP 201 {"id":1,"number":"INV-1","amount":100.00,"tenant":"acme"}
-- acme creates INV-2
POST /invoices -> HTTP 201 {"id":2,"number":"INV-2","amount":250.00,"tenant":"acme"}
-- globex creates INV-9
POST /invoices -> HTTP 201 {"id":1,"number":"INV-9","amount":999.00,"tenant":"globex"}
-- acme lists
GET /invoices -> HTTP 200 [{"id":1,"number":"INV-1","amount":100.00,"tenant":"acme"},{"id":2,"number":"INV-2","amount":250.00,"tenant":"acme"}]
-- globex lists
GET /invoices -> HTTP 200 [{"id":1,"number":"INV-9","amount":999.00,"tenant":"globex"}]
== cross-tenant read by id
-- globex asks for acme's INV-2 by id (2)
GET /invoices/2 -> HTTP 404
-- acme asks for the same id
GET /invoices/2 -> HTTP 200 {"id":2,"number":"INV-2","amount":250.00,"tenant":"acme"}
== a well-formed tenant nobody has onboarded
-- token for tenant "ghostco": GET /invoices
GET /invoices -> HTTP 500 <!doctype html><html lang="en"><head><title>HTTP Status 500 – Internal Server Error</title><style type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1...
-- token for tenant "ghostco": POST /invoices
POST /invoices -> HTTP 500 <!doctype html><html lang="en"><head><title>HTTP Status 500 – Internal Server Error</title><style type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1...