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:
xcs
2026-08-27 11:44:36 +08:00
co-authored by Claude Fable 5
parent bd86dd6088
commit 8556432dba
50 changed files with 4242 additions and 402 deletions
+72 -2
View File
@@ -5,8 +5,27 @@
* 本模块管三件事:①验签后的人是谁(名册即时更新);②他能进哪几个端(角色→端);③岗位与工作组。
* 口令 / 会话 / 岗位授权(客体三类 × 动作三种)已退役。
*/
import type { InFlightWork, Position, PositionId, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffId, Surface, WorkGroup, WorkGroupBinding } from '@contracts';
export type { InFlightWork, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding };
import type { InFlightWork, OrgGroup, OrgRole, OrgUser, OrgUserDetail, Position, PositionId, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffId, Surface, WorkGroup, WorkGroupBinding } from '@contracts';
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;
@@ -47,6 +66,24 @@ export declare class AccountDisabledError extends Error {
export declare class AccessLockoutError extends Error {
constructor(message?: string);
}
/**
* 目录整个不可用 —— 没配 provisioning 服务账号,或那个账号缺 realm-management 角色。
* **重试无益**:路由译成 502 并原样带出 Keycloak 的话,界面提示找管理员补权限(手册 §14.6)。
*/
export declare class DirectoryUnavailableError extends Error {
/** true = 配错了(补权限);false = 暂时够不着(可退避重试)—— 两句话,人的下一步不同 */
readonly misconfigured: boolean;
constructor(message: string, misconfigured: boolean);
}
/**
* 写面对着一个服务账号 —— 拒。它是 Keycloak 给 client 自动建的身份(见 `staffKindSchema`),
* 在这三个页签里**根本不该出现**;能对着它下手,只能是有人手输了 sub。
* 停掉它的后果不显眼却很重:试跑与 e2e 第二天全趴下。
*/
export declare class ServiceAccountProtectedError extends Error {
readonly account: string;
constructor(account: string);
}
export type OrgService = {
/** 验签 → 名册即时更新 → 角色→端。抛 TokenRejectedError(401)/ AccountDisabledError(403) */
authenticate(token: string): Promise<Principal>;
@@ -75,6 +112,39 @@ export type OrgService = {
deleteRoleAccess(role: string): Promise<void>;
/** 能进该端的活跃员工 —— 治理台通知的收件人 */
staffWithEnd(surface: Surface): Promise<StaffWithPosition[]>;
/**
* 目录**没配 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>;
/** 建组 / 改组;id 留空即新建 */
saveWorkGroup(input: {
id?: string;
+7 -3
View File
@@ -1,18 +1,22 @@
/**
* module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type { Identity, InFlightWork, OrgService, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffWithPosition, Surface, WorkGroup, WorkGroupBinding, } from './contract.ts';
export { AccessLockoutError, AccountDisabledError, PositionNotFoundError, StaffNotFoundError, TokenRejectedError, } from './contract.ts';
export type { Clock, IdGenerator, IdentityPatch, InFlightWorkProbe, PositionRepository, RoleAccessRepository, RosterSource, RosterUser, StaffRecord, StaffRepository, TokenVerifier, VerifiedClaims, WorkGroupRepository, } from './port.ts';
export type { CreateDirectoryUser, DirectoryUserPatch, Identity, InFlightWork, OrgGroup, OrgRole, OrgService, OrgUser, OrgUserDetail, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffWithPosition, Surface, WorkGroup, WorkGroupBinding, } from './contract.ts';
export { AccessLockoutError, AccountDisabledError, DirectoryUnavailableError, PositionNotFoundError, ServiceAccountProtectedError, StaffNotFoundError, TokenRejectedError, } from './contract.ts';
export type { Clock, DirectoryGroup, DirectoryRole, DirectoryUser, IdGenerator, IdentityPatch, InFlightWorkProbe, KeycloakDirectory, PositionRepository, RoleAccessRepository, RosterSource, RosterUser, StaffRecord, StaffRepository, TokenVerifier, VerifiedClaims, WorkGroupRepository, } from './port.ts';
export type { OrgServiceDeps } from './internal/usecase/org-service.ts';
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';
export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts';
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, createStaffRepository, createWorkGroupRepository, } from './internal/adapter/org-repository.ts';
export { positionTable, roleAccessTable, staffTable, workGroupTable } from './internal/adapter/org-schema.ts';
+72 -3
View File
@@ -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;
};
@@ -11,7 +11,12 @@ export type Clock = {
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[];
@@ -28,6 +33,65 @@ export type RosterUser = Identity & {
export type RosterSource = {
listUsers(): Promise<readonly RosterUser[]>;
};
/**
* 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;
@@ -45,7 +109,12 @@ export type PositionRepository = {
}): Promise<Position>;
};
/** 员工行 —— 契约 Staff 之外还带两枚时刻,只给用例判「要不要再写一次」,不出模块 */
export type StaffRecord = Staff & {
/**
* 库里真存着的那几列。`Staff.kind` 不在其中 —— 它由账号名现判(见 `staffKindSchema`),
* 存一列就会与账号名分叉,而分叉之后没人看得出以哪份为准。
*/
export type StoredStaff = Omit<Staff, 'kind'>;
export type StaffRecord = StoredStaff & {
readonly lastSeenAt?: Date;
readonly syncedAt?: Date;
};
@@ -55,7 +124,7 @@ export type StaffRepository = {
/** 按账号找 —— `account` 有唯一索引,建档前得先问一句「这个名字被谁占着」(同名让位) */
findByAccount(account: string): Promise<StaffRecord | undefined>;
listByPosition(positionId: PositionId): Promise<StaffRecord[]>;
insert(staff: Staff, marks: {
insert(staff: StoredStaff, marks: {
readonly lastSeenAt?: Date;
readonly syncedAt?: Date;
}): Promise<StaffRecord>;