audit-report.md 16 KB

审计报告(第 18 轮)

审计范围:internal/ 下的全部生产代码(排除 _test.gomocks/、CLI 生成的 *_gen.go 中模型原型)。 重点关注:多接口间的逻辑耦合、并发/事务一致性、缓存与 DB 的双写对齐、权限边界、接口契约完整性。

R10~R17 已发现的核心漏洞(包括本轮编号为 H-R17-1/2/3、M-R17-1/2、L-R17-1~6)经核查均已在代码中落地修复并附注释(createUserLogic.go / deleteDeptLogic.go / updateDeptLogic.go / createRoleLogic.go / deleteRoleLogic.go / syncPermsService.go / updateUserStatusLogic.go / changePasswordLogic.go / createDeptLogic.go / deptTreeLogic.go 等)。本轮仅列出新增发现


🚩 核心逻辑漏洞 (High Risk)

H-R18-1 · UpdateRoleLogic 重命名角色时未失效旧名字索引缓存,残留"幽灵角色快照"

  • 描述internal/logic/role/updateRoleLogic.go 通过 SysRoleModel.UpdateWithOptLock(role, prevUpdateTime) 落库,model 层(sysRoleModel.go:101-116)传给 m.ExecCtx 的失效键只有:

    sysRoleIdKey               = cacheSysRoleIdPrefix + data.Id
    sysRoleProductCodeNameKey  = cacheSysRoleProductCodeNamePrefix + data.ProductCode + ":" + data.Name   // ← 新 name
    

    当本次 UPDATE 把 nameX 改成 Y 时,cacheSysRoleProductCodeNamePrefix:<code>:X(旧 name 键)没有人清,Redis 里仍然保留着指向原主键的索引值。

  • 影响

    1. FindOneByProductCodeName(code, X) 在 TTL(默认 7 天,走 sqlc 默认)内会继续命中旧索引 → 拿到"名为 X、实际已改为 Y"的脏行。
    2. 结合同样 R17 已修过但调用路径仍在的 CreateRole(依赖 DB UNIQUE(productCode,name) 而非预检):当运营先 "UpdateRole X→Y",紧接着 "CreateRole 名字=X" 时,DB 的 UNIQUE 允许新建一条合法的 X,但 Redis 索引仍指向原来的主键,导致 FindOneByProductCodeName(code, X) 直至下一次 Insert/Update 触发 DelCache 才会自愈——两条同名 X 的"旧主键映射 → 新 name=Y 的行"状态会持续长达 TTL。
    3. 与 R17 对 DeleteRole 补的 InvalidateRoleCache 对称缺口:rename 路径唯独漏掉这一对"旧 name 键"的显式失效。
  • 修复方案: 在 UpdateRole 里先快照 prevProductCode/prevNameUpdateWithOptLock 完成后显式调用 InvalidateRoleCache(ctx, role.Id, prevProductCode, prevName)InvalidateRoleCache(ctx, role.Id, role.ProductCode, role.Name)——与 R17 DeleteRoleLogic 的失效顺序保持一致,避免"幽灵 role 快照"。若不想改 model 语义,也可直接在 updateRoleLogic.go 里手动 l.svcCtx.Redis.DelCtx(ctx, "cache:sysRole:productCode:name:"+prevCode+":"+prevName)

    prevProductCode := role.ProductCode
    prevName := role.Name
    // ... 赋值 req.Name 到 role.Name 之后
    if err := l.svcCtx.SysRoleModel.UpdateWithOptLock(l.ctx, role, prevUpdateTime); err != nil { ... }
    cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
    defer cancel()
    if prevName != role.Name {
      l.svcCtx.SysRoleModel.InvalidateRoleCache(cleanCtx, role.Id, prevProductCode, prevName)
    }
    l.svcCtx.SysRoleModel.InvalidateRoleCache(cleanCtx, role.Id, role.ProductCode, role.Name)
    

    H-R18-2 · UpdateDeptLogic 冻结 NORMAL 部门仅递增 tokenVersion,未实际收窄权限,与注释声明不一致

    • 描述internal/logic/dept/updateDeptLogic.go:99-107normalDeptFrozen 分支判定为 "NORMAL 部门 Enabled→Disabled",注释声明为"冻结本部门所有活动,一并吊销"。实际行为:
    • 事务内对受影响 userIds 走 BatchIncrementTokenVersionWithTx — 仅让旧 JWT 失效。
    • userDetailsLoader.go:loadPerms 的授权计算里只有 DEV 部门分支依赖 DeptStatus == Enabled(第 543 行);对 NORMAL 成员,DeptStatus 从未进入权限计算。
    • checkDeptHierarchyaccess.go:377-422)也不读 targetDept.Status
    • 用户 sys_user.Status 保持不变。

    净效果:被"冻结"的 NORMAL 部门成员只是被强制退出一次登录;立即重新登录即可继续使用所有原有权限。审计日志里会留下 "affectedUsers=N revokedSessions=N",值班看上去像是"已吊销",但业务权限实际上没有任何变化

    • 影响
    • 对运营侧形成"已冻结假象":操作者执行"冻结某部门"后相信该部门无法再访问系统,但成员立刻重登就恢复;在出现"部门整体涉嫌违规 → 临时冻结调查"这类场景下放出越权窗口。
    • 审计日志里的 audit="UpdateDept"revokedSessions 字段容易被上级审阅/合规稽核误判为"已生效的访问控制措施"。
    • updateUserStatusLogic / updateMemberLogic / updateProductLogic 同族 "收窄" 路径的业务语义不对称——后三者都是"DB 字段落地 + loadPerms 分支响应"的完整闭环。
    • 修复方案(择一,建议 A):
    • A. 让 loadPerms 对 NORMAL 成员也读 DeptStatus:在 userDetailsLoader.go:524-625 loadPerms 的普通成员分支最前面加 if ud.DeptStatus != consts.StatusEnabled { ud.Perms = nil; return nil };同时 middleware/jwtauthMiddleware.go 的校验链里补 "ud.DeptStatus != Enabled → 401/403"。
    • B. 放弃冻结 NORMAL 部门的语义:把注释里的"一并吊销"改掉,并在 UpdateDeptLogicnormalDeptFrozen 分支只失效缓存、不再 BatchIncrementTokenVersionWithTx,避免运营误解。
    • A 与当前 UpdateProduct 禁用产品时的全员收窄口径完全对称,优先推荐。

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

    M-R18-1 · AddMemberLogic / SetUserPerms / BindRoles 未复核 ud.Status,仅靠中间件兜底

    • 文件:internal/logic/member/addMemberLogic.gointernal/logic/user/bindRolesLogic.gosetUserPermsLogic.go
    • 这些管理接口均先 middleware.GetUserDetails 取 caller,随后进入业务流。caller.UD 的 5min 聚合缓存里的 Status 字段在"超管刚把 caller 冻结但 Redis Clean 抖动失败"窗口内仍是旧值,jwtauthMiddleware 的 ud.Status != Enabled 拦截同样可能被旁路。虽然 R11-R15 已经给高风险写入加了 loadFreshMinPermsLevel 强一致复核,但"caller 本身已被冻结"这一维度只靠缓存。
    • 建议:在 access.go 新增 RequireActiveCaller(ctx, svcCtx),在 HTTP 写路径入口一次性走 DB 强一致复核 caller 的 sys_user.status(类似 loadFreshMinPermsLevel 的缓存旁路)。成本是每次写 +1 次 FindOne(caller.UserId),但写路径 QPS 不高,收益是彻底堵住"刚被冻结还能写"的 TTL 级 TOCTOU。

    M-R18-2 · DeptTreeLogic 非超管分支仍全表 FindAll 再内存过滤

    • 文件:internal/logic/dept/deptTreeLogic.go
    • checkDeptHierarchy 对非超管要求 DeptPath 前缀匹配——完全可以在 DB 层用 WHERE path LIKE CONCAT(?, '%') 走索引,避免把全量 sys_dept 拉回进程内存再过滤。
    • 建议:模型层补 FindByPathPrefix(ctx, prefix)(带 path 前缀索引命中),在 DeptTreeLogic 非超管分支改用它。对 300~500 个部门这种真实规模,内存滤选问题不大;但模型接口暴露 FindAll 让其它调用方未来踩到同类坑。

    M-R18-3 · loadMembership 使用 err == productmember.ErrNotFound 进行裸等值比较

    • 文件:internal/loaders/userDetailsLoader.go:473
    • 现状:productmember.ErrNotFound = sqlx.ErrNotFound,裸等值成立;但今后任何一层包装(fmt.Errorf("%w", err) / 多级 model)都会让这条分支失效——走到 error 分支打日志并最终合并成 ErrLoaderDegraded,把"真的不是成员"退化为 503。
    • 建议:统一改为 errors.Is(err, productmember.ErrNotFound),与文件内其它 error 分支口径对齐(如 loadUsererrors.Is(err, sqlx.ErrNotFound))。

    M-R18-4 · CreateUserLogic 的强口径校验落在 req.DeptId > 0 内层,DB 落盘后的 req.DeptId == 0(超管)被直接放行

    • 文件:internal/logic/user/createUserLogic.go:134-161
    • 审计 H-R17-1 的 S 锁在 req.DeptId > 0 分支生效;超管走 deptId=0 分支完全跳过 FindOneForShareTx,同时 Insert 依然落成功行——这符合"只有超管能创建无部门用户"的业务约束,但会在历史数据里继续沉淀"DeptId=0 的 MEMBER 幽灵账号"。checkDeptHierarchy 虽然对这类账号 fail-close,但缺少一条"创建即审计"的告警事件,运维无法识别此类账号何时被创建以及谁创建。
    • 建议:在超管 deptId=0 分支显式打一条 logx.Infow("create user without dept", logx.Field("audit", "create_user_no_dept"), ...),方便事后回捞。

    L-R18-1 · GuardCreateRolePermsLevelsnap.HasFullPerms 分支对 DEVELOPER 是事实上的死代码

    • 文件:internal/logic/auth/access.go:261-282
    • CreateRoleLogic 的入口已有 authHelper.RequireProductAdminFor(role.ProductCode) 把 DEVELOPER / MEMBER 全部挡在门外,因此 GuardCreateRolePermsLevel 内部 HasFullPerms 分支仅对 "超管"(已提前 return)和 "ADMIN" 两类 caller 生效。函数注释已说明"为未来 DEVELOPER 可建 sub-role 留接口",但需要显式的单元测试在 DEVELOPER 入口放开时捕获行为偏移;目前该分支没有任何调用路径会触发。
    • 建议:要么删除 DEVELOPER 相关注释只保留"ADMIN"语义,要么增加一条明确的契约测试(入口临时放开 DEVELOPER 后验证 permsLevel<=1 被拒),避免注释与调用现实脱节。

    L-R18-2 · UpdateProduct(Disabled→Enabled) 没有对应的回灌 tokenVersion 语义补偿

    • 文件:internal/logic/product/updateProductLogic.go
    • 产品 Enabled→Disabled 会 BatchIncrementTokenVersionWithTx + 全员缓存 Clean;反向的 Disabled→Enabled 只清缓存、不动 tokenVersion。理论上符合"升权不踢下线"的原则,但在"误禁用 → 立即启用"的运维回滚场景下,被踢下线的用户必须全部重登——这是业务可接受的代价,需要在接口文档 / 审计日志里显式声明,避免运维对"为什么已经恢复了还不能用旧 token"产生误解。
    • 建议:在 updateProductLogic.go 的禁用分支审计日志里加字段 audit_hint="sessions_revoked_irreversibly",或在 OpenAPI 文档里显式标注。

    L-R18-3 · loadPerms 对"最终 perm 集合为空"的普通成员返回 nil 而非 []

    • 文件:internal/loaders/userDetailsLoader.go:603-623
    • Go encoding/json[]string(nil) 会输出 null,前端 / gRPC 客户端若按"数组"做判断会触发不必要的 defensive check;对比"有 perm 但全 deny 掉的用户"输出 [],两种"空"表达不一致。
    • 建议loadPerms 出口处 if ud.Perms == nil { ud.Perms = []string{} },保证响应体里 perms 恒为数组;对 JWT 签发路径同样友好。

    L-R18-4 · MemberListLogicmap[int64]struct{Username, Nickname string} 匿名类型降低可读性

    • 文件:internal/logic/member/memberListLogic.go:55-58
    • 匿名结构体作为 map value 在 Go 里每次构造都推导类型,IDE 跳转 / pprof 分析不友好。改为具名类型(或直接用 *userModel.SysUser)能让 N>N 条成员列表的调试 / 性能归因更方便。
    • 建议:本地定义 type nickname struct { Username, Nickname string },或复用现有 sysUserModel.UserWithMemberType 风格的小结构体。

    L-R18-5 · CreateDeptLogicreq.Sort 没有上限检查

    • 文件:internal/logic/dept/createDeptLogic.go
    • Sort 直接透传到 sys_dept.sort int。超管可以随意写 math.MaxInt64,虽然不会崩 DB,但会把部门树的排序稳定性依赖转嫁到业务前端;同类字段在 SysRole / SysProduct 也都没有保护。
    • 建议:统一在 model 层(或前端)给 sort 加范围校验(例如 [-100000, 100000]),与 permsLevel 1-999 的口径一致;或者在接口契约里显式声明"sort 仅在同级部门间相对有效"。

    L-R18-6 · CreateProductLogic.compensateCreatedRows 使用独立 ctx,但先后顺序"member → user → product"未对 FK 依赖建模

    • 文件:internal/logic/product/createProductLogic.go:343-396
    • 当前无外键约束,删除顺序无所谓;但如果未来对 sys_product_member / sys_user_role / sys_user_perm 加上 ON DELETE RESTRICTcompensateCreatedRows 的顺序需要与 FK 对齐。
    • 建议:在函数顶部加一行注释说明"删除顺序假设无 FK 约束;如果加 FK,顺序必须调整"——降低未来 schema 演进时的踩坑概率。

    L-R18-7 · UpdateUserLogicdevAccessRevoked 判定对 oldDept.Status != Enabled 也触发收窄路径

    • 文件:internal/logic/user/updateUserLogic.go:184-193
    • 判定 devAccessRevoked=true 的三元条件之一是 newDept.Status != consts.StatusEnabled;按 updateUserLogic 自身的前置校验(line 136-138)新部门必须 Enabled,这个分支实际上是"目标 dept 启用→未启用"的兜底,与事务外 FindOne 已做的校验重复。
    • 建议:在注释里把这条兜底的"双重冗余"显式标注,或者直接删掉 newDept.Status != consts.StatusEnabled 这一支(因为事务外非空分支必然 Enabled)。

    L-R18-8 · userDetailsLoader.BatchDelbatchUnregister 的错误被 logCacheInvalidationErr 吞掉,但不上报熔断

    • 文件:internal/loaders/userDetailsLoader.go:294-314
    • 大批量缓存失效失败是"Redis 抖动"的强信号,目前只有日志、没有 metric。生产环境如果 Redis 长时间不可用,UD 聚合缓存和 sys_user 低层缓存同时保持陈旧,最长 5min 期间的任何授权判定都不得退化到 DB。
    • 建议:在 logCacheInvalidationErr 的非 ctx-canceled 分支增一次 prometheus.CounterVec.Inc(或 go-zero 的 metric),告警阈值 5xx/min → on-call,让运维第一时间感知"缓存失效链路挂了"。

    二次核验:R17 已修项留存状态(抽查)

    编号 预期修复位点 实际状态
    H-R17-1 createUserLogic.go / createProductLogic.go 事务内 FindOneForShareTx(sys_dept) ✅ 已落地,注释齐备
    H-R17-2 deleteDeptLogic.go / updateDeptLogic.go / deleteRoleLogic.go post-commit InvalidateDeptCache / InvalidateRoleCache ✅ 已落地(但 updateRoleLogic rename 路径漏 old-name 键——见 H-R18-1)
    H-R17-3 createRoleLogic.go 非超管 permsLevel>=2 ✅ 通过 GuardCreateRolePermsLevel 落地
    M-R17-1 syncPermsService.go 全量触发 CleanByProduct ✅ 已落地
    M-R17-2 updateUserLogic.go tokenVersion 双递增的语义澄清 ✅ 注释齐备
    L-R17-1 createUserLogic.go 保留前缀 admin_ / svc_ / root_ / sys_ ✅ 已落地
    L-R17-2 sql.NullString{Valid: false} 显式落 NULL ✅ 已落地
    L-R17-3 deptTreeLogic.go 空 DeptPath / 空 tree 审计日志 ✅ 已落地
    L-R17-4 changePasswordLogic.go 同密码短路先于 bcrypt ✅ 已落地
    L-R17-5 createDeptLogic.go Insert→UpdatePathWithTx 两步 ✅ 已落地
    L-R17-6 updateUserStatusLogic.go 对解冻也 tokenVersion+1 的注释 ✅ 已落地

    小结

    本轮新增发现 2 项高风险(H-R18-1 缓存键残留、H-R18-2 NORMAL 部门冻结语义偏差),4 项中风险8 项低风险

    其中 H-R18-1 必须优先修复——它是 R17 对 DeleteRole 补失效的"对偶对称缺口",实现成本极低(只要在 updateRoleLogic.go post-commit 追加一次 InvalidateRoleCache(old))。

    H-R18-2 是业务与实现的语义偏差,修复方案需要产品 / 合规侧先定义清楚"冻结部门"的业务意图(强吊销 vs 会话软吊销),再决定 A(收紧 loadPerms)还是 B(澄清注释 + 调整审计字段)。

    其它 Medium / Low 项均可作为常态化重构批次推进,不会立刻造成事故。