This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/docs/superpowers/specs/2026-08-25-keycloak-auth-design.md
T
xcsandClaude Fable 5 98ce2966a2 设计:统一鉴权中心(Keycloak)后端接入 —— 资源服务器 + 角色→端 + 名册同步
推翻 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
2026-08-25 14:37:24 +08:00

29 KiB
Raw Blame History

统一鉴权中心(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_at 60s 内不重写(避免每请求一次写)。
  • 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 侧前置(管理员执行;涉及写操作先向人确认)

  1. 本服务 client:建议复用 api-gateway(四个里唯一的统一入口,web-portal 已挂其 audience mapper); 否则新建 bearer-only client(不需要 secret)。KEYCLOAK_CLIENT_ID 填它。
  2. web-console 补指向本服务 clientId 的 audience mapper(手册 13.3「两个前端都要配」)。
  3. provisioning 服务账号(master realm,手册 §11.1;本服务只读,授 view-users 即可)。
  4. e2e / 试跑服务账号 client bpos-e2e:serviceAccountsEnabled,realm 角色 admin(测试台在设计端、 答卡在工作台),服务账号用户 firstName 设「AI」(显示名),挂本服务 audience mapper。
  5. 团队成员账号与角色;要更多角色(如 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;提交按承包工单粒度,五道门禁以退出码验。