From a7dd25f5c4a05db2f68da9a2bf61a35d77bfd28c Mon Sep 17 00:00:00 2001 From: jw Date: Mon, 14 Sep 2026 07:54:30 +0800 Subject: [PATCH] =?UTF-8?q?docs(order-v3):=20#7609=20=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E5=AE=88=E5=8D=AB=E6=94=B9=20fail-closed?= =?UTF-8?q?=E2=80=94=E2=80=94=E7=BC=BA=20role=20claim=20=E7=9A=84=20token?= =?UTF-8?q?=20=E4=B8=8D=E5=86=8D=E7=BB=95=E8=BF=87=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 退单审批 4 端点 + 流团审批 approve/reject 共 6 个端点。 不改请求/响应结构、不新增错误码、不改路由、无 Flyway。 TEST 真实网关前后对照:缺 role claim 由 589531/589545(绕过进业务) 变为 589530/589547(拦下);role=ADMIN(非超管)行为不变。 ⚠️ 行为变更:生产库若有活跃零角色账号在做审批,上线即被拦,需运营侧知情。 Co-Authored-By: Claude Opus 5 (1M context) --- ...losed缺role的token不再绕过角色门-修复-管理后台.md | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 changelogs-v2/2026-09/14_7609_审批角色守卫改failclosed缺role的token不再绕过角色门-修复-管理后台.md diff --git a/changelogs-v2/2026-09/14_7609_审批角色守卫改failclosed缺role的token不再绕过角色门-修复-管理后台.md b/changelogs-v2/2026-09/14_7609_审批角色守卫改failclosed缺role的token不再绕过角色门-修复-管理后台.md new file mode 100644 index 00000000..e9bc854a --- /dev/null +++ b/changelogs-v2/2026-09/14_7609_审批角色守卫改failclosed缺role的token不再绕过角色门-修复-管理后台.md @@ -0,0 +1,183 @@ +--- +schema: "hl-changelog/v2" +ticket: "7609" +title: "退单/流团审批角色守卫改 fail-closed:缺 role claim 的 token 不再绕过角色门" +consumer: "admin" +author: "jw(GIT)" +change_type: "修复" +backend_status: "deployed" +gateway_status: "verified" +frontend_status: "not_required" +frontend_owner: "" +frontend_ref: "" +target_release: "" +verified_at: "" +status_note: "not_required:不改任何请求/响应结构、不新增错误码、不改路由,无 Flyway。契约一直写的就是「仅管理员可处理」,本单只是让代码真的执行它。受影响的只有「零角色 admin 账号」这一种异常账号状态——正常配了角色的用户行为逐字节不变,前端无触点。⚠️ 但这是行为变更:若线上存在活跃的零角色账号且有人在用它做退单/流团审批,上线即被拦(589530 / 589547),需运营侧知情,见下方「上线前须知」。" +updated_at: "2026-09-14" +base: "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` 本就存在,只是此前这条路径拿不到它们。 + +--- + +## 一、背景 + +守卫老实现是: + +```java +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`,两套守卫一个口径): + +```java +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`