文档:ADR-012 统一鉴权中心;编码规范鉴权定调改为资源服务器 + 权限只到端;org CONTRACT.md 重写;debt 鉴权段;CLAUDE.md 铁规矩 5/6

This commit is contained in:
xcs
2026-08-25 18:32:23 +08:00
parent 9848e2f735
commit aa18701130
5 changed files with 114 additions and 53 deletions
+13 -4
View File
@@ -1309,6 +1309,12 @@
用户那一单误存的三份已删,工作成果现摆 1 份、点开即那份 .docx。5 测。
顺手清两笔:`wake.test.ts` 的 `as unknown as`(§12.5 禁止清单,lint 红)、
`wake` 契约的 api-report 快照(上一轮没入库,api:check 一直红)。
- **统一鉴权中心(Keycloak)接入,只做后端**(2026-08-25,ADR-012):资源服务器 —— 后端只验 Bearer JWT
(`jose`,RS256 / iss / aud 本地验,不回源);`Staff.id` = `sub`,首登即建、Admin API 同步名册;
**权限只到端**:`sys.role_access` 一行一个 realm 角色,运营中心可改(锁死不变量:改完得有人还能进运营中心);
路由归属写在契约 `access` 上,app 层一处中间件统一判 401/403,治理路由里约 100 处守卫整体删除,
覆盖测试 fail-closed;SSE 与下载走 60 秒票据;口令 / 会话表 / 岗位授权退役(八条路由回 410,契约留一轮)。
web 待按前端手册接入,接入前登不进。
## 文档权威地图
@@ -1318,6 +1324,8 @@
| | `docs/产品设计.md` | 三面一体的产品形态与决策记录 |
| | `docs/活动开发规范.md` | **开发活动照这份**(2026-08-02 第三版,按四轴重组:解耦机制/Harness 契约/Loop/活动怎么写):基座读数与机检纪律、门与闸、五类卡、剧本 steps、建模十纪律、三常备案例与验收口径(released+被拒0)、**排障速查表**;§8 = 待实现 |
| | `docs/HTML报告生成规范.md` | **报告模版照这份**(2026-08-02 从开发规范拆出):一份 HTML 三个身份(件/表单/归档件)、**报告与卡的关系与同源纪律**、绑定语言全表、编辑态(闸/孪生件/增删行/红带)、PRD 平移法、陷阱表 |
| | `docs/前端对接说明-统一鉴权中心.md` | **前端接统一鉴权中心照这份**(ADR-012):要改哪些文件、`/api/me` 与端、401/403/410、SSE 与下载的票据 |
| | `docs/API文档-鉴权与人员.md` | 鉴权 / 人员 / 角色→端接口的人话版 + 全部路由归属总表;契约不一致时以 `packages/contracts` 为准 |
| | `docs/debt.md` | 欠账台账(只增,销账划线) |
| | `docs/adr/` · 各模块 `CONTRACT.md` | 架构决策与模块级不变量 |
| 业务样例(活) | `docs/MVP示例工作流.html` | 七节点 SOP + 判定表 + 台账域 —— 开发活动的业务素材 |
@@ -1333,10 +1341,11 @@
3. **§12.5 禁止清单**:no `any` / `as unknown as` / `@ts-ignore`;domain 零 IO;
不删测试;不动门禁配置;不绕写三分/两缝。
4. **mock 只许出现在外界**(邮件通道、外部系统的录制回放替身);系统内部机制不得 mock。
5. **密钥只从环境读**:`JCAI_DEEPSEEK_API_KEY`(模型)、`JCAI_SEED_PASSWORD`(种子口令)、
`JCAI_SECRET_KEY`(凭证密封);不入源码、不入包、不入留痕;E2E 永不回退开发库。
6. **试跑一律用 AI 账号**(密码 `jcai-dev-2026`),绝不派到用户工作台;测完
`pnpm --filter @app/api reset-runs` 清运行数据(清跑出来的,留配置好的)。
5. **密钥只从环境读**:`JCAI_DEEPSEEK_API_KEY`(模型)、`JCAI_SECRET_KEY`(凭证密封 + 票据派生)、
`KEYCLOAK_URL` / `KEYCLOAK_REALM` / `KEYCLOAK_CLIENT_ID`(启动必填)、`KEYCLOAK_PROVISIONING_CLIENT_*`(名册同步,可选);
不入源码、不入包、不入留痕;E2E 永不回退开发库。
6. **试跑一律用 AI 服务账号**(Keycloak 里 `bpos-e2e` 的服务账号,client_credentials 拿 token;`JCAI_TEST_ACCOUNTS` 列上它的用户名),
绝不派到用户工作台;测完 `pnpm --filter @app/api reset-runs` 清运行数据(清跑出来的,留配置好的)。
7. **安装恒新增**:活动/工具改了内容必须升版本号,同版重装是 no-op(静默丢失,踩过)。
8. **界面不撒谎**:状态如实(未绑就写未绑)、错误带厂商原话、不可用不摆假数字。
+36 -41
View File
@@ -1,51 +1,46 @@
# module: org
职责: 员工与岗位:账号、口令与登录会话(ADR-010)、岗位授权(客体三类 × 动作三种)、
停用前置检查、岗位在岗人员查询(负责人四策略解析的候选集来源)、
工作组(ADR-011:起单时的岗位绑定)、种子数据播种
不负责: 负责人解析算法本身(见 process)、任务与卡的在途判定(经 InFlightWorkProbe 端口由 app 注入)、
鉴权中间件与 cookie 的读写(app 层:§4「中间件在 app 层,身份判定读 org 出口」)
职责: 员工与岗位(ADR-012):验签后的人是谁(名册即时更新)、他能进哪几个端(角色→端)、
岗位指派与在岗人员查询(负责人四策略解析的候选集来源)、名册同步(Admin API)、
在途工作(只读)、工作组(ADR-011)、种子(岗位 + 缺省角色→端)
不负责: 验签之外的 HTTP 语义(401/403 由 app 层中间件按路由归属判)、
负责人解析算法本身(见 process)、任务与卡的在途判定(经 InFlightWorkProbe 由 app 注入)
公开出口: index.ts
- 类型:`OrgService` `StaffWithPosition` `CreateStaffInput` `IssuedSession` 与 @contracts 的
`Staff` / `Position` / `Grant` / `GrantObject` / `GrantAction` / `DisableCheck` / `Session` 转出
- 错误:`StaffNotFoundError` `PositionNotFoundError` `AccountTakenError` `DisableBlockedError`
- 端口类型:`PositionRepository` `StaffRepository` `GrantRepository` `SessionRepository`
`WorkGroupRepository` `PasswordHasher` `InFlightWorkProbe` `Clock` `IdGenerator`
- 类型:`OrgService` `StaffWithPosition` `Identity` 与 @contracts 的 `Staff` / `Position` / `Principal` /
`RoleAccess` / `RosterSyncOutcome` / `InFlightWork` / `Surface` / `WorkGroup` / `WorkGroupBinding` 转出
- 错误:`TokenRejectedError`(→401)`AccountDisabledError`(→403)`AccessLockoutError`(→409)
`StaffNotFoundError` `PositionNotFoundError`
- 端口类型:`TokenVerifier` `VerifiedClaims` `RosterSource` `RosterUser` `IdentityPatch`
`PositionRepository` `StaffRepository` `StaffRecord` `RoleAccessRepository`
`WorkGroupRepository` `InFlightWorkProbe` `Clock` `IdGenerator`
- 用例工厂:`createOrgService(deps: OrgServiceDeps): OrgService`
- 纯函数:`isAuthorized` `actionCovers` `objectCovers`
- 常量:`POSITION_SEED` `STAFF_SEED` `DEFAULT_LOGIN_ACCOUNT`
- adapter 工厂(供 app 装配):`createPositionRepository` `createStaffRepository`
`createGrantRepository` `createSessionRepository` `createWorkGroupRepository` `objectKeyOf`
- 口令散列(供 app 装 `PasswordHasher`):`hashPassword` `verifyPassword` `newSessionToken`
- Drizzle 表(供 app 迁移与 join):`positionTable` `staffTable` `grantTable` `sessionTable`
`workGroupTable`
- 纯函数:`identityOf` `isNoiseRole` `normalizeRoles` `endsOf` `assertNotLockedOut` `normalizeEnds` `buildInFlightWork`
- 常量:`POSITION_SEED` `NONE_POSITION_CODE` `TEST_POSITION_CODE` `DEFAULT_ROLE_ACCESS`
- adapter 工厂(供 app 装配):`createKeycloakTokenVerifier`(jose,配置型 `KeycloakVerifierOptions`)
`createKeycloakRoster`(Admin API,配置型 `KeycloakRosterOptions`)
`createPositionRepository` `createStaffRepository` `createRoleAccessRepository` `createWorkGroupRepository`
- 错误译文(供 app 拼 401 message):`explainJoseError`(jose 错误 → 人话,手册 4.4)
- Drizzle 表:`positionTable` `staffTable` `roleAccessTable` `workGroupTable`
层级: L1(编码规范 §4)
依赖: @contracts;@shared/db(Drizzle 句柄与 sys schema)
port:`InFlightWorkProbe`(app 注入 task / human-tool 的只读用例)、`IdGenerator`
被依赖: process(负责人解析的候选集)、message、interaction、human-tool、report、
app(鉴权中间件与 /api/gov/staff 路由组装)
依赖: @contracts;@shared/db(Drizzle 句柄与 sys schema);jose(仅 adapter)
port:`InFlightWorkProbe`(app 注入 task / human-tool 的只读用例)、`IdGenerator`、
`TokenVerifier`(app 给 jose 远程 JWKS;测试给本地密钥对)、`RosterSource`(可缺省)
被依赖: process(负责人解析的候选集)、message(治理台收件人 = staffWithEnd('ops'))、
app(鉴权中间件与 /api/gov/staff、/api/gov/access 路由组装)
不变量:
- 动作蕴含:治理 ⊃ 写 ⊃ 读;数据客体三级覆盖(域 ⊃ 对象 ⊃ 字段)
- 停用是全有全无:前置检查未过即抛 `DisableBlockedError`,不做部分执行
- `authorize` 对停用员工恒 false
- `ensureSeeded` 幂等:按 code / account 判存在,已存在则跳过,不覆盖人工改动;
唯一例外是**补种口令** —— 已存在但 `password_hash` 为空的员工会被补上初始口令,
否则 ADR-010 之前建的账号永远登不进(这不算覆盖人工改动:空不是改动)
- **登录失败不区分原因**:账号不存在 / 已停用 / 未设口令 / 口令错,一律 `undefined`;
且无论账号是否存在都走一遍散列比对,免得响应时间泄漏账号是否存在
- **明文永不落库**:口令只在 `login` / `setPassword` 的入参上停留一瞬,落库前即散列
- **会话可即刻作废**:服务端表而非签名 cookie —— `logout` 立刻生效;
`disableStaff` 连带 `revokeAllOf`,被停用的人拿旧 cookie 也办不了事
- **工作组是绑定不是策略**(ADR-011):它给出岗位的人选,四策略于是无从发问;
`bindingOf` 只出成员表快照,在岗与否由调用方(process)核 —— 岗位候选集核不了跨岗位的绑定
- `Staff.id` 就是 Keycloak `sub`;`account` 只作显示,永不当键
- `state` 只由名册同步置 `disabled`;`authenticate` / `principalOf` 对 disabled 一律抛 AccountDisabledError,
有效 token 不能把它翻回 active(撤权在同步之后即时生效)
- 名册即时更新只写变了的列;`last_seen_at` 60 秒内不重写
- 角色→端:`setRoleAccess` / `deleteRoleAccess` 先算新表再验锁死不变量(名册为空不算锁死)
- `staffOfPosition` 只回 active;哨兵岗 NONE 恒回 []
- `ensureSeeded` 幂等:岗位按 code 判存在;缺省角色→端只在表空时播;测试账号策略只补不覆盖
- 工作组是绑定不是策略(ADR-011):`bindingOf` 只出成员表快照,在岗与否由调用方核
影响半径: contract 增 `login` / `logout` / `resolveSession` / `setPassword` 与 `IssuedSession`;
port 增 `SessionRepository` / `PasswordHasher`,`StaffRepository` 增两个口令读写方法;
下游只有 app(装配 + 鉴权中间件),depcruise 已反查确认
近变(2026-07-27):模块面**未变**。app 层新增工作组读写路由
(`GET|PUT /api/gov/work-groups`),组装既有的 `workGroupOf` / `saveWorkGroup`
—— ADR-011 落地时欠的那个治理界面就此清账,一人一组的口径由 app 层按 ownerId 保证。
影响半径(2026-08-25,ADR-012):contract / port / index 全面重写 —— 退役 `login` `logout` `resolveSession`
`setPassword` `authorize` `loadSession` `listGrants` `setGrants` `createStaff` `checkDisable`
`disableStaff` `findByAccount` 与 `SessionRepository` `PasswordHasher` `GrantRepository`;
下游 app(中间件 / 装配 / 路由)与 message 装配同批改,depcruise 已反查确认。
+47
View File
@@ -0,0 +1,47 @@
# ADR-012 统一鉴权中心(Keycloak):身份、名册与「角色 → 端」
状态:已接受(2026-08-25)
背景出处:`docs/keycloak-backend-guide.md`(Keycloak 26.7.1 / jose,核实日期 2026-08-12);
设计稿 `docs/superpowers/specs/2026-08-25-keycloak-auth-design.md`。
取代:ADR-010(口令 + 服务端会话)与编码规范 §4 原「不引 OAuth/OIDC」的定调。
## 问题
公司有了统一鉴权中心,本系统还在自建账号口令、服务端会话表与岗位授权(客体三类 × 动作三种)。
人要在两处维护,权限模型也比产品实际需要的细太多 —— 用户只要「不同角色可以动态配置能进哪几个端」。
## 决策
1. **资源服务器,不做 BFF**:前端(`web-portal` / `web-console`,PKCE)自取 token;后端只本地验签
Bearer JWT(`jose`:RS256 写死、校 iss 与 aud、30s 容差、远程 JWKS 缓存 10 分钟 / 冷却 30 秒),
不回源、不 introspect、**不配 client secret**。
2. **身份在 token 里**:`Staff.id` = `sub`;`account` = `preferred_username` 只作显示;
首次见到即建档(JIT),账号 / 姓名 / 邮箱 / 角色变了才写,`last_seen_at` 60 秒内不重写。
3. **权限只到端**:三个端 design / ops / workbench;`sys.role_access` 一行一个 realm 角色,
运营中心可改;一个人的端 = 其角色命中行的并集。缺省 `admin → 三端`,`user → workbench`。
**锁死不变量**:改完至少一名在册活跃员工仍能进运营中心,否则 409。
4. **路由归属进契约**:`RouteContract.access` 必填(public / any / 端 / 端数组);app 层一处中间件
按注册表判 401 / 403;治理路由里约 100 处逐条守卫整体删除;覆盖测试 fail-closed。
5. **名册**:岗位仍是本系统的表(哨兵岗 `NONE / 未分配`,运营中心指派);名册经 Admin API 同步
(master realm 的 provisioning 服务账号,只要 `view-users`)+ 首登即建。`state` 只由同步置
`disabled`(Keycloak 停用或删除),有效 token 不改它 —— 撤权在同步之后即时 403。
6. **票据**:`EventSource` 与 `<a href download>` 带不了 Authorization 头 —— `GET /api/auth/ticket`
换一枚 60 秒 HMAC 票据(`HKDF(JCAI_SECRET_KEY, 'jcai-ticket')`),只在 GET 的 `?ticket=` 上认;
票据里没有权限,中间件拿 sub 回库现算。无状态、不单次:60 秒重放窗换零状态表。
7. **退役**:`login` / `logout` / `session` / 建人 / 停用 / 岗位授权八条路由回 410(新错误码 `gone`),
契约标 `@deprecated` 留一轮(§12.4 三步);`sys.session` `sys.position_grant` `staff.password_hash` drop。
8. **测试账号**:`JCAI_TEST_ACCOUNTS`(Keycloak 用户名)命中者落 TEST 岗 + 独立工作组(ADR-011 原语义);
e2e 与试跑用服务账号(client_credentials),不用人的账号。
## 后果
- 撤角色的时效 ≤ 5 分钟(token 寿命);停用经同步后即时。
- web 需按《前端接入手册》改造(登录 / Bearer / 401 跳转 / SSE 与下载改走票据 / 入口亮灯改读 `/api/me` 的 `ends`),
改造前登不进 —— 这是「只做后端」的既定后果。
- **部署顺序是先跑迁移、再 `reset-runs`**:迁移 0032 清空 `sys.staff` 并连带清掉两张持有身份的
**配置**表(`sys.node_owner`、`sys.trigger` 的确认位);而 `sys.task.owner_id` 这类**无外键**的
运行数据列不受迁移约束,若顺序颠倒,这些运行数据会指着一个已经不存在的身份变成孤儿 ——
`reset-runs` 必须排在迁移之后跑,才能把这批指着旧身份的运行数据一并清掉。
- Keycloak 侧前置(管理员):本服务 client(建议复用 `api-gateway`)、`web-console` 补 audience mapper、
provisioning 服务账号、e2e 服务账号 client(`bpos-e2e`,realm 角色 admin,firstName「AI」)。
- 欠账见 `docs/debt.md`「鉴权」段。
+15 -7
View File
@@ -56,14 +56,22 @@ ADR-011 的「绑定先于策略」随岗位一起退场 —— 工作组快照
`.parallel()` 任一失败即整体失败(失败走向不进 Mastra)、三态聚合投影
(读实例表不读 run 状态)、`node_instance` 投影表照写(发号权在主工程)。
## 鉴权(ADR-010 落地后的已知欠账)
## 鉴权(ADR-012 落地后的已知欠账)
- **登录失败无次数限制**:可无限次试口令。单组织 ≤50 人、内网部署下暂缓;
接入外网前必须补(按账号 + 来源 IP 双维度退避)。
- **无口令强度校验、无首登强制改密**:全员初始口令相同(`JCAI_SEED_PASSWORD`),
谁都没改过就等于没有口令。治理台「员工」面需补:改密 / 重置 / 强制改密标记。
- **`setPassword` 已在 org 出口就位但无路由**:改密只能经播种或直接调用例,界面上没有入口。
- **会话无「登出全部设备」**:`revokeAllOf` 已实现(停用员工时调),但本人无从触发。
- **撤角色时效 ≤ 5 分钟**:改了 Keycloak 里的角色,现有 access token 到期前仍按旧角色算(手册 13.4);
要立即生效在 Keycloak 上 `POST /users/{id}/logout`。停用经名册同步后即时 403。
- **票据 60 秒可重放窗**(无状态换来的):绑定 sub、只认 GET;要单次得加一张已用票据表。
- **名册同步是手动按钮**(运营中心 · 员工 · 同步),没有定时;没配 provisioning 就只有首登即建。
- **web 待按《Keycloak 前端接入手册》接入**:登录页 / Bearer / 401 跳转 / SSE 与下载改走 `GET /api/auth/ticket` /
入口亮灯改读 `GET /api/me` 的 `ends`;接入前登不进。那一刻一并清掉 `SURFACE_FNS` 与退役契约。
- **退役契约下个迭代删**(§12.4 三步之第三步):`loginRoute` `logoutRoute` `sessionRoute` `createStaffRoute`
`disableStaff*Route` `listGrantsRoute` `setGrantsRoute` 与 `sessionSchema` / `grant*` / `governanceFunction*` / `disableCheckSchema`。
- **`groups` 未用**:日后要按部门过滤数据,需 Keycloak 配 group membership mapper。
- **aud mapper 未配即全线 401**:401 的 message 会点名「给发 token 的那个 client 加 audience mapper」,但没有启动期探测。
- **无库环境里,路由覆盖的 fail-closed 保险会静默消失**:`apps/api/src/app/__tests__/route-coverage.it.test.ts`
用 `describe.skipIf(database === undefined)` 跳过(同目录另外三份 it-test 同款,是仓库级惯例)——
没有库的环境里这条测试根本不跑,进程仍 exit 0;正在搭 CI 的人必须先给它一个库,
否则「写了路由处理器却没在契约里声明 access」这类漏洞会在 CI 里悄悄放过。
## 工作组(ADR-011 落地后的已知欠账)
+3 -1
View File
@@ -44,6 +44,7 @@
| 台账自生长 | node-postgres(`pg`)直写(ledger adapter 内) | 运行时 DDL/DML 由声明生成,ORM 静态 schema 不适用 |
| 队列/定时 | pg-boss | PG 原生,先持久后投递、至少一次天然契合 |
| 代码契约校验 | zod | 仓库内 API/DTO |
| 鉴权 | Keycloak(统一鉴权中心)+ `jose` 本地验签 | 资源服务器:RS256 / iss / aud 本地验,不回源;权限只到端(ADR-012) |
| 声明数据校验 | ajv(JSON Schema 2020-12 子集) | 活动声明是运行时数据,不是编译期代码(ADR-003) |
| 测试 | Vitest;集成测试用 Testcontainers(PG) | domain 层测试零 mock |
| 门禁 | tsc / ESLint(flat) / dependency-cruiser / 契约快照 | §11 |
@@ -154,7 +155,7 @@ repo/
- 技能编排属于活动(AM:活动内容物含技能清单)——`skill-engine` 与 loop/flow 同列范式运行器;置于 L4 因两范式依赖它(临场装配/步引用),import 恒向下、免 port。
- 找不到归属的代码,先怀疑模块拆分错了,再考虑 `shared/`;`shared/` 只收无业务语义的纯工具
- 度量八指标(OS 8.3)是 `memory`(任务日志)与 `task`(终态/纠正信号)之上的**只读投影**,落 `report` 的用例,不设独立模块
- 鉴权定调:单组织 cookie session——登录、会话与岗位权限校验中间件在 apps/api 的 app 层实现,权限判定读 `org` 出口;不引 OAuth/OIDC 与多租户机制
- 鉴权定调(ADR-012):统一鉴权中心(Keycloak)资源服务器——后端只验 Bearer JWT;鉴权中间件在 apps/api 的 app 层实现,身份判定读 `org` 出口;**权限只到端**(设计中心 / 运营中心 / 工作台),角色→端由运营中心的表定,路由归属写在契约的 `access` 上;不做多租户
---
@@ -640,6 +641,7 @@ pnpm test
- **ADR-004** 活动 .JS 装载 ABI:单文件 ESM,默认导出 `defineActivity()`;能力全部注入(基座六件句柄),零环境能力;静态审扫描产物 import 与全局引用
- **ADR-005** 工具 `validate` 能力位:调用契约 `capabilities.validate?: true`,预检存在即调(OS 7.3 落地预检第四项)
- **ADR-006** S-FEEL 自研于 `@sfeel`,子集文法冻结于 OS 03;不引第三方 FEEL 引擎
- **ADR-012** 统一鉴权中心(Keycloak):资源服务器本地验签(`jose`),`Staff.id` = `sub`;权限只到端,`sys.role_access` 可改;名册 Admin API 同步 + 首登即建;60 秒票据给 SSE 与下载;口令 / 会话 / 岗位授权退役(取代 ADR-010)
留白(实现期再定,各建 ADR 后方可动工):鉴权会话与凭证加密方案;JSON Schema→zod 转换库选型;活动装载的 worker 隔离硬化;活动记忆向量召回启停;e2e 框架。