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
+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...