From 98ce2966a24e09e98daf23b0f70ada4493ec516e Mon Sep 17 00:00:00 2001 From: xcs Date: Tue, 25 Aug 2026 14:37:24 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=BE=E8=AE=A1:=E7=BB=9F=E4=B8=80=E9=89=B4?= =?UTF-8?q?=E6=9D=83=E4=B8=AD=E5=BF=83(Keycloak)=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=20=E2=80=94=E2=80=94=20=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=20+=20=E8=A7=92=E8=89=B2=E2=86=92?= =?UTF-8?q?=E7=AB=AF=20+=20=E5=90=8D=E5=86=8C=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 推翻 ADR-010 的口令/会话/岗位授权:身份只认 Bearer JWT(jose 本地验签), 权限只到三个端(design/ops/workbench),角色→端一张表运营中心可改; 岗位本地 + 名册经 Admin API 同步;SSE 与下载走 60s 票据;退役契约留一轮回 410。 只做后端,web 不动。四问与三取舍已由用户裁决,记在 §2。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw --- .../specs/2026-08-25-keycloak-auth-design.md | 446 ++++++++++++++++++ 1 file changed, 446 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-25-keycloak-auth-design.md diff --git a/docs/superpowers/specs/2026-08-25-keycloak-auth-design.md b/docs/superpowers/specs/2026-08-25-keycloak-auth-design.md new file mode 100644 index 0000000..b5377db --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-keycloak-auth-design.md @@ -0,0 +1,446 @@ +# 统一鉴权中心(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 + ▼ +apps/api server.ts 鉴权中间件(app 层,唯一一处) + ① 取凭据:Authorization 头;GET 且无头时认 ?ticket=(60s 票据,专给 EventSource / 下载) + ② 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; + +/** 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; + +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; // 抛 TokenRejectedError / AccountDisabledError + principalOf(staffId: StaffId): Promise; // 票据路;停用抛 AccountDisabledError + registerIdentity(identity: Identity): Promise; // JIT 入口;测试也用它造人 + /* 名册与岗位 */ + listStaff(): Promise<{ staff: StaffWithPosition[]; positions: Position[] }>; + getStaff(staffId: StaffId): Promise; + staffOfPosition(positionCode: string): Promise; // 只回 active;NONE 恒回 [] + setPosition(staffId: StaffId, positionId: PositionId): Promise; + syncRoster(): Promise; + inFlightOf(staffId: StaffId): Promise; + /* 角色 → 端 */ + listRoleAccess(): Promise<{ roles: RoleAccess[]; knownRoles: string[] }>; + setRoleAccess(role: string, ends: readonly Surface[]): Promise; // 抛 AccessLockoutError + deleteRoleAccess(role: string): Promise; // 抛 AccessLockoutError + staffWithEnd(surface: Surface): Promise; // 通知收件人:能进该端的活跃员工 + /* 工作组(ADR-011,原样) */ + saveWorkGroup(...) / workGroupOf(...) / bindingOf(...) + ensureSeeded(): Promise; +}; + +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 }; // 失败抛 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 }; +export type RosterUser = Identity & { readonly enabled: boolean }; + +export type StaffRepository = { + list(): Promise; + findById(id: StaffId): Promise; + findByAccount(account: string): Promise; // 仅播种/测试账号策略用 + listByPosition(positionId: PositionId): Promise; + insert(staff: Staff): Promise; + updateIdentity(id: StaffId, patch: { account; name; email?; roles; lastSeenAt? }): Promise; + setPosition(id: StaffId, positionId: PositionId): Promise; + setState(id: StaffId, state: Staff['state'], syncedAt: Date): Promise; +}; +export type RoleAccessRepository = { + list(): Promise; + upsert(row: RoleAccess): Promise; + remove(role: string): Promise; +}; +// 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-` | +| `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-` 的 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')` 与 ``。 +- `GET /api/auth/ticket`(持 Bearer)→ `v1...`,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`;提交按承包工单粒度,五道门禁以退出码验。