# 审计报告(第 18 轮) > 审计范围:`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` 等)。本轮仅列出**新增发现**。 --- ## 🚩 核心逻辑漏洞 (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 把 `name` 从 `X` 改成 `Y` 时,`cacheSysRoleProductCodeNamePrefix::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/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)`: ```go 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-107` 的 `normalDeptFrozen` 分支判定为 "NORMAL 部门 Enabled→Disabled",注释声明为"**冻结本部门所有活动**,一并吊销"。实际行为: 1. 事务内对受影响 userIds 走 `BatchIncrementTokenVersionWithTx` — 仅让旧 JWT 失效。 2. `userDetailsLoader.go:loadPerms` 的授权计算里**只有 DEV 部门**分支依赖 `DeptStatus == Enabled`(第 543 行);对 NORMAL 成员,`DeptStatus` 从未进入权限计算。 3. `checkDeptHierarchy`(`access.go:377-422`)也不读 `targetDept.Status`。 4. 用户 `sys_user.Status` 保持不变。 净效果:被"冻结"的 NORMAL 部门成员只是被强制退出一次登录;立即重新登录即可继续使用所有原有权限。审计日志里会留下 "affectedUsers=N revokedSessions=N",值班看上去像是"已吊销",但业务权限实际上**没有任何变化**。 - **影响**: 1. 对运营侧形成"已冻结假象":操作者执行"冻结某部门"后相信该部门无法再访问系统,但成员立刻重登就恢复;在出现"部门整体涉嫌违规 → 临时冻结调查"这类场景下放出越权窗口。 2. 审计日志里的 `audit="UpdateDept"` 带 `revokedSessions` 字段容易被上级审阅/合规稽核误判为"已生效的访问控制措施"。 3. 与 `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 部门的语义**:把注释里的"一并吊销"改掉,并在 `UpdateDeptLogic` 的 `normalDeptFrozen` 分支只失效缓存、不再 `BatchIncrementTokenVersionWithTx`,避免运营误解。 - A 与当前 `UpdateProduct` 禁用产品时的全员收窄口径完全对称,优先推荐。 --- ## ⚠️ 健壮性与性能建议 (Medium/Low) ### M-R18-1 · `AddMemberLogic` / `SetUserPerms` / `BindRoles` 未复核 `ud.Status`,仅靠中间件兜底 - 文件:`internal/logic/member/addMemberLogic.go`、`internal/logic/user/bindRolesLogic.go`、`setUserPermsLogic.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 分支口径对齐(如 `loadUser` 的 `errors.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 · `GuardCreateRolePermsLevel` 的 `snap.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 · `MemberListLogic` 的 `map[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 · `CreateDeptLogic` 对 `req.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 RESTRICT`,`compensateCreatedRows` 的顺序需要与 FK 对齐。 - **建议**:在函数顶部加一行注释说明"删除顺序假设无 FK 约束;如果加 FK,顺序必须调整"——降低未来 schema 演进时的踩坑概率。 ### L-R18-7 · `UpdateUserLogic` 的 `devAccessRevoked` 判定对 `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.BatchDel` 中 `batchUnregister` 的错误被 `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 项均可作为常态化重构批次推进,不会立刻造成事故。