Files
BPOS-Service-Gateway/docs/keycloak-setup-log.md
T
lovefyfandClaude Opus 5 ec0293fb79 docs: Keycloak 配置补齐——service 角色/audience mapper 落地与 token 验证
人类补授 manage-realm + manage-clients 后重跑第 1 轮被 403 的操作:
① realm 角色 service 已建(201);② 已挂到 task-dispatcher / api-gateway
两个服务账号(204,composite 复核含 service);③ task-dispatcher 加
aud-data-service + aud-agent-service(201×2);④ api-gateway 加
aud-task-dispatcher(201);⑤ web-portal 早已存在,全程零写操作。

三项验证解码真实 token 全部 PASS,Task 11 的人类管理员路径与服务间
调用路径前置条件均已满足。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 07:08:06 +08:00

258 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Keycloak 配置与验证记录(N1 Task 10)
> **目标 realm**:`corp` @ `https://keycloak.akhr-ai.com`
> **依据**:`docs/keycloak-agent-guide.md` §6(admin token)、§7.4/§7.5(角色与 mapper)、§10(验证)
> **最终结论**:**DONE** —— ①②③④ 全部落地,⑤ 早已存在无需变更;三项 token 验证全部 PASS。
> **本记录不含任何 secret、密码或完整 token**,只记录操作与解码后的 `aud` / `realm_access.roles` claim。
本任务分两轮执行:
| 轮次 | 时间 | 结果 |
|---|---|---|
| 第 1 轮 | 2026-08-13 | **BLOCKED** —— provisioning 服务账号缺 `manage-realm` / `manage-clients`,①③④ 被 403,② 因 ① 未完成而阻塞 |
| 第 2 轮 | 2026-08-13(人类补授权限后) | **DONE** —— ①②③④ 全部成功,验证全绿 |
---
## 0. 执行凭证与权限变化
按指南 §6.2 用 master realm 的 `provisioning` 服务账号(`client_credentials`)取 admin token。
解码该 token 得到它在 `corp-realm` 管理 client 上的实际角色:
**第 1 轮(阻塞时)**
```json
["manage-users", "view-users", "view-clients", "query-clients", "query-groups", "query-users"]
```
对照指南 §6.3,缺 `manage-realm`(建 realm 角色)与 `manage-clients`(加 mapper)。
按安全规则**未做任何提权尝试**,只记录并上报,由人类在服务器侧补授。
**第 2 轮(人类执行 `kcadm add-roles --rolename manage-realm --rolename manage-clients` 后)**
```json
["manage-realm", "manage-users", "view-users", "view-clients",
"manage-clients", "query-clients", "query-groups", "query-users"]
```
两个缺失角色已到位,且未授予 `realm-admin`,符合指南 §12-5 最小权限原则。
### 对象清单
| 对象 | UUID |
|---|---|
| client `task-dispatcher` | `3e7c17c6-829e-43d1-95e5-aad5d8ae750e` |
| client `api-gateway` | `e02d6fc4-d463-49d8-bc24-4e85dde551d3` |
| client `web-portal` | `0e77f3a9-9a0c-4975-b441-14c183d12665` |
| client `data-service` | `feddbc60-4cc6-4e49-a1d8-a969fc4ea46f`(未改动) |
| client `agent-service` | `b53cb9f3-3bad-4a10-bb9c-f2df8982e2ca`(未改动,归智能体方) |
| SA `service-account-task-dispatcher` | `49a85fc7-020c-4098-ba38-af7b138129ea` |
| SA `service-account-api-gateway` | `e3f07791-7494-496a-953e-87f8bbc33fb5` |
| 用户 `chaihao` | `96d21014-4e27-431e-a845-8d467bbad287` |
| **realm 角色 `service`(本次新建)** | **`f25993d7-6707-4ee2-9a6b-70b9511f4a79`** |
---
## 1. 逐项操作结果
### ① 建 realm 角色 `service` —— ✅ created
- 第 1 轮:`POST /roles` → **403**(缺 `manage-realm`);连 `GET /roles` 都 403。已跳过并上报。
- 第 2 轮先 GET:`GET /roles?briefRepresentation=true&max=200` → http=200,
返回 `admin user offline_access default-roles-corp uma_authorization` —— **确认 `service` 不存在**,非重复创建。
- 写入:`POST /admin/realms/corp/roles`,body `{"name":"service","description":"服务间调用面角色"}` → **HTTP 201**
- 复核:`GET /roles/service` → http=200
```json
{ "id": "f25993d7-6707-4ee2-9a6b-70b9511f4a79", "name": "service",
"description": "服务间调用面角色", "composite": false, "clientRole": false }
```
> 该角色仅授予服务账号,用于按指南 §5.7-3 的建议「判角色而不是判 `preferred_username` 前缀」区分服务调用。
### ② 把 `service` 挂到两个服务账号 —— ✅ created(两个都成功)
- 第 1 轮:因角色不存在无法取得 role id 而阻塞。当时用一个不存在的 role id 做了**不产生变更**的权限探针,
返回 **404 `Role not found` 而非 403**,据此判定 `manage-users` 已足够、唯一阻塞点是 ①。**第 2 轮证实了这个判断。**
- 第 2 轮先查 `GET /users/{uid}/role-mappings/realm/composite`:两个服务账号均不含 `service`,确需授予。
- 写入 `POST /users/{uid}/role-mappings/realm`,body 为角色对象数组(指南 §7.4):
`[{"id":"f25993d7-6707-4ee2-9a6b-70b9511f4a79","name":"service"}]`
| 服务账号 | HTTP | composite AFTER |
|---|---|---|
| `service-account-task-dispatcher` | **204** | `["user","offline_access","service","default-roles-corp","uma_authorization"]` |
| `service-account-api-gateway` | **204** | `["user","offline_access","service","default-roles-corp","uma_authorization"]` |
### ③ 给 `task-dispatcher` 加两个 audience mapper —— ✅ created(两条都成功)
- 第 1 轮:两条均 **403**(缺 `manage-clients`)。
- 第 2 轮先 GET `GET /clients/{uuid}/protocol-mappers/models` → http=200,**列表为空**,无同名 mapper,非重复创建。
- 写入(body 按指南 §7.5,`protocolMapper=oidc-audience-mapper`,`access.token.claim=true`):
| mapper 名 | `included.client.audience` | HTTP |
|---|---|---|
| `aud-data-service` | `data-service` | **201** |
| `aud-agent-service` | `agent-service` | **201** |
### ④ 给 `api-gateway` 加 `aud-task-dispatcher` —— ✅ created
- 第 1 轮:**403**(缺 `manage-clients`)。
- 第 2 轮先 GET → http=200,列表为空,非重复创建。
- 写入 `aud-task-dispatcher`(`included.client.audience=task-dispatcher`)→ **HTTP 201**。
### ⑤ `web-portal` 加 `aud-data-service`(D7 选项 a)—— ✅ already-present,**全程零写操作**
前置确认(要求:不得给人类授角色,只做校验)
`GET /users?username=chaihao&exact=true` → id;`GET /users/{id}/role-mappings/realm/composite` → http=200:
```
admin, user, offline_access, default-roles-corp, uma_authorization
```
**chaihao 确实已持有 realm 角色 `admin`**,无需也未授予任何角色。
`GET /clients/{web-portal-uuid}/protocol-mappers/models` → http=200,目标 mapper **已存在**,按幂等原则未创建重复项。
### 变更后的 mapper 全景(复核 `GET .../protocol-mappers/models`)
| client | mapper | 类型 | `included.client.audience` | `access.token.claim` |
|---|---|---|---|---|
| `task-dispatcher` | `aud-data-service` | oidc-audience-mapper | `data-service` | true |
| `task-dispatcher` | `aud-agent-service` | oidc-audience-mapper | `agent-service` | true |
| `api-gateway` | `aud-task-dispatcher` | oidc-audience-mapper | `task-dispatcher` | true |
| `web-portal` | `aud-api-gateway` | oidc-audience-mapper | `api-gateway` | true |
| `web-portal` | `aud-agent-service` | oidc-audience-mapper | `agent-service` | true |
| `web-portal` | `aud-task-dispatcher` | oidc-audience-mapper | `task-dispatcher` | true |
| `web-portal` | `aud-data-service` | oidc-audience-mapper | `data-service` | true |
| `web-portal` | `groups` | oidc-group-membership-mapper | — | true |
(`web-portal` 5 条全部为既有配置,本次未增删改。)
#### 共享 client 影响面说明(SHARED-CLIENT blast radius)
- 「`web-portal` 用户 token 的 `aud` 含 `data-service`」这一影响面**在本任务之前就已存在**,
与指南 §1 末尾「当前的受众模式:网关透传,`web-portal` 上挂了 4 个 audience mapper」完全一致。
**本次任务没有扩大它**,属于既有的、已文档化的架构决策。
- 影响面本身仍然成立并在此记录备查:**每一个经 `web-portal` 登录的用户**,其 token 的 `aud`
都包含 `data-service`(以及另外 3 个后端)。代价是任一持有该 token 的下游服务技术上也能调其它服务。
- 收紧方式(指南 §1 / §8.3):把对应 audience mapper 从 `web-portal` 摘掉,改由 `api-gateway`
用 token exchange 单独换发。这是局部改动,不需要重构。
- **新增影响面(本次引入,需知悉)**:`task-dispatcher` 的服务账号 token 现在同时含
`data-service` 与 `agent-service` 两个受众,因此该 token 技术上可调用这两个服务中的任何一个;
`api-gateway` 的服务账号 token 含 `task-dispatcher`。这是服务间调用面的**预期设计**(Task 10 目标),
但同样意味着这两个 secret 的泄漏面对应上述受众范围,须按指南 §12-4 妥善保管。
---
## 2. 验证(指南 §10,解码真实 token,不看 2xx)
两个服务的 client secret 均于**运行时**从各自 `.env` 读取(`BPOS-Service-Task/.env`、`BPOS-Service-Gateway/.env`),
未落盘、未复制到任何位置、未出现在本文件中。
### V1 `task-dispatcher` client_credentials —— ✅ PASS
```json
{
"azp": "task-dispatcher",
"preferred_username": "service-account-task-dispatcher",
"aud": ["agent-service", "data-service", "account"],
"realm_access": { "roles": ["default-roles-corp", "service", "offline_access", "uma_authorization", "user"] }
}
```
- `aud` 含 `data-service` ✅ 且含 `agent-service` ✅
- `realm_access.roles` 含 `service` ✅
(第 1 轮同一请求为 `aud: "account"`、roles 无 `service`,即 FAIL。)
### V2 `api-gateway` client_credentials —— ✅ PASS
```json
{
"azp": "api-gateway",
"preferred_username": "service-account-api-gateway",
"aud": ["task-dispatcher", "account"],
"realm_access": { "roles": ["default-roles-corp", "service", "offline_access", "uma_authorization", "user"] }
}
```
- `aud` 含 `task-dispatcher` ✅
- `realm_access.roles` 含 `service` ✅
(第 1 轮同一请求为 `aud: "account"`、roles 无 `service`,即 FAIL。)
### V3 `chaihao` 经 `web-portal` —— ✅ PASS(两轮结果一致,未受本次变更影响)
**密码模式不可用**:`grant_type=password` + `client_id=web-portal` 返回
`{"error":"unauthorized_client","error_description":"Client not allowed for direct access grants"}`。
查 client 配置确认这是**正确现状**,不是故障:
```json
{ "clientId": "web-portal", "publicClient": true,
"standardFlowEnabled": true, "directAccessGrantsEnabled": false }
```
与指南 §1「两个前端只开 standard flow」、§12-3「不要用密码模式做生产登录」一致。
故改用指南 §7.5 / §10 认可的预览端点
`GET /clients/{web-portal-uuid}/evaluate-scopes/generate-example-access-token?scope=openid&userId={chaihao}`(http=200):
```json
{
"azp": "web-portal",
"preferred_username": "chaihao",
"aud": ["agent-service", "api-gateway", "data-service", "task-dispatcher", "account"],
"realm_access": { "roles": ["default-roles-corp", "offline_access", "admin", "uma_authorization", "user"] }
}
```
- `aud` 含 **`data-service`** ✅
- `realm_access.roles` 含 **`admin`** ✅
- 复核:chaihao **不含** `service` 角色 ✅(`service` 只挂服务账号,未污染人类用户)
- ⚠️ 说明:这是同一套 mapper 管线生成的预览 token,不是浏览器真实登录换来的 token。
真实 token 需走授权码+PKCE(指南 §8.1)。Task 11 若要用真 token,需从前端登录后取,
或由人类在浏览器登录后复制 token。
---
## 3. Task 11 前置条件
| 路径 | 状态 | 依据 |
|---|---|---|
| 人类管理员(chaihao / `web-portal`) | ✅ **满足** | V3:`aud` 含 `data-service`,roles 含 `admin` → 可通过 `requireRealmRole('admin')` + `aud=data-service` 的建域接口 |
| 服务间调用(`task-dispatcher` → data/agent) | ✅ **满足** | V1:`aud` 含两个目标,roles 含 `service` |
| 服务间调用(`api-gateway` → `task-dispatcher`) | ✅ **满足** | V2:`aud` 含 `task-dispatcher`,roles 含 `service` |
**两条前置路径均已满足,Task 11 可按设计执行。**
---
## 4. 交接与注意事项
- **智能体方(agent-service)**:`agent-service` 自身的 audience 配置归智能体团队,本次**仅知会、未触碰**
(UUID `b53cb9f3-3bad-4a10-bb9c-f2df8982e2ca`)。
现在 `task-dispatcher` 的服务账号 token **已带 `aud=agent-service`**,智能体方需确认自己按指南 §5.3
校验 `aud` 含 `agent-service`,并按 §5.7 处理服务账号 token 的差异
(无 `groups`、`sub` 查不到业务用户、用 `realm_access.roles` 含 `service` 判定服务调用)。
- **`data-service` 未配 secret 是正确的**(指南 §5.2 / §12-4,只验不调),本次未给它加任何凭据。
- **各下游服务的角色判断**:现在可以用 `requireRealmRole('service')` 区分服务间调用,
不要再判 `preferred_username` 的 `service-account-` 前缀(指南 §5.7-3)。
- **token 生效延迟**:access token 有效期 300 秒,旧 token 在过期前仍不含新 claim,
排查时先确认手上的 token 是变更后重新签发的。
---
## 5. 附:人类侧已执行的补授命令(记录备查)
```bash
cd /opt/keycloak
PROV=$(sudo ./kc kcadm get clients -r master -q clientId=provisioning \
--fields id --format csv --noquotes | tail -1)
SA=$(sudo ./kc kcadm get "clients/$PROV/service-account-user" -r master \
--fields id --format csv --noquotes | tail -1)
sudo ./kc kcadm add-roles -r master --uid "$SA" \
--cclientid corp-realm --rolename manage-realm --rolename manage-clients
```
> 补授后 provisioning 账号具备 `manage-realm` + `manage-clients`,但**仍未授予 `realm-admin`**,
> 符合指南 §12-5。若后续不再需要这两个角色,可用 `remove-roles` 收回。