audit-report.md 25 KB

权限管理系统 —— 深度代码审计报告(第 8 轮)

审计范围/internal 下全部非测试、非 _gen.go 生产代码(含 internal/server/permserver.go、HTTP logic / handler / middleware、loaders、model 定制层、svc、util、consts)。 审计时间:2026-04-19 审计维度:逻辑一致性 / 并发与 RMW / 资源管理 / 数据完整性 / 安全漏洞 / 边界坍塌 / DB 性能 / 僵尸代码 / 接口契约与对象完整性。 与第 7 轮对比

  • 已落地(本轮不再复列):H-1 loadPerms deny fail-open、H-3 AddMember 目标侧授权、H-4 JWT HMAC 断言、M-1 Load 半成品缓存污染、M-2 UpdatePassword/UpdateStatus 未校验 RowsAffected、M-4 CreateProduct 明文密码(切成一次性 credentialsTicket)、M-7 gRPC 限流剥端口、L-1 DeleteDept 锁序列、L-2 IncrementTokenVersion WARN 注释、L-3 loadPerms 其余分支 fail-close、L-4 SQL status = ? 参数化(sysUserRole / sysUserPerm / sysRole 三处)、L-5 FindMapByProductCodeUserIds / FindMapByProductCode 非事务版移除、L-6 gRPC 负缓存预污染(TTL=10s + 预写前强一致 FindOne)。
  • 未落地 / 回归H-2 PII 暴露(第 6~7 轮持续未修),M-3 残留分支GuardRoleLevelAssignable 已走 fresh read,但 CheckManageAccess → checkPermLevel 仍读缓存 caller.MinPermsLevel,降级 admin 的 TOCTOU 窗口只封了"分配角色"一个出口),L-5 CountActiveAdminsTx 零调用,L-7 历史账号 DeptId=0 兜底。
  • 新发现:M-N1 CreateProduct → Redis 票据写入失败 导致产品/管理员孤儿化;M-N2 checkPermLevel 读缓存 MinPermsLevel 的 TOCTOU 口子(M-3 未闭合的另一半);M-N3 SyncPermsError{Code:404} 在 gRPC 映射里被同化成 codes.Internal;L-N1 sysPermModel 仍用 fmt.Sprintf("... status = %d", consts.StatusEnabled),与 L-4 修复风格不一致;L-N2 SetUserPerms FindByIds 校验与 BatchInsert 之间的 TOCTOU(影响面轻,列入存档)。

🚩 核心逻辑漏洞 (High Risk)

H-1. UserDetailLogic / UserListLogic 仍把 Email / Phone / Remark 暴露给任意同产品成员(第 6 轮 M-3、第 7 轮 H-2,三轮未落地)

  • 位置
    • internal/logic/user/userDetailLogic.go:64-76types.UserItem 构造)
    • internal/logic/user/userListLogic.go:77-89
  • 描述:两接口的访问控制仍然只到"同产品即可看全字段"的粒度:
    • UserDetail 只用 FindOneByProductCodeUserId 核对 caller 与 target 是否在同一产品,之后直接塞 Email / Phone / Remark(纯文本,无脱敏)。
    • UserList 分页返回同产品所有成员,每条也原样带上 Email / Phone / Remark。一次分页可以把整个产品通讯录灌下来。
  • 影响
    • 同产品最低权限 MEMBER 即可遍历整个产品通讯录,获取手机 / 邮箱 / 备注(备注里常常会有内部身份、外部联络人、岗位说明等 PII)。
    • 叠加风险:
    • 即便 H-3(第 7 轮,已修)已关掉了"ADMIN 跨部门 AddMember"的入口,现有 ADMIN 仍可通过 UserList 一次拿走本产品全员通讯录;
    • 对于拿到一次性有效 JWT 的外包 / 服务账号,本接口天然就是拖库点。
    • 违反《个人信息保护法》第 6 / 13 条的最小必要原则与《数据安全法》下的分级保护要求。
  • 修复方案(与前两轮报告一致,代码未落):

    1. internal/logic/auth/access.go 新增:

      // CanViewContact 判定 caller 是否可以看 target 的联系信息字段。
      //   - caller 是 SuperAdmin → true
      //   - caller.UserId == target.Id(看自己) → true
      //   - CheckManageAccess(caller, target) 通过(caller 在 target 的管理链上) → true
      //   - 其余情况一律 false,由 filterPIIForCaller 落地脱敏。
      func CanViewContact(ctx context.Context, svcCtx *svc.ServiceContext, caller *loaders.UserDetails, target *userModel.SysUser, productCode string) bool
      
      1. 新增 authHelper.MaskEmail / MaskPhone138****1234 / a***@b.com),在 Logic 构造 DTO 前调用 filterPIIForCaller(caller, target, &item) 统一覆盖 Email / Phone / Remark 三字段。
      2. 单测覆盖四种身份组合:同产品同级 MEMBER 互看、跨部门互看、ADMIN 看下级、看自己。
      3. 为什么必须本轮解决:前两轮已经连续标记为 P0 且提出了完整方案,在 H-3(AddMember)修完之后这条变成"剩下最大的单点 PII 出口",攻击面大、修复工作量小(一个 helper + 两个 Logic 返回前的 hook)。

      H-2. CheckManageAccess → checkPermLevel 仍靠 caller 的缓存 MinPermsLevel 决策,降级 admin 在 5 分钟 TTL 内仍可管辖原本够不到的目标(M-3 只封了一半)

      • 位置
      • internal/logic/auth/access.go:279-316checkPermLevel
      • 受影响调用方:internal/logic/member/updateMemberLogic.gointernal/logic/member/removeMemberLogic.gointernal/logic/user/updateUserLogic.gointernal/logic/user/updateUserStatusLogic.gointernal/logic/user/setUserPermsLogic.go
      • 描述:第 7 轮对 GuardRoleLevelAssignable 的修复方式是"在做 role 分配决策前 FindMinPermsLevelByUserIdAndProductCode 走一次 NoCache 查 DB"。但同样类型的 TOCTOU 问题在更一般的 CheckManageAccess 里没修: go // access.go:312 if caller.MinPermsLevel >= targetLevel { return response.ErrForbidden("无权管理权限级别高于或等于您的用户") }

这里 caller 来自 middleware.GetUserDetails(ctx),也就是 UserDetailsLoader.Load 缓存的那份;TTL 5 分钟。 攻击路径:

  1. T=0:超管把 caller C 从 MinPermsLevel=10(总监级)降到 20(经理级)。超管调用业务接口时会触发 UserDetailsLoader.Clean(C.UserId),但 Redis 抖动 / 集群主从切换期间,Clean 单次失败只会被 logx.Errorf,没有重试、没有降级旁路。
  2. T=0+δ:C 在另一终端调 RemoveMember / UpdateMember / UpdateUser / UpdateUserStatus / SetUserPerms 去管理一个 MinPermsLevel=15 的目标 D。
  3. checkPermLevel 读到 C 的缓存 MinPermsLevel=10,判定 10 >= 15 为 false → 放行。C 的实际级别 20,20 >= 15 为 true,本应被拦。
  4. C 有整整 5 分钟时间把下属统一踢人、改 MemberType、覆盖 UserPerms、冻结状态 —— 审计日志里看起来是合法操作。
  5. 与 M-3 修复的对照GuardRoleLevelAssignable 修的是"C 用旧身份去授角色给别人"的路径;checkPermLevel 漏的是"C 用旧身份去直接动别人"的路径。两条路径一个在一个在,安全边界对称才叫完整。
  6. 影响:与 M-3 同一量级,但触达面更广(5 个 Logic 都走 CheckManageAccess),且 RemoveMember / UpdateUserStatus 可以直接产生不可逆的破坏性操作(把管理员从产品踢出、冻结账号)。
  7. 修复方案

    1. checkPermLevel 对 caller.MinPermsLevel 采用与 GuardRoleLevelAssignable 一致的策略:

      freshCallerLevel, err := svcCtx.SysRoleModel.FindMinPermsLevelByUserIdAndProductCode(ctx, caller.UserId, productCode)
      if err != nil {
       if errors.Is(err, sqlx.ErrNotFound) {
           // caller 当前已无产品角色 → 等同最低等级,对同级管辖一律拒绝
           return response.ErrForbidden("无权管理权限级别高于或等于您的用户")
       }
       return response.NewCodeError(500, "校验权限级别失败,请稍后重试")
      }
      if freshCallerLevel >= targetLevel {
       return response.ErrForbidden("无权管理权限级别高于或等于您的用户")
      }
      
      1. 抽一个共享 helper loadFreshMinPermsLevel(ctx, svcCtx, userId, productCode) (int64, error)GuardRoleLevelAssignablecheckPermLevel 统一调用;同时顺手把两处的 ErrNotFound 语义文档化。
      2. 长期建议(可选):给 UserDetailsLoader 加一个 LoadFresh(ctx, userId, productCode) 接口,授权决策点强制 bypass 缓存;普通业务依然走缓存。

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

      M-1. CreateProduct 事务已提交后,Redis 票据写入失败会把产品 + admin 用户永久孤儿化(第 7 轮 M-4 修复方案遗留的新缺陷)

      • 位置internal/logic/product/createProductLogic.go:109-185
      • 描述:第 7 轮把 M-4(响应体明文密码)改成了"DB 事务提交 → 暂存 Redis → 响应 ticket"三段流水线。但流水线的失败语义没有做补偿:
      • L109-149:SysProductModel.TransactCtx 里同时 INSERT sys_product / sys_user(admin 账号)/ sys_product_member。成功后 productId 已经持久化。
      • L162-169:generateRandomHex(32) 生成 ticket —— 理论上 crypto/rand.Read 出错概率 ≈ 0,但已不在事务内。
      • L176-180:json.Marshal(&payload) —— 对固定结构体几乎不会失败,但落出错分支同样只返回 500。
      • L181-184:Redis.SetexCtx(ticketKey, …) —— 这里才是真实风险面。Redis 短暂不可用 / 超时 / 集群 failover 都会让 Setex 返 err。 落到任一 fail 分支时:
      • sys_product 里新建的产品记录已经落盘;
      • admin_<code> 账号已经落盘,bcrypt 密码是我们刚刚在内存里生成、随 500 响应丢弃的那串随机 12 字节;
      • 没有任何方式可以拿回这个密码:
      • 仓库里没有 DeleteProductLogic(只有 CreateProduct / UpdateProduct / ProductList / ProductDetail);
      • 仓库里没有 "SuperAdmin Reset Password" 类型的接口,只有 ChangePasswordLogic,且它要求 oldPassword 校验通过;
      • CreateProduct 再试一次会命中 product.Code / admin_<code>FindOneByCode / FindOneByUsername 前置判定,直接 ErrConflict。 运维只能下场直接 SQL:要么跑一次 UPDATE sys_user SET password = ? WHERE username = ? 硬改 admin 密码,要么 DELETE 三张表对应数据。这两种手法都绕过了业务不变式和审计日志。
      • 影响:数据完整性问题。概率虽低(依赖 Redis 单次失败 & 在一次创建流程内),但一旦发生是永久性的(孤儿产品不会自愈),且需要手工改库,违反最小事故介入原则。
      • 修复方案(按代价从低到高):
      • 最小成本Redis.SetexCtx 失败时,在同一 handler 内做补偿 DB 事务——删除刚才创建的 sys_product / sys_user / sys_product_member,回到"从未创建"状态再返 500。为防止补偿事务自己也失败,至少要把 productId / adminUserId 落一条 logx.Errorw("createProduct compensation required", ...) 带有 ERROR 等级 + 结构化字段,方便告警侧接管人工回捞。
      • 更稳:新增 SuperAdmin 专用接口 RegenerateInitialCredentials(productCode) —— 找到 admin_<code>,重新生成随机密码、bcrypt 后写 DB,再走一次 SetexCtx + 返回新的 credentialsTicket;与 ChangePassword 解耦,有独立审计日志字段 audit=regenerate_init_cred
      • 一步到位:引入 outbox / 2PC 风格 —— DB 事务 + sys_outbox 行同时写;单独 worker 消费 outbox,驱动 Redis 写入;对调用方接口异步化(响应体先给 ticketId,轮询拉真值)。落地成本高,按本仓规模不建议。
      • 锁死建议:无论采纳 (1) 还是 (2),在上线前至少要补齐:CreateProduct 集成测试中把 Redis.Setex 做成主动注错路径,断言 "产品不存在 / admin 用户不存在(方案 1)" 或 "可以通过 Regenerate 拿回凭证(方案 2)",防止未来重构又漏掉这一路径。

      M-2. SyncPermsError{Code: 404} 在 gRPC 映射里被同化成 codes.Internal,接口契约泄露 DB 语义

      • 位置
      • internal/logic/pub/syncPermsService.go:75-79(事务内 LockByCodeTxErrNotFoundSyncPermsError{Code: 404}
      • internal/server/permserver.go:67-83(gRPC 层映射)
      • 描述:gRPC handler 的 switch se.Code 只列了 400 / 401 / 403 / 409,其余(含 404 / 500 / 任何异常值)一律落 default → codes.Internal。于是:
      • 前置 FindOneByAppKey 已经能 hit 到一条 sys_product,但事务内 LockByCodeTxErrNotFound(极罕见:并发 "DeleteProduct" —— 虽然目前没有 Delete 入口,但一旦加上就中招);
      • 调用方(产品接入方的服务端)拿到 codes.Internal + 消息 "产品不存在",但 SDK 侧的重试策略一般对 Internal 是 "not retriable" —— 调用方会直接当作永久错误上报,而我们实际希望它是 codes.NotFound 让其按 404 处理。
      • 影响:契约级一致性缺陷。目前无直接安全风险,但一旦未来引入 DeleteProduct / ProductCode 重命名逻辑,会给接入方的调用栈制造误导性错误分类(Internal 会 page 值班,NotFound 不会)。
      • 修复方案go case 404: return nil, status.Error(codes.NotFound, se.Message)

同时把 HTTP 侧的 response.NewCodeError(se.Code, …) / gRPC 侧的映射表都抽到一个 mapSyncPermsErr helper,避免两边漂移。

M-3. RefreshToken CAS 成功后,若 GenerateAccessToken / GenerateRefreshTokenWithExpiry 失败,tokenVersion 已递增但客户端拿不到新令牌,用户被强制退出

  • 位置internal/server/permserver.go:198-229
  • 描述
    • L199:IncrementTokenVersionIfMatch 成功把 DB 的 tokenVersion 从 N 增到 N+1,返回 newVersion
    • L206:UserDetailsLoader.Clean(ctx, claims.UserId) 清缓存(此时 TokenVersion 在 DB 是 N+1,但在用户手上的 refreshToken 还是 N)。
    • L208-214:生成新 accessToken 失败(理论上 HMAC signing 几乎不会失败,但 OOM / 奇怪的运行时错误不是 0 概率)。
    • L216-223:生成新 refreshToken 失败亦然。
    • 任一处失败就直接 return nil, status.Error(codes.Internal / codes.Unauthenticated, ...)客户端的老 refreshToken 因为 tokenVersion 对不上,下一次刷新会被 claims.TokenVersion != ud.TokenVersion 一刀切成 "登录状态已失效"。用户必须完整重登,体验上等同于被踢下线。
  • 影响:可用性 / 数据完整性:签名失败原本是 100% 服务端 bug,用户却要重登。量不大但会污染"非预期登出"告警,淹没真正的会话劫持信号。
  • 修复方案
    1. 重排顺序:先生成两个新 token(不成功就直接 500,不动 DB),成功后再 CAS 递增 tokenVersion、Clean 缓存。即便递增后 log 层有问题,至少签名成功的 token 一定带回给了客户端。
    2. 若坚持"先 CAS 再签名"(语义上更安全:CAS 成功才证明本次 refresh 是唯一 winner),则失败路径需要记一条明确的 audit=refresh_post_cas_sign_fail userId=X oldVer=N newVer=N+1 的 ERROR 日志,并把返回消息改成用户可感知的 "登录刷新失败,请重新登录"(保留现有行为但补上 observability)。

L-1. sysPermModel.go SQL 仍用 fmt.Sprintf("... status = %d", consts.StatusEnabled),与 L-4 本轮修复风格不一致

  • 位置internal/model/perm/sysPermModel.go:59,102,135

    query := fmt.Sprintf("SELECT `code` FROM %s WHERE `productCode` = ? AND `status` = %d", m.table, consts.StatusEnabled)
    findQuery := fmt.Sprintf("SELECT %s FROM %s WHERE `productCode` = ? AND `status` = %d", sysPermRows, m.table, consts.StatusEnabled)
    updateQuery := fmt.Sprintf("UPDATE %s SET `status` = %d, `updateTime` = ? WHERE `productCode` = ? AND `status` = %d", m.table, consts.StatusDisabled, consts.StatusEnabled)
    
    • 描述:第 7 轮 L-4 已经把 sysUserRole / sysUserPerm / sysRole 三处改成了占位符 ? + 参数传 consts.StatusEnabled,但 sysPermModel 的三条查询还是走 fmt.Sprintfint 直接嵌到 SQL 里。问题不是注入(consts.StatusEnabled 是编译期常量),而是:
    • 类型契约不稳:如果未来把 StatusEnabledint 改成 int8 / uint8 / ActiveStatus enum,%d 可能要改,占位符版本可以稳定。
    • 审计一致性:L-4 的修复初衷是"SQL 里不再出现状态常量字面值,统一走 prepared statement 占位"。sysPermModel 的三处仍然把数字编进 SQL 字符串(虽然是间接通过 Sprintf),与 L-4 的意图偏离。
    • 影响:非安全问题,属于审计口径一致性 / 未来可维护性。
    • 修复方案go query := fmt.Sprintf("SELECT `code` FROM %s WHERE `productCode` = ? AND `status` = ?", m.table) // 调用处: QueryRowsNoCacheCtx(ctx, &dest, query, productCode, consts.StatusEnabled) 三条一致处理;BatchUpdateWithTx 里的 status = ? 同理。

L-2. CountActiveAdminsTx 零调用仍在接口里公开(第 7 轮 L-5 部分遗留)

  • 位置internal/model/productmember/sysProductMemberModel.go(接口 + 实现)
  • 描述:第 7 轮已经把 FindMapByProductCodeUserIds / FindMapByProductCode(非事务版)从模型接口里干掉。但与之同批引入的 CountActiveAdminsTx(不带 Other)在业务层的实际调用方是 CountOtherActiveAdminsTxCountActiveAdminsTx 自身只在 mock / test 里被引用。
  • 验证方式

    rg -n 'CountActiveAdminsTx' internal/logic | wc -l     # 0
    rg -n 'CountOtherActiveAdminsTx' internal/logic | wc -l # >0
    
    • 影响:僵尸接口方法,增加接口 surface area 与未来误用机会("应该用 CountActiveAdminsTx 还是 CountOtherActiveAdminsTx?"的歧义)。
    • 修复方案:从 interface / 实现 / mock / 单测里删除 CountActiveAdminsTx;保留一条 CountOtherActiveAdminsTx(业务语义是"除自己以外还有几个活跃 admin",刚好吻合"不能移除/降级最后一个 admin"的不变式)。

    L-3. CheckManageAccess 对 caller DeptId=0 且非 ADMIN / SuperAdmin 的历史账号直接 403(第 7 轮 L-7 未消化)

    • 位置internal/logic/auth/access.gocheckDeptHierarchycaller.DeptId == 0 || caller.DeptPath == "" 分支)
    • 描述:历史遗留账号仍有 DeptId=0 的 MEMBER / DEVELOPER,即使在自己的产品范围内想做简单的"看自己 / 改自己"操作,也会被 checkDeptHierarchy 拒 403(除非上层已短路)。
    • 修复方案(任选其一,两者都不破坏现有安全边界):
    • 运维侧一次性 SQL: sql UPDATE sys_user SET deptId = <DEFAULT_NORMAL_DEPT_ID> WHERE deptId = 0 AND isSuperAdmin = 0 AND ( userId IN (SELECT userId FROM sys_product_member WHERE memberType != 'ADMIN') OR userId NOT IN (SELECT userId FROM sys_product_member) ); 同时 UserDetailsLoader.CleanByUserIds 一次性批量清缓存。
    1. 代码侧:CheckManageAccess 早期追加 go if caller.UserId == targetUserId { return nil } (这一条已经在 L60-69 实现了,但仅对"看自己"生效;真正被 403 的路径是"看同部门的人"——仍需要数据修复)。 ### L-4. SetUserPermsFindByIds 校验与 BatchInsertWithTx 之间存在 perm 状态 TOCTOU - 位置internal/logic/user/setUserPermsLogic.go:90-130 - 描述: - L95-109:循环检查 dbPerms 里每条权限都 ProductCode == productCodeStatus == StatusEnabled; - L112-131:在新事务里 DeleteByUserIdForProductTx + BatchInsertWithTx。 - 两段之间若并发一次 SyncPermissions 把某个 permId 的 status 置成 DISABLED,本次 SetUserPerms 依然会把那条 user_perm 落库。 - 影响loadPerms 在组装缓存时是 JOIN sys_perm WHERE sys_perm.status = ?(L-4 修过的部分),失效权限不会真正生效。本漏洞不造成越权,但会留下一行"脏"sys_user_perm,让审计查询 "X 拥有哪些权限" 与 "X 实际享有哪些权限" 出现微小偏差,是数据一致性噪声。 - 修复方案(按实际需要决定是否做): 1. 把 FindByIds 挪到事务内,并对 sys_perm 这几行加 FOR SHARESyncPerms 已经通过 LockByCodeTx 在 sys_product 行上串行化;这里加 FOR SHARE 只是为了在事务边界内读到一致的 status,开销很低); 2. 或者最廉价:在 BatchInsertWithTx 之后补一条 COUNT(*) 校验 —— "我刚才插入的 permId 全部仍是 Enabled",不满足就主动回滚事务。任一方案都能把 TOCTOU 窗口缩到零。 --- ## 📋 修复优先级汇总 | 优先级 | finding | 一句话概要 | | :----- | :------------------------------------------------------------ | :------------------------------------------------------------------------------------------ | | P0 | H-1 UserDetail/UserList PII 暴露(3 轮未落地) | 任意同产品 MEMBER 可读全员手机邮箱备注,违反 PIPL 最小必要 | | P0 | H-2 checkPermLevel 仍读缓存 MinPermsLevel(M-3 另一半) | 降级 admin 在 5 分钟 TTL 内仍可 RemoveMember / UpdateMember / SetUserPerms | | P1 | M-1 CreateProduct Redis 写失败 → 产品 + admin 孤儿化 | 一次 Redis 失败 → 永久不可恢复,只能手动 SQL | | P1 | M-2 SyncPermsError 404 → codes.Internal 契约错位 | 接入方 SDK 的错误分类/重试策略失真 | | P1 | M-3 RefreshToken CAS 后签名失败 → 用户被强制登出 | tokenVersion 已推进,客户端必须重登,可用性事件 | | P2 | L-1 sysPermModel 仍用 fmt.Sprintf(L-4 风格漂移) | 三条 SQL 统一改占位符,接近审计一致性 | | P2 | L-2 CountActiveAdminsTx 零调用(L-5 遗留) | 接口层可见的僵尸方法 | | P2 | L-3 CheckManageAccessDeptId=0 老账号 403(L-7) | 需数据迁移或 "看自己" 短路 | | P3 | L-4 SetUserPerms FindByIds / BatchInsert TOCTOU | 不越权,但会留脏 sys_user_perm 行 | --- ## 🛠 建议修复次序 1. 本轮必修 P0(对称封口): - H-1:filterPIIForCaller + CanViewContact 两个 helper,落到 UserDetail / UserList 返回前。 - H-2:checkPermLevelcaller.MinPermsLevel 换成 loadFreshMinPermsLevel(...);与 GuardRoleLevelAssignable 共享同一个 helper。两条 P0 建议同批上线,一起做回归单测。 2. P1(稳定性与契约): - M-1:CreateProduct 失败路径补偿(删 sys_product + sys_user + sys_product_member)或新增 SuperAdmin-only RegenerateInitialCredentials;集成测试强制注入 Redis fault。 - M-2:SyncPermsError{Code:404} 补到 switch 里,映射到 codes.NotFound;抽 mapSyncPermsErr 统一 HTTP / gRPC。 - M-3:RefreshToken 重排为"先签新 token 成功才做 CAS";或至少给 post-CAS 失败路径加 audit 字段。 3. P2 / P3 收尾: - L-1:sysPermModel 三条 SQL 改占位符; - L-2:CountActiveAdminsTx 删除(接口 + 实现 + mock),CountOtherActiveAdminsTx 保留; - L-3:数据迁移脚本批量把历史 deptId=0 账号挪到默认部门,然后 CleanByUserIds 刷缓存; - L-4:SetUserPerms 要么把 FindByIds 挪进事务 + FOR SHARE,要么事务末补一条 status 复核 COUNT(*)。 --- ## 🔎 备注:本轮已验证仍在正轨上的机制 下列机制经过完整阅读后认为仍然安全、无需调整,特此备注以免未来误改: - UserDetailsLoader:H-1(deny fail-close)、M-1(半加载不写缓存 + 中间件按 (ud, err) 分流 401/503)、L-3(loadPerms 任一子步骤错误都 fail-close)、L-6(负缓存 TTL=10s + 写前 FindOne)全部落地。 - RefreshToken / LogoutIncrementTokenVersionIfMatch / IncrementTokenVersion 两把刀,前者是 CAS(单会话轮转),后者是"无条件大杀器"(强制全量失效),L-2 的 WARN 注释已就位。 - DeleteDeptLogic 的锁序列:X(target dept)FOR SHARE(children)FOR SHARE(users in dept range),AB-BA 死锁风险大幅收敛。 - SyncPermissions 通过 LockByCodeTx 把同 product 的 sync 串行化,sys_perm UNIQUE 再撞 1062 会落一条 audit=mysql_error_1062 ERROR 日志,足以触发告警。 - LoginLogic / AdminLoginLogic / UserInfoLogic 里的 Email / Phone 返回属于"看自己"合法场景,本轮不纳入 H-1 修复范围。 - ExtractClientIPX-Forwarded-For / X-Real-IP 做了严格解析 + firstValidIP 过滤,gRPC 侧 net.SplitHostPort 剥端口(M-7 已修)。 - CheckAddMemberAccess 对 ADMIN 也强制走部门链校验 + 拒绝 target.IsSuperAdmin,H-3 入口已封住。