docs(order-v3): #7609 审批角色守卫改 fail-closed——缺 role claim 的 token 不再绕过角色门
changelog-filename-gate / validate (push) Failing after 2s
changelog-filename-gate / validate (push) Failing after 2s
退单审批 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>
这个提交包含在:
@@ -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`
|
||||
在新工单中引用
屏蔽一个用户