审计范围:
/internal下全部非测试、非_gen.go生产代码(含internal/server/permserver.go、HTTP logic / handler / middleware、loaders、model 定制层、svc、util、consts)。 审计时间:2026-04-19 审计维度:逻辑一致性 / 并发与 RMW / 资源管理 / 数据完整性 / 安全漏洞 / 边界坍塌 / DB 性能 / 僵尸代码 / 接口契约与对象完整性。 与第 8 轮对比:
- 已落地(本轮复核通过,不再复列修复细节):
- H-2(第 8 轮)
checkPermLevel读缓存MinPermsLevel的 TOCTOU ——internal/logic/auth/access.go:302-347现统一调用loadFreshMinPermsLevel走 DB 复核,sqlx.ErrNotFound降级为"0 roles"、其他 DB 错误向上冒出。- M-3(第 7~8 轮)RefreshToken CAS 在签发失败后脏升级
tokenVersion——internal/logic/pub/refreshTokenLogic.go:90-110与internal/server/permserver.go:215-230均已把Generate*Token前置到IncrementTokenVersionIfMatch之前,CAS 只在两份 token 都成功生成后执行。- M-N1(第 8 轮)CreateProduct Redis 票据写失败孤儿化 ——
internal/logic/product/createProductLogic.go:178-283已落地compensateCreatedRows:ticket Setex 任一步失败都会按 admin / member / product 倒序硬删,并附 WARN 日志便于对账。- M-N3(第 8 轮)gRPC
SyncPermsError{Code:404}被吞成codes.Internal——internal/server/permserver.go:61-90已加errors.As显式映射到codes.NotFound,并保留具体错误文案。- L-2(第 7~8 轮)
CountActiveAdminsTx僵尸方法 ——internal/model/productmember/sysProductMemberModel.go中仅保留CountOtherActiveAdminsTx,接口与实现两端均已删除旧签名。- L-N1(第 8 轮)
sysPermModel仍拼接fmt.Sprintf("... status = %d", ...)—— 现统一用?占位(FindAllCodesByProductCode:57-67、DisableNotInCodesWithTx:100-154)。- L-N2(第 8 轮)
SetUserPerms的FindByIds→BatchInsertTOCTOU ——internal/logic/user/setUserPermsLogic.go:148-165已在事务内对目标权限集做COUNT(*) WHERE status=?复核,并要求计数等于入参长度,否则 rollback。- 产品契约接受、不列入风险清单:
- H-1 同产品成员 PII 互见(第 6 → 7 → 8 轮累计列入 P0)—— 产品确认"内部系统 + 通讯录互见"为业务契约,不修。该条从本轮起归档,不再进入后续审计复核列表。
- 未落地 / 回归:
- L-3 历史
DeptId=0账号的CheckManageAccess兜底(需数据迁移,不属纯代码问题,延续存档)。- 本轮新发现(权重较高的 5 条):
- M-N1(新)
userDetailsLoader.Load的loadOk=false契约错位 —— 基础设施故障被同化为业务 403 / "产品已禁用",前端侧不重试且 SOC 无故障信号。- M-N2(新)
BatchDel中unregisterCacheKey走串行 N×2 次 SREM —— 绑/解绑/改角色链路退化为 O(N) Redis RTT,属 N+1。- M-N3(新)
RoleDetail枚举 oracle —— 跨产品访问 403 vs 404 可区分,可用于穷举合法 roleId。- M-N4(新)
CreateUser未做 caller→req.DeptId的部门链校验 —— 产品 ADMIN 可为非自己管辖的部门预埋用户名并占坑,绕过 AddMember 侧部门链防护。- L-N1(新)
ParseWithHMAChelper 未统一使用 ——jwtauthMiddleware.go、gRPCVerifyToken仍用内联token.Method.(*jwt.SigningMethodHMAC)断言,三处拷贝导致审计面错位。- 本轮复现的低风险残点:L-N2
UpdateUser调部门未校验dept.Status;L-N3AdminLoginIsSuperAdmin判断在 bcrypt 之后;L-N4sysUserModel.UpdateStatus缺乐观锁字段(由上层短路保护)。
本轮无 High Risk 项。
checkPermLevel TOCTOU)已通过 loadFreshMinPermsLevel 闭环,见上方"已落地"小节。userDetailsLoader.loadFromDB 的 loadOk=false 语义错位,导致基础设施故障被同化为业务拒绝(新发现)internal/loaders/userDetailsLoader.go:138-204、:321-574(loadFromDB、loadDept、loadProduct、loadPerms 等子加载)。loadFromDB 约定:(ud, true, nil) — 全量成功,写 5 分钟缓存;(ud, false, nil) — 子段(dept / product / membership / roles / perms)中某段失败,返回残缺 ud、仅跳过缓存写;(nil, _, err) — 主体加载失败,Load 向上传 error。Load(:180-183) 在 loadOk=false 时 return ud, nil。调用方拿到的是 Username≠""、但 DeptPath="" / Perms=nil / ProductStatus=0 的"半成品":jwtauthMiddleware.go 只校 tokenVersion 与 IsSuperAdmin || MemberType!="",放行;refreshTokenLogic.go 的 ProductStatus != StatusEnabled 分支把这种 case 归类为"产品已被禁用",返 403;Perms=nil 命中 hasPerm=false,返 403。loadOk=false 的语义改为"基础设施故障":Load 里遇到该分支直接 return nil, ErrLoaderDegraded(新定义 sentinel)。ErrLoaderDegraded 统一返 503(或 codes.Unavailable),并附 retry-after。(ud, false, nil) 作为内部观测用途时,改为 panic-safe 的诊断日志聚合而非对外返回。refreshTokenLogic 里的 "产品已被禁用" 分支前增加 if ud.ProductCode != "" && ud.DeptStatus == 0 { return ErrLoaderDegraded } 兜底。TC-9101 loadPerms 报错 → Load 返回 Unavailable、TC-9102 loadProduct 报错 → Load 不返回"产品已禁用"。UserDetailsLoader.BatchDel 在大 roleId 场景退化为 O(N) Redis RTT(N+1 新发现)internal/loaders/userDetailsLoader.go:257-272,调用方 internal/logic/role/updateRoleLogic.go、internal/logic/role/bindRolePermsLogic.go、internal/logic/user/bindRolesLogic.go。BatchDel 先用一次 DelCtx(keys...) 批量删主 key(OK),但紧跟着 for-range 调用 unregisterCacheKey,每个用户串行 2 次 SremCtx(userIndex + productIndex)。UpdateRole 会对 Redis 发出 2N 次 串行往返。registerCacheKey 已经用 PipelinedCtx 合并 RTT,表明作者清楚 pipeline 的必要性,unregisterCacheKey 却没同步改造。UpdateRole 接口被 go-zero 的 ctx timeout 打断,落入"已 UPDATE DB 但 Clean 缓存失败"的分支(目前该分支仅写 Errorf,不回滚)。unregisterCacheKey 的 per-user 逻辑合入一次 PipelinedCtx:对每个 key 发 pipe.SRem(userIdxKey, cacheKey) / pipe.SRem(productIdxKey, cacheKey),一次 Exec。BatchDel 写一个专用 batchUnregister(ctx, pairs),单 pipeline 内合并所有 userIndex / productIndex 的 SREM + 可选 EXPIRE。TC-9201 BatchDel(1000 users) 的 Redis 命令数 ≤ 3 次 pipeline Exec(用 redis-mock 的 CallCount 断言)。RoleDetail 枚举 oracle:跨产品访问 404 vs 403 可区分(新发现)internal/logic/role/roleDetailLogic.go:29-58。SysRoleModel.FindOne(req.Id):找不到返 404 "角色不存在";!IsSuperAdmin && caller.ProductCode != role.ProductCode:返 403 "无权访问该产品的数据"。req.Id,即可通过响应码精确区分"该 id 不存在" vs "存在于别的产品",从而绘制跨产品的 role id 分布图,为后续定向攻击(社工、横向越权尝试)提供素材。ProductDetailLogic.productDetail 也存在(先 FindOneByCode,再做成员检查);RoleList/ProductList 因天然按 caller.ProductCode 过滤不泄漏。AdminLogin 非法用户名的 bcrypt 时序补齐的精神不一致。caller.ProductCode 做过滤(或查询时把 productCode = ? 塞进 WHERE),未命中或跨产品一律返 ErrNotFound("角色不存在")。ProductDetailLogic 同步处理:对非超管直接用 FindByCodeWithMemberCheck 或先查 sys_product_member 命中后再读 sys_product,省掉"存在性差异"泄漏。TC-9301 非超管请求别产品 roleId → 404、TC-9302 非超管请求不存在 roleId → 404,两者响应体必须完全一致。CreateUser 允许产品 ADMIN 为"非自己管辖部门"预埋用户名(新发现)internal/logic/user/createUserLogic.go:37-100。RequireProductAdminFor(productCode) 校验 caller 是该产品的 ADMIN,并用 FindOne(req.DeptId) 核对部门存在,未校验 caller 的 DeptPath 是否覆盖 newDept.Path。admin_*、ops_*、sre_* 等易被运营/运维复用的账号);AddMember,由于 AddMember 会走 CheckAddMemberAccess 的部门链校验,对方 ADMIN 的部门链可能覆盖,最终把这个"伪造种子账号"挂进产品。UpdateUserLogic 的设计(调部门时严格校验 caller.DeptPath 前缀,见 updateUserLogic.go:116-120)不一致:同样的敏感位,创建时无校验、修改时严校验,出现"先创建后改部门"的绕路。UpdateUserLogic 保持对称:if req.DeptId > 0 && !caller.IsSuperAdmin && caller.MemberType != Admin || caller.DeptPath != "" && !strings.HasPrefix(newDept.Path, caller.DeptPath) { return ErrForbidden }。req.DeptId 必须是 caller 所在产品已有成员的部门集合之一(查 sys_product_member join sys_user 得到部门白名单)。TC-9401 部门 ADMIN 创建跨部门用户 → 403、TC-9402 产品 ADMIN 创建本部门子部门用户 → 200、TC-9403 SuperAdmin 创建任意部门用户 → 200。ParseWithHMAC helper 未统一使用internal/logic/auth/jwt.go:16-31;internal/middleware/jwtauthMiddleware.go:62-66;internal/server/permserver.go 的 VerifyToken(jwt.ParseWithClaims(... keyfunc {...}))。ParseWithHMAC 的注释明确写着"所有 JWT 解析点(HTTP 中间件 / gRPC VerifyToken / RefreshToken)统一走这里",但目前只有 ParseRefreshToken 一个调用方。另两处自己又写了一遍 token.Method.(*jwt.SigningMethodHMAC) 断言 —— 功能上一致,但:
typ 断言,要改 3 处;test-design.md 的 TC-0951~0960 只覆盖了 ParseRefreshToken 一条路径。jwtauthMiddleware.go 改用 authHelper.ParseWithHMAC(tokenStr, secret, &UserClaims{}),移除内联 keyfunc;VerifyToken 同样替换;UpdateUserLogic 调部门时未校验目标 dept.Statusinternal/logic/user/updateUserLogic.go:110-131。FindOne(*req.DeptId) 仅判存在,不判 Status。产品 ADMIN 可把用户调入已 Disabled 的部门,随后该用户在 loadPerms 里会命中"普通成员 + DeptStatus!=Enabled"分支,其 DEV 全权特权被撤销。若业务意图是"停用部门=冻结该部门所有活动",该路径破坏了不变量。if newDept.Status != StatusEnabled { return ErrBadRequest("目标部门已停用") },与 UpdateDept 禁用时的数据流闭环。AdminLogin IsSuperAdmin 判断在 bcrypt 之后,对"合法用户名"的时序侧漏略大于"非法用户名"分支internal/logic/pub/adminLoginLogic.go:55-85。IsSuperAdmin → 校 status"。对于一个存在但非超管的账号,攻击者即便密码随机也会触发 bcrypt 计算(约 60~100ms),与"存在且密码错"分支耗时相近;而"不存在"分支走 dummyBcryptHash 也有类似耗时兜底 —— 单凭这点难以区分。但若攻击者能获取大量样本,IsSuperAdmin 这一步的耗时(sql 比较)理论上可让"存在但非超管"比"存在是超管且密码错"略快(无 token 签发路径),仍可能形成<10ms 级的统计差。IsSuperAdmin 判断前置到 FindByUsername 之后、bcrypt 之前:若非超管,仍走 dummyBcryptHash 消耗一次 bcrypt,再返回 403,恒定时序。FindByUsername → 非超管则用 dummy 计算再统一返 ErrInvalidCredentials → 超管再真 bcrypt → Status 校验。sysUserModel.UpdateStatus 缺乐观锁字段(由上层短路保护,但 model 自身语义不自洽)internal/model/user/sysUserModel.go:154-175。UpdateStatus 的 WHERE id=? 无 updateTime / tokenVersion 比较;与同文件里 UpdateProfile 使用乐观锁、IncrementTokenVersionIfMatch 使用 CAS 的风格不一致。上层 UpdateUserStatusLogic 做了"状态相同则短路"、UserDetailsLoader 5 分钟 TTL 也提供事实一致性,所以实际故障概率低。UpdateStatus 改为 WHERE id=? AND updateTime=?,接口加入 expectedUpdateTime 参数,语义与 UpdateProfile 对齐。| 机制 | 位置 | 关键保护点 |
|---|---|---|
checkPermLevel fresh read |
logic/auth/access.go:302-347、loadFreshMinPermsLevel:339-343 |
caller.MinPermsLevel 每次走 DB,降级 admin 后续的跨级分配被立即拒绝(第 8 轮 H-2 已闭环) |
| CreateProduct 补偿 | logic/product/createProductLogic.go:178-283 |
Redis 票据写入 SetexCtx 失败调用 compensateCreatedRows 级联删除 admin / member / product |
| RefreshToken CAS 顺序 | logic/pub/refreshTokenLogic.go:90-110、server/permserver.go:215-230 |
先生成 access+refresh 再 IncrementTokenVersionIfMatch,签名失败不会吞掉旧 refresh |
| DeleteDept AB-BA 防护 | logic/dept/deleteDeptLogic.go |
自身 FOR UPDATE,子部门 / 成员 FOR SHARE,锁顺序与 CreateDept 对齐 |
| 负缓存投毒防御 | loaders/userDetailsLoader.go:161-178 |
写哨兵前再 FindOne 强一致复核,避免 Insert→Load 并发把新用户哨兵掉 |
| AddMember 部门链二次校验 | logic/auth/access.go:CheckAddMemberAccess、logic/member/addMemberLogic.go:76-78 |
产品 ADMIN 也要过部门链,切断第 6 轮 H-3 |
| RemoveMember 末位守卫 | logic/member/removeMemberLogic.go:49-53 |
CountOtherActiveAdminsTx 事务内排除自己并 lock row,杜绝并发撤 admin 导致 0 admin |
| GuardRoleLevelAssignable | logic/user/bindRolesLogic.go:86-88 |
caller 的 min-level fresh read,避免缓存 admin 权级绑定高于自己的角色 |
| SetUserPerms 事务内复核 | logic/user/setUserPermsLogic.go:148-165 |
BatchInsertWithTx 前后 COUNT(*) WHERE status=? 复核,并发禁用权限的 TOCTOU 闭环 |
| gRPC SyncPerms 错误分级 | server/permserver.go:61-90 |
SyncPermsError{Code:404} → codes.NotFound,不再同化为 Internal |
| gRPC 限流剥端口 | server/permserver.go / ratelimit |
PeriodLimit key 用客户端 IP 而非 host:port,防单连接多端口绕限 |
| JWT HMAC 断言本身 | logic/auth/jwt.go:16-31 |
alg=none / RS256 / ES256 全拒;L-N1 指出的是"调用点未统一",算法防御自身 OK |
| 优先级 | 议题 | 预估工作量 | 风险 |
|---|---|---|---|
| P0 | M-N1 Loader 半成品降级 | 1 天:新增 sentinel 错误 + 中间件映射 + refreshToken 分支修正 + 2 条新单测 | 中:需验证 5xx 监控告警路径 |
| P1 | M-N3 RoleDetail 枚举 oracle | 半天:RoleDetail + ProductDetail 合并成"404 或 200" |
低 |
| P1 | M-N4 CreateUser 部门链校验 | 半天:对称复用 UpdateUserLogic 的校验代码 |
低 |
| P2 | M-N2 BatchDel pipeline 化 | 半天:改 unregisterCacheKey 支持批量 + 回归压测 |
低 |
| P2 | L-N1 ParseWithHMAC 统一 | 半天:替换 2 处调用 + table-driven 用例复用 | 低 |
| P3 | L-N2 UpdateUser 目标部门 Status | 1 小时 | 低 |
| P3 | L-N3 AdminLogin IsSuperAdmin 前置 | 1 小时 | 低 |
| P3 | L-N4 UpdateStatus 乐观锁 | 2 小时(需调上层一处调用) | 低 |
| Backlog | L-3 DeptId=0 历史账号迁移 |
需 DBA 配合 | — |
.go 文件(不含 _test.go / _gen.go / testdata/),覆盖 logic(全部)、loaders、server、middleware、model 定制层、consts、util;