audit-report.md 27 KB

权限管理系统 —— 深度代码审计报告(第 7 轮)

审计范围/internal 下全部非测试、非 _gen.go 生产代码(含 internal/server/permserver.go、HTTP logic / handler / middleware、loaders、model 定制层、svc、util、consts)。 审计时间:2026-04-19 审计维度:逻辑一致性 / 并发与 RMW / 资源管理 / 数据完整性 / 安全漏洞 / 边界坍塌 / DB 性能 / 僵尸代码 / 接口契约与对象完整性。 与上一轮对比:第 6 轮的 H-1 / H-2 / H-3 / M-1 / M-2 / M-4 / M-5 / M-6 / M-8 / L-1 / L-3 / L-5 均已在 HEAD 代码中落地。本轮聚焦第 6 轮未修复项(M-3 / M-7 / L-2 / L-4 / L-6)这一轮深挖出来的新漏洞,尤其是UserDetailsLoader.loadPermsdeny-list fail-openAddMemberLogic 的目标侧授权缺失等高风险项。


🚩 核心逻辑漏洞 (High Risk)

H-1. UserDetailsLoader.loadPermsdeny 列表查询失败时 fail-open,且把"少了 deny"的权限集写入 5 分钟缓存 —— 单次 DB 抖动 → 用户越权

  • 位置internal/loaders/userDetailsLoader.go:456-499
  • 描述:普通成员的权限集计算顺序是:
    1. FindPermIdsByUserIdAndEffectForProduct(allow) —— 失败时 return,ud.Perms 保持 nil(fail-close,OK)。
    2. FindPermIdsByUserIdAndEffectForProduct(deny) —— 失败时只 log,然后继续往下跑denyIds 为 nil,denySet 为空。
    3. permIdSet 里塞 rolePermIds + allowIds,然后直接把这个未经 deny 过滤的集合作为最终权限写回缓存(缓存 TTL 5 分钟)。

FindPermIdsByUserIdAndEffectForProductQueryRowsNoCacheCtx,任何瞬时 DB 错误(连接池耗尽、slow query 触发 context deadline、主从漂移等)都会让 deny 查询失败。结果就是:

  • 一个原本被 effect=DENY 显式撤销权限的用户,立刻拿回这条被撤销的权限
  • 并且这个"多出来的权限"被 json.Marshal 进 UD JSON 后写入 ud:userId:productCode Redis key,持续 5 分钟
  • 5 分钟内该用户所有请求(HTTP middleware / gRPC GetUserPerms)读取的都是这份"无 deny"权限集,有多少次请求就有多少次越权
  • 影响
    • 任意 deny-revoke 授权操作在单次瞬时 DB 抖动下 5 分钟内失效。考虑到 setUserPermsLogic 的主要用途就是"临时撤销某用户对敏感权限的访问",这类 deny 往往就是最后一道安全闸。闸被 silently 打开 5 分钟。
    • 攻击者若能制造一次对 sys_user_perm 的短时读失败(例如对该表发起 hot-row 争抢使 denyIds 的查询 timeout),即可让目标用户的 deny 被旁路。
    • 与 R6 H-3 / H-4 不同,这是一条纯代码路径问题,不依赖配置、不依赖代理头,出现概率等于 DB 抖动概率
  • 修复方案:把两次查询在错误语义上对称处理:

    denyIds, err := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(ctx, ud.UserId, consts.PermEffectDeny, ud.ProductCode)
    if err != nil {
      logx.WithContext(ctx).Errorf("userDetailsLoader: load deny perms failed: %v", err)
      return // fail-close:宁可让用户看到 0 perms 让他们刷新,也不能把 deny 旁路
    }
    

    同时本次加载不要写缓存,交给下一次 Load 重试,或者把失败信号往上传(见 M-1)。

    H-2. UserDetailLogic / UserListLogic 仍把 Email / Phone / Remark 暴露给任意同产品成员(R6 M-3 未落地)

    • 位置
    • internal/logic/user/userDetailLogic.go:68-70
    • internal/logic/user/userListLogic.go:81-83
    • 描述:两接口的访问控制是"同产品 → 返回完整资料"。UserDetail 仅用 FindOneByProductCodeUserId 检查目标与调用方在同一产品内,就返回 EmailPhoneRemark(纯文本,无脱敏)。UserList 更严重 —— 一次分页可以批量拿到同产品所有成员的手机号与邮箱。
    • 影响
    • 同产品最低权限 MEMBER 即可遍历整个产品通讯录,获取手机 / 邮箱 / 备注(备注里可能含 PII / 内部身份)。
    • 与 H-3(AddMember 不做目标侧授权)组合后威力更强:一个产品 ADMIN 可先把想看的任意人(包括其部门树外、不归自己管的用户)强行拉入自己的产品,再通过 UserDetailUserList 抽走其 PII。
    • 严重违反 GDPR / 《个人信息保护法》最小必要原则。
    • 修复方案
    • 新增 authHelper.CanViewContact(caller *UserDetails, target *SysUser, targetMember) bool,只在以下任一条件时返回 true
      • caller.IsSuperAdmin;
      • target.UserId == caller.UserId(看自己);
      • caller 对 target 的 CheckManageAccess 通过(即 caller 在管理链上)。
    • 两个 Logic 返回 DTO 前统一走 filterPIIForCaller,其余情况把 Email / Phone / Remark 置空或做掩码(如 138****1234)。
    • 单元测试覆盖"同产品同级成员互看"、"跨部门互看"、"ADMIN 看下级"、"看自己"四条。

    H-3. AddMemberLogic 缺失目标侧 CheckManageAccess + 超管防御,产品 ADMIN 可把部门树外 / 超管用户拉入自己产品

    • 位置internal/logic/member/addMemberLogic.go:41-75
    • 描述AddMember 仅做了三件事:
    • RequireProductAdminFor(req.ProductCode):caller 是该产品 ADMIN 或超管。
    • CheckMemberTypeAssignment(req.MemberType):caller 允许分配这种 MemberType。
    • FindOneByProductCodeUserId 排查重复加入。

    缺失的两道防线:

    1. req.UserId 目标的 CheckManageAccess:没有任何基于 DeptPath 的部门链校验,也没有任何基于 MinPermsLevel 的权限级校验。产品 ADMIN 可以把自己部门树之外的用户(例如 HR 部、财务部员工)强行拉入自己的产品。
    2. targetUser.IsSuperAdmin 的显式拒绝:如果系统中存在"超管但未主动加入任何产品"的账号,产品 ADMIN 可通过 AddMember 把这个超管拉入自己产品成为 MEMBER。虽然 loadMembership 会在 ud.IsSuperAdmin == true 时把 MemberType 固定为 SuperAdmin(实际权限没被限制),但这在审计日志里会留下一条"product_admin 把 super_admin 纳入自己产品"的假成员关系,为后续权限推理工具 / 审计系统制造混淆,也是第一步社工放大点。
    3. 影响
    4. 与 H-2(PII 暴露)组合:ADMIN 随意从部门树外拉人入产品,然后读全员手机邮箱。这是实际上最容易被滥用的越权路径
    5. UpdateMemberLogic 组合:拉入后还可以赋予任何允许的 MemberType,制造跨部门的管理权扩张。
    6. 修复方案RequireProductAdminFor / CheckMemberTypeAssignment 之后、Insert 之前,追加如下两段: go if targetUser.IsSuperAdmin == consts.IsSuperAdminYes { return nil, response.ErrForbidden("无法将超级管理员加入具体产品") } if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.UserId, req.ProductCode, authHelper.WithPrefetchedTarget(targetUser)); err != nil { return nil, err }

注意:CheckManageAccess 内部对 "caller 是 ADMIN" 会短路 checkDeptHierarchy,所以这不会让产品 ADMIN 失去管理自己下属 / 旁部门用户的能力,但会拦住"部门树外 + 不归自己管"这类真正的越权路径。

H-4. JWT 解析三处 keyfunc 未显式断言 *jwt.SigningMethodHMAC(R6 M-7 未落地)

  • 位置
    • internal/middleware/jwtauthMiddleware.go:59-61(HTTP access token)
    • internal/server/permserver.go:242-244(gRPC access token)
    • internal/logic/auth/jwt.go:78-80(refresh token)
  • 描述:三处都是直接 return []byte(secret), nil,不检查 token.Method 类型。当前使用 jwt/v4accessSecret / refreshSecret 都是对称密钥,不受 alg=none 攻击,但这是深度防御盲区
    • 如果未来迁移到 RSA/ECDSA 非对称密钥,而 keyfunc 仍然把 []byte 塞进去,攻击者可以用把公钥当 HMAC 密钥的经典手法伪造 token —— 这在 jwt-go 历史上是实打实出过 CVE 的(CVE-2016-10555 同类问题)。
    • 即使密钥不换,线上一旦因为误配生成出 alg=HS512 的 token,也不会被明确拒绝,而是当作 HS256 尝试解析,带来噪音和潜在被动兼容。
  • 影响:当前配置下不构成直接漏洞,但违反 OWASP JWT Cheat Sheet、RFC 8725(JWT Best Current Practice)对 alg 白名单的强制要求。
  • 修复方案:抽出一个通用 helper,三处统一调用:

    // internal/logic/auth/jwt.go
    func parseWithHMAC(tokenStr, secret string, claims jwt.Claims) (*jwt.Token, error) {
      return jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (interface{}, error) {
          if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
              return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
          }
          return []byte(secret), nil
      })
    }
    

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

    M-1. UserDetailsLoader.Load 把 "DB 瞬时故障" 同化为 "用户不存在",副作用是把半成品 UD 写入 5 分钟缓存

    • 位置internal/loaders/userDetailsLoader.go:119-165, 284-304
    • 描述
    • loadFromDBloadUser 失败(非 NotFound)时返回 ud, err;在 Load 的 singleflight 闭包里转化为 (nil, err)
    • 但是 Load 最后的 if !ok || ud == nil 分支会构造一个空 UD 返回。HTTP jwtauthMiddleware 看到 ud.Username == "" 就直接 401 "用户不存在或已被删除"。
    • 用户体验:一次 DB 抖动 → 全站在线用户被踢出,客户端清 token 重新登录 → 登录又打 DB → 进一步加重 DB → 雪崩
    • 更隐蔽的是:loadDept / loadProduct / loadMembership / loadRoles / loadPerms 这五个子步骤里的任何错误都是 log + 静默继续。然后 Load 在 singleflight 成功分支里照常 json.Marshal(ud)SetexCtx 写入缓存。于是当 dept 表抖动时,用户的 DeptPath / DeptType 变空白,checkDeptHierarchy 直接 403(除非 caller 是 ADMIN/超管),这份"半残" UD 还会被缓存 5 分钟。
    • 影响
    • 雪崩风险:单次 DB 抖动 → 全站强制退出登录,客户端反复重试。
    • 半加载缓存污染:用户在 5 分钟内会遇到"莫名其妙的 403",且运维看监控是绿的(因为错误被 log 吞了)。
    • 与 H-1 叠加:loadPerms 的 deny 失败同样落入这个"半加载也写缓存"的通道。
    • 建议
    • Load 返回 (*UserDetails, error),让中间件自己区分 "NotFound → 401 用户不存在" 与 "其他错误 → 503 服务暂时不可用"。
    • loadFromDB 里任何子步骤出错,都不要写缓存(让下次 Load 重试)。
    • 如果还是想保留无 error 返回,至少在 ud 上加一个 PartiallyLoaded bool 字段,Load 写缓存前检查这一位。

    M-2. SysUserModel.UpdatePassword / UpdateStatus 不校验 RowsAffected,对已删除 / 条件不满足的用户会静默成功

    • 位置
    • internal/model/user/sysUserModel.go:128-140UpdatePassword
    • internal/model/user/sysUserModel.go:143-155UpdateStatus
    • 描述:两处都是 m.ExecCtx(..., conn.ExecCtx(...)) 然后直接 return err不读 sql.Result.RowsAffected。如果 FindOneExecCtx 之间用户被另一会话删除,或者主从延迟导致 WHERE id = ? 命中 0 行,调用方拿到的是 nil err,以为"更新成功"——实际 DB 里没有变化,但 DelCacheCtx(sysUserIdKey, sysUserUsernameKey) 已经执行了。
    • 影响
    • ChangePassword 对已删除用户会返回 200 "成功",客户端以为改密成功,用户下次登录发现密码没变。
    • UpdateUserStatus 对跨进程并发删除的用户会"假装成功",上层以为冻结生效。
    • 更重要的:UpdatePassword / UpdateStatus 自身没有乐观锁(UpdateProfileupdateTime CAS),两次并发 ChangePassword 会"后写覆盖先写"且都返回成功,用户拿到的新密码是无法预测的那一份(在已知旧密码共谋场景下影响低)。
    • 建议go res, err := m.ExecCtx(...) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound // 或自定义 ErrUpdateConflict } return nil

另外建议对 UpdatePassword 加上 AND updateTime = ? 的乐观锁,语义上与 UpdateProfile 对齐,避免并发改密"最后一写赢"的隐式行为。

M-3. GuardRoleLevelAssignable 的授权依据是 caller 的缓存 MinPermsLevel,被降级的调用者在 5 分钟缓存 TTL 内仍可分配原等级角色

  • 位置internal/logic/auth/access.goGuardRoleLevelAssignable);调用方 internal/logic/user/bindRolesLogic.go
  • 描述BindRoles 的授权判断是"caller 的 MinPermsLevel 必须严格小于被分配角色的 PermsLevel",而 caller 是从 UserDetailsLoader.Load(callerUserId, productCode) 取的,缓存 TTL 5 分钟。攻击窗口:
    1. T=0:超管把 caller C 从"总监级角色 (permsLevel=10)"降到"普通员工 (permsLevel=500)"。超管调用 BindRoles 改 C 的角色(Clean(C.UserId))。
    2. T=0+δ:C 自己在其他机器上调用 BindRoles 给下属 X 分配"总监级角色 (permsLevel=10)"。
    3. C 的 JWT token 里没有 MinPermsLevel,它要靠 UD 缓存。如果 C 的 UD 缓存在 T=0 被 Clean,第二次读会打 DB 拿到新级别 → 授权失败。但只要 Clean 因为 Redis 抖动失败了一次,C 的 UD 缓存还在,GuardRoleLevelAssignable 读到 10,判定"严格小于 10 即可"不通过,判定 10 >= 10 → 授权失败(这条实际 OK)。
    4. 真正的问题:如果 C 的角色从 10 降到 20(不是 500),C 要分配 15 级角色:caller.MinPermsLevel(缓存=10) >= 15 不成立 → 允许。实际 DB 里 C 是 20,20 >= 15 成立 → 应该拒绝。C 用缓存越权分配了自己现在够不到的角色
    5. 5 分钟窗口足够 C 把一整组下属 bulk 升到 15 级。
  • 影响
    • 典型的"TOCTOU + 缓存失效延迟"叠加漏洞。Clean 失败只被 log,没有重试,也没有降级成"缓存读写直通 DB"的 fallback。
    • 触发条件依赖"超管降级某 admin"的时序,实际被动利用概率低,但主动制造(admin 预见自己要被降级,抢 5 分钟窗口)可能性不能排除。
  • 建议
    1. GuardRoleLevelAssignable 里,对 caller 的 MinPermsLevel 额外做一次"旁路缓存直查 DB"校验(只在这条授权点,不影响其他用 UD 缓存的路径)。
    2. 或者 Clean 走 Redis pipeline + 一次 Lua script 保证原子,失败时 retry 2~3 次,失败后把 userId 入一个短 ttl 的降级黑名单,命中就强制走 DB。
    3. UserDetailsLoader 加个 LoadFresh(ctx, userId, productCode) 方法专供授权点使用,bypass cache。

M-4. CreateProductLogic 响应体里明文返回初始 admin 密码,穿过任何响应日志 / 监控都会落盘

  • 位置internal/logic/product/createProductLogic.go(返回 types.CreateProductResp.AdminPassword 明文)、internal/types/types.go:47-54
  • 描述CreateProductResp 包含 AdminPassword stringgo-zero 默认响应序列化走 httpx,响应体默认不自动打日志,但在以下三个常见运维情况下会落盘:
    • API 网关 / Nginx access log 关掉了 body redaction;
    • APM / OpenTelemetry 开了 "response body" 采样;
    • 前端 console 或者集成测试截图留在了代码仓库 / 工单系统。
  • 影响:密码泄漏;IsSuperAdmin 产品默认密码一旦落到长期存储就需要紧急全量改密。
  • 建议
    1. 响应里把 AdminPassword 字段标记为 "一次性展示",并在文档里强制要求立刻改密。
    2. 更稳的方案:响应只返回 adminUser,密码随后走带 nonce 的一次性链接(Redis 中存 5 分钟、一次消费后删除),新产品 owner 登录后自己重置。
    3. 至少在 response.Middleware 中把 AdminPassword 字段加入日志脱敏白名单。

L-1. DeleteDeptLogic 事务内多段 FOR UPDATE 锁序列仍保留 AB-BA 死锁理论风险(R6 L-2 未消化)

  • 位置internal/logic/dept/deleteDeptLogic.go
  • 描述:一个事务内依次 FOR UPDATEsys_dept(row)sys_dept(children range)sys_user(dept range)。如果另一事务(比如 UpdateUser 要改 DeptId,同时 CreateDept 插一个子部门)以不同顺序抢锁,理论上存在 AB-BA 交叉死锁。实际频率低,但 MySQL 死锁 retry 会被 go-zero 上浮成 500。
  • 建议:全局统一 "先锁部门再锁用户"、"先锁父部门再锁子部门" 的顺序协议,并在 UpdateUserLogic 涉及 DeptId 变更的时候显式 SELECT ... FOR SHARE 一下新旧部门。或把 DeleteDept 中排查子部门 / 关联用户的 FOR UPDATE 降成 FOR SHARE(因为这里是存在性判断而不是修改)。

L-2. SysUserModel.IncrementTokenVersion 仍是"无条件大杀器",缺安全注释 / 调用点约束(R6 L-4 未消化)

  • 位置internal/model/user/sysUserModel.goIncrementTokenVersion),调用方 internal/logic/auth/logoutLogic.go:46
  • 描述RefreshToken 已经切到 IncrementTokenVersionIfMatch(CAS 语义正确),Logout 还在用老的 IncrementTokenVersion(业务语义正确,"强制所有会话失效")。风险是这个 API 现在对整个仓库可见,未来任何改 Refresh / Rotate 场景的开发者都可能误调它,退回到 R5 以前的会话劫持窗口。
  • 建议
    1. IncrementTokenVersion 加个 // WARN: 仅限强制全量失效(Logout / 封禁)。Refresh/Rotate 必须使用 IncrementTokenVersionIfMatch。 的显式 header 注释。
    2. 最干净的做法:把 IncrementTokenVersion 改成 package-private,再在 logout 所在 package 用显式命名的 wrapperForceRevokeAllSessions)暴露,新接入者一眼看到红色标签。

L-3. loadPerms 其他分支的错误同样被静默 log(rolePermIds / allowIds / FindAllCodesByProductCode),和 H-1 同宗

  • 位置internal/loaders/userDetailsLoader.go:435-498
  • 描述
    • L450-453:FindPermIdsByRoleIds 失败 → rolePermIds 保持空。若此时 role 权限正常、但查询临时失败,用户的"角色→权限"整块就被丢掉。
    • L435-437:FindAllCodesByProductCode 失败 → ud.Perms = nil(对 ADMIN / DEV 部门这类"全量权限"角色来说直接降成 0 perm)。
    • L487-498:FindByIds 失败 → ud.Perms = nil

所有这些都会被 Load 写入 5 分钟缓存。对 ADMIN 来说是"5 分钟内所有权限消失",对普通成员来说是"5 分钟内权限表不一致"。用户体感就是间歇性 403,定位困难。

  • 建议:与 M-1 同步修复:loadPerms 内任一子步骤返回 error,整次 Load 跳过缓存写入,同时把 error 传给 Load,由上层决定是 503 还是 401。

L-4. Model 层 SQL 中的 status = 1 硬编码,与 consts.StatusEnabled 脱钩

  • 位置
    • internal/model/userrole/sysUserRoleModel.go:51 —— r.status = 1
    • internal/model/userperm/sysUserPermModel.go:35 —— p.status = 1
    • internal/model/role/sysRoleModel.goFindMinPermsLevelByUserIdAndProductCode)—— r.status = 1
  • 描述consts.StatusEnabled 当前定义为 1,但三处 SQL 把它写死。一旦运维 / 迁移脚本把 StatusEnabled 的语义改掉,或者业务加出 "status=2 已归档" 之类的新状态,这几条查询会默默返回错误数据集,没有编译期 / 单元测试期信号。
  • 建议:改成 ... AND r.status = ? + 参数传 consts.StatusEnabled,与其他同类查询(如 sysProductMemberModel.CountOtherActiveAdminsTx)风格一致。

L-5. internal/model/productmember/sysProductMemberModel.go 中两个僵尸接口方法

  • 位置
    • FindMapByProductCodeUserIds(定义在接口和实现中)
    • CountActiveAdmins(非事务版)
  • 描述rg 扫下来这两个方法仅在 testutil/mocks/mock_productmember_model.gosysProductMemberModel_test.go 中被引用,整个 /internal/logic/** 没有一处调用。实现里还包含手写 SQL,是维护负担与误用风险源。 同样地,internal/model/perm/sysPermModel.go 中的 FindMapByProductCode(非事务版)也仅在 mock 与 test 中出现,syncPermsService 已切到 FindMapByProductCodeWithTx
  • 建议:确认无残留调用后从接口 / 实现 / mock 里移除,避免接口 surface area 膨胀。

L-6. gRPC GetUserPerms 可被有效 AppKey/AppSecret 拥有者用作负缓存预污染工具

  • 位置internal/server/permserver.goGetUserPerms);结合 internal/loaders/userDetailsLoader.go:134-154 的负缓存写入路径。
  • 描述GetUserPerms 接受任意 req.UserId,内部会调用 UserDetailsLoader.Load。若攻击者拥有有效的产品凭证(如被泄漏的 appKey/appSecret),可批量请求未来将分配的自增 ID(userId = maxUserId+1 ... maxUserId+N):
    • 每个未命中查询会落一条 negativeCacheMarker(TTL 30s);
    • 当一个新用户在这 30s 内被 CreateUser 自增到这个 ID,他的 UD 缓存键已被占用为负缓存;
    • 新用户自身一旦被 Load,直接命中 _NOT_FOUND_Username 返回空,JWT middleware 判定"用户已被删除",登录 / 使用失败。
  • 影响:条件依赖 AppKey 泄漏 + CreateUser 时机,概率低,但这是唯一一条"外部可写负缓存"的通道。
  • 建议
    1. Load 在写入负缓存之前,再通过 SysUserModel.FindOne(ctx, userId) 强一致校验一次(绕过 cache),确认真 NotFound 才写哨兵。
    2. 或者在 CreateUserLogic 成功插入之后主动 Delud:newId:* 的负缓存键(需要遍历产品维度,因此成本较高)。
    3. 最简:negativeCacheTTL 从 30 → 10,并加一条 svc 级的"新用户创建后 30s 内绕过负缓存"的白名单(按 userId > watermark 判定)。

L-7. CheckManageAccess 对 caller DeptId=0 且非 ADMIN 的历史账号直接 403(R6 L-6 未消化)

  • 位置internal/logic/auth/access.gocheckDeptHierarchy
  • 描述:H-4(R6 时已修复)之后新建 MEMBER/DEVELOPER 不再是 DeptId=0,但存量数据中有遗留DeptId=0 MEMBER 账号。这类账号即便通过 checkPermLevel 校验也会因为 caller.DeptPath == ""checkDeptHierarchy 被直接 403。
  • 建议
    1. 运维侧一次性迁移 UPDATE sys_user SET deptId = <default_dept_id> WHERE deptId = 0 AND isSuperAdmin = 0 AND memberType NOT IN ('ADMIN')
    2. 代码侧把"看自己"场景短路,在 CheckManageAccess 最上面加 if callerUserId == targetUserId && productCode == caller.ProductCode { return nil } 避免纯看自己的操作被部门树误伤。

📋 修复优先级汇总

优先级 finding 一句话概要
P0 H-1 loadPerms deny-list fail-open DB 抖动一次 → 用户越权 5 分钟,纯代码路径,修最简单
P0 H-2 UserDetail/UserList PII 暴露(R6 M-3 未落地) 任意同产品 MEMBER 可读全员手机邮箱
P0 H-3 AddMember 缺 CheckManageAccess + 超管防御 产品 ADMIN 可拉跨部门 / 超管用户入产品,直接放大 H-2
P0 H-4 JWT keyfunc 未断言 HMAC(R6 M-7 未落地) 深度防御盲区,未来密钥体系迁移的定时炸弹
P1 M-1 Load 把 DB 故障同化为用户不存在;半加载也写缓存 单点 DB 抖动触发雪崩 + 5 分钟半残缓存污染
P1 M-2 UpdatePassword/UpdateStatus 不校验 RowsAffected 对已删除用户静默成功,语义欺骗客户端
P1 M-3 GuardRoleLevelAssignable 依赖缓存 MinPermsLevel TOCTOU + Clean 失败 → 降级 admin 在 5 分钟内仍能授出原等级
P1 M-4 CreateProduct 响应里带明文初始密码 穿过日志 / APM 就落盘,需紧急改密
P2 L-1 DeleteDept 多段 FOR UPDATE 锁序列(R6 L-2) AB-BA 死锁理论风险
P2 L-2 IncrementTokenVersion 无安全注释(R6 L-4) 易被未来改 Refresh 的开发者误用
P2 L-3 loadPerms 其余分支错误同样静默 和 H-1 同宗,应作为一个修复包一起上
P2 L-4 SQL 中 status = 1 硬编码 统一改成 consts.StatusEnabled 占位参数
P2 L-5 FindMapByProductCodeUserIds / CountActiveAdmins(非 Tx)等僵尸 仅 mock/test 引用,清理
P3 L-6 gRPC GetUserPerms 负缓存预污染 依赖 AppKey 泄漏 + 自增 ID 命中,概率低但可行
P3 L-7 CheckManageAccess caller DeptId=0 时 403(R6 L-6) 历史遗留账号,运维侧补数据或代码兜底"看自己"

🛠 建议修复次序

  1. P0 同批上线(同一次发版一起修,互相放大):

    • H-1:给 deny-list 查询改 fail-close。
    • H-2:filterPIIForCaller 在 UserDetail / UserList 返回前强制走一遍。
    • H-3:AddMember 追加 CheckManageAccess + 超管判定。
    • H-4:抽 parseWithHMAC helper,三处 keyfunc 替换。
  2. P1 紧随

    • M-1 + L-3:一起做 "Load/loadPerms 错误模型重构"。接口改成 (*UserDetails, error),半加载不写缓存。
    • M-2:两处 ExecCtx 后加 RowsAffected 判定。
    • M-3:GuardRoleLevelAssignable 改走 fresh read,不靠 UD 缓存。
    • M-4:CreateProductResp 换成"一次性展示链接 + 立即改密"流程。
  3. P2 / P3 收尾

    • L-1 统一 FOR UPDATE 锁序列。
    • L-2 加红色注释 + 考虑 package-private。
    • L-4 status = 1 批量改占位参数。
    • L-5 清掉僵尸接口方法 + 其 mock。
    • L-6 Load 写负缓存前再跑一次 fresh FindOne,或者缩 TTL 到 10s。
    • L-7 数据迁移 + CheckManageAccess "看自己" 短路。