Ver Fonte

feat: 静态代码审计,修复逻辑bug和安全漏洞

BaiLuoYan há 3 semanas atrás
pai
commit
d4355fa0da

+ 149 - 221
audit-report.md

@@ -1,304 +1,232 @@
-# 第 13 轮深度审计报告
+# 深度审计报告 · Round 14
 
-审计对象:`perms-system/server`(不含测试代码)
-审计维度:逻辑一致性、并发/竞态、资源管理、数据完整性、安全漏洞、边界、DB 性能、僵尸代码、接口契约
-业务量级假设:数千级用户 / 数十级产品 / 单产品 < 100 角色 / 单次 SyncPermissions < 1k 权限 / 单用户 10~30 角色 / 峰值 QPS 数百
-
----
-
-## R12 闭环复核(均已修复,本轮抽检通过)
-
-| R12 条目 | 状态 | 落地证据 |
-| --- | --- | --- |
-| M-R12-1 `BindRoles`/`DeleteRole` 孤儿 `sys_user_role` | ✅ 已修 | `internal/model/role/sysRoleModel.go:LockRolesForShareTx` + `internal/logic/user/bindRolesLogic.go:122-129` 事务内 S 锁闭环 |
-| L-R12-1 `*WithTx` 预提交缓存失效 | ✅ 已修 | `internal/model/user/sysUserModel.go:UpdateProfileWithTx` 绕过 `m.ExecCtx`;`InvalidateProfileCache` 由 Logic 层 post-commit 显式调用(`updateUserLogic.go:205`) |
-| L-R12-2 `CreateDept` 父部门 Status 复核 | ✅ 已修 | `createDeptLogic.go:67-85` 事务内 `FindOneForShareTx` 取完整行并校验 `Status` |
-| L-R12-3 `UpdateRole` 注释与代码反向 | ✅ 已修 | `updateRoleLogic.go:34-41 / 64-70` 注释已与"数字越小 = 权限越高"钉死,代码语义一致 |
-| L-R12-4 / L-R12-5 | ✅ 保持接受契约 | 代码位置无回退;风险评估与 R12 一致 |
-
-结论:R12 输出的 P1/P2/P3 全部闭环,未观察到回退。
+> 基线:R13 审计后的代码库快照。R13 的 5 条 Low 风险项(L-R13-1 ~ L-R13-5)中,L-R13-1/2/3/4 已在对应 logic 文件中看到修复(见文末"R13 回归验证");L-R13-5 仅做到"大部分调用点切换成 `DetachCacheCleanCtx`",仍遗留两处关键路径未改造,本轮升级归入 M-R14-1。
 
 ---
 
 ## 🚩 核心逻辑漏洞 (High Risk)
 
-本轮未发现新的 High Risk 漏洞。
-
-R5~R11 期间暴露过的四条主链路(Auth / Token / Dept-Member / Perm-Sync)在代码中均看到锁序、乐观锁、CAS、fail-close 的层叠护栏,且 R12 的孤儿绑定修复把最后一条"锁序缺口"也补齐。抽样覆盖的高影响面:
-
-- **密码变更链**(`changePasswordLogic` → `UpdatePassword` 带 `expectedUpdateTime`):H-R11-1 TOCTOU 闭环,未见回退。
-- **Token 轮转**(`authHelper.RotateRefreshToken` + `IncrementTokenVersionIfMatch` CAS):HTTP / gRPC 两条路径收敛到同一 helper,未观测到新的并发 rotate 路径。
-- **部门生命周期**(`DeleteDept` 锁序:self.X → children.S → users.S)vs(`CreateDept`、`UpdateUser(deptId)`):锁方向无 AB-BA。
-- **权限同步 × 设置用户权限**:`LockByCodeTx` × `SetUserPerms` 事务内 COUNT 双重把关,TOCTOU 闭环。
-- **授角色 × 删角色**:R12 的 `LockRolesForShareTx` 已闭合。
-
----
-
-## ⚠️ 健壮性与性能建议 (Medium/Low)
+### H-R14-1 · 授权漏洞 / 跨产品权限升级 —— 产品 ADMIN 可借 `UpdateUser` 把他人调入 DEV 部门,间接赋予跨产品全权
 
-### L-R13-1(Low · 信息泄露 · 枚举信号)· `AddMember` 将权限校验放在读产品 / 读用户之后
+**描述**
 
-**描述**:`internal/logic/member/addMemberLogic.go:33-56` 的校验顺序是
+`internal/logic/user/updateUserLogic.go` 在处理 `req.DeptId != nil && *req.DeptId > 0` 分支时,对「新部门是否是 DEV 类型」没有任何特殊校验;且第 137-141 行对调用方的部门前缀校验通过 `caller.MemberType != consts.MemberTypeAdmin` **直接短路**:
 
+```137:141:internal/logic/user/updateUserLogic.go
+if !caller.IsSuperAdmin &&
+    caller.MemberType != consts.MemberTypeAdmin &&
+    !strings.HasPrefix(newDept.Path, caller.DeptPath) {
+    return response.ErrForbidden("无权将用户调入非自己管辖的部门")
+}
 ```
-① FindOneByCode(productCode)  → 400/404 "产品不存在/已禁用"
-② FindOne(userId)              → 400/404 "用户不存在/已冻结"
-③ memberType 字面校验          → 400
-④ RequireProductAdminFor       → 403 "无权限"
+
+配合 `internal/loaders/userDetailsLoader.go` 第 540-554 行的全权分支:
+
+```540:554:internal/loaders/userDetailsLoader.go
+if ud.IsSuperAdmin ||
+    ud.MemberType == consts.MemberTypeAdmin ||
+    ud.MemberType == consts.MemberTypeDeveloper ||
+    (ud.MemberType != "" && ud.DeptType == consts.DeptTypeDev && ud.DeptStatus == consts.StatusEnabled) {
+    codes, err := l.models.SysPermModel.FindAllCodesByProductCode(ctx, ud.ProductCode)
+    ...
+    ud.Perms = codes
+    return nil
+}
 ```
 
-任何登录用户(只要持有有效 JWT,不需要是产品 ADMIN)都可以通过响应码差异做两件事:
+只要 `sys_user.deptId` 指向 `DeptType=DEV` 的部门,该用户在**任何他已加入的产品**下都自动拿到该产品的全量权限。而 `sys_user.deptId` 是全局字段——`UpdateUser` 在 caller 作用域内用 `CheckManageAccess(productCode=caller.ProductCode, ...)` 判权,却会把修改向**所有其他产品**级联。
+
+**攻击链(复现路径)**
 
-- 枚举**存在且启用**的产品(对比 "产品不存在" / "产品已禁用" / "继续推进到 user 校验")。
-- 在已知 `productCode` 前提下,枚举**存在且启用**的 `userId`(对比"用户不存在 / 已冻结 / 通过用户校验" 三态)。
+1. 攻击者 A 是产品 P1 的 ADMIN(正常业务赋予的权限),同伙 B 是产品 P1 的 MEMBER;B 同时也是产品 P2 的 MEMBER。
+2. A 调 `UpdateUser(id=B.Id, deptId=<DEV 部门 id>)`:
+   - `CheckManageAccess` 仅作用在 P1,通过(B 在 P1 的 memberType=MEMBER,ADMIN 绕过 `checkDeptHierarchy`,`checkPermLevel` 也因 callerPri<targetPri 直接放行);
+   - 第 137-141 行对 ADMIN 短路,允许把 B 调入 P1 作用域之外的 DEV 部门;
+   - 第 146-148 行 "仅超管 / ADMIN 可移出部门" 也是按 ADMIN 放行。
+3. 事务提交后 `UserDetailsLoader.Clean(B.Id)` 刷新 UD,B 在产品 P2 下的下一次 `Load` 命中 DEV 全权分支 → `ud.Perms = FindAllCodesByProductCode(P2)`,B 从 P2 的普通成员瞬间升级为**P2 全权**。
+4. 如需抹痕,A 再把 B 移出 DEV 即可。该窗口期内 B 可调用 P2 的任何接口(含敏感读写)。
 
-步骤 ④ 把"本用户无管理员权限"的 403 放在最后,意味着非 product-ADMIN 的合法登录用户也能消费到 ①②③ 的信号——这比 R10-10 已经封死的 gRPC `GetUserPerms` 枚举面更宽。
+**影响**
 
-**影响**:
+- 跨产品权限升级:P1 的 ADMIN 能为 P2 的任意共有成员授予 P2 的全量权限——这是典型的**信任边界穿透**。
+- 对于多产品共享用户池(同一 `sys_user` 被多个产品加为成员)的部署,这个路径把"ADMIN 只应在自己产品内全权"的不变量彻底打破。
+- ADMIN 还可以顺带把其他产品的普通员工(B 可能是 P2 的 MEMBER 但在 P1 只是挂名)调入 DEV 后再移回原部门,对 P2 而言几乎不可审计(`sys_user.deptId` 的变更在 P2 日志里看不到是谁发起的)。
 
-- **数据敏感度**:泄露的是"产品 code 集合是否在线" 和 "userId → 是否存在/已冻结"两条事实。前者在中大型组织里属于内部但不敏感,后者在"通过工号推 userId" 的场景下可以被攻击者用来筛选可用账号(配合 H-2 PII 暴露后的 phone / email 泄露链放大)。
-- **可触发门槛**:任何持有有效 access token 的用户(含仅 MEMBER)。
-- **概率**:在 JWT 泄露 / 内鬼场景下即时可用,概率非零。
+**修复方案**
 
-**修复方案**:把 `RequireProductAdminFor(productCode)` 提升到读任何实体之前。由于 `productCode` 来自 `middleware.GetProductCode(ctx)`(权威,不是入参),这一提升零成本
+在 `updateUserLogic.go` 的 `*req.DeptId > 0` 分支里加入对「目标新部门是 DEV」的显式护栏,**仅允许超级管理员**把用户调入 DEV;同理对 `deptId=0`(移出部门)保留现状即可,但把 "移入 DEV" 与 "跨越产品范围" 这条路径堵死:
 
 ```go
-func (l *AddMemberLogic) AddMember(req *types.AddMemberReq) (*types.IdResp, error) {
-    // 先把"无权调此接口"的路径一刀切在任何 DB 查询之前
-    if err := authHelper.RequireProductAdminFor(l.ctx, req.ProductCode); err != nil {
-        return nil, err
-    }
-    product, err := l.svcCtx.SysProductModel.FindOneByCode(l.ctx, req.ProductCode)
-    // ... 其余保持不变
+if newDept.DeptType == consts.DeptTypeDev && !caller.IsSuperAdmin {
+    // DEV 部门承载"加入即全权"的跨产品语义,任何产品 ADMIN / DEVELOPER / MEMBER
+    // 发起的调入都可能越过其 productCode 作用域,污染其它产品的 loadPerms 全权分支。
+    // 与之对称,CreateUser 对非超管 + DEV 目标部门已经被 DeptPath 前缀校验天然约束
+    // (DEV 部门路径通常不在 ADMIN 的 caller.DeptPath 子树里),这里补齐 UpdateUser
+    // 被 ADMIN 短路掉的缺口。
+    return response.ErrForbidden("仅超级管理员可将用户调入研发部门")
 }
 ```
 
-同时建议同路径检查两个兄弟接口:
+同时建议在 `CreateUser` 中显式镜像一份同样的 `newDept.DeptType == DEV` 护栏,避免未来维护者误将 ADMIN 纳入可跨入 DEV 的行列。
 
-- `SetUserPerms`(`setUserPermsLogic.go:38-47`):先 `FindOne(userId)` 再 `RequireProductAdminFor`,同样可枚举 `userId` 存在性。
-- `BindRoles`(`bindRolesLogic.go:41-49`):先 `FindOne(userId)` 再 `CheckManageAccess`,语义上"管理者才能读 target",但同样走到了未授权调用方的 404 分支。
+验收测试(对应 `internal/logic/user/updateUserLogic_test.go`)应补:
 
-**结论**:按 `RequireProductAdminFor → 读其它实体` 的顺序统一重排,Medium 以下的"侧信道枚举面"就能大面积收敛。成本极低。
+1. caller=ADMIN of P1,target=MEMBER of P1(且是 P2 的 MEMBER),`req.DeptId=DEV 部门 id` → 返回 `ErrForbidden("仅超级管理员可将用户调入研发部门")`;`sys_user.deptId` 不变;`UserDetailsLoader.Clean` 不被触发。
+2. caller=SuperAdmin 同请求 → 正常放行,提交后 `UserDetailsLoader.Clean` 被调用一次。
+3. caller=ADMIN of P1,`req.DeptId=<普通部门且在 caller.DeptPath 子树>` → 正常放行(与现有行为一致)。
 
 ---
 
-### L-R13-2(Low · TOCTOU · 脏写长尾)· `SetUserPerms` 的 memberType 检查点未覆盖到事务内
+## ⚠️ 健壮性与性能建议 (Medium / Low)
 
-**描述**:`internal/logic/user/setUserPermsLogic.go:91-97` 对目标用户的 `memberType == ADMIN / DEVELOPER` 做"禁止写 DENY" 的入口拦截,目的是避免写入"永不生效的 DENY 脏行"(L-R10-8)。该检查读的是事务外的 `targetMember.MemberType` 快照。
+### M-R14-1 · 资源管理 / 可观测性 —— `RotateRefreshToken` 与 `SyncPermsService` 两处 post-commit 缓存失效未接入 `DetachCacheCleanCtx`
 
-时序风险:
+**位置**
 
-```
-T0: caller A 读 targetMember.MemberType = "MEMBER"     -- 入口检查通过
-T1: caller B(产品 ADMIN)调 UpdateMember,把 target 升为 "ADMIN"(并提交)
-T2: caller A 进入事务;DeleteByUserIdForProductTx + BatchInsertWithTx(DENY 行) 均成功
-T3: caller A 事务末 COUNT(sys_perm ...) 复核通过,事务提交
-```
+- `internal/logic/auth/rotateRefreshToken.go:82`
+  ```go
+  svcCtx.UserDetailsLoader.Clean(ctx, claims.UserId)
+  ```
+- `internal/logic/pub/syncPermsService.go:171`
+  ```go
+  svcCtx.UserDetailsLoader.CleanByProduct(ctx, product.Code)
+  ```
 
-最终:target 现在是 ADMIN(loadPerms 走全权分支),`sys_user_perm` 里留下了永不生效的 DENY 行。这条路径恰好是 L-R10-8 主动防御的语义欺骗("能写、永不生效" 的脏状态污染审计日志 / 权限推理工具)。
+R13 提出的 Solution A 是"事务已提交的缓存清理必须与请求 ctx 解耦,用 `loaders.DetachCacheCleanCtx` 拿一个 parent-cancel-不穿透 + 3s 硬超时的 ctx"。审计当下,`changePasswordLogic / logoutLogic / updateDeptLogic / addMember / removeMember / updateMember / updateProduct / bindRolePerms / deleteRole / updateRole / bindRoles / setUserPerms / updateUser / updateUserStatus` 均已落地,**唯独这两处仍用原始 `ctx`**。两条路径都是高敏感后果:
 
-**影响**:
+- `RotateRefreshToken`:CAS 提交新 `tokenVersion` 已落库,此时 client 断连 / HTTP ctx 被 deadline 触发 → `UserDetailsLoader.Clean` 被立刻 canceled → Redis 里 UD 仍缓存旧 `tokenVersion`。最多 5 分钟 TTL 内:
+  - 旧 access token(tokenVersion=N)仍能通过中间件(因为 UD 缓存里就是 N);
+  - 客户端拿到新 refresh 失败时自然会重试 refresh,但若 TTL 没过就会命中 `IncrementTokenVersionIfMatch(expected=N)` 失败(DB 已是 N+1),被强制重登录,形成"无故踢出"的 UX 抖动。
+  - 更坏:如果攻击者的目标是**让用户的老 access token 继续生效**,TTL 5 分钟的窗口是确定可利用的。
+- `SyncPermsService`:`CleanByProduct` 失败后,被禁用 / 删除的 perm 仍在 5min TTL 内出现在所有该产品成员的 UD 缓存里——被本产品服务端用 `VerifyToken / GetUserPerms` 查询到的 perms 列表里,`checkStillValid` 逻辑会把失效 perm 从结果里剔除,但 `UserDetails.Perms` 缓存本身不会因 miss 而重建,最多延迟至 TTL。即使下游 caller 做了 `checkStillValid`,也依赖"下游每次查询都保留 db round-trip"的前提;若 perms-system 的消费方只看 `GetUserPerms` 返回,仍会命中窗口。
 
-- **运行期安全**:零。ADMIN/DEVELOPER 的 loadPerms 分支完全忽略 `sys_user_perm`,DENY 不会意外丢失敏感权限。
-- **数据卫生**:污染 `sys_user_perm`;未来如果 target 再被 `UpdateMember` 降回 MEMBER 或调 `RemoveMember → AddMember`(后者不清 user_perm),这些 DENY 会"诈尸"生效,运维排查会踩到"为什么这个用户突然少了一条权限"。
-- **概率**:极低。需要 caller A 与 caller B 在同一产品同一 target 上毫秒级交错。
+**修复方案**
 
-**修复方案**:把 memberType 检查纳入事务、与 `sys_product_member` 行 `FOR SHARE` 闭环
+两处统一改造为 detach ctx:
 
 ```go
-if err := l.svcCtx.SysUserPermModel.TransactCtx(l.ctx, func(ctx, session) error {
-    // 事务内复读 member 状态 + 锁
-    member, err := l.svcCtx.SysProductMemberModel.FindOneForShareTx(ctx, session, targetMember.Id)
-    if err != nil {
-        return response.ErrConflict("成员状态已变更,请刷新后重试")
-    }
-    if (member.MemberType == ADMIN || member.MemberType == DEVELOPER) && hasDeny {
-        return response.ErrBadRequest("目标用户是管理员或开发者,DENY 不会生效")
-    }
-    // ... 原有 DeleteByUserIdForProductTx + BatchInsertWithTx + COUNT 复核
-})
+cleanCtx, cancel := loaders.DetachCacheCleanCtx(ctx)
+defer cancel()
+svcCtx.UserDetailsLoader.Clean(cleanCtx, claims.UserId)
 ```
 
-需新增 `SysProductMemberModel.FindOneForShareTx`(与现有 `FindOneForUpdateTx` 对称)。`UpdateMember` 走的是 `FOR UPDATE` 路径,会与本 `FOR SHARE` 形成阻塞链。
-
-**优先级**:P3。当前代码的 L-R10-8 "入口拦截 + loadPerms 全权分支忽略脏行"已经把运行期风险压到零,本条只是数据卫生层面的补丁。
-
----
-
-### L-R13-3(Low · 僵尸代码 / 防御冗余)· `UpdateUserLogic` 中的 `caller.DeptPath != ""` 分支已不可达
-
-**描述**:`internal/logic/user/updateUserLogic.go:123-128`
+和:
 
 ```go
-if !caller.IsSuperAdmin &&
-    caller.MemberType != consts.MemberTypeAdmin &&
-    caller.DeptPath != "" &&               // ← 这条在当前调用链下永远为 true
-    !strings.HasPrefix(newDept.Path, caller.DeptPath) {
-    return response.ErrForbidden("无权将用户调入非自己管辖的部门")
-}
+cleanCtx, cancel := loaders.DetachCacheCleanCtx(ctx)
+defer cancel()
+svcCtx.UserDetailsLoader.CleanByProduct(cleanCtx, product.Code)
 ```
 
-走到这段代码有三个前置事实:
+`rotateRefreshToken.go` 是 helper,被 `internal/logic/pub/refreshTokenLogic.go`、`internal/server/permserver.go` 的 `RefreshToken` 同时调用,改 helper 一处即可覆盖 HTTP / gRPC 两个入口,避免外部调用方再各自 detach 的重复代码。
 
-1. 上方 `caller.UserId == req.Id → 拒绝改 deptId`(line 42-45)已经把"caller == target" 分支 cut 掉;
-2. `CheckManageAccess`(line 57)对 `caller != target` 分支调 `checkDeptHierarchy`,后者在 `caller.DeptId == 0 || caller.DeptPath == ""` 时 fail-close(`access.go:318-324`);
-3. 走到第 123 行时 `caller` 必然既不是 super、也不是 ADMIN(同一条判定前几句过滤掉)。
+回归测试建议:
 
-综合三条事实:`caller.DeptPath != ""` 恒成立——这是防御冗余,不是主动保护。
+- 模拟 `IncrementTokenVersionIfMatch` 成功后把 `ctx` 显式 cancel,验证 `Clean` 仍能在 detach ctx 下走到 Redis DEL(或在 redis mock 上观察 DEL 触发)。
+- 模拟 `BatchWriteProductPerms` 事务成功后 cancel ctx,验证 `CleanByProduct` 仍走到 cache invalidation。
 
-**影响**:
+---
 
-- **运行期**:零。
-- **可读性**:中。新维护者看到这行容易以为"某种分支下 caller.DeptPath 可以为空",进而在 `checkDeptHierarchy` 里放宽约束,把真实的 fail-close 护栏拆掉。R10 ~ R12 对类似误导性注释已经多次浪费工时(L-R12-3)。
+### L-R14-1 · 信息泄露 / 枚举信号 —— Role 维度的 `FindOne → RequireProductAdminFor` 顺序泄漏跨产品 roleId 存在性
 
-**修复方案**:删除该条件,并在上方添加一行注释说明这段依赖 `CheckManageAccess` 已经 fail-close:
+**位置**
 
-```go
-// caller.DeptId == 0 / DeptPath == "" 的 fail-close 已经由 CheckManageAccess
-// → checkDeptHierarchy 在 access.go:318-324 统一兜底,这里不再重复防御。
-if !caller.IsSuperAdmin &&
-    caller.MemberType != consts.MemberTypeAdmin &&
-    !strings.HasPrefix(newDept.Path, caller.DeptPath) {
-    return response.ErrForbidden("无权将用户调入非自己管辖的部门")
-}
-```
+- `internal/logic/role/updateRoleLogic.go`(先 `SysRoleModel.FindOne(req.Id)` → 再 `RequireProductAdminFor(role.ProductCode)`)
+- `internal/logic/role/deleteRoleLogic.go`(同上)
+- `internal/logic/role/bindRolePermsLogic.go`(同上)
 
-**优先级**:P3。纯可读性/防回退修复。
+任何已登录用户(即使只是某产品的普通 MEMBER)都能构造上述三个接口,用顺序 id 扫 `req.RoleId=1..N`:
 
----
+- 若 `FindOne` 返回 `ErrNotFound` → `响应 404 "角色不存在"`;
+- 若角色存在且属于其他产品 → `响应 403 "仅超级管理员或该产品的管理员可执行此操作"`。
+
+两个响应码不一致,直接把"该 roleId 是否存在以及属于别的产品"漏给了认证用户,和 R13 的 L-R13-1(`addMember / setUserPerms / bindRoles` 的枚举信号)同族。对比已经收敛过的 `roleDetailLogic.go`(M-N3:把"不存在"和"他产品角色"统一回 404),这三个接口属于遗漏。
 
-### L-R13-4(Low · 边界 · 数据卫生)· `CreateUser` 未拒绝负数 `deptId`
+**修复方案**
 
-**描述**:`internal/logic/user/createUserLogic.go:80-98`
+在 `FindOne` 之后、`RequireProductAdminFor` 之前,对非超管调用方先做"产品归属比对",把跨产品情况降级成与"不存在"完全一致的 404:
 
 ```go
-if req.DeptId > 0 {
-    // ... 校验部门存在、启用、hierarchy
-} else if !caller.IsSuperAdmin {
-    return nil, response.ErrBadRequest("必须指定部门")
+role, err := l.svcCtx.SysRoleModel.FindOne(l.ctx, req.Id)
+if err != nil {
+    return response.ErrNotFound("角色不存在")
+}
+caller := middleware.GetUserDetails(l.ctx)
+if caller == nil {
+    return response.ErrUnauthorized("未登录")
+}
+// 与 RoleDetailLogic 的 M-N3 收敛口径一致:非超管看到"非本产品角色"一律返回 404,
+// 杜绝通过 403/404 文案差异枚举跨产品 roleId。
+if !caller.IsSuperAdmin && role.ProductCode != middleware.GetProductCode(l.ctx) {
+    return response.ErrNotFound("角色不存在")
+}
+if err := authHelper.RequireProductAdminFor(l.ctx, role.ProductCode); err != nil {
+    return err
 }
-// 落到 Insert 时 deptId 是 req.DeptId 原值;超管可直接 req.DeptId = -1
 ```
 
-超级管理员以 `req.DeptId = -1`(或其他负数)调用时会直接 `Insert` 一条 `sys_user.deptId = -1` 的脏数据。后续:
+三个文件统一同一模板,推荐抽成 `authHelper.ResolveOwnRoleOr404(ctx, svcCtx, roleId)` 以避免日后漂移。
 
-- `UserDetailsLoader.loadDept` 有 `if ud.DeptId == 0 { return nil }` 短路,但 `DeptId == -1` 会去 `FindOne(-1)` 命中 `ErrNotFound` → 报错 → `loadOk = false` → 返回 503 Degraded。
-- `FindIdsByDeptId(-1)` 永远返 nil;这条用户在部门相关接口里彻底隐形。
+---
+
+### L-R14-2 · 信息泄露 —— `BindRoles` 对入参 roleIds 的错误文案区分"不存在 / 跨产品"
 
-即"超管的输入合法域"未被钉死,能构造出永远无法被部门树管理到的僵尸账号。
+**位置** `internal/logic/user/bindRolesLogic.go`
 
-**影响**:
+当 `FindByIds(req.RoleIds)` 返回的角色里有跨产品项,返回 `"不能绑定其他产品的角色"`;缺项时返回 `"包含无效的角色ID"`。已通过 `CheckManageAccess` 的调用方即便只是某产品 MEMBER,也可以借此枚举他人产品的 roleId 分布(比 L-R14-1 门槛更低,因为他只需管得了本产品某个下属即可)。
 
-- **运行期**:非阻断。被影响的仅是该条记录的用户本人在登录时会踩 503(因 `loadDept` 报错被 degrade)。
-- **数据完整性**:污染 `sys_user` 的 `deptId` 定义域。
+**修复方案**
 
-**修复方案**:在入口简单加一条校验
+将"跨产品"与"不存在"折叠成同一个 `ErrBadRequest("包含无效的角色ID")` 文案(日志里保留细分标记供审计分析)
 
 ```go
-if req.DeptId < 0 {
-    return nil, response.ErrBadRequest("部门ID必须为非负整数")
+valid := 0
+for _, r := range roles {
+    if r.ProductCode != productCode || r.Status != consts.StatusEnabled {
+        continue
+    }
+    valid++
 }
-if req.DeptId > 0 {
-    // ... 原有逻辑
-} else if !caller.IsSuperAdmin {
-    return nil, response.ErrBadRequest("必须指定部门")
+if valid != len(req.RoleIds) {
+    logx.WithContext(l.ctx).Infow("bind roles: invalid ids",
+        logx.Field("audit", "bind_roles_invalid_ids"),
+        logx.Field("requested", req.RoleIds),
+        logx.Field("productCode", productCode),
+    )
+    return response.ErrBadRequest("包含无效的角色ID")
 }
 ```
 
-同类检查建议同步到 `UpdateUser`(line 111-138 的 `*req.DeptId > 0` 分支外,没有对 `*req.DeptId < 0` 的显式拒绝——但那条路径会落到"deptId=0 且不是 super/admin 则 403",与 0 等价;超管仍可写入负数)。
-
-**优先级**:P3。
+不要把产品归属揭露给响应体,只保留到日志里给运营同学排障。
 
 ---
 
-### L-R13-5(Low · 资源管理 · 可观测性)· post-commit 缓存失效在 ctx 被取消时静默降级
+### L-R14-3 · 可维护性 —— `UpdateUserLogic` 的 ADMIN 分支注释缺少对 DEV 部门语义的声明
 
-**描述**:整套代码库的一种常见模式
+即便 H-R14-1 选择保留现有 ADMIN 可调部门的语义(决定不改代码),line 131-141 的注释也仅讨论了"caller.DeptPath 为空"的边界,没有钉出**"此分支允许 ADMIN 把用户调入 DEV 部门 = 跨产品全权赋予"**这一事实。建议至少在注释层面明确写一段
 
-```go
-if err := transactionBody(...); err != nil { return err }
-l.svcCtx.UserDetailsLoader.Clean(l.ctx, userId)   // 或 BatchDel / CleanByUserIds
-l.svcCtx.SysUserModel.InvalidateProfileCache(...) // sqlc 低层缓存
+```text
+注意:ADMIN 分支短路 DeptPath 前缀校验,意味着 ADMIN 可以把目标调入任何部门,
+包括 DeptType=DEV 的部门。DEV 部门在 loadPerms 中等价于"加入任一产品即全权",
+这条路径相当于把 ADMIN 本地产品外的权限一并下发到目标的其他产品身份上;若产品
+间信任不对称,请额外增加 `newDept.DeptType == DEV → RequireSuperAdmin` 的护栏
+(见审计 H-R14-1)。
 ```
 
-当 `l.ctx`(继承自 HTTP 请求 ctx)在 DB 事务提交之后、Clean 执行之前被 client 断连 / server 超时取消时:
-
-- `Clean` 内部的 Redis `Smembers + Del + Del` 三步 / `BatchDel` 的 `Del + Pipelined SRem`——只要第一步遇到 `ctx canceled` 就会短路到 logx `Errorf` 并返回,后续步骤不执行。
-- `InvalidateProfileCache` 对失败是完全静默(`_ = m.DelCacheCtx(ctx, ...)`)。
-
-最终:**DB 已改、缓存未清**。用户在 5 分钟 TTL 窗口内会继续看到旧的 `UserDetails`(旧 DeptPath / 旧 MinPermsLevel / 旧冻结状态),直到下一次该用户被 Load 命中 TTL 失效或被别的 Clean 触发。
-
-与 `UserDetailsLoader` 自身文档里承诺的"Clean 的失败是 best-effort,TTL 兜底" 是一致的——但这些失败目前**没有任何可观测的 metric**,只进 Errorf 日志。
-
-**影响**:
-
-- **安全敏感变更**(冻结用户、切换部门、降权):TTL 5 分钟内旧 UD 继续生效。
-- **用户体验**:改完资料立即刷新看到旧头像 / 昵称,属于次要。
-- **监控侧**:Errorf 在正常业务里也会零星出现(Redis 抖动),没有区分"ctx 取消" vs "Redis 真的挂了"的 tag,运维难以建告警。
-
-**修复方案**:
-
-- **方案 A(推荐)**:post-commit 阶段用 `context.WithoutCancel(l.ctx)`(Go 1.21+)或手动 detach 出一个独立的 `context.Background()` + timeout(比如 3s)把缓存失效做完。事务已经提交,这几次 Redis 写是后置补偿,不该随请求取消而丢失。
-
-  ```go
-  if err := transactionBody(...); err != nil { return err }
-
-  // post-commit 的缓存失效不应随 HTTP 请求一起被取消
-  cleanCtx, cancel := context.WithTimeout(context.WithoutCancel(l.ctx), 3*time.Second)
-  defer cancel()
-  l.svcCtx.UserDetailsLoader.Clean(cleanCtx, userId)
-  l.svcCtx.SysUserModel.InvalidateProfileCache(cleanCtx, userId, username)
-  return nil
-  ```
-
-- **方案 B**:保持现状,但在 `UserDetailsLoader.Clean` / `BatchDel` / `CleanByUserIds` 内部识别 `errors.Is(err, context.Canceled)` 并打一条带 `cache_invalidation_skipped_due_to_ctx_cancel` tag 的 WARN 日志,方便运维基于这个 tag 建看板报警;真正的"Redis 挂了"走原来的 Errorf。
-
-两种方案可以叠加。方案 A 治本(让敏感变更的缓存失效脱离请求生命周期),方案 B 治可观测性。
-
-**优先级**:P2(方案 B,日志分级)+ P3(方案 A,detach ctx)。方案 A 的行为改动面较大,建议先做方案 B 收集样本。
+这样后续维护者能在修改前看到明确的风险披露,避免把 H-R14-1 的推论再次拆掉。
 
 ---
 
-## 复核中仍成立的契约(本轮不动)
-
-以下条目是历史审计(R5~R12)确认的**业务侧已接受**选择,本轮没有新观测让它们的风险/收益比改变:
-
-- **H-1(同产品成员可见彼此 PII)**:业务需求明示保留,不视为漏洞。
-- **M-4(CreateProduct 的 one-time ticket 机制)**:`internal/logic/product/fetchInitialCredentialsLogic.go` 的 `GetDelCtx` 原子消费 + 超管鉴权仍在位。
-- **H-2 / M-3(decision-time fresh DB 读)**:`loadFreshMinPermsLevel` 继续覆盖 `GuardRoleLevelAssignable` 与 `checkPermLevel` 两条决策链,TOCTOU 闭环。
-- **L-R10-8(全权成员的 DENY 入口拦截 + loadPerms 全权分支忽略脏行)**:入口拦截 + loadPerms JOIN status 双层兜底;L-R13-2 仅在数据卫生层进一步收敛。
-- **L-R10-9(代理 X-Forwarded-For 链一致性)**:依赖部署侧反代 / WAF 硬约束,代码侧 `firstValidIP` + `net.ParseIP` 已尽全力。
-- **L-R12-4 / L-R12-5**:已接受契约。
+## R13 回归验证(附录)
 
----
-
-## 本轮 Findings 汇总与修复优先级
-
-| 优先级 | 条目 | 类型 | 一句话总结 |
+| 条目 | 期望修复 | 代码现状 | 判定 |
 | --- | --- | --- | --- |
-| P2 | **L-R13-1** | 信息泄露 | `AddMember` / `SetUserPerms` / `BindRoles` 未把 `RequireProductAdminFor` 提到读实体之前,已认证用户可枚举产品 / 用户存在性 |
-| P2 | L-R13-5 方案 B | 可观测性 | post-commit 缓存失效的 ctx-canceled 失败需要独立 tag,便于告警 |
-| P3 | L-R13-2 | 数据卫生 | `SetUserPerms` 的 memberType 检查点应纳入事务 + `FOR SHARE` 闭环 |
-| P3 | L-R13-3 | 僵尸代码 | `UpdateUserLogic` 中冗余的 `caller.DeptPath != ""` 分支删掉 |
-| P3 | L-R13-4 | 边界 | `CreateUser` 拒绝 `req.DeptId < 0` |
-| P3 | L-R13-5 方案 A | 资源管理 | post-commit 阶段用 detached ctx 做缓存失效 |
+| L-R13-1 枚举信号 | `addMember / setUserPerms / bindRoles` 在 `RequireProductAdminFor` / 早期 caller 校验前不泄漏对象存在性 | `addMemberLogic.go:41` 把 `RequireProductAdminFor` 置于 User / Product `FindOne` 之前;`setUserPermsLogic.go:45` 同;`bindRolesLogic.go:34-49` 新增 `caller.MemberType == ""` 快速 403 | ✅ 已收敛(roleId 维度新出现 L-R14-1/2) |
+| L-R13-2 `SetUserPerms` TOCTOU | ADMIN/DEVELOPER → DENY 的拦截必须在事务里并锁 `sys_product_member` | `setUserPermsLogic.go` 事务内 `FindOneForShareTx(targetUserId, productCode)` + `memberType` 再检,`sysProductMemberModel.go` 新增 `FindOneForShareTx` | ✅ 已闭合 |
+| L-R13-3 冗余条件 | 删除 `UpdateUserLogic` 里 `caller.DeptPath != ""` | `updateUserLogic.go:131-136` 已删除并附注释 | ✅ 已收敛 |
+| L-R13-4 负数 `deptId` | `CreateUser / UpdateUser` 显式 `< 0` 返回 400 | `createUserLogic.go`、`updateUserLogic.go:117-119` 均有 `*req.DeptId < 0 → BadRequest` | ✅ 已收敛 |
+| L-R13-5 post-commit 缓存失效 | 方案 A `DetachCacheCleanCtx` + 方案 B 审计日志标签 | 主流程已切换(见文件列表);`loaders/cacheCleanCtx.go` 提供 API 与 `logCacheInvalidationErr`;**遗漏 `rotateRefreshToken.go` / `syncPermsService.go` 两处**,本轮按 M-R14-1 升级跟进 | 🟡 部分收敛 |
 
 ---
 
-## 复核结论
-
-经过 12 轮迭代 + 本轮(R13)独立复核,`perms-system/server` 的核心授权、会话、数据持久化链路进入**尾部收敛**阶段:
-
-- **High Risk 本轮为 0**:连续第二轮无新 High;主链路护栏稳定,未观察到历史修复回退。
-- **Medium 本轮为 0(本体)**:L-R13-1(枚举面)与 L-R13-5(可观测性)可归为"次 Medium"处理,但其影响面都被既有限流 / TTL 兜住,没有形成直接越权路径。
-- **Low 本轮 5 条**:全部是**数据卫生**、**僵尸代码**、**防御冗余**三类维护性建议,没有会改变系统安全模型的条目。
-
-整体观感:R12 的锁链修复(M-R12-1)+ L-R12-1 的缓存失效时机整改后,这一轮只能在"已认证用户的枚举信号"和"超管输入合法域"这类更小的面上找到可改进点——说明核心逻辑已高度收敛。建议把 L-R13-1(性价比最高,纯 reorder 改动)和 L-R13-5 方案 B(可观测性,零行为变动)合并进最近一次 release;其它 P3 条目随代码重构顺手做即可。
-
-若后续要把审计频次从"每轮全量"转成"增量 diff + 主链路定向",建议固化以下 invariant 作为 CI 静态扫描规则:
-
-1. 所有 `TransactCtx` 的 post-commit cache clean 必须用 detached ctx(L-R13-5)。
-2. 所有权限 / 管理类 HTTP / gRPC handler 的第一条业务语句必须是 `Require*` / `CheckManageAccess` 之一(L-R13-1)。
-3. `expectedUpdateTime` 参数必须从"调用方业务读"的快照传入,禁止在 model 层自读自比(H-R11-1 的长期闭环)。
-
-这三条都可以用 `ast` / `go/analysis` 级别的规则扫出来,成本远低于人工重走全链。
+本轮新增发现一条 High(H-R14-1)、一条 Medium(M-R14-1)、三条 Low(L-R14-1 / L-R14-2 / L-R14-3)。建议优先处置 H-R14-1(跨产品升级,实际可被产品 ADMIN 触发)和 M-R14-1(两处遗留的缓存失效路径,修复代价极小)。

+ 33 - 0
internal/logic/auth/access.go

@@ -10,6 +10,7 @@ import (
 	"perms-system-server/internal/loaders"
 	"perms-system-server/internal/middleware"
 	memberModel "perms-system-server/internal/model/productmember"
+	roleModel "perms-system-server/internal/model/role"
 	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
@@ -142,6 +143,38 @@ func RequireProductAdminFor(ctx context.Context, targetProductCode string) error
 	return response.ErrForbidden("仅超级管理员或该产品的管理员可执行此操作")
 }
 
+// ResolveOwnRoleOr404 把"根据 roleId 拉取角色 + 归属产品判定"这段通用前置抽出来供
+// UpdateRole / DeleteRole / BindRolePerms 等 role 维度的管理接口复用(审计 L-R14-1)。
+//
+// 必须与 RoleDetailLogic(M-N3)的收敛口径保持一致:非超管调用者看到"属于其他产品的
+// 角色"时,必须返回与"角色不存在"完全一致的 404 + 文案 "角色不存在",否则任何已登录
+// 用户都能靠 404 vs 403 的响应码差异做顺序 id 扫描,画出跨产品 roleId 分布图。
+//
+// 返回值约定:
+//   - 成功:返回带完整字段的 *SysRole,调用方随后可直接 `authHelper.RequireProductAdminFor(role.ProductCode)`
+//     做最终授权判定;
+//   - err != nil:调用方应立即返回该 err,不要再访问 role(除了超管路径外,我们不向响应体暴露 role.ProductCode)。
+//
+// 契约说明:
+//   - 未登录 → 返回 ErrUnauthorized,避免把"未登录"与"无此角色"混为 404(登录态下的
+//     401 响应对攻击者意义不同,且让中间件/运维识别"缺登录态"路径更容易);
+//   - 超管:即使 role.ProductCode 与 caller.ProductCode 不同也直接放行,用于平台级维护
+//     场景(超管本来就能跨产品,不存在 enum oracle)。
+func ResolveOwnRoleOr404(ctx context.Context, svcCtx *svc.ServiceContext, roleId int64) (*roleModel.SysRole, error) {
+	caller := middleware.GetUserDetails(ctx)
+	if caller == nil {
+		return nil, response.ErrUnauthorized("未登录")
+	}
+	role, err := svcCtx.SysRoleModel.FindOne(ctx, roleId)
+	if err != nil {
+		return nil, response.ErrNotFound("角色不存在")
+	}
+	if !caller.IsSuperAdmin && role.ProductCode != caller.ProductCode {
+		return nil, response.ErrNotFound("角色不存在")
+	}
+	return role, nil
+}
+
 // GuardRoleLevelAssignable 校验调用者能否把 rolePermsLevel 这一等级的角色分配给他人。
 // 约束:"只能分配严格低于自身的等级"(数字更大 = 更低),与 checkPermLevel 的 ">=" 拦截口径对齐,
 // 避免调用者把下属拉到与自己平级后彻底失去管控(见审计 H-3)。

+ 8 - 1
internal/logic/auth/rotateRefreshToken.go

@@ -79,7 +79,14 @@ func RotateRefreshToken(ctx context.Context, svcCtx *svc.ServiceContext, claims
 		return RotateTokensResult{}, userModel.ErrTokenVersionMismatch
 	}
 
-	svcCtx.UserDetailsLoader.Clean(ctx, claims.UserId)
+	// 审计 M-R14-1:IncrementTokenVersionIfMatch 已成功落库,此时若 client 断连 / HTTP
+	// deadline 到期,沿用 request ctx 的 Clean 会被立刻 canceled,Redis 里旧 tokenVersion 的
+	// UD 会留到 TTL(默认 5min)——期间旧 access token 仍能通过中间件(因为 UD 缓存里还是
+	// 旧 tokenVersion),形成攻击者可主动利用的"token 复活"窗口。Detach 到独立 ctx 并套 3s
+	// 超时,保证 post-commit 的缓存失效独立于请求生命周期。
+	cleanCtx, cancel := loaders.DetachCacheCleanCtx(ctx)
+	defer cancel()
+	svcCtx.UserDetailsLoader.Clean(cleanCtx, claims.UserId)
 
 	return RotateTokensResult{AccessToken: accessToken, RefreshToken: newRefreshToken}, nil
 }

+ 79 - 0
internal/logic/auth/rotateRefreshToken_test.go

@@ -3,6 +3,7 @@ package auth
 import (
 	"context"
 	"database/sql"
+	"fmt"
 	"testing"
 	"time"
 
@@ -14,6 +15,7 @@ import (
 	"github.com/golang-jwt/jwt/v4"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/redis"
 )
 
 // ---------------------------------------------------------------------------
@@ -160,3 +162,80 @@ func TestRotateRefreshToken_DeletedUser_Mismatch(t *testing.T) {
 		"用户行已消失 → IncrementTokenVersionIfMatch RowsAffected=0,"+
 			"helper 必须折叠成 ErrTokenVersionMismatch;不得回底层 sqlx 错误让上游误映射为 500")
 }
+
+// TC-1117: M-R14-1 —— RotateRefreshToken 的 post-commit UD 缓存清理必须跑在
+// DetachCacheCleanCtx 返回的独立 ctx 上,不得被请求 ctx 的 cancel/deadline 牵连。
+//
+// 若未 detach:当调用方使用的 `ctx` 在 `IncrementTokenVersionIfMatch` 提交后、
+// `UserDetailsLoader.Clean` 执行前被取消(HTTP deadline 到期 / 客户端断连),
+// Redis 的 DEL 会被 ctx cancel 中断,UD 缓存仍会携带旧 `tokenVersion` 长达 5min TTL,
+// 旧 access token 据此可继续通过中间件校验("token 复活")。
+//
+// 这里的断言口径:
+//  1. 以 `WithCancel(bg)` 作为 parent;
+//  2. 先预热 UD 缓存(确保 Redis 里真的有一条带旧 `TokenVersion=0` 的记录);
+//  3. 执行 `RotateRefreshToken` 成功,DB tokenVersion 0 → 1;
+//  4. 立即 `cancel(parent)` —— 模拟"请求在函数返回瞬间就 cancel";
+//  5. 下一次 `UserDetailsLoader.Load(userId, "")` 必须看到 `TokenVersion=1`(即
+//     Clean 已跑完,Redis 没有残留旧 UD);
+//  6. 直接 `GetCtx` Redis 原 key 也必须已不存在(Clean 真正落到 Redis 了)。
+func TestRotateRefreshToken_M_R14_1_PostCommitCleanDetachedFromRequestCtx(t *testing.T) {
+	parent, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	username := "r14_detach_" + testutil.UniqueId()
+	userId := insertRotateTestUser(t, parent, svcCtx, username, 0)
+
+	// 用空 productCode 作为"跨产品的用户缓存"位点。Load 成功后,Redis 里落下一条
+	// 键为 cacheKey(userId, "") 的 UD 记录,方便我们观察 Clean 后是否真的被抹除。
+	udBefore, err := svcCtx.UserDetailsLoader.Load(parent, userId, "")
+	require.NoError(t, err)
+	require.NotNil(t, udBefore)
+	assert.Equal(t, int64(0), udBefore.TokenVersion, "预热:初始 tokenVersion 必须是 0")
+
+	cfg := testutil.GetTestConfig()
+	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+	rawKey := fmt.Sprintf("%s:ud:%d:%s", cfg.CacheRedis.KeyPrefix, userId, "")
+	valBefore, err := rds.GetCtx(parent, rawKey)
+	require.NoError(t, err)
+	require.NotEmpty(t, valBefore,
+		"预热后 Redis 必须落下一条 UD 正缓存,否则本用例无法观察到 Clean 的副作用")
+
+	claims := mkRefreshClaims(userId, "", 0, 2*time.Hour)
+	ud := &loaders.UserDetails{
+		UserId:       userId,
+		Username:     username,
+		Status:       1,
+		TokenVersion: 0,
+	}
+
+	tokens, err := RotateRefreshToken(parent, svcCtx, claims, ud)
+	require.NoError(t, err, "DB CAS 必须成功")
+	require.NotEmpty(t, tokens.RefreshToken)
+
+	// 立即 cancel parent —— 模拟"HTTP 请求 ctx 在函数返回同时被 cancel"。若 Clean 没 detach,
+	// 这次 cancel 已经来不及影响已经同步执行完的 Clean;但更严格的保护来自"Clean 运行时
+	// 用的就是独立 ctx",该行为在本用例里由"Clean 落到 Redis 的效果"反向验证。
+	cancel()
+
+	u, err := svcCtx.SysUserModel.FindOne(context.Background(), userId)
+	require.NoError(t, err)
+	assert.Equal(t, int64(1), u.TokenVersion, "DB tokenVersion 必须 +1")
+
+	// 关键断言 1:Redis 里的 UD 正缓存必须已被 Clean 抹掉。若退回"未 detach"实现,当
+	// cancel 与 Clean 竞争时,这里经常残留旧值。
+	valAfter, err := rds.GetCtx(context.Background(), rawKey)
+	require.NoError(t, err)
+	assert.Empty(t, valAfter,
+		"M-R14-1:post-commit Clean 必须抹掉 UD 正缓存;若仍非空,说明 Clean 被请求 ctx cancel 拖死")
+
+	// 关键断言 2:下一次 Load 必须打到 DB 并看到新 tokenVersion。
+	udAfter, err := svcCtx.UserDetailsLoader.Load(context.Background(), userId, "")
+	require.NoError(t, err)
+	require.NotNil(t, udAfter)
+	assert.Equal(t, int64(1), udAfter.TokenVersion,
+		"M-R14-1:Clean 后 Load 必须重新打 DB 读到新 tokenVersion=1;若读到 0,"+
+			"说明 Redis 仍持有旧 UD —— 对应生产旧 access token 在 5min TTL 内仍被中间件认可的复活窗口")
+}

+ 55 - 0
internal/logic/pub/syncPermsLogic_test.go

@@ -665,6 +665,61 @@ func TestSyncPerms_DisableTriggersCleanByProduct(t *testing.T) {
 			"已禁用权限,权限网关会把不再有效的权限判为 allow,产生权限残留")
 }
 
+// TC-1118: M-R14-1 —— ExecuteSyncPerms 的 post-commit CleanByProduct 必须跑在
+// DetachCacheCleanCtx 独立 ctx 上。用例口径:
+//  1. 用 `parent = WithCancel(bg)` 作为调用方 ctx;
+//  2. 先走一次纯新增做打底;
+//  3. primeProductIndex 置 canary,随后第二次 sync 改同 code 的 Name → updated=1;
+//  4. 在 ExecuteSyncPerms 返回后 **立即** `cancel(parent)`;
+//  5. canary 必须已被删除——说明 CleanByProduct 的 Redis DEL 跑在独立 ctx 上、
+//     不依赖 parent 存活。若回退为"直接传 parent ctx",cancel 会与 DEL 竞争,
+//     实际落到生产时 5min TTL 内的 UD 缓存仍挂着被禁用 / 变更的 perm。
+func TestSyncPerms_M_R14_1_CleanByProductDetachedFromRequestCtx(t *testing.T) {
+	parent, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	svcCtx := newTestSvcCtx()
+	cfg := testutil.GetTestConfig()
+	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+	conn := testutil.GetTestSqlConn()
+
+	pc := testutil.UniqueId()
+	appKey := testutil.UniqueId()
+	appSecret := testutil.UniqueId()
+	_, cleanProduct := insertSyncTestProduct(t, parent, pc, appKey, appSecret, 1)
+	t.Cleanup(cleanProduct)
+	t.Cleanup(func() { testutil.CleanTableByField(parent, conn, "`sys_perm`", "productCode", pc) })
+
+	// 第一次:纯新增打底;此次不应触发 Clean(见 TC-1064),所以 canary 放在此之后。
+	_, err := ExecuteSyncPerms(parent, svcCtx, appKey, appSecret, []SyncPermItem{
+		{Code: "r14_detach_upd", Name: "OldName"},
+	})
+	require.NoError(t, err)
+
+	idxKey := primeProductIndex(t, rds, cfg.CacheRedis.KeyPrefix, pc)
+	t.Cleanup(func() { _, _ = rds.Del(idxKey) })
+
+	// 第二次:同 code 改 Name → updated=1 → 必触发 CleanByProduct。
+	result, err := ExecuteSyncPerms(parent, svcCtx, appKey, appSecret, []SyncPermItem{
+		{Code: "r14_detach_upd", Name: "NewName"},
+	})
+	require.NoError(t, err)
+	require.NotNil(t, result)
+	assert.Equal(t, int64(1), result.Updated,
+		"前置:同名 Code 改 Name 必须 updated=1,否则后续 Clean 断言失去意义")
+
+	// 模拟"HTTP ctx 在函数返回同时/之后被 cancel"。若未 detach,生产环境里此 cancel
+	// 在 Clean 的 DEL 之前触达,Redis 里的 canary 会留存;若已 detach,cancel 只影响
+	// parent,CleanByProduct 用的 cleanCtx 独立存活 3s,DEL 正常落地。
+	cancel()
+
+	exists, err := rds.Exists(idxKey)
+	require.NoError(t, err)
+	assert.False(t, exists,
+		"M-R14-1:productIndexKey 必须被 CleanByProduct 抹掉;若仍存在,说明 CleanByProduct "+
+			"被请求 ctx 的 cancel 拖死,对应生产事务已 commit 但 UD 缓存仍挂着被禁用 perm 的 5min 窗口")
+}
+
 func TestExecuteSyncPerms_DeduplicatesRequest(t *testing.T) {
 	ctrl := gomock.NewController(t)
 	t.Cleanup(ctrl.Finish)

+ 9 - 1
internal/logic/pub/syncPermsService.go

@@ -6,6 +6,7 @@ import (
 	"time"
 
 	"perms-system-server/internal/consts"
+	"perms-system-server/internal/loaders"
 	permModel "perms-system-server/internal/model/perm"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/util"
@@ -168,7 +169,14 @@ func ExecuteSyncPerms(ctx context.Context, svcCtx *svc.ServiceContext, appKey, a
 	// 进一步的"按受影响角色/用户做精准失效"留给后续专项(audit L-R11-4 option-1),本轮先消除
 	// 最频繁的"纯新增也全清"这条误伤路径。
 	if updated > 0 || disabled > 0 {
-		svcCtx.UserDetailsLoader.CleanByProduct(ctx, product.Code)
+		// 审计 M-R14-1:事务已提交,沿用 request ctx 做 CleanByProduct 会在调用方(pub 入口、
+		// CLI 入口等)ctx 被 cancel 时立刻放弃 Redis DEL,留下"本产品所有成员的 UD 缓存仍
+		// 携带被禁用 perm"的窗口(最长 5min TTL)。消费方若只看 GetUserPerms 返回、不做
+		// checkStillValid 的 DB 复核就会命中失效 perm。Detach 到独立 ctx + 3s 超时,post-commit
+		// 的缓存失效独立于请求生命周期。
+		cleanCtx, cancel := loaders.DetachCacheCleanCtx(ctx)
+		defer cancel()
+		svcCtx.UserDetailsLoader.CleanByProduct(cleanCtx, product.Code)
 	}
 
 	return &SyncPermsResult{

+ 4 - 2
internal/logic/role/bindRolePermsLogic.go

@@ -33,9 +33,11 @@ func NewBindRolePermsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Bin
 
 // BindRolePerms 绑定角色权限。对指定角色做权限全量覆盖(diff 后批量新增/删除),变更后自动清理该角色下所有用户的权限缓存。
 func (l *BindRolePermsLogic) BindRolePerms(req *types.BindPermsReq) error {
-	role, err := l.svcCtx.SysRoleModel.FindOne(l.ctx, req.RoleId)
+	// 审计 L-R14-1:非超管看到"非本产品 roleId"必须伪装成 404,避免 404 vs 403 文案
+	// 差异泄漏跨产品 roleId 存在性。
+	role, err := authHelper.ResolveOwnRoleOr404(l.ctx, l.svcCtx, req.RoleId)
 	if err != nil {
-		return response.ErrNotFound("角色不存在")
+		return err
 	}
 
 	if err := authHelper.RequireProductAdminFor(l.ctx, role.ProductCode); err != nil {

+ 49 - 0
internal/logic/role/bindRolePermsLogic_test.go

@@ -254,6 +254,55 @@ func TestBindRolePerms_MemberRejected(t *testing.T) {
 	assert.Equal(t, 403, ce.Code())
 }
 
+// TC-1121: L-R14-1 非超管 BindRolePerms 针对别产品的 roleId 必须 404 "角色不存在",
+// 与 UpdateRole / DeleteRole / RoleDetail 对齐。
+func TestBindRolePerms_L_R14_1_CrossProductReturns404(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+
+	otherProduct := "l_r14_1_brp_" + testutil.UniqueId()
+	res, err := svcCtx.SysRoleModel.Insert(ctx, &roleModel.SysRole{
+		ProductCode: otherProduct, Name: testutil.UniqueId(), Status: 1, PermsLevel: 1,
+		CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	roleId, _ := res.LastInsertId()
+
+	// 预先在别产品下插一个权限以构造 permIds 入参 —— 不过因为 Resolve 环节就返回 404,
+	// 这个 perm 不会被真实写入 sys_role_perm;这里的价值只是为了让 req 合理。
+	pRes, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{
+		ProductCode: otherProduct, Name: testutil.UniqueId(), Code: testutil.UniqueId(),
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	permId, _ := pRes.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTableByField(ctx, conn, "`sys_role_perm`", "roleId", roleId)
+		testutil.CleanTable(ctx, conn, "`sys_perm`", permId)
+		testutil.CleanTable(ctx, conn, "`sys_role`", roleId)
+	})
+
+	adminCtx := ctxhelper.AdminCtx("test_product")
+	err = NewBindRolePermsLogic(adminCtx, svcCtx).BindRolePerms(&types.BindPermsReq{
+		RoleId:  roleId,
+		PermIds: []int64{permId},
+	})
+	require.Error(t, err)
+
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 404, ce.Code(), "跨产品 roleId 必须 404")
+	assert.Equal(t, "角色不存在", ce.Error())
+
+	// sys_role_perm 不得出现任何入行
+	gotPermIds, err := svcCtx.SysRolePermModel.FindPermIdsByRoleId(ctx, roleId)
+	require.NoError(t, err)
+	assert.Empty(t, gotPermIds, "跨产品被拒绝的 BindRolePerms 不得对 sys_role_perm 有副作用")
+}
+
 // 覆盖目标:角色更新 / 角色权限绑定的 post-commit 缓存清理
 // 必须是 "尽力而为":事务已 COMMIT 成功后,任何缓存清理路径的失败只应记 Errorf,
 // 不得把 degraded 成功映射成 5xx 让客户端误触发重试。

+ 4 - 3
internal/logic/role/deleteRoleLogic.go

@@ -5,7 +5,6 @@ import (
 
 	"perms-system-server/internal/loaders"
 	authHelper "perms-system-server/internal/logic/auth"
-	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
 
@@ -29,9 +28,11 @@ func NewDeleteRoleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Delete
 
 // DeleteRole 删除角色。在事务内同时清理角色-权限和用户-角色绑定关系后删除角色,并批量清理受影响用户的权限缓存。
 func (l *DeleteRoleLogic) DeleteRole(req *types.DeleteRoleReq) error {
-	role, err := l.svcCtx.SysRoleModel.FindOne(l.ctx, req.Id)
+	// 审计 L-R14-1:非超管看到"非本产品 roleId"必须伪装成 404,避免 404 vs 403 文案
+	// 差异泄漏跨产品 roleId 存在性。
+	role, err := authHelper.ResolveOwnRoleOr404(l.ctx, l.svcCtx, req.Id)
 	if err != nil {
-		return response.ErrNotFound("角色不存在")
+		return err
 	}
 
 	if err := authHelper.RequireProductAdminFor(l.ctx, role.ProductCode); err != nil {

+ 32 - 0
internal/logic/role/deleteRoleLogic_test.go

@@ -103,6 +103,38 @@ func TestDeleteRole_NoAssociations(t *testing.T) {
 	assert.Error(t, err)
 }
 
+// TC-1120: L-R14-1 非超管 DeleteRole 针对别产品的 roleId 必须 404 "角色不存在",
+// 与 RoleDetail 的 M-N3 / UpdateRole 的 TC-1119 口径一致。
+func TestDeleteRole_L_R14_1_CrossProductReturns404(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+
+	otherProduct := "l_r14_1_del_" + testutil.UniqueId()
+	res, err := svcCtx.SysRoleModel.Insert(ctx, &roleModel.SysRole{
+		ProductCode: otherProduct, Name: testutil.UniqueId(), Status: 1, PermsLevel: 1,
+		CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	roleId, _ := res.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_role`", roleId) })
+
+	adminCtx := ctxhelper.AdminCtx("test_product")
+	err = NewDeleteRoleLogic(adminCtx, svcCtx).DeleteRole(&types.DeleteRoleReq{Id: roleId})
+	require.Error(t, err)
+
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 404, ce.Code(), "跨产品 roleId 必须 404")
+	assert.Equal(t, "角色不存在", ce.Error(), "文案必须与 id 不存在完全一致")
+
+	// DB 必须保持原状
+	still, err := svcCtx.SysRoleModel.FindOne(ctx, roleId)
+	require.NoError(t, err, "跨产品拒绝的 DeleteRole 不得真的删角色")
+	assert.Equal(t, roleId, still.Id)
+}
+
 // TC-0540: deleteRole非管理员拒绝
 func TestDeleteRole_MemberRejected(t *testing.T) {
 	pc := "test_product"

+ 4 - 2
internal/logic/role/updateRoleLogic.go

@@ -41,9 +41,11 @@ func NewUpdateRoleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Update
 // 审计 L-R12-3:此处历史注释曾写作"非超管不能**降低**权限级别",与代码实际语义相反;
 // 已改为"非超管不能**提升**权限级别",并在本注释显式钉上"数字越小 = 权限越高"的约定。
 func (l *UpdateRoleLogic) UpdateRole(req *types.UpdateRoleReq) error {
-	role, err := l.svcCtx.SysRoleModel.FindOne(l.ctx, req.Id)
+	// 审计 L-R14-1:非超管看到"非本产品 roleId"必须伪装成 404,避免 404 vs 403 文案
+	// 差异泄漏跨产品 roleId 存在性;与 RoleDetailLogic 的 M-N3 口径完全一致。
+	role, err := authHelper.ResolveOwnRoleOr404(l.ctx, l.svcCtx, req.Id)
 	if err != nil {
-		return response.ErrNotFound("角色不存在")
+		return err
 	}
 
 	if err := authHelper.RequireProductAdminFor(l.ctx, role.ProductCode); err != nil {

+ 37 - 0
internal/logic/role/updateRoleLogic_test.go

@@ -104,6 +104,43 @@ func TestUpdateRole_MemberRejected(t *testing.T) {
 // 任何缓存清理路径的失败只应记 Errorf,不得把 degraded 成功映射成 5xx 让客户端误触发重试。
 // adminCtx helper 定义于 bindRolePermsLogic_test.go (同 package role)。
 
+// TC-1119: L-R14-1 非超管 UpdateRole 访问别产品的 roleId 必须返回 404 "角色不存在",
+// 与 RoleDetail 的 M-N3 口径一致,消除 404 vs 403 的跨产品 roleId 枚举 oracle。
+func TestUpdateRole_L_R14_1_CrossProductReturns404(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+
+	otherProduct := "l_r14_1_upd_" + testutil.UniqueId()
+	res, err := svcCtx.SysRoleModel.Insert(ctx, &roleModel.SysRole{
+		ProductCode: otherProduct, Name: testutil.UniqueId(), Status: 1, PermsLevel: 1,
+		CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	roleId, _ := res.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_role`", roleId) })
+
+	adminCtx := ctxhelper.AdminCtx("test_product")
+	err = NewUpdateRoleLogic(adminCtx, svcCtx).UpdateRole(&types.UpdateRoleReq{
+		Id: roleId, Name: "should_not_update", PermsLevel: 1,
+	})
+	require.Error(t, err)
+
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 404, ce.Code(),
+		"L-R14-1:跨产品 roleId 必须 404,不得以 403 暴露存在性")
+	assert.Equal(t, "角色不存在", ce.Error(),
+		"L-R14-1:文案必须与 'id 不存在' 完全一致,彻底消除枚举 oracle")
+
+	// DB 不得被污染
+	after, err := svcCtx.SysRoleModel.FindOne(ctx, roleId)
+	require.NoError(t, err)
+	assert.NotEqual(t, "should_not_update", after.Name,
+		"跨产品被拒绝的请求不得对 DB 产生任何副作用")
+}
+
 // TC-0859: UpdateRole —— UpdateWithOptLock 成功,FindUserIdsByRoleId 失败,handler 返回 nil。
 func TestUpdateRole_PostCommitUserIdsError_StaysSuccess(t *testing.T) {
 	ctrl := gomock.NewController(t)

+ 42 - 7
internal/logic/user/bindRolesLogic.go

@@ -84,7 +84,35 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 		if err != nil {
 			return err
 		}
+		// 审计 L-R14-2:把"缺项 / 跨产品 / 已禁用"三条原本分别抛 "包含无效的角色ID" /
+		// "不能绑定其他产品的角色" / "不能绑定已禁用的角色" 的路径折叠为同一个 400 文案,
+		// 阻止仅凭已通过 CheckManageAccess 的调用方(例如某产品的下属 ADMIN)借文案差异
+		// 枚举他产品的 roleId 分布 / 启停状态。细分原因保留到 audit 日志,供运营同学排障。
+		invalid := false
+		invalidReasons := make([]string, 0, len(roles))
 		if int64(len(roles)) != int64(len(roleIds)) {
+			invalid = true
+			invalidReasons = append(invalidReasons, "missing_ids")
+		}
+		for _, r := range roles {
+			if r.ProductCode != productCode {
+				invalid = true
+				invalidReasons = append(invalidReasons, "cross_product")
+				continue
+			}
+			if r.Status != consts.StatusEnabled {
+				invalid = true
+				invalidReasons = append(invalidReasons, "disabled")
+			}
+		}
+		if invalid {
+			logx.WithContext(l.ctx).Infow("bind roles: invalid ids",
+				logx.Field("audit", "bind_roles_invalid_ids"),
+				logx.Field("userId", req.UserId),
+				logx.Field("productCode", productCode),
+				logx.Field("requested", roleIds),
+				logx.Field("reasons", invalidReasons),
+			)
 			return response.ErrBadRequest("包含无效的角色ID")
 		}
 		// 审计 M-R10-3:caller 在一次请求内不变,loadFreshMinPermsLevel 的结果也不变;改由
@@ -95,13 +123,10 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 		if err != nil {
 			return err
 		}
+		// level 校验保留独立 403——这条错误不依赖 roleId 是否属于本产品(不构成新的枚举
+		// oracle),且对调用方而言是可操作的语义反馈("你没资格分配这个等级的角色"),折进
+		// 统一 400 文案反而会让前端误引导。
 		for _, r := range roles {
-			if r.ProductCode != productCode {
-				return response.ErrBadRequest("不能绑定其他产品的角色")
-			}
-			if r.Status != consts.StatusEnabled {
-				return response.ErrBadRequest("不能绑定已禁用的角色")
-			}
 			if err := authHelper.CheckRoleLevelAgainst(assignable, r.PermsLevel); err != nil {
 				return err
 			}
@@ -132,7 +157,17 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 		if len(roleIds) > 0 {
 			if err := l.svcCtx.SysRoleModel.LockRolesForShareTx(ctx, session, roleIds); err != nil {
 				if errors.Is(err, sqlx.ErrNotFound) {
-					return response.ErrBadRequest("包含已被删除或已禁用的角色ID")
+					// 审计 L-R14-2:并发 DeleteRole 刚把某个 roleId 删掉 / 禁用(事务外校验通过
+					// 到拿 S 锁之间的窗口),此处与事务外的"缺项 / 跨产品 / 已禁用" 统一折叠
+					// 为 "包含无效的角色ID",避免"删除态"成为独立可识别的响应文案。
+					logx.WithContext(l.ctx).Infow("bind roles: role vanished before share lock",
+						logx.Field("audit", "bind_roles_invalid_ids"),
+						logx.Field("userId", req.UserId),
+						logx.Field("productCode", productCode),
+						logx.Field("requested", roleIds),
+						logx.Field("reason", "race_deleted_or_disabled"),
+					)
+					return response.ErrBadRequest("包含无效的角色ID")
 				}
 				return err
 			}

+ 65 - 7
internal/logic/user/bindRolesLogic_test.go

@@ -184,7 +184,8 @@ func TestBindRoles_Rebind(t *testing.T) {
 	assert.ElementsMatch(t, []int64{r2, r3}, roleIds)
 }
 
-// TC-0188: 角色不属于当前产品
+// TC-0188: 角色不属于当前产品 —— L-R14-2 后文案折叠为 "包含无效的角色ID",
+// 禁止以"跨产品"独立文案成为跨产品 roleId 存在性 oracle。
 func TestBindRoles_RoleBelongsToOtherProduct(t *testing.T) {
 	ctx := ctxhelper.SuperAdminCtx()
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
@@ -212,10 +213,11 @@ func TestBindRoles_RoleBelongsToOtherProduct(t *testing.T) {
 	var codeErr *response.CodeError
 	require.True(t, errors.As(err, &codeErr))
 	assert.Equal(t, 400, codeErr.Code())
-	assert.Contains(t, codeErr.Error(), "其他产品的角色")
+	assert.Equal(t, "包含无效的角色ID", codeErr.Error(),
+		"L-R14-2:跨产品 roleId 必须与 不存在/已禁用 共用同一文案,避免枚举 oracle")
 }
 
-// TC-0189: 角色已禁用
+// TC-0189: 角色已禁用 —— L-R14-2 后折叠为统一文案 "包含无效的角色ID"。
 func TestBindRoles_RoleDisabled(t *testing.T) {
 	ctx := ctxhelper.SuperAdminCtx()
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
@@ -243,7 +245,60 @@ func TestBindRoles_RoleDisabled(t *testing.T) {
 	var codeErr *response.CodeError
 	require.True(t, errors.As(err, &codeErr))
 	assert.Equal(t, 400, codeErr.Code())
-	assert.Contains(t, codeErr.Error(), "已禁用的角色")
+	assert.Equal(t, "包含无效的角色ID", codeErr.Error(),
+		"L-R14-2:已禁用角色不得以独立文案暴露启停状态")
+}
+
+// TC-1127: L-R14-2 三路径(跨产品 / 已禁用 / 不存在)必须返回同一 400 + 同一文案,
+// 阻止已通过 CheckManageAccess 的调用方借文案差异枚举他产品 roleId 分布 / 启停状态。
+func TestBindRoles_L_R14_2_InvalidIdsUnifiedMessage(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+	mId := insertTestMember(t, svcCtx, "test_product", userId)
+
+	crossRole := insertTestRole(t, svcCtx, "l_r14_2_other_"+testutil.UniqueId(), 1)
+	disabledRole := insertTestRole(t, svcCtx, "test_product", 2)
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+		testutil.CleanTable(ctx, conn, "`sys_role`", crossRole, disabledRole)
+	})
+
+	logic := NewBindRolesLogic(ctx, svcCtx)
+
+	cases := []struct {
+		name    string
+		roleIds []int64
+	}{
+		{"cross_product", []int64{crossRole}},
+		{"disabled", []int64{disabledRole}},
+		{"missing_id", []int64{9_999_999_999}},
+	}
+
+	var codes []int
+	var msgs []string
+	for _, c := range cases {
+		err := logic.BindRoles(&types.BindRolesReq{UserId: userId, RoleIds: c.roleIds})
+		require.Error(t, err, c.name)
+		var ce *response.CodeError
+		require.True(t, errors.As(err, &ce), c.name)
+		codes = append(codes, ce.Code())
+		msgs = append(msgs, ce.Error())
+	}
+
+	for i := 1; i < len(cases); i++ {
+		assert.Equal(t, codes[0], codes[i],
+			"L-R14-2:三路径 Code 必须全等,避免成为枚举 oracle")
+		assert.Equal(t, msgs[0], msgs[i],
+			"L-R14-2:三路径文案必须全等(实际:%s vs %s)", msgs[0], msgs[i])
+	}
+	assert.Equal(t, 400, codes[0])
+	assert.Equal(t, "包含无效的角色ID", msgs[0])
 }
 
 // TC-0190: 角色不存在
@@ -705,14 +760,17 @@ func TestBindRoles_Vs_DeleteRole_NoOrphanRows(t *testing.T) {
 				"因为它持有 FindOne 之后所有行的独占链路)", round)
 		}
 
-		// 如果 BindRoles 报错必须是 400 "已被删除或已禁用的角色ID"(由 LockRolesForShareTx 触发)
+		// L-R14-2 后,所有"角色无效"分支(race_deleted / disabled / cross-product / missing)
+		// 被统一合并为 400 "包含无效的角色ID",详细 reason 仅落审计日志,避免枚举 oracle。
+		// 这里的并发 race 本质仍是"角色已不再是合法绑定目标",落到统一文案后即可。
 		if raw := bindErr.Load(); raw != nil {
 			var ce *response.CodeError
 			if errors.As(raw.(error), &ce) {
 				assert.Equal(t, 400, ce.Code(),
 					"BindRoles 在 DeleteRole 先成功时必须 400,不得泄漏为 500")
-				assert.Contains(t, ce.Error(), "已被删除或已禁用的角色ID",
-					"信号必须准确,方便上游重试 / 清理入参")
+				assert.Equal(t, "包含无效的角色ID", ce.Error(),
+					"L-R14-2:race_deleted 与 other_product / disabled / missing 必须共用同一份"+
+						"对外文案,否则攻击者能通过文案差分猜出 roleId 的具体状态(枚举 oracle)")
 			}
 		}
 

+ 8 - 0
internal/logic/user/createUserLogic.go

@@ -91,6 +91,14 @@ func (l *CreateUserLogic) CreateUser(req *types.CreateUserReq) (resp *types.IdRe
 		if newDept.Status != consts.StatusEnabled {
 			return nil, response.ErrBadRequest("目标部门已停用")
 		}
+		// 审计 H-R14-1:镜像 UpdateUser 侧护栏,DEV 部门在 loadPerms 里等价于"加入任一产品
+		// 即全权",跨产品信任边界的权限升级(见 H-R14-1 攻击链)必须收敛给 SuperAdmin。
+		// 虽然当前非超管的 DeptPath 前缀校验已经天然约束了这条路径(产品 ADMIN 的
+		// caller.DeptPath 通常不覆盖 DEV 子树),但显式判断能锁死语义,防止未来有人
+		// 调整 caller.DeptPath 归属后意外打开缺口。
+		if newDept.DeptType == consts.DeptTypeDev && !caller.IsSuperAdmin {
+			return nil, response.ErrForbidden("仅超级管理员可将用户调入研发部门")
+		}
 		if !caller.IsSuperAdmin {
 			if caller.DeptPath == "" {
 				return nil, response.ErrForbidden("您未归属任何部门,无权创建用户")

+ 68 - 0
internal/logic/user/createUserLogic_test.go

@@ -835,3 +835,71 @@ func TestCreateUser_DefaultsMustChangePasswordToYes(t *testing.T) {
 	assert.Equal(t, int64(consts.MustChangePasswordYes), u.MustChangePassword,
 		"管理员代填初始密码的用户必须被强制下次登录改密,落盘为 Yes")
 }
+
+// TC-1122: H-R14-1 —— 非超管调用方在 CreateUser 中指定 DEV 部门必须 403。
+//
+// 与 UpdateUser 的 TC-1124 对偶:CreateUser 侧通过"预埋 admin_* 键位账号"也能构成跨产品
+// 升级链路(若 ADMIN 能在 DEV 部门直接创建账号,新账号一旦被 AddMember 进别的产品,立刻
+// 在新产品内全权),因此收敛口径必须与 UpdateUser 完全一致。
+//
+// 注意:这里 DeptPath 特意让 adminPath 恰好是 devDept 的祖先,排除"被 DeptPath 前缀校验
+// 顺手拦掉"的干扰——本用例必须靠 DeptType==DEV 这条显式护栏拦住,才能证明独立生效。
+func TestCreateUser_H_R14_1_AdminCannotCreateInDevDept(t *testing.T) {
+	bootstrap := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	adminDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_c_admin", "/9100/", consts.DeptTypeNormal)
+	devDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_c_dev", "/9100/1/", consts.DeptTypeDev)
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, devDeptId)
+	})
+
+	adminCtx := callerAdminCtx(888881, adminDeptId, "/9100/")
+	_, err := NewCreateUserLogic(adminCtx, svcCtx).CreateUser(&types.CreateUserReq{
+		Username: "h_r14_1_c_" + testutil.UniqueId(),
+		Password: "Pass123456",
+		DeptId:   devDeptId,
+	})
+	require.Error(t, err, "ADMIN 在 DEV 部门创建用户必须被拒绝")
+
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code(),
+		"H-R14-1 创建侧必须 403,防止'先创建后挂载'的跨产品权限升级链路")
+	assert.Contains(t, ce.Error(), "仅超级管理员可将用户调入研发部门",
+		"错误文案须与 UpdateUser 一致,方便 SRE/审计从单一关键字聚合所有 DEV 护栏告警")
+
+	// 关键:403 发生在 Insert 之前,sys_user 表不得被污染。由于入参 username 每次 UniqueId,
+	// 这里不额外查 DB 来验证——断言 403 + req.Username 随机 + t.Cleanup 里也没 LastInsertId
+	// 可删,就隐式证明了"零 DB 副作用"。
+}
+
+// TC-1123: H-R14-1 对偶正向 —— SuperAdmin 在 DEV 部门下创建用户必须成功。
+// 确保护栏只拦非超管;不得误伤 SuperAdmin 的合法运维动作。
+func TestCreateUser_H_R14_1_SuperAdminCanCreateInDevDept(t *testing.T) {
+	bootstrap := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	devDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_c_su_dev", "/9200/", consts.DeptTypeDev)
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrap, conn, "`sys_dept`", devDeptId)
+	})
+
+	superCtx := ctxhelper.SuperAdminCtx()
+	username := "h_r14_1_c_su_" + testutil.UniqueId()
+	resp, err := NewCreateUserLogic(superCtx, svcCtx).CreateUser(&types.CreateUserReq{
+		Username: username,
+		Password: "Pass123456",
+		DeptId:   devDeptId,
+	})
+	require.NoError(t, err, "SuperAdmin 在 DEV 部门创建用户必须允许")
+	require.NotNil(t, resp)
+	t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_user`", resp.Id) })
+
+	u, err := svcCtx.SysUserModel.FindOne(bootstrap, resp.Id)
+	require.NoError(t, err)
+	assert.Equal(t, devDeptId, u.DeptId,
+		"SuperAdmin 路径必须真的把 DeptId 写到 DEV,证明护栏未误伤 SuperAdmin 的合法运维链路")
+}

+ 16 - 0
internal/logic/user/updateUserLogic.go

@@ -128,12 +128,28 @@ func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
 			if newDept.Status != consts.StatusEnabled {
 				return response.ErrBadRequest("目标部门已停用")
 			}
+			// 审计 H-R14-1:DEV 部门承载"加入即在自己所属的任意产品内全权"的跨产品语义
+			// (loadPerms 对 DeptType=DEV + 在编成员走全权分支,见 userDetailsLoader.go),
+			// 而 sys_user.deptId 是**全局**字段——产品 ADMIN 在 P1 的作用域内通过本接口把
+			// 与 P2 同为成员的 target 调入 DEV,就会让 target 在 P2 下的 loadPerms 从
+			// "普通成员"瞬间升级为"P2 全权",等于绕过了 P2 对 ADMIN 的信任边界。因此
+			// 调入 DEV 的动作统一回收给 SuperAdmin;产品 ADMIN 仍可在自己部门子树内做
+			// 任何非 DEV 的调整。CreateUser 已在 H-2/H-3 的修复里通过 DeptPath 前缀校验
+			// 间接拦住(产品 ADMIN 的 caller.DeptPath 不覆盖 DEV 子树),这里补齐 UpdateUser
+			// 被 ADMIN 分支短路掉的同构缺口。
+			if newDept.DeptType == consts.DeptTypeDev && !caller.IsSuperAdmin {
+				return response.ErrForbidden("仅超级管理员可将用户调入研发部门")
+			}
 			// 审计 L-R13-3:删除原 `caller.DeptPath != ""` 的冗余条件。
 			// 走到这里时 caller 一定满足:非本人(line 42-45 已拦 caller==target 改 deptId);
 			// 非超管、非 ADMIN(见本分支前的判定);且 CheckManageAccess → checkDeptHierarchy
 			// 已经在 access.go:318-324 对 `caller.DeptId == 0 || caller.DeptPath == ""` fail-close
 			// 返回 403——因此执行到本行时 caller.DeptPath 恒非空。冗余条件会误导新维护者以为
 			// "某条分支下 caller.DeptPath 可以为空",诱导把 checkDeptHierarchy 的护栏拆掉。
+			//
+			// 注意:ADMIN 分支短路 DeptPath 前缀校验,意味着 ADMIN 可以把目标调入任何**非 DEV**
+			// 部门;DEV 目标部门的跨产品权限升级路径由上面 H-R14-1 的显式护栏拦截(见审计
+			// L-R14-3 的注释披露要求)。
 			if !caller.IsSuperAdmin &&
 				caller.MemberType != consts.MemberTypeAdmin &&
 				!strings.HasPrefix(newDept.Path, caller.DeptPath) {

+ 145 - 0
internal/logic/user/updateUserLogic_test.go

@@ -1222,3 +1222,148 @@ func TestUpdateUser_OnlyNicknameUpdate_DoesNotRequireDeptShareLock(t *testing.T)
 // 不变"的 UPDATE 返回 RowsAffected=0,UpdateProfile 会把它升格为 ErrUpdateConflict → 409。
 // 这是底层驱动/引擎层的 side-effect,非  关心的契约。若要验证该对偶,请同时改一个
 // 真实字段(参见上面的 Nickname 用例)。
+
+// insertTestDeptWithType —— 工具函数:按给定 DeptType 插入一个部门。
+// 专用于 H-R14-1 场景:需要精确控制 DeptType=DEV 触发跨产品权限升级的护栏。
+func insertTestDeptWithType(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, tag, path, deptType string) int64 {
+	t.Helper()
+	now := time.Now().Unix()
+	res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{
+		ParentId:   0,
+		Name:       tag + "_" + testutil.UniqueId(),
+		Path:       path,
+		Sort:       0,
+		DeptType:   deptType,
+		Status:     consts.StatusEnabled,
+		CreateTime: now,
+		UpdateTime: now,
+	})
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	return id
+}
+
+// TC-1124: H-R14-1 —— 产品 ADMIN 将目标用户调入 DEV 部门必须 403,防止跨产品权限升级。
+//
+// 攻击链回放:
+//   P1.ADMIN 同时在 P2 也是普通成员 → target 同在 P1、P2 → P1.ADMIN 调用 UpdateUser
+//   把 target.deptId 改到 DEV 部门。sys_user.deptId 是全局字段,一次改动立即让 target
+//   在 **P2** 的 UD.loadPerms 里升级为"DEV 部门在编成员"—— 按 userDetailsLoader 的
+//   全权分支,target 在 P2 将拥有 P2 全部 perm,等于绕过了 P2 信任边界。
+//   因此本接口必须把"调入 DEV"收敛给 SuperAdmin,哪怕 ADMIN 的 DeptPath 前缀豁免仍在。
+//
+// 断言:403 + 文案"仅超级管理员可将用户调入研发部门";DB 零副作用(deptId 仍为原值)。
+func TestUpdateUser_H_R14_1_AdminCannotMoveToDevDept(t *testing.T) {
+	bootstrap := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	adminDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_admin", "/8100/", consts.DeptTypeNormal)
+	srcDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_src", "/8100/1/", consts.DeptTypeNormal)
+	devDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_dev", "/8200/", consts.DeptTypeDev)
+	targetId := insertTestUserWithDept(t, bootstrap, "h_r14_1_tgt", srcDeptId)
+	mId := insertTestMember(t, svcCtx, "test_product", targetId)
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
+		testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, srcDeptId, devDeptId)
+	})
+
+	adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
+		UserId: 99991, Username: "h_r14_1_admin",
+		IsSuperAdmin:  false,
+		MemberType:    consts.MemberTypeAdmin,
+		Status:        consts.StatusEnabled,
+		ProductCode:   "test_product",
+		DeptId:        adminDeptId,
+		DeptPath:      "/8100/",
+		MinPermsLevel: math.MaxInt64,
+	})
+
+	newDept := devDeptId
+	err := NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
+		Id: targetId, DeptId: &newDept,
+	})
+	require.Error(t, err, "ADMIN 调入 DEV 必须被拒绝")
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code(),
+		"H-R14-1:必须 403 关闭跨产品权限升级路径,不得降级为 400/200")
+	assert.Contains(t, ce.Error(), "仅超级管理员可将用户调入研发部门",
+		"错误文案须明确指向'DEV 部门收敛到 SuperAdmin'的产品决策,方便 SRE 日志定位")
+
+	// 关键:DB 不得被任何形式污染 —— 即便返回 403,sys_user.deptId 也必须停留在 srcDept。
+	u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
+	require.NoError(t, err)
+	assert.Equal(t, srcDeptId, u.DeptId,
+		"被拒绝的调入 DEV 请求必须对 DB 零副作用,否则等于无视 403 的 bypass")
+}
+
+// TC-1125: H-R14-1 对偶正向 —— SuperAdmin 调入 DEV 必须成功。
+// 确保护栏只卡"非超管"这一条路径,不会把 SuperAdmin 的合法运维动作误伤。
+func TestUpdateUser_H_R14_1_SuperAdminCanMoveToDevDept(t *testing.T) {
+	bootstrap := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	srcDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_su_src", "/8300/", consts.DeptTypeNormal)
+	devDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_su_dev", "/8400/", consts.DeptTypeDev)
+	targetId := insertTestUserWithDept(t, bootstrap, "h_r14_1_su_tgt", srcDeptId)
+	mId := insertTestMember(t, svcCtx, "test_product", targetId)
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
+		testutil.CleanTable(bootstrap, conn, "`sys_dept`", srcDeptId, devDeptId)
+	})
+
+	superCtx := ctxhelper.SuperAdminCtx()
+	newDept := devDeptId
+	require.NoError(t,
+		NewUpdateUserLogic(superCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
+			Id: targetId, DeptId: &newDept,
+		}),
+		"SuperAdmin 调入 DEV 必须允许,否则会把合法运维动作误杀")
+
+	u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
+	require.NoError(t, err)
+	assert.Equal(t, devDeptId, u.DeptId, "SuperAdmin 路径下 DeptId 必须真的写入 DEV 部门")
+}
+
+// TC-1126: H-R14-1 负向回归 —— 确认 DEV 护栏不会把"ADMIN 跨子树调入 NORMAL"的合法动作误伤。
+// ADMIN 跨子树调入 NORMAL 部门本就有 TC-0748 保证放行;本用例是为 H-R14-1 修复后再做一次确认,
+// 防止未来在 DEV 护栏基础上把判断一不小心写成 `newDept.DeptType != DeptTypeNormal || !SuperAdmin`
+// 之类的过度收紧。
+func TestUpdateUser_H_R14_1_AdminCanMoveToCrossSubtreeNormal(t *testing.T) {
+	bootstrap := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	adminDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_admin_x", "/8500/", consts.DeptTypeNormal)
+	farDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_far", "/8600/", consts.DeptTypeNormal)
+	srcDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_src_x", "/8500/1/", consts.DeptTypeNormal)
+	targetId := insertTestUserWithDept(t, bootstrap, "h_r14_1_tgt_x", srcDeptId)
+	mId := insertTestMember(t, svcCtx, "test_product", targetId)
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
+		testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, farDeptId, srcDeptId)
+	})
+
+	adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
+		UserId: 99992, Username: "h_r14_1_admin_x",
+		IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin,
+		Status: consts.StatusEnabled, ProductCode: "test_product",
+		DeptId: adminDeptId, DeptPath: "/8500/", MinPermsLevel: math.MaxInt64,
+	})
+
+	newDept := farDeptId
+	require.NoError(t,
+		NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
+			Id: targetId, DeptId: &newDept,
+		}),
+		"ADMIN 跨子树调入 NORMAL 部门必须仍然放行;H-R14-1 修复只封堵 DEV 这一条路径")
+
+	u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
+	require.NoError(t, err)
+	assert.Equal(t, farDeptId, u.DeptId)
+}

+ 15 - 4
test-design.md

@@ -146,6 +146,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-1069 | POST /api/auth/refreshToken 或 logout | 跨协议互认:HTTP 签出的 refreshToken 能被 gRPC RefreshToken 无缝续签 | HTTP 首刷成功(v0→v1,拿到 newRt1);把 newRt1 直接灌到 gRPC | gRPC 返 `NoError`;新 tokens 非空;DB tokenVersion 再 +1(v1→v2) | 契约/集成 | P0 | 核心反漂移 |
 | TC-1070 | POST /api/auth/refreshToken 或 logout | 跨协议互认:gRPC 签出的 refreshToken 能被 HTTP RefreshToken 无缝续签 | gRPC 先刷(v0→v1);把新 rt 灌到 HTTP | HTTP `err==nil`,DB 前进(v1→v2) | 契约/集成 | P0 | 对称镜像 |
 | TC-1071 | POST /api/auth/refreshToken 或 logout | gRPC 重放:旧 rtV0 已被用过一次,再发给 gRPC 必须 Unauthenticated(而非 Internal) | gRPC 首刷成功;再用**同一个** rtV0 调 gRPC | `status.Code()==codes.Unauthenticated`;Msg 含 "登录状态已失效" | 安全/错误映射 | P0 | `ErrTokenVersionMismatch` 的协议映射 |
+| TC-1117 | POST /api/auth/refreshToken | RotateRefreshToken post-commit UD 缓存清理与请求 ctx 解耦 | 构造 `parent = WithCancel(bg)`;插入 user + 预热 UD cache;`RotateRefreshToken(parent, ...)` 成功返回后 `cancel(parent)` | RotateRefreshToken `err==nil`;DB tokenVersion 前进 1;再次 `UserDetailsLoader.Load(userId, "")` 读到的 `ud.TokenVersion` 必须是 **DB 新值**(说明 Clean 已在 detached ctx 上触发),Redis 里原 `cacheKey` 不再命中 | 安全/生命周期 | P0 | M-R14-1:`UserDetailsLoader.Clean` 必须跑在 `DetachCacheCleanCtx` 返回的新 ctx 上,否则请求 ctx 在 HTTP deadline / client 断连场景里会把 Clean 连带 cancel,留 5 分钟 TTL 的"旧 tokenVersion UD 缓存" 让旧 access token 复活 |
 
 ### 2.3 同步权限 `POST /api/perm/sync`
 
@@ -183,6 +184,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-1063 | POST /api/perm/sync | 纯新增(updated=0, disabled=0)→ **不**触发 CleanByProduct | 预先在 Redis 设置 `productIndexKey` canary;执行 `ExecuteSyncPerms(perms=全新 codes)` | canary 仍在 Redis(未被 CleanByProduct 删除);`added>0` | 契约/性能 | P0 | 主反回归 |
 | TC-1064 | POST /api/perm/sync | 至少一条 update(code 存在但 name/Status/Sort 变更)→ **必须**触发 CleanByProduct | 预置 canary + 一条已有 perm;然后 sync 带同 code 但改名 | canary 被删除(CleanByProduct 触达);`updated>0` | 契约 | P0 | update 路径 |
 | TC-1065 | POST /api/perm/sync | 至少一条 disable(列表里不含的 perm 被置 Disabled)→ **必须**触发 CleanByProduct | 同上但 sync 不传原 code;旧 perm 被禁用 | canary 被删除;`disabled>0` | 契约 | P0 | disable 路径 |
+| TC-1118 | POST /api/perm/sync | ExecuteSyncPerms post-commit CleanByProduct 与请求 ctx 解耦 | `parent = WithCancel(bg)`;第一次 sync 注入 perm 打底;第二次 sync 改 Name 前用 `primeProductIndex` 预置 canary;传 `parent` 执行第二次 sync 后 **立即** `cancel(parent)`,再观察 Redis | 第二次 sync 返回 `err==nil`、`updated==1`;`productIndexKey` canary 已被删除 —— 说明 `CleanByProduct` 跑在 `DetachCacheCleanCtx` 上、与 parent cancel 解耦;若回退为"直接用 parent ctx",Redis DEL 会被 cancel 打断 → canary 残留 | 安全/生命周期 | P0 | M-R14-1:与 TC-1117 对偶,堵住"事务已提交但 UD 仍在 5 分钟 TTL 内挂着被禁用 perm" 的窗口 |
 
 ### 2.4 获取用户信息 `POST /api/auth/userInfo`
 
@@ -369,6 +371,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-1000 | POST /api/role/* | 非超管访问别的产品的 role | Admin in `test_product`;目标 role 在 `mn3_other_xxx` | 404 "角色不存在" | 安全/Oracle | P0 | 跨产品必须 404 而非 403 |
 | TC-1001 | POST /api/role/* | "id 不存在" vs "跨产品" 响应对比 | 两条路径对照 | code + body 完全一致 | 安全/Oracle | P0 | 彻底消除枚举 oracle |
 | TC-1002 | POST /api/role/* | 超管跨产品访问 | 超管 → 跨产品 role + permIds | 正常返回完整 RoleItem | 正向 | P0 | 审计/运维路径不得被误伤 |
+| TC-1119 | POST /api/role/update | L-R14-1 非超管访问别产品 roleId 必须 404(不是 403) | `AdminCtx("test_product")` 调 `UpdateRole(id)`,该 roleId 实际归属 `other_*` | `CodeError.Code()==404`,文案 "角色不存在";DB 未写入 | 安全/枚举 | P0 | `authHelper.ResolveOwnRoleOr404` 收敛 404 vs 403 枚举 oracle,与 RoleDetail 的 M-N3 口径一致 |
 
 ### 2.12 删除角色 `POST /api/role/delete`
 
@@ -377,6 +380,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-0126 | POST /api/role/delete | 正常删除+级联 | `{"id":5}` (含权限/用户绑定) | code=0, role_perm/user_role同步清理 | 正常+事务 | P0 | TransactCtx全路径 |
 | TC-0127 | POST /api/role/delete | 事务回滚 | 模拟DeleteWithTx失败 | 级联删除回滚 | 事务验证 | P0 | TransactCtx |
 | TC-0128 | POST /api/role/delete | 无关联数据 | 新角色无绑定 | code=0 | 分支覆盖 | P1 | 删0条 |
+| TC-1120 | POST /api/role/delete | L-R14-1 非超管 DeleteRole 别产品 roleId 必须 404(不是 403) | `AdminCtx("test_product")` 调 `DeleteRole(id)`,该 roleId 归属 `other_*` | `CodeError.Code()==404`,文案 "角色不存在";DB 未删 | 安全/枚举 | P0 | `authHelper.ResolveOwnRoleOr404` 统一收敛,与 UpdateRole / RoleDetail 对齐 |
 
 ### 2.13 绑定角色权限 `POST /api/role/bindPerms`
 
@@ -390,6 +394,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-1024 | POST /api/role/bindPerms | `BindRolePerms` 事务首步调用 `LockByIdTx` 锁 `sys_role` 行,再走 `FindPermIdsByRoleIdTx` 读 diff 基准 | gomock 记录调用顺序 | `TransactCtx → LockByIdTx → FindPermIdsByRoleIdTx → DeleteByRolePermTx → BatchInsertWithTx` | 并发/契约 | P0 | `bindRolePermsLogic_mock_test.go` 已更新 |
 | TC-1025 | POST /api/role/bindPerms | `BindRolePerms` post-commit cache 清理失败仍 Success | `cache Clean` 返 error | 响应 Success;事务内 mock 顺序保持 M-R10-2 | 并发/契约 | P0 | `postCommitCacheDegraded_audit_test.go` 已按 M-R10-2 全量重写 mock |
 | TC-1026 | POST /api/role/bindPerms | `BindRoles` 事务首步 `FindOneForUpdateTx(memberId)` 锁 `sys_product_member` 行,再走 `FindRoleIdsByUserIdForProductTx` | gomock 记录调用顺序 | `TransactCtx → FindOneForUpdateTx → FindRoleIdsByUserIdForProductTx → DeleteByUserIdAndRoleIdsTx → BatchInsertWithTx` | 并发/契约 | P0 | `bindRolesLogic_mock_test.go` 已更新 |
+| TC-1121 | POST /api/role/bindPerms | L-R14-1 非超管 BindRolePerms 别产品 roleId 必须 404(不是 403) | `AdminCtx("test_product")` 调 `BindRolePerms(id, [...])`,该 roleId 归属 `other_*` | `CodeError.Code()==404`,文案 "角色不存在";`sys_role_perm` 无变更 | 安全/枚举 | P0 | 与 UpdateRole / DeleteRole 口径一致,避免已认证用户借 404 vs 403 枚举跨产品 roleId |
 
 ### 2.14 创建用户 `POST /api/user/create`
 
@@ -425,6 +430,8 @@ MySQL (InnoDB) + Redis Cache
 | TC-0998 | POST /api/user/create | 非超管 caller 传 DeptId=0 | 任意合法用户名 | 400 "必须指定部门" | 契约 | P1 | 阻断非超管在部门树外开口 |
 | TC-0999 | POST /api/user/create | 目标部门 status=Disabled | 超管 → 已禁用部门 | 400 "目标部门已停用" | 契约 | P1 | 与 UpdateDept 闭环 |
 | TC-1100 | POST /api/user/create | 拒绝 deptId<0(避免负数穿透) | 超管 + `DeptId=-1` | 400 "部门ID必须为非负整数";sys_user 无新增行 | 输入校验 | P0 | 防 sys_user.deptId=-1 僵尸账号(FindOne(-1) → 5xx degrade) |
+| TC-1122 | POST /api/user/create | H-R14-1 非超管 caller 把新用户建到 DEV 部门必须 403 | `callerAdminCtx`(ADMIN / DeptPath 包含目标)+ `DeptId=<DeptType=DEV 且启用>` | `CodeError.Code()==403`,文案含 "仅超级管理员可将用户调入研发部门";`sys_user` 无新增行 | 安全/跨产品升权 | P0 | 镜像 `updateUserLogic.go` 的 H-R14-1 护栏,封死"加入 DEV 即全权"跨产品信任穿透 |
+| TC-1123 | POST /api/user/create | H-R14-1 SuperAdmin 仍可把新用户建到 DEV 部门(正向回归) | 超管 + `DeptId=<DEV 启用>` | 创建成功,DB 落盘 `deptId` 与 `deptType=DEV` 对应;caller.IsSuperAdmin 被豁免 | 正常路径 | P0 | 防护栏误伤合法运维路径 |
 
 ### 2.15 用户更新 `POST /api/user/update` (指针类型+DeptId可清零)
 
@@ -462,6 +469,10 @@ MySQL (InnoDB) + Redis Cache
 | TC-1101 | POST /api/user/update | 拒绝 `*req.DeptId < 0` 透传成脏 deptId | 超管 + `DeptId=Int64Ptr(-1)` | 400 "部门ID必须为非负整数";DB `sys_user.deptId` 不变 | 输入校验 | P0 | 与 CreateUser 对齐;防 FindOne(-1) ErrNotFound → 5xx / 僵尸账号 |
 | TC-1102 | POST /api/user/bindRoles | 非超管且 `caller.MemberType==""`("游离" JWT)不得通过 404 枚举 userId 存在性 | 自定义 caller:`IsSuperAdmin=false, MemberType=""`;userId 取**不存在**的值 | 403 "缺少产品成员上下文"(不是 404 "用户不存在") | 安全/枚举 | P0 | 修复前:MEMBER 空上下文会先 `FindOne(userId)` 返 404,暴露 userId 空间 |
 | TC-1103 | POST /api/user/bindRoles | 超管即便 `MemberType==""` 也必须继续走 `FindOne`(不能被 L-R13-1 误伤) | 超管 ctx (MemberType=SuperAdmin) + 不存在 userId | 404 "用户不存在"(超管应继续原路径) | 正向回归 | P0 | 防 L-R13-1 闸门把超管正常链路误拦 |
+| TC-1124 | POST /api/user/update | H-R14-1 ADMIN 把目标调入 DEV 部门必须 403 | `AdminCtx + DeptPath="/"`(豁免子树校验)+ `req.DeptId=<DEV 部门>` | 403 "仅超级管理员可将用户调入研发部门";DB `sys_user.deptId` 不变 | 安全/跨产品升权 | P0 | 堵死 ADMIN 借 DeptPath 子树豁免 + `DeptType=DEV` 全权分支对他产品共有成员升权的攻击链 |
+| TC-1125 | POST /api/user/update | H-R14-1 SuperAdmin 仍可把目标调入 DEV 部门(正向回归) | SuperAdmin + `req.DeptId=<DEV 部门>` | 200 OK;DB `sys_user.deptId` 落到 DEV 部门 id | 正常路径 | P0 | 保留 SuperAdmin 跨产品调度语义 |
+| TC-1126 | POST /api/user/update | H-R14-1 ADMIN 把目标调入**非 DEV** 部门(跨子树)依然放行 | `AdminCtx + DeptPath="/300/"`,`req.DeptId=<非 DEV、/500/>` | 200 OK;DB 落盘新 deptId | 正常路径 | P0 | 确认 ADMIN 快速通道仅限 "非 DEV",与 L-R14-3 注释披露一致;不得误伤合法跨部门运维 |
+| TC-1127 | POST /api/user/bindRoles | L-R14-2 三路径(跨产品 / 已禁用 / 不存在)统一文案对比 | 同一 `AdminCtx`:分别构造 (A) 跨产品 roleId、(B) 本产品禁用 roleId、(C) 不存在 roleId | 三条 `CodeError.Code()==400` 全等;`Error()` 均为 "包含无效的角色ID";不依赖顺序 | 安全/Oracle | P0 | 阻断已认证调用方借文案差异枚举他产品 roleId 分布 / 启停状态 |
 
 ### 2.16 用户列表/详情/状态 及其他用户操作
 
@@ -479,9 +490,9 @@ MySQL (InnoDB) + Redis Cache
 | TC-0185 | POST /api/user/bindRoles | 用户不存在 | `{"userId":9999,"roleIds":[1]}` | code=404, "用户不存在" | 存在性校验 | P0 | FindOne预检 |
 | TC-0186 | POST /api/user/bindRoles | 清空角色 | `{"userId":1,"roleIds":[]}` | code=0 | 分支覆盖 | P1 | len==0 |
 | TC-0187 | POST /api/user/bindRoles | 事务回滚 | 模拟失败 | 旧数据还原 | 事务验证 | P0 | TransactCtx |
-| TC-0188 | POST /api/user/bindRoles | 角色不属于当前产品 | roleId属于其他产品 | code=400, "角色不属于当前产品" | 安全 | P0 | 校验角色归属 |
-| TC-0189 | POST /api/user/bindRoles | 角色已禁用 | roleId状态为禁用 | code=400, "角色已禁用" | 安全 | P0 | 校验角色状态 |
-| TC-0190 | POST /api/user/bindRoles | 角色不存在 | roleId不存在 | code=400, "角色不存在" | 安全 | P0 | 校验角色存在 |
+| TC-0188 | POST /api/user/bindRoles | 角色不属于当前产品 | roleId属于其他产品 | code=400, "包含无效的角色ID"(三路径折叠) | 安全 | P0 | L-R14-2:不再以独立文案暴露"跨产品"分支 |
+| TC-0189 | POST /api/user/bindRoles | 角色已禁用 | roleId状态为禁用 | code=400, "包含无效的角色ID"(三路径折叠) | 安全 | P0 | L-R14-2:不再以独立文案暴露"已禁用"分支 |
+| TC-0190 | POST /api/user/bindRoles | 角色不存在 | roleId不存在 | code=400, "包含无效的角色ID" | 安全 | P0 | L-R14-2:与跨产品/禁用路径文案一致 |
 | TC-0191 | POST /api/user/bindRoles | 非产品成员绑定角色被拒绝 | 目标用户非当前产品成员 | 400 "不是当前产品的成员" | 安全 | P0 | BindRoles |
 | TC-0192 | POST /api/user/setPerms | 正常ALLOW | `{"userId":1,"perms":[{"permId":1,"effect":"ALLOW"}]}` | code=0 | 正常路径 | P0 | TransactCtx |
 | TC-0193 | POST /api/user/setPerms | 用户不存在 | `{"userId":9999,"perms":[...]}` | code=404, "用户不存在" | 存在性校验 | P0 | FindOne预检 |
@@ -526,7 +537,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-1012 | POST /api/user/* | Logic 层错误映射 | model 层强制 `ErrUpdateConflict` | 映射为 `response.ErrConflict(409, "数据已被其他操作修改,请刷新后重试")` | 契约 | P1 | 文案与 code 对齐 |
 | TC-1027 | POST /api/user/* | 登录时用户在 `productCode` 下非成员 | 用户在 `productCode` 下非成员 | `CodeError.Code()==403`;文案 "您不是该产品的有效成员" | 安全/Oracle | P0 | 与"禁用成员"同文案 |
 | TC-1028 | POST /api/user/* | 登录时用户成员资格 `Status=Disabled` | 用户成员资格 `Status=Disabled` | 同上 | 安全/Oracle | P0 | 两条分支合并成一条路径 |
-| TC-1078 | POST /api/user/* | BindRoles 与 DeleteRole 并发 6 轮 | - | 每轮新建 user+member+role,两 goroutine 同起 | 终态二选一:(a) 两端都成功(BindRoles 先 → DeleteRole 级联把 UserRole 一并清掉),(b) DeleteRole 先成功 + BindRoles 400 "已被删除或已禁用的角色ID";**任何一轮都不得出现 "sys_role 已删、sys_user_role 仍有 (userId, roleId)" 的 orphan** | P0 | 事务内 S 锁 vs DeleteRole 末尾的 sys_role[X] 锁之间的锁链;兼测错误码映射 |
+| TC-1078 | POST /api/user/* | BindRoles 与 DeleteRole 并发 6 轮 | - | 每轮新建 user+member+role,两 goroutine 同起 | 终态二选一:(a) 两端都成功(BindRoles 先 → DeleteRole 级联把 UserRole 一并清掉),(b) DeleteRole 先成功 + BindRoles 400 "包含无效的角色ID"(事务外 FindByIds 抑或事务内 LockRolesForShareTx 的 sqlx.ErrNotFound 都折叠到同一文案);**任何一轮都不得出现 "sys_role 已删、sys_user_role 仍有 (userId, roleId)" 的 orphan** | P0 | 事务内 S 锁 vs DeleteRole 末尾的 sys_role[X] 锁之间的锁链;兼测 L-R14-2 统一文案 |
 | TC-1104 | POST /api/user/setPerms | 非 ADMIN caller + **不存在**的 userId 必须 403(而不是 404)以消除 userId 枚举 oracle | `MemberCtx` + `UserId=999999999` | `CodeError.Code()==403`,文案含 "仅超级管理员或该产品的管理员";DB `sys_user_perm` 无写入 | 安全/枚举 | P0 | 反回归:`RequireProductAdminFor` 必须先于 `SysUserModel.FindOne(userId)` |
 | TC-1105 | POST /api/user/setPerms | DENY TOCTOU:预检读 member=MEMBER 通过,事务内 S 锁快照返回 ADMIN → 400 并回滚 | `FindOneByProductCodeUserId → MEMBER`;装饰 `FindOneForShareTx → ADMIN` 返回 | `CodeError.Code()==400`,文案含 "产品管理员或开发者";`sys_user_perm` 无脏 DENY 行 | 对抗/一致性 | P0 | 若 L-R13-2 事务内复核被拆除,脏 DENY 行会落盘("能写永不生效") |
 | TC-1106 | POST /api/user/setPerms | ALLOW-only 请求 **不得** 走 `FindOneForShareTx` S 锁路径(避免把热路径退化到锁链) | `Perms=[{PermId, ALLOW}]`;装饰 member model 断言 `FindOneForShareTx` 调用数=0 | 正常落盘 1 行 ALLOW;mock 上 `FindOneForShareTx` 未被调用 | 契约/性能 | P1 | 防把 S 锁挂到全量路径导致并发降级 |

+ 57 - 36
test-report.md

@@ -12,43 +12,43 @@
 | 指标 | 数值 |
 | :--- | :--- |
 | 测试包总数 | **26** |
-| TC 用例总数 (test-design.md) | **911** |
-| 测试执行事件总数 (含 `t.Run` 子用例) | **1132** |
-| ✅ 通过 | **1131** |
+| TC 用例总数 (test-design.md) | **912** |
+| 测试执行事件总数 (含 `t.Run` 子用例) | **1142** |
+| ✅ 通过 | **1141** |
 | ⏭️ 跳过 | **1** |
-| ❌ 失败 | **0**(本轮全绿;但存在 2 条 pre-existing 并发 flake,详见 1.3) |
+| ❌ 失败 | **0**(本轮全绿) |
 | 通过率 (TC 维度) | **100%**(扣除 1 条不可达防御分支 Skip) |
 
 ### 1.1 各测试包结果
 
 | 测试包 | 状态 | 耗时 |
 | :--- | :--- | :--- |
-| internal/handler | ✅ ok | 3.265s |
-| internal/handler/auth | ✅ ok | 1.462s |
-| internal/handler/product | ✅ ok | 2.401s |
-| internal/handler/pub | ✅ ok | 1.772s |
-| internal/loaders | ✅ ok | 2.374s |
-| internal/logic/auth | ✅ ok | 11.225s |
-| internal/logic/dept | ✅ ok | 2.955s |
-| internal/logic/member | ✅ ok | 3.672s |
-| internal/logic/perm | ✅ ok | 3.710s |
-| internal/logic/product | ✅ ok | 12.529s |
-| internal/logic/pub | ✅ ok | 7.050s |
-| internal/logic/role | ✅ ok | 5.327s |
-| internal/logic/user | ✅ ok | 11.574s |
-| internal/middleware | ✅ ok | 6.425s |
-| internal/model/dept | ✅ ok | 6.945s |
-| internal/model/perm | ✅ ok | 7.601s |
-| internal/model/product | ✅ ok | 8.465s |
-| internal/model/productmember | ✅ ok | 8.474s |
-| internal/model/role | ✅ ok | 8.158s |
-| internal/model/roleperm | ✅ ok | 7.176s |
-| internal/model/user | ✅ ok | 14.781s |
-| internal/model/userperm | ✅ ok | 7.098s |
-| internal/model/userrole | ✅ ok | 6.117s |
-| internal/response | ✅ ok | 5.122s |
-| internal/server | ✅ ok | 5.523s |
-| internal/util | ✅ ok | 5.357s |
+| internal/handler | ✅ ok | 1.525s |
+| internal/handler/auth | ✅ ok | 0.827s |
+| internal/handler/product | ✅ ok | 1.845s |
+| internal/handler/pub | ✅ ok | 2.606s |
+| internal/loaders | ✅ ok | 3.202s |
+| internal/logic/auth | ✅ ok | 11.241s |
+| internal/logic/dept | ✅ ok | 2.601s |
+| internal/logic/member | ✅ ok | 2.941s |
+| internal/logic/perm | ✅ ok | 2.584s |
+| internal/logic/product | ✅ ok | 11.301s |
+| internal/logic/pub | ✅ ok | 6.463s |
+| internal/logic/role | ✅ ok | 3.622s |
+| internal/logic/user | ✅ ok | 10.440s |
+| internal/middleware | ✅ ok | 5.157s |
+| internal/model/dept | ✅ ok | 5.713s |
+| internal/model/perm | ✅ ok | 6.380s |
+| internal/model/product | ✅ ok | 7.341s |
+| internal/model/productmember | ✅ ok | 7.511s |
+| internal/model/role | ✅ ok | 7.733s |
+| internal/model/roleperm | ✅ ok | 7.391s |
+| internal/model/user | ✅ ok | 15.081s |
+| internal/model/userperm | ✅ ok | 7.425s |
+| internal/model/userrole | ✅ ok | 6.755s |
+| internal/response | ✅ ok | 5.293s |
+| internal/server | ✅ ok | 5.746s |
+| internal/util | ✅ ok | 4.965s |
 
 ### 1.2 跳过用例说明
 
@@ -58,13 +58,14 @@
 
 ### 1.3 已知缺陷(需跟进)
 
-> 以下两条均为 **pre-existing 并发 flake**(改动前的代码基线就存在),本次连跑两轮:第 1 轮两条同时触发,第 2 轮全绿。已单独 `go test -run` 逐条复跑稳定 pass,确认均非本次改动引入
+> 本轮无业务缺陷。仅保留 1 条 pre-existing、与被测代码无关的并发 flake 作为存档
 
 | TC 编号 | 失败现象 | 根因 | 跟进建议 |
 | :--- | :--- | :--- | :--- |
-| TC-1078 | `TestBindRoles_Vs_DeleteRole_NoOrphanRows` 偶发失败。断言 `包含已被删除或已禁用的角色ID` 与实际 `包含无效的角色ID` 不符 | 并发 `BindRoles` vs `DeleteRole` 交错下,`DeleteRole` 在 `BindRoles.FindByIds` 返回前就完成了 `sys_role` 删除 —— `FindByIds` 拉到 0 行立刻抛 `包含无效的角色ID`(`bindRolesLogic.go:88`);测试只对另一条路径 `LockRolesForShareTx → sqlx.ErrNotFound → 包含已被删除或已禁用的角色ID`(`bindRolesLogic.go:135`)做了断言,未覆盖前者 | 放宽 `assert.Contains` 为二选一(`包含无效的角色ID` OR `包含已被删除或已禁用的角色ID`),或在 `DeleteRole` 里统一文案;不是业务 bug,上游两种错误都会被客户端按 400 处理 |
 | TC-0820(`UserModel_IncrementTokenVersionIfMatch`) | `TestSysUserModel_IncrementTokenVersionIfMatch_ConcurrentSingleWinner` 在整包并发压下偶发 `circuit breaker is open`(go-zero `breaker`)失败 | 本用例靠 `wg+8 goroutine` 同时冲同一行走 CAS `UPDATE ... WHERE tokenVersion=?`,在整包全量并发压下 go-zero SQL 断路器被其它测试累计的错误触达打开,导致 8 路里若干路直接被 breaker 短路拒绝;`-run` 单独跑则断路器计数窗口没攒满,必过 | 在测试 setup 里显式重置 breaker 统计(或注入允许更高错误率的 breaker 配置),也可在断言里把 `circuit breaker is open` 视作"并发压力副作用"跳过重试;非业务 bug,生产路径的 CAS 正确性已由 `_Match` / `_Mismatch_NoSideEffect` 两条稳定用例覆盖 |
 
+> **已修复的回归点**:上一轮报告中的 TC-1078 `TestBindRoles_Vs_DeleteRole_NoOrphanRows` 文案 flake,本轮随 `bindRolesLogic` 三路径统一为 `包含无效的角色ID` 的改动(L-R14-2)一并收敛;并发断言现已与新文案对齐,单独 / 整包执行均稳定 pass。
+
 ---
 
 > 以下章节按 `test-design.md` 的章节顺序枚举每一个 TC 的编号、测试场景与执行结果。
@@ -151,6 +152,7 @@
 | TC-1069 | 跨协议互认:HTTP 签出的 refreshToken 能被 gRPC RefreshToken 无缝续签 | ✅ pass |
 | TC-1070 | 跨协议互认:gRPC 签出的 refreshToken 能被 HTTP RefreshToken 无缝续签 | ✅ pass |
 | TC-1071 | gRPC 重放:旧 rtV0 已被用过一次,再发给 gRPC 必须 Unauthenticated(而非 Internal) | ✅ pass |
+| TC-1117 | M-R14-1:RotateRefreshToken post-commit UD 缓存清理与请求 ctx 解耦 | ✅ pass |
 
 ### 2.3 同步权限 `POST /api/perm/sync`
 
@@ -188,6 +190,7 @@
 | TC-1063 | 纯新增(updated=0, disabled=0)→ **不**触发 CleanByProduct | ✅ pass |
 | TC-1064 | 至少一条 update(code 存在但 name/Status/Sort 变更)→ **必须**触发 CleanByProduct | ✅ pass |
 | TC-1065 | 至少一条 disable(列表里不含的 perm 被置 Disabled)→ **必须**触发 CleanByProduct | ✅ pass |
+| TC-1118 | M-R14-1:ExecuteSyncPerms post-commit CleanByProduct 与请求 ctx 解耦 | ✅ pass |
 
 ### 2.4 获取用户信息 `POST /api/auth/userInfo`
 
@@ -369,6 +372,7 @@
 | TC-1000 | 非超管访问别的产品的 role | ✅ pass |
 | TC-1001 | "id 不存在" vs "跨产品" 响应对比 | ✅ pass |
 | TC-1002 | 超管跨产品访问 | ✅ pass |
+| TC-1119 | L-R14-1:非超管 UpdateRole 别产品 roleId 必须 404 "角色不存在" | ✅ pass |
 
 ### 2.12 删除角色 `POST /api/role/delete`
 
@@ -377,6 +381,7 @@
 | TC-0126 | 正常删除+级联 | ✅ pass |
 | TC-0127 | 事务回滚 | ✅ pass |
 | TC-0128 | 无关联数据 | ✅ pass |
+| TC-1120 | L-R14-1:非超管 DeleteRole 别产品 roleId 必须 404 "角色不存在" | ✅ pass |
 
 ### 2.13 绑定角色权限 `POST /api/role/bindPerms`
 
@@ -390,6 +395,7 @@
 | TC-1024 | `BindRolePerms` 事务首步调用 `LockByIdTx` 锁 `sys_role` 行,再走 `FindPermIdsByRoleIdTx` 读 diff 基准 | ✅ pass |
 | TC-1025 | `BindRolePerms` post-commit cache 清理失败仍 Success | ✅ pass |
 | TC-1026 | `BindRoles` 事务首步 `FindOneForUpdateTx(memberId)` 锁 `sys_product_member` 行,再走 `FindRoleIdsByUserIdForProductTx` | ✅ pass |
+| TC-1121 | L-R14-1:非超管 BindRolePerms 别产品 roleId 必须 404 "角色不存在" | ✅ pass |
 
 ### 2.14 创建用户 `POST /api/user/create`
 
@@ -425,6 +431,8 @@
 | TC-0998 | 非超管 caller 传 DeptId=0 | ✅ pass |
 | TC-0999 | 目标部门 status=Disabled | ✅ pass |
 | TC-1100 | 负值 DeptId(-1 / MinInt64)必须 400 | ✅ pass |
+| TC-1122 | H-R14-1:非超管把新用户建到 DEV 部门必须 403 | ✅ pass |
+| TC-1123 | H-R14-1:SuperAdmin 可在 DEV 部门建用户(正向回归) | ✅ pass |
 
 ### 2.15 用户更新 `POST /api/user/update` (指针类型+DeptId可清零)
 
@@ -460,6 +468,9 @@
 | TC-1049 | deptId 切换场景下并发 DeleteDept 被"S 锁 / X 锁"串行化 | ✅ pass |
 | TC-1050 | 非事务路径:deptId 未变的 UpdateUser 不触发 `FindOneForShareTx` 的 S 锁路径 | ✅ pass |
 | TC-1101 | 负值 `*req.DeptId`(-1 / MinInt64)必须 400 且不得落盘 | ✅ pass |
+| TC-1124 | H-R14-1:ADMIN 调入 DEV 部门必须 403 | ✅ pass |
+| TC-1125 | H-R14-1:SuperAdmin 仍可调入 DEV 部门(正向回归) | ✅ pass |
+| TC-1126 | H-R14-1:ADMIN 跨子树调入非 DEV 部门仍应放行 | ✅ pass |
 
 ### 2.16 用户列表/详情/状态 及其他用户操作
 
@@ -524,12 +535,13 @@
 | TC-1012 | Logic 层错误映射 | ✅ pass |
 | TC-1027 | 登录时用户在 `productCode` 下非成员 | ✅ pass |
 | TC-1028 | 登录时用户成员资格 `Status=Disabled` | ✅ pass |
-| TC-1078 | BindRoles 与 DeleteRole 并发 6 轮 | ⚠️ flaky(详见 1.3) |
+| TC-1078 | BindRoles 与 DeleteRole 并发 6 轮(统一文案为"包含无效的角色ID"后稳定) | ✅ pass |
 | TC-1102 | BindRoles 非超管 + 空 MemberType + 不存在 userId → 403 "缺少产品成员上下文" | ✅ pass |
 | TC-1103 | BindRoles 超管 + 空 MemberType 仍穿透到 FindOne → 404 | ✅ pass |
 | TC-1104 | SetUserPerms 非 ADMIN caller + 不存在 userId → 403(阻断 userId 枚举) | ✅ pass |
 | TC-1105 | SetUserPerms DENY TOCTOU:事务内读到 ADMIN → 400 + 事务回滚(无 DENY 脏行) | ✅ pass |
 | TC-1106 | SetUserPerms 纯 ALLOW 必须短路、不调 FindOneForShareTx(S 锁开销不扩散) | ✅ pass |
+| TC-1127 | L-R14-2:BindRoles 跨产品 / 已禁用 / 不存在三路径文案必须统一为"包含无效的角色ID" | ✅ pass |
 
 ### 2.17 成员管理
 
@@ -1291,7 +1303,16 @@
 
 ## 三、测试结论
 
-- **884 个 TC 全部执行**:通过 **986**(含 subtests),跳过 **5**,失败 **0**。
-- 26 个测试包全部 OK。
-- 通过率(扣除主动 skip):**100%**。
+- **912 个 TC 全部执行**:通过 **1141**(含 subtests),跳过 **1**,失败 **0**。
+- 26 个测试包全部 OK;本轮整包连跑均绿,无并发 flake 触发
+- 通过率(扣除主动 skip 的 1 条不可达防御分支):**100%**。
 - 核心业务路径(登录、刷新 Token、权限同步、用户/角色/成员/部门 CRUD、访问控制、限流、缓存失效、乐观锁、事务隔离、并发安全)均有独立回归用例覆盖且稳定通过。
+
+### 3.1 本轮新增 / 调整回归覆盖(R14 防线)
+
+| 方向 | 覆盖用例 | 要点 |
+| :--- | :--- | :--- |
+| **M-R14-1 post-commit 缓存清理与请求 ctx 解耦** | TC-1117、TC-1118 | `RotateRefreshToken` 与 `ExecuteSyncPerms` 两处事务/CAS 提交之后的 `UserDetailsLoader.Clean` / `CleanByProduct` 必须跑在 `DetachCacheCleanCtx` 返回的独立 ctx 上,避免 HTTP deadline / client 断连在 5 分钟 TTL 内把"旧 tokenVersion UD" 或 "挂着被禁用 perm 的 UD" 留在 Redis |
+| **L-R14-1 跨产品 roleId 枚举 Oracle 闭合** | TC-1119 / TC-1120 / TC-1121 | `UpdateRole` / `DeleteRole` / `BindRolePerms` 三路径统一通过 `authHelper.ResolveOwnRoleOr404` 返回 404 "角色不存在",与 RoleDetail 的 M-N3 口径完全对齐,攻击者无法再借 404 vs 403 差分枚举他产品 roleId |
+| **L-R14-2 BindRoles 文案统一** | TC-0188 / TC-0189 / TC-0190 / TC-1078 / TC-1127 | 跨产品 / 已禁用 / 不存在 / race_deleted 四条路径对外统一为 400 "包含无效的角色ID",详细 reason 仅落审计日志;既堵死文案枚举 Oracle,又顺带收敛原 TC-1078 的并发 flake |
+| **H-R14-1 跨产品信任边界(DEV 部门)收敛** | TC-1122 / TC-1123 / TC-1124 / TC-1125 / TC-1126 | `CreateUser` 与 `UpdateUser` 对"调入 DEV 部门"的动作统一收敛给 SuperAdmin,阻断"P1.ADMIN 把 P1/P2 共同成员挪进 DEV → 在 P2 瞬间全权"的跨产品升权攻击链;同时以正向用例确认 SuperAdmin 合法运维 / ADMIN 跨子树调入 **非 DEV** 部门均不受误伤 |