diff --git a/api-report/apps/api/src/modules/org/contract.d.ts b/api-report/apps/api/src/modules/org/contract.d.ts index 468f225..aa1575b 100644 --- a/api-report/apps/api/src/modules/org/contract.d.ts +++ b/api-report/apps/api/src/modules/org/contract.d.ts @@ -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) */ diff --git a/api-report/apps/api/src/modules/org/port.d.ts b/api-report/apps/api/src/modules/org/port.d.ts index 3172508..f0b9ff1 100644 --- a/api-report/apps/api/src/modules/org/port.d.ts +++ b/api-report/apps/api/src/modules/org/port.d.ts @@ -52,6 +52,8 @@ export type StaffRecord = Staff & { export type StaffRepository = { list(): Promise; findById(id: StaffId): Promise; + /** 按账号找 —— `account` 有唯一索引,建档前得先问一句「这个名字被谁占着」(同名让位) */ + findByAccount(account: string): Promise; listByPosition(positionId: PositionId): Promise; insert(staff: Staff, marks: { readonly lastSeenAt?: Date; diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index 4d46002..8a8de12 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -238,6 +238,10 @@ export declare const syncStaffRoute: { updated: z.ZodNumber; disabled: z.ZodNumber; total: z.ZodNumber; + failed: z.ZodArray>; }, z.core.$strip>], "configured">; }; /** 指派岗位;指回哨兵岗 NONE = 取消指派 */ diff --git a/api-report/packages/contracts/src/org.d.ts b/api-report/packages/contracts/src/org.d.ts index 3496b79..cfde330 100644 --- a/api-report/packages/contracts/src/org.d.ts +++ b/api-report/packages/contracts/src/org.d.ts @@ -69,7 +69,11 @@ export declare const roleAccessSchema: z.ZodObject<{ }>>; }, z.core.$strip>; export type RoleAccess = z.infer; -/** 名册同步的结果 —— 没配 provisioning 就如实说没配,不摆零 */ +/** + * 名册同步的结果 —— 没配 provisioning 就如实说没配,不摆零。 + * 三个计数说的是**真落进库里的条数**,不是打算写几条:单条写失败时它不进计数, + * 而是点名进 `failed` —— 一次半途失败的同步不许报成一排绿数字。 + */ export declare const rosterSyncOutcomeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ configured: z.ZodLiteral; 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.core.$strip>], "configured">; export type RosterSyncOutcome = z.infer; export declare const inFlightWorkSchema: z.ZodObject<{ diff --git a/apps/api/src/app/routes/governance.ts b/apps/api/src/app/routes/governance.ts index ef3753d..c26b7ca 100644 --- a/apps/api/src/app/routes/governance.ts +++ b/apps/api/src/app/routes/governance.ts @@ -34,8 +34,22 @@ export function registerGovernanceRoutes(app: Hono, 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')); diff --git a/apps/api/src/modules/org/CONTRACT.md b/apps/api/src/modules/org/CONTRACT.md index 447ea2e..3b1d849 100644 --- a/apps/api/src/modules/org/CONTRACT.md +++ b/apps/api/src/modules/org/CONTRACT.md @@ -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 尚无消费方。 diff --git a/apps/api/src/modules/org/__tests__/org-service.test.ts b/apps/api/src/modules/org/__tests__/org-service.test.ts index a92b4d6..3124742 100644 --- a/apps/api/src/modules/org/__tests__/org-service.test.ts +++ b/apps/api/src/modules/org/__tests__/org-service.test.ts @@ -18,6 +18,8 @@ type Harness = { groups: Map; positions: Map; calls: { updateIdentity: number }; + /** 往里放 staffId:那一行的写入就当场失败(模拟单条落库出错) */ + failWrites: Set; advance(ms: number): void; }; @@ -28,6 +30,7 @@ function harness(opts: { testAccounts?: string[]; roster?: RosterSource } = {}): const groups = new Map(); const tokens = new Map(); const calls = { updateIdentity: 0 }; + const failWrites = new Set(); 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'); }); diff --git a/apps/api/src/modules/org/__tests__/roster-diff.test.ts b/apps/api/src/modules/org/__tests__/roster-diff.test.ts index 09e8c28..03efa13 100644 --- a/apps/api/src/modules/org/__tests__/roster-diff.test.ts +++ b/apps/api/src/modules/org/__tests__/roster-diff.test.ts @@ -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 => ({ 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); diff --git a/apps/api/src/modules/org/contract.ts b/apps/api/src/modules/org/contract.ts index e8eec2d..e17e299 100644 --- a/apps/api/src/modules/org/contract.ts +++ b/apps/api/src/modules/org/contract.ts @@ -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'; } } diff --git a/apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts b/apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts index fbaf954..bffb86b 100644 --- a/apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts +++ b/apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts @@ -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 }), }); diff --git a/apps/api/src/modules/org/internal/adapter/org-repository.ts b/apps/api/src/modules/org/internal/adapter/org-repository.ts index 84b99ad..72df653 100644 --- a/apps/api/src/modules/org/internal/adapter/org-repository.ts +++ b/apps/api/src/modules/org/internal/adapter/org-repository.ts @@ -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); diff --git a/apps/api/src/modules/org/internal/domain/ends.ts b/apps/api/src/modules/org/internal/domain/ends.ts index 0e2a846..58726ca 100644 --- a/apps/api/src/modules/org/internal/domain/ends.ts +++ b/apps/api/src/modules/org/internal/domain/ends.ts @@ -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, + 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 的顺序 */ diff --git a/apps/api/src/modules/org/internal/domain/roster-diff.ts b/apps/api/src/modules/org/internal/domain/roster-diff.ts index b00a9ec..d7d55bc 100644 --- a/apps/api/src/modules/org/internal/domain/roster-diff.ts +++ b/apps/api/src/modules/org/internal/domain/roster-diff.ts @@ -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(local.map((s) => [s.id, s])); const diff: RosterDiff = { insert: [], update: [], setState: [] }; const seen = new Set(); @@ -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(local.map((s) => [s.id, s.state])); + const roles = new Map(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; +} diff --git a/apps/api/src/modules/org/internal/usecase/org-service.ts b/apps/api/src/modules/org/internal/usecase/org-service.ts index d38707d..248b531 100644 --- a/apps/api/src/modules/org/internal/usecase/org-service.ts +++ b/apps/api/src/modules/org/internal/usecase/org-service.ts @@ -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 { + 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 { 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): Promise => { + 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) { diff --git a/apps/api/src/modules/org/port.ts b/apps/api/src/modules/org/port.ts index 187738f..b438108 100644 --- a/apps/api/src/modules/org/port.ts +++ b/apps/api/src/modules/org/port.ts @@ -49,6 +49,8 @@ export type StaffRecord = Staff & { readonly lastSeenAt?: Date; readonly syncedA export type StaffRepository = { list(): Promise; findById(id: StaffId): Promise; + /** 按账号找 —— `account` 有唯一索引,建档前得先问一句「这个名字被谁占着」(同名让位) */ + findByAccount(account: string): Promise; listByPosition(positionId: PositionId): Promise; insert(staff: Staff, marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date }): Promise; updateIdentity(id: StaffId, patch: IdentityPatch): Promise; diff --git a/docs/API文档-鉴权与人员.md b/docs/API文档-鉴权与人员.md index c38a56e..0bb5710 100644 --- a/docs/API文档-鉴权与人员.md +++ b/docs/API文档-鉴权与人员.md @@ -11,6 +11,7 @@ | Base | 同源 `/api/*`(开发期 Vite 把 `/api` 反代到 `:8787`) | | 认证 | `Authorization: Bearer `;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 = { diff --git a/packages/contracts/src/org.ts b/packages/contracts/src/org.ts index 9404767..e53c340 100644 --- a/packages/contracts/src/org.ts +++ b/packages/contracts/src/org.ts @@ -55,7 +55,11 @@ export const roleAccessSchema = z.object({ }); export type RoleAccess = z.infer; -/** 名册同步的结果 —— 没配 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;