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
192 lines
7.3 KiB
TypeScript
192 lines
7.3 KiB
TypeScript
/**
|
|
* 员工、岗位与端(编码规范 §4 · `org`;语义出处 OS 02 员工管理;鉴权归统一鉴权中心,ADR-012)。
|
|
*
|
|
* 身份来自 Keycloak:`Staff.id` 就是 token 的 `sub`。权限只到「端」(design / ops / workbench),
|
|
* 角色 → 端由运营中心的表定;岗位仍是本系统的表(流程引擎按它解析负责人)。
|
|
* 旧的岗位授权(客体三类 × 动作三种)与口令会话已退役 —— 类型留一轮(@deprecated,下个迭代删)。
|
|
*/
|
|
import { z } from 'zod';
|
|
import { positionIdSchema, staffIdSchema } from './id.ts';
|
|
import { surfaceSchema } from './api/http.ts';
|
|
|
|
export const positionSchema = z.object({
|
|
id: positionIdSchema,
|
|
code: z.string().min(1),
|
|
name: z.string().min(1),
|
|
note: z.string().optional(),
|
|
});
|
|
export type Position = z.infer<typeof positionSchema>;
|
|
|
|
export const STAFF_STATES = ['active', 'disabled'] as const;
|
|
export const staffStateSchema = z.enum(STAFF_STATES);
|
|
export type StaffState = z.infer<typeof staffStateSchema>;
|
|
|
|
export const staffSchema = z.object({
|
|
/** = Keycloak `sub`(手册硬约束 8:业务外键用 sub,不用用户名) */
|
|
id: staffIdSchema,
|
|
/** = `preferred_username`,只作显示 —— 用户名允许改,别拿它当键 */
|
|
account: z.string().min(1),
|
|
/** = `name` claim,缺则回退 account */
|
|
name: z.string().min(1),
|
|
email: z.string().optional(),
|
|
/** 未指派 = 哨兵岗 `NONE / 未分配` 的 id */
|
|
positionId: positionIdSchema,
|
|
/** 只由名册同步置 `disabled`(Keycloak 停用或删除);有效 token 不改它 */
|
|
state: staffStateSchema.default('active'),
|
|
/** realm 角色快照(token 签发时;噪声角色已滤) */
|
|
roles: z.array(z.string()).default([]),
|
|
});
|
|
export type Staff = z.infer<typeof staffSchema>;
|
|
|
|
/* ── 我是谁、能进哪几个端(ADR-012) ───────────────────────────────── */
|
|
|
|
export const principalSchema = z.object({
|
|
staff: staffSchema,
|
|
position: positionSchema,
|
|
roles: z.array(z.string()),
|
|
ends: z.array(surfaceSchema),
|
|
});
|
|
export type Principal = z.infer<typeof principalSchema>;
|
|
|
|
/** 角色 → 端:一行一个 realm 角色 */
|
|
export const roleAccessSchema = z.object({
|
|
role: z.string().min(1),
|
|
ends: z.array(surfaceSchema),
|
|
});
|
|
export type RoleAccess = z.infer<typeof roleAccessSchema>;
|
|
|
|
/**
|
|
* 名册同步的结果 —— 没配 provisioning 就如实说没配,不摆零。
|
|
* 三个计数说的是**真落进库里的条数**,不是打算写几条:单条写失败时它不进计数,
|
|
* 而是点名进 `failed` —— 一次半途失败的同步不许报成一排绿数字。
|
|
*/
|
|
export const rosterSyncOutcomeSchema = z.discriminatedUnion('configured', [
|
|
z.object({ configured: z.literal(false), message: z.string() }),
|
|
z.object({
|
|
configured: z.literal(true),
|
|
inserted: z.number().int().min(0),
|
|
updated: z.number().int().min(0),
|
|
disabled: z.number().int().min(0),
|
|
total: z.number().int().min(0),
|
|
/** 逐条落库时失败的人:账号 + 原话(厂商原文照抄,不改写成「操作失败」) */
|
|
failed: z.array(z.object({ account: z.string(), reason: z.string() })),
|
|
}),
|
|
]);
|
|
export type RosterSyncOutcome = z.infer<typeof rosterSyncOutcomeSchema>;
|
|
|
|
/* ── 在途工作(只读;OS 02:改派先于停用,停用本身已归鉴权中心) ───── */
|
|
|
|
export const inFlightWorkSchema = z.object({
|
|
staffId: staffIdSchema,
|
|
blocked: z.boolean(),
|
|
openTasks: z.array(z.object({ taskId: z.string(), title: z.string() })).default([]),
|
|
openCards: z.array(z.object({ cardId: z.string(), title: z.string() })).default([]),
|
|
openApprovals: z.array(z.object({ approvalId: z.string(), title: z.string() })).default([]),
|
|
});
|
|
export type InFlightWork = z.infer<typeof inFlightWorkSchema>;
|
|
|
|
/** @deprecated 随 ADR-012 改名 `inFlightWorkSchema`;下个迭代删 */
|
|
export const disableCheckSchema = inFlightWorkSchema;
|
|
/** @deprecated 改用 `InFlightWork` */
|
|
export type DisableCheck = InFlightWork;
|
|
|
|
/* ── 岗位授权(已退役,ADR-012;类型留一轮给 web 编译,下个迭代删) ──── */
|
|
|
|
/** @deprecated 权限只到端,动作三分已退役 */
|
|
export const GRANT_ACTIONS = ['read', 'write', 'govern'] as const;
|
|
/** @deprecated */
|
|
export const grantActionSchema = z.enum(GRANT_ACTIONS);
|
|
/** @deprecated */
|
|
export type GrantAction = z.infer<typeof grantActionSchema>;
|
|
/** @deprecated */
|
|
export const GRANT_ACTION_LABEL: Readonly<Record<GrantAction, string>> = {
|
|
read: '读',
|
|
write: '写',
|
|
govern: '治理',
|
|
};
|
|
|
|
/** @deprecated 治理台九功能不再是授权客体 */
|
|
export const GOVERNANCE_FUNCTIONS = [
|
|
'task',
|
|
'staff',
|
|
'process',
|
|
'trigger',
|
|
'activity',
|
|
'tool',
|
|
'data',
|
|
'memory',
|
|
'system',
|
|
] as const;
|
|
/** @deprecated */
|
|
export const governanceFunctionSchema = z.enum(GOVERNANCE_FUNCTIONS);
|
|
/** @deprecated */
|
|
export type GovernanceFunction = z.infer<typeof governanceFunctionSchema>;
|
|
/** @deprecated */
|
|
export const GOVERNANCE_FUNCTION_LABEL: Readonly<Record<GovernanceFunction, string>> = {
|
|
task: '任务管理',
|
|
staff: '员工管理',
|
|
process: '工作流管理',
|
|
trigger: '触发器配置',
|
|
activity: '活动库管理',
|
|
tool: '工具管理',
|
|
data: '数据管理',
|
|
memory: '记忆管理',
|
|
system: '系统管理',
|
|
};
|
|
|
|
/** @deprecated */
|
|
export const grantObjectSchema = z.discriminatedUnion('kind', [
|
|
z.object({
|
|
kind: z.literal('data'),
|
|
domain: z.string().min(1),
|
|
object: z.string().min(1),
|
|
field: z.string().optional(),
|
|
}),
|
|
z.object({ kind: z.literal('governance'), function: governanceFunctionSchema }),
|
|
z.object({ kind: z.literal('task'), activityId: z.string().optional() }),
|
|
]);
|
|
/** @deprecated */
|
|
export type GrantObject = z.infer<typeof grantObjectSchema>;
|
|
|
|
/** @deprecated */
|
|
export const grantSchema = z.object({
|
|
positionId: positionIdSchema,
|
|
object: grantObjectSchema,
|
|
action: grantActionSchema,
|
|
});
|
|
/** @deprecated */
|
|
export type Grant = z.infer<typeof grantSchema>;
|
|
|
|
/* ── 会话(已退役:身份在 token 里,ADR-012) ──────────────────────── */
|
|
|
|
/** @deprecated 改用 `principalSchema` / `GET /api/me` */
|
|
export const sessionSchema = z.object({
|
|
staff: staffSchema,
|
|
position: positionSchema,
|
|
grants: z.array(grantSchema),
|
|
});
|
|
/** @deprecated 改用 `Principal` */
|
|
export type Session = z.infer<typeof sessionSchema>;
|
|
|
|
/* ── 工作组(ADR-011) ────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* 工作组 —— 一组「岗位 → 员工」的绑定,起单时装载、随工作流走。
|
|
*
|
|
* **不是第五种负责人策略**(OS 03「策略封闭四选」不动):
|
|
* 四策略回答的是「这个岗位该派给谁」,工作组直接给出了答案,策略于是无从发问。
|
|
* 键是**岗位 code**(BD / PM / FAE …),与流程包 `owner.position` 同一口径。
|
|
*/
|
|
export const workGroupSchema = z.object({
|
|
id: z.string().min(1),
|
|
name: z.string().min(1),
|
|
/** 组归谁 —— 起单时按来源人取其组 */
|
|
ownerId: staffIdSchema,
|
|
members: z.record(z.string().min(1), staffIdSchema),
|
|
});
|
|
export type WorkGroup = z.infer<typeof workGroupSchema>;
|
|
|
|
/** 装进工作流的**快照**:改组不改已起的单(留痕不被改写) */
|
|
export const workGroupBindingSchema = z.record(z.string().min(1), staffIdSchema);
|
|
export type WorkGroupBinding = z.infer<typeof workGroupBindingSchema>;
|