**C2:一次同步能把所有人停用,而停用之后没有界面能救回来。** 「本地有、远端无 → 停」是有意的,但没有下限:①`listUsers()` 因任何原因回了空数组 (realm 配错但存在、代理 200 空体、view-users 被改窄)→ 全表 disabled;②更可能的是 **Admin 的 `GET /users` 不返回服务账号用户**(手册 §14.5 为此单列一条接口)—— e2e 与试跑的 AI 账号是首登 JIT 建的,本地在册、远端查无此人,**第一次点「同步名册」 就把它停了,再同步多少次都回不来**。而恢复动作 `POST /api/gov/staff/sync` 要 ops, ops 要一个 active 的 principal:全员停用之后这条路自己把自己锁死,只剩去库里手改 state。 三道闸(两个触发面分别被后两条挡住,第一条是最后的网): - `diffRoster`:`remote` 为空时不产出「缺席即停」—— 远端一个人都没有,更像取数出了问题; - `diffRoster`:`testAccounts` 豁免「缺席即停」(新入参,必填 —— 只有装配层知道哪几个是 服务账号),判据用既有的 `isTestAccount`(不分大小写、忽略空白); - `syncRoster`:落 setState 之前用**同步后的活跃员工角色集**(新纯函数 `activeRolesAfter`) 跑一遍 `assertNotLockedOut`,不过就整体拒绝、一条都不写;路由映射 409(与 setRoleAccess 那两条同款,`syncStaffRoute` 从前没有这个 catch)。 `AccessLockoutError` / `assertNotLockedOut` 收可选 message —— 两个来路拒绝的理由不同, 给人的下一步也不同(这条指向「先核对 provisioning 指的是不是同一个 realm」)。 **I3:JIT 与同步撞 `account` 唯一索引 → 500,且此人从此登不进。** Keycloak 里删了人再建一个同名的(或用户名被让渡),新 sub 走 insert 就撞 `staff_account_key`,`server.ts` 的 onError 把 PG 原文原样回给调用方。 处置选的是**先查后让位**(不是捕异常):走到 insert 就说明按 sub 找不到他,本地那条同名的 必然是另一个 sub 的 stale 行 —— 把它的账号让位成 `账号#retired-<旧 sub 前 8 位>` 再插, 留一条 stderr;不删那一行(它身上可能还挂着在途工作)。`StaffRepository.findByAccount` 为此回到端口上(退役的是 OrgService 上的那个同名出口,不是仓储这一口)。 同步逐条落库改为**单条失败不停在半路**:失败的人点名进 `RosterSyncOutcome.failed` (契约变更,api-report 已刷新),三个计数改成**真落下的条数** —— 半途失败的同步 不许报成一排绿数字。 顺手两条: - `authenticate` 的 JIT 写挪到停用判定之后 —— 一个已停用的账号只要还攥着有效 token, 从前每 60 秒就能驱动一次 updateIdentity 写库;「第一次见到的人要建档」原样保留。 - `keycloak-admin-roster` 自己又写了一遍 dedup+sort+滤噪声 → 改用 `normalizeRoles`。 两处对「同一次归一」的理解一分叉,`identityChanged` 的逐字比对就会天天判「变了」。 测:roster-diff +8(两道下限保护 + activeRolesAfter 四种局面)、org-service +5 (整体拒绝且一条 setState 都没落 · 远端空着不停人 · 服务账号缺席不停 · 单条失败点名且 不停在半路 · 停用账号驱动不了 JIT 写)、同名让位 1(内存仓储照搬了唯一索引这条约束)。 文档:org/CONTRACT.md 不变量与影响半径、API 文档 §0(HEAD / 409)· §3(三道保护 + failed)· §7。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw
19 KiB
API 文档 —— 鉴权、人员与端(ADR-012)
契约的唯一真相在
packages/contracts/src/api/*.ts(zod schema,前后端共用);本文是给前端 / 联调 / 运维读的人话版。 两者不一致时以契约为准并回来改本文(实施计划 Task 12 会用契约把 §6 的总表重新生成一遍核对)。 设计出处:docs/superpowers/specs/2026-08-25-keycloak-auth-design.md;前端怎么接:docs/前端对接说明-统一鉴权中心.md。
0. 通则
| 项 | 值 |
|---|---|
| Base | 同源 /api/*(开发期 Vite 把 /api 反代到 :8787) |
| 认证 | Authorization: Bearer <Keycloak access_token>;RS256;iss = https://keycloak.akhr-ai.com/realms/corp;aud 须含本服务 clientId(KEYCLOAK_CLIENT_ID,建议 api-gateway) |
| 票据 | GET 且没带头时认 ?ticket=<60 秒票据>(取自 GET /api/auth/ticket);其它方法不认 |
| HEAD | 按 GET 判(框架把 HEAD 改写成 GET 再分发)—— 端归属、票据两处都与同路径的 GET 一模一样 |
| 请求体 | JSON,content-type: application/json |
| 错误体 | { "code": string, "message": string, "detail"?: unknown };code ∈ unauthenticated forbidden not-found validation-failed conflict precondition-failed internal gone |
| 端归属 | 每条路由有 access:public(不要凭据)/ any(登录即可)/ design / ops / workbench / design|ops(能进其中任一端即可) |
状态码怎么读:
| 状态 | code | 何时 |
|---|---|---|
| 401 | unauthenticated |
缺 token / 过期 / 签名不对 / aud 或 iss 不对 / 票据坏了(message 逐一说清) |
| 403 | forbidden |
账号已在鉴权中心停用;或这条路由要求的端不在你的 ends 里(detail.need / detail.ends) |
| 404 | not-found |
无此路由(先 401 后 404:没登录的人探不到路由表)/ 对象不存在 |
| 409 | conflict |
角色 → 端改完没人能进运营中心;这次名册同步会让没人能进运营中心(整体拒绝,一条都不写);删 LLM 实例还有活动引用 |
| 410 | gone |
路由随统一鉴权中心退役(§5) |
| 412 | precondition-failed |
入参合法、权限也够,但这件事此刻办不了:流程包写回生效版本 / 启用时闭合校验没过、活动包安装两审(静态审计 + 回放)未过、决议已登记过不重复起单、汇报已提交过、入站邮件通道已停用。detail 常带闭合报告或安装审计结论 |
| 400 | validation-failed |
入参不合 schema,detail 是 zod issues |
1. 身份
GET /api/me —— access: any
我是谁、能进哪几个端。ends 为空也 200(前端据此摆「未授权」页)。
出参 Principal(§7):
{
"staff": {
"id": "8f3c1a2b-6c1e-4b3a-9f0e-2d5c7a1b9e44",
"account": "chaihao",
"name": "柴浩",
"email": "chaihao@akhr-ai.com",
"positionId": "pm",
"state": "active",
"roles": ["admin"]
},
"position": { "id": "pm", "code": "PM", "name": "项目经理", "note": "定价审批、策略拍板" },
"roles": ["admin"],
"ends": ["design", "ops", "workbench"]
}
副作用:第一次见到这个 sub 会在名册里建档(岗位 NONE 未分配);账号 / 姓名 / 邮箱 / 角色变了会写回。
GET /api/auth/ticket —— access: any
换一枚 60 秒票据,给 EventSource 与 <a href download> 这两条带不了 Authorization 头的路。
{ "ticket": "v1.OGYzYzFhMmItNmMxZS00YjNhLTlmMGUtMmQ1YzdhMWI5ZTQ0.1756112460.k5vJ…", "expiresAt": "2026-08-25T09:01:00.000Z" }
- 只在 GET 的
?ticket=上认;票据里没有权限,后端按它对应的 sub 回库现算(停用 / 改角色即时生效)。 - 同时带头与票据时以头为准。
- 过期或坏了 → 401
票据无效或已过期 —— 重新取一张 GET /api/auth/ticket。
2. 实时推送
GET /api/stream?ticket=… —— access: any(SSE)
事件:ready(连上)、touch(哪个目标动了;载荷形状不变,见 apps/web/src/app/subscribe.ts)、ping(20 秒心跳)。
连接只在建立那一刻验一次票据;断线重连要重新取票据(浏览器自动重连用旧 URL 会吃 401 且不再重试)。
3. 员工与岗位(运营中心 · 员工)
GET /api/gov/staff —— access: design|ops
{
"staff": [
{
"id": "8f3c1a2b-…", "account": "chaihao", "name": "柴浩", "email": "chaihao@akhr-ai.com",
"positionId": "pm", "state": "active", "roles": ["admin"],
"position": { "id": "pm", "code": "PM", "name": "项目经理", "note": "定价审批、策略拍板" }
}
],
"positions": [
{ "id": "pm", "code": "PM", "name": "项目经理", "note": "定价审批、策略拍板" },
{ "id": "bd", "code": "BD", "name": "商务", "note": "RFQ 受理、成本定价、报价议价、情报归档" },
{ "id": "fae", "code": "FAE", "name": "现场应用", "note": "替代选型" },
{ "id": "prm", "code": "PRM", "name": "采购/寻源", "note": "渠道寻源" },
{ "id": "op", "code": "OP", "name": "运营", "note": "运营支持" },
{ "id": "test", "code": "TEST", "name": "测试", "note": "测试账号专用岗;整条流经工作组绑定落到本人" },
{ "id": "none", "code": "NONE", "name": "未分配", "note": "未指派岗位;流程包不得声明它" }
]
}
state 只由名册同步置 disabled(Keycloak 停用或删除),有效 token 不改它;roles 是签发时的快照。
测试台选负责人也用这条,所以设计端也能读。
POST /api/gov/staff/sync —— access: ops
从 Keycloak Admin API 同步名册(provisioning 服务账号,只读)。无请求体。
{ "configured": true, "inserted": 2, "updated": 1, "disabled": 0, "total": 14, "failed": [] }
三个计数说的是真落进库里的条数。单条写失败不让整次同步停在半路:它不进计数,而是点名进 failed
(reason 照抄原话,不改写成「操作失败」):
{ "configured": true, "inserted": 0, "updated": 3, "disabled": 1, "total": 14,
"failed": [{ "account": "bob", "reason": "duplicate key value violates unique constraint \"staff_account_key\"" }] }
没配 provisioning 时如实回(不是一排 0):
{ "configured": false, "message": "未配置 KEYCLOAK_PROVISIONING_CLIENT_ID / KEYCLOAK_PROVISIONING_CLIENT_SECRET —— 名册只能靠首次登录时自动建档" }
规则:远端没有的本地人 → disabled;远端 enabled=false → disabled;远端又启用 → active;字段没变的不写。
「缺席即停用」有三道下限保护(全员停用之后,这个按钮自己要 ops,那条恢复路会自锁):
- 远端一个人都没有时不据此停任何人 —— 那更像取数出了问题(realm 配错、代理 200 空体、
view-users被改窄),不像全员离职; JCAI_TEST_ACCOUNTS里的服务账号缺席不算离职 —— Admin 的GET /users本来就不返回服务账号用户;- 落库之前先算一遍「这一次同步之后还有谁能进运营中心」,算下来没有人 → 409 整体拒绝,一条都不写:
{ "code": "conflict", "message": "这次同步会让没有人能进运营中心,已拒绝(一条都没写)—— 先核对 provisioning 指的是不是同一个 realm,以及那个服务账号还有没有 view-users。" }
PUT /api/gov/staff/:staffId/position —— access: ops
body { "positionId": "bd" } → 出参 = 那一行 staff 连同 position(形状同 §3 列表里的一项)。
指回 NONE 的 id 等于取消指派。404:员工或岗位不存在。
GET /api/gov/staff/:staffId/in-flight —— access: ops
此人身上还挂着什么(只读;原「停用前置检查」)。停用本身在 Keycloak 做。
{
"staffId": "8f3c1a2b-…",
"blocked": true,
"openTasks": [{ "taskId": "t-01J…", "title": "客户RFQ受理 · Itelma" }],
"openCards": [{ "cardId": "c-01J…", "title": "受理需求审定" }],
"openApprovals": []
}
工作组(不变):GET /api/gov/work-groups · PUT /api/gov/work-groups —— access: ops
- GET →
{ "groups": [{ "id", "name", "ownerId", "members": { "BD": "<staffId>", … }, "ownerName" }] } - PUT body
{ "ownerId", "name", "members": { "<岗位 code>": "<staffId>" } }→{ "group": {…} };一人一组,按ownerId落。
4. 角色 → 端(运营中心)
一行一个 Keycloak realm 角色;一个人的 ends = 他的角色命中行的并集。缺省两行:admin → design, ops, workbench;user → workbench。
新角色在 Keycloak 建;这边 knownRoles 把名册上见过的角色列出来供配置 —— 没配置前它等于「无端可进」。
GET /api/gov/access/roles —— access: ops
{
"roles": [
{ "role": "admin", "ends": ["design", "ops", "workbench"] },
{ "role": "user", "ends": ["workbench"] }
],
"knownRoles": ["admin", "designer", "user"]
}
PUT /api/gov/access/roles/:role —— access: ops
body { "ends": ["design"] }(端名数组,顺序 / 重复不敏感)→ { "role": "designer", "ends": ["design"] }。
409 conflict:改完之后没有任何在册活跃员工能进运营中心 —— 那张表就再也没人改得了,拒绝。名册为空(全新安装)不算锁死。
DELETE /api/gov/access/roles/:role —— access: ops
→ { "ok": true };同样的 409 保护。
5. 退役路由(一律 410 gone,契约留一轮下个迭代删)
| 退役 | message 里指的路 |
|---|---|
POST /api/login |
登录由 Keycloak 完成,请求带 Authorization: Bearer |
POST /api/logout |
登出在前端丢弃 token,或调 Keycloak 的 end-session |
GET /api/session |
改用 GET /api/me |
POST /api/gov/staff |
首次登录自动建档,或 POST /api/gov/staff/sync |
GET /api/gov/staff/:staffId/disable-check |
GET /api/gov/staff/:staffId/in-flight |
POST /api/gov/staff/:staffId/disable |
在 Keycloak 停用后 POST /api/gov/staff/sync |
GET /api/gov/positions/:positionId/grants |
GET /api/gov/access/roles |
POST /api/gov/positions/:positionId/grants |
PUT /api/gov/access/roles/:role |
6. 全部路由的端归属(总表)
规则:写归所属端;被两端消费的读标两端。
| access | 方法 | 路径 |
|---|---|---|
public |
GET | /api/health |
public |
POST | /api/ingest/channel/:channelId/mail(外界边界:模拟入站邮件) |
public(410) |
— | §5 的八条 |
any |
GET | /api/me |
any |
GET | /api/auth/ticket |
any |
GET | /api/stream(SSE) |
workbench |
GET | /api/desk/tasks |
workbench |
GET | /api/desk/tasks/:taskId |
workbench |
POST | /api/desk/work-orders/reply |
workbench |
POST | /api/desk/chat-cards/reply |
workbench |
POST | /api/desk/comms/:commId/read |
workbench |
POST | /api/desk/tasks/:taskId/table |
workbench |
GET | /api/desk/tasks/:taskId/file(?address=&key=&raw=1;可用票据) |
workbench |
POST | /api/desk/tasks/:taskId/say |
workbench |
GET | /api/desk/messages |
workbench |
POST | /api/desk/messages/:messageId/read |
workbench |
POST | /api/desk/messages/:messageId/answer |
workbench |
POST | /api/desk/trigger-pending/:id/confirm |
workbench |
GET | /api/minutes |
workbench |
POST | /api/minutes |
workbench |
POST | /api/minutes/:minuteId/resolutions |
workbench |
POST | /api/minutes/resolutions/:resolutionId/register |
workbench |
GET | /api/reports/:period |
workbench |
GET | /api/reports |
workbench |
POST | /api/reports/:reportId/submit |
workbench |
GET | /api/approvals |
workbench |
POST | /api/approvals/:workflowId/:nodeId/decide |
workbench |
GET | /api/triggers/registration |
workbench |
POST | /api/triggers/:triggerId/register |
design |
PUT | /api/gov/process |
design |
POST | /api/gov/process/:id/:version/state |
design |
DELETE | /api/gov/process/:id/:version |
design |
DELETE | /api/gov/process/:id |
design |
POST | /api/gov/process/:id/:version/edit |
design |
POST | /api/gov/process/validate |
design |
GET | /api/gov/approval-templates |
design |
PUT | /api/gov/approval-templates |
design |
DELETE | /api/gov/approval-templates/:id |
design |
PUT | /api/gov/triggers(定义归设计) |
design |
GET | /api/gov/activities |
design |
POST | /api/gov/activities |
design |
GET | /api/gov/tools |
design |
POST | /api/gov/tools |
design |
GET | /api/gov/memory/:activityId |
design |
GET | /api/gov/llm/providers |
design |
GET | /api/gov/llm |
design |
POST | /api/gov/llm |
design |
DELETE | /api/gov/llm/:id |
design |
POST | /api/gov/llm/probe |
design |
POST | /api/gov/llm/:id/probe |
design |
POST | /api/gov/testbed/run |
design |
POST | /api/gov/testbed/run-activity |
design |
POST | /api/gov/testbed/stop |
design |
GET | /api/gov/testbed/current |
design |
GET | /api/gov/testbed/activity/:taskId |
design |
GET | /api/gov/testbed/logs |
design |
GET | /api/gov/testbed/logs/:id |
design |
DELETE | /api/gov/testbed/logs/:id |
design |
DELETE | /api/gov/testbed/logs |
design |
POST | /api/gov/upload |
design |
GET | /api/gov/upload/:id |
design |
GET | /api/gov/workflows/:id/trace |
ops |
GET | /api/gov/tasks |
ops |
POST | /api/gov/tasks/:taskId/control |
ops |
POST | /api/gov/tasks/:taskId/resume |
ops |
POST | /api/gov/tasks/:taskId/reassign |
ops |
GET | /api/gov/tasks/:taskId/log |
ops |
GET | /api/gov/approvals |
ops |
GET | /api/gov/metrics |
ops |
POST | /api/gov/triggers/:id/toggle(启停归运营) |
ops |
POST | /api/gov/triggers/:id/confirm |
ops |
GET | /api/gov/packages/:id/owners |
ops |
PUT | /api/gov/packages/:id/owners/:nodeId |
ops |
GET | /api/gov/work-groups |
ops |
PUT | /api/gov/work-groups |
ops |
POST | /api/gov/staff/sync |
ops |
PUT | /api/gov/staff/:staffId/position |
ops |
GET | /api/gov/staff/:staffId/in-flight |
ops |
GET | /api/gov/access/roles |
ops |
PUT | /api/gov/access/roles/:role |
ops |
DELETE | /api/gov/access/roles/:role |
design|ops |
GET | /api/gov/staff(测试台选负责人) |
design|ops |
GET | /api/gov/process |
design|ops |
GET | /api/gov/triggers |
design|ops |
GET | /api/gov/channels |
design|ops |
GET | /api/gov/data/objects |
design|ops |
GET | /api/gov/data/by-activity |
design|ops |
GET | /api/gov/data/:domain/:object |
design|ops |
GET | /api/gov/workflows/:id |
计数:public 2 + 退役 8 + any 2(+ SSE 1 条字面路由)+ workbench 23 + design 33 + ops 19 + 两端 8 = 契约 95 条。
7. Schema 附录(与 packages/contracts/src/org.ts 同名)
// 三个端
type Surface = 'design' | 'ops' | 'workbench';
// 员工 —— id 就是 Keycloak sub
type Staff = {
id: string; // Keycloak sub(UUID)
account: string; // preferred_username,只作显示
name: string; // name claim,缺则 = account
email?: string;
positionId: string; // 未指派 = 哨兵岗 NONE 的 id
state: 'active' | 'disabled';
roles: string[]; // realm 角色快照(噪声角色已滤)
};
type Position = { id: string; code: string; name: string; note?: string };
// GET /api/me
type Principal = { staff: Staff; position: Position; roles: string[]; ends: Surface[] };
// 角色 → 端
type RoleAccess = { role: string; ends: Surface[] };
// POST /api/gov/staff/sync
type RosterSyncOutcome =
| { configured: false; message: string }
| {
configured: true;
inserted: number; // 三个计数 = 真落进库里的条数,不是打算写几条
updated: number;
disabled: number;
total: number; // 远端这一次回了几个人
failed: Array<{ account: string; reason: string }>; // 逐条落库时失败的人,原话照抄
};
// GET /api/gov/staff/:id/in-flight
type InFlightWork = {
staffId: string;
blocked: boolean;
openTasks: Array<{ taskId: string; title: string }>;
openCards: Array<{ cardId: string; title: string }>;
openApprovals: Array<{ approvalId: string; title: string }>;
};
// 工作组(ADR-011,不变)
type WorkGroup = { id: string; name: string; ownerId: string; members: Record<string /* 岗位 code */, string /* staffId */> };
type ApiError = { code: 'unauthenticated' | 'forbidden' | 'not-found' | 'validation-failed' | 'conflict' | 'precondition-failed' | 'internal' | 'gone'; message: string; detail?: unknown };
8. curl 实录
用服务账号拿 token(自动化的正道,手册 §10;该 client 需 serviceAccountsEnabled、realm 角色 admin、且 audience mapper 指向本服务 clientId):
TOKEN=$(curl -s -X POST https://keycloak.akhr-ai.com/realms/corp/protocol/openid-connect/token \
-d grant_type=client_credentials -d client_id=bpos-e2e -d client_secret="$KEYCLOAK_E2E_CLIENT_SECRET" | jq -r .access_token)
# 我是谁
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/me | jq .
# 没带 token → 401
curl -s http://localhost:8787/api/me | jq .
# { "code": "unauthenticated", "message": "缺少 Bearer token" }
# 端不够 → 403(拿一个只有 user 角色的 token 试)
curl -s -H "Authorization: Bearer $USER_TOKEN" http://localhost:8787/api/gov/tasks | jq .
# { "code": "forbidden", "message": "进入「运营中心」需要授权;你的角色(user)可进:工作台", "detail": { "need": ["ops"], "ends": ["workbench"] } }
# 票据 + SSE
TICKET=$(curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/auth/ticket | jq -r .ticket)
curl -s -N "http://localhost:8787/api/stream?ticket=$TICKET"
# event: ready
# data: 1
# 角色 → 端
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/gov/access/roles | jq .
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"ends":["design"]}' http://localhost:8787/api/gov/access/roles/designer | jq .
# 名册同步 / 指派岗位
curl -s -X POST -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/gov/staff/sync | jq .
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"positionId":"bd"}' http://localhost:8787/api/gov/staff/8f3c1a2b-6c1e-4b3a-9f0e-2d5c7a1b9e44/position | jq .
# 退役路由 → 410
curl -s -X POST http://localhost:8787/api/login | jq .
# { "code": "gone", "message": "已随统一鉴权中心退役:登录由 Keycloak 完成,请求带 Authorization: Bearer <token>" }
Task 12 已用契约(packages/contracts/src/api/*.ts + org.ts)核过本文:§6 总表逐行比对生成表、§7 字段名逐个核对 schema —— 全部一致,零改动。
示例里的 UUID / 岗位 id / 时间戳仍按契约构造 —— 真实响应需要 Keycloak 侧前置 4(e2e / 试跑服务账号 client bpos-e2e:serviceAccountsEnabled、realm 角色 admin、挂本服务 audience mapper)先就位,这是管理员的活,本轮未就位;前置就位后用 §8 给的 curl 序列换成真实响应。