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
xcsandClaude Fable 5 094386f02c 文档:前端对接说明与 API 文档(鉴权 / 人员 / 角色→端);计划加 Task 12 按契约核对两份文档
前端对接说明:要改哪些文件、/api/me 与端、401/403/410 处置、SSE 与下载的 60s 票据、
运营中心员工页的新接口、接入完成判据。API 文档:通则与状态码、每条新接口的出入参与示例、
退役八条的替代、全部 95 条契约路由的端归属总表、schema 附录、curl 实录。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw
2026-08-25 15:25:30 +08:00

448 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 统一鉴权中心(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 新增
```ts
// 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:已退役
```
```ts
// 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`
```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`
```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`
```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` |
| `docs/前端对接说明-统一鉴权中心.md` `docs/API文档-鉴权与人员.md` | 设计阶段按本 spec 写好(用户 2026-08-25 追加);实现落地后用契约重新生成路由归属总表核对(计划 Task 12) |
## 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`;提交按承包工单粒度,五道门禁以退出码验。