audit-report.md 35 KB

深度审计报告 · Round 16

基线:R15 审计后的代码库快照。R15 提出的 1 条 Medium(M-R15-1,缓存 TTL 下的 MemberType TOCTOU)与 3 条 Low(L-R15-1/2/3)均已在代码中落地(updateMemberLogic.go / updateProductLogic.go 在 tx 内补齐 tokenVersion+1 降权吊销;updateUserLogic.godeptId=0 收敛给 SuperAdmin;deptTreeLogic.gofullAccess 收敛给 SuperAdmin)。本轮聚焦两类 R15 未闭合的面:

  1. 降权吊销的覆盖对称性UpdateMember / UpdateProduct 已走"降权即 tokenVersion+1"的闭环,但与它们语义同构的其它降权路径RemoveMemberUpdateDept 改 DeptType/Status、UpdateUser 改 deptId 跨越 DEV/NORMAL 边界)仍是"仅 post-commit UserDetailsLoader.* 尽力而为失效"的旧口径——Redis 抖动时 5min TTL 窗口内旧权限继续生效,构成与 M-R15-1 同等的 TOCTOU 面;
  2. 全局字段 sys_user.deptId 的跨产品结构性副作用:R15 的 L-R15-1 只封了 deptId=0 这条极端路径,但产品 ADMIN 把共享成员调入自己部门子树外的非 DEV 部门同样会改写全局 sys_user.deptId,在其它产品的管理视角里制造"同产品 ADMIN 够不到、DeptTree 里显示错位"的异常状态;
  3. PII 最小授权UserList / UserDetail 两接口只做"同产品成员"校验即返回目标的 email / phone,没有按 MemberType 收敛,普通 MEMBER 成员可枚举本产品全部成员的联系方式。

🚩 核心逻辑漏洞 (High Risk)

H-R16-1 · RemoveMember 降权路径未随 M-R15-1 同步吊销会话 —— 与 UpdateMember 对称缺口

位置

  • internal/logic/member/removeMemberLogic.go:42-74(事务体只做 DeleteByUserId* + Delete,无 IncrementTokenVersionWithTx
  • 对比参照:internal/logic/member/updateMemberLogic.go:94-134("降权即 tokenVersion+1"已落地)

描述

M-R15-1 / L-R15-3 落地后,"降权/禁用"这类"从'有效成员'向'无效成员'迁移"的路径都在 tx 内把目标的 sys_user.tokenVersion+1,让旧 access token 在 jwtauthMiddlewareclaims.TokenVersion != ud.TokenVersion 兜底下立刻 401,即使 Redis Del/Clean 失败也不会残留特权。但这条口径漏掉了 RemoveMember

if err := l.svcCtx.SysProductMemberModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
    locked, err := l.svcCtx.SysProductMemberModel.FindOneForUpdateTx(ctx, session, req.Id)
    if err != nil {
        return response.ErrNotFound("成员不存在")
    }
    if locked.MemberType == consts.MemberTypeAdmin && locked.Status == consts.StatusEnabled {
        otherAdminCount, err := l.svcCtx.SysProductMemberModel.CountOtherActiveAdminsTx(ctx, session, member.ProductCode, locked.Id)
        if err != nil {
            return err
        }
        if otherAdminCount == 0 {
            return response.ErrBadRequest("不能移除该产品的最后一个管理员")
        }
    }
    if err := l.svcCtx.SysUserRoleModel.DeleteByUserIdForProductTx(ctx, session, member.UserId, member.ProductCode); err != nil {
        return err
    }
    if err := l.svcCtx.SysUserPermModel.DeleteByUserIdForProductTx(ctx, session, member.UserId, member.ProductCode); err != nil {
        return err
    }
    return l.svcCtx.SysProductMemberModel.DeleteWithTx(ctx, session, req.Id)
}); err != nil {
    return err
}

cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
defer cancel()
l.svcCtx.UserDetailsLoader.Del(cleanCtx, member.UserId, member.ProductCode)
return nil

RemoveMember 事实上是 UpdateMember 降权路径的极端版——不是"ADMIN→MEMBER"而是"ADMIN→MemberType=""(非成员)",造成的授权语义跳变更剧烈:

  • loadPerms 对"非成员"会返回 nil → 原本的 ADMIN 全权直接清空;
  • jwtauthMiddlewareud.MemberType == "" 非超管会 403 "您已不是该产品的有效成员"
  • CheckManageAccess 的 ADMIN 分支直接跳过 checkDeptHierarchy,对成员的管理面彻底开放。

攻击场景(与 M-R15-1 的描述完全同构,只是触发点从 UpdateMember 换成 RemoveMember):

  1. SuperAdmin 发起 RemoveMember 把 P1 的 ADMIN A 移出产品;tx 成功,UserDetailsLoader.Del(A.UserId, P1) 被调用;
  2. Redis 在这 3s 内(DetachCacheCleanCtx 的超时)出现网络抖动,DelCtx 返回 err(代码已通过 logCacheInvalidationErrcache_invalidation_skipped_* 标签,但不重试、不 block 请求,继续返回 200);
  3. 缓存里仍是 {MemberType: ADMIN, Perms: [...P1 全权], TokenVersion: 旧值},TTL 还剩最长 5 分钟;
  4. A 拿着手里的 access token 继续调:
    • jwtauthMiddleware 命中 Redis 旧 UD → 放行;
    • UpdateMember / BindRoles / UpdateRole / DeleteRole / SetUserPerms 等所有产品管理接口的 RequireProductAdminFor 都看 caller.MemberType == ADMIN 放行;
    • CheckManageAccess 走 ADMIN 分支跳过部门链校验,A 可以对 P1 任意成员继续降级、改 PermsLevel、授高权限角色;
  5. 5 分钟 TTL 过期后缓存自然重建,A 才被系统真正视作"非成员"。

更严重的是:此时 A 的 tokenVersion 未被递增,即便运维事后发现 Redis 抖动手动 Del 了缓存 key,只要 A 把 access token 保存好,下一次 Load 重建的 UD 仍然是"DB 视角下的非成员"——这是良性的(会 403)。但在 TTL 滞留窗口里 A 的 access token 本身是有效凭据(签名、类型、过期时间、TokenVersionud.TokenVersion 都匹配),jwtauthMiddleware 无法把这段残留权限踢下线。

影响

  • 与 M-R15-1 等级相同的权限升级 TOCTOU:被移除的 ADMIN / DEVELOPER 在 Redis 抖动时保留完整产品管理权 ≤5min;
  • 运维侧无任何手段强制下线(缓存 TTL 过期是唯一收敛机制);
  • 组合攻击面:如果 5min 内 A 利用残留的 ADMIN 权把自己以不同 userId(例如预埋的备用账号)重新 AddMember / BindRoles,账号回收动作形同虚设——这正是 M-R15-1 修 UpdateMember 时就要求的"必须在签发层吊销而不是在缓存层吊销"语义。

修复方案

RemoveMember 的事务体补齐"降权即 tokenVersion+1"的闭环,与 UpdateMember 的 M-R15-1 口径完全对齐。RemoveMember 的语义比 UpdateMember 更清晰——只要走到事务体,就一定构成"从有效成员(或 ADMIN/DEVELOPER)→ 非成员"的降权,无需再判定是否为"降权",无条件递增即可:

// internal/logic/member/removeMemberLogic.go
if err := l.svcCtx.SysProductMemberModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
    locked, err := l.svcCtx.SysProductMemberModel.FindOneForUpdateTx(ctx, session, req.Id)
    if err != nil {
        return response.ErrNotFound("成员不存在")
    }
    if locked.MemberType == consts.MemberTypeAdmin && locked.Status == consts.StatusEnabled {
        otherAdminCount, err := l.svcCtx.SysProductMemberModel.CountOtherActiveAdminsTx(ctx, session, member.ProductCode, locked.Id)
        if err != nil {
            return err
        }
        if otherAdminCount == 0 {
            return response.ErrBadRequest("不能移除该产品的最后一个管理员")
        }
    }
    if err := l.svcCtx.SysUserRoleModel.DeleteByUserIdForProductTx(ctx, session, member.UserId, member.ProductCode); err != nil {
        return err
    }
    if err := l.svcCtx.SysUserPermModel.DeleteByUserIdForProductTx(ctx, session, member.UserId, member.ProductCode); err != nil {
        return err
    }
    // 审计 H-R16-1:移除成员是"有效成员→非成员"的极端降权,签发层吊销与 M-R15-1 / L-R15-3 对齐:
    //   - IncrementTokenVersionWithTx 放在 DeleteWithTx 之前,任一步失败整体回滚,不会出现
    //     "已 +1 但成员行没删" 或 "成员行已删但 tokenVersion 没 +1" 的脏中间态。
    //   - 不再把吊销绑定到 UserDetailsLoader.Del 的 Redis 可用性——即使 Del 失败,
    //     jwtauthMiddleware 的 claims.TokenVersion != ud.TokenVersion 兜底仍然会 401。
    if _, err := l.svcCtx.SysUserModel.IncrementTokenVersionWithTx(ctx, session, member.UserId); err != nil {
        return err
    }
    return l.svcCtx.SysProductMemberModel.DeleteWithTx(ctx, session, req.Id)
}); err != nil {
    return err
}

cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
defer cancel()
// 审计 H-R16-1:tokenVersion 已落库,post-commit 还要把 sysUser 的低层缓存
// (cacheSysUserIdPrefix / cacheSysUserUsernamePrefix)一并失效——否则 UD loader 下次 miss 时
// 会从 sysUser 低层缓存里拿到旧 tokenVersion,把刚递增过的值抹回去(与 UpdateMember 同)。
if user, err := l.svcCtx.SysUserModel.FindOne(cleanCtx, member.UserId); err == nil && user != nil {
    l.svcCtx.SysUserModel.InvalidateProfileCache(cleanCtx, user.Id, user.Username)
} else if err != nil {
    logx.WithContext(l.ctx).Errorf("RemoveMember post-commit FindOne(%d) failed for token-version cache invalidation: %v", member.UserId, err)
}
l.svcCtx.UserDetailsLoader.Del(cleanCtx, member.UserId, member.ProductCode)
return nil

若不希望在事务外再 FindOne(Redis 抖动时可能慢 100ms 级别),也可以在事务体内把 locked.UserIdusername(通过 tx 内的 l.svcCtx.SysUserModel.FindOneForShareTx 或在进入 tx 前的 member 查询里预取)通过闭包透传到 post-commit;由于 RemoveMember 没有超高 QPS 预期(行政操作),直接走 post-commit FindOne(该查询本身带 sqlc 缓存)也足够。

回归验证要点

  • tx 体内 IncrementTokenVersionWithTx 返回 ErrUpdateConflict(竞态:并发 RemoveMember/Logout/ChangePassword)时整体回滚,测试需断言成员行仍存在;
  • Redis 完全不可用场景下,断言被移除的 ADMIN 在下一次 HTTP 请求时被 jwtauthMiddleware 401;
  • UpdateMember 的测试矩阵对称扩容:ADMIN/DEVELOPER/MEMBER 被移除后,旧 access token 均应被立即拒绝。

⚠️ 健壮性与性能建议 (Medium / Low)

M-R16-1 · UserList / UserDetail 对任意同产品成员暴露 email / phone —— PII 最小授权缺失

位置

  • internal/logic/user/userListLogic.go:38-90(仅做"同产品"校验,无 MemberType 收敛,email/phone 直接回落到所有可见成员)
  • internal/logic/user/userDetailLogic.go:34-76(仅做"同产品成员"校验,email/phone 同样全量返回)

描述

R14 / R15 已经把"同产品 ADMIN 可以管理同产品成员"的边界拉紧,但"同产品 MEMBER 其他成员信息"的边界还停留在"只要是同产品成员即可":

if !caller.IsSuperAdmin {
    if req.ProductCode == "" {
        return nil, response.ErrForbidden("非超管用户必须指定产品编码")
    }
    if caller.ProductCode != req.ProductCode {
        return nil, response.ErrForbidden("无权访问该产品的数据")
    }
}
if !caller.IsSuperAdmin {
    if caller.ProductCode == "" {
        return nil, response.ErrForbidden("会话缺少产品上下文")
    }
    if _, err := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, caller.ProductCode, req.Id); err != nil {
        return nil, response.ErrForbidden("无权查看非本产品成员的用户信息")
    }
}

两处响应都无差别地u.Email / u.Phone / u.Remark / u.DeptId 填到响应:

items = append(items, types.UserItem{
    Id:         u.Id,
    Username:   u.Username,
    Nickname:   u.Nickname,
    Avatar:     avatar,
    Email:      u.Email,
    Phone:      u.Phone,
    Remark:     u.Remark,
    DeptId:     u.DeptId,
    Status:     u.Status,
    MemberType: memberType,
    CreateTime: u.CreateTime,
})

对比 MemberListLogic 的返回(只含 Username / Nickname / MemberType / Status email / phone)可以看到:业务上 MEMBER 浏览成员列表的合理需要是"看到谁在这个产品里",不是"拿到所有同事的联系方式"。当前 UserList / UserDetail 无视 caller 的 MemberType,让 P1 的普通 MEMBER 能一键分页导出 P1 全体成员的 email / phone

  • 内部滥用面:P1 的任一普通 MEMBER(包括产品接入方给终端客户开的 low-tier 账号)都能拉走全员通讯录,钓鱼 / 二次社工的投递名单直接就位;
  • 跨产品溯源弱化email / phone 是全局 sys_user 字段,一人在 P1+P2 同为 MEMBER 时,两产品任一方的 MEMBER 都可拉到同一份 PII,合规审计上分不出泄漏源;
  • UI 真需 vs 接口默认输出不一致:即使前端当前 MEMBER 视角的页面不渲染 email / phone,接口仍在响应体里把字段返回,绕过前端就能拿到——API 契约才是授权边界,不是 UI。

影响

  • PII 过度暴露 → 合规红线:GDPR/PIPL/内部数据分级都要求"联系方式"类字段按职责最小化返回。当前接口对同产品 MEMBER 无差别发放,容易被监管 / 安全评估点名;
  • 社工攻击前置资源充足:攻击者一旦拿到任意 P1 MEMBER 的凭据(撞库、钓鱼、误 commit token),就能把 P1 全员通讯录导出,为后续的二阶钓鱼 / SIM swap / 账号接管提供精准名单;
  • 审计覆盖面与 MemberList 口径不一致MemberListLogic 已经收敛了响应字段(不泄露 PII),但 UserListLogic(同为"按产品分页列成员"用途)没有收敛,两者 API 语义重合但安全边界不同,易被误判。

修复方案

按"自己可见全部字段、他人仅超管/ADMIN/DEVELOPER 可见 PII"的分层授权收窄响应体。不建议在 logic 层加复杂 if/else 后再 copy 字段——容易随字段增加漏脱敏。推荐在响应装配前统一做 PII 脱敏:

// internal/logic/user/userListLogic.go

// maskPII 统一决定:对于同产品内的他人,是否对 caller 隐藏 PII。
// 规则:caller 是 SuperAdmin / ADMIN / DEVELOPER 返回原值;其他情况(含普通 MEMBER)置空。
// caller 看自己不走这里——UserListLogic / UserDetailLogic 单独兜一下 `u.Id == caller.UserId` 的分支即可。
func maskPII(caller *loaders.UserDetails, u *userModel.SysUser) (email, phone, remark string) {
    if caller.IsSuperAdmin ||
        caller.MemberType == consts.MemberTypeAdmin ||
        caller.MemberType == consts.MemberTypeDeveloper ||
        (caller.UserId == u.Id) {
        return u.Email, u.Phone, u.Remark
    }
    return "", "", ""
}

// 拼装 items 时:
for _, u := range list {
    email, phone, remark := maskPII(caller, u)
    items = append(items, types.UserItem{
        Id:         u.Id,
        Username:   u.Username,
        Nickname:   u.Nickname,
        Avatar:     avatar,
        Email:      email,
        Phone:      phone,
        Remark:     remark,
        DeptId:     u.DeptId, // DeptId 本身不含 PII,可保留——前端可以凭此做部门筛选
        Status:     u.Status,
        MemberType: memberType,
        CreateTime: u.CreateTime,
    })
}

UserDetailLogic 沿用相同 helper;同时在 UserDetailLogic 里额外加一层"caller == target 或 caller 有管理职权时才返回完整信息"的自检(目标是 caller.UserId != req.Idcaller 非 ADMIN/DEVELOPER 的场景,可以考虑直接 return ErrForbidden,而不是回一个"只有昵称没有 PII"的半成品——后者会让前端误以为目标真的没绑邮箱 / 手机号)。

回归验证要点

  • MEMBER 身份调用 UserList / UserDetail
    • 看自己 → Email / Phone 原样返回;
    • 看他人 → Email / Phone / Remark 为空字符串,其余字段保留;
  • ADMIN / DEVELOPER 调用上述接口:所有字段原样返回(与现网行为一致,避免破坏管理台体验);
  • 前端若强依赖"字段非空"作逻辑分支,需同步升级——建议增加响应 schema 的版本协商或在 item 上新增 piiVisible bool 提示,减少默默置空导致的 UI 侧 regression。

L-R16-1 · UpdateUser 的 ADMIN 分支短路 DeptPath 前缀校验 —— 非 deptId=0 方向的同构缺口

位置

  • internal/logic/user/updateUserLogic.go:140-157newDept.DeptType != DEVcaller.MemberType == ADMIN 时直接放行,不比 DeptPath 前缀)
  • 对比参照:
    • internal/logic/user/updateUserLogic.go:158-170deptId=0 已由 L-R15-1 收敛给 SuperAdmin)
    • internal/logic/user/createUserLogic.go:102-109CreateUser 对非超管强制执行 strings.HasPrefix(newDept.Path, caller.DeptPath),无 ADMIN 豁免)

描述

L-R15-1 把 UpdateUser.req.DeptId = 0 这种"把目标移出全局部门树"的极端路径收敛给了 SuperAdmin,理由是:sys_user.deptId全局字段,P1 ADMIN 在 P1 的授权范围不应影响 P2 视角下的成员归属。但同一个逻辑在"req.DeptId > 0newDept.DeptType != DEV"分支里仍然存在:

if newDept.DeptType == consts.DeptTypeDev && !caller.IsSuperAdmin {
    return response.ErrForbidden("仅超级管理员可将用户调入研发部门")
}
// 注意:ADMIN 分支短路 DeptPath 前缀校验,意味着 ADMIN 可以把目标调入任何**非 DEV**
// 部门;DEV 目标部门的跨产品权限升级路径由上面 H-R14-1 的显式护栏拦截。
if !caller.IsSuperAdmin &&
    caller.MemberType != consts.MemberTypeAdmin &&
    !strings.HasPrefix(newDept.Path, caller.DeptPath) {
    return response.ErrForbidden("无权将用户调入非自己管辖的部门")
}

这一 caller.MemberType != consts.MemberTypeAdmin 短路让 P1 ADMIN 可以把"同时是 P2 成员"的 target B 从 P2 的部门子树下挪到 P1 的部门子树下(或任意既非 DEV 又处于 Enabled 的 NORMAL 部门,只要校验通过 FindOneForShareTx)。其直接副作用:

  1. P2 视角下的部门链崩坏:P2 的非 ADMIN 管理员(DEVELOPER、PermsLevel 接近 SuperAdmin 的 MEMBER)依赖 CheckManageAccess → checkDeptHierarchy 做"目标必须在 caller 子树下"的判定;B 的 DeptPath 被 P1 ADMIN 单方面改写为 P1 子树的前缀后,P2 的这些管理员对 B 的所有管理操作(降级、授权、改 Profile、冻结)都会落到 response.ErrForbidden("无权管理其他部门的用户")——B 在 P2 视角成为一个结构性不可触达的账号
  2. P2 的 DeptTree 里 B 也同时失踪deptTreeLogic.go:52-62 对非超管做 strings.HasPrefix(d.Path, caller.DeptPath) 的裁剪,B 的新 DeptPath 一旦不再以 P2 任何管理员的 caller.DeptPath 为前缀,B 在 P2 的部门树渲染里完全消失;
  3. 攻击链可达性:P1 ADMIN 不需要超管权限,不需要绕过 H-R14-1 的 DEV 部门护栏,仅靠合法的 "给 P1 成员换部门" 操作就能在 P2 侧制造"隐形成员"——这与 L-R15-1 的 deptId=0 场景完全同构,只是构造方式换成"挪到 P1 子树"而不是"清空到 0"。

注释里声明的豁免理由("product ADMIN 对产品内既有成员有全面管理权")只在"不共享 target 的单产品场景"成立;一旦 target 同时归属多个产品(sys_product_member 允许多对多),ADMIN 改 sys_user.deptId 的动作已经穿透了"本产品 ADMIN 的权限天花板"。

影响

  • 与 L-R15-1 同量级的跨产品结构性扰动,差别只是"orphan(deptId=0)"换成"挪到 P1 子树";运维可观测性更差——L-R15-1 的 orphan 至少可以通过 WHERE deptId=0 AND isSuperAdmin=0 直接捞出,而本条的 B 依然有正常 DeptPath,P2 运维必须跨产品比对才能发现"B 明明在 P2 有成员行,但 DeptPath 已经不在 P2 子树"的异常;
  • P1 ADMIN 对 B 的"单方面去 P2 化"是一种隐蔽的服务降级工具——B 在 P2 里还能调接口(jwt 依然有效、MemberType 还在 P2),但 P2 的管理台侧完全管不了他;
  • CreateUser 的不对称性:CreateUser 对 ADMIN 也强制 DeptPath 前缀校验(createUserLogic.go:102-109),UpdateUser 对 ADMIN 却短路了这一校验——两者语义应该对齐(同样是"让一个用户落在某部门下"的动作)。

修复方案

删除 UpdateUsercaller.MemberType != consts.MemberTypeAdmin 短路,把 ADMIN 也纳入 DeptPath 前缀校验范围;同时与 CreateUser 的校验口径保持一致。这条修改不会破坏 ADMIN 的正当业务:ADMIN 作为产品管理员,其 caller.DeptPath 本来就是其部门子树前缀,调整同子树内的成员部门归属不会被拦;真正被拦住的是"跨子树、跨产品"的越权改写。

// internal/logic/user/updateUserLogic.go
if *req.DeptId > 0 {
    newDept, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, *req.DeptId)
    if err != nil {
        return response.ErrBadRequest("部门不存在")
    }
    if newDept.Status != consts.StatusEnabled {
        return response.ErrBadRequest("目标部门已停用")
    }
    if newDept.DeptType == consts.DeptTypeDev && !caller.IsSuperAdmin {
        return response.ErrForbidden("仅超级管理员可将用户调入研发部门")
    }
    // 审计 L-R16-1:与 CreateUser 口径对齐,删除 ADMIN 豁免。`sys_user.deptId` 是全局字段,
    // ADMIN 把共享成员挪到自己部门子树外会让其它产品的部门链校验对该 target 失效——与 L-R15-1
    // 的 deptId=0 场景完全同构,仅改"目标落点"而非"结构性副作用"。SuperAdmin 仍可跨子树调度。
    if !caller.IsSuperAdmin {
        if caller.DeptPath == "" {
            return response.ErrForbidden("您未归属任何部门,无权调整用户部门")
        }
        if !strings.HasPrefix(newDept.Path, caller.DeptPath) {
            return response.ErrForbidden("无权将用户调入非自己管辖的部门")
        }
    }
}

回归验证要点

  • P1 ADMIN 在 P1 子树内挪动自己部门的成员:放行(与现网一致);
  • P1 ADMIN 挪动"只属于 P1"的成员到 P1 子树外的 NORMAL 部门:拦截为 403(本轮新增;若业务确有此需求,应改走 SuperAdmin 审批流);
  • P1 ADMIN 挪动"同时也是 P2 成员"的 target 到 P1 子树外的部门:同上 403,避免跨产品结构性扰动;
  • SuperAdmin 行为不变:任何 NORMAL 部门均可挪入;DEV 部门同样不受本条改动影响。

L-R16-2 · UpdateDept DeptType/Status 收窄 + UpdateUser 跨 DEV/NORMAL 边界调 deptId 均未吊销 tokenVersion —— 与 M-R15-1 同构的缓存失效 TOCTOU

位置

  • internal/logic/dept/updateDeptLogic.go:91-106deptTypeChanged || statusChanged 只走 UserDetailsLoader.CleanByUserIds BatchIncrementTokenVersionWithTx
  • internal/logic/user/updateUserLogic.go:195-250UpdateProfile / UpdateProfileWithTx 仅在 statusChanged 触发 tokenVersion+1deptId 跨越 DEV↔NORMAL 边界不递增
  • 对比参照:internal/logic/product/updateProductLogic.go:77-104(L-R15-3 已落地"禁用产品即批量吊销成员 session")、internal/logic/member/updateMemberLogic.go:94-134(M-R15-1 已落地)

描述

loadPerms 的"是否走全权分支"明确地受三个字段驱动:IsSuperAdmin / MemberType / DeptType + DeptStatus

// 超管 / ADMIN / DEVELOPER / 研发部门的有效成员 → 全量权限
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)
    ...
}

M-R15-1 / L-R15-3 已经为 MemberType 的收窄路径(UpdateMember 降级 / UpdateProduct 禁用)建立了"tx 内 tokenVersion+1 → 签发层吊销"的闭环,避免 Redis 抖动时 5min TTL 让旧全权继续生效。但 DeptType / DeptStatus 同样是 loadPerms 全权分支的直接输入,它们的收窄路径现在还是"仅 UserDetailsLoader.CleanByUserIds 尽力而为":

if deptTypeChanged || statusChanged {
    userIds, err := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id)
    if err != nil {
        l.Errorf("UpdateDept id=%d deptType=%s status=%d 部门已更新但 FindIdsByDeptId 失败,用户权限缓存未能主动失效,将等待 TTL 自然过期: %v", req.Id, dept.DeptType, dept.Status, err)
        return nil
    }
    if len(userIds) > 0 {
        cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
        defer cancel()
        l.svcCtx.UserDetailsLoader.CleanByUserIds(cleanCtx, userIds)
        l.Infof("UpdateDept id=%d deptType=%s status=%d affectedUsers=%d", req.Id, dept.DeptType, dept.Status, len(userIds))
    }
}

相同问题也出现在 UpdateUserdeptId 的路径:UpdateProfileWithTx 只认 statusChanged 去递增 tokenVersiondeptId 跨越 DEV/NORMAL 边界时没有任何签发层吊销,只依赖 UserDetailsLoader.Clean 的尽力而为失效。

收窄方向的具体触发条件(这些全都是"权限从全权收回"的场景):

  1. UpdateDeptDeptTypeDEV → NORMAL——该部门所有在编成员在所属产品里的 loadPerms 从"全量权限"降级为"角色/allow-deny 计算";
  2. UpdateDeptStatusEnabled → Disabled——DEV 部门全权分支要求 DeptStatus == StatusEnabled,禁用即失去全权;NORMAL 部门成员是否被禁用则改变 jwtauthMiddlewareud.Status 的放行,但 dept.Status 对用户而言不是直接阻断字段,主要影响仍是 DEV 部门的全权分支;
  3. UpdateUser:把用户 deptId 从 DEV 部门挪到 NORMAL 部门——单一用户的全权被收回。

这三条路径的 TOCTOU 窗口与 M-R15-1 完全一致:

  1. SuperAdmin 改部门/用户归属;事务 COMMIT;
  2. post-commit UserDetailsLoader.Clean* 因 Redis 抖动失败(3s DetachCacheCleanCtx 超时 + 日志标记 cache_invalidation_skipped_*,不重试);
  3. 缓存里 {DeptType=DEV, DeptStatus=Enabled, Perms=[全量]} 保留到 TTL 过期(最长 5min);
  4. 受影响用户的 access token TokenVersion 未递增(DeptType/Status 变更不触发 UpdateProfile 的 tokenVersion+1),jwtauthMiddlewareTokenVersion != ud.TokenVersion 兜底不触发;
  5. 用户在这 5min 内继续以 "DEV 全权" 身份调业务接口。

与 R15 不同的是:UpdateDept / UpdateUser.deptId 的典型使用者是 SuperAdminUpdateDept 已经 RequireSuperAdminUpdateUser 改 deptId 跨 DEV 边界也被 H-R14-1 收敛给 SuperAdmin),因此这条 TOCTOU 的触发点比 UpdateMember 更低频——但影响面更广UpdateDept 一次性影响"该部门所有成员";UpdateProduct 影响"该产品所有成员";两者叠加时,Redis 抖动可以让一个完整部门在 5min 窗口内保留已经被收回的 DEV 全权。

影响

  • Redis 抖动或长时间网络分区(backfill / 故障演练期间)下,"DEV→NORMAL"或"禁用部门"的操作在 5min 窗口内不生效;
  • 与 M-R15-1 / L-R15-3 的"签发层吊销"设计哲学不一致,审计回归矩阵需要解释"为什么 dept 收窄走尽力而为、member/product 收窄走强制吊销"——当前代码没有任何注释给出差异理由,容易被后续维护者当作遗漏;
  • 若组合 H-R16-1 攻击(tokenVersion 不变),一个原本在 DEV 部门的 ADMIN 被 SuperAdmin 从 DEV 挪到 NORMAL,其全权仍在 5min 内有效——与"先 DEV→NORMAL 后立即 RemoveMember"的组合相比,本路径更难被监控感知(无 RemoveMember 事件、只有 UpdateUser/UpdateDept 的低敏感事件)。

修复方案

两处收窄路径在 tx 内补齐 BatchIncrementTokenVersionWithTx——复用 UpdateProduct 的 L-R15-3 模式,把"找出受影响 userIds"和"批量 +1"收敛进同一个事务,整体回滚语义天然成立。关键判定:只在真正构成"权限收窄"时递增,避免 NORMAL→DEV(升权)或 Disabled→Enabled(重启用)场景误踢用户下线。

1. UpdateDept 修复(DEV→NORMAL 或 DEV 部门 Enabled→Disabled 才递增):

// internal/logic/dept/updateDeptLogic.go
// 判定"收窄方向":仅在以下三种情况下需要 tokenVersion+1:
//   (a) DeptType: DEV → NORMAL(该部门所有成员失去 DEV 全权分支)
//   (b) DeptType 不变但 DEV 部门 Status: Enabled → Disabled(DEV 成员失去全权)
//   (c) DeptType 不变但 NORMAL 部门 Status: Enabled → Disabled(无直接授权影响,可选是否递增;保守起见建议递增,因为业务语义是"冻结部门所有活动")
// NORMAL→DEV(升权)与 Disabled→Enabled(恢复)不递增。
prevType := req.dept.DeptType // 即原 dept.DeptType
prevStatus := req.dept.Status // 即原 dept.Status
nextType := dept.DeptType     // 应用 req 之后
nextStatus := dept.Status
devFullAccessRevoked := (prevType == consts.DeptTypeDev && nextType == consts.DeptTypeNormal) ||
    (prevType == consts.DeptTypeDev && prevStatus == consts.StatusEnabled && nextStatus == consts.StatusDisabled)
normalDeptFrozen := prevType == consts.DeptTypeNormal && nextType == consts.DeptTypeNormal &&
    prevStatus == consts.StatusEnabled && nextStatus == consts.StatusDisabled

var revokedUserIds []int64
err := l.svcCtx.SysDeptModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
    if err := l.svcCtx.SysDeptModel.UpdateWithOptLockTx(ctx, session, dept, expectedUpdateTime); err != nil {
        return err
    }
    if devFullAccessRevoked || normalDeptFrozen {
        // FindIdsByDeptId 需要提供 Tx 版本;若 tx 版本不存在,可先 pre-commit 拿 userIds,但必须收进同一个事务以避免 TOCTOU。
        ids, err := l.svcCtx.SysUserModel.FindIdsByDeptIdForShareTx(ctx, session, req.Id)
        if err != nil {
            return err
        }
        if len(ids) > 0 {
            if err := l.svcCtx.SysUserModel.BatchIncrementTokenVersionWithTx(ctx, session, ids); err != nil {
                return err
            }
            revokedUserIds = ids
        }
    }
    return nil
})
// post-commit 继续走 CleanByUserIds + InvalidateProfileCache(与 UpdateProduct 对齐)

SysUserModel 当前没有 FindIdsByDeptIdForShareTx,需补一个带 LOCK IN SHARE MODE 的版本——比照 FindActiveMemberUserIdsByProductCodeTx 的实现(internal/model/productmember/sysProductMemberModel.go:102-110),与并发 UpdateProfileWithTx(X 锁 sys_user)互斥,防止"列出 userIds 期间有人刚被挪出本部门"造成吊销漏挂。

2. UpdateUser 修复(仅在 deptId 跨 DEV↔NORMAL 边界且方向为收窄时递增):

// internal/logic/user/updateUserLogic.go
// 读取旧 dept(user.DeptId)与新 dept(newDept)的 DeptType:
// 收窄方向 = (old.DeptType==DEV && new.DeptType==NORMAL)
// 升权方向 = (old.DeptType==NORMAL && new.DeptType==DEV)——已由 H-R14-1 收敛给 SuperAdmin
devAccessRevoked := false
if req.DeptId != nil && *req.DeptId != user.DeptId {
    var oldDept *deptModel.SysDept
    if user.DeptId > 0 {
        oldDept, _ = l.svcCtx.SysDeptModel.FindOne(l.ctx, user.DeptId)
    }
    if oldDept != nil && oldDept.DeptType == consts.DeptTypeDev && newDept.DeptType == consts.DeptTypeNormal {
        devAccessRevoked = true
    }
}

// tx 体内补:
if devAccessRevoked {
    if _, err := l.svcCtx.SysUserModel.IncrementTokenVersionWithTx(ctx, session, req.Id); err != nil {
        return err
    }
}

权衡说明

本条优先级明确低于 M-R15-1 / H-R16-1:触发者几乎都是 SuperAdmin(UpdateDept 强制超管;UpdateUser 改 deptId 跨 DEV 边界也被 H-R14-1 限制为超管),攻击窗口需要 "SuperAdmin 误操作 + Redis 故障" 双重触发;但一旦落在监管敏感的产品上,5min 的残余 DEV 全权可能覆盖多次敏感接口调用。按"与 L-R15-3 的口径对称"的原则把这条修了对长期维护一致性有利,单独不修也可以在审计注释里明确声明"本路径 TOCTOU 由 SuperAdmin 信任边界 + Clean 尽力而为组合承担"——但这会让后续维护者难以推理"为什么同样是授权收窄,member/product 走强吊销、dept/user.deptId 走尽力而为"。


R15 回归验证(附录)

条目 期望修复 代码现状 判定
M-R15-1 MemberType TOCTOU(UpdateMember 降级/禁用不吊销 session) tx 内 IncrementTokenVersionWithTx,post-commit 同步失效 sysUser 低层缓存 updateMemberLogic.go:94-154 新增 typeDowngraded/statusRevoked 判定 + IncrementTokenVersionWithTx + InvalidateProfileCachetokenVersionRevocation 结构封装闭包跨域传 userId,避免事务失败时脏状态泄漏 ✅ 已闭合(方案 A)
L-R15-1 UpdateUser.deptId=0 改写全局字段 deptId=0 收敛给 SuperAdmin updateUserLogic.go:158-170 非超管直接 403;注释显式声明与 H-R14-1 的结构对称性 ✅ 已闭合
L-R15-2 DeptTree fullAccess 泄露组织架构 fullAccess = caller.IsSuperAdmin deptTreeLogic.go:50 已改;产品 ADMIN 走 DeptPath 前缀裁剪;注释清晰说明 sys_dept 全局命名空间与 M-2 的最小授权精神 ✅ 已闭合
L-R15-3 UpdateProduct 禁用不吊销成员 session tx 内 BatchIncrementTokenVersionWithTx + FOR SHARE 锁成员行 updateProductLogic.go:77-127 已落地;FindActiveMemberUserIdsByProductCodeTxLOCK IN SHARE MODE 阻塞并发 AddMember/UpdateMember/RemoveMember 的 X 锁 ✅ 已闭合
L-R15-4 降权路径语义需在注释里显式声明 updateMemberLogic / updateProductLogic 顶部注释说明 updateMemberLogic.go:35-43 / updateProductLogic.go:36-42 均已补齐,引用 M-R15-1 / L-R15-3 审计条目 ✅ 已闭合

本轮总结

本轮新增 1 条 High(H-R16-1RemoveMember 降权路径与 M-R15-1 同构但未同步吊销 session),1 条 Medium(M-R16-1UserList / UserDetail 对同产品 MEMBER 暴露 PII),2 条 Low(L-R16-1UpdateUser ADMIN 分支短路 DeptPath 前缀校验,构成 L-R15-1 的非零 deptId 同构缺口;L-R16-2UpdateDept / UpdateUser.deptId 的权限收窄路径仍只走缓存失效、未 tokenVersion+1,与 M-R15-1 的签发层吊销口径不一致)。

语义关联:

  • H-R16-1L-R16-2 同属"降权吊销覆盖对称性"的残留工单;修完 H-R16-1 应顺带把 L-R16-2 一并处置,否则每轮审计都要重新解释"为什么有些降权走强吊销、有些走尽力而为";
  • L-R16-1 是 L-R15-1 在另一方向的同构项,两者合计描述的是"sys_user.deptId 作为全局字段,其任何写入都应该是 SuperAdmin 的权限维度"这一不变式;
  • M-R16-1 是独立的 PII 最小授权工单,与上述三条互相正交,但同样与 R14 的 H-R14-1 / L-R15-1 共享"产品 ADMIN 的权限边界不应穿透到全局字段"的设计哲学——MemberType 应当反向收敛读权限,不应因"同产品"就默认放行全部 PII。

优先处置顺序:H-R16-1 → M-R16-1 → L-R16-1 → L-R16-2。H-R16-1 最低代价(tx 内加一次 IncrementTokenVersionWithTx,与 M-R15-1 逻辑完全同构)但关掉一个 High 级攻击窗口;M-R16-1 对合规侧影响最大,修复面集中在两个 logic 文件;L-R16-1 / L-R16-2 可并入下一轮安全变更统一回归。