iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Software Development

30 天把舊 ERP 整合成現代微服務平台:架構治理與工程實作雙軌實戰系列 第 12 篇

Day 12|RBAC 還不夠:把角色、資源與資料範圍分開

  • 分享至 

  • xImage
  •  

假設使用者有 ORDER_VIEWER 角色,可以查訂單。那麼,他能查所有公司的訂單,還是只有自己廠區的資料?

本篇名詞小筆記

  • RBAC:角色型存取控制(Role-Based Access Control),依角色決定使用者可執行哪些功能。
  • Scope:Token 中描述授權範圍的欄位,常用來表示呼叫端獲准使用哪些 API 能力。
  • Claim:Token 內的一項身分或權限聲明,例如使用者、角色、發行者與有效期限。
  • Data Range:資料範圍,進一步限制使用者可以存取哪些公司、廠區、客戶或資料列。
  • Authority:Spring Security 把 Token 角色轉成的內部權限字串;hasRole 會自動加 ROLE_ 前綴再比對,hasAuthority 則直接比對原字串。

今天要解決的問題

規格如果只寫「訂單查詢」,實作時還是會遇到很多選擇:全公司的、自己建立的,還是負責客戶的訂單?如果沒有先確認,不同的人很可能做出不同結果,而只有一組廠區資料的測試,也不容易發現。我覺得,這個問題要在寫程式之前就先談清楚。

所以,我會把授權拆成三層,分別確認:

層次 問題 範例
Role 可執行哪類能力 ORDER_VIEWER
Resource/Scope 可對什麼做何種動作 order:read
Data Range 可存取哪個資料範圍 plant=TPE、company=100

架構師視角:哪一層由誰判斷

角色與部分政策可以由 Keycloak 管理。至於這筆資料屬於誰、使用者能不能操作,往往還要看組織、負責單位與交易狀態。我會把這些判斷留給掌握資料的業務服務。

https://ithelp.ithome.com.tw/upload/images/20260925/201842307C9KZyemgW.png

圖 Day 12-1:三層授權模型。

我會用「做這個決定需要哪些資訊」來安排責任。角色與 scope 可以放進 Token;需要即時確認的組織關係、代理設定或交易狀態,則要另外查。也要想好權限異動後,舊 Token 裡的值還能使用多久。

要注意的是,Data Range 不只是查詢條件。若只在 SQL 加上廠區過濾,那是「查不到」而不是「無權存取」;兩者在稽核與錯誤處理上的意義不同。明確的做法是先判斷授權、再執行查詢,讓拒絕成為可記錄的決定。

工程師視角:權限矩陣、claim 對應與稽核

準備權限矩陣時,我會把角色、API、動作、資料範圍、核准者與複核週期一起列出來。後端先預設拒絕,確認符合規則再放行,大家也比較知道每一項權限是怎麼開出來的。

以 Keycloak 為例,realm 層級角色與 client 層級角色是兩個不同來源:realm 角色通常對應到框架慣例的 ROLE_ 前綴形式,client 角色則保留原始值。兩者混用會出現「權限明明給了卻檢查不到」的情況,因此授權判斷要清楚區分它來自哪一個來源,Token claim 到應用權限的對應也要明確定義。

這條規則我不會散在各個檢查點,而是直接寫進 JWT 轉換器。下面是目前實作的簡化版:

/**
 * Keycloak JWT → Spring Security authorities 對應規則:
 * - realm_access.roles[]        → ROLE_<大寫>(供 hasRole 用)+ 原始值(供 hasAuthority 用)
 * - resource_access.<*>.roles[] → 原始值(只能用 hasAuthority)
 *
 * 例:realm 角色 EXTERNAL_PARTNER
 *   hasRole("EXTERNAL_PARTNER")      → 比對 ROLE_EXTERNAL_PARTNER
 *   hasAuthority("EXTERNAL_PARTNER") → 比對 EXTERNAL_PARTNER
 *
 * Gateway 為 WebFlux,另有一份 Reactive 版(gateway SecurityConfig.extractAuthorities),
 * 兩邊無法共用;修改本規則時需同步修改該處。
 */
public class KeycloakJwtConverter implements Converter<Jwt, AbstractAuthenticationToken> {

    @Override
    public AbstractAuthenticationToken convert(Jwt jwt) {
        List<GrantedAuthority> authorities = new ArrayList<>();

        // realm_access.roles[] → ROLE_<大寫>,同時保留原始值
        Map<String, Object> realm = jwt.getClaim("realm_access");
        for (String role : rolesOf(realm)) {
            authorities.add(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()));
            authorities.add(new SimpleGrantedAuthority(role));
        }

        // resource_access.<client>.roles[] → 原始值,不加前綴
        Map<String, Object> resources = jwt.getClaim("resource_access");
        if (resources != null) {
            resources.forEach((clientId, value) -> {
                if (value instanceof Map<?, ?> client) {
                    rolesOf(client).forEach(r -> authorities.add(new SimpleGrantedAuthority(r)));
                }
            });
        }
        return new JwtAuthenticationToken(jwt, authorities);
    }

    @SuppressWarnings("unchecked")
    private static List<String> rolesOf(Map<?, ?> access) {
        if (access == null || access.get("roles") == null) return List.of();
        return (List<String>) access.get("roles");
    }
}

為什麼 realm 角色要輸出兩次?Spring Security 的 hasRole 會自己補 ROLE_ 前綴再找,hasAuthority 不補直接找。只輸出其中一種,就會有一種寫法永遠找不到。兩種都輸出,兩種寫法都能命中。client 角色只輸出原值,檢查時就只能用 hasAuthority。

用一個假設的 token 走一遍,比較容易看出每一行在做什麼(以外部夥伴角色 EXTERNAL_PARTNER 為例):

{
  "sub": "partner-001",
  "realm_access": { "roles": ["EXTERNAL_PARTNER", "user"] },
  "resource_access": {
    "external-client": { "roles": ["EXTERNAL_PARTNER"] },
    "account":         { "roles": ["view-profile"] }
  }
}

轉換後 authorities 這個 List 裡有六筆(去掉重複後剩五種不同字串):

# 來源 claim 原始角色 產出的 authority 說明
1 realm_access EXTERNAL_PARTNER ROLE_EXTERNAL_PARTNER 加 ROLE_ 前綴並轉大寫
2 realm_access EXTERNAL_PARTNER EXTERNAL_PARTNER 保留原值
3 realm_access user ROLE_USER 加 ROLE_ 前綴並轉大寫
4 realm_access user user 保留原值
5 resource_access.external-client EXTERNAL_PARTNER EXTERNAL_PARTNER 與第 2 筆字串相同;List 不會去掉重複,仍佔一筆
6 resource_access.account view-profile view-profile 只有原值,沒有 ROLE_ 版本

再拿幾種常見的檢查寫法對照:

檢查寫法 實際比對的字串 命中 原因
hasRole("EXTERNAL_PARTNER") ROLE_EXTERNAL_PARTNER 是 第 1 筆
hasAuthority("EXTERNAL_PARTNER") EXTERNAL_PARTNER 是 第 2 筆或第 5 筆都能命中
hasRole("user") ROLE_user 否 轉換器輸出的是大寫 ROLE_USER,hasRole 不會幫你轉大小寫
hasRole("view-profile") ROLE_view-profile 否 client 角色從未產出 ROLE_ 前綴版本
hasAuthority("view-profile") view-profile 是 client 原值

因為 ROLE_ 版本被強制大寫,hasRole 的參數一律要用大寫。角色可能掛在 realm,也可能掛在 client,兩種情況都要能放行,所以 Gateway 對外部夥伴的路徑選用 hasAuthority("EXTERNAL_PARTNER") 而不是 hasRole。這些差別我直接寫在轉換器的類別註解裡,查權限的人不用翻實作就知道該用哪種寫法。註解最後那段也提醒了 Gateway 另有一份 Reactive 版本要同步改。

政策會修改,之後要能知道當時為什麼做出這個決定,所以被拒絕的請求,也要留下能追查的紀錄,尤其要保留政策版本。使用者識別、資源、動作、政策版本與 correlation ID 都很有幫助,但敏感內容要避開。

策略取捨與限制

取捨 這樣選的理由 何時要重新評估
穩定職責放角色,動態條件放政策 角色數量可控,變動條件不需重新發 Token 政策判斷複雜到難以測試與說明時
資料範圍由業務服務判斷 入口缺少組織與交易狀態資訊 需要在入口做粗粒度阻擋以保護下游時
後端一律預設拒絕 漏設規則時失敗方向是安全的 無
授權失敗全部留稽核 誤拒與越權都需要可追溯 紀錄量造成儲存或效能問題時,調整保留期而非欄位

角色開得太大,容易多給權限;拆得太細,日常維護又會很辛苦。我會一起看使用者因權限求助的情況,再確認角色是不是符合實際分工,避免大家一直靠人工補開權限才能工作。

驗證方式與衡量指標

指標 計算方式 想回答的問題
越權測試通過率 應被拒絕而確實被拒絕的案例/全部案例 三層授權是否都真的生效?
閒置高權限帳號數 長期未使用的高權限帳號數 權限是否只增不減?
權限複核完成率 已複核角色/應複核角色 複核週期是否被執行?
403 異常增加情況 403 回應數的變化趨勢 權限調整是否誤擋了正常作業?

測試時,我會準備不同公司、不同廠區的資料,再加上停權與撤銷權限的情境。除了確認原本能做的事,也要看看權限改變之後,該擋的操作有沒有真的被擋下來。

今天先整理到這裡

從登入走到資料授權,每一層的責任就比較清楚了。下一篇,回到 ERP 的介接,看看 SOAP 前面加上一層 Adapter,能幫我們整理哪些事情。

參考資料

  1. Keycloak, Keycloak Authorization Services,查閱日期:2026-09-25。
  2. OWASP, OWASP Authorization Cheat Sheet,查閱日期:2026-09-25。
  3. Spring, OAuth 2.0 Resource Server JWT(Reactive),查閱日期:2026-09-25。
  4. Spring, Spring WebFlux,查閱日期:2026-09-25。

上一篇
Day 11|Keycloak、OAuth 2.0、OIDC 與 JWT:別再把登入和授權混在一起
下一篇
Day 13|ERP SOAP 前為什麼需要 Adapter?
系列文
30 天把舊 ERP 整合成現代微服務平台:架構治理與工程實作雙軌實戰 共 13 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言