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:
+6
-2
@@ -39,9 +39,13 @@ export declare class AccountDisabledError extends Error {
|
||||
readonly account: string;
|
||||
constructor(account: string);
|
||||
}
|
||||
/** 角色→端改完之后没有任何在册员工能进运营中心 —— 那张表就再也没人改得了 */
|
||||
/**
|
||||
* 这一步做下去就没有任何在册活跃员工能进运营中心了 —— 拒绝(路由映射 409)。
|
||||
* 两个来路:改「角色 → 端」(那张表就再也没人改得了)、同步名册(同步按钮自己也要 ops)。
|
||||
* `message` 可覆盖:拒绝的理由不同,给人的下一步也不同。
|
||||
*/
|
||||
export declare class AccessLockoutError extends Error {
|
||||
constructor();
|
||||
constructor(message?: string);
|
||||
}
|
||||
export type OrgService = {
|
||||
/** 验签 → 名册即时更新 → 角色→端。抛 TokenRejectedError(401)/ AccountDisabledError(403) */
|
||||
|
||||
@@ -52,6 +52,8 @@ export type StaffRecord = Staff & {
|
||||
export type StaffRepository = {
|
||||
list(): Promise<StaffRecord[]>;
|
||||
findById(id: StaffId): Promise<StaffRecord | undefined>;
|
||||
/** 按账号找 —— `account` 有唯一索引,建档前得先问一句「这个名字被谁占着」(同名让位) */
|
||||
findByAccount(account: string): Promise<StaffRecord | undefined>;
|
||||
listByPosition(positionId: PositionId): Promise<StaffRecord[]>;
|
||||
insert(staff: Staff, marks: {
|
||||
readonly lastSeenAt?: Date;
|
||||
|
||||
@@ -238,6 +238,10 @@ export declare const syncStaffRoute: {
|
||||
updated: z.ZodNumber;
|
||||
disabled: z.ZodNumber;
|
||||
total: z.ZodNumber;
|
||||
failed: z.ZodArray<z.ZodObject<{
|
||||
account: z.ZodString;
|
||||
reason: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>], "configured">;
|
||||
};
|
||||
/** 指派岗位;指回哨兵岗 NONE = 取消指派 */
|
||||
|
||||
+9
-1
@@ -69,7 +69,11 @@ export declare const roleAccessSchema: z.ZodObject<{
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
export type RoleAccess = z.infer<typeof roleAccessSchema>;
|
||||
/** 名册同步的结果 —— 没配 provisioning 就如实说没配,不摆零 */
|
||||
/**
|
||||
* 名册同步的结果 —— 没配 provisioning 就如实说没配,不摆零。
|
||||
* 三个计数说的是**真落进库里的条数**,不是打算写几条:单条写失败时它不进计数,
|
||||
* 而是点名进 `failed` —— 一次半途失败的同步不许报成一排绿数字。
|
||||
*/
|
||||
export declare const rosterSyncOutcomeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
configured: z.ZodLiteral<false>;
|
||||
message: z.ZodString;
|
||||
@@ -79,6 +83,10 @@ export declare const rosterSyncOutcomeSchema: z.ZodDiscriminatedUnion<[z.ZodObje
|
||||
updated: z.ZodNumber;
|
||||
disabled: z.ZodNumber;
|
||||
total: z.ZodNumber;
|
||||
failed: z.ZodArray<z.ZodObject<{
|
||||
account: z.ZodString;
|
||||
reason: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>], "configured">;
|
||||
export type RosterSyncOutcome = z.infer<typeof rosterSyncOutcomeSchema>;
|
||||
export declare const inFlightWorkSchema: z.ZodObject<{
|
||||
|
||||
@@ -34,8 +34,22 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
|
||||
|
||||
app.get(listStaffRoute.path, async (c) => c.json(await container.org.listStaff()));
|
||||
|
||||
/** 从 Keycloak Admin API 同步名册;没配 provisioning 就回 configured:false(界面不撒谎) */
|
||||
app.post(syncStaffRoute.path, async (c) => c.json(await container.org.syncRoster()));
|
||||
/**
|
||||
* 从 Keycloak Admin API 同步名册;没配 provisioning 就回 configured:false(界面不撒谎)。
|
||||
* 409:这一次同步会把最后一个能进运营中心的人也停了 —— 整体拒绝,一条都没写
|
||||
* (与「角色 → 端」那两条同款,理由不同、话不同)。
|
||||
*/
|
||||
app.post(syncStaffRoute.path, async (c) => {
|
||||
try {
|
||||
return c.json(await container.org.syncRoster());
|
||||
} catch (error) {
|
||||
if (error instanceof AccessLockoutError) {
|
||||
const body: ApiError = { code: 'conflict', message: error.message };
|
||||
return c.json(body, 409);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
app.put(setStaffPositionRoute.path, async (c) => {
|
||||
const staffId = staffIdSchema.parse(c.req.param('staffId'));
|
||||
|
||||
@@ -34,8 +34,17 @@
|
||||
- `Staff.id` 就是 Keycloak `sub`;`account` 只作显示,永不当键
|
||||
- `state` 只由名册同步置 `disabled`;`authenticate` / `principalOf` 对 disabled 一律抛 AccountDisabledError,
|
||||
有效 token 不能把它翻回 active(撤权在同步之后即时生效)
|
||||
- **停用判定走在 JIT 写之前**:已停用的人攥着有效 token 也驱动不了 `updateIdentity`;
|
||||
「第一次见到的人要建档」不受影响(本地查无此人时照建)
|
||||
- 名册即时更新只写变了的列;`last_seen_at` 60 秒内不重写
|
||||
- **同名让位**:`account` 有唯一索引;建档前按账号查一次,占着的若是另一个 sub(stale 行),
|
||||
先把它的账号让位成 `账号#retired-<旧 sub 前 8 位>` 再插 —— 不删行、留 stderr
|
||||
- 角色→端:`setRoleAccess` / `deleteRoleAccess` 先算新表再验锁死不变量(名册为空不算锁死)
|
||||
- **名册同步的三道下限保护**(全员停用之后同步按钮自己要 ops,那条恢复路会自锁):
|
||||
①远端整个空着不据此停任何人;②服务账号(`testAccounts`)缺席不算离职(Admin 用户列表本来就不含它们);
|
||||
③落库之前按差异算一遍「同步后还有谁能进运营中心」,不过就整体拒绝(AccessLockoutError → 409),一条都不写
|
||||
- 同步逐条落库:**单条失败不停在半路**,失败的人点名进 `RosterSyncOutcome.failed`;
|
||||
三个计数说的是真落下的条数,不是打算写几条
|
||||
- `staffOfPosition` 只回 active;哨兵岗 NONE 恒回 []
|
||||
- `ensureSeeded` 幂等:岗位按 code 判存在;缺省角色→端只在表空时播;测试账号策略只补不覆盖
|
||||
- 工作组是绑定不是策略(ADR-011):`bindingOf` 只出成员表快照,在岗与否由调用方核
|
||||
@@ -44,3 +53,9 @@
|
||||
`setPassword` `authorize` `loadSession` `listGrants` `setGrants` `createStaff` `checkDisable`
|
||||
`disableStaff` `findByAccount` 与 `SessionRepository` `PasswordHasher` `GrantRepository`;
|
||||
下游 app(中间件 / 装配 / 路由)与 message 装配同批改,depcruise 全量校验通过。
|
||||
|
||||
影响半径(2026-08-25,终审修复波次):`StaffRepository.findByAccount` **回来了**(同名让位要按账号查一次;
|
||||
它是仓储端口上的一口,不是 OrgService 的出口 —— 退役的那个是后者);
|
||||
`RosterSyncOutcome` 增 `failed`(契约变更,api-report 已刷新,`docs/API文档-鉴权与人员.md` §3/§7 同步);
|
||||
`AccessLockoutError` / `assertNotLockedOut` 收可选 `message`(两个来路各说各的话)。
|
||||
下游 depcruise 反查:`rosterSyncOutcomeSchema` 只被 `syncStaffRoute.output` 与本模块用,web 尚无消费方。
|
||||
|
||||
@@ -18,6 +18,8 @@ type Harness = {
|
||||
groups: Map<string, WorkGroup>;
|
||||
positions: Map<string, Position>;
|
||||
calls: { updateIdentity: number };
|
||||
/** 往里放 staffId:那一行的写入就当场失败(模拟单条落库出错) */
|
||||
failWrites: Set<string>;
|
||||
advance(ms: number): void;
|
||||
};
|
||||
|
||||
@@ -28,6 +30,7 @@ function harness(opts: { testAccounts?: string[]; roster?: RosterSource } = {}):
|
||||
const groups = new Map<string, WorkGroup>();
|
||||
const tokens = new Map<string, VerifiedClaims>();
|
||||
const calls = { updateIdentity: 0 };
|
||||
const failWrites = new Set<string>();
|
||||
let nowMs = Date.parse('2026-08-25T09:00:00Z');
|
||||
let seq = 0;
|
||||
|
||||
@@ -54,13 +57,21 @@ function harness(opts: { testAccounts?: string[]; roster?: RosterSource } = {}):
|
||||
staff: {
|
||||
list: async () => [...staff.values()],
|
||||
findById: async (id) => staff.get(id),
|
||||
findByAccount: async (account) => [...staff.values()].find((s) => s.account === account),
|
||||
listByPosition: async (pid) => [...staff.values()].filter((s) => s.positionId === pid),
|
||||
insert: async (s, marks) => {
|
||||
if (failWrites.has(s.id)) throw new Error(`写不进去:${s.id}`);
|
||||
// 库里 `account` 有唯一索引 —— 内存版把这条约束也照搬,不然「同名让位」在测里等于没测
|
||||
const clash = [...staff.values()].find((row) => row.account === s.account && row.id !== s.id);
|
||||
if (clash !== undefined) {
|
||||
throw new Error('duplicate key value violates unique constraint "staff_account_key"');
|
||||
}
|
||||
const row: StaffRecord = { ...s, ...marks };
|
||||
staff.set(s.id, row);
|
||||
return row;
|
||||
},
|
||||
updateIdentity: async (id, patch) => {
|
||||
if (failWrites.has(id)) throw new Error(`写不进去:${id}`);
|
||||
calls.updateIdentity += 1;
|
||||
const { email: _dropped, ...rest } = staff.get(id)!;
|
||||
staff.set(id, {
|
||||
@@ -76,6 +87,7 @@ function harness(opts: { testAccounts?: string[]; roster?: RosterSource } = {}):
|
||||
staff.set(id, { ...staff.get(id)!, positionId: pid });
|
||||
},
|
||||
setState: async (id, state, syncedAt) => {
|
||||
if (failWrites.has(id)) throw new Error(`写不进去:${id}`);
|
||||
staff.set(id, { ...staff.get(id)!, state, syncedAt });
|
||||
},
|
||||
},
|
||||
@@ -110,6 +122,7 @@ function harness(opts: { testAccounts?: string[]; roster?: RosterSource } = {}):
|
||||
groups,
|
||||
positions,
|
||||
calls,
|
||||
failWrites,
|
||||
advance: (ms) => {
|
||||
nowMs += ms;
|
||||
},
|
||||
@@ -179,6 +192,32 @@ describe('authenticate —— 验签 → 名册即时更新 → 角色→端', (
|
||||
expect(await h.org.principalOf(staffIdSchema.parse('ghost'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('停用的账号攥着有效 token 也驱动不了 JIT 写 —— 停用判定走在建档之前', async () => {
|
||||
const h = harness();
|
||||
await h.org.ensureSeeded();
|
||||
h.tokens.set('t', claims({ sub: 's', preferred_username: 'bob' }));
|
||||
await h.org.authenticate('t');
|
||||
h.staff.set('s', { ...h.staff.get('s')!, state: 'disabled' });
|
||||
const before = h.calls.updateIdentity;
|
||||
h.advance(600_000); // 过了 60s 的重写窗:从前这一下就会写一次 last_seen_at
|
||||
h.tokens.set('t', claims({ sub: 's', preferred_username: 'bob', name: '改了名也不许写' }));
|
||||
await expect(h.org.authenticate('t')).rejects.toBeInstanceOf(AccountDisabledError);
|
||||
expect(h.calls.updateIdentity).toBe(before);
|
||||
expect(h.staff.get('s')?.name).toBe('bob');
|
||||
});
|
||||
|
||||
it('同名让位:Keycloak 里同名重建(新 sub)—— 旧行让开账号,新人进得来', async () => {
|
||||
const h = harness();
|
||||
await h.org.ensureSeeded();
|
||||
await h.org.registerIdentity({ sub: 'old-sub-1234567', account: 'alice', name: '旧爱丽丝', roles: ['user'] });
|
||||
h.tokens.set('t', claims({ sub: 'new-sub', preferred_username: 'alice', name: '新爱丽丝' }));
|
||||
const p = await h.org.authenticate('t');
|
||||
expect(p.staff.id).toBe('new-sub');
|
||||
expect(p.staff.account).toBe('alice');
|
||||
expect(h.staff.get('old-sub-1234567')?.account).toBe('alice#retired-old-sub-');
|
||||
expect(h.staff.size).toBe(2);
|
||||
});
|
||||
|
||||
it('测试账号:首次建档即 TEST 岗 + 独立工作组(五岗指向本人)', async () => {
|
||||
const h = harness({ testAccounts: ['service-account-bpos-e2e'] });
|
||||
await h.org.ensureSeeded();
|
||||
@@ -226,13 +265,76 @@ describe('名册与岗位', () => {
|
||||
await h.org.registerIdentity({ sub: 'z', account: 'z', name: 'Z', roles: ['user'] });
|
||||
h.staff.set('c', { ...h.staff.get('a')!, id: staffIdSchema.parse('c'), account: 'c', name: 'C', roles: ['admin'], state: 'disabled' });
|
||||
const outcome = await h.org.syncRoster();
|
||||
expect(outcome).toEqual({ configured: true, inserted: 0, updated: 1, disabled: 2, total: 3 });
|
||||
expect(outcome).toEqual({ configured: true, inserted: 0, updated: 1, disabled: 2, total: 3, failed: [] });
|
||||
expect(h.staff.get('a')?.name).toBe('阿');
|
||||
expect(h.staff.get('b')?.state).toBe('disabled');
|
||||
expect(h.staff.get('z')?.state).toBe('disabled');
|
||||
expect(h.staff.get('c')?.state).toBe('active');
|
||||
});
|
||||
|
||||
it('一次同步会把最后一个能进运营中心的人也停了 → 整体拒绝,一条 setState 都没落', async () => {
|
||||
// 远端只回了一个人(realm 配错 / view-users 被改窄的典型形状):admin 那位「缺席」被判停用
|
||||
const remote: RosterSource = {
|
||||
listUsers: async () => [{ sub: 'u', account: 'u', name: 'U', roles: ['user'], enabled: true }],
|
||||
};
|
||||
const h = harness({ roster: remote });
|
||||
await h.org.ensureSeeded();
|
||||
await h.org.registerIdentity({ sub: 'root', account: 'root', name: 'R', roles: ['admin'] });
|
||||
await h.org.registerIdentity({ sub: 'u', account: 'u', name: 'U', roles: ['user'] });
|
||||
await expect(h.org.syncRoster()).rejects.toBeInstanceOf(AccessLockoutError);
|
||||
expect(h.staff.get('root')?.state).toBe('active');
|
||||
expect(h.staff.get('root')?.syncedAt).toBeUndefined(); // 连碰都没碰过
|
||||
});
|
||||
|
||||
it('远端整个空着 → 一个人都不停(取数出问题不是全员离职)', async () => {
|
||||
const h = harness({ roster: { listUsers: async () => [] } });
|
||||
await h.org.ensureSeeded();
|
||||
await h.org.registerIdentity({ sub: 'root', account: 'root', name: 'R', roles: ['admin'] });
|
||||
await h.org.registerIdentity({ sub: 'u', account: 'u', name: 'U', roles: ['user'] });
|
||||
expect(await h.org.syncRoster()).toEqual({ configured: true, inserted: 0, updated: 0, disabled: 0, total: 0, failed: [] });
|
||||
expect(h.staff.get('root')?.state).toBe('active');
|
||||
expect(h.staff.get('u')?.state).toBe('active');
|
||||
});
|
||||
|
||||
it('服务账号不在 Admin 的用户列表里 —— 缺席不停它', async () => {
|
||||
const remote: RosterSource = {
|
||||
listUsers: async () => [{ sub: 'root', account: 'root', name: 'R', roles: ['admin'], enabled: true }],
|
||||
};
|
||||
const h = harness({ roster: remote, testAccounts: ['service-account-bpos-e2e'] });
|
||||
await h.org.ensureSeeded();
|
||||
await h.org.registerIdentity({ sub: 'root', account: 'root', name: 'R', roles: ['admin'] });
|
||||
await h.org.registerIdentity({ sub: 'ai', account: 'service-account-bpos-e2e', name: 'AI', roles: ['admin'] });
|
||||
const outcome = await h.org.syncRoster();
|
||||
expect(outcome).toEqual({ configured: true, inserted: 0, updated: 0, disabled: 0, total: 1, failed: [] });
|
||||
expect(h.staff.get('ai')?.state).toBe('active');
|
||||
});
|
||||
|
||||
it('单条落库失败:不停在半路,计数只数真落下的,失败的人点名', async () => {
|
||||
const remote: RosterSource = {
|
||||
listUsers: async () => [
|
||||
{ sub: 'root', account: 'root', name: 'R', roles: ['admin'], enabled: true },
|
||||
{ sub: 'u1', account: 'u1', name: '改了名', roles: ['user'], enabled: true },
|
||||
],
|
||||
};
|
||||
const h = harness({ roster: remote });
|
||||
await h.org.ensureSeeded();
|
||||
await h.org.registerIdentity({ sub: 'root', account: 'root', name: 'R', roles: ['admin'] });
|
||||
await h.org.registerIdentity({ sub: 'u1', account: 'u1', name: 'U1', roles: ['user'] });
|
||||
await h.org.registerIdentity({ sub: 'u2', account: 'u2', name: 'U2', roles: ['user'] });
|
||||
h.failWrites.add('u1');
|
||||
const outcome = await h.org.syncRoster();
|
||||
expect(outcome).toEqual({
|
||||
configured: true,
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
disabled: 1,
|
||||
total: 2,
|
||||
failed: [{ account: 'u1', reason: '写不进去:u1' }],
|
||||
});
|
||||
expect(h.staff.get('u1')?.name).toBe('U1'); // 没改成
|
||||
expect(h.staff.get('u2')?.state).toBe('disabled'); // 后面那条照办 —— 不停在半路
|
||||
});
|
||||
|
||||
it('syncRoster 插入的新人也走测试账号策略,且远端停用的直接落 disabled', async () => {
|
||||
const remote: RosterSource = {
|
||||
listUsers: async () => [{ sub: 'ai', account: 'ai', name: 'AI', roles: ['admin'], enabled: true }, { sub: 'x', account: 'x', name: 'X', roles: [], enabled: false }],
|
||||
@@ -240,7 +342,7 @@ describe('名册与岗位', () => {
|
||||
const h = harness({ roster: remote, testAccounts: ['AI'] });
|
||||
await h.org.ensureSeeded();
|
||||
const outcome = await h.org.syncRoster();
|
||||
expect(outcome).toEqual({ configured: true, inserted: 2, updated: 0, disabled: 0, total: 2 });
|
||||
expect(outcome).toEqual({ configured: true, inserted: 2, updated: 0, disabled: 0, total: 2, failed: [] });
|
||||
expect((await h.org.getStaff(staffIdSchema.parse('ai'))).position.code).toBe('TEST');
|
||||
expect(h.staff.get('x')?.state).toBe('disabled');
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Staff } from '@contracts';
|
||||
import { positionIdSchema, staffIdSchema } from '@contracts';
|
||||
import { diffRoster, identityChanged, sameRoles } from '../internal/domain/roster-diff.ts';
|
||||
import { activeRolesAfter, diffRoster, identityChanged, sameRoles } from '../internal/domain/roster-diff.ts';
|
||||
import type { RosterUser } from '../port.ts';
|
||||
|
||||
const none = positionIdSchema.parse('none');
|
||||
@@ -26,13 +26,13 @@ const remote = (sub: string, over: Partial<RosterUser> = {}): RosterUser => ({
|
||||
|
||||
describe('diffRoster —— 远端说了算', () => {
|
||||
it('远端有本地无 → 插', () => {
|
||||
const d = diffRoster([remote('a')], []);
|
||||
const d = diffRoster([remote('a')], [], []);
|
||||
expect(d.insert.map((u) => u.sub)).toEqual(['a']);
|
||||
expect(d.update).toEqual([]);
|
||||
expect(d.setState).toEqual([]);
|
||||
});
|
||||
it('改名 / 改角色 → 改;没变的不出现', () => {
|
||||
const d = diffRoster([remote('a', { name: '阿尔法', roles: ['admin', 'user'] }), remote('b')], [local('a'), local('b')]);
|
||||
const d = diffRoster([remote('a', { name: '阿尔法', roles: ['admin', 'user'] }), remote('b')], [local('a'), local('b')], []);
|
||||
expect(d.update).toEqual([{ id: 'a', patch: { account: 'a', name: '阿尔法', roles: ['admin', 'user'] } }]);
|
||||
expect(d.setState).toEqual([]);
|
||||
});
|
||||
@@ -40,19 +40,68 @@ describe('diffRoster —— 远端说了算', () => {
|
||||
const d = diffRoster(
|
||||
[remote('a', { enabled: false }), remote('b', { enabled: false })],
|
||||
[local('a'), local('b', { state: 'disabled' })],
|
||||
[],
|
||||
);
|
||||
expect(d.setState).toEqual([{ id: 'a', state: 'disabled' }]);
|
||||
});
|
||||
it('本地有远端无(Keycloak 删了人)→ 停', () => {
|
||||
const d = diffRoster([], [local('a'), local('b', { state: 'disabled' })]);
|
||||
const d = diffRoster([remote('c')], [local('a'), local('b', { state: 'disabled' }), local('c')], []);
|
||||
expect(d.setState).toEqual([{ id: 'a', state: 'disabled' }]);
|
||||
});
|
||||
it('远端又启用了 → 复活', () => {
|
||||
const d = diffRoster([remote('a')], [local('a', { state: 'disabled' })]);
|
||||
const d = diffRoster([remote('a')], [local('a', { state: 'disabled' })], []);
|
||||
expect(d.setState).toEqual([{ id: 'a', state: 'active' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('缺席即停用的两道下限保护', () => {
|
||||
it('远端一个人都没有 → 一个都不停(那更像取数出了问题,不像全员离职)', () => {
|
||||
const d = diffRoster([], [local('a'), local('b')], []);
|
||||
expect(d.setState).toEqual([]);
|
||||
expect(d.insert).toEqual([]);
|
||||
expect(d.update).toEqual([]);
|
||||
});
|
||||
it('远端说停的照停 —— 下限保护只挡「缺席」那一支', () => {
|
||||
// b 在远端也在(只是没被停),所以这里只该停 a —— 两支各管各的
|
||||
const d = diffRoster([remote('a', { enabled: false }), remote('b')], [local('a'), local('b')], []);
|
||||
expect(d.setState).toEqual([{ id: 'a', state: 'disabled' }]);
|
||||
});
|
||||
it('测试账号缺席不算离职(Admin 的用户列表本来就不含服务账号)', () => {
|
||||
const d = diffRoster(
|
||||
[remote('x')],
|
||||
[local('ai', { account: 'service-account-bpos-e2e' }), local('b'), local('x')],
|
||||
['service-account-bpos-e2e'],
|
||||
);
|
||||
expect(d.setState).toEqual([{ id: 'b', state: 'disabled' }]);
|
||||
});
|
||||
it('测试账号认大小写与空白 —— 与 isTestAccount 同一把尺', () => {
|
||||
const d = diffRoster([remote('x')], [local('ai', { account: 'AI-Runner' }), local('x')], [' ai-runner ']);
|
||||
expect(d.setState).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activeRolesAfter —— 同步落库之后还活跃的人', () => {
|
||||
it('停用的不算、复活的算、改了角色的按新角色算、新插的按 enabled 算', () => {
|
||||
const localRows = [
|
||||
local('root', { roles: ['admin'] }),
|
||||
local('u1'),
|
||||
local('u2', { state: 'disabled' }),
|
||||
];
|
||||
const diff = diffRoster(
|
||||
[
|
||||
remote('u1', { enabled: false }),
|
||||
remote('u2'),
|
||||
remote('root', { roles: ['user'] }),
|
||||
remote('new', { roles: ['ops-lead'] }),
|
||||
remote('newoff', { roles: ['admin'], enabled: false }),
|
||||
],
|
||||
localRows,
|
||||
[],
|
||||
);
|
||||
expect(activeRolesAfter(localRows, diff).map((r) => r.join('+')).sort()).toEqual(['ops-lead', 'user', 'user']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('identityChanged / sameRoles', () => {
|
||||
it('邮箱从无到有算变', () => {
|
||||
expect(identityChanged(local('a'), { sub: 'a', account: 'a', name: 'a', roles: ['user'], email: 'a@x' })).toBe(true);
|
||||
|
||||
@@ -66,10 +66,14 @@ export class AccountDisabledError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** 角色→端改完之后没有任何在册员工能进运营中心 —— 那张表就再也没人改得了 */
|
||||
/**
|
||||
* 这一步做下去就没有任何在册活跃员工能进运营中心了 —— 拒绝(路由映射 409)。
|
||||
* 两个来路:改「角色 → 端」(那张表就再也没人改得了)、同步名册(同步按钮自己也要 ops)。
|
||||
* `message` 可覆盖:拒绝的理由不同,给人的下一步也不同。
|
||||
*/
|
||||
export class AccessLockoutError extends Error {
|
||||
constructor() {
|
||||
super('改完之后没有任何在册员工能进运营中心 —— 这张表就再也改不回来了,拒绝');
|
||||
constructor(message = '改完之后没有任何在册员工能进运营中心 —— 这张表就再也改不回来了,拒绝') {
|
||||
super(message);
|
||||
this.name = 'AccessLockoutError';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 需要的 realm-management 角色只有 `view-users`(手册 14.6 最小权限)。
|
||||
*/
|
||||
import type { RosterSource, RosterUser } from '../../port.ts';
|
||||
import { isNoiseRole } from '../domain/claims.ts';
|
||||
import { normalizeRoles } from '../domain/claims.ts';
|
||||
|
||||
export type KeycloakRosterOptions = {
|
||||
readonly url: string;
|
||||
@@ -84,7 +84,9 @@ export function createKeycloakRoster(options: KeycloakRosterOptions): RosterSour
|
||||
sub: u.id,
|
||||
account: u.username,
|
||||
name: name === '' ? u.username : name,
|
||||
roles: [...new Set(roles.map((r) => r.name).filter((r) => !isNoiseRole(r)))].sort(),
|
||||
// 与 token 那条路同一把尺 —— 归一自己再写一遍,两处理解一分叉,
|
||||
// `identityChanged` 的逐字比对就会天天判「变了」(或天天判「没变」)
|
||||
roles: normalizeRoles(roles.map((r) => r.name)),
|
||||
enabled: u.enabled,
|
||||
...(u.email === undefined || u.email === '' ? {} : { email: u.email }),
|
||||
});
|
||||
|
||||
@@ -76,6 +76,10 @@ export function createStaffRepository(db: Db): StaffRepository {
|
||||
const rows = await db.select().from(staffTable).where(eq(staffTable.id, id)).limit(1);
|
||||
return rows[0] === undefined ? undefined : toRecord(rows[0]);
|
||||
},
|
||||
async findByAccount(account: string) {
|
||||
const rows = await db.select().from(staffTable).where(eq(staffTable.account, account)).limit(1);
|
||||
return rows[0] === undefined ? undefined : toRecord(rows[0]);
|
||||
},
|
||||
async listByPosition(positionId: PositionId) {
|
||||
const rows = await db.select().from(staffTable).where(eq(staffTable.positionId, positionId));
|
||||
return rows.map(toRecord);
|
||||
|
||||
@@ -17,14 +17,19 @@ export function endsOf(roles: readonly string[], table: readonly RoleAccess[]):
|
||||
return SURFACES.filter((s) => hit.has(s));
|
||||
}
|
||||
|
||||
/** 名册空着(全新安装)不算锁死 —— 那时缺省表还在,第一个人进来就能改 */
|
||||
/**
|
||||
* 名册空着(全新安装)不算锁死 —— 那时缺省表还在,第一个人进来就能改。
|
||||
* 两个调用点各说各的话:改「角色 → 端」说的是这张表,同步名册说的是这一次同步 ——
|
||||
* 拒绝的理由不同,`message` 就得不同(话说不对,人不知道下一步该查什么)。
|
||||
*/
|
||||
export function assertNotLockedOut(
|
||||
table: readonly RoleAccess[],
|
||||
activeStaffRoles: ReadonlyArray<readonly string[]>,
|
||||
message?: string,
|
||||
): void {
|
||||
if (activeStaffRoles.length === 0) return;
|
||||
const someoneKeepsOps = activeStaffRoles.some((roles) => endsOf(roles, table).includes('ops'));
|
||||
if (!someoneKeepsOps) throw new AccessLockoutError();
|
||||
if (!someoneKeepsOps) throw new AccessLockoutError(message);
|
||||
}
|
||||
|
||||
/** 一行存进表里的样子只有一种:去重、按 SURFACES 的顺序 */
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
* 远端(Keycloak Admin API)说了算:
|
||||
* 不在本地 → 插;字段变了 → 改;远端 enabled=false 或**根本不在远端** → 停;远端又启用 → 复活。
|
||||
* 没变的不出现在结果里 —— 同步一次不该把全表重写一遍。
|
||||
*
|
||||
* 「缺席即停用」那一支有两道下限保护:远端整个空着不据此停任何人;服务账号缺席不算离职
|
||||
* (Admin 的用户列表本来就不含它们)。理由写在函数里。
|
||||
*/
|
||||
import type { Staff, StaffId } from '@contracts';
|
||||
import type { Identity } from '../../contract.ts';
|
||||
import type { IdentityPatch, RosterUser } from '../../port.ts';
|
||||
import { isTestAccount } from './test-account.ts';
|
||||
|
||||
export type RosterDiff = {
|
||||
readonly insert: RosterUser[];
|
||||
@@ -41,7 +45,15 @@ export function patchOf(identity: Identity): IdentityPatch {
|
||||
};
|
||||
}
|
||||
|
||||
export function diffRoster(remote: readonly RosterUser[], local: readonly Staff[]): RosterDiff {
|
||||
/**
|
||||
* @param testAccounts 服务账号的用户名(`JCAI_TEST_ACCOUNTS`)—— **必填,调用方得回答得出来**:
|
||||
* 「缺席即停用」这一支要绕开它们,而只有装配层知道哪几个是服务账号。
|
||||
*/
|
||||
export function diffRoster(
|
||||
remote: readonly RosterUser[],
|
||||
local: readonly Staff[],
|
||||
testAccounts: readonly string[],
|
||||
): RosterDiff {
|
||||
const bySub = new Map<string, Staff>(local.map((s) => [s.id, s]));
|
||||
const diff: RosterDiff = { insert: [], update: [], setState: [] };
|
||||
const seen = new Set<string>();
|
||||
@@ -56,8 +68,38 @@ export function diffRoster(remote: readonly RosterUser[], local: readonly Staff[
|
||||
const wanted: Staff['state'] = user.enabled ? 'active' : 'disabled';
|
||||
if (existing.state !== wanted) diff.setState.push({ id: existing.id, state: wanted });
|
||||
}
|
||||
/*
|
||||
* 「本地有、远端无 → 停」是有意的(Keycloak 里删了人,这边就该停),但它是一刀切人的动作,
|
||||
* 下面两道下限保护缺一不可 —— 全员 disabled 之后,恢复动作(同步按钮要 ops)自己就点不动了。
|
||||
*/
|
||||
// ① 远端一个人都没有:更像是取数出了问题(realm 配错、代理 200 空体、view-users 被改窄),
|
||||
// 不像是全员离职。一个人都不据此停。
|
||||
if (remote.length === 0) return diff;
|
||||
for (const s of local) {
|
||||
if (!seen.has(s.id) && s.state !== 'disabled') diff.setState.push({ id: s.id, state: 'disabled' });
|
||||
if (seen.has(s.id) || s.state === 'disabled') continue;
|
||||
// ② 服务账号(e2e / 试跑)是首登 JIT 建的,而 Admin 的 `GET /users` **不返回服务账号用户**
|
||||
// (手册 §14.5 为此单列一条接口)—— 它的「缺席」是名单的形状,不是这个人不在了。
|
||||
if (isTestAccount(s.account, testAccounts)) continue;
|
||||
diff.setState.push({ id: s.id, state: 'disabled' });
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
|
||||
/**
|
||||
* 这次同步落库之后,还活跃的人各自的角色 —— 喂给锁死校验(`assertNotLockedOut`)。
|
||||
* 一次同步能停很多人;要是把最后一个能进运营中心的人也停了,那张表与同步按钮就都没人点得动了,
|
||||
* 只剩去库里手改 `state`。所以要**在落 setState 之前**先按差异算一遍结果。
|
||||
*/
|
||||
export function activeRolesAfter(local: readonly Staff[], diff: RosterDiff): string[][] {
|
||||
const state = new Map<string, Staff['state']>(local.map((s) => [s.id, s.state]));
|
||||
const roles = new Map<string, readonly string[]>(local.map((s) => [s.id, s.roles]));
|
||||
for (const u of diff.update) roles.set(u.id, u.patch.roles);
|
||||
for (const s of diff.setState) state.set(s.id, s.state);
|
||||
for (const user of diff.insert) {
|
||||
state.set(user.sub, user.enabled ? 'active' : 'disabled');
|
||||
roles.set(user.sub, user.roles);
|
||||
}
|
||||
const out: string[][] = [];
|
||||
for (const [id, st] of state) if (st === 'active') out.push([...(roles.get(id) ?? [])]);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import type {
|
||||
import { identityOf } from '../domain/claims.ts';
|
||||
import { assertNotLockedOut, endsOf, normalizeEnds } from '../domain/ends.ts';
|
||||
import { buildInFlightWork } from '../domain/in-flight.ts';
|
||||
import { diffRoster, identityChanged, patchOf } from '../domain/roster-diff.ts';
|
||||
import { activeRolesAfter, diffRoster, identityChanged, patchOf } from '../domain/roster-diff.ts';
|
||||
import { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, POSITION_SEED, TEST_POSITION_CODE } from '../domain/seed-data.ts';
|
||||
import { isTestAccount, soloGroupOf } from '../domain/test-account.ts';
|
||||
|
||||
@@ -98,12 +98,38 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
|
||||
return (await deps.staff.list()).filter((s) => s.state === 'active').map((s) => [...s.roles]);
|
||||
}
|
||||
|
||||
/*
|
||||
* 同名让位 —— `sys.staff.account` 有唯一索引。Keycloak 里删了人再建一个同名的
|
||||
* (或用户名被让渡给别人),新 sub 直插就撞索引;撞了的后果是**这个人一次也登不进来**,
|
||||
* 拿到的还是一句 PG 原文的 500,同步则停在半路。
|
||||
* 走到 insert 就说明按 sub 找不到他 —— 本地那条同名的必然是**另一个 sub 的 stale 行**:
|
||||
* 把它的账号让开(`账号#retired-<旧 sub 前 8 位>`)再插,并留一条 stderr 说明发生了什么。
|
||||
* 不删那一行:它身上可能还挂着在途工作,谁是谁得留得下痕(下一次同步会把它停用)。
|
||||
*/
|
||||
async function retireAccountClash(identity: Identity): Promise<void> {
|
||||
const clash = await deps.staff.findByAccount(identity.account);
|
||||
if (clash === undefined || clash.id === identity.sub) return;
|
||||
const retired = `${clash.account}#retired-${clash.id.slice(0, 8)}`;
|
||||
await deps.staff.updateIdentity(clash.id, {
|
||||
account: retired,
|
||||
name: clash.name,
|
||||
roles: [...clash.roles],
|
||||
...(clash.email === undefined ? {} : { email: clash.email }),
|
||||
});
|
||||
process.stderr.write(
|
||||
`[org] 账号 ${identity.account} 被新的 sub(${identity.sub})占用 —— ` +
|
||||
`本地那条 stale 行(sub ${clash.id})的账号让位为 ${retired};` +
|
||||
'若这是同一个人换了 sub,去运营中心把他身上的在途工作改派过来。\n',
|
||||
);
|
||||
}
|
||||
|
||||
async function insertPerson(
|
||||
identity: Identity,
|
||||
state: Staff['state'],
|
||||
marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date },
|
||||
): Promise<Staff> {
|
||||
const none = await positionByCode(NONE_POSITION_CODE);
|
||||
await retireAccountClash(identity);
|
||||
const created = await deps.staff.insert(
|
||||
{
|
||||
id: staffIdSchema.parse(identity.sub),
|
||||
@@ -124,8 +150,16 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
|
||||
const service: OrgService = {
|
||||
async authenticate(token) {
|
||||
const claims = await deps.verifier.verify(token);
|
||||
await service.registerIdentity(identityOf(claims));
|
||||
const id = staffIdSchema.parse(claims.sub);
|
||||
const identity = identityOf(claims);
|
||||
const id = staffIdSchema.parse(identity.sub);
|
||||
/*
|
||||
* 停用判定走在 JIT 写之前:一个已停用的账号只要还攥着有效 token,
|
||||
* 每 60 秒就能驱动一次 `updateIdentity` —— 停用的人不该还能写我们的库。
|
||||
* 「第一次见到的人要建档」这条不丢:本地查无此人时照建(建出来即 active)。
|
||||
*/
|
||||
const existing = await deps.staff.findById(id);
|
||||
if (existing !== undefined && existing.state !== 'active') throw new AccountDisabledError(existing.account);
|
||||
await service.registerIdentity(identity);
|
||||
const record = await deps.staff.findById(id);
|
||||
if (record === undefined) throw new StaffNotFoundError(id);
|
||||
return principalFor(record);
|
||||
@@ -189,18 +223,49 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
|
||||
}
|
||||
const remote = await deps.roster.listUsers();
|
||||
const local = await deps.staff.list();
|
||||
const diff = diffRoster(remote, local);
|
||||
const diff = diffRoster(remote, local, deps.testAccounts);
|
||||
/*
|
||||
* 落库之前先算一遍「这一次同步之后还有谁能进运营中心」—— 不过就**整体拒绝**,
|
||||
* 一条 setState 都不落。恢复动作(同步按钮)本身要 ops:全员停用之后这条路自己把自己锁死,
|
||||
* 只剩去库里手改 state。宁可这一次不同步,也不能把最后一个能进来的人关在门外。
|
||||
*/
|
||||
assertNotLockedOut(
|
||||
await deps.roleAccess.list(),
|
||||
activeRolesAfter(local, diff),
|
||||
'这次同步会让没有人能进运营中心,已拒绝(一条都没写)—— 先核对 provisioning 指的是不是同一个 realm,' +
|
||||
'以及那个服务账号还有没有 view-users。',
|
||||
);
|
||||
const now = deps.clock.now();
|
||||
for (const user of diff.insert) await insertPerson(user, user.enabled ? 'active' : 'disabled', { syncedAt: now });
|
||||
for (const u of diff.update) await deps.staff.updateIdentity(u.id, u.patch);
|
||||
for (const s of diff.setState) await deps.staff.setState(s.id, s.state, now);
|
||||
return {
|
||||
configured: true,
|
||||
inserted: diff.insert.length,
|
||||
updated: diff.update.length,
|
||||
disabled: diff.setState.filter((s) => s.state === 'disabled').length,
|
||||
total: remote.length,
|
||||
const accountOf = new Map(local.map((s) => [s.id, s.account]));
|
||||
const failed: Array<{ account: string; reason: string }> = [];
|
||||
// 单条写失败不该让整次同步静默停在半路:记下是谁、原话是什么,接着往下走
|
||||
const guard = async (account: string, write: () => Promise<unknown>): Promise<boolean> => {
|
||||
try {
|
||||
await write();
|
||||
return true;
|
||||
} catch (error) {
|
||||
failed.push({ account, reason: error instanceof Error ? error.message : String(error) });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let disabled = 0;
|
||||
for (const user of diff.insert) {
|
||||
const ok = await guard(user.account, () => insertPerson(user, user.enabled ? 'active' : 'disabled', { syncedAt: now }));
|
||||
if (ok) inserted += 1;
|
||||
}
|
||||
for (const u of diff.update) {
|
||||
const ok = await guard(u.patch.account, () => deps.staff.updateIdentity(u.id, u.patch));
|
||||
if (ok) updated += 1;
|
||||
}
|
||||
for (const s of diff.setState) {
|
||||
const ok = await guard(accountOf.get(s.id) ?? s.id, () => deps.staff.setState(s.id, s.state, now));
|
||||
if (ok && s.state === 'disabled') disabled += 1;
|
||||
}
|
||||
// 三个计数说的是真落进库里的条数 —— 半途失败的同步不许报成一排绿数字
|
||||
return { configured: true, inserted, updated, disabled, total: remote.length, failed };
|
||||
},
|
||||
|
||||
async inFlightOf(staffId) {
|
||||
|
||||
@@ -49,6 +49,8 @@ export type StaffRecord = Staff & { readonly lastSeenAt?: Date; readonly syncedA
|
||||
export type StaffRepository = {
|
||||
list(): Promise<StaffRecord[]>;
|
||||
findById(id: StaffId): Promise<StaffRecord | undefined>;
|
||||
/** 按账号找 —— `account` 有唯一索引,建档前得先问一句「这个名字被谁占着」(同名让位) */
|
||||
findByAccount(account: string): Promise<StaffRecord | undefined>;
|
||||
listByPosition(positionId: PositionId): Promise<StaffRecord[]>;
|
||||
insert(staff: Staff, marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date }): Promise<StaffRecord>;
|
||||
updateIdentity(id: StaffId, patch: IdentityPatch): Promise<void>;
|
||||
|
||||
+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 = {
|
||||
|
||||
@@ -55,7 +55,11 @@ export const roleAccessSchema = z.object({
|
||||
});
|
||||
export type RoleAccess = z.infer<typeof roleAccessSchema>;
|
||||
|
||||
/** 名册同步的结果 —— 没配 provisioning 就如实说没配,不摆零 */
|
||||
/**
|
||||
* 名册同步的结果 —— 没配 provisioning 就如实说没配,不摆零。
|
||||
* 三个计数说的是**真落进库里的条数**,不是打算写几条:单条写失败时它不进计数,
|
||||
* 而是点名进 `failed` —— 一次半途失败的同步不许报成一排绿数字。
|
||||
*/
|
||||
export const rosterSyncOutcomeSchema = z.discriminatedUnion('configured', [
|
||||
z.object({ configured: z.literal(false), message: z.string() }),
|
||||
z.object({
|
||||
@@ -64,6 +68,8 @@ export const rosterSyncOutcomeSchema = z.discriminatedUnion('configured', [
|
||||
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>;
|
||||
|
||||
Reference in New Issue
Block a user