From 094386f02c2e06347d60c3c0a74868094110ca2d Mon Sep 17 00:00:00 2001 From: xcs Date: Tue, 25 Aug 2026 15:25:30 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=87=E6=A1=A3:=E5=89=8D=E7=AB=AF=E5=AF=B9?= =?UTF-8?q?=E6=8E=A5=E8=AF=B4=E6=98=8E=E4=B8=8E=20API=20=E6=96=87=E6=A1=A3?= =?UTF-8?q?(=E9=89=B4=E6=9D=83=20/=20=E4=BA=BA=E5=91=98=20/=20=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E2=86=92=E7=AB=AF);=E8=AE=A1=E5=88=92=E5=8A=A0=20Task?= =?UTF-8?q?=2012=20=E6=8C=89=E5=A5=91=E7=BA=A6=E6=A0=B8=E5=AF=B9=E4=B8=A4?= =?UTF-8?q?=E4=BB=BD=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端对接说明:要改哪些文件、/api/me 与端、401/403/410 处置、SSE 与下载的 60s 票据、 运营中心员工页的新接口、接入完成判据。API 文档:通则与状态码、每条新接口的出入参与示例、 退役八条的替代、全部 95 条契约路由的端归属总表、schema 附录、curl 实录。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw --- docs/API文档-鉴权与人员.md | 365 ++++++++++++++++++ .../plans/2026-08-25-keycloak-auth.md | 84 +++- .../specs/2026-08-25-keycloak-auth-design.md | 1 + docs/前端对接说明-统一鉴权中心.md | 198 ++++++++++ 4 files changed, 647 insertions(+), 1 deletion(-) create mode 100644 docs/API文档-鉴权与人员.md create mode 100644 docs/前端对接说明-统一鉴权中心.md diff --git a/docs/API文档-鉴权与人员.md b/docs/API文档-鉴权与人员.md new file mode 100644 index 0000000..7547d04 --- /dev/null +++ b/docs/API文档-鉴权与人员.md @@ -0,0 +1,365 @@ +# 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 `;RS256;`iss` = `https://keycloak.akhr-ai.com/realms/corp`;`aud` 须含本服务 clientId(`KEYCLOAK_CLIENT_ID`,建议 `api-gateway`) | +| 票据 | **GET** 且没带头时认 `?ticket=<60 秒票据>`(取自 `GET /api/auth/ticket`);其它方法不认 | +| 请求体 | 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) | +| 400 | `validation-failed` | 入参不合 schema,`detail` 是 zod issues | + +## 1. 身份 + +### `GET /api/me` —— access: `any` + +我是谁、能进哪几个端。**`ends` 为空也 200**(前端据此摆「未授权」页)。 + +出参 `Principal`(§7): + +```json +{ + "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` 与 `` 这两条带不了 Authorization 头的路。 + +```json +{ "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` + +```json +{ + "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 服务账号,只读)。无请求体。 + +```json +{ "configured": true, "inserted": 2, "updated": 1, "disabled": 0, "total": 14 } +``` + +没配 provisioning 时如实回(**不是**一排 0): + +```json +{ "configured": false, "message": "未配置 KEYCLOAK_PROVISIONING_CLIENT_ID / KEYCLOAK_PROVISIONING_CLIENT_SECRET —— 名册只能靠首次登录时自动建档" } +``` + +规则:远端没有的本地人 → `disabled`;远端 `enabled=false` → `disabled`;远端又启用 → `active`;字段没变的不写。 + +### `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 做。 + +```json +{ + "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": "", … }, "ownerName" }] }` +- PUT body `{ "ownerId", "name", "members": { "<岗位 code>": "" } }` → `{ "group": {…} }`;一人一组,按 `ownerId` 落。 + +## 4. 角色 → 端(运营中心) + +一行一个 Keycloak realm 角色;一个人的 `ends` = 他的角色命中行的并集。缺省两行:`admin → design, ops, workbench`;`user → workbench`。 +新角色在 Keycloak 建;这边 `knownRoles` 把名册上见过的角色列出来供配置 —— 没配置前它等于「无端可进」。 + +### `GET /api/gov/access/roles` —— access: `ops` + +```json +{ + "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` 同名) + +```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 }; + +// 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 }; + +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): + +```bash +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 " } +``` + +示例里的 UUID / 岗位 id / 时间戳是按契约构造的;实施完成后 Task 12 会用真实响应替换。 diff --git a/docs/superpowers/plans/2026-08-25-keycloak-auth.md b/docs/superpowers/plans/2026-08-25-keycloak-auth.md index 97e2706..ee0d484 100644 --- a/docs/superpowers/plans/2026-08-25-keycloak-auth.md +++ b/docs/superpowers/plans/2026-08-25-keycloak-auth.md @@ -55,6 +55,7 @@ | 数据 | `db/migrations/0032_keycloak_identity.sql` + `meta/_journal.json` | 见 spec §6 | | 测试 | `org/__tests__/{claims,ends,roster-diff,test-account,keycloak-token-verifier,keycloak-admin-roster,org-service}.test.ts`;`app/__tests__/{route-registry,ticket,auth-middleware,config-keycloak,keycloak-check}.test.ts`;`app/__tests__/route-coverage.it.test.ts`;三份既有 `*.it.test.ts` 改造人 | | | 工具 / 文档 | `tools/e2e-a2.mjs`;`docs/adr/ADR-012-keycloak-identity.md`;`docs/编码规范.md`;`docs/debt.md`;`CLAUDE.md`;`api-report/` | | +| 对外文档(已随设计写好,Task 12 核对) | `docs/前端对接说明-统一鉴权中心.md`;`docs/API文档-鉴权与人员.md` | 前端要改什么、后端给了什么;接口人话版 + 全部路由归属总表 | --- @@ -4857,6 +4858,13 @@ git commit -m "e2e:改用 Keycloak 服务账号 client_credentials 拿 token,4 绝不派到用户工作台;测完 `pnpm --filter @app/api reset-runs` 清运行数据(清跑出来的,留配置好的)。 ``` +「文档权威地图」表在 `docs/debt.md` 那一行之前加两行: + +```markdown +| | `docs/前端对接说明-统一鉴权中心.md` | **前端接统一鉴权中心照这份**(ADR-012):要改哪些文件、`/api/me` 与端、401/403/410、SSE 与下载的票据 | +| | `docs/API文档-鉴权与人员.md` | 鉴权 / 人员 / 角色→端接口的人话版 + 全部路由归属总表;契约不一致时以 `packages/contracts` 为准 | +``` + 现状快照列表末尾(`## 文档权威地图` 之前)加一条: ```markdown @@ -4885,9 +4893,83 @@ git commit -m "文档:ADR-012 统一鉴权中心;编码规范鉴权定调改为 --- +### Task 12: 前端对接说明与 API 文档 —— 与真实契约核对 + +两份文档是设计阶段按 spec 写好的(`docs/前端对接说明-统一鉴权中心.md` `docs/API文档-鉴权与人员.md`), +实现落地后要用**契约本身**把它们核一遍:路由归属总表由契约重新生成、schema 字段名逐个对、示例尽量换成真实响应。 + +**Files:** +- Modify: `docs/API文档-鉴权与人员.md`(§6 总表整段重生成;§7 字段核对;§8 示例) +- Modify: `docs/前端对接说明-统一鉴权中心.md`(§2 文件指针、§7 接口名核对) + +**Interfaces:** +- Consumes:`packages/contracts`(Task 1 之后每条路由都有 `access`)、`GET /api/me` 等已实现的路由(Task 8)。 + +- [ ] **Step 1: 用契约重新生成路由归属总表** + +```bash +node --experimental-strip-types --input-type=module -e " +const m = await import('./packages/contracts/src/index.ts'); +const isRoute = (v) => v !== null && typeof v === 'object' && typeof v.method === 'string' && typeof v.path === 'string' && 'access' in v && 'output' in v; +const order = ['public', 'any', 'workbench', 'design', 'ops', 'design|ops']; +const rows = Object.entries(m).filter(([, v]) => isRoute(v)).map(([name, r]) => ({ access: Array.isArray(r.access) ? r.access.join('|') : r.access, method: r.method, path: r.path, name })); +rows.sort((a, b) => order.indexOf(a.access) - order.indexOf(b.access) || a.path.localeCompare(b.path) || a.method.localeCompare(b.method)); +console.log('| access | 方法 | 路径 |'); +console.log('|---|---|---|'); +for (const r of rows) console.log('| \`' + r.access.replace('|', '\\\\|') + '\` | ' + r.method + ' | \`' + r.path + '\` |'); +console.log('\n计数:' + rows.length + ' 条契约路由'); +" > /tmp/route-access-table.md +wc -l /tmp/route-access-table.md +``` +Expected:打印 95 行路由 + 计数 `95 条契约路由`。若 `node --experimental-strip-types` 在本机版本上不认 `satisfies`(Node < 22.7),改用 `pnpm vitest run` 跑一个临时测试文件把同一段代码 `console.log` 出来,跑完删掉临时文件。 + +- [ ] **Step 2: 对 §6 总表** + +把生成表与 `docs/API文档-鉴权与人员.md` §6 逐行比:方法 / 路径 / access 三者任一不同 → 以生成表为准改文档; +文档里手写的备注(「定义归设计」「可用票据」等)保留。SSE `GET /api/stream` 不在契约里(`EXTRA_ROUTES`),文档单列一行。 +最后一行「计数」按生成结果改。 + +- [ ] **Step 3: 对 §7 字段名** + +```bash +grep -n "^export const \(staffSchema\|principalSchema\|roleAccessSchema\|rosterSyncOutcomeSchema\|inFlightWorkSchema\|workGroupSchema\|positionSchema\)" packages/contracts/src/org.ts +grep -n "API_ERROR_CODES" -A 10 packages/contracts/src/api/http.ts | head -12 +``` +逐个 schema 打开看字段名与可选性,与文档 §7 的 TS 形状对齐(字段多了补、少了删、可选性以契约为准)。 + +- [ ] **Step 4: 示例换成真实响应(Keycloak 侧前置 1 与 4 就位时做;未就位就跳过并在文档末尾注明「示例按契约构造」)** + +```bash +export KEYCLOAK_URL=https://keycloak.akhr-ai.com KEYCLOAK_REALM=corp KEYCLOAK_CLIENT_ID=api-gateway +pnpm dev:api & # 另开终端亦可;启动日志须见「✓ Keycloak 连通」 +TOKEN=$(curl -s -X POST "$KEYCLOAK_URL/realms/corp/protocol/openid-connect/token" -d grant_type=client_credentials -d client_id="$KEYCLOAK_E2E_CLIENT_ID" -d client_secret="$KEYCLOAK_E2E_CLIENT_SECRET" | jq -r .access_token) +curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/me | jq . +curl -s http://localhost:8787/api/me | jq . +curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/auth/ticket | jq . +curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/gov/access/roles | jq . +curl -s -X POST http://localhost:8787/api/login | jq . +``` +把上面五个响应原样贴进文档 §1 / §4 / §5 / §8(sub、邮箱按真实值;secret 一个字都别贴)。 + +- [ ] **Step 5: 核前端对接说明里的文件指针** + +```bash +for f in shared/typed-fetch.ts app/main.tsx features/auth/internal/LoginPage.tsx shared/gov/surfaces.ts app/gov-shell.tsx app/app-shell.tsx app/subscribe.ts features/desk-task/internal/ViewRegion.tsx features/desk-task/internal/FileViewer.tsx shared/gov/gov-api.ts; do test -f "apps/web/src/$f" && echo "ok $f" || echo "MISSING $f"; done +``` +Expected:全部 `ok`;有 `MISSING` 就改文档 §2 的指针(web 本轮没动,理论上不会)。§7 里提到的接口名与 §6 总表逐个对一遍。 + +- [ ] **Step 6: 提交** + +```bash +git add 'docs/API文档-鉴权与人员.md' 'docs/前端对接说明-统一鉴权中心.md' +git commit -m "文档:前端对接说明与 API 文档按真实契约核对(路由归属总表由契约生成,示例换真实响应)" +``` + +--- + ## 自审记录(写完计划后对着 spec 过的一遍) -- **spec 覆盖**:§4 契约 → Task 1;§5.3 domain → Task 2;§5.5 两个 adapter → Task 3 / 4;§9.1 注册表 → Task 5;§9.3 票据 → Task 6;§9.2 中间件 → Task 7;§5.1 / 5.2 / 5.4 / 5.5 仓储与用例、§6 迁移、§11 连带改动(container / engines / message / 路由 / it-tests / tsup / package.json)→ Task 8;§10.2 启动自检 + §9.1 覆盖测试 → Task 9;§11 e2e → Task 10;§11 文档 + §13 债 → Task 11。§10.3 Keycloak 侧前置是管理员的事,写在 ADR-012「后果」与 Task 10 的说明里。 +- **spec 覆盖**:§4 契约 → Task 1;§5.3 domain → Task 2;§5.5 两个 adapter → Task 3 / 4;§9.1 注册表 → Task 5;§9.3 票据 → Task 6;§9.2 中间件 → Task 7;§5.1 / 5.2 / 5.4 / 5.5 仓储与用例、§6 迁移、§11 连带改动(container / engines / message / 路由 / it-tests / tsup / package.json)→ Task 8;§10.2 启动自检 + §9.1 覆盖测试 → Task 9;§11 e2e → Task 10;§11 文档 + §13 债 → Task 11;用户追加的前端对接说明与 API 文档(设计阶段已写)→ Task 12 用契约核对。§10.3 Keycloak 侧前置是管理员的事,写在 ADR-012「后果」与 Task 10 的说明里。 - **类型一致性**:`OrgService` 的方法名在 Task 8 A2(契约)、A7(实现)、B3–B9(调用方)一致:`authenticate` `principalOf` `registerIdentity` `listStaff` `getStaff` `staffOfPosition` `setPosition` `syncRoster` `inFlightOf` `listRoleAccess` `setRoleAccess` `deleteRoleAccess` `staffWithEnd` `saveWorkGroup` `workGroupOf` `bindingOf` `ensureSeeded`;`Authenticator`(Task 7)是它的子集;`StaffRepository` 无 `findByAccount`(A3 / A6 / A7 / A1 的内存实现一致);`createContainer(config, overrides)`(B3)与 Task 9 的 it-test 调用一致;`EXTRA_ROUTES` / `appRouteRegistry`(B4)与 Task 9 覆盖测试一致。 - **占位扫描**:无 TBD / TODO;每个改动步骤都给了代码或精确的替换文本;脚本化改写(B7 / B8)带断言与验收 grep。 - **已知取舍**:Task 8 是一次提交(中间态 typecheck 必红,不拆);it-tests 与路由覆盖测试需要 PG,没库时自报跳过。 diff --git a/docs/superpowers/specs/2026-08-25-keycloak-auth-design.md b/docs/superpowers/specs/2026-08-25-keycloak-auth-design.md index b5377db..f6adf8d 100644 --- a/docs/superpowers/specs/2026-08-25-keycloak-auth-design.md +++ b/docs/superpowers/specs/2026-08-25-keycloak-auth-design.md @@ -417,6 +417,7 @@ c.set('principal', principal) → next | `docs/debt.md` | 鉴权段重写(见 §13) | | `CLAUDE.md` | 铁规矩 5 环境变量列表加 `KEYCLOAK_*`;铁规矩 6 「AI 账号(密码…)」改「AI 服务账号(client_credentials)」;现状快照加一条 | | `api-report/` | `pnpm api:check --update` | +| `docs/前端对接说明-统一鉴权中心.md` `docs/API文档-鉴权与人员.md` | 设计阶段按本 spec 写好(用户 2026-08-25 追加);实现落地后用契约重新生成路由归属总表核对(计划 Task 12) | ## 12. 测试策略 diff --git a/docs/前端对接说明-统一鉴权中心.md b/docs/前端对接说明-统一鉴权中心.md new file mode 100644 index 0000000..b00d8eb --- /dev/null +++ b/docs/前端对接说明-统一鉴权中心.md @@ -0,0 +1,198 @@ +# 统一鉴权中心(Keycloak)—— 前端对接说明 + +> 后端已按 ADR-012 改成**资源服务器**:不再有登录口、会话 cookie 与岗位授权; +> 每个请求带 `Authorization: Bearer `,后端本地验签、按「角色 → 端」判权。 +> 这份说明只讲**前端要改什么、后端给了什么**。浏览器怎么登录、怎么取 / 刷 token, +> 照公司统一的《Keycloak 前端接入手册》,本文不重复。 +> 接口的字段与状态码以 `docs/API文档-鉴权与人员.md` 与 `packages/contracts/src/api/*.ts` 为准。 + +## 0. 一分钟看懂 + +- 后端**不做登录**。前端(公司已在 Keycloak `corp` realm 建好两个前端 client:`web-portal` 用户端 + `jcai-bpos.akhr-ai.com` / `web-console` 管理端 `jcai-bpos-opt.akhr-ai.com`,PKCE)自己拿 token。 +- token 的 `aud` 必须含本后端的 clientId(后端环境变量 `KEYCLOAK_CLIENT_ID`,建议 `api-gateway`); + 后端**不看 `azp`**,用哪个前端 client 取的 token 都行 —— 没有 aud 会全线 401,后端的 401 message 会点名让管理员加 audience mapper。 +- 每个请求头带 `Authorization: Bearer …`。`GET /api/me` 回答两件事:我是谁、我能进哪几个端(`ends`)。 +- **权限只到端**:`design`(设计中心 `/design`)/ `ops`(运营中心 `/ops`)/ `workbench`(工作台 `/`)。 + 入口亮不亮、页面能不能进,只看 `ends`;进去之后哪个接口能调,后端按每条路由的归属自己判(§6 总表)。 +- 两条带不了 Authorization 头的路(`EventSource` 与 ``)用 **60 秒票据**: + `GET /api/auth/ticket` → 把 `ticket` 放到那条 GET 的 `?ticket=` 上。 +- 401 = 该重新登录(或刷 token);403 = 没权限,重登没用;410 = 这条路由退役了,看 §7 的替代。 + +## 1. 请求链 + +``` +浏览器(SPA,PKCE)──登录/刷新 token──▶ https://keycloak.akhr-ai.com/realms/corp + │ + │ Authorization: Bearer (aud ∋ api-gateway) + ▼ +apps/api 鉴权中间件(唯一一处) + ① 路由归属:契约里每条路由自带 access(public / any / 端) + ② 验签:RS256 · iss · aud · 30s 容差(本地,不回源) + ③ 名册即时更新:第一次见到这个 sub 就建档(岗位「未分配」) + ④ 角色 × 角色→端表 → ends;端不够 → 403 + ⑤ 路由拿到 principal(staff / position / roles / ends) +``` + +## 2. 改动清单(按 `apps/web` 的文件) + +| 文件 | 现在 | 改成 | +|---|---|---| +| `shared/typed-fetch.ts` | `credentials: 'same-origin'`,靠 cookie | 每个请求加 `Authorization: Bearer `;401 → 刷一次 token 重试,仍 401 → 落登录;403 → 抛给页面提示(带 `detail.need` / `detail.ends`),**不要**跳登录 | +| `app/main.tsx`(`Gate`) | `call(sessionRoute)`,401 → `LoginPage` | 没 token → 走手册的登录跳转;有 token → `call(meRoute)`;`authed` = `me` 200;401 仍落登录 | +| `features/auth/internal/LoginPage.tsx` | 账号 + 口令表单 `POST /api/login` | 删除表单;换成「跳转到统一登录」(手册的 `login()`);`/api/login` 现回 410 | +| `shared/gov/surfaces.ts` | `canSeeSurface(session, surface)` 读 `session.grants` × `SURFACE_FNS` | `canSeeSurface(me, surface) = me.ends.includes(surface)`;`SURFACE_FNS` 删除 | +| `app/gov-shell.tsx` / `app/app-shell.tsx` | `session.data?.staff.name`;登出 `call(logoutRoute)` | `me.data?.staff.name`;登出 = 丢掉本地 token + Keycloak end-session(手册);`/api/logout` 现回 410 | +| `app/subscribe.ts` | `new EventSource('/api/stream')` | 先 `GET /api/auth/ticket`,再 `new EventSource('/api/stream?ticket=…')`;断线重连要**重新取票据**(§5) | +| `features/desk-task/internal/ViewRegion.tsx` + `FileViewer.tsx` | `` | 点击前现取票据拼到 `&ticket=`,或 `fetch`(带头)→ `blob:` URL(§5) | +| `features/gov-staff/*` + `shared/gov/gov-api.ts` | 建人 / 停用 / 停用前置检查 / 岗位授权 | 四条已退役(410);换成:同步名册、指派岗位、在途工作、角色 → 端(§7);`useGovGrants` 删 | +| 类型清理 | `Session` `sessionSchema` `grant*` `GovernanceFunction*` `disableCheckSchema` | 这轮契约里标了 `@deprecated`,web 编译照过;前端接入完成时一并从 `@contracts` 删(§12.4 三步之第三步) | + +不用动的:`ui-kit` 的 `ViewContainer`、各 feature 的业务调用(路由契约的路径 / 参数 / 出参一个没改,只是每条多了 `access`)、 +Vite 的 `/api` 反代、SSE 事件的形状(`ready` / `touch` / `ping`)。 + +## 3. `GET /api/me` 与「端」 + +```json +{ + "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"] +} +``` + +- `staff.id` 就是 Keycloak 的 `sub`。所有原来拿 `staff.id` 的地方(负责人、卡的收件人、工作组成员)语义不变,值变成 UUID。 +- `staff.roles` 是 realm 角色(签发时的快照;`default-roles-corp` / `offline_access` / `uma_authorization` 已滤掉)。 +- `ends` 是**并集**:角色命中「角色 → 端」表的每一行取并集;顺序固定 `design, ops, workbench`。 +- `ends` 可以是 `[]`(角色在表里一行都没命中)—— `/api/me` 仍 200,别的路由 403。 + 这时候页面该说「你的角色(xxx)还没有被授权进入任何一端,请联系运营中心」,不要落登录页。 +- 岗位 `position.code` 有两个特殊值:`NONE`(未分配,新人第一次登录就是它)、`TEST`(测试账号)。 + +403 的 `detail` 告诉你差的是哪个端、他能进哪几个: + +```json +{ + "code": "forbidden", + "message": "进入「运营中心」需要授权;你的角色(user)可进:工作台", + "detail": { "need": ["ops"], "ends": ["workbench"] } +} +``` + +## 4. token 怎么带、失败怎么处理 + +| 状态 | code | 意思 | 前端动作 | +|---|---|---|---| +| 401 | `unauthenticated` | 没带 token / 过期 / 签名不对 / aud 不含本服务(message 会说清哪一种) | 先刷一次 token 重试;仍 401 → 清本地 token,走登录 | +| 403 | `forbidden` | token 对,但账号已在鉴权中心停用,或这条路由的端不在 `ends` 里 | 提示,不重登;停用的 message 会说「已停用」 | +| 410 | `gone` | 路由随统一鉴权中心退役 | 改调 message 里点名的替代路由(§7) | +| 409 | `conflict` | 角色 → 端改完会没人能进运营中心(锁死保护) | 把 message 原样给用户看 | + +细节: + +- access token 默认 5 分钟;刷新由前端管(手册),后端不参与。后端验签有 30 秒时钟容差。 +- 后端每个请求都验签,是纯本地运算(公钥缓存 10 分钟),不用担心「带 token 会慢」。 +- 撤了角色,旧 token 到期前仍按旧角色算(最坏 5 分钟);账号停用经运营中心「同步名册」后即时 403。 +- 不要把 token 放进 URL(票据除外,见 §5)、不要放进 localStorage 以外的地方由后端读 —— 后端只认 `Authorization` 头。 + +## 5. 票据:SSE 与下载 + +`EventSource` 与 `` 带不了请求头,所以后端给了一条**只对 GET 有效**的路: + +``` +GET /api/auth/ticket Authorization: Bearer … +→ 200 { "ticket": "v1.OGYzYzFhMmI….1756112460.k5v…", "expiresAt": "2026-08-25T09:01:00.000Z" } +``` + +- 票据 60 秒有效、只在 **GET** 的 `?ticket=` 上认;POST / PUT / DELETE 带了也当没带(401)。 +- 票据里没有权限:后端拿它对应的 sub 回库现算,停用 / 改角色即时生效。 +- 同时带了 `Authorization` 头和票据时,以头为准。 + +### SSE + +```ts +// app/subscribe.ts 的改法(示意) +async function openStream(onTouch: (t: Touch) => void, onOpen: () => void): Promise<() => void> { + const { ticket } = await call<{ ticket: string; expiresAt: string }>(authTicketRoute); + const source = new EventSource(`/api/stream?ticket=${encodeURIComponent(ticket)}`); + source.addEventListener('touch', (e) => onTouch(JSON.parse((e as MessageEvent).data))); + source.addEventListener('open', onOpen); + // 票据只活 60 秒:断线后浏览器自动重连用的还是旧 URL,后端会 401, + // 而 EventSource 收到非 200 就不再自动重连 —— 所以 error 时要自己关掉、重新取票据、重建 + source.addEventListener('error', () => { + source.close(); + setTimeout(() => void openStream(onTouch, onOpen), 1000); + }); + return () => source.close(); +} +``` + +### 下载原件 + +两种都行,任选其一: + +1. 点击前现取票据:`href = \`/api/desk/tasks/${id}/file?address=…&key=…&raw=1&ticket=${encodeURIComponent(ticket)}\`` + —— 票据 60 秒,**别在渲染时预取**,要在 `onClick` 里取完再 `window.location.assign(href)`(或动态创建 `` 点一下)。 +2. `fetch(href, { headers: { authorization } })` → `blob()` → `URL.createObjectURL` → `` 点一下 → `revokeObjectURL`。 + 不用票据,但大文件会先进内存。 + +`FileViewer` 里「预览」那条路本来就是 `fetch`(经 `typed-fetch`),带头即可;只有「下载原件」那枚 `` 需要上面两选一。 + +## 6. 谁能进哪个端:入口与页面 + +- 入口亮灯:`canSeeSurface(me, 'design')` / `'ops'`;工作台入口同理看 `'workbench'`(从前工作台是默认进得去的,现在也要看 `ends`)。 +- 深链保护:进入 `/design` 或 `/ops` 时若 `ends` 不含该端,落到能进的第一个端;一个都不能进 → §3 的「未授权」页。 +- 页面内的按钮**不用**再按功能藏:后端不再有「task:read / task:govern」这种粒度,同一端内的接口对能进该端的人全开。 + 某些**读**接口两端都能调(`GET /api/gov/staff` 等八条,见 API 文档 §6), + 写接口只归一端(触发器的启停 / 节点负责人归运营;定义类归设计)。 + +## 7. 退役路由与替代 + +| 退役(410) | 替代 | +|---|---| +| `POST /api/login` | 手册的登录跳转(Keycloak) | +| `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/:id/disable-check` | `GET /api/gov/staff/:id/in-flight`(只读:此人身上还挂着什么) | +| `POST /api/gov/staff/:id/disable` | 在 Keycloak 停用账号 → `POST /api/gov/staff/sync` | +| `GET|POST /api/gov/positions/:id/grants` | `GET|PUT|DELETE /api/gov/access/roles[/:role]`(角色 → 端) | + +运营中心 · 员工 那一页建议长这样: + +- **名册**:`GET /api/gov/staff` 照旧(每人多了 `email` / `roles`;`positions` 里多了 `NONE` 未分配、`TEST` 测试); + 「同步名册」按钮 → `POST /api/gov/staff/sync`,`configured:false` 就把 `message` 原样摆出来(别摆一排 0); + 每人一个岗位下拉 → `PUT /api/gov/staff/:id/position`;「在途工作」→ `GET /api/gov/staff/:id/in-flight`。 +- **角色 → 端**:`GET /api/gov/access/roles` 出 `roles`(已配置的行)与 `knownRoles`(名册上见过的 ∪ 已配置的); + 每行三个勾(设计中心 / 运营中心 / 工作台)→ `PUT /api/gov/access/roles/:role` body `{ ends }`; + 删行 → `DELETE`;409 时把 message 摆出来(「改完没人能进运营中心」)。 + 没在表里的 `knownRoles` 项要能一键「加进来」(等于 PUT 一行)。 +- **工作组**:`GET|PUT /api/gov/work-groups` 不变。 + +## 8. 本地开发 + +- 后端启动必须配 `KEYCLOAK_URL` `KEYCLOAK_REALM` `KEYCLOAK_CLIENT_ID`(缺一拒绝启动,会打印缺哪个); + 可选 `KEYCLOAK_PROVISIONING_CLIENT_ID/_SECRET`(同步名册)、`JCAI_TEST_ACCOUNTS`(测试账号用户名)。 +- Vite 的 `/api` 反代不变;浏览器直连 `keycloak.akhr-ai.com` 取 token,与后端无关(跨域由 Keycloak 的 Web Origins 管,手册有写)。 +- 想在没前端的情况下打接口:用服务账号拿 token(见 API 文档 §8 的 curl),或让管理员临时给 `web-portal` 开 Direct Access Grants 用密码取一枚(手册 §6.1,用完关掉)。 +- 试跑 / e2e 用的「AI 账号」现在是 Keycloak 里的服务账号(`bpos-e2e`),工作台测试时用它的 token。 + +## 9. 接入完成的判据 + +- [ ] 未登录打开任一页 → 跳统一登录;登录回来 `GET /api/me` 200,首页按 `ends` 落到能进的端 +- [ ] `ends = []` 的账号看到「未授权」页,不是登录页,也不是白屏 +- [ ] 三个入口的亮灯与 `ends` 一致;深链到没权限的端会被挡回 +- [ ] token 过期(等 5 分钟)后操作不报错:刷新后重试成功 +- [ ] SSE 连上、断线 61 秒后能自动重连(新票据) +- [ ] 「下载原件」拿到的是真字节(`.docx` 能被 Word 打开) +- [ ] 运营中心 · 员工:同步名册 / 指派岗位 / 角色 → 端三样能用;409 与 `configured:false` 的话原样可见 +- [ ] 登出后再访问 → 401 → 登录页;旧 token 不再被任何地方发出 +- [ ] `apps/web` 里不再 import `loginRoute` / `logoutRoute` / `sessionRoute` / `Session` / `grantSchema` / `SURFACE_FNS`