ADR-016: 租戶隔離改採 Hibernate 原生 @TenantId
ADR 編號: 016 狀態: 已接受 (Accepted) 決策日期: 2026-07-15 決策者: Development Team 取代: ADR-001 的機制部分(欄位策略維持不變) 被取代: 無
摘要
租戶隔離的執行機制由「Hibernate @Filter + 應用層 AOP 手動啟用」改為 Hibernate 原生
discriminator 多租戶(@TenantId + CurrentTenantIdentifierResolver)。
起因是一個實證的靜默資安缺陷:TenantFilterAspect 以 @Order(1) 執行、先於交易攔截器,
因此無法為自己綁定 Session,只能沿用 OSIV(spring.jpa.open-in-view)已綁定的;取不到時
TenantFilterSupport.enableFilter 丟出的例外被自己 catch 吞掉(僅 log.warn、回傳 false,
而 aspect 不檢查回傳值)。結果:設定 spring.jpa.open-in-view=false 會使租戶 filter 從未啟用、
所有查詢跨租戶可見,且完全無聲——而 Spring Boot 每次啟動都在建議明確設定該值。
ADR-001 的欄位策略(單一 schema、String tenant_id 判別欄、不用 JOIN)維持不變。本 ADR 換掉的
是它沒有兌現的那半:其決策理由 #2 宣稱「透過 Hibernate Filter 自動過濾,開發者無需手動處理」
——實際上必須有人在正確時機手動 enableFilter,那正是缺陷的根源。原生機制在 Session 建構時
套用,才真正做到 ADR-001 當初承諾的事。
背景 (Context)
問題陳述
核心是失效方向。 舊機制把「租戶隔離生不生效」押在一個必須被正確呼叫、失敗還被吞掉的副作用上:
舊(@Filter + aspect) | 新(原生 @TenantId) | |
|---|---|---|
| filter 宣告 | entity 上的 @FilterDef / @Filter | TenantIdBinder 自動裝 |
| filter 啟用 | 要有人呼叫 session.enableFilter() ← aspect 的存在理由 | Session 建構時由 Hibernate 自動(AbstractSharedSessionContract#setUpMultitenancy) |
| 失敗時 | 靜默跨租戶外洩(編譯正常、既有測試全綠) | 失效方向翻轉為「看不到資料」,立即可見 |
| 注入租戶 | @PrePersist 手寫 | TenantIdGeneration |
| 跨租戶寫入守衛 | @PreUpdate 手寫 | TenantIdGeneration(語意相同)+ 讀取過濾 |
一個資安機制的失效方向必須是「明顯壞掉」,而不是「安靜地把別的租戶的資料端出來」。
缺陷的實證(單一變因)
同一個測試、同樣的斷言,只差 spring.jpa.open-in-view:
open-in-view | 結果 |
|---|---|
true(Boot 預設) | ✅ 隔離成立 |
false | ❌ 租戶 A 看得到租戶 B 的商品,以及整份 default-tenant 的目錄 |
機轉(皆由堆疊與原始碼確認):
TenantFilterAspect的@Order(1)使其成為外層,交易攔截器(Ordered.LOWEST_PRECEDENCE)在內層——堆疊實證MethodBeforeAdviceInterceptor→TransactionInterceptor。- 故 aspect 執行時交易尚未開始、Session 未綁定,
entityManager.unwrap(Session.class)取不到交易的 Session。 TenantFilterSupport.enableFilter的catch (Exception e) { log.warn(...); }吞掉失敗、回傳false;aspect 不檢查回傳值。- 生產路徑上唯一在 aspect 之前綁定 Session 的是 OSIV(僅 HTTP 請求緒生效)。
既有測試抓不到:TenantIsolationIntegrationTest 的 17 處全是手動 TenantFilterSupport.enableFilter(...)
——它測的是 filter 機制本身,從未測過 aspect 接線。這正是缺陷能長期潛伏的原因。
限制條件
- fleet 現況無人受害:app-server / ict / tps / tts / appfuse 均未設定
open-in-view(預設true),故為潛在陷阱而非進行中的外洩。此決定了「可以直接做正解,不必先上 workaround 止血」。 - 框架 jar 的
TenantAwareEntity/TenantFilterSupport屬公開 API(見30-public-api.md),變更須依03-versioning.md的 deprecation cycle 與m-changelog-format.md的 Breaking Changes。 - 依 ADR-014,框架刻意不採 autoconfiguration——新機制的接線不得以
@AutoConfiguration達成。 - 隔離策略須維持對
ownership模式中性(ADR-004):不使用@TenantId的實體完全不受影響。
假設前提
- Hibernate 7.2(實際版本)提供
@TenantId(6.0+)與CurrentTenantIdentifierResolver#isRoot。 - 系統管理員(租戶 ID 為 null)與背景執行緒(排程 /
@Async)需要跨租戶視野——這在舊機制是「無 context → aspect 跳過 → 湊巧不過濾」的湧現副作用,從未見諸文件。
考量的方案 (Options Considered)
方案 A: 維持現狀
- ✅ 零成本
- ❌ 保留一個靜默的跨租戶外洩陷阱,而 Spring Boot 每次啟動都在誘導使用者踩它
方案 B: 調整 AOP 排序(workaround)
@EnableTransactionManagement(order = 0) 使交易攔截器成為最外層,aspect 改 @Order(2) 跑在交易內。
- ✅ 已實測可行(迴歸測試轉綠、全套 363 綠)
- ❌ 把「靠 AOP 順序 + 吞例外」的機制修成「靠另一個 AOP 順序」,陷阱只是往後推
- ❌ tenant feature 指定全域交易順序——一個 optional feature 決定整個 app 的 AOP 排序;交易還會包在
@PreAuthorize授權檢查外面 - ❌
TenantConfig是 tenant feature 的 seam(/feature sync只呈現 diff、不覆蓋),修法有一半無法零分歧廣播到 fleet - ❌
/remove-multi-tenancy會刪掉TenantConfig,該設定隨之消失(隱形耦合)
方案 C: Hibernate 原生 @TenantId(✅ 選擇)
TenantAwareEntity 的租戶欄位改掛 @TenantId,由框架提供 TenantContextIdentifierResolver 接到 TenantContext。
- ✅ 失效方向翻轉;不依賴 AOP 順序、OSIV、或「有人記得開」
- ✅ 刪除
TenantFilterAspect、TenantFilterSupport的使用、@FilterDef/@Filter/@PrePersist/@PreUpdate、以及七個 repository 的手寫租戶守衛 - ✅
isRoot把跨租戶視野從湧現副作用變成宣告模型 - ❌ jar 的破壞性變更,需 deprecation cycle 與 fleet 遷移
- ❌ 數項語意變更(見「影響」)
決策 (Decision)
選擇方案 C:租戶隔離改採 Hibernate 原生 discriminator 多租戶。
機制
// 框架 jar:TenantAwareEntity
@MappedSuperclass
public abstract class TenantAwareEntity implements TenantAware {
@TenantId
@Column(name = "tenant_id", length = 36, nullable = false, updatable = false)
private String tenantId;
// 註:不得加 @NotBlank —— Bean Validation 早於 @TenantId 值生成(見「實證約束」)
}
# 消費端:config/feature-tenant.yml —— 以類名字串交給 Hibernate 自行實例化
spring.jpa.properties.hibernate.tenant_identifier_resolver:
io.leandev.appfuse.jpa.tenant.TenantContextIdentifierResolver
resolver 為何是「類別 + 設定」而非 Spring @Bean:一旦任何實體掛 @TenantId,_tenantId filter
即在 SessionFactory 層定義,於是每一個 Session 建構都會走 setUpMultitenancy,取不到租戶識別即丟
HibernateException。若 resolver 只是應用層 @Configuration 的 @Bean,不載入該 config 的
@DataJpaTest slice 會整個 context 起不來(實測)。以設定注入即無此問題,且不需 autoconfiguration
(符合 ADR-014)。
root 的讀寫權界(決策)
Hibernate 不接受 resolver 回 null,故「無租戶 context」以 ROOT_TENANT = "__root__" 哨兵表達,
由 isRoot() 宣告其可存取所有分區。
| 動作 | 行為 | 依據 |
|---|---|---|
| root 讀(跨租戶) | 放行 | Hibernate isRoot:「a root tenant with access to all partitions」 |
| root 改他人租戶的既有列 | 放行(刻意放寬) | 對齊 Hibernate 模型;租戶歸屬不受影響(updatable = false)。舊機制此處拋 IllegalStateException |
| root 建(顯式指定租戶) | 放行 | TenantIdGeneration:「the root tenant is allowed to set the tenant id explicitly」 |
| root 建(租戶無處可得) | 擋(@PrePersist 守衛) | 否則落 __root__ → 該列對所有租戶皆不可見 → 靜默污染 |
| 非 root 設了不同租戶 | Hibernate 拋 PropertyValueException | TenantIdGeneration |
第四列是本 ADR 唯一超出 Hibernate 的守衛,理由即本 ADR 的核心論據:失效方向必須是「明顯壞掉」
而非「靜默污染」。舊機制在此情境會拋 IllegalStateException(吵鬧失敗),遷移不得在這條路徑上
自我違背。守衛只擋「未顯式設 tenantId 且 無 TenantContext」,不比 Hibernate 更嚴。
誠實邊界:該守衛查的是
TenantContext,而@TenantId取值來自 Session 建構時綁定的租戶識別。 兩者在遵守紀律(先設 context、再開交易)時一致,但不是同一個東西:若 Session 以 root 建立、 之後才設 context,守衛會放行而實體仍落__root__。要完全對齊需框架層的 HibernateInterceptor(有 Session 存取);本守衛是低成本的第一道,非全覆蓋。
權衡分析 (Trade-offs)
我們獲得什麼 (Gains)
- 失效方向翻轉:靜默外洩 → 明顯壞掉。這是本決策的全部理由。
applyToLoadByKey = true(意外收穫):@TenantId的 filter 涵蓋entityManager.find(), 舊式@FilterDef預設false。七個 repository 的手寫租戶守衛正是在補這個洞(其註解白紙黑字: 「entityManager.find()不受 Hibernate Filter 影響,因此需要手動驗證 tenantId」)。洞補起來 → 守衛刪除 → 順帶消掉「無 context 時getCurrentTenantId()直接拋」。- bulk update 也被過濾(實測 0 列)。舊機制下
@PreUpdate完全不觸發、filter 未必啟用, 跨租戶 bulk update 很可能會成功——此處新機制嚴格更好。 isRoot使跨租戶視野成為宣告模型,取代「無 context → aspect 跳過」的湧現副作用。- 刪除大量手寫機制:aspect、filter support 呼叫、生命週期鉤子、七個守衛。
我們放棄什麼 (Losses)
@PreUpdate的SecurityException消失。防線前移到「載入不到」——對其他租戶有效 (實測:merge(detached)被OptimisticLockException擋下、bulk update 0 列),對 root 則為刻意放寬。- root 修改他人租戶資料由拋錯變為放行(見「root 權界」)。
- 對 Hibernate 多租戶實作的耦合加深(換走此機制的成本提高)。
風險與緩解措施 (Risks & Mitigations)
| 風險 | 緩解 |
|---|---|
日後有人改回 @Order/OSIV 依賴的機制 | TenantFilterAspectOsivIT 以 open-in-view=false 釘住;javadoc 載明改動紀律 |
| root 建業務資料靜默污染 | TenantAwareEntity 的 @PrePersist 守衛 + RootTenantWriteGuardIT |
| 有人認為「root 可改他人資料」是漏洞而加守衛 | rootSession_mayUpdateOtherTenantsData_byDesign 釘住此決策;紅了即代表有人推翻了本 ADR |
下游遷移遺漏(仍留 TenantFilterAspect 副本) | TenantFilterAspect 在 tenant feature 的 core slice → /feature sync tenant 零分歧廣播 |
影響 (Consequences)
語意變更(遷移成本)
| # | 變更 | 說明 |
|---|---|---|
| 1 | 租戶綁在 Session 建構時,非 persist 時 | 交易開始之後才設 TenantContext 的碼,行為會變。生產路徑不受影響(security filter chain 早於 service);測試若以 class 層 @Transactional + @BeforeEach 設租戶則會失效——Spring 的 TransactionalTestExecutionListener 早於 @BeforeEach |
| 2 | 租戶值於 INSERT 生成,非 persist 時 | TenantIdGeneration 為 BeforeExecutionGenerator / INSERT_ONLY。flush 前讀 getTenantId() 得 null |
| 3 | @NotBlank 與 @TenantId 不相容 | Bean Validation 於 pre-persist 執行、早於值生成,必然先失敗(實測 ConstraintViolationException: propertyPath=tenantId)。非空由 DB nullable = false 把關 |
| 4 | 基類生命週期鉤子移除 | 子類覆寫 onPrePersist() 呼叫 super 者編譯失敗(本專案 2 檔) |
| 5 | resolver 須對所有 Session 可得 | 含不載入應用 @Configuration 的 @DataJpaTest slice → 故以設定注入 |
| 6 | resolver 不得回 null | 否則 HibernateException: SessionFactory configured for multi-tenancy, but no tenant identifier specified。root 哨兵為強制而非選配 |
曾被誤判為阻礙、經查證撤回的宣稱
記錄於此,以免日後重蹈:
| 宣稱 | 實情 |
|---|---|
「@TenantId 欄位不得由應用層設值 → 約 5 處 setTenantId 須改」 | 撤回。TenantIdGeneration 原始碼顯示:root 可顯式設定;非 root 為 set-must-match(即現行 validateTenantId 的語意);未設則注入 |
「tenant_id IS NULL 的全域共用列無法用原生判別器表達,是 showstopper」 | 撤回。舉證用的 CodeData extends AuditableBase、從不在 @TenantId 機制內;其「自帶 nullable 租戶欄 + repository 顯式處理」正是 Hibernate 期待的做法(全域資料不是租戶分區資料 → 不掛 @TenantId)。當初的 17 個紅是單一 wiring 問題(見變更 #5),與全域資料無關 |
中性影響
- schema 不變:仍是單一 schema +
tenant_id判別欄(ADR-001 的欄位策略)。無資料遷移。 ownership模式不受影響(ADR-004):不繼承TenantAwareEntity的實體完全不涉此機制。
實作指南 (Implementation Guidelines)
必須遵守的規則
- 租戶業務實體繼承
TenantAwareEntity(或其 audit 組合子類),不自行宣告tenant_id。 - 不得在租戶欄位加
@NotBlank(見變更 #3)。 - 建立租戶資料前必須指明租戶:
TenantContext.runAs(tenantId, ...)(首選)或顯式setTenantId(...)。 - 先設
TenantContext、再開交易——租戶綁在 Session 建構時(見變更 #1)。測試尤須注意: 不得以 class 層@Transactional+@BeforeEach設租戶,改用TransactionTemplate。 - 不得手動
enableFilter/disableFilter——原生機制自動套用;手動介入即測不到真實接線。 跨租戶請走 root session(Hibernate 明文支援的模型),而非關閉 filter。 - 不需再手寫
find()的租戶比對——applyToLoadByKey = true已涵蓋。
檢查清單(遷移下游 server 模組)
- 升級
appfuse-server至含本變更的版本 - 刪除本模組的
TenantFilterAspect副本(或跑/feature sync tenant) - 新增
config/feature-tenant.yml(resolver 接線)並加入spring.config.import清單 - 移除租戶欄位上的
@NotBlank(若有自訂基類) - 修正覆寫
onPrePersist()呼叫super的子類 - 移除 repository 中「因
find()不受 filter 影響」而手寫的租戶比對 - 檢查測試:class 層
@Transactional+@BeforeEach設租戶者須改寫 - 以
open-in-view=false跑一次隔離測試(比照TenantFilterAspectOsivIT)
驗證(本次遷移的實績)
app-server 全套 367 tests / 0 紅,含三個新增的永久測試:
| 測試 | 守什麼 |
|---|---|
TenantFilterAspectOsivIT | 隔離不得依賴 open-in-view(本 ADR 的起因,舊機制下為紅) |
TenantIsolationIntegrationTest(重寫) | 測真實接線而非 filter 機制;不手動 enable、不用 class 層 @Transactional |
RootTenantWriteGuardIT | root 建資料的守衛、子類 @PrePersist 不遮蔽基類守衛、root 顯式指定放行、root 可改他人資料(刻意) |
相關文檔 (References)
- ADR-001: 多租戶數據隔離策略 —— 欄位策略維持;本 ADR 取代其機制部分
- ADR-017: 框架 jar 不擁有 @Entity —— 補完本 ADR 的「唯一性」:本 ADR 換了機制卻未宣告
@TenantId為唯一,致 jar 的 5 個 notification 實體以裸tenant_id(可為 null)停留在機制之外,衍生 unique 約束的靜默失效。ADR-017 立「@TenantId唯一」為原則,並兌現本 ADR「不需任何人手動處理」的承諾——jar 不知租戶,filter 仍自動限縮範圍 - ADR-004: Tenant-neutral 方法論 ——
ownership模式不受本變更影響 - ADR-014: 框架能力與參考接線碼的對偶 —— resolver 以設定注入而非 autoconfiguration 的依據
- 多租戶資料隔離設計指南 —— 須依本 ADR 更新
- Hibernate:
org.hibernate.annotations.TenantId、org.hibernate.context.spi.CurrentTenantIdentifierResolver、org.hibernate.generator.internal.TenantIdGeneration、org.hibernate.internal.AbstractSharedSessionContract#setUpMultitenancy