From bb22545bdff05704b22d264b9bc400a47bd4802d Mon Sep 17 00:00:00 2001 From: xcs Date: Tue, 25 Aug 2026 19:42:48 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=AF=E7=94=B1=E8=A1=A8=E5=AD=97=E9=9D=A2?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E4=B9=9F=E6=8E=80=E6=A1=8C=E5=AD=90;?= =?UTF-8?q?=E6=AD=BB=E4=BF=A1=E6=B2=A1=E6=94=B6=E4=BB=B6=E4=BA=BA=E6=97=B6?= =?UTF-8?q?=E5=87=BA=E5=A3=B0;=E7=A5=A8=E6=8D=AE=E6=A8=AA=E5=90=91?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E4=B8=8E=E3=80=8C=E6=9D=83=E9=99=90=E5=8F=AA?= =?UTF-8?q?=E5=88=B0=E7=AB=AF=E3=80=8D=E5=86=99=E8=BF=9B=20ADR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **I1:字面路由重复注册仍是静默覆盖 —— 与参数侧的掀桌子不对称。** 两条**字面**契约同方法同路径撞上时,后写覆盖先写,命中谁全看 `Object.values(contracts)` 的顺序。参数模版撞了会抛(理由是「命中谁只看声明顺序 = 静默判错权限」),而字面这一半 没设防:同一个病、同一个后果。把守卫从 59 处收成 1 处之后,注册表是**唯一**的授权 事实源,它的自检不该有半边缺口。`size` 一并从 `routes.length` 改成 `literal.size + patterns.length`(重复被拒之后两者相等,但语义上它说的是「表里有几条」)。 `lookup` 上补一句:它只认 HTTP_METHODS 那五个,**归一是调用方的职责**;测里钉了 `lookup('HEAD', …)` 仍是 undefined —— 将来有人把中间件那行归一挪走,这条分工还在。 **顺手两条:** - 死信通知的收件人为空时不出声(`deadLetters.report`),而 container 的 governanceStaff 对同一件事会往 stderr 喊一句 —— 后者的理由(把「出事了」无声丢掉最糟)在前者同样成立。 - ADR-010 状态行补「已被 ADR-012 取代」:ADR-012 说了取代,但先翻到 ADR-010 的人看不到。 **两条不修,写进文档:** - 票据的**横向范围**进 ADR-012 与 debt:票据里没有用途,60 秒内它等于持票人的 **全部读权限** —— 凭一枚为下载附件取的票据,同一分钟里读得了整张客户主数据。 不是提权(那些读权限他本来就有),风险在留痕面(URL 走浏览器历史 / 反代日志 / Referer)。 收敛做法记进 debt:签发时把用途绑进票据(scope ∈ {stream, file}),或中间件对认票据的 路径设白名单。 - 「权限只到端」的影响面进 ADR-012 的「后果」:design 现在同时意味着装活动、跑测试台、 传文件、探大模型;design|ops 意味着 `GET /api/gov/data/:domain/:object` 能翻整张客户 主数据(**从前要 data:read 才行**)。半年后有人问「为什么设计的人看得到客户手机号」, ADR 里得有这句话在。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw --- .../src/app/__tests__/route-registry.test.ts | 23 ++++++++++++++++ apps/api/src/app/route-registry.ts | 27 ++++++++++++++++--- apps/api/src/app/wiring/engines.ts | 11 +++++++- docs/adr/ADR-010-auth-session.md | 2 +- docs/adr/ADR-012-keycloak-identity.md | 10 +++++++ docs/debt.md | 6 +++++ 6 files changed, 73 insertions(+), 6 deletions(-) diff --git a/apps/api/src/app/__tests__/route-registry.test.ts b/apps/api/src/app/__tests__/route-registry.test.ts index 3c43f7f..734cf4c 100644 --- a/apps/api/src/app/__tests__/route-registry.test.ts +++ b/apps/api/src/app/__tests__/route-registry.test.ts @@ -36,6 +36,13 @@ describe('路由注册表', () => { it('未知路由 → undefined', () => { expect(registry.lookup('GET', '/api/nope')).toBeUndefined(); }); + it('只认五个 HTTP 方法:HEAD 查不到 —— 归一是中间件的职责,不是表的', () => { + // 框架把 HEAD 改写成 GET 再分发,而请求行上的方法仍是 HEAD; + // 表只按 HTTP_METHODS 装,所以这里如实回 undefined。 + // 这条钉住的是分工:哪天有人把中间件里那行归一挪走,鉴权就漏了,而这里仍然该是 undefined。 + expect(registry.lookup('HEAD', '/api/gov/tasks')).toBeUndefined(); + expect(registry.lookup('GET', '/api/gov/tasks')).toBe('ops'); + }); it('数组归属原样回', () => { expect(registry.lookup('GET', '/api/gov/staff')).toEqual(['design', 'ops']); }); @@ -124,6 +131,22 @@ describe('建表自检 —— 两条模版能撞就不许建', () => { ]), ).not.toThrow(); }); + it('同一条字面路由注册两遍 → 抛(后写的会静默盖掉先写的)', () => { + expect( + reg([ + { method: 'GET', path: '/api/gov/staff', access: 'ops' }, + { method: 'GET', path: '/api/gov/staff', access: 'design' }, + ]), + ).toThrow(/注册了两遍/); + }); + it('同路径不同方法照旧不算撞', () => { + expect( + reg([ + { method: 'GET', path: '/api/gov/staff', access: 'ops' }, + { method: 'POST', path: '/api/gov/staff', access: 'ops' }, + ]), + ).not.toThrow(); + }); it('真实 95 条契约路由建得起来(没有暗撞)', () => { expect(() => buildRouteRegistry(routesOf(Object.values(contracts)))).not.toThrow(); }); diff --git a/apps/api/src/app/route-registry.ts b/apps/api/src/app/route-registry.ts index 625d772..2fdf58c 100644 --- a/apps/api/src/app/route-registry.ts +++ b/apps/api/src/app/route-registry.ts @@ -16,7 +16,11 @@ export type RegisteredRoute = { export type RouteRegistry = { readonly size: number; - /** 按请求的方法与实际路径查归属;查不到回 undefined(中间件按 any 处置,随后 404) */ + /** + * 按请求的方法与实际路径查归属;查不到回 undefined(中间件按 any 处置,随后 404)。 + * **只认 `HTTP_METHODS` 那五个方法,归一是调用方的职责** —— 比如 HEAD:框架把它 + * 改写成 GET 再分发,这里照字面查必然 miss,中间件必须先归一再来查。 + */ lookup(method: string, path: string): RouteAccess | undefined; /** 按注册时的路径模版查(覆盖测试用):`has('GET', '/api/gov/tasks/:taskId/log')` */ has(method: string, pathPattern: string): boolean; @@ -72,8 +76,22 @@ export function buildRouteRegistry(routes: readonly RegisteredRoute[]): RouteReg const literal = new Map(); const patterns: Array<{ method: string; pattern: string; re: RegExp; access: RouteAccess }> = []; for (const r of routes) { - if (r.path.includes(':')) patterns.push({ method: r.method, pattern: r.path, re: matcherOf(r.path), access: r.access }); - else literal.set(`${r.method} ${r.path}`, r.access); + if (r.path.includes(':')) { + patterns.push({ method: r.method, pattern: r.path, re: matcherOf(r.path), access: r.access }); + continue; + } + /* + * 同方法同路径注册两遍:后写的静默盖掉先写的,命中谁全看 `Object.values(contracts)` + * 的顺序 —— 与两条模版相撞是同一个病、同一个后果(静默判错权限)。 + * 注册表是唯一的授权事实源,它的自检不该有半边缺口:字面这一半照样掀桌子。 + */ + const key = `${r.method} ${r.path}`; + if (literal.has(key)) { + throw new Error( + `路由归属表冲突:${key} 注册了两遍 —— 后写的会静默盖掉先写的,等于静默判错权限。删掉重复的那一条。`, + ); + } + literal.set(key, r.access); } /* @@ -96,7 +114,8 @@ export function buildRouteRegistry(routes: readonly RegisteredRoute[]): RouteReg } return { - size: routes.length, + // 实际可查询的条数(重复被拒之后它与 routes.length 相等 —— 但语义上它说的是表里有几条) + size: literal.size + patterns.length, lookup(method, path) { const exact = literal.get(`${method} ${path}`); if (exact !== undefined) return exact; diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index bba18d1..28ece17 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -2620,7 +2620,16 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { report: async (input) => { writeLog(`[trigger] 起单失败入死信:${input.triggerId} —— ${input.reason}`); // 收件人 = 能进运营中心的人(触发器的启停归运营;ADR-012 权限只到端) - for (const person of await deps.org.staffWithEnd('ops')) { + const recipients = await deps.org.staffWithEnd('ops'); + // 一个收件人都没有:这条通知这一次谁也收不到 —— 把「出事了」无声丢掉最糟, + // 所以出声(与 container.ts 的 governanceStaff 同一口径) + if (recipients.length === 0) { + writeLog( + '[trigger] 死信通知这一次没有收件人 —— 没有能进运营中心的活跃员工。' + + '去运营中心的「角色 → 端」把某个 realm 角色配上 ops,或让持该角色的人先登录一次。', + ); + } + for (const person of recipients) { await deps.message.notify({ staffId: person.id, title: `触发器起单失败:${input.triggerId}`, diff --git a/docs/adr/ADR-010-auth-session.md b/docs/adr/ADR-010-auth-session.md index f2480d8..7eed4bd 100644 --- a/docs/adr/ADR-010-auth-session.md +++ b/docs/adr/ADR-010-auth-session.md @@ -1,6 +1,6 @@ # ADR-010 鉴权会话方案 -状态:已接受(2026-07-27) +状态:已接受(2026-07-27);已被 ADR-012 取代(2026-08-25) 背景出处:编码规范 §4「鉴权定调:单组织 cookie session —— 登录、会话与岗位权限校验中间件在 apps/api 的 app 层实现,权限判定读 `org` 出口;不引 OAuth/OIDC 与多租户机制」; §12 留白清单「鉴权会话与凭证加密方案 —— 实现期再定,各建 ADR 后方可动工」。 diff --git a/docs/adr/ADR-012-keycloak-identity.md b/docs/adr/ADR-012-keycloak-identity.md index 452adde..a4516da 100644 --- a/docs/adr/ADR-012-keycloak-identity.md +++ b/docs/adr/ADR-012-keycloak-identity.md @@ -28,6 +28,11 @@ 6. **票据**:`EventSource` 与 `` 带不了 Authorization 头 —— `GET /api/auth/ticket` 换一枚 60 秒 HMAC 票据(`HKDF(JCAI_SECRET_KEY, 'jcai-ticket')`),只在 GET 的 `?ticket=` 上认; 票据里没有权限,中间件拿 sub 回库现算。无状态、不单次:60 秒重放窗换零状态表。 + **横向范围要说明白:票据里既然没有用途,它在这 60 秒里就等于持票人的全部读权限** —— + 凭一枚为下载附件取的票据,同一分钟内照样读得了 `GET /api/gov/data/:domain/:object`(整张客户主数据)。 + 这**不是提权**(该用户本就有这些读权限),风险在**留痕面**:票据走在 URL 上, + 浏览器历史、反向代理日志、`Referer` 都可能把它留下来。收敛做法见 `docs/debt.md` 鉴权段 + (签发时把用途绑进票据,或中间件对认票据的路径设白名单)。 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 原语义); @@ -36,6 +41,11 @@ ## 后果 - 撤角色的时效 ≤ 5 分钟(token 寿命);停用经同步后即时。 +- **权限只到端,粒度就到此为止** —— 这是有意的收敛,但半年后有人问起时,答案得在这儿写着: + `design` 现在同时意味着装活动、跑测试台、传文件、探大模型;`design|ops` 意味着 + `GET /api/gov/data/:domain/:object` 能翻整张客户主数据(**从前要 `data:read` 这类客体授权才行**)。 + 产品要的就是「不同角色能进哪几个端」,再细的粒度没有需求撑着;真要收窄, + 该走的是「给那几条路由单列一个端」,不是把客体 × 动作那套授权请回来。 - web 需按 `docs/前端对接说明-统一鉴权中心.md` 改造(登录 / Bearer / 401 跳转 / SSE 与下载改走票据 / 入口亮灯改读 `/api/me` 的 `ends`), 改造前登不进 —— 这是「只做后端」的既定后果。 - **部署顺序是先跑迁移、再 `reset-runs`**:迁移 0032 清空 `sys.staff` 并连带清掉两张持有身份的 diff --git a/docs/debt.md b/docs/debt.md index a02264d..87952f1 100644 --- a/docs/debt.md +++ b/docs/debt.md @@ -61,6 +61,12 @@ ADR-011 的「绑定先于策略」随岗位一起退场 —— 工作组快照 - **撤角色时效 ≤ 5 分钟**:改了 Keycloak 里的角色,现有 access token 到期前仍按旧角色算(手册 13.4); 要立即生效在 Keycloak 上 `POST /users/{id}/logout`。停用经名册同步后即时 403。 - **票据 60 秒可重放窗**(无状态换来的):绑定 sub、只认 GET;要单次得加一张已用票据表。 +- **票据没有用途,横向范围 = 持票人的全部读权限**:它走在 **URL** 上(浏览器历史 / 反代日志 / + `Referer` 都可能留下),而中间件只认「这枚票据是谁的」,不认「取它是为了干什么」—— + 一枚为下载附件取的票据,同一分钟内照样读得了 `GET /api/gov/data/:domain/:object`(整张客户主数据)。 + **不是提权**(那些读权限他本来就有),风险在留痕面。收敛做法二选一: + ①签发时把用途绑进票据(`v1....`,scope ∈ {stream, file},中间件按路由核 scope); + ②中间件对「认票据」的路径设白名单(只有 `/api/stream` 与那两条下载路由认 `?ticket=`)。 - **名册同步是手动按钮**(运营中心 · 员工 · 同步),没有定时;没配 provisioning 就只有首登即建。 - **web 待按《Keycloak 前端接入手册》接入**:登录页 / Bearer / 401 跳转 / SSE 与下载改走 `GET /api/auth/ticket` / 入口亮灯改读 `GET /api/me` 的 `ends`;接入前登不进。那一刻一并清掉 `SURFACE_FNS` 与退役契约。