Archived
feat(org): 成员目录 —— Keycloak 读写面穿透、治理台成员/组织/权限三页、姓在前 personName、Staff.kind
后端:`KeycloakDirectory` 端口(users / groups / roles 读写;写直接穿透 Keycloak —— 这里建的人立刻能登录、 删的人立刻消失,停用与收权之后 logout 踢掉会话)、`keycloak-directory.ts` + `keycloak-admin-http.ts` 适配器、 org-directory 契约与 /api/gov 路由、`sync-roster` CLI(ops 路由够不着时整册照过来), 容器按 provisioning 服务账号有无决定注不注入(没配就如实说不可用)。 `personName` 姓在前:汉字直拼「柴浩」、否则留一个空格「Admin Amy」,token 与名册同步两条路同一把尺; `staffKindOf` 按 Keycloak 固定前缀 service-account- 认服务账号,员工那一屏不摆它(`Staff.kind` 不落库,按账号名现判)。 前端:员工那一屏重做成成员 / 组织 / 权限三页签(member-page 下钻),页签栏右端一句话说清这一格管什么。 契约变更:contracts org.ts / api/http.ts / api/org-directory.ts;api-report 已刷新;org CONTRACT.md 与 ADR-012 同步。 2026-08-26 做的,一直留在工作树没入库。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
This commit is contained in:
@@ -32,6 +32,15 @@
|
||||
|
||||
不变量:
|
||||
- `Staff.id` 就是 Keycloak `sub`;`account` 只作显示,永不当键
|
||||
- **姓在前、名在后**(2026-08-26 用户定):`Staff.name` 由 `personName` 一处拼 ——
|
||||
token 那条路取 `family_name` / `given_name`,名册同步取 `lastName` / `firstName`;
|
||||
**不吃 Keycloak 拼好的 `name` claim**(那是 `firstName lastName`,西式序)。
|
||||
两边都是汉字直拼(「柴浩」),否则留一个空格(「Admin Amy」);皆空回落用户名。
|
||||
拆不开的 realm(没配 profile scope)才退回 `name` claim —— 重排不了的东西如实用它。
|
||||
- `Staff.kind` 由**账号名现判**、不落库:`service-account-<clientId>` 是 Keycloak 自己的命名,
|
||||
开了 `serviceAccountsEnabled` 的 client 一律配一个。服务账号是**真身份**(试跑与 e2e 用它跑单),
|
||||
但不是人 —— 员工管理那一屏据此不摆它,试跑台的负责人下拉照旧摆(铁规矩 6)。
|
||||
按名字认不按角色认:角色是各 realm 自己配的,这个前缀由 Keycloak 保证
|
||||
- `state` 只由名册同步置 `disabled`;`authenticate` / `principalOf` 对 disabled 一律抛 AccountDisabledError,
|
||||
有效 token 不能把它翻回 active(撤权在同步之后即时生效)
|
||||
- **停用判定走在 JIT 写之前**:已停用的人攥着有效 token 也驱动不了 `updateIdentity`;
|
||||
@@ -45,6 +54,16 @@
|
||||
③落库之前按差异算一遍「同步后还有谁能进运营中心」,不过就整体拒绝(AccessLockoutError → 409),一条都不写
|
||||
- 同步逐条落库:**单条失败不停在半路**,失败的人点名进 `RosterSyncOutcome.failed`;
|
||||
三个计数说的是真落下的条数,不是打算写几条
|
||||
- **成员目录**(`directory*`,ADR-012):没配 provisioning 就**整个不可用**(抛
|
||||
`DirectoryUnavailableError` → 502),不给「读得了写不了」的半开状态 —— 那样人会以为是自己点错了。
|
||||
读是投影、写**穿透**到 Keycloak(没有本地草稿,也没有撤销)。四条不变量:
|
||||
①**服务账号一律不可见不可写**(目录读剔干净、写面抛 `ServiceAccountProtectedError` → 403);
|
||||
②**停用与收权当场踢会话** —— 否则那枚 token 到期前照样通行,而屏上已经写了「已停用」;
|
||||
③**收权先算自锁**:收完没人进得了运营中心就整体拒(409),与改「角色→端」同一道闸、同一个理由;
|
||||
④**删人本地只停不删** —— 留痕、在途任务、台账的 `by` 都还指着这个 sub。
|
||||
- 目录读有 **60 秒进程内缓存**,**任一写(成功或失败)之后整体清空**:写完立刻重拉一定看得到新结果,
|
||||
前端不必延时重试。失败也清 —— 半途失败之后那份缓存说不清停在哪一边。
|
||||
唯一滞后:**别的**管理员在另一个后端实例上写,这边最多滞后 60 秒。
|
||||
- `staffOfPosition` 只回 active;哨兵岗 NONE 恒回 []
|
||||
- `ensureSeeded` 幂等:岗位按 code 判存在;缺省角色→端只在表空时播;测试账号策略只补不覆盖
|
||||
- 工作组是绑定不是策略(ADR-011):`bindingOf` 只出成员表快照,在岗与否由调用方核
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** domain 测试零 mock(编码规范 §16) */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { identityOf, isNoiseRole, normalizeRoles } from '../internal/domain/claims.ts';
|
||||
import { identityOf, isNoiseRole, normalizeRoles, personName, staffKindOf } from '../internal/domain/claims.ts';
|
||||
|
||||
describe('identityOf —— claims 归一成一个人', () => {
|
||||
it('完整 claims:sub / 用户名 / 姓名 / 邮箱 / 角色(滤噪声、排序、去重)', () => {
|
||||
@@ -43,3 +43,58 @@ describe('噪声角色', () => {
|
||||
expect(normalizeRoles(['user', '', 'admin', 'user'])).toEqual(['admin', 'user']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('staffKindOf —— 人还是服务账号', () => {
|
||||
it('Keycloak 的 service-account-<clientId> 认作服务账号', () => {
|
||||
expect(staffKindOf('service-account-task-dispatcher')).toBe('service');
|
||||
});
|
||||
it('大小写与前后空格不影响判断', () => {
|
||||
expect(staffKindOf(' Service-Account-Provisioning ')).toBe('service');
|
||||
});
|
||||
it('真人一律 person —— 名字里带 service 也不算', () => {
|
||||
expect(staffKindOf('chaihao')).toBe('person');
|
||||
expect(staffKindOf('ai-test')).toBe('person');
|
||||
expect(staffKindOf('service-desk')).toBe('person');
|
||||
expect(staffKindOf('')).toBe('person');
|
||||
});
|
||||
});
|
||||
|
||||
describe('personName —— 姓在前、名在后', () => {
|
||||
it('中文名直拼,不留空格', () => {
|
||||
expect(personName({ familyName: '柴', givenName: '浩', fallback: 'chaihao' })).toBe('柴浩');
|
||||
expect(personName({ familyName: '丁', givenName: '子乔', fallback: 'x' })).toBe('丁子乔');
|
||||
});
|
||||
it('非汉字留一个空格 —— 拼在一起会连成一个词', () => {
|
||||
expect(personName({ familyName: 'Admin', givenName: 'Amy', fallback: 'amy' })).toBe('Admin Amy');
|
||||
});
|
||||
it('一边混着字母就留空格(拼死了读不出来是两截)', () => {
|
||||
expect(personName({ familyName: '李', givenName: 'Ken', fallback: 'x' })).toBe('李 Ken');
|
||||
});
|
||||
it('只有一半就摆那一半,不留孤零零的空格', () => {
|
||||
expect(personName({ familyName: '柴', fallback: 'chaihao' })).toBe('柴');
|
||||
expect(personName({ givenName: '浩', fallback: 'chaihao' })).toBe('浩');
|
||||
});
|
||||
it('两边都空(或只有空白)→ 回落,名字这一格不许空着', () => {
|
||||
expect(personName({ fallback: 'chaihao' })).toBe('chaihao');
|
||||
expect(personName({ familyName: ' ', givenName: '', fallback: 'chaihao' })).toBe('chaihao');
|
||||
});
|
||||
});
|
||||
|
||||
describe('identityOf 的名字:自己拼,不吃 Keycloak 拼好的那个', () => {
|
||||
it('有 family_name / given_name 就按姓在前拼 —— name claim 是西式序,不用它', () => {
|
||||
const id = identityOf({
|
||||
sub: 's',
|
||||
preferred_username: 'chaihao',
|
||||
name: '浩 柴',
|
||||
given_name: '浩',
|
||||
family_name: '柴',
|
||||
});
|
||||
expect(id.name).toBe('柴浩');
|
||||
});
|
||||
it('realm 没配 profile scope(拆不开)→ 如实用 name claim', () => {
|
||||
expect(identityOf({ sub: 's', preferred_username: 'bob', name: 'Bob Lee' }).name).toBe('Bob Lee');
|
||||
});
|
||||
it('两样都没有 → 回落用户名', () => {
|
||||
expect(identityOf({ sub: 's', preferred_username: 'bob' }).name).toBe('bob');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* 成员目录的用例层 —— 两道闸与一处缓存(ADR-012)。
|
||||
* Keycloak 是外界,给它一个记账的替身(编码规范 §16:只有外界能 mock)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Position, RoleAccess, WorkGroup } from '@contracts';
|
||||
import { positionIdSchema } from '@contracts';
|
||||
import { AccessLockoutError, DirectoryUnavailableError, ServiceAccountProtectedError } from '../contract.ts';
|
||||
import type { DirectoryUser, KeycloakDirectory, OrgServiceDeps, StaffRecord } from '../index.ts';
|
||||
import { createOrgService } from '../index.ts';
|
||||
|
||||
const PEOPLE: DirectoryUser[] = [
|
||||
{ sub: 'sub-admin', username: 'chaihao', enabled: true, emailVerified: true, roles: ['admin', 'user'] },
|
||||
{ sub: 'sub-user', username: 'linpeixuan', enabled: true, emailVerified: false, roles: ['user'] },
|
||||
{ sub: 'sub-svc', username: 'service-account-task-dispatcher', enabled: true, emailVerified: false, roles: ['service'] },
|
||||
];
|
||||
|
||||
type Log = string[];
|
||||
|
||||
/** 记账替身 —— 只回预置的形状,把调过什么记下来 */
|
||||
function fakeDirectory(log: Log, users = PEOPLE): KeycloakDirectory {
|
||||
const find = (sub: string): DirectoryUser => {
|
||||
const u = users.find((x) => x.sub === sub);
|
||||
if (u === undefined) throw new Error(`没有这个人:${sub}`);
|
||||
return u;
|
||||
};
|
||||
return {
|
||||
listUsers: async () => {
|
||||
log.push('listUsers');
|
||||
return users;
|
||||
},
|
||||
getUser: async (sub) => ({ ...find(sub), groups: [] }),
|
||||
createUser: async (input) => {
|
||||
log.push(`createUser:${input.username}`);
|
||||
return 'sub-new';
|
||||
},
|
||||
updateUser: async (sub) => void log.push(`updateUser:${sub}`),
|
||||
setEnabled: async (sub, enabled) => void log.push(`setEnabled:${sub}:${String(enabled)}`),
|
||||
resetPassword: async (sub) => void log.push(`resetPassword:${sub}`),
|
||||
deleteUser: async (sub) => void log.push(`deleteUser:${sub}`),
|
||||
logout: async (sub) => void log.push(`logout:${sub}`),
|
||||
listRoles: async () => [
|
||||
{ name: 'admin', composite: false },
|
||||
{ name: 'user', composite: false },
|
||||
{ name: 'default-roles-corp', composite: true },
|
||||
],
|
||||
setRole: async (sub, role, granted) => void log.push(`setRole:${sub}:${role}:${String(granted)}`),
|
||||
listGroups: async () => {
|
||||
log.push('listGroups');
|
||||
return [{ id: 'g1', name: '技术部', path: '/技术部', depth: 0, memberCount: 0 }];
|
||||
},
|
||||
groupMembers: async () => users,
|
||||
groupMemberUsernames: async () => users.map((u) => u.username),
|
||||
createGroup: async (name) => {
|
||||
log.push(`createGroup:${name}`);
|
||||
return { id: 'g2', name, path: `/${name}`, depth: 0, memberCount: 0 };
|
||||
},
|
||||
renameGroup: async (id, name) => {
|
||||
log.push(`renameGroup:${id}:${name}`);
|
||||
return { id, name, path: `/${name}`, depth: 0, memberCount: 0 };
|
||||
},
|
||||
deleteGroup: async (id) => void log.push(`deleteGroup:${id}`),
|
||||
setUserGroup: async (sub, gid, member) => void log.push(`setUserGroup:${sub}:${gid}:${String(member)}`),
|
||||
};
|
||||
}
|
||||
|
||||
function harness(opts: { directory?: KeycloakDirectory } = {}) {
|
||||
const positions = new Map<string, Position>();
|
||||
const staff = new Map<string, StaffRecord>();
|
||||
const roleAccess = new Map<string, RoleAccess>([
|
||||
['admin', { role: 'admin', ends: ['design', 'ops', 'workbench'] }],
|
||||
['user', { role: 'user', ends: ['workbench'] }],
|
||||
]);
|
||||
const groups = new Map<string, WorkGroup>();
|
||||
let nowMs = Date.parse('2026-08-26T09:00:00Z');
|
||||
|
||||
const deps: OrgServiceDeps = {
|
||||
verifier: { verify: async () => ({ sub: 'x', preferred_username: 'x' }) },
|
||||
...(opts.directory === undefined ? {} : { directory: opts.directory }),
|
||||
positions: {
|
||||
list: async () => [...positions.values()],
|
||||
findByCode: async (code) => [...positions.values()].find((p) => p.code === code),
|
||||
findById: async (id) => positions.get(id),
|
||||
upsert: async (p) => {
|
||||
const id = p.id ?? positionIdSchema.parse(p.code.toLowerCase());
|
||||
const row: Position = { id, code: p.code, name: p.name };
|
||||
positions.set(id, row);
|
||||
return row;
|
||||
},
|
||||
},
|
||||
staff: {
|
||||
list: async () => [...staff.values()],
|
||||
findById: async (id) => staff.get(id),
|
||||
findByAccount: async (a) => [...staff.values()].find((s) => s.account === a),
|
||||
listByPosition: async () => [],
|
||||
insert: async (s, marks) => {
|
||||
const row: StaffRecord = { ...s, ...marks };
|
||||
staff.set(row.id, row);
|
||||
return row;
|
||||
},
|
||||
updateIdentity: async () => undefined,
|
||||
setPosition: async () => undefined,
|
||||
setState: async (id, state) => {
|
||||
const row = staff.get(id);
|
||||
if (row !== undefined) staff.set(id, { ...row, state });
|
||||
},
|
||||
},
|
||||
roleAccess: {
|
||||
list: async () => [...roleAccess.values()],
|
||||
upsert: async (row) => {
|
||||
roleAccess.set(row.role, row);
|
||||
return row;
|
||||
},
|
||||
remove: async (role) => void roleAccess.delete(role),
|
||||
},
|
||||
workGroups: {
|
||||
listByOwner: async () => [],
|
||||
upsert: async (g) => {
|
||||
groups.set(g.id, g);
|
||||
return g;
|
||||
},
|
||||
},
|
||||
inFlight: { openTasksOf: async () => [], openCardsOf: async () => [], openApprovalsOf: async () => [] },
|
||||
clock: { now: () => new Date(nowMs) },
|
||||
ids: { next: () => 'id' },
|
||||
testAccounts: [],
|
||||
};
|
||||
return { org: createOrgService(deps), staff, tick: (ms: number) => (nowMs += ms) };
|
||||
}
|
||||
|
||||
describe('没配 provisioning —— 目录整个不可用,不给半开状态', () => {
|
||||
it('读也不行,写也不行;话里说清缺什么', async () => {
|
||||
const h = harness();
|
||||
await expect(h.org.directoryUsers({})).rejects.toBeInstanceOf(DirectoryUnavailableError);
|
||||
await expect(h.org.directoryGroups()).rejects.toBeInstanceOf(DirectoryUnavailableError);
|
||||
await h.org.directoryUsers({}).catch((e: unknown) => {
|
||||
expect((e as Error).message).toContain('KEYCLOAK_PROVISIONING_CLIENT_ID');
|
||||
expect((e as DirectoryUnavailableError).misconfigured).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('服务账号:是身份,不是人', () => {
|
||||
it('目录读里一个都不出现', async () => {
|
||||
const h = harness({ directory: fakeDirectory([]) });
|
||||
const { users } = await h.org.directoryUsers({ includeDisabled: true });
|
||||
expect(users.map((u) => u.username)).toEqual(['chaihao', 'linpeixuan']);
|
||||
});
|
||||
|
||||
it('对着它下手一律拒 —— 停掉它,试跑与 e2e 第二天全趴下', async () => {
|
||||
const h = harness({ directory: fakeDirectory([]) });
|
||||
await expect(h.org.setDirectoryUserEnabled('sub-svc', false)).rejects.toBeInstanceOf(ServiceAccountProtectedError);
|
||||
await expect(h.org.deleteDirectoryUser('sub-svc')).rejects.toBeInstanceOf(ServiceAccountProtectedError);
|
||||
await expect(h.org.setDirectoryUserRole('sub-svc', 'admin', false)).rejects.toBeInstanceOf(ServiceAccountProtectedError);
|
||||
});
|
||||
|
||||
it('连建一个叫这名字的都不许 —— 免得从正门造出一个假服务账号', async () => {
|
||||
const h = harness({ directory: fakeDirectory([]) });
|
||||
await expect(
|
||||
h.org.createDirectoryUser({ username: 'service-account-fake' }),
|
||||
).rejects.toBeInstanceOf(ServiceAccountProtectedError);
|
||||
});
|
||||
|
||||
it('组成员里也剔干净', async () => {
|
||||
const h = harness({ directory: fakeDirectory([]) });
|
||||
const { members } = await h.org.directoryGroupMembers('g1');
|
||||
expect(members.map((m) => m.username)).toEqual(['chaihao', 'linpeixuan']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('收权不能把最后一个管理员关在门外', () => {
|
||||
it('收掉唯一 admin 的 admin → 拒,一个字都没写出去', async () => {
|
||||
const log: Log = [];
|
||||
const h = harness({ directory: fakeDirectory(log, [PEOPLE[0]!, PEOPLE[1]!]) });
|
||||
await expect(h.org.setDirectoryUserRole('sub-admin', 'admin', false)).rejects.toBeInstanceOf(AccessLockoutError);
|
||||
expect(log.filter((l) => l.startsWith('setRole'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('还有另一个 admin 在 → 放行,并**当场踢掉他的会话**', async () => {
|
||||
const log: Log = [];
|
||||
const two: DirectoryUser[] = [
|
||||
PEOPLE[0]!,
|
||||
{ sub: 'sub-admin2', username: 'amy', enabled: true, emailVerified: false, roles: ['admin', 'user'] },
|
||||
];
|
||||
const h = harness({ directory: fakeDirectory(log, two) });
|
||||
await h.org.setDirectoryUserRole('sub-admin', 'admin', false);
|
||||
expect(log).toContain('setRole:sub-admin:admin:false');
|
||||
// 不踢的话他手上那枚 token 到期前还带着 admin —— 收权要等过期才生效
|
||||
expect(log).toContain('logout:sub-admin');
|
||||
});
|
||||
|
||||
it('授权那一路不查自锁(只会更宽,锁不死)', async () => {
|
||||
const log: Log = [];
|
||||
const h = harness({ directory: fakeDirectory(log, [PEOPLE[1]!]) });
|
||||
await h.org.setDirectoryUserRole('sub-user', 'admin', true);
|
||||
expect(log).toContain('setRole:sub-user:admin:true');
|
||||
expect(log).not.toContain('logout:sub-user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('停用与删除:本地那一行跟手', () => {
|
||||
it('停用 → 踢会话 + 本地置 disabled(两屏不许说两句话)', async () => {
|
||||
const log: Log = [];
|
||||
const h = harness({ directory: fakeDirectory(log) });
|
||||
await h.org.ensureSeeded();
|
||||
await h.org.registerIdentity({ sub: 'sub-user', account: 'linpeixuan', name: '佩萱 林', roles: ['user'] });
|
||||
await h.org.setDirectoryUserEnabled('sub-user', false);
|
||||
expect(log).toContain('setEnabled:sub-user:false');
|
||||
expect(log).toContain('logout:sub-user');
|
||||
expect(h.staff.get('sub-user')?.state).toBe('disabled');
|
||||
});
|
||||
|
||||
it('启用不踢会话(他本来就登不上,踢什么)', async () => {
|
||||
const log: Log = [];
|
||||
const h = harness({ directory: fakeDirectory(log) });
|
||||
await h.org.setDirectoryUserEnabled('sub-user', true);
|
||||
expect(log).not.toContain('logout:sub-user');
|
||||
});
|
||||
|
||||
it('删人 → 本地那一行**停用不删**:留痕、在途、台账都还指着这个 sub', async () => {
|
||||
const log: Log = [];
|
||||
const h = harness({ directory: fakeDirectory(log) });
|
||||
await h.org.ensureSeeded();
|
||||
await h.org.registerIdentity({ sub: 'sub-user', account: 'linpeixuan', name: '佩萱 林', roles: ['user'] });
|
||||
await h.org.deleteDirectoryUser('sub-user');
|
||||
expect(log).toContain('deleteUser:sub-user');
|
||||
expect(h.staff.get('sub-user')?.state).toBe('disabled');
|
||||
expect(h.staff.has('sub-user')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('60 秒读缓存 —— 写完立刻清,不必延时重试', () => {
|
||||
it('60 秒内不再问 Keycloak', async () => {
|
||||
const log: Log = [];
|
||||
const h = harness({ directory: fakeDirectory(log) });
|
||||
await h.org.directoryUsers({});
|
||||
await h.org.directoryUsers({ q: 'chai' });
|
||||
expect(log.filter((l) => l === 'listUsers')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('过了 60 秒自己重取', async () => {
|
||||
const log: Log = [];
|
||||
const h = harness({ directory: fakeDirectory(log) });
|
||||
await h.org.directoryUsers({});
|
||||
h.tick(61_000);
|
||||
await h.org.directoryUsers({});
|
||||
expect(log.filter((l) => l === 'listUsers')).toHaveLength(2);
|
||||
});
|
||||
|
||||
/*
|
||||
* 清缓存**按写真正碰到的东西清**。全清的代价看得见:删一个组之后下一次读要把
|
||||
* 「全量成员的角色」那趟 N+1 重付一遍,屏上那棵树因此多钉着已经没了的组好几秒。
|
||||
*/
|
||||
it('建组只清组那一份 —— 人的角色与端没变,别陪着重付一趟 N+1', async () => {
|
||||
const log: Log = [];
|
||||
const h = harness({ directory: fakeDirectory(log) });
|
||||
await h.org.directoryUsers({});
|
||||
await h.org.directoryGroups();
|
||||
await h.org.createDirectoryGroup('新组');
|
||||
await h.org.directoryUsers({});
|
||||
await h.org.directoryGroups();
|
||||
expect(log.filter((l) => l === 'listUsers')).toHaveLength(1);
|
||||
expect(log.filter((l) => l === 'listGroups')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('改资料只清人那一份 —— 组的成员数没变', async () => {
|
||||
const log: Log = [];
|
||||
const h = harness({ directory: fakeDirectory(log) });
|
||||
await h.org.directoryUsers({});
|
||||
await h.org.directoryGroups();
|
||||
await h.org.patchDirectoryUser('sub-user', { firstName: '佩萱' });
|
||||
await h.org.directoryUsers({});
|
||||
await h.org.directoryGroups();
|
||||
expect(log.filter((l) => l === 'listUsers')).toHaveLength(2);
|
||||
expect(log.filter((l) => l === 'listGroups')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('删人两边都清 —— 人没了,他在的那些组也少一个人', async () => {
|
||||
const log: Log = [];
|
||||
const h = harness({ directory: fakeDirectory(log) });
|
||||
await h.org.directoryUsers({});
|
||||
await h.org.directoryGroups();
|
||||
await h.org.deleteDirectoryUser('sub-user');
|
||||
await h.org.directoryUsers({});
|
||||
await h.org.directoryGroups();
|
||||
expect(log.filter((l) => l === 'listUsers')).toHaveLength(2);
|
||||
expect(log.filter((l) => l === 'listGroups')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('写**失败**了也清 —— 半途失败之后那份缓存说不清是哪一边', async () => {
|
||||
const log: Log = [];
|
||||
const dir = fakeDirectory(log);
|
||||
const h = harness({
|
||||
directory: { ...dir, createGroup: async () => { throw new Error('同级重名'); } },
|
||||
});
|
||||
await h.org.directoryGroups();
|
||||
await expect(h.org.createDirectoryGroup('技术部')).rejects.toThrow('同级重名');
|
||||
await h.org.directoryGroups();
|
||||
expect(log.filter((l) => l === 'listGroups')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('角色清单', () => {
|
||||
it('家具角色不摆(授收它没有意义),持有数按生效角色数', async () => {
|
||||
const h = harness({ directory: fakeDirectory([]) });
|
||||
const { roles } = await h.org.directoryRoles();
|
||||
expect(roles.map((r) => r.name)).toEqual(['admin', 'user']);
|
||||
// service-account 已剔,所以 user 是 2 不是 3
|
||||
expect(roles.find((r) => r.name === 'user')?.holders).toBe(2);
|
||||
expect(roles.find((r) => r.name === 'admin')?.holders).toBe(1);
|
||||
expect(roles.find((r) => r.name === 'admin')?.ends).toEqual(['design', 'ops', 'workbench']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/** domain 测试零 mock(编码规范 §16) */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { RoleAccess } from '@contracts';
|
||||
import { positionIdSchema, staffIdSchema } from '@contracts';
|
||||
import type { DirectoryGroup, DirectoryRole, DirectoryUser, StoredStaff } from '../port.ts';
|
||||
import {
|
||||
displayNameOf,
|
||||
filterOrgUsers,
|
||||
isServiceAccount,
|
||||
localMarks,
|
||||
sortOrgUsers,
|
||||
toOrgGroups,
|
||||
toOrgRoles,
|
||||
toOrgUser,
|
||||
} from '../internal/domain/directory.ts';
|
||||
|
||||
const TABLE: RoleAccess[] = [
|
||||
{ role: 'admin', ends: ['design', 'ops', 'workbench'] },
|
||||
{ role: 'user', ends: ['workbench'] },
|
||||
];
|
||||
|
||||
const kc = (over: Partial<DirectoryUser> = {}): DirectoryUser => ({
|
||||
sub: 'sub-1',
|
||||
username: 'chaihao',
|
||||
enabled: true,
|
||||
emailVerified: false,
|
||||
roles: ['admin', 'user'],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('服务账号:是身份,不是人', () => {
|
||||
it('Keycloak 的 service-account-<clientId> 认得出', () => {
|
||||
expect(isServiceAccount('service-account-task-dispatcher')).toBe(true);
|
||||
expect(isServiceAccount('chaihao')).toBe(false);
|
||||
// 名字里带 service 不算 —— 认的是 Keycloak 那个固定前缀,不是关键词
|
||||
expect(isServiceAccount('service-desk')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('displayNameOf', () => {
|
||||
it('姓在前、名在后 —— 中文名直拼', () => {
|
||||
expect(displayNameOf({ username: 'a', firstName: '浩', lastName: '柴' })).toBe('柴浩');
|
||||
});
|
||||
it('只有一半就摆那一半', () => {
|
||||
expect(displayNameOf({ username: 'a', lastName: '柴' })).toBe('柴');
|
||||
});
|
||||
it('两个都空 → 回落用户名,不回空串', () => {
|
||||
expect(displayNameOf({ username: 'amy', firstName: ' ', lastName: '' })).toBe('amy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toOrgUser —— Keycloak 那份 + 本地那两列', () => {
|
||||
it('ends 由本地表算,不是 Keycloak 的字段', () => {
|
||||
expect(toOrgUser(kc(), TABLE).ends).toEqual(['design', 'ops', 'workbench']);
|
||||
expect(toOrgUser(kc({ roles: ['user'] }), TABLE).ends).toEqual(['workbench']);
|
||||
});
|
||||
it('角色一行都没命中 → 无端可进(不是报错,是事实)', () => {
|
||||
expect(toOrgUser(kc({ roles: ['service'] }), TABLE).ends).toEqual([]);
|
||||
});
|
||||
it('本地两列合进来;没有就整个键不出现', () => {
|
||||
const withLocal = toOrgUser(kc(), TABLE, { lastSeenAt: new Date('2026-08-26T07:00:00Z'), positionCode: 'TEST' });
|
||||
expect(withLocal.lastSeenAt).toBe('2026-08-26T07:00:00.000Z');
|
||||
expect(withLocal.positionCode).toBe('TEST');
|
||||
const bare = toOrgUser(kc(), TABLE);
|
||||
expect('lastSeenAt' in bare).toBe(false);
|
||||
expect('positionCode' in bare).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortOrgUsers —— 来过的在前', () => {
|
||||
it('来过的按时刻倒序在前,没来过的按用户名在后', () => {
|
||||
const rows = [
|
||||
toOrgUser(kc({ sub: 'c', username: 'cathy' }), TABLE),
|
||||
toOrgUser(kc({ sub: 'a', username: 'amy' }), TABLE, { lastSeenAt: new Date('2026-08-01T00:00:00Z') }),
|
||||
toOrgUser(kc({ sub: 'b', username: 'bob' }), TABLE),
|
||||
toOrgUser(kc({ sub: 'd', username: 'dan' }), TABLE, { lastSeenAt: new Date('2026-08-20T00:00:00Z') }),
|
||||
];
|
||||
expect(sortOrgUsers(rows).map((u) => u.username)).toEqual(['dan', 'amy', 'bob', 'cathy']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterOrgUsers —— 检索与「含停用」', () => {
|
||||
const rows = [
|
||||
toOrgUser(kc({ sub: '1', username: 'chaihao', firstName: '浩', lastName: '柴', email: 'a@x.com' }), TABLE),
|
||||
toOrgUser(kc({ sub: '2', username: 'amy', enabled: false }), TABLE),
|
||||
];
|
||||
it('缺省不摆停用的人', () => {
|
||||
expect(filterOrgUsers(rows, '', false).map((u) => u.username)).toEqual(['chaihao']);
|
||||
});
|
||||
it('含停用 → 都摆', () => {
|
||||
expect(filterOrgUsers(rows, '', true)).toHaveLength(2);
|
||||
});
|
||||
it('用户名 / 姓名 / 邮箱任一含关键词即命中,不区分大小写', () => {
|
||||
expect(filterOrgUsers(rows, 'CHAI', true)).toHaveLength(1);
|
||||
expect(filterOrgUsers(rows, '柴', true)).toHaveLength(1);
|
||||
expect(filterOrgUsers(rows, 'a@x', true)).toHaveLength(1);
|
||||
});
|
||||
it('**含在中间**也搜得到 —— 这正是不转给 Keycloak 的 search= 的理由(那个是前缀式)', () => {
|
||||
expect(filterOrgUsers(rows, 'aiha', true).map((u) => u.username)).toEqual(['chaihao']);
|
||||
});
|
||||
it('搜不到就是空,不是全量', () => {
|
||||
expect(filterOrgUsers(rows, '查无此人', true)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toOrgRoles', () => {
|
||||
const roles: DirectoryRole[] = [
|
||||
{ name: 'admin', composite: false },
|
||||
{ name: 'user', composite: false, description: '员工' },
|
||||
];
|
||||
it('每个角色配上本地算的 ends 与调用方数好的持有数', () => {
|
||||
const out = toOrgRoles(roles, TABLE, [5, 8]);
|
||||
expect(out[0]).toMatchObject({ name: 'admin', ends: ['design', 'ops', 'workbench'], holders: 5 });
|
||||
expect(out[1]).toMatchObject({ name: 'user', ends: ['workbench'], holders: 8, description: '员工' });
|
||||
});
|
||||
it('持有数没给到那一位 → 0,不是 undefined', () => {
|
||||
expect(toOrgRoles(roles, TABLE, [3])[1]?.holders).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toOrgGroups —— 平表 + 成员数', () => {
|
||||
const groups: DirectoryGroup[] = [
|
||||
{ id: 'g1', name: '技术部', path: '/技术部', depth: 0, memberCount: 0 },
|
||||
{ id: 'g2', name: '平台组', path: '/技术部/平台组', parentId: 'g1', depth: 1, memberCount: 0 },
|
||||
];
|
||||
it('成员数由调用方现数;顶层组没有 parentId 那个键', () => {
|
||||
const out = toOrgGroups(groups, new Map([['g1', 3]]));
|
||||
expect(out[0]).toEqual({ id: 'g1', name: '技术部', path: '/技术部', depth: 0, memberCount: 3 });
|
||||
expect(out[1]?.parentId).toBe('g1');
|
||||
});
|
||||
it('没数过的组是 0 —— 不给 undefined 让界面摆一个空格', () => {
|
||||
expect(toOrgGroups(groups, new Map())[1]?.memberCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('localMarks', () => {
|
||||
const none = positionIdSchema.parse('pos-none');
|
||||
const staff: Array<StoredStaff & { lastSeenAt?: Date }> = [
|
||||
{
|
||||
id: staffIdSchema.parse('sub-1'),
|
||||
account: 'chaihao',
|
||||
name: '柴浩',
|
||||
positionId: none,
|
||||
state: 'active',
|
||||
roles: ['admin'],
|
||||
lastSeenAt: new Date('2026-08-26T07:00:00Z'),
|
||||
},
|
||||
{ id: staffIdSchema.parse('sub-2'), account: 'amy', name: 'Amy', positionId: none, state: 'active', roles: [] },
|
||||
];
|
||||
it('按 sub 索引;岗位查不到就不摆那个键', () => {
|
||||
const marks = localMarks(staff, (id) => (id === none ? 'NONE' : undefined));
|
||||
expect(marks.get('sub-1')).toEqual({ lastSeenAt: new Date('2026-08-26T07:00:00Z'), positionCode: 'NONE' });
|
||||
expect('lastSeenAt' in (marks.get('sub-2') ?? {})).toBe(false);
|
||||
});
|
||||
it('岗位表里没有那一行 → 键不出现,不摆空串', () => {
|
||||
const marks = localMarks(staff, () => undefined);
|
||||
expect('positionCode' in (marks.get('sub-1') ?? {})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,9 @@ function fakeKeycloak(): { fetch: typeof fetch; calls: string[] } {
|
||||
id: `sub-${i}`,
|
||||
username: `u${i}`,
|
||||
enabled: i !== 3,
|
||||
firstName: i === 0 ? '' : '名',
|
||||
lastName: i === 0 ? '' : `${i}`,
|
||||
// 第 0 位两边都空 —— 试的是「名字这一格不许空着,回落用户名」那一支
|
||||
firstName: i === 0 ? '' : '浩',
|
||||
lastName: i === 0 ? '' : '柴',
|
||||
email: `u${i}@x.test`,
|
||||
}));
|
||||
const json = (body: unknown, status = 200): Response =>
|
||||
@@ -42,13 +43,14 @@ function fakeKeycloak(): { fetch: typeof fetch; calls: string[] } {
|
||||
}
|
||||
|
||||
describe('Keycloak Admin API 名册', () => {
|
||||
it('分页拉全(100 + 5)、姓名拼 firstName lastName、缺名回退用户名、角色滤噪声、enabled 照抄', async () => {
|
||||
it('分页拉全(100 + 5)、姓在前名在后、缺名回退用户名、角色滤噪声、enabled 照抄', async () => {
|
||||
const kc = fakeKeycloak();
|
||||
const roster = createKeycloakRoster({ url: 'https://kc.test/', realm: 'corp', clientId: 'provisioning', secret: 's', fetch: kc.fetch });
|
||||
const users = await roster.listUsers();
|
||||
expect(users).toHaveLength(105);
|
||||
expect(users[0]).toEqual({ sub: 'sub-0', account: 'u0', name: 'u0', email: 'u0@x.test', roles: ['user'], enabled: true });
|
||||
expect(users[1]).toEqual({ sub: 'sub-1', account: 'u1', name: '名 1', email: 'u1@x.test', roles: ['admin', 'user'], enabled: true });
|
||||
// 姓在前:lastName「柴」+ firstName「浩」= 柴浩(中文直拼,不留空格)
|
||||
expect(users[1]).toEqual({ sub: 'sub-1', account: 'u1', name: '柴浩', email: 'u1@x.test', roles: ['admin', 'user'], enabled: true });
|
||||
expect(users[3]?.enabled).toBe(false);
|
||||
expect(kc.calls.filter((c) => c.includes('/users?')).length).toBe(2);
|
||||
});
|
||||
|
||||
@@ -62,6 +62,24 @@ describe('Keycloak 验签', () => {
|
||||
expect(claims.realm_access?.roles).toContain('user');
|
||||
});
|
||||
|
||||
/*
|
||||
* 验签这一层是**挑字段**拼 `VerifiedClaims` 的 —— 类型上加了字段而这儿忘了挑,
|
||||
* 编译一声不吭,下游拿到的永远是 undefined。实测栽过一次:
|
||||
* 姓名改「姓在前」之后每个请求还是把名字写回西式序,查了两台后端才发现漏在这儿。
|
||||
* 所以这条钉的是**整条链**:真 token 的形状进去,`identityOf` 认得出的形状出来。
|
||||
*/
|
||||
it('given_name / family_name 要原样带出来 —— 姓在前的拼法全靠它俩', async () => {
|
||||
const claims = await verifier.verify(await mint({ claims: { given_name: '浩', family_name: '柴' } }));
|
||||
expect(claims.given_name).toBe('浩');
|
||||
expect(claims.family_name).toBe('柴');
|
||||
});
|
||||
|
||||
it('token 里没有那两个 claim → 键干脆不出现(不是 undefined)', async () => {
|
||||
const claims = await verifier.verify(await mint());
|
||||
expect('given_name' in claims).toBe(false);
|
||||
expect('family_name' in claims).toBe(false);
|
||||
});
|
||||
|
||||
it('过期 30 秒内(时钟容差)仍通过', async () => {
|
||||
await expect(verifier.verify(await mint({ exp: nowSec() - 10 }))).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
@@ -243,6 +243,22 @@ describe('名册与岗位', () => {
|
||||
expect(await h.org.staffOfPosition('NONE')).toEqual([]);
|
||||
});
|
||||
|
||||
it('listStaff 给每行标 kind —— 服务账号是身份不是人(员工管理那一屏据此不摆它)', async () => {
|
||||
const h = harness();
|
||||
await h.org.ensureSeeded();
|
||||
await h.org.registerIdentity({ sub: 'p', account: 'chaihao', name: '柴浩', roles: ['admin'] });
|
||||
await h.org.registerIdentity({
|
||||
sub: 's',
|
||||
account: 'service-account-task-dispatcher',
|
||||
name: 'service-account-task-dispatcher',
|
||||
roles: ['service'],
|
||||
});
|
||||
const { staff } = await h.org.listStaff();
|
||||
const kinds = Object.fromEntries(staff.map((x) => [x.account, x.kind]));
|
||||
expect(kinds['chaihao']).toBe('person');
|
||||
expect(kinds['service-account-task-dispatcher']).toBe('service');
|
||||
});
|
||||
|
||||
it('syncRoster 未配置 → configured:false', async () => {
|
||||
const h = harness();
|
||||
await h.org.ensureSeeded();
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/** domain 测试零 mock(编码规范 §16) */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Staff } from '@contracts';
|
||||
import { positionIdSchema, staffIdSchema } from '@contracts';
|
||||
import { activeRolesAfter, diffRoster, identityChanged, sameRoles } from '../internal/domain/roster-diff.ts';
|
||||
import type { RosterUser } from '../port.ts';
|
||||
import type { RosterUser, StoredStaff } from '../port.ts';
|
||||
|
||||
const none = positionIdSchema.parse('none');
|
||||
const local = (sub: string, over: Partial<Staff> = {}): Staff => ({
|
||||
const local = (sub: string, over: Partial<StoredStaff> = {}): StoredStaff => ({
|
||||
id: staffIdSchema.parse(sub),
|
||||
account: sub,
|
||||
name: sub,
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
*/
|
||||
import type {
|
||||
InFlightWork,
|
||||
OrgGroup,
|
||||
OrgRole,
|
||||
OrgUser,
|
||||
OrgUserDetail,
|
||||
Position,
|
||||
PositionId,
|
||||
Principal,
|
||||
@@ -19,7 +23,27 @@ import type {
|
||||
WorkGroupBinding,
|
||||
} from '@contracts';
|
||||
|
||||
export type { InFlightWork, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding };
|
||||
export type { InFlightWork, OrgGroup, OrgRole, OrgUser, OrgUserDetail, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding };
|
||||
/**
|
||||
* 目录写面的两个入参 —— 住在 contract.ts 而不是 port.ts:它们是**服务出口的形状**
|
||||
* (路由照它填),port 只是恰好也要用。反过来放会让 port ↔ contract 成环。
|
||||
*/
|
||||
export type CreateDirectoryUser = {
|
||||
readonly username: string;
|
||||
readonly firstName?: string;
|
||||
readonly lastName?: string;
|
||||
readonly email?: string;
|
||||
/** 临时口令;不给就建一个还登不进来的壳 */
|
||||
readonly password?: string;
|
||||
/** 一并授的 realm 角色 */
|
||||
readonly roles?: readonly string[];
|
||||
};
|
||||
|
||||
export type DirectoryUserPatch = {
|
||||
readonly firstName?: string;
|
||||
readonly lastName?: string;
|
||||
readonly email?: string;
|
||||
};
|
||||
|
||||
/** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */
|
||||
export type StaffWithPosition = Staff & { readonly position: Position };
|
||||
@@ -78,6 +102,32 @@ export class AccessLockoutError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 目录整个不可用 —— 没配 provisioning 服务账号,或那个账号缺 realm-management 角色。
|
||||
* **重试无益**:路由译成 502 并原样带出 Keycloak 的话,界面提示找管理员补权限(手册 §14.6)。
|
||||
*/
|
||||
export class DirectoryUnavailableError extends Error {
|
||||
/** true = 配错了(补权限);false = 暂时够不着(可退避重试)—— 两句话,人的下一步不同 */
|
||||
readonly misconfigured: boolean;
|
||||
constructor(message: string, misconfigured: boolean) {
|
||||
super(message);
|
||||
this.name = 'DirectoryUnavailableError';
|
||||
this.misconfigured = misconfigured;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写面对着一个服务账号 —— 拒。它是 Keycloak 给 client 自动建的身份(见 `staffKindSchema`),
|
||||
* 在这三个页签里**根本不该出现**;能对着它下手,只能是有人手输了 sub。
|
||||
* 停掉它的后果不显眼却很重:试跑与 e2e 第二天全趴下。
|
||||
*/
|
||||
export class ServiceAccountProtectedError extends Error {
|
||||
constructor(readonly account: string) {
|
||||
super(`「${account}」是服务账号,不是人 —— 成员目录不管它;要停它去 Keycloak 停对应的 client`);
|
||||
this.name = 'ServiceAccountProtectedError';
|
||||
}
|
||||
}
|
||||
|
||||
export type OrgService = {
|
||||
/* ── 身份(ADR-012)—— 中间件在 app 层,身份判定读这里 ─────────────── */
|
||||
|
||||
@@ -109,6 +159,31 @@ export type OrgService = {
|
||||
/** 能进该端的活跃员工 —— 治理台通知的收件人 */
|
||||
staffWithEnd(surface: Surface): Promise<StaffWithPosition[]>;
|
||||
|
||||
/* ── 成员目录(Keycloak Admin API 的读写面;ADR-012) ────────────────── */
|
||||
|
||||
/**
|
||||
* 目录**没配 provisioning 服务账号就整个不可用** —— 一律抛 `DirectoryUnavailableError`
|
||||
* (路由译成 502 + 原话)。不给「读得了写不了」这种半开状态:那样人会以为是自己点错了。
|
||||
* 服务账号在这一层就剔干净;写面对着服务账号的 sub 一律抛 `ServiceAccountProtectedError`。
|
||||
*/
|
||||
directoryUsers(query: { q?: string; includeDisabled?: boolean }): Promise<{ users: OrgUser[] }>;
|
||||
directoryUser(sub: string): Promise<OrgUserDetail>;
|
||||
createDirectoryUser(input: CreateDirectoryUser): Promise<OrgUser>;
|
||||
patchDirectoryUser(sub: string, patch: DirectoryUserPatch): Promise<OrgUser>;
|
||||
/** 停用顺手踢会话 —— 界面写了「已停用」,那枚 token 就不该还能用到过期 */
|
||||
setDirectoryUserEnabled(sub: string, enabled: boolean): Promise<OrgUser>;
|
||||
resetDirectoryUserPassword(sub: string, password: string): Promise<void>;
|
||||
deleteDirectoryUser(sub: string): Promise<void>;
|
||||
directoryRoles(): Promise<{ roles: OrgRole[] }>;
|
||||
/** 授 / 收一个 realm 角色;收权顺手踢会话。抛 AccessLockoutError(409):收完没人进得了运营中心 */
|
||||
setDirectoryUserRole(sub: string, role: string, granted: boolean): Promise<OrgUser>;
|
||||
directoryGroups(): Promise<{ groups: OrgGroup[] }>;
|
||||
directoryGroupMembers(groupId: string): Promise<{ members: OrgUser[] }>;
|
||||
createDirectoryGroup(name: string, parentId?: string): Promise<OrgGroup>;
|
||||
renameDirectoryGroup(groupId: string, name: string): Promise<OrgGroup>;
|
||||
deleteDirectoryGroup(groupId: string): Promise<void>;
|
||||
setDirectoryUserGroup(sub: string, groupId: string, member: boolean): Promise<void>;
|
||||
|
||||
/* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────────── */
|
||||
|
||||
/** 建组 / 改组;id 留空即新建 */
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
* module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type {
|
||||
CreateDirectoryUser,
|
||||
DirectoryUserPatch,
|
||||
Identity,
|
||||
InFlightWork,
|
||||
OrgGroup,
|
||||
OrgRole,
|
||||
OrgService,
|
||||
OrgUser,
|
||||
OrgUserDetail,
|
||||
Position,
|
||||
Principal,
|
||||
RoleAccess,
|
||||
@@ -18,16 +24,22 @@ export type {
|
||||
export {
|
||||
AccessLockoutError,
|
||||
AccountDisabledError,
|
||||
DirectoryUnavailableError,
|
||||
PositionNotFoundError,
|
||||
ServiceAccountProtectedError,
|
||||
StaffNotFoundError,
|
||||
TokenRejectedError,
|
||||
} from './contract.ts';
|
||||
|
||||
export type {
|
||||
Clock,
|
||||
DirectoryGroup,
|
||||
DirectoryRole,
|
||||
DirectoryUser,
|
||||
IdGenerator,
|
||||
IdentityPatch,
|
||||
InFlightWorkProbe,
|
||||
KeycloakDirectory,
|
||||
PositionRepository,
|
||||
RoleAccessRepository,
|
||||
RosterSource,
|
||||
@@ -44,6 +56,15 @@ export { createOrgService } from './internal/usecase/org-service.ts';
|
||||
|
||||
export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claims.ts';
|
||||
export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts';
|
||||
export {
|
||||
displayNameOf,
|
||||
filterOrgUsers,
|
||||
isServiceAccount,
|
||||
sortOrgUsers,
|
||||
toOrgGroups,
|
||||
toOrgRoles,
|
||||
toOrgUser,
|
||||
} from './internal/domain/directory.ts';
|
||||
export { buildInFlightWork } from './internal/domain/in-flight.ts';
|
||||
export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, POSITION_SEED, TEST_POSITION_CODE } from './internal/domain/seed-data.ts';
|
||||
|
||||
@@ -51,6 +72,9 @@ export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapte
|
||||
export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts';
|
||||
export { createKeycloakRoster } from './internal/adapter/keycloak-admin-roster.ts';
|
||||
export type { KeycloakRosterOptions } from './internal/adapter/keycloak-admin-roster.ts';
|
||||
export { createKeycloakDirectory } from './internal/adapter/keycloak-directory.ts';
|
||||
export { createAdminHttp, KeycloakMisconfiguredError, KeycloakRejectedError, KeycloakUnavailableError } from './internal/adapter/keycloak-admin-http.ts';
|
||||
export type { AdminHttp, KeycloakAdminOptions } from './internal/adapter/keycloak-admin-http.ts';
|
||||
export {
|
||||
createPositionRepository,
|
||||
createRoleAccessRepository,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Keycloak Admin API 的 HTTP 底座(手册 §11 / §14;ADR-012)。
|
||||
*
|
||||
* 名册同步与成员目录**共用同一枚管理 token** —— 从前只有名册那一条路,
|
||||
* token 缓存写在它里面;目录接上来之后各缓存一份就是两条独立的续期时钟,
|
||||
* 边界上谁先过期谁自己去换,白烧两次握手,还没人说得清当下在用哪一枚。
|
||||
*
|
||||
* 三条路径规则照手册 §14.1:`{client-uuid}` 不是 clientId、创建返回 201 空体
|
||||
* (新 id 在 `Location` 末段)、列表默认 `max=100` 必须分页。
|
||||
*
|
||||
* 错误分两种说 —— **重试有没有意义,决定了界面该说什么话**:
|
||||
* `KeycloakMisconfiguredError`(401/403)= 服务账号缺角色或凭证不对,重试无益;
|
||||
* `KeycloakUnavailableError`(网络 / 5xx)= 暂时够不着,过会儿再来。
|
||||
*/
|
||||
|
||||
/** 服务账号缺 `realm-management` 角色,或凭证不对 —— 重试无益,去补权限 */
|
||||
export class KeycloakMisconfiguredError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'KeycloakMisconfiguredError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Keycloak 暂时够不着(网络 / 5xx)—— 可退避重试 */
|
||||
export class KeycloakUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'KeycloakUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Keycloak 明确拒绝了这次写(重名 / 约束不过)—— 原话带出去给人看 */
|
||||
export class KeycloakRejectedError extends Error {
|
||||
readonly status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = 'KeycloakRejectedError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export type KeycloakAdminOptions = {
|
||||
readonly url: string;
|
||||
readonly realm: string;
|
||||
readonly clientId: string;
|
||||
readonly secret: string;
|
||||
/** 测试注入;缺省全局 fetch */
|
||||
readonly fetch?: typeof fetch;
|
||||
/** 毫秒时刻;测试注入 */
|
||||
readonly now?: () => number;
|
||||
};
|
||||
|
||||
export type AdminHttp = {
|
||||
get<T>(path: string): Promise<T>;
|
||||
/** 建资源:成功 201 空体,回 `Location` 末段(新 id);Keycloak 不给就回 undefined */
|
||||
post(path: string, body?: unknown): Promise<string | undefined>;
|
||||
put(path: string, body?: unknown): Promise<void>;
|
||||
/** `body` 可选是因为**撤 realm 角色是带 body 的 DELETE**(手册 §14.4),不是笔误 */
|
||||
delete(path: string, body?: unknown): Promise<void>;
|
||||
};
|
||||
|
||||
/** 列表接口默认 `max=100`(手册 §14.1 第三条),分页取全 */
|
||||
export const ADMIN_PAGE = 100;
|
||||
|
||||
export function createAdminHttp(options: KeycloakAdminOptions): AdminHttp {
|
||||
const fetchImpl = options.fetch ?? fetch;
|
||||
const now = options.now ?? ((): number => Date.now());
|
||||
const base = options.url.replace(/\/+$/, '');
|
||||
const realmBase = `${base}/admin/realms/${options.realm}`;
|
||||
let cached: { token: string; expiresAt: number } | undefined;
|
||||
|
||||
async function call(path: string, init: RequestInit): Promise<Response> {
|
||||
try {
|
||||
return await fetchImpl(path, init);
|
||||
} catch (error) {
|
||||
throw new KeycloakUnavailableError(`够不着 Keycloak:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function adminToken(): Promise<string> {
|
||||
if (cached !== undefined && now() < cached.expiresAt) return cached.token;
|
||||
const resp = await call(`${base}/realms/master/protocol/openid-connect/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: options.clientId,
|
||||
client_secret: options.secret,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
// 401/400 在这条路上一律是凭证不对(client_id / secret)—— 重试一百次也是同一句
|
||||
if (resp.status < 500) throw new KeycloakMisconfiguredError(`取管理 token 失败:${resp.status} ${text}`);
|
||||
throw new KeycloakUnavailableError(`取管理 token 失败:${resp.status} ${text}`);
|
||||
}
|
||||
const data = (await resp.json()) as { access_token: string; expires_in: number };
|
||||
// 提前 60 秒过期,免得边界上拿着一枚刚好失效的 token 去调
|
||||
cached = { token: data.access_token, expiresAt: now() + (data.expires_in - 60) * 1000 };
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
async function send(method: string, path: string, body?: unknown): Promise<Response> {
|
||||
const token = await adminToken();
|
||||
const resp = await call(`${realmBase}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
if (resp.ok) return resp;
|
||||
const text = await resp.text();
|
||||
const where = `${method} ${path}`;
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
// 手册 §14.6:403 就是服务账号缺 realm-management 角色 —— 说清缺在哪一步
|
||||
throw new KeycloakMisconfiguredError(
|
||||
`${where} 被拒(${resp.status})—— provisioning 服务账号缺 realm-management 角色(手册 §14.6);原话:${text}`,
|
||||
);
|
||||
}
|
||||
if (resp.status >= 500) throw new KeycloakUnavailableError(`${where} 失败(${resp.status}):${text}`);
|
||||
throw new KeycloakRejectedError(resp.status, `${where} 被拒(${resp.status}):${text}`);
|
||||
}
|
||||
|
||||
return {
|
||||
async get<T>(path: string): Promise<T> {
|
||||
return (await (await send('GET', path)).json()) as T;
|
||||
},
|
||||
async post(path: string, body?: unknown): Promise<string | undefined> {
|
||||
const resp = await send('POST', path, body);
|
||||
// 手册 §14.1 第二条:新资源 id 在 Location 末段,响应体是空的
|
||||
const location = resp.headers.get('location');
|
||||
return location === null ? undefined : location.split('/').pop();
|
||||
},
|
||||
async put(path: string, body?: unknown): Promise<void> {
|
||||
await send('PUT', path, body);
|
||||
},
|
||||
async delete(path: string, body?: unknown): Promise<void> {
|
||||
await send('DELETE', path, body);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* 需要的 realm-management 角色只有 `view-users`(手册 14.6 最小权限)。
|
||||
*/
|
||||
import type { RosterSource, RosterUser } from '../../port.ts';
|
||||
import { normalizeRoles } from '../domain/claims.ts';
|
||||
import { normalizeRoles, personName } from '../domain/claims.ts';
|
||||
|
||||
export type KeycloakRosterOptions = {
|
||||
readonly url: string;
|
||||
@@ -76,14 +76,15 @@ export function createKeycloakRoster(options: KeycloakRosterOptions): RosterSour
|
||||
const out: RosterUser[] = [];
|
||||
for (const u of users) {
|
||||
const roles = await adminGet<KcRole[]>(`/users/${encodeURIComponent(u.id)}/role-mappings/realm/composite`);
|
||||
const name = [u.firstName, u.lastName]
|
||||
.filter((s): s is string => s !== undefined && s.trim() !== '')
|
||||
.join(' ')
|
||||
.trim();
|
||||
out.push({
|
||||
sub: u.id,
|
||||
account: u.username,
|
||||
name: name === '' ? u.username : name,
|
||||
// 姓在前、名在后 —— 与 token 那条路同一把尺(`personName`),两处理解不许分叉
|
||||
name: personName({
|
||||
...(u.lastName === undefined ? {} : { familyName: u.lastName }),
|
||||
...(u.firstName === undefined ? {} : { givenName: u.firstName }),
|
||||
fallback: u.username,
|
||||
}),
|
||||
// 与 token 那条路同一把尺 —— 归一自己再写一遍,两处理解一分叉,
|
||||
// `identityChanged` 的逐字比对就会天天判「变了」(或天天判「没变」)
|
||||
roles: normalizeRoles(roles.map((r) => r.name)),
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 成员目录 —— Keycloak Admin API 的读写面(手册 §14;ADR-012)。
|
||||
*
|
||||
* 与名册同步(`keycloak-admin-roster.ts`)共用 `createAdminHttp` 的那枚管理 token。
|
||||
* 这里**不做过滤、不做归并**:剔服务账号、拼 `ends`、合本地岗位都归 domain 与 usecase ——
|
||||
* 适配器只把 Keycloak 的形状原样翻过来,翻错了一眼看得出。
|
||||
*/
|
||||
import type {
|
||||
AdminHttp,
|
||||
KeycloakAdminOptions,
|
||||
} from './keycloak-admin-http.ts';
|
||||
import { ADMIN_PAGE, createAdminHttp } from './keycloak-admin-http.ts';
|
||||
import type { CreateDirectoryUser, DirectoryUserPatch } from '../../contract.ts';
|
||||
import type {
|
||||
DirectoryGroup,
|
||||
DirectoryRole,
|
||||
DirectoryUser,
|
||||
KeycloakDirectory,
|
||||
} from '../../port.ts';
|
||||
import { normalizeRoles } from '../domain/claims.ts';
|
||||
|
||||
type KcUser = {
|
||||
readonly id: string;
|
||||
readonly username: string;
|
||||
readonly enabled: boolean;
|
||||
readonly emailVerified?: boolean;
|
||||
readonly firstName?: string;
|
||||
readonly lastName?: string;
|
||||
readonly email?: string;
|
||||
};
|
||||
type KcRole = { readonly name: string; readonly description?: string; readonly composite?: boolean };
|
||||
type KcGroup = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly path: string;
|
||||
readonly subGroups?: readonly KcGroup[];
|
||||
};
|
||||
|
||||
/** 空串与 undefined 是同一件事:Keycloak 那边没填,别在契约里变成一个空格子 */
|
||||
function opt(value: string | undefined): { [k: string]: string } {
|
||||
return value === undefined || value.trim() === '' ? {} : { v: value.trim() };
|
||||
}
|
||||
|
||||
function toUser(u: KcUser, roles: readonly string[]): DirectoryUser {
|
||||
const first = opt(u.firstName);
|
||||
const last = opt(u.lastName);
|
||||
const mail = opt(u.email);
|
||||
return {
|
||||
sub: u.id,
|
||||
username: u.username,
|
||||
enabled: u.enabled,
|
||||
emailVerified: u.emailVerified === true,
|
||||
roles: normalizeRoles([...roles]),
|
||||
...(first.v === undefined ? {} : { firstName: first.v }),
|
||||
...(last.v === undefined ? {} : { lastName: last.v }),
|
||||
...(mail.v === undefined ? {} : { email: mail.v }),
|
||||
};
|
||||
}
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
|
||||
/**
|
||||
* 有限并发地跑一批 —— Keycloak 没有「一次拿回全部人的角色」的接口(手册 §14.4 逐人查),
|
||||
* 串行跑 8 个人就是 8 趟远程往返(实测整页 37 秒)。并发上限压着,
|
||||
* 免得一屏把管理端打成一次小型压测。
|
||||
*/
|
||||
async function mapLimit<T, R>(items: readonly T[], limit: number, run: (item: T) => Promise<R>): Promise<R[]> {
|
||||
const out: R[] = new Array<R>(items.length);
|
||||
let cursor = 0;
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
for (;;) {
|
||||
const i = cursor;
|
||||
cursor += 1;
|
||||
if (i >= items.length) return;
|
||||
out[i] = await run(items[i]!);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return out;
|
||||
}
|
||||
|
||||
const ROLE_FETCH_CONCURRENCY = 8;
|
||||
|
||||
export function createKeycloakDirectory(options: KeycloakAdminOptions, http?: AdminHttp): KeycloakDirectory {
|
||||
const api = http ?? createAdminHttp(options);
|
||||
|
||||
/** 实际生效的 realm 角色 —— 排查权限永远看 `/composite`(手册 §14.4) */
|
||||
async function rolesOf(sub: string): Promise<string[]> {
|
||||
const roles = await api.get<KcRole[]>(`/users/${enc(sub)}/role-mappings/realm/composite`);
|
||||
return roles.map((r) => r.name);
|
||||
}
|
||||
|
||||
async function allUsers(): Promise<KcUser[]> {
|
||||
const out: KcUser[] = [];
|
||||
for (let first = 0; ; first += ADMIN_PAGE) {
|
||||
const page = await api.get<KcUser[]>(`/users?first=${first}&max=${ADMIN_PAGE}&briefRepresentation=false`);
|
||||
out.push(...page);
|
||||
if (page.length < ADMIN_PAGE) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 一行一个组;`subGroups` 递归摊平,顺手记住谁是谁的爹 */
|
||||
function flatten(nodes: readonly KcGroup[], parentId: string | undefined, depth: number, into: DirectoryGroup[]): void {
|
||||
for (const g of nodes) {
|
||||
into.push({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
path: g.path,
|
||||
depth,
|
||||
memberCount: 0, // 由 usecase 按组成员现数 —— Keycloak 的组表不带这个数
|
||||
...(parentId === undefined ? {} : { parentId }),
|
||||
});
|
||||
flatten(g.subGroups ?? [], g.id, depth + 1, into);
|
||||
}
|
||||
}
|
||||
|
||||
/** 口令一律**临时**:本人首登必须改(手册 §14.2);我们不存、不回显、不留痕 */
|
||||
async function resetPassword(sub: string, password: string): Promise<void> {
|
||||
await api.put(`/users/${enc(sub)}/reset-password`, { type: 'password', value: password, temporary: true });
|
||||
}
|
||||
|
||||
async function setRole(sub: string, role: string, granted: boolean): Promise<void> {
|
||||
// 授/撤的 body 是**角色对象数组**(手册 §14.4)—— 光给名字 Keycloak 不认
|
||||
const all = await api.get<Array<KcRole & { id: string }>>('/roles');
|
||||
const target = all.find((r) => r.name === role);
|
||||
if (target === undefined) throw new Error(`realm 里没有角色「${role}」`);
|
||||
const body = [{ id: target.id, name: target.name }];
|
||||
const path = `/users/${enc(sub)}/role-mappings/realm`;
|
||||
if (granted) await api.post(path, body);
|
||||
else await api.delete(path, body);
|
||||
}
|
||||
|
||||
async function rawGroupMembers(groupId: string): Promise<KcUser[]> {
|
||||
const members: KcUser[] = [];
|
||||
for (let first = 0; ; first += ADMIN_PAGE) {
|
||||
const page = await api.get<KcUser[]>(`/groups/${enc(groupId)}/members?first=${first}&max=${ADMIN_PAGE}`);
|
||||
members.push(...page);
|
||||
if (page.length < ADMIN_PAGE) break;
|
||||
}
|
||||
return members;
|
||||
}
|
||||
|
||||
async function groupById(groupId: string): Promise<DirectoryGroup> {
|
||||
const g = await api.get<KcGroup>(`/groups/${enc(groupId)}`);
|
||||
const segments = g.path.split('/').filter((s) => s !== '');
|
||||
return { id: g.id, name: g.name, path: g.path, depth: segments.length - 1, memberCount: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
async listUsers() {
|
||||
const users = await allUsers();
|
||||
return mapLimit(users, ROLE_FETCH_CONCURRENCY, async (u) => toUser(u, await rolesOf(u.id)));
|
||||
},
|
||||
|
||||
async getUser(sub) {
|
||||
const u = await api.get<KcUser>(`/users/${enc(sub)}`);
|
||||
const [roles, groups] = await Promise.all([
|
||||
rolesOf(sub),
|
||||
api.get<Array<{ id: string; path: string }>>(`/users/${enc(sub)}/groups`),
|
||||
]);
|
||||
return { ...toUser(u, roles), groups: groups.map((g) => ({ id: g.id, path: g.path })) };
|
||||
},
|
||||
|
||||
async createUser(input: CreateDirectoryUser) {
|
||||
const first = opt(input.firstName);
|
||||
const last = opt(input.lastName);
|
||||
const mail = opt(input.email);
|
||||
const sub = await api.post('/users', {
|
||||
username: input.username,
|
||||
enabled: true,
|
||||
...(first.v === undefined ? {} : { firstName: first.v }),
|
||||
...(last.v === undefined ? {} : { lastName: last.v }),
|
||||
...(mail.v === undefined ? {} : { email: mail.v }),
|
||||
});
|
||||
if (sub === undefined) throw new Error('Keycloak 建用户成功但没回 Location —— 拿不到新 id,别往下走');
|
||||
if (input.password !== undefined) await resetPassword(sub, input.password);
|
||||
for (const role of input.roles ?? []) await setRole(sub, role, true);
|
||||
return sub;
|
||||
},
|
||||
|
||||
async updateUser(sub, patch: DirectoryUserPatch) {
|
||||
// 手册 §14.2:PUT 是全量覆盖 —— 先 GET 再改再 PUT,不然没带的字段会被清空
|
||||
const current = await api.get<KcUser>(`/users/${enc(sub)}`);
|
||||
const first = opt(patch.firstName ?? current.firstName);
|
||||
const last = opt(patch.lastName ?? current.lastName);
|
||||
const mail = opt(patch.email ?? current.email);
|
||||
await api.put(`/users/${enc(sub)}`, {
|
||||
...current,
|
||||
firstName: first.v ?? '',
|
||||
lastName: last.v ?? '',
|
||||
email: mail.v ?? '',
|
||||
});
|
||||
},
|
||||
|
||||
async setEnabled(sub, enabled) {
|
||||
const current = await api.get<KcUser>(`/users/${enc(sub)}`);
|
||||
await api.put(`/users/${enc(sub)}`, { ...current, enabled });
|
||||
},
|
||||
|
||||
resetPassword,
|
||||
|
||||
async deleteUser(sub) {
|
||||
await api.delete(`/users/${enc(sub)}`);
|
||||
},
|
||||
|
||||
async logout(sub) {
|
||||
await api.post(`/users/${enc(sub)}/logout`);
|
||||
},
|
||||
|
||||
async listRoles(): Promise<readonly DirectoryRole[]> {
|
||||
const roles = await api.get<KcRole[]>('/roles');
|
||||
return roles.map((r) => ({
|
||||
name: r.name,
|
||||
composite: r.composite === true,
|
||||
...(r.description === undefined || r.description.trim() === '' ? {} : { description: r.description.trim() }),
|
||||
}));
|
||||
},
|
||||
|
||||
setRole,
|
||||
|
||||
async listGroups() {
|
||||
const tree = await api.get<KcGroup[]>('/groups?briefRepresentation=false');
|
||||
const flat: DirectoryGroup[] = [];
|
||||
flatten(tree, undefined, 0, flat);
|
||||
return flat.sort((a, b) => a.path.localeCompare(b.path));
|
||||
},
|
||||
|
||||
async groupMembers(groupId) {
|
||||
const members = await rawGroupMembers(groupId);
|
||||
return mapLimit(members, ROLE_FETCH_CONCURRENCY, async (u) => toUser(u, await rolesOf(u.id)));
|
||||
},
|
||||
|
||||
async groupMemberUsernames(groupId) {
|
||||
return (await rawGroupMembers(groupId)).map((u) => u.username);
|
||||
},
|
||||
|
||||
|
||||
async createGroup(name, parentId) {
|
||||
const path = parentId === undefined ? '/groups' : `/groups/${enc(parentId)}/children`;
|
||||
const id = await api.post(path, { name });
|
||||
if (id === undefined) throw new Error('Keycloak 建组成功但没回 Location —— 拿不到新 id,别往下走');
|
||||
return groupById(id);
|
||||
},
|
||||
|
||||
async renameGroup(groupId, name) {
|
||||
const current = await api.get<KcGroup>(`/groups/${enc(groupId)}`);
|
||||
await api.put(`/groups/${enc(groupId)}`, { ...current, name });
|
||||
return groupById(groupId);
|
||||
},
|
||||
|
||||
async deleteGroup(groupId) {
|
||||
await api.delete(`/groups/${enc(groupId)}`);
|
||||
},
|
||||
|
||||
async setUserGroup(sub, groupId, member) {
|
||||
const path = `/users/${enc(sub)}/groups/${enc(groupId)}`;
|
||||
if (member) await api.put(path);
|
||||
else await api.delete(path);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,9 @@ const claimsSchema = z.object({
|
||||
sub: z.string().min(1),
|
||||
preferred_username: z.string().min(1),
|
||||
name: z.string().optional(),
|
||||
/* 姓与名分开的两个 claim —— 我们按「姓在前」自己拼(`personName`),不吃拼好的 `name` */
|
||||
given_name: z.string().optional(),
|
||||
family_name: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
realm_access: z.object({ roles: z.array(z.string()).optional() }).optional(),
|
||||
});
|
||||
@@ -39,6 +42,8 @@ function toVerifiedClaims(claims: z.infer<typeof claimsSchema>): VerifiedClaims
|
||||
sub: claims.sub,
|
||||
preferred_username: claims.preferred_username,
|
||||
...(claims.name === undefined ? {} : { name: claims.name }),
|
||||
...(claims.given_name === undefined ? {} : { given_name: claims.given_name }),
|
||||
...(claims.family_name === undefined ? {} : { family_name: claims.family_name }),
|
||||
...(claims.email === undefined ? {} : { email: claims.email }),
|
||||
...(claims.realm_access === undefined
|
||||
? {}
|
||||
|
||||
@@ -5,11 +5,51 @@
|
||||
* Keycloak 给每个用户自动挂的三个「家具角色」不是业务角色,这里滤掉:
|
||||
* `default-roles-<realm>` / `offline_access` / `uma_authorization`。
|
||||
*/
|
||||
import type { StaffKind } from '@contracts';
|
||||
import type { Identity } from '../../contract.ts';
|
||||
import type { VerifiedClaims } from '../../port.ts';
|
||||
|
||||
const NOISE_ROLES: ReadonlySet<string> = new Set(['offline_access', 'uma_authorization']);
|
||||
|
||||
/**
|
||||
* Keycloak 给服务账号用户的固定命名 —— **它自己定的**,不是我们的约定:
|
||||
* 开了 `serviceAccountsEnabled` 的 client 一律配一个 `service-account-<clientId>`。
|
||||
* 按名字认而不按角色认,是因为角色是每个 realm 自己配的(这边叫 `service`,别处未必),
|
||||
* 而这个前缀由 Keycloak 保证。
|
||||
*/
|
||||
const SERVICE_ACCOUNT_PREFIX = 'service-account-';
|
||||
|
||||
/**
|
||||
* 姓在前、名在后(2026-08-26 用户定)。
|
||||
*
|
||||
* Keycloak 自己拼的 `name` claim 是 `firstName lastName` —— 西式顺序,中文名读出来是「浩 柴」。
|
||||
* 所以**不吃那个 claim**,拿分开的 `family_name` / `given_name` 自己拼;
|
||||
* 名册同步那条路同理,拿 `lastName` / `firstName`。
|
||||
*
|
||||
* 中间那个空格按**字形**决定,不按语言设置:两边都是汉字就直拼(「柴浩」),
|
||||
* 否则留一个空格(「Admin Amy」)—— 中文名里塞个空格,读的人会以为那是两个词。
|
||||
*/
|
||||
const CJK = /^[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3040-\u30ff]+$/u;
|
||||
|
||||
export function personName(input: {
|
||||
readonly familyName?: string;
|
||||
readonly givenName?: string;
|
||||
/** 两边都空时回落到这个(通常是用户名)—— 名字这一格永远不许是空的 */
|
||||
readonly fallback: string;
|
||||
}): string {
|
||||
const family = (input.familyName ?? '').trim();
|
||||
const given = (input.givenName ?? '').trim();
|
||||
if (family === '' && given === '') return input.fallback;
|
||||
if (family === '') return given;
|
||||
if (given === '') return family;
|
||||
return CJK.test(family) && CJK.test(given) ? `${family}${given}` : `${family} ${given}`;
|
||||
}
|
||||
|
||||
/** 这个账号是人还是服务账号 —— 员工管理那一屏据此不摆服务账号(它是身份,不是人) */
|
||||
export function staffKindOf(account: string): StaffKind {
|
||||
return account.trim().toLowerCase().startsWith(SERVICE_ACCOUNT_PREFIX) ? 'service' : 'person';
|
||||
}
|
||||
|
||||
export function isNoiseRole(role: string): boolean {
|
||||
return NOISE_ROLES.has(role) || role.startsWith('default-roles-');
|
||||
}
|
||||
@@ -21,7 +61,21 @@ export function normalizeRoles(roles: readonly string[]): string[] {
|
||||
|
||||
export function identityOf(claims: VerifiedClaims): Identity {
|
||||
const account = claims.preferred_username;
|
||||
const name = claims.name === undefined || claims.name.trim() === '' ? account : claims.name.trim();
|
||||
/*
|
||||
* 先按姓在前自己拼;`family_name` / `given_name` 都没有的 realm(没配 profile scope)
|
||||
* 才退回 Keycloak 拼好的 `name` —— 拆不开的东西没法重排,如实用它。
|
||||
*/
|
||||
const composed = personName({
|
||||
...(claims.family_name === undefined ? {} : { familyName: claims.family_name }),
|
||||
...(claims.given_name === undefined ? {} : { givenName: claims.given_name }),
|
||||
fallback: '',
|
||||
});
|
||||
const name =
|
||||
composed !== ''
|
||||
? composed
|
||||
: claims.name === undefined || claims.name.trim() === ''
|
||||
? account
|
||||
: claims.name.trim();
|
||||
const email = claims.email === undefined || claims.email.trim() === '' ? undefined : claims.email.trim();
|
||||
return {
|
||||
sub: claims.sub,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 成员目录的归一(纯函数,零 IO;ADR-012)。
|
||||
*
|
||||
* Keycloak 那一份(身份 · 角色 · 组)与本地那一份(岗位 · 几时来过)在这里合成一行 ——
|
||||
* **两份事实各有各的主人**:改身份去 Keycloak,改岗位在本地;这段只负责摆在一起给人看。
|
||||
*
|
||||
* 服务账号在这一层就剔掉:它是真身份、跑得了单,但**不是人**(见 `staffKindSchema`),
|
||||
* 而这三个页签管的是人。剔在 domain 而不是各路由自己记得滤 —— 漏一处就是一处泄漏,
|
||||
* 而泄漏的后果是有人在界面上把 e2e 账号停用了,试跑第二天全趴下。
|
||||
*/
|
||||
import type { OrgGroup, OrgRole, OrgUser, RoleAccess } from '@contracts';
|
||||
import type { DirectoryGroup, DirectoryRole, DirectoryUser, StoredStaff } from '../../port.ts';
|
||||
import { personName, staffKindOf } from './claims.ts';
|
||||
import { endsOf } from './ends.ts';
|
||||
|
||||
/** 本地名册里跟这个人有关的那两列 —— 目录只读它们,不写 */
|
||||
export type LocalMark = { readonly lastSeenAt?: Date; readonly positionCode?: string };
|
||||
|
||||
export function isServiceAccount(username: string): boolean {
|
||||
return staffKindOf(username) === 'service';
|
||||
}
|
||||
|
||||
/** 姓在前、名在后;皆空回落 username —— 名字这一格不会是空的。拼法归 `personName` 一处 */
|
||||
export function displayNameOf(user: Pick<DirectoryUser, 'username' | 'firstName' | 'lastName'>): string {
|
||||
return personName({
|
||||
...(user.lastName === undefined ? {} : { familyName: user.lastName }),
|
||||
...(user.firstName === undefined ? {} : { givenName: user.firstName }),
|
||||
fallback: user.username,
|
||||
});
|
||||
}
|
||||
|
||||
export function toOrgUser(user: DirectoryUser, table: readonly RoleAccess[], local?: LocalMark): OrgUser {
|
||||
return {
|
||||
sub: user.sub,
|
||||
username: user.username,
|
||||
displayName: displayNameOf(user),
|
||||
enabled: user.enabled,
|
||||
roles: [...user.roles],
|
||||
ends: endsOf(user.roles, table),
|
||||
...(user.email === undefined ? {} : { email: user.email }),
|
||||
...(local?.lastSeenAt === undefined ? {} : { lastSeenAt: local.lastSeenAt.toISOString() }),
|
||||
...(local?.positionCode === undefined ? {} : { positionCode: local.positionCode }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 排序:来过的在前(倒序),没来过的在后(按用户名升序)。
|
||||
* 「没有 `lastSeenAt`」说的是**还没拿这枚身份访问过本系统**,不等于「从未登录 Keycloak」——
|
||||
* 名册同步照过来的人天生就是这一档。
|
||||
*/
|
||||
export function sortOrgUsers(users: readonly OrgUser[]): OrgUser[] {
|
||||
return [...users].sort((a, b) => {
|
||||
if (a.lastSeenAt !== undefined && b.lastSeenAt !== undefined) {
|
||||
return a.lastSeenAt === b.lastSeenAt ? a.username.localeCompare(b.username) : b.lastSeenAt.localeCompare(a.lastSeenAt);
|
||||
}
|
||||
if (a.lastSeenAt !== undefined) return -1;
|
||||
if (b.lastSeenAt !== undefined) return 1;
|
||||
return a.username.localeCompare(b.username);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检索:用户名 / 姓名 / 邮箱任一含关键词(不区分大小写)。
|
||||
* 在**我们这一侧**过滤而不是转给 Keycloak 的 `search=` —— 那个是前缀式,
|
||||
* 「按姓名搜中间那两个字」搜不出来,而人会以为这个人不在。realm 不大,全量在手上。
|
||||
*/
|
||||
export function filterOrgUsers(users: readonly OrgUser[], q: string, includeDisabled: boolean): OrgUser[] {
|
||||
const needle = q.trim().toLowerCase();
|
||||
return users.filter((u) => {
|
||||
if (!includeDisabled && !u.enabled) return false;
|
||||
if (needle === '') return true;
|
||||
return [u.username, u.displayName, u.email ?? ''].some((f) => f.toLowerCase().includes(needle));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个角色配上「能进哪几个端」与「几个人持有」—— 前者本地说了算,后者 Keycloak 说了算。
|
||||
* `holders` 由调用方按角色现数后传进来(每个角色一趟),不在这儿从全量用户里筛 ——
|
||||
* 那样等于逼调用方先把每个人的角色都拉一遍。
|
||||
*/
|
||||
export function toOrgRoles(
|
||||
roles: readonly DirectoryRole[],
|
||||
table: readonly RoleAccess[],
|
||||
holders: readonly number[],
|
||||
): OrgRole[] {
|
||||
return roles.map((r, i) => ({
|
||||
name: r.name,
|
||||
composite: r.composite,
|
||||
ends: endsOf([r.name], table),
|
||||
holders: holders[i] ?? 0,
|
||||
...(r.description === undefined ? {} : { description: r.description }),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 组树平表配上直属成员数(已剔服务账号)—— Keycloak 的组表本身不带这个数 */
|
||||
export function toOrgGroups(groups: readonly DirectoryGroup[], counts: ReadonlyMap<string, number>): OrgGroup[] {
|
||||
return groups.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
path: g.path,
|
||||
depth: g.depth,
|
||||
memberCount: counts.get(g.id) ?? 0,
|
||||
...(g.parentId === undefined ? {} : { parentId: g.parentId }),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 本地名册 → 按 sub 取那两列 */
|
||||
export function localMarks(
|
||||
staff: ReadonlyArray<StoredStaff & { readonly lastSeenAt?: Date }>,
|
||||
positionCodeOf: (positionId: string) => string | undefined,
|
||||
): Map<string, LocalMark> {
|
||||
const out = new Map<string, LocalMark>();
|
||||
for (const s of staff) {
|
||||
const code = positionCodeOf(s.positionId);
|
||||
out.set(s.id, {
|
||||
...(s.lastSeenAt === undefined ? {} : { lastSeenAt: s.lastSeenAt }),
|
||||
...(code === undefined ? {} : { positionCode: code }),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
import type { Staff, StaffId } from '@contracts';
|
||||
import type { Identity } from '../../contract.ts';
|
||||
import type { IdentityPatch, RosterUser } from '../../port.ts';
|
||||
import type { IdentityPatch, RosterUser, StoredStaff } from '../../port.ts';
|
||||
import { isTestAccount } from './test-account.ts';
|
||||
|
||||
export type RosterDiff = {
|
||||
@@ -27,7 +27,7 @@ export function sameRoles(a: readonly string[], b: readonly string[]): boolean {
|
||||
}
|
||||
|
||||
/** 账号 / 姓名 / 邮箱 / 角色任一不同即「变了」;state 不在此列(它归同步) */
|
||||
export function identityChanged(staff: Staff, identity: Identity): boolean {
|
||||
export function identityChanged(staff: StoredStaff, identity: Identity): boolean {
|
||||
return (
|
||||
staff.account !== identity.account ||
|
||||
staff.name !== identity.name ||
|
||||
@@ -51,10 +51,10 @@ export function patchOf(identity: Identity): IdentityPatch {
|
||||
*/
|
||||
export function diffRoster(
|
||||
remote: readonly RosterUser[],
|
||||
local: readonly Staff[],
|
||||
local: readonly StoredStaff[],
|
||||
testAccounts: readonly string[],
|
||||
): RosterDiff {
|
||||
const bySub = new Map<string, Staff>(local.map((s) => [s.id, s]));
|
||||
const bySub = new Map<string, StoredStaff>(local.map((s) => [s.id, s]));
|
||||
const diff: RosterDiff = { insert: [], update: [], setState: [] };
|
||||
const seen = new Set<string>();
|
||||
for (const user of remote) {
|
||||
@@ -90,7 +90,7 @@ export function diffRoster(
|
||||
* 一次同步能停很多人;要是把最后一个能进运营中心的人也停了,那张表与同步按钮就都没人点得动了,
|
||||
* 只剩去库里手改 `state`。所以要**在落 setState 之前**先按差异算一遍结果。
|
||||
*/
|
||||
export function activeRolesAfter(local: readonly Staff[], diff: RosterDiff): string[][] {
|
||||
export function activeRolesAfter(local: readonly StoredStaff[], 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);
|
||||
|
||||
@@ -6,15 +6,30 @@
|
||||
* `state` 由名册同步说了算,JIT 不碰它(插入时 active):本地 disabled 即使 token 有效也拒 ——
|
||||
* 撤权在同步之后即时生效;Keycloak 恢复了就再同步一次。
|
||||
*/
|
||||
import type { Position, Principal, RoleAccess, Staff } from '@contracts';
|
||||
import type { OrgGroup, OrgRole, OrgUser, OrgUserDetail, Position, Principal, RoleAccess, Staff } from '@contracts';
|
||||
import { positionIdSchema, staffIdSchema } from '@contracts';
|
||||
import type { Identity, OrgService, RosterSyncOutcome, StaffWithPosition } from '../../contract.ts';
|
||||
import { AccountDisabledError, PositionNotFoundError, StaffNotFoundError } from '../../contract.ts';
|
||||
import type {
|
||||
CreateDirectoryUser,
|
||||
DirectoryUserPatch,
|
||||
Identity,
|
||||
OrgService,
|
||||
RosterSyncOutcome,
|
||||
StaffWithPosition,
|
||||
} from '../../contract.ts';
|
||||
import {
|
||||
AccountDisabledError,
|
||||
DirectoryUnavailableError,
|
||||
PositionNotFoundError,
|
||||
ServiceAccountProtectedError,
|
||||
StaffNotFoundError,
|
||||
} from '../../contract.ts';
|
||||
import type {
|
||||
Clock,
|
||||
DirectoryUser,
|
||||
IdGenerator,
|
||||
InFlightWorkProbe,
|
||||
PositionRepository,
|
||||
KeycloakDirectory,
|
||||
RoleAccessRepository,
|
||||
RosterSource,
|
||||
StaffRecord,
|
||||
@@ -22,7 +37,17 @@ import type {
|
||||
TokenVerifier,
|
||||
WorkGroupRepository,
|
||||
} from '../../port.ts';
|
||||
import { identityOf } from '../domain/claims.ts';
|
||||
import { identityOf, isNoiseRole, staffKindOf } from '../domain/claims.ts';
|
||||
import type { LocalMark } from '../domain/directory.ts';
|
||||
import {
|
||||
filterOrgUsers,
|
||||
isServiceAccount,
|
||||
localMarks,
|
||||
sortOrgUsers,
|
||||
toOrgGroups,
|
||||
toOrgRoles,
|
||||
toOrgUser,
|
||||
} from '../domain/directory.ts';
|
||||
import { assertNotLockedOut, endsOf, normalizeEnds } from '../domain/ends.ts';
|
||||
import { buildInFlightWork } from '../domain/in-flight.ts';
|
||||
import { activeRolesAfter, diffRoster, identityChanged, patchOf } from '../domain/roster-diff.ts';
|
||||
@@ -33,6 +58,8 @@ export type OrgServiceDeps = {
|
||||
readonly verifier: TokenVerifier;
|
||||
/** 未配 provisioning 服务账号时缺省 —— 同步按钮如实回「未配置」 */
|
||||
readonly roster?: RosterSource;
|
||||
/** 同上;缺省时成员目录整个不可用(抛 DirectoryUnavailableError → 502) */
|
||||
readonly directory?: KeycloakDirectory;
|
||||
readonly positions: PositionRepository;
|
||||
readonly staff: StaffRepository;
|
||||
readonly roleAccess: RoleAccessRepository;
|
||||
@@ -47,10 +74,19 @@ export type OrgServiceDeps = {
|
||||
/** `last_seen_at` 多久之内不重写 —— 每个请求一次写太贵 */
|
||||
const LAST_SEEN_REWRITE_MS = 60_000;
|
||||
|
||||
/**
|
||||
* 成员目录读缓存的寿命。Keycloak 没有「一次拿回全部人的生效角色」的接口(手册 §14.4 逐人查),
|
||||
* 一屏就是十几趟远程往返 —— 实测 37 秒。缓存之后:
|
||||
* **本进程写完立刻整体清空** → 写后马上重拉一定看得到新结果,前端不必延时重试;
|
||||
* 唯一的滞后是**别的**管理员在另一个后端实例上写,这边最多滞后这么久。
|
||||
*/
|
||||
const DIRECTORY_TTL_MS = 60_000;
|
||||
|
||||
/** 契约里的 Staff 没有那两枚时刻 —— 出模块前剥掉 */
|
||||
function toStaff(record: StaffRecord): Staff {
|
||||
const { lastSeenAt: _seen, syncedAt: _synced, ...staff } = record;
|
||||
return staff;
|
||||
// `kind` 由账号名现判,不落库 —— 它是 Keycloak 命名的事实,不是我们存的一列状态
|
||||
return { ...staff, kind: staffKindOf(staff.account) };
|
||||
}
|
||||
|
||||
export function createOrgService(deps: OrgServiceDeps): OrgService {
|
||||
@@ -147,6 +183,117 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
|
||||
return toStaff(fresh ?? created);
|
||||
}
|
||||
|
||||
/* ── 成员目录的几个共用件 ─────────────────────────────────────────── */
|
||||
|
||||
/** 只缓存**贵的那两趟**(全量成员 / 组树);详情与成员单查照旧现取 */
|
||||
let usersCache: { at: number; value: OrgUser[] } | undefined;
|
||||
let groupsCache: { at: number; value: OrgGroup[] } | undefined;
|
||||
|
||||
/**
|
||||
* 写完清缓存 —— **按这次写真正碰到的东西清**,不是一律全清。
|
||||
* 全清的代价看得见:删一个组之后,下一次读要把「全量成员的角色」那趟 N+1 重付一遍,
|
||||
* 屏上那棵树因此多钉着已经没了的组好几秒(实测删 4 秒 + 重取 5.5 秒)。
|
||||
*
|
||||
* 'users' 改的是人本身(资料 / 口令)—— 组的成员数没变
|
||||
* 'groups' 改的是组(建 / 改名 / 删)—— 人的角色与端没变
|
||||
* 'all' 两边都动了(删人 = 人没了 + 他所在的组少一个人;进出组同理)
|
||||
*/
|
||||
function bustDirectoryCache(scope: 'users' | 'groups' | 'all'): void {
|
||||
if (scope !== 'groups') usersCache = undefined;
|
||||
if (scope !== 'users') groupsCache = undefined;
|
||||
}
|
||||
|
||||
function fresh<T>(entry: { at: number; value: T } | undefined): T | undefined {
|
||||
if (entry === undefined) return undefined;
|
||||
return deps.clock.now().getTime() - entry.at < DIRECTORY_TTL_MS ? entry.value : undefined;
|
||||
}
|
||||
|
||||
function needDirectory(): KeycloakDirectory {
|
||||
if (deps.directory === undefined) {
|
||||
throw new DirectoryUnavailableError(
|
||||
'未配置 KEYCLOAK_PROVISIONING_CLIENT_ID / KEYCLOAK_PROVISIONING_CLIENT_SECRET —— 成员与组织的真源是 Keycloak,读写都要经它的 Admin API',
|
||||
true,
|
||||
);
|
||||
}
|
||||
return deps.directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把适配器抛的两类 Keycloak 故障翻成模块自己的错误 —— **重试有没有意义**是分界:
|
||||
* 配错了(缺角色 / 凭证不对)重试一百次也是同一句;暂时够不着才值得过会儿再来。
|
||||
* 别的错(重名、约束不过)原样往上抛,路由译成 4xx 并带原话。
|
||||
*/
|
||||
async function guardKeycloak<T>(run: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await run();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'KeycloakMisconfiguredError') {
|
||||
throw new DirectoryUnavailableError(error.message, true);
|
||||
}
|
||||
if (error instanceof Error && error.name === 'KeycloakUnavailableError') {
|
||||
throw new DirectoryUnavailableError(error.message, false);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 本地名册那两列,按 sub 索引 —— 目录只读它们 */
|
||||
async function localMarkMap(): Promise<Map<string, LocalMark>> {
|
||||
const [rows, positions] = await Promise.all([deps.staff.list(), deps.positions.list()]);
|
||||
const codeOf = new Map(positions.map((p) => [p.id as string, p.code]));
|
||||
return localMarks(rows, (id) => codeOf.get(id));
|
||||
}
|
||||
|
||||
/** 目录全量(已剔服务账号、已配好端与本地两列、已排序)—— 检索在这之后做 */
|
||||
async function allDirectoryUsers(): Promise<OrgUser[]> {
|
||||
const dir = needDirectory();
|
||||
const hit = fresh(usersCache);
|
||||
if (hit !== undefined) return hit;
|
||||
const [raw, table, marks] = await Promise.all([
|
||||
guardKeycloak(() => dir.listUsers()),
|
||||
deps.roleAccess.list(),
|
||||
localMarkMap(),
|
||||
]);
|
||||
const people = raw.filter((u) => !isServiceAccount(u.username));
|
||||
const value = sortOrgUsers(people.map((u) => toOrgUser(u, table, marks.get(u.sub))));
|
||||
usersCache = { at: deps.clock.now().getTime(), value };
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次穿透写 —— 跑完**一律清缓存**。
|
||||
* 包起来而不是各写面自己记得调:漏一处就是一处「写完看不到」,
|
||||
* 而那种漏不报错,只是画面比事实旧(与治理台写路由统一广播同一个道理)。
|
||||
*/
|
||||
async function writeThrough<T>(scope: 'users' | 'groups' | 'all', run: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await guardKeycloak(run);
|
||||
} finally {
|
||||
bustDirectoryCache(scope);
|
||||
}
|
||||
}
|
||||
|
||||
/** 一次写之后回那一行的当下样子 —— 界面照它重画,不必自己拼 */
|
||||
async function oneUser(sub: string): Promise<OrgUser> {
|
||||
const dir = needDirectory();
|
||||
const [raw, table, marks] = await Promise.all([
|
||||
guardKeycloak(() => dir.getUser(sub)),
|
||||
deps.roleAccess.list(),
|
||||
localMarkMap(),
|
||||
]);
|
||||
return toOrgUser(raw, table, marks.get(sub));
|
||||
}
|
||||
|
||||
function assertPerson(user: Pick<DirectoryUser, 'username'>): void {
|
||||
if (isServiceAccount(user.username)) throw new ServiceAccountProtectedError(user.username);
|
||||
}
|
||||
|
||||
/** 写面开工之前先问一句「这个 sub 是人吗」—— 服务账号一律不许动 */
|
||||
async function assertPersonBySub(sub: string): Promise<void> {
|
||||
const dir = needDirectory();
|
||||
assertPerson(await guardKeycloak(() => dir.getUser(sub)));
|
||||
}
|
||||
|
||||
const service: OrgService = {
|
||||
async authenticate(token) {
|
||||
const claims = await deps.verifier.verify(token);
|
||||
@@ -308,6 +455,169 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
|
||||
return staff.filter((s) => s.state === 'active' && endsOf(s.roles, table).includes(surface));
|
||||
},
|
||||
|
||||
/* ── 成员目录(Keycloak Admin API 的读写面;ADR-012) ──────────────── */
|
||||
|
||||
async directoryUsers(query) {
|
||||
const users = await allDirectoryUsers();
|
||||
const q = query.q ?? '';
|
||||
return { users: filterOrgUsers(users, q, query.includeDisabled === true) };
|
||||
},
|
||||
|
||||
async directoryUser(sub): Promise<OrgUserDetail> {
|
||||
const dir = needDirectory();
|
||||
const raw = await guardKeycloak(() => dir.getUser(sub));
|
||||
assertPerson(raw);
|
||||
const [table, marks] = await Promise.all([deps.roleAccess.list(), localMarkMap()]);
|
||||
return {
|
||||
...toOrgUser(raw, table, marks.get(raw.sub)),
|
||||
emailVerified: raw.emailVerified,
|
||||
groups: raw.groups.map((g) => ({ ...g })),
|
||||
...(raw.firstName === undefined ? {} : { firstName: raw.firstName }),
|
||||
...(raw.lastName === undefined ? {} : { lastName: raw.lastName }),
|
||||
};
|
||||
},
|
||||
|
||||
async createDirectoryUser(input: CreateDirectoryUser) {
|
||||
const dir = needDirectory();
|
||||
if (isServiceAccount(input.username)) throw new ServiceAccountProtectedError(input.username);
|
||||
const sub = await writeThrough('all', () => dir.createUser(input));
|
||||
return oneUser(sub);
|
||||
},
|
||||
|
||||
async patchDirectoryUser(sub, patch: DirectoryUserPatch) {
|
||||
const dir = needDirectory();
|
||||
await assertPersonBySub(sub);
|
||||
await writeThrough('users', () => dir.updateUser(sub, patch));
|
||||
return oneUser(sub);
|
||||
},
|
||||
|
||||
async setDirectoryUserEnabled(sub, enabled) {
|
||||
const dir = needDirectory();
|
||||
await assertPersonBySub(sub);
|
||||
await writeThrough('users', () => dir.setEnabled(sub, enabled));
|
||||
/*
|
||||
* 停用之后**踢会话** —— 界面上已经写了「已停用」,而他手上那枚 token 到期前照样通行。
|
||||
* 这是「界面不撒谎」在鉴权上的样子:说停了就得真的进不来。
|
||||
*/
|
||||
if (!enabled) await writeThrough('users', () => dir.logout(sub));
|
||||
// 本地那一行跟手:两屏说的是同一件事(名册同步也会做,但那要等人去按)
|
||||
const local = await deps.staff.findById(staffIdSchema.parse(sub));
|
||||
if (local !== undefined) await deps.staff.setState(local.id, enabled ? 'active' : 'disabled', deps.clock.now());
|
||||
return oneUser(sub);
|
||||
},
|
||||
|
||||
async resetDirectoryUserPassword(sub, password) {
|
||||
const dir = needDirectory();
|
||||
await assertPersonBySub(sub);
|
||||
await writeThrough('users', () => dir.resetPassword(sub, password));
|
||||
},
|
||||
|
||||
async deleteDirectoryUser(sub) {
|
||||
const dir = needDirectory();
|
||||
await assertPersonBySub(sub);
|
||||
await writeThrough('all', () => dir.deleteUser(sub));
|
||||
/*
|
||||
* 本地那一行**停用,不删** —— 留痕、在途任务、台账的 `by` 都还指着这个 sub。
|
||||
* 删了那些地方就成了悬空的 id,而历史是只增不改的。
|
||||
*/
|
||||
const local = await deps.staff.findById(staffIdSchema.parse(sub));
|
||||
if (local !== undefined) await deps.staff.setState(local.id, 'disabled', deps.clock.now());
|
||||
},
|
||||
|
||||
async directoryRoles(): Promise<{ roles: OrgRole[] }> {
|
||||
const dir = needDirectory();
|
||||
const [roles, table, users] = await Promise.all([
|
||||
guardKeycloak(() => dir.listRoles()),
|
||||
deps.roleAccess.list(),
|
||||
allDirectoryUsers(),
|
||||
]);
|
||||
// 家具角色不摆:它们是 Keycloak 给每个用户自动挂的,授收都没有意义(见 isNoiseRole)
|
||||
const real = roles.filter((r) => !isNoiseRole(r.name));
|
||||
/*
|
||||
* 持有数从**生效角色**里数(每人的 `/composite`),不用 `GET /roles/{name}/users` ——
|
||||
* 那个只回**直接授予**的:`user` 是经 `default-roles-corp` 继承来的,
|
||||
* 那条路会报「持有 0 人」而实际人人都有。快了却是错的,不要。
|
||||
* 速度靠 `allDirectoryUsers` 的 60 秒缓存拿。
|
||||
*/
|
||||
const counts = real.map((r) => users.filter((u) => u.roles.includes(r.name)).length);
|
||||
return { roles: toOrgRoles(real, table, counts) };
|
||||
},
|
||||
|
||||
async setDirectoryUserRole(sub, role, granted) {
|
||||
const dir = needDirectory();
|
||||
await assertPersonBySub(sub);
|
||||
/*
|
||||
* 收权之前先算一遍「收完还有谁进得了运营中心」—— 与改「角色→端」那条同一道闸,
|
||||
* 理由也同一个:这两条路都能把最后一个管理员关在门外,而**恢复动作本身要 ops**。
|
||||
*/
|
||||
if (!granted) {
|
||||
const [table, users] = await Promise.all([deps.roleAccess.list(), allDirectoryUsers()]);
|
||||
const after = users
|
||||
.filter((u) => u.enabled)
|
||||
.map((u) => (u.sub === sub ? u.roles.filter((r) => r !== role) : u.roles));
|
||||
assertNotLockedOut(
|
||||
table,
|
||||
after,
|
||||
`收掉「${role}」之后就没有人能进运营中心了 —— 这扇门会锁死(恢复动作本身要运营中心),拒绝`,
|
||||
);
|
||||
}
|
||||
await writeThrough('users', () => dir.setRole(sub, role, granted));
|
||||
// 收权立刻踢会话:否则他手上那枚 token 还带着旧角色,收权要等到过期才生效
|
||||
if (!granted) await writeThrough('users', () => dir.logout(sub));
|
||||
return oneUser(sub);
|
||||
},
|
||||
|
||||
async directoryGroups(): Promise<{ groups: OrgGroup[] }> {
|
||||
const dir = needDirectory();
|
||||
const hit = fresh(groupsCache);
|
||||
if (hit !== undefined) return { groups: hit };
|
||||
const groups = await guardKeycloak(() => dir.listGroups());
|
||||
// 数数就只数数 —— 别为了一个数字把每个成员的角色都拉一遍(N+1 的第二处)
|
||||
const pairs = await Promise.all(
|
||||
groups.map(async (g) => {
|
||||
const names = await guardKeycloak(() => dir.groupMemberUsernames(g.id));
|
||||
return [g.id, names.filter((n) => !isServiceAccount(n)).length] as const;
|
||||
}),
|
||||
);
|
||||
const value = toOrgGroups(groups, new Map(pairs));
|
||||
groupsCache = { at: deps.clock.now().getTime(), value };
|
||||
return { groups: value };
|
||||
},
|
||||
|
||||
async directoryGroupMembers(groupId) {
|
||||
const dir = needDirectory();
|
||||
const [raw, table, marks] = await Promise.all([
|
||||
guardKeycloak(() => dir.groupMembers(groupId)),
|
||||
deps.roleAccess.list(),
|
||||
localMarkMap(),
|
||||
]);
|
||||
const members = raw.filter((u) => !isServiceAccount(u.username)).map((u) => toOrgUser(u, table, marks.get(u.sub)));
|
||||
return { members: sortOrgUsers(members) };
|
||||
},
|
||||
|
||||
async createDirectoryGroup(name, parentId) {
|
||||
const dir = needDirectory();
|
||||
const g = await writeThrough('groups', () => dir.createGroup(name, parentId));
|
||||
return toOrgGroups([g], new Map())[0]!;
|
||||
},
|
||||
|
||||
async renameDirectoryGroup(groupId, name) {
|
||||
const dir = needDirectory();
|
||||
const g = await writeThrough('groups', () => dir.renameGroup(groupId, name));
|
||||
return toOrgGroups([g], new Map())[0]!;
|
||||
},
|
||||
|
||||
async deleteDirectoryGroup(groupId) {
|
||||
const dir = needDirectory();
|
||||
await writeThrough('groups', () => dir.deleteGroup(groupId));
|
||||
},
|
||||
|
||||
async setDirectoryUserGroup(sub, groupId, member) {
|
||||
const dir = needDirectory();
|
||||
await assertPersonBySub(sub);
|
||||
await writeThrough('groups', () => dir.setUserGroup(sub, groupId, member));
|
||||
},
|
||||
|
||||
/* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────── */
|
||||
|
||||
async saveWorkGroup(input) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* adapter 实现之,usecase 依赖之,app 装配之。
|
||||
*/
|
||||
import type { Position, PositionId, RoleAccess, Staff, StaffId, WorkGroup } from '@contracts';
|
||||
import type { Identity } from './contract.ts';
|
||||
import type { CreateDirectoryUser, DirectoryUserPatch, Identity } from './contract.ts';
|
||||
|
||||
export type Clock = { now(): Date };
|
||||
|
||||
@@ -13,7 +13,12 @@ export type Clock = { now(): Date };
|
||||
export type VerifiedClaims = {
|
||||
readonly sub: string;
|
||||
readonly preferred_username: string;
|
||||
/** Keycloak 拼好的 `firstName lastName` —— 西式顺序,只在拆不开时才用它 */
|
||||
readonly name?: string;
|
||||
/** 名(firstName) */
|
||||
readonly given_name?: string;
|
||||
/** 姓(lastName)—— 我们按「姓在前」自己拼,见 `personName` */
|
||||
readonly family_name?: string;
|
||||
readonly email?: string;
|
||||
readonly realm_access?: { readonly roles?: readonly string[] };
|
||||
};
|
||||
@@ -25,6 +30,66 @@ export type TokenVerifier = { verify(token: string): Promise<VerifiedClaims> };
|
||||
export type RosterUser = Identity & { readonly enabled: boolean };
|
||||
export type RosterSource = { listUsers(): Promise<readonly RosterUser[]> };
|
||||
|
||||
/* ── 成员目录(Keycloak Admin API 的读写面;ADR-012) ────────────────── */
|
||||
|
||||
/**
|
||||
* Keycloak 那一侧的一个用户 —— **原样的形状**,不掺本地字段。
|
||||
* 与 `RosterUser` 的区别:名册只要「同步要比对的那几列」,目录要摆给人看的全部。
|
||||
*/
|
||||
export type DirectoryUser = {
|
||||
readonly sub: string;
|
||||
readonly username: string;
|
||||
readonly firstName?: string;
|
||||
readonly lastName?: string;
|
||||
readonly email?: string;
|
||||
readonly enabled: boolean;
|
||||
readonly emailVerified: boolean;
|
||||
/** realm 角色(家具角色已滤) */
|
||||
readonly roles: readonly string[];
|
||||
};
|
||||
|
||||
/** 组树里的一行 —— 平表,层级看 `parentId` / `depth`(契约同款,理由见 org-directory.ts) */
|
||||
export type DirectoryGroup = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly path: string;
|
||||
readonly parentId?: string;
|
||||
readonly depth: number;
|
||||
readonly memberCount: number;
|
||||
};
|
||||
|
||||
export type DirectoryRole = {
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly composite: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 成员目录 —— app 未配 provisioning 服务账号时不注入(与 `RosterSource` 同款)。
|
||||
* 读是投影,写**直接穿透**到 Keycloak:这里建的人立刻能登录,这里删的人从 Keycloak 消失。
|
||||
*/
|
||||
export type KeycloakDirectory = {
|
||||
listUsers(): Promise<readonly DirectoryUser[]>;
|
||||
getUser(sub: string): Promise<DirectoryUser & { readonly groups: ReadonlyArray<{ id: string; path: string }> }>;
|
||||
createUser(input: CreateDirectoryUser): Promise<string>;
|
||||
updateUser(sub: string, patch: DirectoryUserPatch): Promise<void>;
|
||||
setEnabled(sub: string, enabled: boolean): Promise<void>;
|
||||
resetPassword(sub: string, password: string): Promise<void>;
|
||||
deleteUser(sub: string): Promise<void>;
|
||||
/** 踢掉此人全部会话 —— 停用与收权之后必调,否则旧 token 到期前照样通行 */
|
||||
logout(sub: string): Promise<void>;
|
||||
listRoles(): Promise<readonly DirectoryRole[]>;
|
||||
setRole(sub: string, role: string, granted: boolean): Promise<void>;
|
||||
listGroups(): Promise<readonly DirectoryGroup[]>;
|
||||
groupMembers(groupId: string): Promise<readonly DirectoryUser[]>;
|
||||
/** 组的直属成员用户名 —— 组树只要个数,别为了数数把每个人的角色都拉一遍 */
|
||||
groupMemberUsernames(groupId: string): Promise<readonly string[]>;
|
||||
createGroup(name: string, parentId?: string): Promise<DirectoryGroup>;
|
||||
renameGroup(groupId: string, name: string): Promise<DirectoryGroup>;
|
||||
deleteGroup(groupId: string): Promise<void>;
|
||||
setUserGroup(sub: string, groupId: string, member: boolean): Promise<void>;
|
||||
};
|
||||
|
||||
/** 名册即时更新 / 同步要改的那几列 */
|
||||
export type IdentityPatch = {
|
||||
readonly account: string;
|
||||
@@ -44,7 +109,13 @@ export type PositionRepository = {
|
||||
};
|
||||
|
||||
/** 员工行 —— 契约 Staff 之外还带两枚时刻,只给用例判「要不要再写一次」,不出模块 */
|
||||
export type StaffRecord = Staff & { readonly lastSeenAt?: Date; readonly syncedAt?: Date };
|
||||
/**
|
||||
* 库里真存着的那几列。`Staff.kind` 不在其中 —— 它由账号名现判(见 `staffKindSchema`),
|
||||
* 存一列就会与账号名分叉,而分叉之后没人看得出以哪份为准。
|
||||
*/
|
||||
export type StoredStaff = Omit<Staff, 'kind'>;
|
||||
|
||||
export type StaffRecord = StoredStaff & { readonly lastSeenAt?: Date; readonly syncedAt?: Date };
|
||||
|
||||
export type StaffRepository = {
|
||||
list(): Promise<StaffRecord[]>;
|
||||
@@ -52,7 +123,7 @@ export type StaffRepository = {
|
||||
/** 按账号找 —— `account` 有唯一索引,建档前得先问一句「这个名字被谁占着」(同名让位) */
|
||||
findByAccount(account: string): Promise<StaffRecord | undefined>;
|
||||
listByPosition(positionId: PositionId): Promise<StaffRecord[]>;
|
||||
insert(staff: Staff, marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date }): Promise<StaffRecord>;
|
||||
insert(staff: StoredStaff, marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date }): Promise<StaffRecord>;
|
||||
updateIdentity(id: StaffId, patch: IdentityPatch): Promise<void>;
|
||||
setPosition(id: StaffId, positionId: PositionId): Promise<void>;
|
||||
setState(id: StaffId, state: Staff['state'], syncedAt: Date): Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user