名册同步补三道下限保护;同名让位;停用判定走在 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
+16 -2
View File
@@ -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'));
+15
View File
@@ -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);
+7 -3
View File
@@ -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) {
+2
View File
@@ -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>;