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:
@@ -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...
|
||||
Reference in New Issue
Block a user