
系列:30 天打造企業級 PLM|面向:後端
企業系統的使用者體驗,第一條就是不能讓員工多記一組密碼。公司已經有 AD(Active Directory),PLM 就該用 AD 帳號登入;登入之後這個人能看哪些選單、能按哪些鍵,則是 RBAC 授權鏈的工作。今天講認證(LDAP)與授權(RBAC)怎麼在 Mini-PLM 落地,外加兩個帳號管理的經典坑。

先釐清一個容易寫錯的比較:Mini-PLM 並不是另創一套與 Oracle Agile 完全不同的權限模型,兩者都以 User/User Group → Role → Privilege 作為核心。權限架構:

圖解閱讀:User 或 User Group 先取得 Role,Role 再聚合 Privilege Mask;Privilege 定義能做什麼,Criteria 定義這項權限適用哪些資料與條件。
也就是說,Oracle Agile 的 Privilege + Criteria 會組成 privilege mask,再掛到 Role;EJB 或其他後端攔截層是執行授權的技術方式,不是單純的 RBAC 模型。Oracle 官方文件也明確以「Privilege 搭配 reusable Criteria,再由 Role 集合管理」說明這條授權鏈:Privileges and Privilege Masks。這也是為什麼排查「看不到某筆料號」時,必須沿著 User/Group、Role、Privilege Mask、Criteria 一路追查,而不能只看登入是否成功。
Mini-PLM 沿用相同的角色授權思想,但把應用程式常見的授權面拆成幾個可直接維護的關聯:
| 比較面向 | Oracle Agile PLM | Mini-PLM |
|---|---|---|
| 共同骨架 | User/User Group 取得 Role,Role 聚合權限 | Account 可直接取得 Role,也可經 UserGroup 取得 Role |
| 功能與資料特權 | Privilege 搭配 reusable Criteria 形成 privilege mask | MP_ROLE_PRIVILEGE 將 MP_PRIVILEGE 掛到 Role;特權可帶目標物件、欄位、資料表與可執行的 ConfigCriteriaNode |
| 導覽呈現 | 權限模型的重點是功能與資料存取 | 額外以 MP_ROLE_FOLDER 將 MP_MENU 掛到 Role,組出每個角色的可見選單 |
| 組織維護 | 使用者與 User Group 的角色指派 | UserGroup 另有 owner 與成員關係,可將角色與選單配置下放給業務維護 |
因此,兩者的主要差異不是「Agile 有 RBAC、Mini-PLM 沒有」,而是 Privilege 與 UI Menu 明確拆開,並增加「角色可自訂選單」這個應用層能力。這樣可以讓工程師與管理員擁有不同的入口,即使兩者仍共用某些後端權限規則。
圖解閱讀提示:左側呈現的是 Agile 在 privilege、criteria 與後端攔截層疊加後的排查複雜度,不代表 Agile 沒有 RBAC;兩邊共同的授權骨架仍是「使用者/群組 → 角色 → 權限」。右側的 Menu 是 Mini-PLM 額外提供的角色級 UI 投影。

前面的比較表如果只寫「Privilege 可帶 Criteria」,仍然不足以表達 Mini-PLM 的特色。Mini-PLM 的 Criteria 不是掛在權限旁邊、只供管理員閱讀的靜態備註,而是可以和動態查詢結合的條件樹(ConfigCriteriaNode)。它會在每次授權判斷時,拿目前資料重新比對,決定這個 Role 的 Privilege 是否適用於這筆資料。

圖解閱讀:CriteriaNode 不是單純備註,而是會被執行的條件樹;系統將它套入目前資料後,依比對結果決定是否套用該 Role 的資料/欄位權限。
例如,同一個 MODIFY Privilege 可以只允許使用者修改「符合某個部門、狀態或其他業務條件」的表單;管理員調整 Criteria 的條件後,權限範圍就能隨資料與設定變化,不必為每一種範圍另寫一條硬編碼的 Java 規則。實際執行上,AuthorizationService 會透過 QueryService.matchFormByCriteria() 判斷單筆資料;列表或搜尋情境則使用 matchFormsByCriteria() 批次比對,並由 resolveReadableFieldsBatch() 避免逐筆資料乘上每個 Criteria 的查詢成本。
這讓 Mini-PLM 的授權可以拆成三個層次理解:
Role:User 或 UserGroup 是否具有某個 Role。Privilege:這個 Role 能不能執行 READ/MODIFY,以及能操作哪些欄位。Criteria:這筆資料是否落在可操作的動態範圍內。因此,Criteria 才是把「角色有某項權限」進一步收斂成「角色對哪些資料有某項權限」的關鍵。選單只負責呈現入口,Criteria 則參與真正的資料級授權;更新授權設定後仍須配合既有的快取清除或更新事件,避免舊的授權結果暫時留在快取中。
當權限關係同時包含直掛 Role、UserGroup、Role、Privilege 與 Criteria 時,單靠人工逐張資料表追查並不適合。Mini-PLM 另外提供管理員使用的權限診斷 API:
POST /api/v1/admin/diagnosis/permission
這條路徑將複雜查詢固定成可追蹤的授權鏈:ACCOUNT_EXISTS → ROLES_RESOLVED → 選單或 Privilege 綁定 → CACHE_HINT,回傳 ALLOWED 或 BLOCKED,並把每個步驟標示為 PASS、FAIL、WARN 或 SKIPPED。也就是說,管理員可以先快速回答「這個帳號是否透過某個 Role 具備某個選單或 Privilege」,不用把登入、群組、角色與權限關聯人工拼湊一次。
目前這是一個 deterministic 的結構化查詢服務,AI 尚未直接代替授權判斷。未來可以讓 AI 將「請查王小明是否有修改 P1 表單的權限」解析成帳號、目標類型、Privilege 與表單類型,再呼叫這條服務;如果問題還包含特定資料範圍,則再把同一套 Criteria 動態比對納入診斷。最後由 deterministic 結果作為唯一依據,AI 只負責自然語言解析與白話說明,才能保留可稽核、可重現的權限答案。
認證方法由設定檔決定,同一套程式支援「AD 驗證」與「DB 驗證」兩種模式(AuthService.authenticate() 實碼節錄):
String authenticateMethod = ymlData.getAuthenticateMethod();
if ("ldap".equalsIgnoreCase(authenticateMethod)) {
log.info("login by ldap");
// === LDAP 端:僅負責密碼驗證;
// 拋 AuthenticationException 代表 LDAP 端失敗 ===
...
}
// 首次登入自動同步 AD 屬性(如 email)
if ("ldap".equalsIgnoreCase(authenticateMethod)
&& ldapUser != null && persistentUser.getEmail() == null) {
persistentUser.setEmail(ldapUser.getMail());
}
職責劃分很清楚:LDAP 只管密碼對不對,本地資料庫管這個人是誰、能做什麼。帳號主檔(mp_account)永遠在本地,權限、主管鏈、偏好語系這些 AD 沒有的欄位都掛在上面;AD 有的屬性(email)首登時同步進來。這樣即使哪天換掉 LDAP,授權體系一行都不用改。

實際 schema 把「角色取得」「端點授權」「功能特權」與「選單投影」分開:
mp_account ──< mp_user_role >── mp_role ← 人直接掛角色
mp_account ──< mp_user_usergroup >── mp_usergroup ← 人屬於群組
mp_usergroup ──< mp_usergroup_role >── mp_role ← 群組掛角色(批次授權的主力)
mp_usergroup ──< mp_usergroup_owner >── mp_account ← 群組負責人(M:N,可多位 owner)
mp_role ──< mp_role_permission >── mp_permission ← HTTP method + URI 存取控制
mp_role ──< mp_role_privilege >── mp_privilege ← 功能/資料特權(Day 6 的 Privilege)
mp_role ──< mp_role_folder >── mp_menu ← 角色可自訂可見選單
登入後的授權計算分成三條線:
buildAuthorities() 將使用者「直掛角色 ∪ 群組角色」收斂成 Spring Security authority,並以 LinkedHashSet 去重。getUserMenus() 依同一批角色查詢 mp_role_folder,只取 MenuEnum.Folder 並組成選單樹;因此不同 Role 可以呈現不同的入口。getUserPrivileges() 查詢 mp_role_privilege,供功能/資料層的後端檢查使用。所以更精確的說法是:選單是授權結果的 UI 投影,不等於完整的後端權限。前端可以隱藏沒有入口的功能,但真正的 API 與資料操作仍必須由 Privilege 在後端再次判斷;其中 Criteria 不是靜態說明,而是可執行的動態查詢條件,會進一步決定這筆資料是否落在可讀或可修改範圍。Day 17 前端會接手選單投影與按鈕顯示這條線。

帳號防暴力破解機制:登入失敗計數、達門檻自動鎖定、管理員也能手動鎖。功能上線後一測,怎麼鎖都鎖不住。程式邏輯反覆看都是對的,最後發現問題在資料流:loadUserByUsername 用 JPQL projection 查帳號核心欄位,而 projection 漏了鎖定相關的三個欄位。現在的程式碼裡留著這段血淚註解:
// 登入閘門 isAccountNonLocked() 需要 accountLocked / lockUntil;
// registerLoginFailure 需要 failedLoginCount。未載入會讓 principal
// 這三欄恆為 null → 手動鎖與自動鎖在真實登入完全失效。
authAccount.setAccountLocked(core.getAccountLocked());
authAccount.setLockUntil(core.getLockUntil());
authAccount.setFailedLoginCount(core.getFailedLoginCount());
教訓有兩層。其一,功能寫對了不代表資料流通了:鎖定判斷讀的是 principal 上的欄位,principal 是 projection 組出來的,中間斷一節就全盤失效。其二,這類 bug 單元測試抓不到(測試裡 entity 是完整的),只有真實登入路徑的整合測試會現形。另一個設計決策是 ADMIN 帳號豁免自動鎖:管理員被鎖死就沒人能解鎖了,韌性優先於一致性。

帳號刪除用 JPA 軟刪除(@SQLDelete 改 UPDATE deleted 旗標 + @Where 過濾),歷史紀錄的關聯得以保全。但唯一約束不會理你的旗標:
使用者刪掉帳號 A → 畫面上消失了 → 重建同名帳號 A → 409 Conflict。因為那筆 deleted=true 的列還躺在資料表裡,佔著 username 的唯一鍵。
使用者的認知是「我刪掉了」,資料庫的事實是「它還在,只是看不見」。解法看資料性質:帳號這種要保歷史的,唯一鍵改成複合(含 deleted 旗標),或重建時改走「復活」既有列;拋棄式資料(如暫存設定)乾脆改硬刪除,bulk JPQL DELETE 繞過 @SQLDelete。軟刪除加唯一約束天生互斥,設計時就要選邊,不要等 409 教你。
今天的主軸是認證與授權分家:LDAP 只驗密碼,身分與權限留在本地,換 LDAP 不動授權;授權鏈跟著組織圖對齊,群組做批次授權、owner 下放維護權。至於兩個坑(projection 斷流、軟刪除佔鍵),共通點是邏輯都對、資料流斷了,這種 bug 最會躲測試。
明日 Day 8:動態表單引擎,Day 4 的 metadata 如何在前後端「活」起來,以及「沒權限的欄位該傳什麼」。
僅提供參考,比較完整的權限控管可以分 Action、Row、Column 三個維度
.Action : 能不能做這件事 (View、Update、Delete 等動作)
.Row : 能不能看或編修這筆資料
.Column : 能不能看或編修這個欄位
由權限去控管選單,應該算是 Action 維度,但需不需要做到 Row 及 Column 可能要視公司規模、產業特性、實際需求而定。
謝謝補充, 不過這裡的選單跟權限是分開處理的
選單會根據部門決定選單呈現, 比較像Portal的設計
權限的規畫還是跟Oracle Agile一樣
Role-Action-Criteria-Column
最複雜的是Criteria, 可透過設定查詢條件動態建立
稍微調整內容, 特別把criteria的部分加強說明
後面內容會再詳細說明動態查詢的實作方式