名册同步补三道下限保护;同名让位;停用判定走在 JIT 写之前

**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
This commit is contained in:
xcs
2026-08-25 19:42:19 +08:00
co-authored by Claude Opus 5
parent 5964a80b28
commit 5facf6b745
17 changed files with 393 additions and 38 deletions
+30 -3
View File
@@ -11,6 +11,7 @@
| 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`(能进其中任一端即可) |
@@ -22,7 +23,7 @@
| 401 | `unauthenticated` | 缺 token / 过期 / 签名不对 / aud 或 iss 不对 / 票据坏了(message 逐一说清) |
| 403 | `forbidden` | 账号已在鉴权中心停用;或这条路由要求的端不在你的 `ends` 里(`detail.need` / `detail.ends`) |
| 404 | `not-found` | 无此路由(**先 401 后 404**:没登录的人探不到路由表)/ 对象不存在 |
| 409 | `conflict` | 角色 → 端改完没人能进运营中心;删 LLM 实例还有活动引用 |
| 409 | `conflict` | 角色 → 端改完没人能进运营中心;**这次名册同步会让没人能进运营中心**(整体拒绝,一条都不写);删 LLM 实例还有活动引用 |
| 410 | `gone` | 路由随统一鉴权中心退役(§5) |
| 412 | `precondition-failed` | 入参合法、权限也够,但**这件事此刻办不了**:流程包写回生效版本 / 启用时闭合校验没过、活动包安装两审(静态审计 + 回放)未过、决议已登记过不重复起单、汇报已提交过、入站邮件通道已停用。`detail` 常带闭合报告或安装审计结论 |
| 400 | `validation-failed` | 入参不合 schema,`detail` 是 zod issues |
@@ -106,7 +107,15 @@
从 Keycloak Admin API 同步名册(provisioning 服务账号,只读)。无请求体。
```json
{ "configured": true, "inserted": 2, "updated": 1, "disabled": 0, "total": 14 }
{ "configured": true, "inserted": 2, "updated": 1, "disabled": 0, "total": 14, "failed": [] }
```
三个计数说的是**真落进库里的条数**。单条写失败不让整次同步停在半路:它不进计数,而是点名进 `failed`
(`reason` 照抄原话,不改写成「操作失败」):
```json
{ "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):
@@ -117,6 +126,17 @@
规则:远端没有的本地人 → `disabled`;远端 `enabled=false` → `disabled`;远端又启用 → `active`;字段没变的不写。
「缺席即停用」有三道下限保护(全员停用之后,这个按钮自己要 `ops`,那条恢复路会自锁):
1. 远端**一个人都没有**时不据此停任何人 —— 那更像取数出了问题(realm 配错、代理 200 空体、
`view-users` 被改窄),不像全员离职;
2. `JCAI_TEST_ACCOUNTS` 里的服务账号缺席不算离职 —— Admin 的 `GET /users` 本来就不返回服务账号用户;
3. 落库之前先算一遍「这一次同步之后还有谁能进运营中心」,算下来没有人 → **409 整体拒绝,一条都不写**:
```json
{ "code": "conflict", "message": "这次同步会让没有人能进运营中心,已拒绝(一条都没写)—— 先核对 provisioning 指的是不是同一个 realm,以及那个服务账号还有没有 view-users。" }
```
### `PUT /api/gov/staff/:staffId/position` —— access: `ops`
body `{ "positionId": "bd" }` → 出参 = 那一行 `staff` 连同 `position`(形状同 §3 列表里的一项)。
@@ -306,7 +326,14 @@ 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 };
| {
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 = {