Archived
**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
129 lines
5.5 KiB
TypeScript
129 lines
5.5 KiB
TypeScript
/**
|
|
* module: org —— 对外类型(编码规范 §10.1)。只有类型和常量,没有实现。
|
|
*
|
|
* 身份归统一鉴权中心(ADR-012):`Staff.id` 就是 Keycloak 的 `sub`。
|
|
* 本模块管三件事:①验签后的人是谁(名册即时更新);②他能进哪几个端(角色→端);③岗位与工作组。
|
|
* 口令 / 会话 / 岗位授权(客体三类 × 动作三种)已退役。
|
|
*/
|
|
import type {
|
|
InFlightWork,
|
|
Position,
|
|
PositionId,
|
|
Principal,
|
|
RoleAccess,
|
|
RosterSyncOutcome,
|
|
Staff,
|
|
StaffId,
|
|
Surface,
|
|
WorkGroup,
|
|
WorkGroupBinding,
|
|
} from '@contracts';
|
|
|
|
export type { InFlightWork, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding };
|
|
|
|
/** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */
|
|
export type StaffWithPosition = Staff & { readonly position: Position };
|
|
|
|
/** 从 token 或 Admin API 归一出来的一个人 —— `sub` 即 Staff.id */
|
|
export type Identity = {
|
|
readonly sub: string;
|
|
/** preferred_username */
|
|
readonly account: string;
|
|
/** name claim,缺则回退 account */
|
|
readonly name: string;
|
|
readonly email?: string;
|
|
/** realm 角色,噪声角色已滤、已排序 */
|
|
readonly roles: readonly string[];
|
|
};
|
|
|
|
export class StaffNotFoundError extends Error {
|
|
constructor(readonly staffId: StaffId) {
|
|
super(`员工不存在:${staffId}`);
|
|
this.name = 'StaffNotFoundError';
|
|
}
|
|
}
|
|
|
|
export class PositionNotFoundError extends Error {
|
|
constructor(readonly positionCode: string) {
|
|
super(`岗位不存在:${positionCode}`);
|
|
this.name = 'PositionNotFoundError';
|
|
}
|
|
}
|
|
|
|
/** 验签没过 —— message 是手册 4.4 的人话,中间件原样回 401 */
|
|
export class TokenRejectedError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'TokenRejectedError';
|
|
}
|
|
}
|
|
|
|
/** 名册同步标了停用 —— 即使 token 还有效也拒(403);Keycloak 恢复后再同步一次 */
|
|
export class AccountDisabledError extends Error {
|
|
constructor(readonly account: string) {
|
|
super(`账号已在鉴权中心停用:${account} —— 若已恢复,请在运营中心同步一次名册`);
|
|
this.name = 'AccountDisabledError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 这一步做下去就没有任何在册活跃员工能进运营中心了 —— 拒绝(路由映射 409)。
|
|
* 两个来路:改「角色 → 端」(那张表就再也没人改得了)、同步名册(同步按钮自己也要 ops)。
|
|
* `message` 可覆盖:拒绝的理由不同,给人的下一步也不同。
|
|
*/
|
|
export class AccessLockoutError extends Error {
|
|
constructor(message = '改完之后没有任何在册员工能进运营中心 —— 这张表就再也改不回来了,拒绝') {
|
|
super(message);
|
|
this.name = 'AccessLockoutError';
|
|
}
|
|
}
|
|
|
|
export type OrgService = {
|
|
/* ── 身份(ADR-012)—— 中间件在 app 层,身份判定读这里 ─────────────── */
|
|
|
|
/** 验签 → 名册即时更新 → 角色→端。抛 TokenRejectedError(401)/ AccountDisabledError(403) */
|
|
authenticate(token: string): Promise<Principal>;
|
|
/** 票据那条路:按 sub 从库现算;停用抛 AccountDisabledError;不存在回 undefined */
|
|
principalOf(staffId: StaffId): Promise<Principal | undefined>;
|
|
/** 名册即时更新的入口 —— 没有就建(NONE 岗、active),有就把变了的列写回;测试也用它造人 */
|
|
registerIdentity(identity: Identity): Promise<Staff>;
|
|
|
|
/* ── 名册与岗位 ───────────────────────────────────────────────────── */
|
|
|
|
listStaff(): Promise<{ staff: StaffWithPosition[]; positions: Position[] }>;
|
|
getStaff(staffId: StaffId): Promise<StaffWithPosition>;
|
|
/** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用);只回 active;NONE 恒回 [] */
|
|
staffOfPosition(positionCode: string): Promise<StaffWithPosition[]>;
|
|
setPosition(staffId: StaffId, positionId: PositionId): Promise<StaffWithPosition>;
|
|
/** Admin API 同步;未配 provisioning 回 configured:false */
|
|
syncRoster(): Promise<RosterSyncOutcome>;
|
|
/** 此人身上还挂着什么(只读;OS 02 改派先于停用) */
|
|
inFlightOf(staffId: StaffId): Promise<InFlightWork>;
|
|
|
|
/* ── 角色 → 端 ───────────────────────────────────────────────────── */
|
|
|
|
listRoleAccess(): Promise<{ roles: RoleAccess[]; knownRoles: string[] }>;
|
|
/** 抛 AccessLockoutError(409):改完没有任何在册活跃员工能进运营中心 */
|
|
setRoleAccess(role: string, ends: readonly Surface[]): Promise<RoleAccess>;
|
|
deleteRoleAccess(role: string): Promise<void>;
|
|
/** 能进该端的活跃员工 —— 治理台通知的收件人 */
|
|
staffWithEnd(surface: Surface): Promise<StaffWithPosition[]>;
|
|
|
|
/* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────────── */
|
|
|
|
/** 建组 / 改组;id 留空即新建 */
|
|
saveWorkGroup(input: {
|
|
id?: string;
|
|
name: string;
|
|
ownerId: StaffId;
|
|
members: Readonly<Record<string, StaffId>>;
|
|
}): Promise<WorkGroup>;
|
|
/** 某人的工作组 —— 起单时按来源人取,取不到即不装载(全走四策略) */
|
|
workGroupOf(staffId: StaffId): Promise<WorkGroup | undefined>;
|
|
/** 装进工作流的快照:改组不改已起的单 */
|
|
bindingOf(staffId: StaffId): Promise<WorkGroupBinding | undefined>;
|
|
|
|
/** 岗位(含 NONE / TEST)+ 缺省角色→端(表空时)+ 已在册测试账号的策略;幂等 */
|
|
ensureSeeded(): Promise<void>;
|
|
};
|