ADR-018: auth 身分模型——Account 降為應用宣告的 seam 實體
ADR 編號: 018 狀態: 已接受 (Accepted) 決策日期: 2026-07-19 決策者: Development Team 取代: 無(將 ADR-017 的「基類 + 應用宣告具體
@Entity」哲學套用到 canonical / seam 邊界;username語意延續既有中立傳統) 被取代: 決策二、五由 ADR-023 取代/修訂(登入引擎上收 jar 後,「同 module 故不建泛型機器」的前提消失,改以AuthPrincipal契約+AuthPrincipalLookupSPI 消費;LoginPolicy宿主上收、簽章改AuthPrincipal)。決策一、六由本 ADR 的「修訂:AccountBase/Account合併」節取代(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;
同時確立兩個配套:
- canonical 的 email 查找流程重複容忍化——「一人多帳號(同 email)」是合法情境,多命中時確定性拒絕、不拋
IncorrectResultSizeDataAccessException、不替應用選帳號。 LoginPolicy登入把關接點——多前端共用單一 server 是 fleet 常態,LoginRequest加選填app欄位,政策裁決由應用 bean 承擔、缺省放行。
動機(為何是現在)
fleet 時間線逼出時點:ict 接近釋出、多個新專案即將 scaffold。介面化之前 scaffold 的每個專案都繼承「具體 Account 是 canonical」的形狀——之後要客製(加欄位、開 @Version、改 uk)只能整檔 drift 成 localSeam、永久失去同步(ict 的 auth 10 檔 localSeams 即實證,其中 Account/AccountRepository 正是最重的兩檔)。本 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 零分歧)持有框架承重面:
| 成員 | 處置 | 理由 |
|---|---|---|
id、password、name、phone | base | 認證鏈基礎欄 |
username @Column(unique = true) | base,unique 留 column 層 | 登入身分單一命中是承重不變量;column 層宣告隨 @MappedSuperclass 繼承、應用拿不掉 |
email | base,不宣告 unique | 聯絡通道、非身分;唯一性歸應用(決策三) |
tenantId(nullable) | base,照舊 | 惰性殘留立場不變(方法論 ADR-009 隔離 variant 軸的「auth delta=0」);兩 variant 共用 |
roles @ManyToMany | base,@JoinTable(name = "account_role") 顯式宣告 | join table 名不得隨具體實體漂移 |
四個 Spring Security 旗標、serviceAccount | base | 認證鏈與 serviceaccount reference domain 承重 |
行為方法(getPrimaryRole、getAllAuthorities…) | base | canonical 消費端只觸及這些 |
Account(具體 @Entity,seam、per-project):
@Table(name = "account", ...)的 indexes 與 uniqueConstraints 全在此宣告(table 層註解本就只能落在具體@Entity)@Version、業務擴充欄位在此 opt-in/增列- 參考實作的
Account保持空體(僅@Table+建構子轉呼叫):- indexes:
idx_account_tenant、idx_account_username、idx_account_email(查詢索引保留) - 不宣告 email uk(中立預設;要鎖的專案自行加)
- 不宣告
@Version(維持 per-entity opt-in 政策——m-server-common.md;參考實作無帳號管理面、無保護對象)
- indexes:
決策二: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 維持 canonical(JpaRepository<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(
MailScopeResolver、ObjectProvider<Clock>):值/runtime 述詞降格為接點,不進結構
決策六:catalog 與傳播
features.json的 authseams增列Account.java;其餘 auth 檔維持現狀(AccountRepository、AccountDetailsService、AccountUserDetails、AuthSeedContributor、LoginRequest、LoginPolicy皆 canonical)- 新專案經
/scaffold-module直接取得 seam 形狀;既有下游經/feature sync對齊 canonical 後,把自家 driftedAccount改寫為extends AccountBase+把 uk/@Version/擴充欄移入具體類,localSeams 相應歸零(ict 於重 baseline 一次收斂)
修訂:AccountBase/Account 合併為單一實體(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 資源整域降級業務層後,這個前提消失:AccountBase、Account 與其全部消費者(AccountDetailsService、AccountPrincipalDirectory、AuthController、AccountAdminService)同屬業務層參考實作,一起隨 scaffold 交給專案、一起走 @reference-surface 生命週期。跨治理邊界的機制約束,退化為同一份專案碼內的內聚建議。
實證:沒有任何檔以 AccountBase 為型別
決定前對 fleet 做過掃描,結果推翻了「base 是 canonical 消費端的編譯期契約」這個假設:
| 專案 | AccountBase 引用 |
|---|---|
| app-server | 4 處,其中 3 處是 javadoc 註解,唯一結構引用為 Account extends AccountBase |
| ict-server | 2 處(1 註解 + extends),另覆寫 isNotYetActive——但 ict 的 base 也在 ict 自己的 repo,可直接改,覆寫是拆分所創造的紀律、非拆分所使能的能力 |
| tts-server | 沒有 AccountBase,auth 照常運作 |
全 fleet 只有一個子類,無「多個具體實體共用基底」的結構理由;所有消費端都直接寫 Account。
真正承重的契約在別處
引擎對帳號的需求由 ADR-023 的 AuthPrincipal + AuthPrincipalLookup 表達(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 的
LoginRequestdrift 可歸隊 - 與方法論 ADR-009(隔離 variant 軸)正交:
AccountBase兩 variant 共用、tenantId惰性殘留立場不變,auth 依然不需要 overlay
負面/代價
- 空體紀律是人的紀律:canonical 若未來新增了「引用參考實作 Account 擴充成員」的碼,會在框架端編譯通過、下游炸開。守則:參考實作的
Account永遠空體,新承重欄一律進AccountBase - 既有下游的收斂需要一次手術(改繼承、搬 uk)——但這是存量 drift 的清償,不是新增成本
findByEmail回傳型別變更是 canonical API 變更:下游 sync 後若自家碼呼叫此方法須跟著改(Optional→List)
遷移(既有下游)
/feature sync auth取得AccountBase/新 canonical 檔- 自家
Account改為extends AccountBase:刪除與 base 重複的欄位與方法,保留@Table(uk/index)、@Version、擴充欄位 - 自家對
findByEmail的呼叫改List語意;額外查詢移入自家 repository 介面 - 驗證:clean build + 真 token 整合測試(教訓見 FU-24)
相關
- ADR-017: 框架 jar 不擁有 @Entity——同一哲學在 jar/app 邊界的版本;本 ADR 是 canonical/seam 邊界的版本
- ADR-009: auth 雙模式 resource server——認證鏈機制;本 ADR 不動 token 驗證
- 方法論 ADR-009: 隔離 variant 軸——auth「delta=0 惰性殘留」立場經 ict 適配覆核(2026-07-19)確認,本 ADR 維持之
m-server-common.md「資料隔離策略」「併發更新」——per-entity@Versionopt-in 政策的權威來源