# 权限管理系统 —— 深度代码审计报告(第 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.loadPerms` 中 **deny-list fail-open**、`AddMemberLogic` 的目标侧授权缺失等高风险项。 --- ## 🚩 核心逻辑漏洞 (High Risk) ### H-1. `UserDetailsLoader.loadPerms` 在 **deny 列表查询失败时 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 分钟)。 `FindPermIdsByUserIdAndEffectForProduct` 走 `QueryRowsNoCacheCtx`,任何瞬时 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 抖动概率**。 - **修复方案**:把两次查询在错误语义上**对称**处理: ```go 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` 检查目标与调用方在同一产品内,就返回 `Email`、`Phone`、`Remark`(纯文本,无脱敏)。`UserList` 更严重 —— 一次分页可以批量拿到同产品所有成员的手机号与邮箱。 - **影响**: - **同产品最低权限 MEMBER 即可遍历整个产品通讯录**,获取手机 / 邮箱 / 备注(备注里可能含 PII / 内部身份)。 - 与 H-3(`AddMember` 不做目标侧授权)组合后威力更强:一个产品 ADMIN 可先把想看的任意人(包括其部门树外、不归自己管的用户)强行拉入自己的产品,再通过 `UserDetail` 或 `UserList` 抽走其 PII。 - 严重违反 GDPR / 《个人信息保护法》最小必要原则。 - **修复方案**: 1. 新增 `authHelper.CanViewContact(caller *UserDetails, target *SysUser, targetMember) bool`,只在以下任一条件时返回 `true`: - caller.IsSuperAdmin; - target.UserId == caller.UserId(看自己); - caller 对 target 的 `CheckManageAccess` 通过(即 caller 在管理链上)。 2. 两个 Logic 返回 DTO 前统一走 `filterPIIForCaller`,其余情况把 `Email / Phone / Remark` 置空或做掩码(如 `138****1234`)。 3. 单元测试覆盖"同产品同级成员互看"、"跨部门互看"、"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 纳入自己产品"的假成员关系,**为后续权限推理工具 / 审计系统制造混淆**,也是第一步社工放大点。 - **影响**: - 与 H-2(PII 暴露)组合:ADMIN 随意从部门树外拉人入产品,然后读全员手机邮箱。**这是实际上最容易被滥用的越权路径**。 - 与 `UpdateMemberLogic` 组合:拉入后还可以赋予任何允许的 `MemberType`,制造跨部门的管理权扩张。 - **修复方案**:`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/v4` 且 `accessSecret` / `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,三处统一调用: ```go // 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` - **描述**: - `loadFromDB` 在 `loadUser` 失败(非 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 失败同样落入这个"半加载也写缓存"的通道。 - **建议**: 1. `Load` 返回 `(*UserDetails, error)`,让中间件自己区分 "NotFound → 401 用户不存在" 与 "其他错误 → 503 服务暂时不可用"。 2. `loadFromDB` 里任何子步骤出错,都**不要写缓存**(让下次 Load 重试)。 3. 如果还是想保留无 error 返回,至少在 ud 上加一个 `PartiallyLoaded bool` 字段,Load 写缓存前检查这一位。 ### M-2. `SysUserModel.UpdatePassword` / `UpdateStatus` **不校验 `RowsAffected`**,对已删除 / 条件不满足的用户会静默成功 - **位置**: - `internal/model/user/sysUserModel.go:128-140`(`UpdatePassword`) - `internal/model/user/sysUserModel.go:143-155`(`UpdateStatus`) - **描述**:两处都是 `m.ExecCtx(..., conn.ExecCtx(...))` 然后直接 `return err`,**不读 `sql.Result.RowsAffected`**。如果 `FindOne` 到 `ExecCtx` 之间用户被另一会话删除,或者主从延迟导致 `WHERE id = ?` 命中 0 行,调用方拿到的是 nil err,以为"更新成功"——实际 DB 里没有变化,但 `DelCacheCtx(sysUserIdKey, sysUserUsernameKey)` 已经执行了。 - **影响**: - `ChangePassword` 对已删除用户会返回 200 "成功",客户端以为改密成功,用户下次登录发现密码没变。 - `UpdateUserStatus` 对跨进程并发删除的用户会"假装成功",上层以为冻结生效。 - 更重要的:`UpdatePassword` / `UpdateStatus` 自身没有乐观锁(`UpdateProfile` 有 `updateTime` 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.go`(`GuardRoleLevelAssignable`);调用方 `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 string`,`go-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 UPDATE` 了 `sys_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.go`(`IncrementTokenVersion`),调用方 `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 用**显式命名的 wrapper**(`ForceRevokeAllSessions`)暴露,新接入者一眼看到红色标签。 ### 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.go`(`FindMinPermsLevelByUserIdAndProductCode`)—— `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.go` 与 `sysProductMemberModel_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.go`(`GetUserPerms`);结合 `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` 成功插入之后主动 `Del` 掉 `ud:newId:*` 的负缓存键(需要遍历产品维度,因此成本较高)。 3. 最简:`negativeCacheTTL` 从 30 → 10,并加一条 `svc` 级的"新用户创建后 30s 内绕过负缓存"的白名单(按 userId > `watermark` 判定)。 ### L-7. `CheckManageAccess` 对 caller `DeptId=0` 且非 ADMIN 的历史账号直接 403(R6 L-6 未消化) - **位置**:`internal/logic/auth/access.go`(`checkDeptHierarchy`) - **描述**:H-4(R6 时已修复)之后新建 MEMBER/DEVELOPER 不再是 `DeptId=0`,但**存量数据中有遗留**的 `DeptId=0` MEMBER 账号。这类账号即便通过 `checkPermLevel` 校验也会因为 `caller.DeptPath == ""` 在 `checkDeptHierarchy` 被直接 403。 - **建议**: 1. 运维侧一次性迁移 `UPDATE sys_user SET deptId = 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` "看自己" 短路。 ---