跳至主要内容

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,只能沿用 OSIVspring.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 / @FilterTenantIdBinder 自動裝
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 的目錄

機轉(皆由堆疊與原始碼確認):

  1. TenantFilterAspect@Order(1) 使其成為外層,交易攔截器(Ordered.LOWEST_PRECEDENCE)在內層——堆疊實證 MethodBeforeAdviceInterceptorTransactionInterceptor
  2. 故 aspect 執行時交易尚未開始、Session 未綁定,entityManager.unwrap(Session.class) 取不到交易的 Session。
  3. TenantFilterSupport.enableFiltercatch (Exception e) { log.warn(...); } 吞掉失敗、回傳 false;aspect 不檢查回傳值。
  4. 生產路徑上唯一在 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、或「有人記得開」
  • ✅ 刪除 TenantFilterAspectTenantFilterSupport 的使用、@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 拋 PropertyValueExceptionTenantIdGeneration

第四列是本 ADR 唯一超出 Hibernate 的守衛,理由即本 ADR 的核心論據:失效方向必須是「明顯壞掉」 而非「靜默污染」。舊機制在此情境會拋 IllegalStateException(吵鬧失敗),遷移不得在這條路徑上 自我違背。守衛只擋「未顯式設 tenantId TenantContext」,不比 Hibernate 更嚴。

誠實邊界:該守衛查的是 TenantContext,而 @TenantId 取值來自 Session 建構時綁定的租戶識別。 兩者在遵守紀律(先設 context、再開交易)時一致,但不是同一個東西:若 Session 以 root 建立、 之後才設 context,守衛會放行而實體仍落 __root__。要完全對齊需框架層的 Hibernate Interceptor (有 Session 存取);本守衛是低成本的第一道,非全覆蓋。


權衡分析 (Trade-offs)

我們獲得什麼 (Gains)

  1. 失效方向翻轉:靜默外洩 → 明顯壞掉。這是本決策的全部理由。
  2. applyToLoadByKey = true(意外收穫)@TenantId 的 filter 涵蓋 entityManager.find(), 舊式 @FilterDef 預設 false。七個 repository 的手寫租戶守衛正是在補這個洞(其註解白紙黑字: 「entityManager.find() 不受 Hibernate Filter 影響,因此需要手動驗證 tenantId」)。洞補起來 → 守衛刪除 → 順帶消掉「無 context 時 getCurrentTenantId() 直接拋」
  3. bulk update 也被過濾(實測 0 列)。舊機制下 @PreUpdate 完全不觸發、filter 未必啟用, 跨租戶 bulk update 很可能會成功——此處新機制嚴格更好
  4. isRoot 使跨租戶視野成為宣告模型,取代「無 context → aspect 跳過」的湧現副作用。
  5. 刪除大量手寫機制:aspect、filter support 呼叫、生命週期鉤子、七個守衛。

我們放棄什麼 (Losses)

  1. @PreUpdateSecurityException 消失。防線前移到「載入不到」——對其他租戶有效 (實測:merge(detached)OptimisticLockException 擋下、bulk update 0 列),對 root 則為刻意放寬。
  2. root 修改他人租戶資料由拋錯變為放行(見「root 權界」)。
  3. 對 Hibernate 多租戶實作的耦合加深(換走此機制的成本提高)。

風險與緩解措施 (Risks & Mitigations)

風險緩解
日後有人改回 @Order/OSIV 依賴的機制TenantFilterAspectOsivITopen-in-view=false 釘住;javadoc 載明改動紀律
root 建業務資料靜默污染TenantAwareEntity@PrePersist 守衛 + RootTenantWriteGuardIT
有人認為「root 可改他人資料」是漏洞而加守衛rootSession_mayUpdateOtherTenantsData_byDesign 釘住此決策;紅了即代表有人推翻了本 ADR
下游遷移遺漏(仍留 TenantFilterAspect 副本)TenantFilterAspecttenant feature 的 core slice → /feature sync tenant 零分歧廣播

影響 (Consequences)

語意變更(遷移成本)

#變更說明
1租戶綁在 Session 建構時,非 persist 時交易開始之後才設 TenantContext 的碼,行為會變。生產路徑不受影響(security filter chain 早於 service);測試若以 class 層 @Transactional + @BeforeEach 設租戶則會失效——Spring 的 TransactionalTestExecutionListener 早於 @BeforeEach
2租戶值於 INSERT 生成,非 persist 時TenantIdGenerationBeforeExecutionGenerator / INSERT_ONLY。flush 前讀 getTenantId()null
3@NotBlank@TenantId 不相容Bean Validation 於 pre-persist 執行、早於值生成,必然先失敗(實測 ConstraintViolationException: propertyPath=tenantId)。非空由 DB nullable = false 把關
4基類生命週期鉤子移除子類覆寫 onPrePersist() 呼叫 super 者編譯失敗(本專案 2 檔)
5resolver 須對所有 Session 可得含不載入應用 @Configuration@DataJpaTest slice → 故以設定注入
6resolver 不得回 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)

必須遵守的規則

  1. 租戶業務實體繼承 TenantAwareEntity(或其 audit 組合子類),不自行宣告 tenant_id
  2. 不得在租戶欄位加 @NotBlank(見變更 #3)。
  3. 建立租戶資料前必須指明租戶TenantContext.runAs(tenantId, ...)(首選)或顯式 setTenantId(...)
  4. 先設 TenantContext、再開交易——租戶綁在 Session 建構時(見變更 #1)。測試尤須注意: 不得以 class 層 @Transactional + @BeforeEach 設租戶,改用 TransactionTemplate
  5. 不得手動 enableFilter/disableFilter——原生機制自動套用;手動介入即測不到真實接線。 跨租戶請走 root session(Hibernate 明文支援的模型),而非關閉 filter。
  6. 不需再手寫 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
RootTenantWriteGuardITroot 建資料的守衛、子類 @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.TenantIdorg.hibernate.context.spi.CurrentTenantIdentifierResolverorg.hibernate.generator.internal.TenantIdGenerationorg.hibernate.internal.AbstractSharedSessionContract#setUpMultitenancy