假設使用者有 ORDER_VIEWER 角色,可以查訂單。那麼,他能查所有公司的訂單,還是只有自己廠區的資料?
hasRole 會自動加 ROLE_ 前綴再比對,hasAuthority 則直接比對原字串。規格如果只寫「訂單查詢」,實作時還是會遇到很多選擇:全公司的、自己建立的,還是負責客戶的訂單?如果沒有先確認,不同的人很可能做出不同結果,而只有一組廠區資料的測試,也不容易發現。我覺得,這個問題要在寫程式之前就先談清楚。
所以,我會把授權拆成三層,分別確認:
| 層次 | 問題 | 範例 |
|---|---|---|
| Role | 可執行哪類能力 | ORDER_VIEWER |
| Resource/Scope | 可對什麼做何種動作 | order:read |
| Data Range | 可存取哪個資料範圍 | plant=TPE、company=100 |
角色與部分政策可以由 Keycloak 管理。至於這筆資料屬於誰、使用者能不能操作,往往還要看組織、負責單位與交易狀態。我會把這些判斷留給掌握資料的業務服務。

圖 Day 12-1:三層授權模型。
我會用「做這個決定需要哪些資訊」來安排責任。角色與 scope 可以放進 Token;需要即時確認的組織關係、代理設定或交易狀態,則要另外查。也要想好權限異動後,舊 Token 裡的值還能使用多久。
要注意的是,Data Range 不只是查詢條件。若只在 SQL 加上廠區過濾,那是「查不到」而不是「無權存取」;兩者在稽核與錯誤處理上的意義不同。明確的做法是先判斷授權、再執行查詢,讓拒絕成為可記錄的決定。
準備權限矩陣時,我會把角色、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,能幫我們整理哪些事情。