# CR-011　服務間呼叫**欠缺呼叫者身分識別** → `SAD-002 §5` 授權白名單**技術上無法實作**

| 項目 | 內容 |
|---|---|
| 文件編號 | CR-011 |
| 版本 | **1.3**（§11 gateway／bff 之爭議**BEAM PMO 2026-09-21 17:10 核定採 B**；轉傳機制**已執行並測試通過**）⚠️ 1.2 版執行後**未回填本欄**，本次一併更正 |
| 狀態 | ✅ **已核定**：身分識別採 **A**（每模組一組專屬 M2M client）；gateway／bff 之呼叫方式採 **B（轉傳使用者權杖）**。✅ **階段一、二皆已完成並實測** |
| 提出日期 | 2026-09-21 |
| 提出者 | WorkBuddy AI（開發團隊） |
| 關聯 | `CR-010`、`SAD-002 §5`、`SAD-005 §3.2`／**`§3.4`（身分轉傳原則）**、`SCS-022 §7.9`／**`§7.10`（白名單）**／**`§7.11`（Token Relay）** |
| 嚴重度 | 🟡 中（非可用性缺陷；屬「最小權限」未落實） |

---

## 1. 背景

`CR-010` 修復「服務間呼叫未帶授權標頭」後，服務間呼叫**能通過認證**了。
但認證（你是誰）與授權（你能做什麼）是兩件事：

> **目前狀態：持有有效服務權杖者，可呼叫**任何**服務的**任何**端點。**

`SAD-005 §3.2` 明訂「授權範圍：每個服務僅能呼叫其被允許之服務端點（見 `SAD-002 §5`）」，
`SAD-002 §5` 亦有完整之服務互動矩陣。**規格已備，實作卻付之闕如**。

本次嘗試依矩陣實作白名單時，發現**先決條件不成立**——詳 §2。

---

## 2. 實測證據（2026-09-21）

### 2.1 權威來源：`SAD-002 §5` 互動矩陣（節錄）

`S`＝同步（HTTP）。以 `statement` 為例：

| 呼叫者 ＼ 被呼叫 | auth | limit | card | party | product | posting | payment | statement | pricing | … |
|---|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|---|
| statement | — | — | — | **S** | **S** | **S** | — | — | — | … |

→ `statement` 僅應能同步呼叫 `party`／`product`／`posting`（＋ `pricing`，見完整表）；
呼叫 `authorization`／`limit`／`card`／`payment` **應被拒**。

### 2.2 實測：被呼叫端拿到的 token **無法識別呼叫者**

以 `client_credentials` 取 token（Keycloak 實測 HTTP 200），解開 claims：

```
azp             = cardcore-services          ← ★ 所有服務皆同一值
client_id       = cardcore-services
aud             = ['cardcore-api', 'account']
scope           = profile email
typ             = Bearer
sub             = e2456954-9315-45c5-835f-fc0e32b43dd2
realm_access    = {roles: [default-roles-cardcore, offline_access, uma_authorization]}
resource_access = {account: {roles: [manage-account, manage-account-links, view-profile]}}
```

**全部 claim keys**：`acr, aud, azp, clientAddress, clientHost, client_id, email_verified,
exp, iat, iss, jti, preferred_username, realm_access, resource_access, scope, sub, typ`

> ★ **關鍵**：`azp`／`client_id` **恆為 `cardcore-services`**，
> `realm_access.roles` 全為**通用角色**（無任何服務專屬角色）。
> 17 個服務取到的 token **內容完全一致**（僅 `jti`／`exp`／`sub` 不同）。
> → **被呼叫端無從判斷「是哪個服務在呼叫我」。**

### 2.3 程式端：完全沒有讀取呼叫者身分

`common-spring` 之 `src/main/java` 內，`azp`／`aud` 僅出現於**出站取 token** 之情境
（`ClientCredentialsTokenProvider`、`OutboundTokenAutoConfiguration` 之 `clientId`），
**沒有任何入站授權判斷**讀取 `azp` 或 `aud`。

---

## 3. 根因：這是 `CR-010` 方案 A 的**既有代價**，不是新缺陷

`CR-010 §4` 三方案中，BEAM PMO 核定採 **A：單一服務帳號（`client_credentials`）**，
其優點為「IdP 端已備妥、不新增依賴、集中於 `common-spring`，17 服務不必重工」。

**該優點的反面即是本次問題**：既然 17 個服務共用同一組 client，
身分就只能是「某個持有 `cardcore-services` 憑證的人」，而**不是「statement-service」**。

> 換言之：`CR-010` 解決了「**有沒有帶證件**」；
> 本次要解決的是「**證件上要不要寫名字**」——而現在的證件**沒寫名字**。

---

## 4. 選項與建議

### 方案 A ⭐　**每服務一組 M2M client**（`azp` 即服務身分）

Keycloak 建立 17 組 confidential client（`cc-stmt`／`cc-posting`／…），
各服務以其專屬 `client-id`／`client-secret` 取 token → `azp` 即為**服務身分**；
被呼叫端依 `SAD-002 §5` 矩陣（轉置為「我允許誰呼叫我」）比對 `azp`。

| | |
|---|---|
| 優點 | 身分識別**乾淨且可稽核**；真正落實 SP-02 最小權限；白名單邏輯單純（比對 `azp`） |
| 代價 | **17 組 client ＋ secret 之生命週期管理**（須由 KMS 提供、輪替）；Keycloak realm 組態擴增；各服務組態之 `client-id` 不再相同（模板之 `$name` 可自動化） |
| 風險 | secret 管理若未制度化，會比單一 client **更難維運** |

### 方案 B　**維持單一 client，改以 audience 區分**

每個服務定義專屬 audience（如 `cardcore-posting`）；呼叫端取 token 時指定目標 audience。

| | |
|---|---|
| 優點 | client 仍為單一，管理負擔低 |
| 代價 | 呼叫端須為**每個目標服務**各取一份 token → 現行「單一快取」須改為**多鍵快取**；且 audience 語意是「這個 token 給誰用」，**不能證明呼叫者是誰**——只能限縮「用途」，無法限縮「來源」 |
| 風險 | 看似解決、實則**只做半套**；與 `SAD-005 §3.2`「每個服務僅能呼叫其被允許之服務端點」之**來源限縮**語意不符 |

### 方案 C　**暫不實作，維持現狀並列為已知落差**

| | |
|---|---|
| 優點 | 不動程式、不增維運負擔 |
| 代價 | 與 `SAD-005 §3.2` 之授權範圍要求**明確不符**；違反 SP-02 最小權限；客戶資安審查時須誠實揭露 |

### 建議

**建議採 A**，理由：
1. 唯有 A 能真正表達「**來源**」限縮，符合 `SAD-005 §3.2` 原意；B 只限縮用途。
2. 憑證管理本來就該由 KMS 承擔（本地已有 `~/.config/cardcore/*.env` 外置慣例，見 `#9`），
   **不是**「多幾組就做不了」的問題，而是「早晚要做」的基礎設施。
3. 17 組 client 可由**產生器／腳本**批次建立，不必手工。

若考量本期時程，**C 亦可接受**，但須於 `SCS-022 §10` 與 `SAD-005 §3.2` 明列為已知落差，
**不得**以「已有服務權杖」宣稱授權範圍已落實。

---

## 5. 設計（採 A）

### 5.1 命名規則

| 項 | 規則 | 例 |
|---|---|---|
| `clientId` | `cc-<模組名>` | `cc-statement-service` |
| `secret`（本地） | `cc-<schema>-dev-secret`；無 schema 者 `cc-<模組名>-dev-secret` | `cc-stmt-dev-secret`、`cc-api-gateway-dev-secret` |
| 環境變數 | `CARDCORE_M2M_CLIENT_SECRET_<後綴>`；後綴＝領域服務採 schema 大寫（`STMT`），平台模組採模組名大寫且 `-`→`_`（`API_GATEWAY`） | `CARDCORE_M2M_CLIENT_SECRET_STMT` |

> ⚠️ 後綴規則**必須**與 `gen_service_skeleton.py` 之 `m2m_env_suffix` 完全一致，
> 兩處皆為同一規則之實作（已於產生器註解標明），改動時須同步。

### 5.2 為何 secret 須**逐模組**不同（而非共用同一環境變數名）

本地開發會**同時**啟動多個服務（如 `statement`＋`posting`＋`pricing`）。
若共用 `CARDCORE_M2M_CLIENT_SECRET` 一名，所有服務讀到**同一個值** →
`azp` 全部相同 → **又回到「認不出呼叫者」**。
故必須以 `$m2m_env_suffix` 產生**逐模組**之變數名。

### 5.3 階段劃分

| 階段 | 內容 | 狀態 |
|---|---|---|
| **一、身分識別** | 19 組專屬 client ＋ 組態使 `azp` ＝服務身分 | ✅ **完成並實測**（§9） |
| **二、白名單機制** | 入站依 `SAD-002 §5` 矩陣比對 `azp`，不符回 403；白名單未設定時**啟動失敗**（fail-closed） | ⬜ **未施作** |

---

## 6. 影響範圍

| 產出 | 變更 |
|---|---|
| `tools/gen_keycloak_m2m_clients.py`（**新增**） | 批次建立／檢查 19 組專屬 client；`--check`／`--write`／`--print-env`／`--sync-realm-json`。零相依（stdlib only），比照既有 `gen_*.py` 之「只建立不存在者」慣例 |
| Keycloak realm `cardcore` | 新增 **19** 組 confidential client（17 領域 ＋ `api-gateway`／`bff-web`），各含 `audience-cardcore-api` mapper |
| `ops/local/config/keycloak-realm.json` | 同步寫入 19 組（**環境重建之可重現性**——否則重建後 client 全數消失） |
| `tools/gen_service_skeleton.py` | ①新增 `m2m_env_suffix` 佔位符；②`T_APP_LOCAL_YML` 與 `T_APP_LOCAL_YML_CORE` **兩模板**之 `client-id` 改 `cc-$name`、`client-secret` 改 `CARDCORE_M2M_CLIENT_SECRET_$m2m_env_suffix` |
| **19 份** `application-local.yml` | 逐模組改為專屬 client（產生器只寫不存在者 → 模板＋既有檔兩邊都動） |
| `~/.config/cardcore/m2m.env` | 改為 **19 組**變數（repo 外、權限 600）；舊共用變數保留但註明停用 |
| 程式 | **未改**（`OutboundTokenAutoConfiguration`／`ClientCredentialsTokenProvider` 原樣可吃新組態） |

## 7. 風險揭露

| # | 風險 | 說明 |
|---|---|---|
| 1 | **白名單機制尚未施作** | 本次僅完成「**認得出呼叫者**」；「**只放行該放的**」仍待階段二。現況仍是「持有有效權杖可呼叫任何端點」——**此為自 `CR-010` 起即存在之狀態，非本次引入** |
| 2 | 19 組 secret 明文見於 `ops/local/config/keycloak-realm.json` | **本地 keycloak 匯入用**，與既有 `cardcore-services` 同性質（既有狀況，非本次引入）；真實環境一律由 KMS 提供，不得進版控 |
| 3 | 憑證管理負擔上升 | 由 1 組增為 19 組；輪替須制度化（本地已有 `~/.config/cardcore/*.env` 外置慣例，見 `#9`） |
| 4 | 舊共用 client `cardcore-services` **仍存在** | 未刪除（避免破壞既有）；任何持有其 secret 者仍可取到「無身分」之 token。**階段二上線前應評估是否停用** |
| 5 | 尚未於 SIT／UAT／PROD 驗證 | 僅本機實測 |

## 8. 核定結果

✅ **BEAM PMO 2026-09-21 裁定：採 A（每服務一組 M2M client）。**

## 9. 執行結果（階段一，2026-09-21 實測）

### 9.1 IdP

`gen_keycloak_m2m_clients.py --check` → 缺漏 17；`--write` → **建立 17**；
再補 `api-gateway`／`bff-web` → **建立 2**（共 **19**）；`--sync-realm-json` → realm json 內 `cc-*` **19 組**。

### 9.2 ★ 核心實測：各服務以專屬 client 取 token，`azp` 即服務身分

| client | `azp` |
|---|---|
| `cc-statement-service` | `cc-statement-service` ✅ |
| `cc-posting-service` | `cc-posting-service` ✅ |
| `cc-pricing-service` | `cc-pricing-service` ✅ |
| （對照）舊共用 `cardcore-services` | `cardcore-services`（**無身分**） |

→ **三者互不相同**，方案 A 之先決條件成立；`aud` 維持 `['cardcore-api','account']`。

### 9.3 組態與啟動實測

- 19 份 `application-local.yml` 全數改為 `cc-<name>` ＋ 逐模組 secret 變數
- 憑證：19 組寫入 `~/.config/cardcore/m2m.env`（權限 600，載入測試 `變數數=19`）
- **啟動實測（`statement-service`，`CARDCORE_SECURITY_ENABLED=true`）**：
  - `服務間呼叫憑證已啟用：client_credentials（client=**cc-statement-service**）` ✅
  - `認證機制已啟用：JWT（OAuth2 Resource Server）；公開路徑 4 個` ✅
  - `Started StatementServiceApplication in 18.229 seconds`、8092 監聽 ✅（實測後已停止）
- `gen_service_skeleton.py --check`：25 模組、缺漏 0

### 9.4 階段二：白名單機制（2026-09-21 實測）

**單一真實來源**：`SAD-002 §5` 互動矩陣 → `tools/gen_service_acl.py --write`
→ `backend/platform/common-spring/src/main/resources/cardcore-service-acl.yml`。
**本檔不手改**——改動請修 `SAD-002 §5` 後重跑產生器，避免出現兩份真相。

| 產出 | 說明 |
|---|---|
| `tools/gen_service_acl.py`（**新增**） | 解析矩陣 markdown 表格 → **轉置**為「被呼叫端 → 允許之 `azp`」；只取同步（`S`），事件（`A`）不管制 |
| `cardcore-service-acl.yml` | 19 條目（17 領域 ＋ `api-gateway`／`bff-web`） |
| `ServiceAcl.java` | 載入／解析／`allowedCallersOf`／`isServiceAccount`／`isAllowed`。**查無自身條目即拋錯**（fail-closed，非靜默空集合）；解析失敗一律拋錯 |
| `ServiceCallerAclManager.java` | `AuthorizationManager<RequestAuthorizationContext>`；分流「服務帳號 → 查 ACL」與「使用者 token → 放行」 |
| `SecurityAutoConfiguration` | `.access(new ServiceCallerAclManager(...))` 取代 `.authenticated()` |
| `ServiceAclTest`（10 例）＋ `ServiceAclFilterTest`（4 例） | 後者為**過濾器層級**——若忘了掛上 `HttpSecurity`，前者仍會全綠（假綠），故必須走完整 filter chain 斷言 |

**⚠️ 產生器修正（矩陣本身之不一致）**：互動矩陣表頭用縮寫 `auth`、呼叫者列卻用全名 `authorization`
→ 產生器原先略過該列，使 `card`／`limit`／`product` 各少一個允許者。
修法：`SHORT_TO_NAME` **同時收錄兩者**。（矩陣不一致本身應回頭修 `SAD-002 §5`，此處僅讓產生器容錯。）

**★ 實測（真實 Keycloak token 打真實 `posting-service`，8087，
`GET /api/v1/accounts/{id}/balances`）**

| # | 情境 | 結果 |
|---|---|---|
| ① | 無憑證 | `401` `CC-PLT-4010` |
| ② | `cc-statement-service`（`SAD-002 §5` 為 S） | `404` `CC-POST-4040` —— **ACL 通過**（查無該分戶，屬業務錯誤） |
| ③ | `cc-clearing-service`（矩陣非 S） | `403` `CC-PLT-4030` ✅ |
| ④ | `cc-bff-web` | `403` `CC-PLT-4030` ⚠️ —— 見 §11 |

- 服務端日誌：`⛔ 服務間呼叫遭拒：本服務=posting-service 呼叫者=cc-clearing-service —— 未列於 SAD-002 §5 互動矩陣之允許清單`，
  其 traceId 與 HTTP 回應之 `traceId` **一致**
- 啟動日誌：`認證機制已啟用…；服務間呼叫 ACL 已載入（19 個條目，本服務允許 **4** 個呼叫者）`
  （`posting-service` 之 4 個＝dispute／payment／rewards／statement，與矩陣相符）

**★ 判定力驗證（負樣本，證明測試不是「寫了就綠」）**

| 竄改 | 轉紅案例 |
|---|---|
| 決策一律**放行** | 5 例：`ServiceAclTest` ⑧⑨、`ServiceAclFilterTest` ②④、`SecurityFailClosedTest` |
| 決策一律**拒絕** | 3 例：`ServiceAclTest` ⑨、`ServiceAclFilterTest` ①③ |

→ 兩個方向皆精準反應，其餘案例不受影響。

**⚠️ 本次實作抓到的自身缺陷（誠實揭露）**

初版以 `auth.isAuthenticated()` 判斷，但 **`AnonymousAuthenticationToken.isAuthenticated()` 為 `true`**
（Spring Security 刻意如此設計）→ 無憑證請求落入「非服務帳號一律放行」分支而**被放行**，實測回 **200**。
此缺陷**單元測試沒抓到**——負樣本用了 `TestingAuthenticationToken(false)`，非真實型別；
是 `ServiceAclFilterTest ④`（走完整 filter chain）抓到的。
修法：①顯式排除 `AnonymousAuthenticationToken`；②單元測試負樣本改用真實型別。
→ 教訓：**負樣本必須覆蓋所有判斷點，且必須用真實型別**。

### 9.5 測試數

`common-spring`：71 → **85** 例（+14），`0` 失敗、`0` 略過。

---

## 11. ✅ `api-gateway`／`bff-web` 之呼叫方式——**BEAM PMO 2026-09-21 17:10 核定採 B（轉傳使用者權杖）**

**實測**：以 `cc-bff-web` 之 token 呼叫 `posting-service` → **403 `CC-PLT-4030`**。

**原因**：`SAD-002 §5` 互動矩陣僅涵蓋 **17 個領域服務**，不含 gateway／bff。
產生器對二者補 `[]` 的結果是——它們**不允許任何服務帳號呼叫**，同時**沒有任何服務允許它們呼叫**。
（此與 `CR-010 §9.7` 之根因同源：`SCS-022 §3` 原記載「並無 gateway／bff 模組」，致二者被規格排除。）

**現況**：兩模組皆為**骨架**（`api-gateway` 無路由設定、`bff-web` 無 Feign client），
故目前**無立即故障**；但一旦 bff 以 M2M 呼叫領域服務，即**全數 403**。

### 選項

| | 做法 | 後果 |
|---|---|---|
| **A** | 於 `SAD-002 §5` 新增 `api-gateway`／`bff-web` 兩列，允許其呼叫領域服務 | 二者取得「可呼叫所有服務」之**超級身分**；且服務端**失去使用者身分** |
| **B** | 明訂 gateway／bff **轉傳使用者 token**，不使用 M2M 呼叫領域服務 | 現行 ACL **無須修改**；使用者 token（`azp` 非 `cc-`）本就不受 ACL 管制，交角色授權 |

### 我的看法：傾向 **B**

1. gateway／BFF 是「**使用者的代理人**」——改用 M2M 會使服務端**失去使用者身分**，
   使用者層級授權與「誰做的」稽核皆無從實作（銀行核心之硬需求）。
2. 現行 ACL 設計**已預留此路徑**：使用者 token 一律放行、交由角色授權，正是預期 gateway／bff 轉傳。
3. 採 A 等於給 gateway／bff 超級權限，違反最小權限原則。

⚠️ 若採 B，`CR-010 §9.5` 為 gateway／bff 補上的**出站 M2M 組態即不應被使用**——
須於 `SAD-005` 明訂「轉傳原則」，避免日後誤用。

### 影響範圍（2026-09-21 15:50 複核——三件事皆為實測，非推測）

| # | 查證項 | 結果 |
|---|---|---|
| 1 | gateway／bff 是否真的只是骨架 | ✅ **屬實**：兩模組 `src/main/java` 各僅 1 個 `Application` 類別，加 `application.yml`／`application-local.yml` 與 1 個 `LayeringTest`——**無路由設定、無 Feign client、無任何出站呼叫** |
| 2 | WebUI 目前是否經過 gateway／bff | ❌ **沒有**——`frontend/vite.config.ts` 之 proxy **直連各領域服務**（8081／8082／8083／8084／8087／8088／8089／8091／8092），且註解明載「**正式環境應統一由 API Gateway（8100）出口**」 |
| 3 | 使用者 token 是否受影響 | ❌ **不受影響**——`azp=cardcore-web`（非 `cc-`），`ServiceCallerAclManager` 判定「非服務帳號」→ 放行（`SCS-022 §7.10` 規則 3） |

**結論**：**目前影響為 0**——沒人呼叫 gateway／bff，WebUI 也不經過它們。
但這是**已預定的地雷**：`vite.config.ts` 之設計註解已明訂正式環境要走 Gateway 8100，
且 `CR-010 §9.5` **已為二者補上出站 M2M 組態**——一旦有人照該組態實作 bff 呼叫，即**全數 403**。

### 附帶實測（2026-09-21 15:50）：本 ACL 之嚴格程度

以真 Keycloak token 打 `installment-service` 8089：

| 呼叫者 `azp` | 結果 |
|---|---|
| `cc-statement-service` | **403** |
| `cc-bff-web` | **403** |
| `cc-api-gateway` | **403** |

⚠️ **三者皆 403 並非缺陷**——`SAD-002 §5` 矩陣之 `installment` 欄（被呼叫端）**全列皆 `—`**，
即**無任何服務以同步方式呼叫分期服務**，故其允許清單本來就是 `[]`（產生器轉置結果正確）。

★ **但這揭示一項性質**：此白名單**極為嚴格**——任何「矩陣未畫到」的同步呼叫都會被擋。
日後若出現新的同步呼叫需求（例如帳單需同步讀取分期期數），**必須回頭改 `SAD-002 §5` 再重跑產生器**，
不得直接改 `cardcore-service-acl.yml`（該檔由產生器產出，**禁止手改**）。

### ✅ 執行結果（2026-09-21 18:00，採 B 後之落地）

| # | 項目 | 內容 |
|---|---|---|
| 1 | **設計層** | `SAD-005` → **1.3**：新增 **§3.4「身分轉傳原則（Token Relay）」**；並更正 §3.2 之過時記載（「端點白名單 ⚪ 未實作」→ ✅ 已實作） |
| 2 | **規格層** | `SCS-022` → 新增 **§7.11「使用者權杖轉傳（Token Relay）」**（模式組態、行為規格、fail-closed、與 ACL 之關係、已知限制） |
| 3 | **程式** | 新增 `OutboundMode`（`CLIENT_CREDENTIALS`／`TOKEN_RELAY`）、`RelayingOutboundTokenProvider`；`SecurityProperties.Outbound` 增 `mode`；`OutboundTokenAutoConfiguration` 依模式分流 |
| 4 | **組態** | `api-gateway`／`bff-web` 之 `application-local.yml` 改為 `mode: token-relay`，**並移除 client-id／client-secret**；產生器 `T_APP_LOCAL_YML_CORE` 模板同步（`--check` 25 模組、缺漏 0） |
| 5 | **測試** | 新增 `RelayingOutboundTokenProviderTest` **5 例**、`OutboundTokenGuardTest` 增 **1 例**（★「入站啟用 ＋ 無憑證 ＋ token-relay」**啟動成功**）；`common-spring` **85 → 91 例，0 失敗 0 略過** |

★ **關鍵決策：以「模式切換」而非「刪除出站組態」**——`OutboundTokenAutoConfiguration` 之 fail-closed
為「入站啟用 ＋ 出站憑證未設定 → **啟動期失敗**」。若直接刪除 gateway／bff 之出站組態，
二者於 `enabled=true` 時**會起不來**。故新增 `TOKEN_RELAY` 模式，
讓「不需要 M2M 憑證」成為**可被表達的合法狀態**，而非「設定缺漏」。

★ **實測抓到之陷阱（本次）**：`JwtAuthenticationToken` **單引數**建構式（`JwtAuthenticationToken(Jwt)`）
**不會**呼叫 `setAuthenticated(true)` → 其 `isAuthenticated()` 為 **false**；
僅**雙引數**（帶 authorities）版本才會（反查 Spring Security 7.1.1 位元碼確認）。
生產路徑（`JwtAuthenticationProvider`）用雙引數版本，故行為正確；
但測試若誤用單引數建構式，會得出「JWT 身分未認證」之**錯誤結論**。已立為測試案例 ⑤。

⚠️ **未驗證**：gateway／bff 仍為骨架（無路由、無 Feign client），**轉傳尚無端到端實測**
（無呼叫端可測）。本節屬**機制先行**，待二者實際接線時須補端到端驗證。



---

## 12. 修訂記錄

| 版本 | 日期 | 說明 |
|---|---|---|
| 1.0 | 2026-09-21 | 初版。嘗試依 `SAD-002 §5` 實作服務對服務端點白名單時，實測發現**先決條件不成立**：所有服務共用單一 M2M client，`azp` 恆為 `cardcore-services` 且無服務專屬角色 → 被呼叫端**無法識別呼叫者**。指出此為 `CR-010` 採方案 A 之既有代價，非新缺陷。提列三方案並建議採 A |
| 1.1 | 2026-09-21 | **BEAM PMO 核定採 A**。執行**階段一（身分識別）**並實測通過：①新增 `tools/gen_keycloak_m2m_clients.py`（`--check`／`--write`／`--print-env`／`--sync-realm-json`，零相依）；②建立 **19 組**專屬 client（17 領域 ＋ gateway／bff）並同步回 `keycloak-realm.json`（**環境重建之可重現性**）；③產生器新增 `m2m_env_suffix` 佔位符，**兩個模板**與 **19 份既有 yml** 全改為 `cc-$name` ＋ 逐模組 secret；④憑證 19 組置於 `~/.config/cardcore/m2m.env`（600）。★ **核心實測**：各服務以專屬 client 取 token → `azp` 分別為 `cc-statement-service`／`cc-posting-service`／`cc-pricing-service`（**互不相同**）；啟動實測見「`client=cc-statement-service`」。⚠️ **階段二（白名單機制：比對 `azp`、不符回 403、未設定即啟動失敗）尚未施作**——現況仍為「持有有效權杖可呼叫任何端點」，此狀態自 `CR-010` 起即存在、非本次引入 |
| 1.2 | 2026-09-21 | 執行**階段二（白名單機制）**並實測通過：①新增 `tools/gen_service_acl.py`，以 `SAD-002 §5` 為**單一真實來源**轉置產生 `cardcore-service-acl.yml`（19 條目，不手改）；②新增 `ServiceAcl`／`ServiceCallerAclManager`（`AuthorizationManager`），掛載於 `SecurityAutoConfiguration` 之 `.access(...)`；③新增 `ServiceAclTest`（10 例）與 `ServiceAclFilterTest`（4 例，**過濾器層級**）。★ **真機實測**（真 Keycloak token → 真 `posting-service`）：無憑證 `401`／`cc-statement-service`（矩陣 S）通過／`cc-clearing-service`（非 S）`403 CC-PLT-4030`，服務端拒絕日誌之 traceId 與回應一致。★ **負樣本驗證**：決策改一律放行 → 5 例轉紅、一律拒絕 → 3 例轉紅，判定力確認。⚠️ **本次實作抓到自身缺陷**：初版未排除 `AnonymousAuthenticationToken`（其 `isAuthenticated()` 為 `true`）→ 無憑證請求被**放行**（實測回 200）；單元測試負樣本用錯型別故未抓到，由 `ServiceAclFilterTest ④` 抓出，已修並改用真實型別。⚠️ **新發現待裁定（§11）**：`cc-bff-web` 呼叫領域服務亦為 `403`——`SAD-002 §5` 未涵蓋 gateway／bff；兩模組現為骨架故無立即故障，但**傾向採「轉傳使用者 token」而非放行 M2M**。`common-spring` 測試 71 → **85** 例全綠 |
| 1.3 | 2026-09-21 | **BEAM PMO 2026-09-21 17:10 核定採 B（轉傳使用者權杖）**，本版執行並測試通過：①**設計層** `SAD-005` → 1.3（新增 §3.4 身分轉傳原則；更正 §3.2「白名單未實作」之過時記載）；②**規格層** `SCS-022` 新增 §7.11（Token Relay）；③**程式** 新增 `OutboundMode`／`RelayingOutboundTokenProvider`，`OutboundTokenAutoConfiguration` 依模式分流；④**組態** gateway／bff 改 `mode: token-relay` 並移除 client 憑證（產生器模板同步）；⑤**測試** `common-spring` **85 → 91** 0 失敗。★ **何以是模式切換而非刪除組態**：fail-closed 會使刪除後於 `enabled=true` 啟動失敗。★ **實測陷阱**：`JwtAuthenticationToken` 單引數建構式之 `isAuthenticated()` 為 false。⚠️ 另：1.2 版執行後**未回填檔頭版本欄**（仍記 1.1），本次一併更正 |
