推翻 ADR-010 的口令/会话/岗位授权:身份只认 Bearer JWT(jose 本地验签), 权限只到三个端(design/ops/workbench),角色→端一张表运营中心可改; 岗位本地 + 名册经 Admin API 同步;SSE 与下载走 60s 票据;退役契约留一轮回 410。 只做后端,web 不动。四问与三取舍已由用户裁决,记在 §2。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw
29 KiB
统一鉴权中心(Keycloak)接入 —— 后端设计
- 日期:2026-08-25
- 状态:设计已通过,待实施(实施计划另出)
- 范围:只做后端(apps/api + packages/contracts + 文档);web 一个文件不动
- 依据:
docs/keycloak-backend-guide.md(Keycloak 26.7.1 / jose / 核实日期 2026-08-12)
1. 背景与目标
现状(ADR-010):自建账号口令(scrypt)+ 服务端会话表 sys.session + 岗位授权
sys.position_grant(客体三类 × 动作三种,治理台九功能)。全部推翻,换成:
- 身份来自 Keycloak(realm
corp):后端只本地验签 Bearer JWT(RS256 / iss / aud),不回源、不 introspect。 - 权限只到「端」:三个端 —— 设计中心
design/ 运营中心ops/ 工作台workbench; 角色 → 能进哪几个端 是一张放在我们库里、运营中心可动态改的表。角色 = Keycloak realm 角色。 - 名册:人从 Keycloak 来(首次登录即建 + Admin API 同步);岗位仍是我们的表,在运营中心给人指派。
非目标:前端接入(按《Keycloak 前端接入手册》另做)、对象级/功能级细粒度权限、多租户、API 网关、
服务间调用、Keycloak 组(groups)。
2. 已定决策(用户 2026-08-25 裁决)
| 问题 | 决策 |
|---|---|
| 后端形态 | 资源服务器:前端自取 token,后端只验 Bearer(手册模型;BFF 弃) |
| 角色来源 | Keycloak realm 角色(realm_access.roles);角色→端映射表在我们库里 |
| 岗位与名册 | 岗位本地 + 名册同步(Admin API,provisioning 服务账号)+ 首登即建 |
| 新依赖 | 批准 jose(§12.5 #3;与手册同一标准件,零传递依赖) |
| 路由归属 | 进契约:RouteContract.access 必填,一处中间件按注册表判 |
| 岗位可空? | 不可空:种哨兵岗 NONE / 未分配,~40 处 staff.position.* 一行不动 |
| 退役契约 | 留一轮:标 @deprecated,后端回 410(新增错误码 gone),web 不动 |
3. 架构与请求链
浏览器(web-portal / web-console,PKCE,前端手册)
│ Authorization: Bearer <RS256 JWT,aud ∋ KEYCLOAK_CLIENT_ID>
▼
apps/api server.ts 鉴权中间件(app 层,唯一一处)
① 取凭据:Authorization 头;GET 且无头时认 ?ticket=(60s 票据,专给 EventSource / <a href> 下载)
② org.authenticate(token) → Principal
验签(jose,本地)→ claims → Identity → 名册即时更新(JIT)→ 角色 × role_access → ends
票据路:org.principalOf(sub)(从库现算,不信票据里的任何权限信息)
③ 路由注册表:lookup(method, path) → access(public / any / 端 / 端数组)
④ 判定:public 放行;无凭据或无效 → 401;账号已停用 → 403;端不在 principal.ends → 403
⑤ c.set('principal') —— 路由用 principal.staff;findByAccount(c.get('account')) 那一族全部退役
分工沿用 ADR-010:中间件在 app 层,身份判定读 org 出口。治理路由里约 100 处
guard(fn, action) / requireGovern(fn) 整体删除 —— 端级控制由中间件按注册表统一做,
「一处拦住,新路由自动生效」(与 /api/gov/* 广播中间件同一套路);注册表覆盖有测试兜底(§9)。
4. 契约变更(packages/contracts)
4.1 新增
// api/http.ts
export const SURFACES = ['design', 'ops', 'workbench'] as const;
export const surfaceSchema = z.enum(SURFACES);
export type Surface = z.infer<typeof surfaceSchema>;
/** public:无需凭据;any:登录即可,不看端;端名 / 端名数组:能进其中任一端即可 */
export type RouteAccess = 'public' | 'any' | Surface | readonly Surface[];
export type RouteContract<…> = {
readonly method: HttpMethod;
readonly path: string;
readonly access: RouteAccess; // ← 必填;漏了 `satisfies RouteContract` 编译不过
…
};
export const API_ERROR_CODES = [ …既有七个…, 'gone' ] as const; // 410:已退役
// org.ts
export const staffSchema = z.object({
id: staffIdSchema, // 值 = Keycloak sub(手册硬约束 8);类型不变
account: z.string().min(1), // = preferred_username,只作显示
name: z.string().min(1), // = name claim,缺则回退 account
email: z.string().optional(),
positionId: positionIdSchema, // 未指派 = 哨兵岗 NONE 的 id
state: staffStateSchema.default('active'),
roles: z.array(z.string()).default([]), // realm 角色快照(签发时;已滤噪声角色)
});
export const principalSchema = z.object({
staff: staffSchema,
position: positionSchema,
roles: z.array(z.string()),
ends: z.array(surfaceSchema),
});
export type Principal = z.infer<typeof principalSchema>;
export const roleAccessSchema = z.object({ role: z.string().min(1), ends: z.array(surfaceSchema) });
/** 某人的在途工作(只读)—— 形状 = 原 disableCheckSchema;本体改名,旧名留作 @deprecated 别名 */
export const inFlightWorkSchema = z.object({ staffId, blocked, openTasks, openCards, openApprovals });
/** @deprecated 下个迭代删 */
export const disableCheckSchema = inFlightWorkSchema;
新路由(api/workbench.ts / api/governance.ts):
| 路由 | access | 说明 |
|---|---|---|
meRoute GET /api/me → principalSchema |
any | 我是谁、我能进哪几个端;ends 为空也 200(前端据此说「无端可进」) |
authTicketRoute GET /api/auth/ticket → { ticket, expiresAt } |
any | 60s 票据,见 §9.3 |
listRoleAccessRoute GET /api/gov/access/roles → { roles: roleAccess[], knownRoles: string[] } |
ops | knownRoles = 名册上见过的角色 ∪ 已配置的角色 |
setRoleAccessRoute PUT /api/gov/access/roles/:role body { ends } → roleAccess |
ops | 锁死不变量不过 → 409 conflict |
deleteRoleAccessRoute DELETE /api/gov/access/roles/:role → { ok } |
ops | 同上不变量 |
syncStaffRoute POST /api/gov/staff/sync → { configured:false, message } | { configured:true, inserted, updated, disabled, total } |
ops | 未配 provisioning 就如实说 |
setStaffPositionRoute PUT /api/gov/staff/:staffId/position body { positionId } → staff & { position } |
ops | 指派岗位;哨兵岗 NONE 也可指(= 取消指派) |
staffInFlightRoute GET /api/gov/staff/:staffId/in-flight → inFlightWorkSchema |
ops | 原停用前置检查降为只读信息 |
4.2 修改
listStaffRoute出参形状不变(staffSchema.extend({ position })+positions);positions含 NONE 与 TEST。- 全部既有路由加
access(归属表见 §4.4)。
4.3 退役(§12.4 三步之第二步:本轮标 @deprecated,后端回 410 gone;下个迭代删)
loginRoute logoutRoute sessionRoute createStaffRoute disableStaffCheckRoute disableStaffRoute
listGrantsRoute setGrantsRoute;类型 sessionSchema Session grantSchema Grant grantObjectSchema
GrantObject grantActionSchema GrantAction GRANT_ACTIONS GRANT_ACTION_LABEL governanceFunctionSchema
GovernanceFunction GOVERNANCE_FUNCTIONS GOVERNANCE_FUNCTION_LABEL disableCheckSchema DisableCheck。
退役路由的 access: 'public'(410 不需要凭据,也不泄漏任何东西);后端由一份 RETIRED_ROUTES 清单统一注册
410 处理器:{ code: 'gone', message: '已随统一鉴权中心退役:<替代路由或说明>' }。
web 引用这些名字的地方(features/auth/LoginPage.tsx、shared/gov/surfaces.ts、三处 sessionRoute)
本轮不动:类型仍在,编译照过;运行时登录页无法登录 —— 这是「只做后端」的既定后果,
前端按《前端接入手册》改造时一并清掉。
4.4 路由归属全表
规则:写归所属端;读被两端消费的标两端(按 web 各区实际消费定;壳层只在 ops 台面拉任务与对象)。
| access | 路由 |
|---|---|
public |
healthRoute · ingestMailRoute(外界边界,ADR-010 原判)· 退役八条 |
any |
meRoute · authTicketRoute · GET /api/stream(SSE,字面路径注册,注册表以 EXTRA_ROUTES 显式补入) |
workbench |
listTasks taskDetail replyWorkOrder replyChatCard markCommRead saveTable openAssetFile say listMessages markMessageRead answerMessage confirmTriggerPending listMinutes createMinute appendResolution registerResolution draftReport listReports submitReport listApprovalsForMe decideApproval listRegistrationTriggers fireRegistration |
design |
流程包:saveProcess setProcessState deleteProcessVersion deleteProcessFlow editProcessVersion validateProcess · 审批模板:listApprovalTemplates upsertApprovalTemplate deleteApprovalTemplate · 触发器定义:upsertTrigger · 活动库:listActivities installActivity · 工具箱:listTools registerTool · 知识库:listActivityMemory · 大模型:listLlmProviders listLlmInstances upsertLlmInstance deleteLlmInstance probeLlmDraft probeLlmInstance · 测试台:testRun testRunActivity testStop testCurrent testActivityState listTestLogs testLogDetail deleteTestLog clearTestLogs uploadFile downloadFile traceOfWorkflow |
ops |
任务:listGovTasks controlTask resumeTask reassignTask govTaskLog listApprovals · 驾驶舱:govMetrics · 工作流运行:toggleTrigger setTriggerConfirm listNodeOwners setNodeOwner · 员工:listWorkGroups saveWorkGroup setStaffPosition syncStaff staffInFlight · 角色→端:listRoleAccess setRoleAccess deleteRoleAccess |
['design','ops'] |
listStaff(测试台选负责人)· listProcessPackages listTriggers listChannels(运营的工作流区也看)· listLedgerObjects ledgerByActivity browseLedger(结构归设计、行数据归运营)· govWorkflow(任务区 + 测试台) |
5. org 模块(L1,原地重写)
5.1 contract.ts
export type Identity = { // 从 token 或 Admin API 归一出来的一个人
readonly sub: string;
readonly account: string; // preferred_username
readonly name: string; // name ?? account
readonly email?: string;
readonly roles: readonly string[]; // 已滤噪声角色
};
export type OrgService = {
/* 身份 */
authenticate(token: string): Promise<Principal>; // 抛 TokenRejectedError / AccountDisabledError
principalOf(staffId: StaffId): Promise<Principal | undefined>; // 票据路;停用抛 AccountDisabledError
registerIdentity(identity: Identity): Promise<Staff>; // JIT 入口;测试也用它造人
/* 名册与岗位 */
listStaff(): Promise<{ staff: StaffWithPosition[]; positions: Position[] }>;
getStaff(staffId: StaffId): Promise<StaffWithPosition>;
staffOfPosition(positionCode: string): Promise<StaffWithPosition[]>; // 只回 active;NONE 恒回 []
setPosition(staffId: StaffId, positionId: PositionId): Promise<StaffWithPosition>;
syncRoster(): Promise<RosterSyncOutcome>;
inFlightOf(staffId: StaffId): Promise<InFlightWork>;
/* 角色 → 端 */
listRoleAccess(): Promise<{ roles: RoleAccess[]; knownRoles: string[] }>;
setRoleAccess(role: string, ends: readonly Surface[]): Promise<RoleAccess>; // 抛 AccessLockoutError
deleteRoleAccess(role: string): Promise<void>; // 抛 AccessLockoutError
staffWithEnd(surface: Surface): Promise<StaffWithPosition[]>; // 通知收件人:能进该端的活跃员工
/* 工作组(ADR-011,原样) */
saveWorkGroup(...) / workGroupOf(...) / bindingOf(...)
ensureSeeded(): Promise<void>;
};
export class TokenRejectedError extends Error { } // → 401,message 是手册 4.4 的人话
export class AccountDisabledError extends Error { } // → 403
export class AccessLockoutError extends Error { } // → 409
export class StaffNotFoundError / PositionNotFoundError // 沿用
删除:login logout resolveSession setPassword loadSession authorize listGrants setGrants
createStaff checkDisable disableStaff findByAccount;IssuedSession CreateStaffInput
AccountTakenError DisableBlockedError;常量 DEFAULT_LOGIN_ACCOUNT STAFF_SEED;
纯函数 isAuthorized actionCovers objectCovers;adapter 出口 hashPassword verifyPassword
newSessionToken createSessionRepository createGrantRepository objectKeyOf;表 sessionTable grantTable。
5.2 port.ts
/** 验签 —— 实现在 adapter(jose);测试注入本地密钥对 */
export type TokenVerifier = { verify(token: string): Promise<VerifiedClaims> }; // 失败抛 TokenRejectedError
export type VerifiedClaims = {
readonly sub: string;
readonly preferred_username: string;
readonly name?: string;
readonly email?: string;
readonly realm_access?: { readonly roles?: readonly string[] };
};
/** 名册来源 —— Keycloak Admin API;app 未配 provisioning 时注入 undefined */
export type RosterSource = { listUsers(): Promise<readonly RosterUser[]> };
export type RosterUser = Identity & { readonly enabled: boolean };
export type StaffRepository = {
list(): Promise<Staff[]>;
findById(id: StaffId): Promise<Staff | undefined>;
findByAccount(account: string): Promise<Staff | undefined>; // 仅播种/测试账号策略用
listByPosition(positionId: PositionId): Promise<Staff[]>;
insert(staff: Staff): Promise<Staff>;
updateIdentity(id: StaffId, patch: { account; name; email?; roles; lastSeenAt? }): Promise<void>;
setPosition(id: StaffId, positionId: PositionId): Promise<void>;
setState(id: StaffId, state: Staff['state'], syncedAt: Date): Promise<void>;
};
export type RoleAccessRepository = {
list(): Promise<RoleAccess[]>;
upsert(row: RoleAccess): Promise<RoleAccess>;
remove(role: string): Promise<void>;
};
// PositionRepository / WorkGroupRepository / InFlightWorkProbe / Clock / IdGenerator 沿用
删除:SessionRepository PasswordHasher GrantRepository;StaffRepository.passwordHashOf/setPasswordHash。
5.3 internal/domain/(纯函数,零 IO)
| 文件 | 内容 |
|---|---|
claims.ts |
identityOf(claims): Identity —— name ?? preferred_username;roles 滤掉 /^default-roles-/、offline_access、uma_authorization |
ends.ts |
endsOf(roles, table): Surface[](并集,按 SURFACES 顺序);assertNotLockedOut(table, activeStaffRoles):名册为空 → 通过;否则至少一名活跃员工的角色命中一个含 ops 的行,否则抛 AccessLockoutError(没人能进运营中心,这张表就再也改不回来) |
roster-diff.ts |
diffRoster(remote: RosterUser[], local: Staff[]) → { insert: Identity[]; update: {id, patch}[]; setState: {id, state}[] };远端 enabled=false 或不在远端 → disabled;远端 enabled 而本地 disabled → active;字段没变的不出现在 update |
test-account.ts |
isTestAccount(account, list)(不区分大小写);soloGroupOf(staff):SOLO_GROUP_POSITIONS(PM/BD/FAE/PRM/OP)全指向本人,id wg-solo-<account> |
in-flight.ts |
原 disable-check.ts,buildInFlightWork |
seed-data.ts |
POSITION_SEED(五业务岗 + TEST + NONE / 未分配)、DEFAULT_ROLE_ACCESS(admin → 三端,user → workbench)、SOLO_GROUP_POSITIONS;不再有 STAFF_SEED |
5.4 internal/usecase/org-service.ts 要点
OrgServiceDeps = { verifier: TokenVerifier; roster?: RosterSource; staff; positions; roleAccess; workGroups; inFlight: InFlightWorkProbe; clock; ids; testAccounts: readonly string[] } —— 口令 / 会话 / 授权三组端口与
seedPassword 全部退役。
authenticate(token):verifier.verify→identityOf→registerIdentity→ 读role_access→Principal。state由同步说了算,JIT 不碰它(插入时active):本地disabled即使 token 有效也抛AccountDisabledError(撤权在同步之后即时生效;Keycloak 恢复了就再同步一次)。registerIdentity:按 sub 找;没有 → 插入(岗 NONE,roles快照)并施加测试账号策略;有 → 账号 / 姓名 / 邮箱 / 角色任一变了才写;last_seen_at60s 内不重写(避免每请求一次写)。principalOf(staffId):查库现算;停用抛错;不存在回undefined。syncRoster:roster未注入 →{ configured: false };否则diffRoster后逐条落库,回报告。setRoleAccess/deleteRoleAccess:先算新表,assertNotLockedOut(新表, 活跃员工角色快照),再写。staffWithEnd(surface):活跃员工中endsOf(roles) ∋ surface。ensureSeeded:岗位(含 NONE / TEST)→role_access表为空时播缺省 → 对testAccounts里已在册的人 施加测试账号策略。幂等,不覆盖人工改动。
5.5 internal/adapter/
| 文件 | 内容 |
|---|---|
keycloak-token-verifier.ts |
createKeycloakTokenVerifier({ issuer, audience, getKey }):jwtVerify(token, getKey, { algorithms: ['RS256'], issuer, audience, clockTolerance: 30 });getKey 由 app 给 createRemoteJWKSet(certs, { cooldownDuration: 30_000, cacheMaxAge: 600_000, timeoutDuration: 5_000 }),测试给 createLocalJWKSet;jose 错误按手册 4.4 译成人话后包成 TokenRejectedError;claims 经 zod 校验(sub / preferred_username 必有) |
keycloak-admin-roster.ts |
createKeycloakRoster({ url, realm, provisioning: { clientId, secret } }):master realm client_credentials 取管理 token(提前 60s 过期缓存在闭包里);GET /admin/realms/{realm}/users?first&max=100&briefRepresentation=false 分页到底;每人 GET /users/{id}/role-mappings/realm/composite;归一成 RosterUser |
org-repository.ts |
Drizzle 仓储:staff / position / role_access / work_group |
org-schema.ts |
见 §6 |
删除:password.ts;__tests__/password.test.ts authorization.test.ts(测的对象没了,随代码一起删,不是绕门禁)。
6. 数据模型与迁移 db/migrations/0032_keycloak_identity.sql
-- 身份归统一鉴权中心(ADR-012):会话表与岗位授权表退役;员工 id 从此就是 Keycloak sub
drop table if exists sys.session;
drop table if exists sys.position_grant;
alter table sys.staff
drop column if exists password_hash,
add column if not exists email text,
add column if not exists roles jsonb not null default '[]'::jsonb,
add column if not exists last_seen_at timestamptz,
add column if not exists synced_at timestamptz;
create table if not exists sys.role_access (
role text primary key,
design boolean not null default false,
ops boolean not null default false,
workbench boolean not null default false,
updated_at timestamptz not null default now()
);
-- 种子人不是 Keycloak 的人,id 不是 sub,账号还会与 Keycloak 用户名撞唯一索引:清掉。
-- 他们名下的运行数据由 `reset-runs` 清(迁移不碰运行表)。
delete from sys.work_group;
delete from sys.staff;
sys.staff.account唯一索引保留(Keycloak 用户名本就唯一)。sys.position不动;哨兵岗NONE由ensureSeeded播(不写进迁移,与其他岗位同一条路)。reset-runs不清staff/role_access/position/work_group(它们是配置,不是运行数据)。
7. 身份、名册与测试账号
- 服务账号也是人:
preferred_username = service-account-<clientId>的 token 与普通人同一条路 JIT 建档 —— e2e 与试跑用的 AI 账号就是它(手册 §10:自动化不用人的账号)。 - 测试账号策略:
JCAI_TEST_ACCOUNTS(逗号分隔的 Keycloak 用户名,缺省空)。命中者在首次建档与播种 两个时机都施加:岗位若为 NONE 则置 TEST + 建独立工作组(所有业务岗指向本人)。ADR-011 语义原样。 - 名册同步是手动按钮(运营中心 · 员工 · 同步),不做定时(记 debt)。
process.OrgDirectory.positions()(engines 装配)滤掉NONE:流程包不该声明它。
8. 角色 → 端
- 表
sys.role_access,行 = 一个 realm 角色;缺省(表为空时播)admin → design+ops+workbench,user → workbench—— 与手册两个前端「用户端要 user、管理端要 admin」一致。 principal.ends= 其roles命中的行的并集;没有任何行命中 →[](/api/me仍 200,别的路由 403)。- 新角色在 Keycloak 建;这边
knownRoles把名册上见过的角色列出来供配置,配置前它等于「无端可进」。 - 锁死不变量见 §5.3。
9. 中间件、注册表、票据、错误语义(app 层)
9.1 注册表 app/route-registry.ts
buildRouteRegistry(routes: readonly RouteContract[], extra: readonly { method; path; access }[]):path的:name段匹配[^/]+,其余字面;lookup(method, path)字面精确命中优先于参数段命中。- 来源:
import * as contracts from '@contracts',取形状为{ method, path, access, output }的导出EXTRA_ROUTES = [{ GET /api/stream, any }]。
- 覆盖测试:
app.routes(Hono 自带)里每条已注册路由(除 Hono 内部ALL)都能在注册表查到; 查不到即测试红 —— fail-closed。
9.2 中间件 app/server.ts
access = registry.lookup(method, path) ?? 'any' // 未知路由:先鉴权再 404,与今天一致
if access === 'public' → next
bearer = Authorization: Bearer …
ticket = method === 'GET' ? query.ticket : undefined
无凭据 → 401 { unauthenticated, '缺少 Bearer token' }
bearer → principal = org.authenticate(bearer) // TokenRejectedError → 401(人话)
ticket → sub = verifyTicket(ticket) (坏/过期 → 401 '票据无效或已过期')
principal = org.principalOf(sub) ?? 401
AccountDisabledError → 403 { forbidden, '账号已在鉴权中心停用;若已恢复,请运营中心同步一次名册' }
access !== 'any' && required ∩ principal.ends = ∅
→ 403 { forbidden, '进入「设计中心」需要授权;你的角色(user)可进:工作台', detail: { need, ends } }
c.set('principal', principal) → next
AppEnv.Variables 改为 { principal: Principal | undefined };account 变量删除。
路由里 me(c.get('account')) / findByAccount(...) 一律改 c.get('principal')!.staff
(中间件已保证非公开路由上必有;公开路由不读它)。
9.3 票据 app/ticket.ts
- 只为两条带不了 Authorization 头的路:
EventSource('/api/stream')与<a href="…&raw=1" download>。 GET /api/auth/ticket(持 Bearer)→v1.<sub>.<expUnix>.<hmac-b64url>,HMAC-SHA256,密钥 =HKDF(JCAI_SECRET_KEY, 'jcai-ticket'), 有效 60s;只在 GET 的?ticket=上认,POST/PUT/DELETE 带票据一律当无凭据。- 权限不在票据里:中间件拿 sub 走
principalOf从库现算,停用 / 改角色即时生效。 - 无状态、不单次:60s 可重放窗换零状态表;写进 ADR-012 与 debt。
9.4 退役路由
RETIRED_ROUTES 清单统一注册 410 处理器(code: 'gone'),消息点名替代路:登录 → 统一鉴权中心;
/api/session → /api/me;建人 → 名册同步;停用 → 在 Keycloak 停用后同步;岗位授权 → 角色→端。
10. 配置、启动自检、Keycloak 侧前置
10.1 app/config.ts
| 变量 | 必填 | 用途 |
|---|---|---|
KEYCLOAK_URL KEYCLOAK_REALM KEYCLOAK_CLIENT_ID |
启动必填(main.ts 缺一即拒绝启动;loadConfig 本身不抛,集成测试可不配) |
issuer / JWKS / aud |
KEYCLOAK_PROVISIONING_CLIENT_ID KEYCLOAK_PROVISIONING_CLIENT_SECRET |
可选 | master realm 服务账号,只要 view-users;缺省 → 同步按钮如实回未配置 |
JCAI_TEST_ACCOUNTS |
可选 | 测试账号策略(§7) |
JCAI_SECRET_KEY |
沿用 | 票据 HMAC 派生 |
JCAI_SEED_PASSWORD |
删除 | 口令没了 |
本服务不配 KEYCLOAK_CLIENT_SECRET(只验不调,手册 13.2)。AppConfig 增
keycloak?: { url; realm; clientId; issuer; jwksUrl; provisioning?: { clientId; secret } }、testAccounts: string[]。
10.2 启动(main.ts)
boot():配置缺 → 抛明文错误退出;checkKeycloakConnection(拉 .well-known/openid-configuration,
issuer 与本地拼的不一致 → 打印期望 / 实际 / 排查指引,exit(1),手册第 5 步);通过后打印
✓ Keycloak 连通 issuer=… client=…。createContainer(config, overrides?: { tokenVerifier?; roster? }):
缺省按配置装 jose 远程 JWKS 与 Admin roster;无 keycloak 配置时装一个恒拒的 verifier
(TokenRejectedError('未配置 KEYCLOAK_*')),集成测试走用例不走 HTTP,不受影响。
10.3 Keycloak 侧前置(管理员执行;涉及写操作先向人确认)
- 本服务 client:建议复用
api-gateway(四个里唯一的统一入口,web-portal已挂其 audience mapper); 否则新建 bearer-only client(不需要 secret)。KEYCLOAK_CLIENT_ID填它。 web-console补指向本服务 clientId 的 audience mapper(手册 13.3「两个前端都要配」)。- provisioning 服务账号(master realm,手册 §11.1;本服务只读,授
view-users即可)。 - e2e / 试跑服务账号 client
bpos-e2e:serviceAccountsEnabled,realm 角色admin(测试台在设计端、 答卡在工作台),服务账号用户 firstName 设「AI」(显示名),挂本服务 audience mapper。 - 团队成员账号与角色;要更多角色(如
designer/operator)在 realm 建,再到运营中心配端。
11. 连带改动
| 处 | 改动 |
|---|---|
app/container.ts |
org 装配换新 deps(createDeferredInFlightProbe 保留,在途探针仍要);会话 / 口令 / 授权仓储的装配删除;message.org.governanceStaff → org.staffWithEnd('ops') |
app/wiring/engines.ts |
死信通知收件人 → staffWithEnd('ops');OrgDirectory.positions() 滤 NONE |
packages/contracts/src/api/index.ts |
导出新路由与 SURFACES / surfaceSchema / RouteAccess |
app/http-env.ts |
Variables: { principal: Principal | undefined };account 删除 |
app/routes/workbench.ts |
删 login/logout/session;加 /api/me /api/auth/ticket |
app/routes/governance.ts |
删 requireGovern 与员工建/停/授权四条;加 sync / position / in-flight / access 六条 |
app/routes/governance-ops.ts |
删 guard 工厂与全部 denied 行 |
app/routes/desk*.ts registration.ts log-lines.ts |
findByAccount → principal.staff |
app/session-cookie.ts app/set-password.ts |
删除;apps/api/package.json 去 set-password 脚本,加 jose |
tools/e2e-a2.mjs |
login() 改 KEYCLOAK_E2E_CLIENT_ID/SECRET client_credentials,请求头带 Bearer;断言 /api/me.ends 含 design 与 workbench |
三份 *.it.test.ts |
ensureSeeded() 后用 registerIdentity({ sub:'it-chaihao', account:'chaihao', name:'柴浩', roles:['admin'] }) + setPosition(TEST) 造人 |
docs/adr/ADR-012-keycloak-identity.md |
新立:决策 / 分工 / 票据取舍 / 撤权时效 / 名册与岗位 |
docs/编码规范.md |
§1 表加 jose;§4 「鉴权定调」改为「统一鉴权中心(Keycloak)资源服务器;中间件在 app 层,身份判定读 org 出口;权限只到端」;§14 列 ADR-012 |
modules/org/CONTRACT.md |
重写 |
docs/debt.md |
鉴权段重写(见 §13) |
CLAUDE.md |
铁规矩 5 环境变量列表加 KEYCLOAK_*;铁规矩 6 「AI 账号(密码…)」改「AI 服务账号(client_credentials)」;现状快照加一条 |
api-report/ |
pnpm api:check --update |
12. 测试策略
| 层 | 测什么 | 怎么测 |
|---|---|---|
| domain | identityOf 五种 claims 形状(缺 name / 缺 roles / 噪声角色 / 服务账号);endsOf 并集与顺序;assertNotLockedOut 空名册通过 / 摘掉最后一个 ops 拒 / 换到另一角色仍有 ops 过;diffRoster 增 / 改 / 停 / 恢复 / 无变化;测试账号策略;路由匹配(字面优先、:param、方法不同不混) |
零 mock |
| adapter | verifier:本地 generateKeyPair('RS256') + createLocalJWKSet(Keycloak 是外界,替身合规);RS256 过、HS256 拒、alg:none 拒、错 iss 拒、错 aud 拒、过期 30s 内过 / 超出拒、错 kid 拒、缺 sub 拒;每种拒回的 message 是人话;票据:签 / 验 / 过期 / 篡改 / 错版本 |
jose 本地密钥 |
| usecase | 内存仓储:JIT 建人 / 改名 / 角色变更写快照 / 60s 不重写 last_seen;停用抛 403 型错误;syncRoster 未配置回 configured:false;锁死;staffWithEnd;ensureSeeded 幂等 + 表非空不覆盖 |
内存实现 |
| app | 小 Hono + 假 verifier:public 放行 / 无凭据 401 / 坏 token 401 带人话 / 端不够 403 带 detail / any 只要登录 / 票据 GET 过、POST 拒 / 退役路由 410 / 未知路由先 401 后 404;注册表覆盖:app.routes ⊆ 注册表 |
假 verifier 注入 |
| 门禁 | pnpm gates 全绿;api-report 刷新 |
— |
| e2e | tools/e2e-a2.mjs 任一套件跑通(需 Keycloak 侧前置 1 与 4 就位) |
真 Keycloak |
13. 风险、取舍与欠账(进 docs/debt.md)
- 撤权时效 ≤ 5 分钟(token 寿命,手册 13.4):改角色要等下一枚 token;停用经同步后即时 403。
- 票据 60s 可重放窗(无状态换来的);绑定 sub、只认 GET。
- 名册同步是手动按钮;没配 provisioning 就只有首登即建。
- web 待按《前端接入手册》接入:登录页 / Bearer / 401 跳转 / SSE 与下载改走票据 / 入口亮灯改读
me.ends; 退役契约与SURFACE_FNS那一刻一并清。 - 退役契约下个迭代删(三步之第三步)。
groups未用;若日后按部门过滤数据,需 group membership mapper。- aud mapper 未配 → 全线 401:401 的 message 会点名「给发 token 的那个 client 加 audience mapper」。
14. 实施边界
- 不动:
apps/web/**、activities/**、process/message等模块的 port 形状(只改 app 层注入的实现)。 - 分支:
feat/keycloak-auth;提交按承包工单粒度,五道门禁以退出码验。