文件
hl-api-changelog/changelogs-v2/2026-09/14_7609_审批角色守卫改failclosed缺role的token不再绕过角色门-修复-管理后台.md
T
jw和Claude Opus 5 a7dd25f5c4
changelog-filename-gate / validate (push) Failing after 2s
docs(order-v3): #7609 审批角色守卫改 fail-closed——缺 role claim 的 token 不再绕过角色门
退单审批 4 端点 + 流团审批 approve/reject 共 6 个端点。
不改请求/响应结构、不新增错误码、不改路由、无 Flyway。

TEST 真实网关前后对照:缺 role claim 由 589531/589545(绕过进业务)
变为 589530/589547(拦下);role=ADMIN(非超管)行为不变。

⚠️ 行为变更:生产库若有活跃零角色账号在做审批,上线即被拦,需运营侧知情。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 07:54:30 +08:00

8.4 KiB
原始文件 Blame 文件历史

schema, ticket, title, consumer, author, change_type, backend_status, gateway_status, frontend_status, frontend_owner, frontend_ref, target_release, verified_at, status_note, updated_at, base
schema ticket title consumer author change_type backend_status gateway_status frontend_status frontend_owner frontend_ref target_release verified_at status_note updated_at base
hl-changelog/v2 7609 退单/流团审批角色守卫改 fail-closed:缺 role claim 的 token 不再绕过角色门 admin jw(GIT) 修复 deployed verified not_required not_required:不改任何请求/响应结构、不新增错误码、不改路由,无 Flyway。契约一直写的就是「仅管理员可处理」,本单只是让代码真的执行它。受影响的只有「零角色 admin 账号」这一种异常账号状态——正常配了角色的用户行为逐字节不变,前端无触点。⚠️ 但这是行为变更:若线上存在活跃的零角色账号且有人在用它做退单/流团审批,上线即被拦(589530 / 589547),需运营侧知情,见下方「上线前须知」。 2026-09-14 dev-v3

退单 / 流团审批角色守卫改 fail-closed(修复)

服务: hl-order-service-v3(WithdrawApprovalGuard) PR: #7657 Issue: #7609 日期: 2026-09-14 影响范围: 退单审批 4 个端点 + 流团审批 approve/reject 共 6 个端点的判权结果;不涉及请求/响应结构、错误码集合、路由与数据库


⚠️ 关键变化

🔴 缺 role claim 的管理端 token,此前能绕过退单/流团审批的角色门,现已拦下。

token 的 role claim 退单审批 approve 流团审批 approve
ADMIN / SUPER_ADMIN 放行(不变) 放行(不变)
CUSTOMIZER 等非管理员 589530(不变) 589547(不变)
完全缺 role claim 589531 → 589530 589545 → 589547

🟢 正常配了角色的用户,行为逐字节不变。 没有新增错误码——589530 / 589547 本就存在,只是此前这条路径拿不到它们。


一、背景

守卫老实现是:

String role = AdminContextUtil.getRole();
if (role == null || role.isEmpty()) {
    return;                       // 注释称其为「非请求上下文放行」
}

但 AdminContextUtil.getRole() 在真的没有 HTTP 请求上下文时是抛 IllegalStateException,根本走不到这条 return。

它返回 null 的唯一情况是:有 HTTP 请求,但网关没透传 X-Admin-Role——AdminAuthInterceptor 对角色头缺失既不报错也不 setAttribute("role")。而零角色 admin 账号正常登录签发出来的就是这种 token(登录链路没有任何一处在 roleKey 为 null 时拒绝登录)。

所以那条 return 的唯一可达路径就是缺陷本身,形成权限倒置:

没有角色,比拥有一个低权限角色更有权力。

注释把两种语义完全不同的 null 混为一谈,这才是根因——不是有人写漏了判断。


二、变更接口清单

# 接口 方法 路径 变更类型 说明
1 退单审批列表 GET /v3/admin/order/group-batch/withdraw/page 修复 缺 role claim 由放行改为 589530
2 退单审批详情 GET /v3/admin/order/group-batch/withdraw/:approvalId 修复 同上
3 退单审批通过 POST /v3/admin/order/group-batch/withdraw/:approvalId/approve 修复 同上
4 退单审批驳回 POST /v3/admin/order/group-batch/withdraw/:approvalId/reject 修复 同上
5 流团审批通过 POST /v3/admin/order/group-batch/disband/:approvalId/approve 修复 缺 role claim 由放行改为 589547
6 流团审批驳回 POST /v3/admin/order/group-batch/disband/:approvalId/reject 修复 同上

请求结构、响应结构、错误码集合、路由均无变化。


三、接口详情

六个端点的请求与响应一字未改,仅判权结果变化,逐端点差异见上表。改法(形状对齐同仓已有的正确参照 SettlementWriteGuard.hasRole,两套守卫一个口径):

try {
    role = AdminContextUtil.getRole();
} catch (IllegalStateException noRequestContext) {
    log.warn("[{}] 审批角色守卫取不到 HTTP 请求上下文,按系统态放行……", errorCode.code(), noRequestContext);
    return;
}
if (role == null || role.isBlank()) {
    throw new BusinessException(errorCode);   // 有请求却没角色 = 外部请求,fail-closed
}

为什么那条 catch 要打日志

它是本次新增的路径——改前 getRole() 抛的 ISE 会一路冒到调用方(结果是 500),压根没有「系统态放行」这回事。保留它只为与 SettlementWriteGuard 同口径,但当前 0 个调用方走得到(6 个调用点全是同步 MVC 入口,反查全仓无 @Async / 监听器 / 定时任务调用方;同链路的 MQ/事件走 approveInTx,不经过本守卫)。

所以这条 WARN 平时不会有噪音,一旦出现就是要被人看见的。


四、契约约束与正确调用方式

  • 调用这六个端点必须持有 ADMIN 或 SUPER_ADMIN 角色的有效管理端 token。
  • 这是角色门不是权限码门,不依赖 group-batch:* 权限种子。
  • 前端无需改动:正常登录的用户 token 必带 role claim。

五、数据库行为

无 DDL、无数据迁移、无 Flyway 脚本。


六、边界行为

  • 无 HTTP 请求上下文(真系统态):放行并打 WARN。当前无调用方走到。
  • 有请求但角色头缺失 / 空白:拒绝(本单修复点)。
  • 非管理员角色:拒绝(行为不变)。

七、不影响范围

  • 请求/响应结构、错误码集合、路由:零变化。
  • GroupBatchPermissionGuard(46 个端点走权限码):此前就是 fail-closed,本单未动。
  • HouseWriteGuard:仍是 fail-open(#7609 的 G-2,需独立 PR)。同一个零角色 token 目前仍能打穿房务写端点。
  • 小程序端(consumer: mp):本单端点均为管理后台端点,无影响。

八、测试环境已验证

构建归属:TEST order-v3 部署 82560872c(面板 git-backend 确认),合并后为 dev-v3 42cd9c61d。

前后对照(同端点、同 token 形态、真实网关)

token 的 role claim 端点 改前(2026-09-13 实测) 改后(2026-09-14 实测)
完全缺 role claim 退单 approve 589531 退单申请不存在 ⚠️ 绕过进业务 589530 仅管理员可处理退单审核 🔒
完全缺 role claim 流团 approve 589545 流团申请不存在 ⚠️ 绕过 589547 仅管理员可处理流团审批 🔒
CUSTOMIZER 两者 589530 / 589547 589530 / 589547(不变)
ADMIN(非超管) 退单列表 — 200 成功
ADMIN(非超管) 两者 approve 589531 / 589545 589531 / 589545(不变,进业务)

放行侧用的是非超管 ADMIN —— user-service 对 SUPER_ADMIN 短路放行,只用超管验会掩盖问题。

本地全量单测

检出 Tests / Failures / Errors / Skipped
基线 23bb56933 10310 / 0 / 13 / 52
合并后 82560872c 10367 / 0 / 13 / 52

Failures / Errors / Skipped 三项零变化,错误类名集合 diff 逐字节相同(13 条全部为 Testcontainers 无 Docker 环境所致)。

变异测试(证明断言不是恒绿)

变异 结果
把 fail-closed 的 throw 改回 return 12 条红
整段还原成改前代码 红的恰好是类 javadoc 标为「新增覆盖/新增行为」的 4 条;标为「行为不变的回归护栏」的 3 条全绿
删掉 GroupBatchDisbandApprovalService 的两行守卫 只有 Service 新增的 2 条红,Controller 切片 7/7 仍绿

⚠️ 上线前须知(运营侧)

这是行为变更。本单只查了 TEST(43 个 admin 账号 / 1 个零角色 / 且 status=DELETED 登不上),生产库有没有活跃零角色账号尚未确认。

若生产上真有人在用零角色账号做退单/流团审批,上线即被拦。这正是本单要的结果,但不能静默上线——「建了账号忘配角色」是极常见的管理状态,且角色绑定被移除后下次登录即进入该状态。


十、相关文档

  • Issue #7609(含 TEST 真实网关的改前实打证据、三套守卫 fail-open/closed 对照表)
  • PR #7657