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:
@@ -11,7 +11,7 @@ import { createTicketIssuer } from '../ticket.ts';
|
||||
|
||||
const position: Position = { id: positionIdSchema.parse('none'), code: 'NONE', name: '未分配' };
|
||||
const principal = (ends: Surface[], roles: string[]): Principal => ({
|
||||
staff: { id: staffIdSchema.parse('sub-1'), account: 'alice', name: '爱丽丝', positionId: position.id, state: 'active', roles },
|
||||
staff: { id: staffIdSchema.parse('sub-1'), account: 'alice', name: '爱丽丝', positionId: position.id, state: 'active', roles, kind: 'person' },
|
||||
position,
|
||||
roles,
|
||||
ends,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 成员与组织的路由归属(ADR-012):15 条全部进契约、全部注册、全部归运营中心。
|
||||
*
|
||||
* 归属写在契约的 `access` 上,鉴权中间件一处按注册表判 —— 所以**漏标一条**
|
||||
* 就会落进 `?? 'any'` 兜底:只要登录过的人都能调。这条测试就是拦那个的。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as contracts from '@contracts';
|
||||
import { ORG_DIRECTORY_ROUTES } from '@contracts';
|
||||
import { appRouteRegistry } from '../server.ts';
|
||||
import { buildRouteRegistry, routesOf } from '../route-registry.ts';
|
||||
|
||||
describe('成员与组织路由契约', () => {
|
||||
it('15 条全在契约命名空间里,注册表建得起来', () => {
|
||||
const registry = buildRouteRegistry(routesOf(Object.values(contracts)));
|
||||
expect(ORG_DIRECTORY_ROUTES).toHaveLength(15);
|
||||
for (const route of ORG_DIRECTORY_ROUTES) {
|
||||
expect(registry.has(route.method, route.path)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('读写一律归运营中心 —— 成员目录没有设计端的消费方', () => {
|
||||
for (const route of ORG_DIRECTORY_ROUTES) {
|
||||
expect([route.path, route.access]).toEqual([route.path, 'ops']);
|
||||
}
|
||||
});
|
||||
|
||||
it('查得到的归属就是 ops,不落兜底 —— 兜底等于「登录了就能调」', () => {
|
||||
const registry = appRouteRegistry();
|
||||
for (const route of ORG_DIRECTORY_ROUTES) {
|
||||
// 拿一条**具体**路径去查(带 :param 的模版要真能匹配上,不然线上每次都 miss)
|
||||
const concrete = route.path.replace(/:[A-Za-z_][\w]*/g, 'X');
|
||||
expect([concrete, registry.lookup(route.method, concrete)]).toEqual([concrete, 'ops']);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -16,8 +16,9 @@ import { createEventService, createEventStore } from '@modules/event';
|
||||
import type { LedgerService } from '@modules/ledger';
|
||||
import type { LoopEngineService } from '@modules/loop-engine';
|
||||
import { createDraftStore, createLedgerService, createLedgerStore, DRAFT_DDL } from '@modules/ledger';
|
||||
import type { InFlightWorkProbe, OrgService, RosterSource, TokenVerifier } from '@modules/org';
|
||||
import type { InFlightWorkProbe, KeycloakDirectory, OrgService, RosterSource, TokenVerifier } from '@modules/org';
|
||||
import {
|
||||
createKeycloakDirectory,
|
||||
createKeycloakRoster,
|
||||
createKeycloakTokenVerifier,
|
||||
createOrgService,
|
||||
@@ -185,6 +186,7 @@ const sfeelEvaluator = (expression: string, scope: Readonly<Record<string, unkno
|
||||
export type ContainerOverrides = {
|
||||
readonly tokenVerifier?: TokenVerifier;
|
||||
readonly roster?: RosterSource;
|
||||
readonly directory?: KeycloakDirectory;
|
||||
};
|
||||
|
||||
export async function createContainer(config: AppConfig, overrides: ContainerOverrides = {}): Promise<Container> {
|
||||
@@ -256,9 +258,22 @@ export async function createContainer(config: AppConfig, overrides: ContainerOve
|
||||
secret: keycloak.provisioning.secret,
|
||||
}));
|
||||
|
||||
/* 成员目录与名册同步同一枚 provisioning 服务账号 —— 没配就都不注入,界面如实说不可用 */
|
||||
const directory: KeycloakDirectory | undefined =
|
||||
overrides.directory ??
|
||||
(keycloak?.provisioning === undefined
|
||||
? undefined
|
||||
: createKeycloakDirectory({
|
||||
url: keycloak.url,
|
||||
realm: keycloak.realm,
|
||||
clientId: keycloak.provisioning.clientId,
|
||||
secret: keycloak.provisioning.secret,
|
||||
}));
|
||||
|
||||
const org = createOrgService({
|
||||
verifier,
|
||||
...(roster === undefined ? {} : { roster }),
|
||||
...(directory === undefined ? {} : { directory }),
|
||||
positions: createPositionRepository(db),
|
||||
staff: createStaffRepository(db),
|
||||
roleAccess: createRoleAccessRepository(db),
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* 成员与组织的路由组装(ADR-012)—— 运营中心「员工」那一屏的三个页签。
|
||||
* 端级授权由 server.ts 的鉴权中间件按契约上的 `access: 'ops'` 统一判,这里不写守卫。
|
||||
*
|
||||
* **写是穿透**:成功之后 Keycloak 已经变了,没有撤销。所以这一层只做三件事 ——
|
||||
* 把入参校出来、把模块的错误翻成 HTTP、把写完的那一行原样回给界面(界面照它重画,不自己拼)。
|
||||
*
|
||||
* 错误分档(手册 §15 + BPOS §17.0):
|
||||
* 502 `keycloak_misconfigured` 服务账号缺角色 / 没配 —— **重试无益**,去补权限;
|
||||
* 502 `keycloak_unavailable` 暂时够不着 —— 可退避重试;
|
||||
* 409 `conflict` 同级重名、收权会锁死运营中心;
|
||||
* 403 `forbidden` 对着服务账号下手。
|
||||
* 两种 502 分开说,是因为**人的下一步不同**:一个去找管理员,一个等一会儿再点。
|
||||
*/
|
||||
import type { Hono } from 'hono';
|
||||
import type { ApiError } from '@contracts';
|
||||
import {
|
||||
createOrgGroupRoute,
|
||||
createOrgUserRoute,
|
||||
deleteOrgGroupRoute,
|
||||
deleteOrgUserRoute,
|
||||
getOrgUserRoute,
|
||||
listOrgGroupMembersRoute,
|
||||
listOrgGroupsRoute,
|
||||
listOrgRolesRoute,
|
||||
listOrgUsersRoute,
|
||||
patchOrgUserRoute,
|
||||
renameOrgGroupRoute,
|
||||
resetOrgUserPasswordRoute,
|
||||
setOrgUserEnabledRoute,
|
||||
setOrgUserGroupRoute,
|
||||
setOrgUserRoleRoute,
|
||||
} from '@contracts';
|
||||
import {
|
||||
AccessLockoutError,
|
||||
DirectoryUnavailableError,
|
||||
ServiceAccountProtectedError,
|
||||
} from '@modules/org';
|
||||
import type { Container } from '../container.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
/** 出错时统一翻译 —— 每条路由各写一遍 try/catch 只会漏掉其中几条 */
|
||||
async function answer<T>(run: () => Promise<T>): Promise<{ body: T } | { error: ApiError; status: 403 | 404 | 409 | 502 }> {
|
||||
try {
|
||||
return { body: await run() };
|
||||
} catch (error) {
|
||||
if (error instanceof DirectoryUnavailableError) {
|
||||
return {
|
||||
error: { code: error.misconfigured ? 'keycloak-misconfigured' : 'keycloak-unavailable', message: error.message },
|
||||
status: 502,
|
||||
};
|
||||
}
|
||||
if (error instanceof ServiceAccountProtectedError) {
|
||||
return { error: { code: 'forbidden', message: error.message }, status: 403 };
|
||||
}
|
||||
if (error instanceof AccessLockoutError) {
|
||||
return { error: { code: 'conflict', message: error.message }, status: 409 };
|
||||
}
|
||||
// Keycloak 明确拒绝(重名 / 约束不过)—— 原话带给人看,别自己编一句更含糊的
|
||||
if (error instanceof Error && error.name === 'KeycloakRejectedError') {
|
||||
const status = (error as Error & { status?: number }).status;
|
||||
return { error: { code: status === 404 ? 'not-found' : 'conflict', message: error.message }, status: status === 404 ? 404 : 409 };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerOrgDirectoryRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||
/**
|
||||
* zod 出来的可选字段是 `string | undefined`,而入参类型在 `exactOptionalPropertyTypes`
|
||||
* 下要的是「要么有、要么这个键不存在」—— 显式的 undefined 在这儿不算「没填」。
|
||||
* 全仓同一个惯用法:条件展开。
|
||||
*/
|
||||
function defined<T extends Record<string, unknown>>(input: T): { [K in keyof T]: Exclude<T[K], undefined> } {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(input)) if (v !== undefined) out[k] = v;
|
||||
return out as { [K in keyof T]: Exclude<T[K], undefined> };
|
||||
}
|
||||
|
||||
/** 校验 body,不合就 400 —— 与治理台其余路由同一套话 */
|
||||
const bad = (detail: unknown): ApiError => ({ code: 'validation-failed', message: '入参不合法', detail });
|
||||
|
||||
/* ── 成员 ───────────────────────────────────────────────────────── */
|
||||
|
||||
app.get(listOrgUsersRoute.path, async (c) => {
|
||||
const q = c.req.query('q') ?? '';
|
||||
// 只有 `1` 算真 —— `true` / `yes` 这类一律当假,免得两处理解不同(与 BPOS §1.1 同款)
|
||||
const includeDisabled = c.req.query('includeDisabled') === '1';
|
||||
const out = await answer(() => container.org.directoryUsers({ q, includeDisabled }));
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.get(getOrgUserRoute.path, async (c) => {
|
||||
const out = await answer(() => container.org.directoryUser(c.req.param('sub')));
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.post(createOrgUserRoute.path, async (c) => {
|
||||
const parsed = createOrgUserRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) return c.json(bad(parsed.error.issues), 400);
|
||||
const out = await answer(() => container.org.createDirectoryUser(defined(parsed.data)));
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.patch(patchOrgUserRoute.path, async (c) => {
|
||||
const parsed = patchOrgUserRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) return c.json(bad(parsed.error.issues), 400);
|
||||
const out = await answer(() => container.org.patchDirectoryUser(c.req.param('sub'), defined(parsed.data)));
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.post(setOrgUserEnabledRoute.path, async (c) => {
|
||||
const parsed = setOrgUserEnabledRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) return c.json(bad(parsed.error.issues), 400);
|
||||
const out = await answer(() => container.org.setDirectoryUserEnabled(c.req.param('sub'), parsed.data.enabled));
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.post(resetOrgUserPasswordRoute.path, async (c) => {
|
||||
const parsed = resetOrgUserPasswordRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) return c.json(bad(parsed.error.issues), 400);
|
||||
const out = await answer(async () => {
|
||||
await container.org.resetDirectoryUserPassword(c.req.param('sub'), parsed.data.password);
|
||||
return { ok: true as const };
|
||||
});
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.delete(deleteOrgUserRoute.path, async (c) => {
|
||||
const out = await answer(async () => {
|
||||
await container.org.deleteDirectoryUser(c.req.param('sub'));
|
||||
return { ok: true as const };
|
||||
});
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
/* ── 权限:realm 角色 × 能进哪几个端 × 谁持有 ───────────────────── */
|
||||
|
||||
app.get(listOrgRolesRoute.path, async (c) => {
|
||||
const out = await answer(() => container.org.directoryRoles());
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.post(setOrgUserRoleRoute.path, async (c) => {
|
||||
const parsed = setOrgUserRoleRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) return c.json(bad(parsed.error.issues), 400);
|
||||
const out = await answer(() =>
|
||||
container.org.setDirectoryUserRole(c.req.param('sub'), c.req.param('role'), parsed.data.granted),
|
||||
);
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
/* ── 组织 ───────────────────────────────────────────────────────── */
|
||||
|
||||
app.get(listOrgGroupsRoute.path, async (c) => {
|
||||
const out = await answer(() => container.org.directoryGroups());
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.get(listOrgGroupMembersRoute.path, async (c) => {
|
||||
const out = await answer(() => container.org.directoryGroupMembers(c.req.param('id')));
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.post(createOrgGroupRoute.path, async (c) => {
|
||||
const parsed = createOrgGroupRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) return c.json(bad(parsed.error.issues), 400);
|
||||
const out = await answer(() => container.org.createDirectoryGroup(parsed.data.name, parsed.data.parentId));
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.patch(renameOrgGroupRoute.path, async (c) => {
|
||||
const parsed = renameOrgGroupRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) return c.json(bad(parsed.error.issues), 400);
|
||||
const out = await answer(() => container.org.renameDirectoryGroup(c.req.param('id'), parsed.data.name));
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.delete(deleteOrgGroupRoute.path, async (c) => {
|
||||
const out = await answer(async () => {
|
||||
await container.org.deleteDirectoryGroup(c.req.param('id'));
|
||||
return { ok: true as const };
|
||||
});
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.post(setOrgUserGroupRoute.path, async (c) => {
|
||||
const parsed = setOrgUserGroupRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) return c.json(bad(parsed.error.issues), 400);
|
||||
const out = await answer(async () => {
|
||||
await container.org.setDirectoryUserGroup(c.req.param('sub'), c.req.param('groupId'), parsed.data.member);
|
||||
return { ok: true as const };
|
||||
});
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { registerRegistrationRoutes } from './routes/registration.ts';
|
||||
import { registerGovernanceOpsRoutes } from './routes/governance-ops.ts';
|
||||
import { registerGovernanceRoutes } from './routes/governance.ts';
|
||||
import { registerIngestRoutes } from './routes/ingest.ts';
|
||||
import { registerOrgDirectoryRoutes } from './routes/org-directory.ts';
|
||||
import { registerMailRoutes } from './routes/mail.ts';
|
||||
import { registerStreamRoute } from './routes/stream.ts';
|
||||
import { registerWorkbenchRoutes } from './routes/workbench.ts';
|
||||
@@ -115,6 +116,7 @@ export function createServer(container: Container): Hono<AppEnv> {
|
||||
|
||||
registerWorkbenchRoutes(app, container);
|
||||
registerGovernanceRoutes(app, container);
|
||||
registerOrgDirectoryRoutes(app, container);
|
||||
registerDeskRoutes(app, container);
|
||||
registerDeskElementRoutes(app, container);
|
||||
registerDeskApprovalRoutes(app, container);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 名册同步 —— 从 Keycloak Admin API 把整册照过来(ADR-012)。
|
||||
*
|
||||
* pnpm --filter @app/api sync-roster
|
||||
*
|
||||
* 与 `POST /api/gov/staff/sync` 是**同一段用例**(`org.syncRoster`),只是换了个入口:
|
||||
* 那条路由要 `ops`,而运维手上常常只有 shell —— 服务账号进不了运营中心,
|
||||
* 前端也未必摆着那枚钮。首登建档(JIT)覆盖的是「在用的人」,这条补的是「还没登录过的人」。
|
||||
*
|
||||
* 三个数说的是**真落进库的条数**;`failed` 单列 —— 半途出事的同步不许报成一排绿数字。
|
||||
*/
|
||||
import { loadConfig } from './config.ts';
|
||||
import { createContainer } from './container.ts';
|
||||
|
||||
export async function syncRoster(): Promise<void> {
|
||||
const container = await createContainer(loadConfig());
|
||||
try {
|
||||
const outcome = await container.org.syncRoster();
|
||||
if (!outcome.configured) {
|
||||
process.stderr.write(`✗ ${outcome.message}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
process.stdout.write(
|
||||
`同步完成:远端 ${outcome.total} 人 · 新建 ${outcome.inserted} · 更新 ${outcome.updated} · 停用 ${outcome.disabled}\n`,
|
||||
);
|
||||
for (const f of outcome.failed) process.stderr.write(` ✗ ${f.account}:${f.reason}\n`);
|
||||
if (outcome.failed.length > 0) process.exitCode = 1;
|
||||
|
||||
const { staff } = await container.org.listStaff();
|
||||
process.stdout.write(`\n名册 ${staff.length} 人:\n`);
|
||||
for (const s of staff) {
|
||||
// 标出人 / 服务账号 —— 服务账号在 Keycloak 管理台的 Users 列表里也看不到,这里如实说
|
||||
const kind = s.kind === 'service' ? '服务账号' : '人 ';
|
||||
process.stdout.write(
|
||||
` ${kind} ${s.account.padEnd(32)} ${s.name.padEnd(14)} ${s.position.code.padEnd(5)} ${s.state.padEnd(9)} ${s.roles.join(',')}\n`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await container.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
await syncRoster();
|
||||
}
|
||||
Reference in New Issue
Block a user