基线:R14 审计后的代码库快照。R14 提出的 1 条 High(H-R14-1)、1 条 Medium(M-R14-1)、3 条 Low(L-R14-1 / L-R14-2 / L-R14-3)已全部在对应 logic 文件中落地修复(见文末"R14 回归验证")。本轮聚焦"缓存 TTL 下的 MemberType TOCTOU"、"全局字段
sys_user.deptId的跨产品副作用再审"与"降权路径是否吊销会话"等此前轮次未覆盖的面。
本轮未新增 High 风险项。R14 的 H-R14-1(DEV 部门跨产品全权升级)已在 updateUserLogic.go:140-142 与 createUserLogic.go:99-101 双侧落地 SuperAdmin-only 护栏;攻击链已被切断。
HasFullProductPerms / LoadCallerAssignableLevel 仅读 UD 缓存的 MemberType,降权期内仍按 ADMIN 放行位置
internal/logic/auth/access.go:265-272(HasFullProductPerms)internal/logic/auth/access.go:211-226(LoadCallerAssignableLevel 短路分支)internal/logic/auth/access.go:337-338(checkDeptHierarchy 的 ADMIN 绕过)internal/logic/auth/access.go:132-144(RequireProductAdminFor)caller.MemberType 当授权输入的调用点:BindRoles / CreateUser / CreateRole / BindRolePerms / UpdateRole / DeleteRole / AddMember / UpdateMember / SetUserPerms / RemoveMember / DeptTree 等描述
审计 H-2 / M-R10-3 / GuardRoleLevelAssignable 已经为 caller.MinPermsLevel 建立了"授权决策点强制走 loadFreshMinPermsLevel 读 DB"的 TOCTOU 闭环,缩短了 UD 缓存 TTL 窗口。但同一份 UD 里另一个同等重要的授权字段 MemberType 仍然只走缓存:
func HasFullProductPerms(caller *loaders.UserDetails) bool {
if caller == nil {
return false
}
return caller.IsSuperAdmin ||
caller.MemberType == consts.MemberTypeAdmin ||
caller.MemberType == consts.MemberTypeDeveloper
}
caller 的来源是 middleware.GetUserDetails(ctx) → UserDetailsLoader.Load → Redis ud:<userId>:<productCode> 键,TTL=5min。一旦 UpdateMember 的 post-commit Del 因 Redis 抖动未成功(本身已用 DetachCacheCleanCtx 3s 超时兜底,但 Redis 真故障时仍会留日志 → 不复查),缓存里的 MemberType 会在 最长 5min 内保持 ADMIN / DEVELOPER 语义。
同时 UpdateMember 本身不递增 sys_user.tokenVersion(对比 UpdateUserStatus 会自动 tokenVersion = tokenVersion + 1、UpdatePassword / Logout 同),降权不触发强制重登录。因此攻击窗口是"缓存读"而非"token 读":
UpdateMember 把 A 降级为 MEMBER;事务提交,UserDetailsLoader.Del(A.UserId, P1) 被调用;Del 的 DelCtx 返回 err,日志打 cache_invalidation_skipped_*,缓存保留 MemberType=ADMIN;jwtauthMiddleware 走 Load(userId, productCode) 命中 Redis 旧 UD → 把 MemberType=ADMIN 的 ud 注入 ctx;BindRoles / UpdateRole 等看 caller.MemberType == consts.MemberTypeAdmin 放行;CheckManageAccess → checkDeptHierarchy 的 ADMIN 分支直接 return nil,跳过部门链校验;LoadCallerAssignableLevel 的 HasFullProductPerms 返回 true,BindRoles 循环里所有 CheckRoleLevelAgainst 全部放行,A 可以把任意权限等级(包括超出其当前 MEMBER 身份应有上限)的角色绑给下属;对比之下,R13 在 H-2 的注释里已经准确钉出"UD 缓存 5 分钟 TTL 内旧 MinPermsLevel 可被利用";但同批同类风险的 MemberType 缺少对称防御,形成审计覆盖不对称。
影响
checkDeptHierarchy ADMIN 绕过)、修改产品内成员(RequireProductAdminFor 通过)。此风险本身是"缓存读一致性"架构层决定,不是单点 bug。闭环方案有两条,任选其一或组合:
修复方案(方案 A,最小代价,推荐):让 UpdateMember 在降级路径({ADMIN, DEVELOPER} → MEMBER 或 Enabled → Disabled)里显式递增目标用户的 tokenVersion,与 UpdateUserStatus 口径对齐:
// internal/logic/member/updateMemberLogic.go,在 tx 内部 update member 之前/之后:
wasPrivileged := locked.MemberType == consts.MemberTypeAdmin ||
locked.MemberType == consts.MemberTypeDeveloper ||
locked.Status == consts.StatusEnabled
willBePrivileged := (nextType == consts.MemberTypeAdmin || nextType == consts.MemberTypeDeveloper) &&
nextStatus == consts.StatusEnabled
if wasPrivileged && !willBePrivileged {
// 事务内递增 sys_user.tokenVersion 强制再登录;
// 与 UpdateUserStatus 的 WHERE updateTime=? 乐观锁语义对齐,
// 并通过 DetachCacheCleanCtx 的 Clean 失效 UD 缓存(已存在)。
if _, err := l.svcCtx.SysUserModel.IncrementTokenVersionWithTx(ctx, session, member.UserId); err != nil {
return err
}
}
这样不论 Redis 是否抖动,旧 access token 都会在下一次中间件校验时因 claims.TokenVersion != ud.TokenVersion 被强制重登录;重登录走 Login / RefreshToken 会重新签发含新 memberType 的 token 并写入 Redis。注意需要在 tx 内递增 tokenVersion,否则如果 tx 回滚,sys_user.tokenVersion 会多走一步。目前 sysUserModel 只有 IncrementTokenVersion(ctx, id, username)(非 tx),需要补一个 IncrementTokenVersionWithTx(ctx, session, id);username 仅用于失效缓存,可以延后到 tx commit 之后配合现有 UserDetailsLoader.Del 链路失效。
修复方案(方案 B,加固授权决策点):在所有依赖 caller.MemberType == ADMIN/DEVELOPER 的授权决策点(HasFullProductPerms / checkDeptHierarchy 的 ADMIN 绕过 / RequireProductAdminFor)前做一次 FindOneByProductCodeUserId(productCode, caller.UserId) 的 DB 复核。代价是每个管理写接口多 1 次(带缓存的)DB 读,但概念上把 MemberType 的 TOCTOU 窗口从 TTL 级压到单查询级,与 loadFreshMinPermsLevel 对称:
// 新增 authHelper.ResolveCallerMembership(ctx, svcCtx, caller) (*SysProductMember, error)
// 并让 HasFullProductPerms 改签名为 (ctx, svcCtx, caller) 走该 helper;
// 在 checkDeptHierarchy ADMIN 分支、RequireProductAdminFor、LoadCallerAssignableLevel
// 三处同步切换。
方案 A 与 B 的本质差异:A 让攻击窗口为 0(token 被废),B 让窗口为"单次 DB 读" ~5ms。优先推荐 A——成本更低、语义更清晰,与现有强制下线 (UpdateUserStatus, ChangePassword, Logout) 的口径一致。
回归测试建议
tokenVersion mismatch 拒绝,或(方案 B 下)通过 RequireProductAdminFor 的 DB 复核拒绝;UpdateUser req.DeptId=0 把共有用户调出部门树位置 internal/logic/user/updateUserLogic.go:158-165
描述
H-R14-1 已经把"调入 DEV 部门"这条真正能做跨产品权限升级的路径收敛给 SuperAdmin。对称地分析 req.DeptId == 0(把用户调出部门树)分支,现状:
} else {
// deptId=0 意味着"把用户移出部门树";...
if !caller.IsSuperAdmin && caller.MemberType != consts.MemberTypeAdmin {
return response.ErrForbidden("仅超级管理员或产品管理员可将用户移出部门")
}
}
产品 ADMIN 于是被放行。sys_user.deptId 是全局字段——产品 P1 的 ADMIN 对一位同时是 P2 成员的用户 B 调用 UpdateUser(deptId=0):
CheckManageAccess 通过(checkDeptHierarchy ADMIN 绕过、checkPermLevel 对 MEMBER 级目标 callerPri<targetPri 放行);sys_user.deptId=0,DeptPath="",DeptType="";checkDeptHierarchy 时会命中 target.DeptId == 0 → 403 "目标用户未归属部门,仅超管或产品管理员可管理";checkPermLevel 在 P2 成员表找到 B 才可继续管理;DeptTree 里 B 不再出现(caller.DeptPath 前缀过滤),运营侧看不到 B 的存在。loadPerms 的 DEV 全权分支。影响
sys_user.deptId 变更在 P2 的日志链路里看不到变更发起方——整条 UpdateUser 调用落在 P1 的审计上下文内。修复方案
与 H-R14-1 对称收敛给 SuperAdmin:
// internal/logic/user/updateUserLogic.go
} else {
// 审计 L-R15-1:与 H-R14-1 对称——sys_user.deptId 是全局字段,改为 0 会把用户从
// **所有**产品的部门结构里移除,跨产品影响。产品 ADMIN 的授权范围仅限自己产品,
// 不应执行改变全局字段的破坏性操作。移出部门树由 SuperAdmin 执行(例如离职流程)。
if !caller.IsSuperAdmin {
return response.ErrForbidden("仅超级管理员可将用户移出部门")
}
}
如业务上确实需要保留"产品 ADMIN 可把非跨产品成员移出部门"的能力,可以加一个"目标是否同时在其他产品"的判断,但复杂度明显提升,不如直接收敛给 SuperAdmin;离职/转岗属于行政 HR 流程,SuperAdmin 执行更合理。
回归测试建议
req.DeptId=0 → ErrForbidden("仅超级管理员可将用户移出部门");sys_user.deptId 不变;UserDetailsLoader.Clean 不触发。req.DeptId=0 → 根据业务决策(本轮建议改为一致拒绝)。DeptTree 对任何产品的 ADMIN 暴露完整组织架构位置 internal/logic/dept/deptTreeLogic.go:45
描述
fullAccess := caller.IsSuperAdmin || caller.MemberType == consts.MemberTypeAdmin
if !fullAccess {
if caller.DeptPath == "" {
return make([]*types.DeptItem, 0), nil
}
filtered := make([]*deptModel.SysDept, 0, len(list))
for _, d := range list {
if strings.HasPrefix(d.Path, caller.DeptPath) {
filtered = append(filtered, d)
}
}
list = filtered
}
sys_dept 是全局命名空间(一份组织架构服务于所有产品);任何一个产品的 product ADMIN 都能走 fullAccess=true 分支拿到全公司所有部门——包括其他产品的敏感研发子树、跨 BU 的 HR/财务部门、未归属到任何产品的战略部门等。
这与 DeptTreeLogic 注释里的"超管 / 产品 ADMIN 返回完整组织架构树;其他成员仅返回以其 DeptPath 为根的子树"一致,但在多产品共享组织架构的部署下,对"小产品的 ADMIN 看到大产品的 DEV 子树名称 + 层级"这一点暴露并没有防御。配合 M-2(MEMBER 级不能看全产品列表以防 admin_ 撞库)的既有意图——审计链路里"MEMBER 拿不到敏感组织信息"是刻意守住的,但对"任何 product ADMIN 可拿到全量 sys_dept"网开一面,属于覆盖不对称。
影响
DeptType=DEV 的子树被用作类似 H-R14-1 的"加入即全权"判据(已被 H-R14-1 的 SuperAdmin-only 护栏堵死),本项就是"知道调去哪个 deptId"的前置侦察接口。修复方案
根据业务对"产品 ADMIN 是否需要全局部门视图"的真实需求,二选一:
fullAccess := caller.IsSuperAdmin。产品 ADMIN 仅看自己 DeptPath 子树(与 DEVELOPER/MEMBER 对齐)。AddMember 若需要从其他部门拉人,由 SuperAdmin 批准或另开 ListAddableDepts 等独立接口。fullAccess=true 分支里,若 caller 不是 SuperAdmin,额外跳过 DeptType == DEV 的部门。保留"正常部门"的全公司视图以支持 AddMember 跨部门拉人,但隐藏 DEV 子树名称避免针对性侦察。从"最小授权"出发推荐方案 1;保留现状的话建议在审计报告里显式钉住"产品 ADMIN 可见全组织架构"这条信任边界,便于未来评审时不被新人误改。
UpdateMember 与 UpdateProduct(禁用)均不递增 tokenVersion位置
internal/logic/member/updateMemberLogic.go:77-107(整个事务体不触及 sys_user.tokenVersion)internal/logic/product/updateProductLogic.go:46-73(产品禁用同样不递增其成员 tokenVersion)描述
与 UpdateUserStatus(会自动走 sysUserModel.UpdateStatus 内的 tokenVersion = tokenVersion + 1 + 乐观锁)、ChangePassword(UpdatePassword 内 tokenVersion = tokenVersion + 1)、Logout(IncrementTokenVersion)形成鲜明对比:
| 变更 | 旧 access token 失效方式 |
|---|---|
| UpdateUserStatus Disabled | ✅ tokenVersion +1 → 中间件下次 403 |
| UpdateUserStatus Enabled(重启用) | ✅ tokenVersion +1(副作用无害) |
| ChangePassword | ✅ tokenVersion +1 |
| Logout | ✅ tokenVersion +1 |
| RefreshToken rotate | ✅ CAS tokenVersion +1 |
| UpdateMember 降级 | ❌ 仅刷 UD 缓存(best-effort) |
| UpdateMember 禁用 | ❌ 仅刷 UD 缓存 |
| UpdateProduct 禁用 | ❌ 仅刷 UD 缓存 |
| DeleteRole / UpdateRole | ❌ 仅刷 UD 缓存(可接受:角色细粒度) |
后三条依赖 UserDetailsLoader.Del / CleanByProduct 的 post-commit 失效(已用 DetachCacheCleanCtx 3s 超时 + 5min TTL 兜底)。Redis 抖动时的窗口见 M-R15-1 的详细分析——这里是"同一风险模式"的另一个触发点:
UpdateMember 降 ADMIN → MEMBER:M-R15-1 已详述;UpdateMember 禁用(Status=Disabled):被禁成员的 access token 仍然能通过中间件——因为中间件读的是 ud.Status(来自 UD 缓存),缓存失效失败就等于"禁用无效" 5 分钟;UpdateProduct 禁用产品:产品内所有成员的 ud.ProductStatus 需要失效,CleanByProduct 触发大范围 SUNION+DEL;任意一步失败就留下"产品禁用但成员仍能查 / 用"的窗口。影响
与 M-R15-1 同一个攻击面,不同触发点:在 Redis 不可用期间,"降级 / 禁用 / 产品下线" 三类敏感变更都依赖缓存失效做唯一拦截。本项与 M-R15-1 的关系是"M-R15-1 是其中最危险的一种降权路径,L-R15-3 是全集"。
修复方案
按 M-R15-1 方案 A 的思路统一让这三个写路径在降权/禁用分支里递增目标 tokenVersion:
updateMemberLogic.go:降 ADMIN/DEVELOPER → MEMBER 或禁用成员时递增目标用户的 tokenVersion(见 M-R15-1 方案 A 代码样例)。updateProductLogic.go:产品变更为 Disabled 时,批量递增该产品下所有成员的 tokenVersion——类似 FindIdsByDeptId 的接口语义,新增 sysProductMember.FindActiveMemberUserIdsByProductCode 拿一次 userIds,再用一条 UPDATE sys_user SET tokenVersion = tokenVersion + 1 WHERE id IN (...) 批量递增。注意:数据量就是该产品的成员数(量级一般在数千内),不是全站用户,不会爆 SQL。配合 UserDetailsLoader.CleanByProduct 并行清缓存即可。IncrementTokenVersionWithTx(ctx, session, id) 与 BatchIncrementTokenVersion(ctx, userIds) 两个 model 方法,共享现有 cacheSysUserIdPrefix / cacheSysUserUsernamePrefix 失效链路。测试要点:
随着 M-R15-1 / L-R15-3 如果按方案 A 采纳"降权即递增 tokenVersion",需要在以下文件顶部注释钉住语义:
internal/logic/member/updateMemberLogic.go 顶部:列明"任何从 {ADMIN, DEVELOPER} 向 MEMBER 的迁移、或从 Enabled 向 Disabled 的迁移都会强制对方重登录(tokenVersion+1)",方便未来维护者区分"刷缓存 + 重登录"的双重防御意图;internal/logic/product/updateProductLogic.go:同样声明产品禁用会批量递增成员 tokenVersion。这条本身不构成代码修改,仅提醒"代码修完后记得同步更新审计注释",与 L-R14-3 同类性质。
| 条目 | 期望修复 | 代码现状 | 判定 |
|---|---|---|---|
| H-R14-1 DEV 部门跨产品全权升级 | UpdateUser + CreateUser 在 DeptType=DEV 目标部门分支拒绝非超管 |
updateUserLogic.go:140-142 + createUserLogic.go:99-101 两处均有显式 newDept.DeptType == consts.DeptTypeDev && !caller.IsSuperAdmin → ErrForbidden |
✅ 已闭合 |
| M-R14-1 rotateRefreshToken / syncPermsService 缓存失效未 detach | 两处改用 loaders.DetachCacheCleanCtx |
rotateRefreshToken.go:87-89 + syncPermsService.go:177-179 均已改造;全仓扫描 `UserDetailsLoader.(Clean |
Del |
| L-R14-1 UpdateRole/DeleteRole/BindRolePerms 404 vs 403 枚举 | 抽 authHelper.ResolveOwnRoleOr404 统一 404 |
access.go:146-176 新增 ResolveOwnRoleOr404;三处调用方(updateRoleLogic.go:46 / deleteRoleLogic.go:33 / bindRolePermsLogic.go:38)均改用该 helper |
✅ 已闭合 |
| L-R14-2 BindRoles 跨产品文案区分 | "缺项 / 跨产品 / 已禁用" 折叠为同一 "包含无效的角色ID" |
bindRolesLogic.go:91-117 合并;事务内拿 S 锁失败(race_deleted_or_disabled)的分支 bindRolesLogic.go:163-170 也走同一响应体 |
✅ 已闭合 |
| L-R14-3 UpdateUser ADMIN 分支注释披露 | 在 ADMIN 分支显式声明 DEV 部门语义 | updateUserLogic.go:131-152 已补注释;且 H-R14-1 的代码兜底已经让注释描述的路径被代码直接拦截,注释与代码互相印证 |
✅ 已闭合 |
本轮新增发现 1 条 Medium(M-R15-1,缓存 TTL 下的 MemberType TOCTOU)与 3 条 Low(L-R15-1 / L-R15-2 / L-R15-3)。Low 三条彼此有语义关联:L-R15-3 是 M-R15-1 的泛化,L-R15-1 是 H-R14-1 的结构对称项,L-R15-2 是跨产品信息泄露面的残留。优先处置 M-R15-1(按方案 A 让 UpdateMember 降级时强制 tokenVersion+1),可同时闭合 L-R15-3 的 UpdateMember 分支——代价仅是在 tx 内补一次 UPDATE;UpdateProduct 禁用批量递增可分作下一轮单独跟进。