Archived
前端对接说明:要改哪些文件、/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
448 lines
29 KiB
Markdown
448 lines
29 KiB
Markdown
# 统一鉴权中心(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`;提交按承包工单粒度,五道门禁以退出码验。
|