跳至主要内容

ADR-018: auth 身分模型——Account 降為應用宣告的 seam 實體

ADR 編號: 018 狀態: 已接受 (Accepted) 決策日期: 2026-07-19 決策者: Development Team 取代: 無(將 ADR-017 的「基類 + 應用宣告具體 @Entity」哲學套用到 canonical / seam 邊界;username 語意延續既有中立傳統) 被取代: 決策二、五由 ADR-023 取代/修訂(登入引擎上收 jar 後,「同 module 故不建泛型機器」的前提消失,改以 AuthPrincipal 契約+AuthPrincipalLookup SPI 消費;LoginPolicy 宿主上收、簽章改 AuthPrincipal)。決策一、六由本 ADR 的「修訂:AccountBaseAccount 合併」節取代ADR-024 把 auth 整域降級業務層後,canonical/seam 分界的前提消失)。決策三、四續存


摘要

auth feature 的 Account 從「canonical 具體 @Entity」降為 catalog 宣告的 seam:canonical 出 AccountBase@MappedSuperclass,框架承重面),具體 Account extends AccountBase 由各專案擁有——@Table(uk / index)、@Version opt-in、業務擴充欄位全部下放應用

原則:username 是登入身分識別子(語意中立:自訂 / 員工編號 / email / 手機,應用層選擇),其唯一性是框架承重、留在 base;email 是聯絡/遞送通道、不是身分,其唯一性是應用的帳號管理政策,base 不宣告。

同時確立兩個配套:

  1. canonical 的 email 查找流程重複容忍化——「一人多帳號(同 email)」是合法情境,多命中時確定性拒絕、不拋 IncorrectResultSizeDataAccessException、不替應用選帳號。
  2. LoginPolicy 登入把關接點——多前端共用單一 server 是 fleet 常態,LoginRequest 加選填 app 欄位,政策裁決由應用 bean 承擔、缺省放行。

動機(為何是現在)

fleet 時間線逼出時點:ict 接近釋出、多個新專案即將 scaffold。介面化之前 scaffold 的每個專案都繼承「具體 Account 是 canonical」的形狀——之後要客製(加欄位、開 @Version、改 uk)只能整檔 drift 成 localSeam、永久失去同步(ict 的 auth 10 檔 localSeams 即實證,其中 AccountAccountRepository 正是最重的兩檔)。本 ADR 落地後:新專案 scaffold 即得 seam 形狀;ict 於重 baseline 時一次把 drift 轉正為宣告 seam。


背景 (Context)

病灶:正當客製與 canonical 同步互斥

/feature 的 catalog 模型把 auth 的檔案分為 canonical(零分歧同步)與 seam(per-project)。Account 目前是 canonical,但下游對它的需求天然 per-project:

需求實例(ict)現行唯一出路
業務擴充欄位category(帳號分類)、啟用/停用日期生命週期整檔 drift → localSeam
@Version 樂觀鎖 opt-in帳號管理 UI 防 lost update同上
uk / index 選擇拿掉 email unique(一人多帳號)同上

這與 ADR-017 診斷的「jar 擁有 @Entity、把表結構強加給下游」同構——只是邊界從 jar / app 變成 canonical / seam。解法同構:擁有結構的層下放,承重的行為留上層

email unique 的具體代價

Account.email 目前 @Column(unique = true)。實務上「同一使用者多個帳號」(如一人身兼兩種身分、per-前端一帳號)是正當情境;且 username 本就中立(可以 email)——把另一個 email 欄位鎖 unique 等於替應用做了帳號管理政策的決定。而 canonical 的 email 查找(signed-link 免密碼登入簽發 findByEmail 等)目前隱含單一命中假設:重複 email 下 Spring Data 拋 IncorrectResultSizeDataAccessException——不是靜默選錯(尚屬萬幸),但也不是設計過的行為。


決策 (Decision)

決策一:AccountBase(canonical)+ Account(seam)

AccountBase@MappedSuperclass extends AuditableBase,canonical、隨 /feature sync 零分歧)持有框架承重面:

成員處置理由
idpasswordnamephonebase認證鏈基礎欄
username @Column(unique = true)base,unique 留 column 層登入身分單一命中是承重不變量;column 層宣告隨 @MappedSuperclass 繼承、應用拿不掉
emailbase,不宣告 unique聯絡通道、非身分;唯一性歸應用(決策三)
tenantId(nullable)base,照舊惰性殘留立場不變(方法論 ADR-009 隔離 variant 軸的「auth delta=0」);兩 variant 共用
roles @ManyToManybase,@JoinTable(name = "account_role") 顯式宣告join table 名不得隨具體實體漂移
四個 Spring Security 旗標、serviceAccountbase認證鏈與 serviceaccount reference domain 承重
行為方法(getPrimaryRolegetAllAuthorities…)basecanonical 消費端只觸及這些

Account(具體 @Entityseam、per-project):

  • @Table(name = "account", ...)indexes 與 uniqueConstraints 全在此宣告(table 層註解本就只能落在具體 @Entity
  • @Version、業務擴充欄位在此 opt-in/增列
  • 參考實作的 Account 保持空體(僅 @Table +建構子轉呼叫):
    • indexes:idx_account_tenantidx_account_usernameidx_account_email(查詢索引保留)
    • 不宣告 email uk(中立預設;要鎖的專案自行加)
    • 不宣告 @Version(維持 per-entity opt-in 政策——m-server-common.md;參考實作無帳號管理面、無保護對象)

決策二:canonical 以「FQN 穩定的 seam 型別引用」消費,不建泛型/工廠機器

比較過兩案:

方案機器判定
(A) jar 式泛型AccountRepositoryBase<T extends AccountBase> + entity 工廠 SPI(如 ADR-017 notification)否決——那套機器是 jar/app 跨 artifact 邊界的必要成本(jar 引用不到 app 類);canonical 與 seam 住同一 module,成本不必要
(B) seam 型別引用(採用)canonical 引用 FQN 穩定的 seam 類已有先例(canonical UserResponse 引用 seam RoleHierarchyService)。new Account(...)JpaRepository<Account, String> 全部照舊

編譯契約:canonical 程式碼只准觸及 AccountBase 成員與 Account 的建構子。此契約由參考實作空體紀律物理保證——參考實作的 Account 沒有任何擴充成員,canonical 想誤依賴也編譯不過。(同時這正是 ownership variant 對 notification 實體 overlay 已採的空體形狀。)

AccountRepository 維持 canonicalJpaRepository<Account, String>):canonical 查詢(findByUsername、email 查找、service-account 系列)住這裡;下游要對擴充欄位加查詢時,在自己的 package 另立 repository 介面(Spring Data 允許同 entity 多介面),不動 canonical 檔。

決策三:email 唯一性下放,canonical 查找重複容忍化

  • base 不宣告 unique(決策一);參考實作 seam 預設亦不宣告——「一人多帳號」自此是合法組態,鎖不鎖是應用的帳號管理取捨(安全面:帳號枚舉、復原歧義,由應用政策承擔)。
  • 配套義務:canonical 的 email 查找流程改寫為重複容忍——
    • AccountRepository.findByEmail 回傳型別改 List<Account>(型別層面杜絕單一命中假設)
    • signed-link 免密碼簽發:0 命中維持現行靜默行為;恰 1 命中照常;多命中 → 確定性拒絕——與「查無帳號」同形的隱私一致拒絕(回 empty/不寄送)+ audit log 留痕,不選第一筆。(語義化錯誤回報會向請求者洩漏帳號存在性,故拒絕面向請求者靜默、面向維運留痕)
    • 框架的義務是確定性地失敗、把選擇權交出去;要 disambiguation(如 email + 來源前端縮域)屬應用層延伸

決策四:@Version 維持 per-entity opt-in

不因本 ADR 改變 [m-server-common.md] 的併發政策:base 永不宣告 @Version;需要的專案在自己的 seam Account 宣告並走完整往返 pattern(concurrency.md)。本 ADR 的貢獻只是讓 opt-in 不再付出 drift 稅

決策五:LoginPolicy 登入把關接點

  • canonical LoginRequest選填 app 欄位(登入來源前端識別,字串、值域由應用定義)
  • canonical 新增 LoginPolicy 函式介面:boolean allows(AccountBase account, String app)
  • seam AuthController 於認證成功後、簽發 token 前以 ObjectProvider<LoginPolicy> 消費:無 bean = 放行(單前端專案零感知);多前端專案在自己的 package 提供政策 bean(政策內容用自己的欄位判——如 ict 的 category × 前端矩陣)
  • 對齊既有接點 pattern(MailScopeResolverObjectProvider<Clock>):值/runtime 述詞降格為接點,不進結構

決策六:catalog 與傳播

  • features.json 的 auth seams 增列 Account.java;其餘 auth 檔維持現狀(AccountRepositoryAccountDetailsServiceAccountUserDetailsAuthSeedContributorLoginRequestLoginPolicy 皆 canonical)
  • 新專案經 /scaffold-module 直接取得 seam 形狀;既有下游經 /feature sync 對齊 canonical 後,把自家 drifted Account 改寫為 extends AccountBase +把 uk/@Version/擴充欄移入具體類,localSeams 相應歸零(ict 於重 baseline 一次收斂)

修訂:AccountBaseAccount 合併為單一實體(2026-07-23,ADR-024 後的沉積清理)

決策一與決策六的拆分至此收斂為單一 Account;決策三(email 唯一性下放)、決策四(@Version per-entity opt-in)與身分語意二分(username 是身分、email 是通道)續存,改由單一檔承載。

為何拆分的理由已消失

決策一的 AccountBase(canonical、隨 /feature sync 零分歧下行)/Account(seam、per-project)分界,前提是兩者分屬不同治理單位——一個由框架同步、一個由專案擁有,中間需要一條編譯期看得見的界線(「空體紀律」)。

ADR-024 把 auth 資源整域降級業務層後,這個前提消失:AccountBaseAccount 與其全部消費者(AccountDetailsServiceAccountPrincipalDirectoryAuthControllerAccountAdminService同屬業務層參考實作,一起隨 scaffold 交給專案、一起走 @reference-surface 生命週期。跨治理邊界的機制約束,退化為同一份專案碼內的內聚建議。

實證:沒有任何檔以 AccountBase 為型別

決定前對 fleet 做過掃描,結果推翻了「base 是 canonical 消費端的編譯期契約」這個假設:

專案AccountBase 引用
app-server4 處,其中 3 處是 javadoc 註解,唯一結構引用為 Account extends AccountBase
ict-server2 處(1 註解 + extends),另覆寫 isNotYetActive——但 ict 的 base 也在 ict 自己的 repo,可直接改,覆寫是拆分所創造的紀律、非拆分所使能的能力
tts-server沒有 AccountBase,auth 照常運作

全 fleet 只有一個子類,無「多個具體實體共用基底」的結構理由;所有消費端都直接寫 Account

真正承重的契約在別處

引擎對帳號的需求由 ADR-023AuthPrincipalAuthPrincipalLookup 表達(UserDetails 式介面),LoginService 不認識 Account 也不認識 AccountBase。九個 requires: auth 的 feature 無一 import Account。契約與實作的分離已由 jar 介面完成,@MappedSuperclass 層在其上不再增加保障。

合併的性質

面向影響
DB schema零變化——@MappedSuperclass 的欄位本即映射進子類的表
行為零(欄位、方法原封移入;version@Setter(AccessLevel.NONE) 保持無 setter)
型別引用零(無人以 base 為型別)

對下游的含意

這不是必須跟進的遷移Account 屬業務層參考實作,各專案自行決定何時收斂(ict 可在下次動 auth 時把 isNotYetActive 覆寫就地變成實作)。維持兩層拆分不會有任何機制上的懲罰——它只是不再買到東西。

未來方向

AccountBase 遺留的「跨 fleet 都想保障的行為」(如啟停日期視窗判定),正確出口是抽上 jar 介面而非恢復基底類傳播——AuthPrincipal#interactiveLoginAllowed() 已是此模式的先例(結構 serviceAccount 欄位留參考實作、行為契約上 jar)。ict 對 isNotYetActive 的覆寫正說明那是個真實變異點,適合作為下一個候選。

修訂:所有多租戶 scaffold 採全域 username 定位(2026-08-16)

  • Account.username 在整個平台唯一,不再以 (tenant_id, username) 作聯合唯一鍵。
  • 兩個 server variant 共通:username trim 後不分大小寫;原值供顯示,另以固定 SHA-256 username_key 完成唯一索引與查找,使語意不受資料庫 collation 影響。
  • local login、Email OTP、密碼重設等 username-based 流程只接收 username;流程為 username → Account → Account.tenantId → AuthPrincipal → JWT tenantId
  • OIDC/External Identity 以 issuer + subject 的全域 identity key 定位 Account,同樣由 Account 推導 tenant,不接受 client 提供 tenant 參與身分查詢。
  • 帳號建立與更名都必須在 service 層先檢查全域衝突,並保留資料庫唯一鍵處理 race condition。
  • SUPER_ADMIN 的 Account 仍歸屬 system,但安全投影維持 root principal,JWT 不帶 tenant claim。
  • 新 scaffold 直接採此模型;本次參考實作沒有既有資料,因此不提供資料遷移或相容讀取路徑。

後果 (Consequences)

正面

  • @Version、屬性擴充、uk/index 選擇全部免 drift 稅——per-project 客製落在本就 per-project 的 seam
  • email 身分歧義從「未定義行為(runtime 例外)」變成「設計過的確定性拒絕」
  • 多前端登入把關成為框架級接點,ict 的 LoginRequest drift 可歸隊
  • 與方法論 ADR-009(隔離 variant 軸)正交:AccountBase 兩 variant 共用、tenantId 惰性殘留立場不變,auth 依然不需要 overlay

負面/代價

  • 空體紀律是人的紀律:canonical 若未來新增了「引用參考實作 Account 擴充成員」的碼,會在框架端編譯通過、下游炸開。守則:參考實作的 Account 永遠空體,新承重欄一律進 AccountBase
  • 既有下游的收斂需要一次手術(改繼承、搬 uk)——但這是存量 drift 的清償,不是新增成本
  • findByEmail 回傳型別變更是 canonical API 變更:下游 sync 後若自家碼呼叫此方法須跟著改(OptionalList

遷移(既有下游)

  1. /feature sync auth 取得 AccountBase/新 canonical 檔
  2. 自家 Account 改為 extends AccountBase:刪除與 base 重複的欄位與方法,保留 @Table(uk/index)、@Version、擴充欄位
  3. 自家對 findByEmail 的呼叫改 List 語意;額外查詢移入自家 repository 介面
  4. 驗證:clean build + 真 token 整合測試(教訓見 FU-24)

相關