WIP-357 · token-exchange 三例补跑 + R2/R5 复核 · v1

任务卡
- 目标:把 344 代跑遗留的「受限 JWT 契约路径零覆盖」补上,R2/R5 由 BLOCKED 转为有实测结论
- 范围:只做只读 GET + 契约内写留痕(destination_tokens 审计表);不改任何后端代码、不改业务数据
- 批准来源:小冈 R1 派工 WIP-357(2026-09-16 08:3x)/义父连轴令
- 验收标准:三例有 HTTP 码与回显;R2/R5 按四态给结论;发现登记到条并指到行

一、执行环境与证据五要素

要素值
时间戳2026-09-16 08:42
目标 host100.64.1.230:8089(staging)
容器 / 主机kailash-staging-app @ VM-0-13-ubuntu
数据库kailash_staging @ kailash-staging-pg(PG 用户 yos)
commit⚠️ 缺失——容器内 /app 不是 git 仓库(git rev-parse 失败)⇒ 见 §五

凭证来源(如实登记):staging APP_ENV=dev 且未配置 JWT_SECRET ⇒ 取默认值 dev-secret-change-me;本岗在容器内用 create_token(1, ['admin'], 0) 自签 admin 测试凭证(user_id=1,is_test=true,token_version=0),未使用任何既有账号密码、未修改任何用户数据。

证据归档:docs/delivery/evidence/ba/344/357-token-exchange-run-2026-09-16-0842.json / 357-run-log-2026-09-16-0842.txt(JWT 已脱敏,保留前 12 位可比对)。

前置真值(跑前查库,确保判据可触发):

id名称status用途
1[测试]冈仁波齐-转山基线载体activeR2 正例 / R5 的 A
2测试目的地(机位fixture)inactiveR4 403
3[测试]雪线之外-白标B端实例activeR5 的 B(Q1 造数已插 ✅)
第二 active 目的地(id=3)已在位 ⇒ 我 00:3x 提的 335 造数缺口已补,R5 首次具备两个 active 目的地,可实测。

二、三例结果(契约 chain-identity-v1 §3)

#用例判据实测结论
S0凭证自证 GET /auth/me200200(user_id=1,identities 含 admin)✅ PASS
R2正例 destination_id=1(active)200 + data.token 非空 + token 解出 destination_id=1200;payload = {user_id:1, identities:[admin,staff,tourist], token_version:0, exp, destination_id:"1", scope:"read"}✅ PASS
R3不存在 destination_id=999404「目的地不存在」404 {"error":{"code":404,"message":"目的地不存在"}}✅ PASS
R4未启用 destination_id=2(inactive)403「目的地未启用,不得签发访问凭证」403 {"error":{"code":403,"message":"目的地未启用,不得签发访问凭证"}}✅ PASS

⇒ token-exchange 端点本身:三例全过,契约 §3 逐条对上(含「不静默降级」硬条款——inactive 给 403 而非回落默认租户)。


三、🔴 R5 跨租户:FAIL(红线级) —— 受限 JWT 的 destination_id 服务端从不消费

3.1 实测

用例判据(356 底稿 §三)实测结论
R5A(=1) 受限 JWT 请求 ?destination_id=3 ⇒ 403「与凭证目的地不符」🔴 HTTP 200,{"code":0,"data":[]}FAIL
R5b 对照A 凭证请求 ?destination_id=1 ⇒ 200200(返回 1 号目的地商户集)PASS(对照组成立)
R5c 反证A 凭证不传参 ⇒ 应走凭证默认目的地,返回 A 数据 200🔴 409「存在多个启用目的地,请求必须携带明确的目的地上下文」FAIL

R5b 成立而 R5/R5c 失败 ⇒ 不是凭证无效、不是环境问题,是校验逻辑本身没生效。

3.2 根因(静态定位到行,非推测)

backend/app/tenant.py:45 读 token_dest = user.get("destination_id"),tenant.py:61 据此抛 403。

但 user 这个 dict 里从来没有 destination_id 这个键:

  1. backend/app/security.py:41-42 —— get_current_user 的 SQL 是

SELECT id, openid, phone, nickname, status, token_version, is_test FROM users WHERE id=$1

⇒ 七列,无 destination_id;

  1. security.py:37 取到完整 payload 后,只用掉 user_id 与 token_version,其余 claim 全部丢弃(destination_id 就此丢失);
  2. 全仓唯一给 user dict 注入 destination_id 的写法不存在——grep 'user["destination_id"]' / user\.get("destination_id") 全仓仅 tenant.py:45 一处读、零处写;auth_chain.py:149 的 "destination_id" 是响应体字段,不是 user dict。

⇒ tenant.py:61 的跨租户 403 是死代码,永不触发。

运行时双证互为反证:R5 该拦没拦(200)、R5c 该放行默认却走了多目的地 409 ⇒ 两条只可能同时由「token_dest 恒为 None」解释。

3.3 影响面

3.4 与既有结论的关系

既有结论本次
356 底稿 §四「token-exchange 只声明不授权,不换凭证带 requested 可绕过」——静态推演✅ 本次升级为运行时实证 + 根因到行
a端核对表 §二·B「B2 跨租户 403 对 admin 恒不触发」一致,且本次坐实根因在 get_current_user 丢 claim
WIP-358 小齐「Q2 批2 签就位、调用方不传参 ⇒ 403 零生效」完全同族:能力在库里、调用方没用上
三次发作同一个模式 ⇒ 建议派工岗把「签了/定义了,但调用方没用上」列为独立检查项(比对「声明点」与「消费点」计数),而不是每次等验收撞见。

四、建议(本岗只给方案,不动后端代码)

  1. 修法(面向后端属主 阿仁/小齐):在 get_current_user 里把 payload 的 destination_id(及 scope)并入返回的 user dict(或新增 get_current_user_with_dest 依赖供隔离端点使用)。一行级改动,但会激活 tenant.py:61 这条此前从未执行过的分支 ⇒ 必须配套回归(否则「修好的 403」可能误伤既有 admin 全局视图)。
  2. 配套回归清单(建议由修复方逐条实测并贴 EXIT 码):①无 destination_id 的老 token 仍按现状通行(向后兼容)②受限 JWT 请求自身目的地 200 ③受限 JWT 请求他目的地 403 ④不带参走凭证默认 200(不再 409)⑤admin 全局 JWT(无锚点)+ 多 active 目的地 = 409 现状保持。
  3. 契约侧:建议契约持有席在 chain-identity-v1.md §3 增一句「受限 JWT 的 destination_id 由服务端在鉴权阶段注入请求上下文」,把「谁消费这个 claim」写明——本次缺陷的根因正是契约只规定了签发、没规定消费。
  4. R5 判据建议升为 335 第五幕必过项(此前因环境不具备而仅登记)。

五、证据链缺口(报 R1)

⇒ 建议:镜像构建时写入 /app/COMMIT(或 GIT_SHA 环境变量),否则任何在容器上跑的验收,证据链都缺一角,严格按剧本 §8 应判 BLOCKED。


六、本岗边界声明


结论汇总:R2 ✅ PASS|R3 ✅ PASS|R4 ✅ PASS|R5 🔴 FAIL(红线级,根因到行)|R5c 🔴 FAIL(同一根因)
上报:@小齐(后端属主·WIP-324 作者)/@契约持有席(chain-identity-v1 补消费侧条款)/@R1(证据链 commit 缺口 + JWT_SECRET 观察项 + R5 升必过项)
署名:波波 2026-09-16 08:4x