Archived
名册同步补三道下限保护;同名让位;停用判定走在 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:
+30
-3
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user