审计范围:
internal/下的全部生产代码(排除_test.go、mocks/、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等)。本轮仅列出新增发现。
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 把 name 从 X 改成 Y 时,cacheSysRoleProductCodeNamePrefix:<code>:X(旧 name 键)没有人清,Redis 里仍然保留着指向原主键的索引值。
影响:
FindOneByProductCodeName(code, X) 在 TTL(默认 7 天,走 sqlc 默认)内会继续命中旧索引 → 拿到"名为 X、实际已改为 Y"的脏行。CreateRole(依赖 DB UNIQUE(productCode,name) 而非预检):当运营先 "UpdateRole X→Y",紧接着 "CreateRole 名字=X" 时,DB 的 UNIQUE 允许新建一条合法的 X,但 Redis 索引仍指向原来的主键,导致 FindOneByProductCodeName(code, X) 直至下一次 Insert/Update 触发 DelCache 才会自愈——两条同名 X 的"旧主键映射 → 新 name=Y 的行"状态会持续长达 TTL。DeleteRole 补的 InvalidateRoleCache 对称缺口:rename 路径唯独漏掉这一对"旧 name 键"的显式失效。修复方案:
在 UpdateRole 里先快照 prevProductCode/prevName,UpdateWithOptLock 完成后显式调用 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)
UpdateDeptLogic 冻结 NORMAL 部门仅递增 tokenVersion,未实际收窄权限,与注释声明不一致internal/logic/dept/updateDeptLogic.go:99-107 的 normalDeptFrozen 分支判定为 "NORMAL 部门 Enabled→Disabled",注释声明为"冻结本部门所有活动,一并吊销"。实际行为:BatchIncrementTokenVersionWithTx — 仅让旧 JWT 失效。userDetailsLoader.go:loadPerms 的授权计算里只有 DEV 部门分支依赖 DeptStatus == Enabled(第 543 行);对 NORMAL 成员,DeptStatus 从未进入权限计算。checkDeptHierarchy(access.go:377-422)也不读 targetDept.Status。sys_user.Status 保持不变。
净效果:被"冻结"的 NORMAL 部门成员只是被强制退出一次登录;立即重新登录即可继续使用所有原有权限。审计日志里会留下 "affectedUsers=N revokedSessions=N",值班看上去像是"已吊销",但业务权限实际上没有任何变化。
audit="UpdateDept" 带 revokedSessions 字段容易被上级审阅/合规稽核误判为"已生效的访问控制措施"。updateUserStatusLogic / updateMemberLogic / updateProductLogic 同族 "收窄" 路径的业务语义不对称——后三者都是"DB 字段落地 + loadPerms 分支响应"的完整闭环。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"。UpdateDeptLogic 的 normalDeptFrozen 分支只失效缓存、不再 BatchIncrementTokenVersionWithTx,避免运营误解。UpdateProduct 禁用产品时的全员收窄口径完全对称,优先推荐。AddMemberLogic / SetUserPerms / BindRoles 未复核 ud.Status,仅靠中间件兜底internal/logic/member/addMemberLogic.go、internal/logic/user/bindRolesLogic.go、setUserPermsLogic.gomiddleware.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。DeptTreeLogic 非超管分支仍全表 FindAll 再内存过滤internal/logic/dept/deptTreeLogic.gocheckDeptHierarchy 对非超管要求 DeptPath 前缀匹配——完全可以在 DB 层用 WHERE path LIKE CONCAT(?, '%') 走索引,避免把全量 sys_dept 拉回进程内存再过滤。FindByPathPrefix(ctx, prefix)(带 path 前缀索引命中),在 DeptTreeLogic 非超管分支改用它。对 300~500 个部门这种真实规模,内存滤选问题不大;但模型接口暴露 FindAll 让其它调用方未来踩到同类坑。loadMembership 使用 err == productmember.ErrNotFound 进行裸等值比较internal/loaders/userDetailsLoader.go:473productmember.ErrNotFound = sqlx.ErrNotFound,裸等值成立;但今后任何一层包装(fmt.Errorf("%w", err) / 多级 model)都会让这条分支失效——走到 error 分支打日志并最终合并成 ErrLoaderDegraded,把"真的不是成员"退化为 503。errors.Is(err, productmember.ErrNotFound),与文件内其它 error 分支口径对齐(如 loadUser 的 errors.Is(err, sqlx.ErrNotFound))。CreateUserLogic 的强口径校验落在 req.DeptId > 0 内层,DB 落盘后的 req.DeptId == 0(超管)被直接放行internal/logic/user/createUserLogic.go:134-161req.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"), ...),方便事后回捞。GuardCreateRolePermsLevel 的 snap.HasFullPerms 分支对 DEVELOPER 是事实上的死代码internal/logic/auth/access.go:261-282CreateRoleLogic 的入口已有 authHelper.RequireProductAdminFor(role.ProductCode) 把 DEVELOPER / MEMBER 全部挡在门外,因此 GuardCreateRolePermsLevel 内部 HasFullPerms 分支仅对 "超管"(已提前 return)和 "ADMIN" 两类 caller 生效。函数注释已说明"为未来 DEVELOPER 可建 sub-role 留接口",但需要显式的单元测试在 DEVELOPER 入口放开时捕获行为偏移;目前该分支没有任何调用路径会触发。permsLevel<=1 被拒),避免注释与调用现实脱节。UpdateProduct(Disabled→Enabled) 没有对应的回灌 tokenVersion 语义补偿internal/logic/product/updateProductLogic.goBatchIncrementTokenVersionWithTx + 全员缓存 Clean;反向的 Disabled→Enabled 只清缓存、不动 tokenVersion。理论上符合"升权不踢下线"的原则,但在"误禁用 → 立即启用"的运维回滚场景下,被踢下线的用户必须全部重登——这是业务可接受的代价,需要在接口文档 / 审计日志里显式声明,避免运维对"为什么已经恢复了还不能用旧 token"产生误解。updateProductLogic.go 的禁用分支审计日志里加字段 audit_hint="sessions_revoked_irreversibly",或在 OpenAPI 文档里显式标注。loadPerms 对"最终 perm 集合为空"的普通成员返回 nil 而非 []internal/loaders/userDetailsLoader.go:603-623encoding/json 对 []string(nil) 会输出 null,前端 / gRPC 客户端若按"数组"做判断会触发不必要的 defensive check;对比"有 perm 但全 deny 掉的用户"输出 [],两种"空"表达不一致。loadPerms 出口处 if ud.Perms == nil { ud.Perms = []string{} },保证响应体里 perms 恒为数组;对 JWT 签发路径同样友好。MemberListLogic 的 map[int64]struct{Username, Nickname string} 匿名类型降低可读性internal/logic/member/memberListLogic.go:55-58*userModel.SysUser)能让 N>N 条成员列表的调试 / 性能归因更方便。type nickname struct { Username, Nickname string },或复用现有 sysUserModel.UserWithMemberType 风格的小结构体。CreateDeptLogic 对 req.Sort 没有上限检查internal/logic/dept/createDeptLogic.goSort 直接透传到 sys_dept.sort int。超管可以随意写 math.MaxInt64,虽然不会崩 DB,但会把部门树的排序稳定性依赖转嫁到业务前端;同类字段在 SysRole / SysProduct 也都没有保护。sort 加范围校验(例如 [-100000, 100000]),与 permsLevel 1-999 的口径一致;或者在接口契约里显式声明"sort 仅在同级部门间相对有效"。CreateProductLogic.compensateCreatedRows 使用独立 ctx,但先后顺序"member → user → product"未对 FK 依赖建模internal/logic/product/createProductLogic.go:343-396sys_product_member / sys_user_role / sys_user_perm 加上 ON DELETE RESTRICT,compensateCreatedRows 的顺序需要与 FK 对齐。UpdateUserLogic 的 devAccessRevoked 判定对 oldDept.Status != Enabled 也触发收窄路径internal/logic/user/updateUserLogic.go:184-193devAccessRevoked=true 的三元条件之一是 newDept.Status != consts.StatusEnabled;按 updateUserLogic 自身的前置校验(line 136-138)新部门必须 Enabled,这个分支实际上是"目标 dept 启用→未启用"的兜底,与事务外 FindOne 已做的校验重复。newDept.Status != consts.StatusEnabled 这一支(因为事务外非空分支必然 Enabled)。userDetailsLoader.BatchDel 中 batchUnregister 的错误被 logCacheInvalidationErr 吞掉,但不上报熔断internal/loaders/userDetailsLoader.go:294-314logCacheInvalidationErr 的非 ctx-canceled 分支增一次 prometheus.CounterVec.Inc(或 go-zero 的 metric),告警阈值 5xx/min → on-call,让运维第一时间感知"缓存失效链路挂了"。| 编号 | 预期修复位点 | 实际状态 |
|---|---|---|
| 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 项均可作为常态化重构批次推进,不会立刻造成事故。