基线: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。
UpdateUser 把他人调入 DEV 部门,间接赋予跨产品全权描述
internal/logic/user/updateUserLogic.go 在处理 req.DeptId != nil && *req.DeptId > 0 分支时,对「新部门是否是 DEV 类型」没有任何特殊校验;且第 137-141 行对调用方的部门前缀校验通过 caller.MemberType != consts.MemberTypeAdmin 直接短路:
if !caller.IsSuperAdmin &&
caller.MemberType != consts.MemberTypeAdmin &&
!strings.HasPrefix(newDept.Path, caller.DeptPath) {
return response.ErrForbidden("无权将用户调入非自己管辖的部门")
}
配合 internal/loaders/userDetailsLoader.go 第 540-554 行的全权分支:
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
}
只要 sys_user.deptId 指向 DeptType=DEV 的部门,该用户在任何他已加入的产品下都自动拿到该产品的全量权限。而 sys_user.deptId 是全局字段——UpdateUser 在 caller 作用域内用 CheckManageAccess(productCode=caller.ProductCode, ...) 判权,却会把修改向所有其他产品级联。
攻击链(复现路径)
UpdateUser(id=B.Id, deptId=<DEV 部门 id>):
CheckManageAccess 仅作用在 P1,通过(B 在 P1 的 memberType=MEMBER,ADMIN 绕过 checkDeptHierarchy,checkPermLevel 也因 callerPri<targetPri 直接放行);UserDetailsLoader.Clean(B.Id) 刷新 UD,B 在产品 P2 下的下一次 Load 命中 DEV 全权分支 → ud.Perms = FindAllCodesByProductCode(P2),B 从 P2 的普通成员瞬间升级为P2 全权。影响
sys_user 被多个产品加为成员)的部署,这个路径把"ADMIN 只应在自己产品内全权"的不变量彻底打破。sys_user.deptId 的变更在 P2 日志里看不到是谁发起的)。修复方案
在 updateUserLogic.go 的 *req.DeptId > 0 分支里加入对「目标新部门是 DEV」的显式护栏,仅允许超级管理员把用户调入 DEV;同理对 deptId=0(移出部门)保留现状即可,但把 "移入 DEV" 与 "跨越产品范围" 这条路径堵死:
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 的行列。
验收测试(对应 internal/logic/user/updateUserLogic_test.go)应补:
req.DeptId=DEV 部门 id → 返回 ErrForbidden("仅超级管理员可将用户调入研发部门");sys_user.deptId 不变;UserDetailsLoader.Clean 不被触发。UserDetailsLoader.Clean 被调用一次。req.DeptId=<普通部门且在 caller.DeptPath 子树> → 正常放行(与现有行为一致)。RotateRefreshToken 与 SyncPermsService 两处 post-commit 缓存失效未接入 DetachCacheCleanCtx位置
internal/logic/auth/rotateRefreshToken.go:82
svcCtx.UserDetailsLoader.Clean(ctx, claims.UserId)
internal/logic/pub/syncPermsService.go:171
go
svcCtx.UserDetailsLoader.CleanByProduct(ctx, product.Code)
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 内:
IncrementTokenVersionIfMatch(expected=N) 失败(DB 已是 N+1),被强制重登录,形成"无故踢出"的 UX 抖动。SyncPermsService:CleanByProduct 失败后,被禁用 / 删除的 perm 仍在 5min TTL 内出现在所有该产品成员的 UD 缓存里——被本产品服务端用 VerifyToken / GetUserPerms 查询到的 perms 列表里,checkStillValid 逻辑会把失效 perm 从结果里剔除,但 UserDetails.Perms 缓存本身不会因 miss 而重建,最多延迟至 TTL。即使下游 caller 做了 checkStillValid,也依赖"下游每次查询都保留 db round-trip"的前提;若 perms-system 的消费方只看 GetUserPerms 返回,仍会命中窗口。修复方案
两处统一改造为 detach ctx:
cleanCtx, cancel := loaders.DetachCacheCleanCtx(ctx)
defer cancel()
svcCtx.UserDetailsLoader.Clean(cleanCtx, claims.UserId)
和:
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 的重复代码。
回归测试建议:
IncrementTokenVersionIfMatch 成功后把 ctx 显式 cancel,验证 Clean 仍能在 detach ctx 下走到 Redis DEL(或在 redis mock 上观察 DEL 触发)。BatchWriteProductPerms 事务成功后 cancel ctx,验证 CleanByProduct 仍走到 cache invalidation。FindOne → RequireProductAdminFor 顺序泄漏跨产品 roleId 存在性位置
internal/logic/role/updateRoleLogic.go(先 SysRoleModel.FindOne(req.Id) → 再 RequireProductAdminFor(role.ProductCode))internal/logic/role/deleteRoleLogic.go(同上)internal/logic/role/bindRolePermsLogic.go(同上)任何已登录用户(即使只是某产品的普通 MEMBER)都能构造上述三个接口,用顺序 id 扫 req.RoleId=1..N:
FindOne 返回 ErrNotFound → 响应 404 "角色不存在";响应 403 "仅超级管理员或该产品的管理员可执行此操作"。两个响应码不一致,直接把"该 roleId 是否存在以及属于别的产品"漏给了认证用户,和 R13 的 L-R13-1(addMember / setUserPerms / bindRoles 的枚举信号)同族。对比已经收敛过的 roleDetailLogic.go(M-N3:把"不存在"和"他产品角色"统一回 404),这三个接口属于遗漏。
修复方案
在 FindOne 之后、RequireProductAdminFor 之前,对非超管调用方先做"产品归属比对",把跨产品情况降级成与"不存在"完全一致的 404:
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
}
三个文件统一同一模板,推荐抽成 authHelper.ResolveOwnRoleOr404(ctx, svcCtx, roleId) 以避免日后漂移。
BindRoles 对入参 roleIds 的错误文案区分"不存在 / 跨产品"位置 internal/logic/user/bindRolesLogic.go
当 FindByIds(req.RoleIds) 返回的角色里有跨产品项,返回 "不能绑定其他产品的角色";缺项时返回 "包含无效的角色ID"。已通过 CheckManageAccess 的调用方即便只是某产品 MEMBER,也可以借此枚举他人产品的 roleId 分布(比 L-R14-1 门槛更低,因为他只需管得了本产品某个下属即可)。
修复方案
将"跨产品"与"不存在"折叠成同一个 ErrBadRequest("包含无效的角色ID") 文案(日志里保留细分标记供审计分析):
valid := 0
for _, r := range roles {
if r.ProductCode != productCode || r.Status != consts.StatusEnabled {
continue
}
valid++
}
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")
}
不要把产品归属揭露给响应体,只保留到日志里给运营同学排障。
UpdateUserLogic 的 ADMIN 分支注释缺少对 DEV 部门语义的声明即便 H-R14-1 选择保留现有 ADMIN 可调部门的语义(决定不改代码),line 131-141 的注释也仅讨论了"caller.DeptPath 为空"的边界,没有钉出"此分支允许 ADMIN 把用户调入 DEV 部门 = 跨产品全权赋予"这一事实。建议至少在注释层面明确写一段:
注意:ADMIN 分支短路 DeptPath 前缀校验,意味着 ADMIN 可以把目标调入任何部门,
包括 DeptType=DEV 的部门。DEV 部门在 loadPerms 中等价于"加入任一产品即全权",
这条路径相当于把 ADMIN 本地产品外的权限一并下发到目标的其他产品身份上;若产品
间信任不对称,请额外增加 `newDept.DeptType == DEV → RequireSuperAdmin` 的护栏
(见审计 H-R14-1)。
这样后续维护者能在修改前看到明确的风险披露,避免把 H-R14-1 的推论再次拆掉。
| 条目 | 期望修复 | 代码现状 | 判定 |
|---|---|---|---|
| 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 升级跟进 |
🟡 部分收敛 |
本轮新增发现一条 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(两处遗留的缓存失效路径,修复代价极小)。