# 深度审计报告 · Round 16 > 基线:R15 审计后的代码库快照。R15 提出的 1 条 Medium(M-R15-1,缓存 TTL 下的 MemberType TOCTOU)与 3 条 Low(L-R15-1/2/3)均已在代码中落地(`updateMemberLogic.go` / `updateProductLogic.go` 在 tx 内补齐 `tokenVersion+1` 降权吊销;`updateUserLogic.go` 的 `deptId=0` 收敛给 SuperAdmin;`deptTreeLogic.go` 的 `fullAccess` 收敛给 SuperAdmin)。本轮聚焦两类 R15 未闭合的面: > > 1. **降权吊销的覆盖对称性**:`UpdateMember` / `UpdateProduct` 已走"降权即 tokenVersion+1"的闭环,但与它们语义同构的**其它降权路径**(`RemoveMember`、`UpdateDept` 改 DeptType/Status、`UpdateUser` 改 deptId 跨越 DEV/NORMAL 边界)仍是"仅 post-commit `UserDetailsLoader.*` 尽力而为失效"的旧口径——Redis 抖动时 5min TTL 窗口内旧权限继续生效,构成与 M-R15-1 同等的 TOCTOU 面; > 2. **全局字段 `sys_user.deptId` 的跨产品结构性副作用**:R15 的 L-R15-1 只封了 `deptId=0` 这条极端路径,但**产品 ADMIN 把共享成员调入自己部门子树外的非 DEV 部门**同样会改写全局 `sys_user.deptId`,在其它产品的管理视角里制造"同产品 ADMIN 够不到、DeptTree 里显示错位"的异常状态; > 3. **PII 最小授权**:`UserList` / `UserDetail` 两接口只做"同产品成员"校验即返回目标的 `email` / `phone`,没有按 MemberType 收敛,普通 MEMBER 成员可枚举本产品全部成员的联系方式。 --- ## 🚩 核心逻辑漏洞 (High Risk) ### H-R16-1 · `RemoveMember` 降权路径未随 M-R15-1 同步吊销会话 —— 与 `UpdateMember` 对称缺口 **位置** - `internal/logic/member/removeMemberLogic.go:42-74`(事务体只做 `DeleteByUserId* + Delete`,无 `IncrementTokenVersionWithTx`) - 对比参照:`internal/logic/member/updateMemberLogic.go:94-134`("降权即 `tokenVersion+1`"已落地) **描述** M-R15-1 / L-R15-3 落地后,"降权/禁用"这类"从'有效成员'向'无效成员'迁移"的路径都在 tx 内把目标的 `sys_user.tokenVersion` 做 `+1`,让旧 access token 在 `jwtauthMiddleware` 的 `claims.TokenVersion != ud.TokenVersion` 兜底下立刻 401,即使 Redis `Del`/`Clean` 失败也不会残留特权。但这条口径**漏掉了 `RemoveMember`**: ```42:74:internal/logic/member/removeMemberLogic.go if err := l.svcCtx.SysProductMemberModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error { locked, err := l.svcCtx.SysProductMemberModel.FindOneForUpdateTx(ctx, session, req.Id) if err != nil { return response.ErrNotFound("成员不存在") } if locked.MemberType == consts.MemberTypeAdmin && locked.Status == consts.StatusEnabled { otherAdminCount, err := l.svcCtx.SysProductMemberModel.CountOtherActiveAdminsTx(ctx, session, member.ProductCode, locked.Id) if err != nil { return err } if otherAdminCount == 0 { return response.ErrBadRequest("不能移除该产品的最后一个管理员") } } if err := l.svcCtx.SysUserRoleModel.DeleteByUserIdForProductTx(ctx, session, member.UserId, member.ProductCode); err != nil { return err } if err := l.svcCtx.SysUserPermModel.DeleteByUserIdForProductTx(ctx, session, member.UserId, member.ProductCode); err != nil { return err } return l.svcCtx.SysProductMemberModel.DeleteWithTx(ctx, session, req.Id) }); err != nil { return err } cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx) defer cancel() l.svcCtx.UserDetailsLoader.Del(cleanCtx, member.UserId, member.ProductCode) return nil ``` **`RemoveMember` 事实上是 `UpdateMember` 降权路径的极端版**——不是"ADMIN→MEMBER"而是"ADMIN→`MemberType=""`(非成员)",造成的授权语义跳变更剧烈: - `loadPerms` 对"非成员"会返回 `nil` → 原本的 ADMIN 全权直接清空; - `jwtauthMiddleware` 对 `ud.MemberType == ""` 非超管会 403 `"您已不是该产品的有效成员"`; - `CheckManageAccess` 的 ADMIN 分支直接跳过 `checkDeptHierarchy`,对成员的管理面彻底开放。 攻击场景(与 M-R15-1 的描述完全同构,只是触发点从 `UpdateMember` 换成 `RemoveMember`): 1. SuperAdmin 发起 `RemoveMember` 把 P1 的 ADMIN `A` 移出产品;tx 成功,`UserDetailsLoader.Del(A.UserId, P1)` 被调用; 2. Redis 在这 3s 内(`DetachCacheCleanCtx` 的超时)出现网络抖动,`DelCtx` 返回 err(代码已通过 `logCacheInvalidationErr` 打 `cache_invalidation_skipped_*` 标签,但**不重试、不 block 请求**,继续返回 200); 3. 缓存里仍是 `{MemberType: ADMIN, Perms: [...P1 全权], TokenVersion: 旧值}`,TTL 还剩最长 5 分钟; 4. A 拿着手里的 access token 继续调: - `jwtauthMiddleware` 命中 Redis 旧 UD → 放行; - `UpdateMember` / `BindRoles` / `UpdateRole` / `DeleteRole` / `SetUserPerms` 等所有产品管理接口的 `RequireProductAdminFor` 都看 `caller.MemberType == ADMIN` 放行; - `CheckManageAccess` 走 ADMIN 分支跳过部门链校验,A 可以对 P1 任意成员继续降级、改 PermsLevel、授高权限角色; 5. 5 分钟 TTL 过期后缓存自然重建,A 才被系统真正视作"非成员"。 更严重的是:**此时 A 的 `tokenVersion` 未被递增**,即便运维事后发现 Redis 抖动手动 `Del` 了缓存 key,只要 A 把 access token 保存好,下一次 Load 重建的 UD 仍然是"DB 视角下的非成员"——这是良性的(会 403)。但在 TTL 滞留窗口里 A 的 access token 本身是有效凭据(签名、类型、过期时间、`TokenVersion` 与 `ud.TokenVersion` 都匹配),`jwtauthMiddleware` 无法把这段残留权限踢下线。 **影响** - 与 M-R15-1 等级相同的权限升级 TOCTOU:被移除的 ADMIN / DEVELOPER 在 Redis 抖动时保留完整产品管理权 ≤5min; - 运维侧无任何手段**强制**下线(缓存 TTL 过期是唯一收敛机制); - 组合攻击面:如果 5min 内 A 利用残留的 ADMIN 权把自己以不同 userId(例如预埋的备用账号)重新 `AddMember` / `BindRoles`,账号回收动作形同虚设——这正是 M-R15-1 修 `UpdateMember` 时就要求的"必须在签发层吊销而不是在缓存层吊销"语义。 **修复方案** 把 `RemoveMember` 的事务体补齐"降权即 `tokenVersion+1`"的闭环,与 `UpdateMember` 的 M-R15-1 口径完全对齐。`RemoveMember` 的语义比 `UpdateMember` 更清晰——只要走到事务体,就一定构成"从有效成员(或 ADMIN/DEVELOPER)→ 非成员"的降权,**无需再判定是否为"降权"**,无条件递增即可: ```go // internal/logic/member/removeMemberLogic.go if err := l.svcCtx.SysProductMemberModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error { locked, err := l.svcCtx.SysProductMemberModel.FindOneForUpdateTx(ctx, session, req.Id) if err != nil { return response.ErrNotFound("成员不存在") } if locked.MemberType == consts.MemberTypeAdmin && locked.Status == consts.StatusEnabled { otherAdminCount, err := l.svcCtx.SysProductMemberModel.CountOtherActiveAdminsTx(ctx, session, member.ProductCode, locked.Id) if err != nil { return err } if otherAdminCount == 0 { return response.ErrBadRequest("不能移除该产品的最后一个管理员") } } if err := l.svcCtx.SysUserRoleModel.DeleteByUserIdForProductTx(ctx, session, member.UserId, member.ProductCode); err != nil { return err } if err := l.svcCtx.SysUserPermModel.DeleteByUserIdForProductTx(ctx, session, member.UserId, member.ProductCode); err != nil { return err } // 审计 H-R16-1:移除成员是"有效成员→非成员"的极端降权,签发层吊销与 M-R15-1 / L-R15-3 对齐: // - IncrementTokenVersionWithTx 放在 DeleteWithTx 之前,任一步失败整体回滚,不会出现 // "已 +1 但成员行没删" 或 "成员行已删但 tokenVersion 没 +1" 的脏中间态。 // - 不再把吊销绑定到 UserDetailsLoader.Del 的 Redis 可用性——即使 Del 失败, // jwtauthMiddleware 的 claims.TokenVersion != ud.TokenVersion 兜底仍然会 401。 if _, err := l.svcCtx.SysUserModel.IncrementTokenVersionWithTx(ctx, session, member.UserId); err != nil { return err } return l.svcCtx.SysProductMemberModel.DeleteWithTx(ctx, session, req.Id) }); err != nil { return err } cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx) defer cancel() // 审计 H-R16-1:tokenVersion 已落库,post-commit 还要把 sysUser 的低层缓存 // (cacheSysUserIdPrefix / cacheSysUserUsernamePrefix)一并失效——否则 UD loader 下次 miss 时 // 会从 sysUser 低层缓存里拿到旧 tokenVersion,把刚递增过的值抹回去(与 UpdateMember 同)。 if user, err := l.svcCtx.SysUserModel.FindOne(cleanCtx, member.UserId); err == nil && user != nil { l.svcCtx.SysUserModel.InvalidateProfileCache(cleanCtx, user.Id, user.Username) } else if err != nil { logx.WithContext(l.ctx).Errorf("RemoveMember post-commit FindOne(%d) failed for token-version cache invalidation: %v", member.UserId, err) } l.svcCtx.UserDetailsLoader.Del(cleanCtx, member.UserId, member.ProductCode) return nil ``` 若不希望在事务外再 `FindOne`(Redis 抖动时可能慢 100ms 级别),也可以在事务体内把 `locked.UserId` 与 `username`(通过 tx 内的 `l.svcCtx.SysUserModel.FindOneForShareTx` 或在进入 tx 前的 `member` 查询里预取)通过闭包透传到 post-commit;由于 `RemoveMember` 没有超高 QPS 预期(行政操作),直接走 post-commit `FindOne`(该查询本身带 sqlc 缓存)也足够。 **回归验证要点** - tx 体内 `IncrementTokenVersionWithTx` 返回 `ErrUpdateConflict`(竞态:并发 `RemoveMember`/`Logout`/`ChangePassword`)时整体回滚,测试需断言成员行仍存在; - Redis 完全不可用场景下,断言被移除的 ADMIN 在下一次 HTTP 请求时被 `jwtauthMiddleware` 401; - 与 `UpdateMember` 的测试矩阵对称扩容:ADMIN/DEVELOPER/MEMBER 被移除后,旧 access token 均应被立即拒绝。 --- ## ⚠️ 健壮性与性能建议 (Medium / Low) ### M-R16-1 · `UserList` / `UserDetail` 对任意同产品成员暴露 `email` / `phone` —— PII 最小授权缺失 **位置** - `internal/logic/user/userListLogic.go:38-90`(仅做"同产品"校验,无 MemberType 收敛,`email`/`phone` 直接回落到所有可见成员) - `internal/logic/user/userDetailLogic.go:34-76`(仅做"同产品成员"校验,`email`/`phone` 同样全量返回) **描述** R14 / R15 已经把"同产品 ADMIN 可以管理同产品成员"的边界拉紧,但"同产品 MEMBER **读**其他成员信息"的边界还停留在"只要是同产品成员即可": ```38:45:internal/logic/user/userListLogic.go if !caller.IsSuperAdmin { if req.ProductCode == "" { return nil, response.ErrForbidden("非超管用户必须指定产品编码") } if caller.ProductCode != req.ProductCode { return nil, response.ErrForbidden("无权访问该产品的数据") } } ``` ```34:41:internal/logic/user/userDetailLogic.go if !caller.IsSuperAdmin { if caller.ProductCode == "" { return nil, response.ErrForbidden("会话缺少产品上下文") } if _, err := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, caller.ProductCode, req.Id); err != nil { return nil, response.ErrForbidden("无权查看非本产品成员的用户信息") } } ``` 两处响应都**无差别地**把 `u.Email` / `u.Phone` / `u.Remark` / `u.DeptId` 填到响应: ```77:90:internal/logic/user/userListLogic.go items = append(items, types.UserItem{ Id: u.Id, Username: u.Username, Nickname: u.Nickname, Avatar: avatar, Email: u.Email, Phone: u.Phone, Remark: u.Remark, DeptId: u.DeptId, Status: u.Status, MemberType: memberType, CreateTime: u.CreateTime, }) ``` 对比 `MemberListLogic` 的返回(只含 `Username` / `Nickname` / `MemberType` / `Status`,**无** `email` / `phone`)可以看到:业务上 MEMBER 浏览成员列表的合理需要是"看到谁在这个产品里",不是"拿到所有同事的联系方式"。当前 `UserList` / `UserDetail` 无视 caller 的 MemberType,让 P1 的普通 MEMBER 能一键分页导出 P1 全体成员的 `email` / `phone`: - **内部滥用面**:P1 的任一普通 MEMBER(包括产品接入方给终端客户开的 low-tier 账号)都能拉走全员通讯录,钓鱼 / 二次社工的投递名单直接就位; - **跨产品溯源弱化**:`email` / `phone` 是全局 `sys_user` 字段,一人在 P1+P2 同为 MEMBER 时,两产品任一方的 MEMBER 都可拉到同一份 PII,合规审计上分不出泄漏源; - **UI 真需 vs 接口默认输出不一致**:即使前端当前 MEMBER 视角的页面不渲染 `email` / `phone`,接口仍在响应体里把字段返回,绕过前端就能拿到——API 契约才是授权边界,不是 UI。 **影响** - **PII 过度暴露 → 合规红线**:GDPR/PIPL/内部数据分级都要求"联系方式"类字段按职责最小化返回。当前接口对同产品 MEMBER 无差别发放,容易被监管 / 安全评估点名; - **社工攻击前置资源充足**:攻击者一旦拿到任意 P1 MEMBER 的凭据(撞库、钓鱼、误 commit token),就能把 P1 全员通讯录导出,为后续的二阶钓鱼 / SIM swap / 账号接管提供精准名单; - **审计覆盖面与 `MemberList` 口径不一致**:`MemberListLogic` 已经收敛了响应字段(不泄露 PII),但 `UserListLogic`(同为"按产品分页列成员"用途)没有收敛,两者 API 语义重合但安全边界不同,易被误判。 **修复方案** 按"自己可见全部字段、他人仅超管/ADMIN/DEVELOPER 可见 PII"的分层授权收窄响应体。不建议在 logic 层加复杂 if/else 后再 copy 字段——容易随字段增加漏脱敏。推荐在响应装配前统一做 PII 脱敏: ```go // internal/logic/user/userListLogic.go // maskPII 统一决定:对于同产品内的他人,是否对 caller 隐藏 PII。 // 规则:caller 是 SuperAdmin / ADMIN / DEVELOPER 返回原值;其他情况(含普通 MEMBER)置空。 // caller 看自己不走这里——UserListLogic / UserDetailLogic 单独兜一下 `u.Id == caller.UserId` 的分支即可。 func maskPII(caller *loaders.UserDetails, u *userModel.SysUser) (email, phone, remark string) { if caller.IsSuperAdmin || caller.MemberType == consts.MemberTypeAdmin || caller.MemberType == consts.MemberTypeDeveloper || (caller.UserId == u.Id) { return u.Email, u.Phone, u.Remark } return "", "", "" } // 拼装 items 时: for _, u := range list { email, phone, remark := maskPII(caller, u) items = append(items, types.UserItem{ Id: u.Id, Username: u.Username, Nickname: u.Nickname, Avatar: avatar, Email: email, Phone: phone, Remark: remark, DeptId: u.DeptId, // DeptId 本身不含 PII,可保留——前端可以凭此做部门筛选 Status: u.Status, MemberType: memberType, CreateTime: u.CreateTime, }) } ``` `UserDetailLogic` 沿用相同 helper;同时在 `UserDetailLogic` 里额外加一层"caller == target 或 caller 有管理职权时才返回完整信息"的自检(目标是 `caller.UserId != req.Id` 且 `caller` 非 ADMIN/DEVELOPER 的场景,可以考虑直接 `return ErrForbidden`,而不是回一个"只有昵称没有 PII"的半成品——后者会让前端误以为目标真的没绑邮箱 / 手机号)。 **回归验证要点** - MEMBER 身份调用 `UserList` / `UserDetail`: - 看自己 → Email / Phone 原样返回; - 看他人 → Email / Phone / Remark 为空字符串,其余字段保留; - ADMIN / DEVELOPER 调用上述接口:所有字段原样返回(与现网行为一致,避免破坏管理台体验); - 前端若强依赖"字段非空"作逻辑分支,需同步升级——建议增加响应 schema 的版本协商或在 item 上新增 `piiVisible bool` 提示,减少默默置空导致的 UI 侧 regression。 --- ### L-R16-1 · `UpdateUser` 的 ADMIN 分支短路 DeptPath 前缀校验 —— 非 `deptId=0` 方向的同构缺口 **位置** - `internal/logic/user/updateUserLogic.go:140-157`(`newDept.DeptType != DEV` 且 `caller.MemberType == ADMIN` 时直接放行,不比 DeptPath 前缀) - 对比参照: - `internal/logic/user/updateUserLogic.go:158-170`(`deptId=0` 已由 L-R15-1 收敛给 SuperAdmin) - `internal/logic/user/createUserLogic.go:102-109`(`CreateUser` 对非超管**强制**执行 `strings.HasPrefix(newDept.Path, caller.DeptPath)`,无 ADMIN 豁免) **描述** L-R15-1 把 `UpdateUser.req.DeptId = 0` 这种"把目标移出全局部门树"的极端路径收敛给了 SuperAdmin,理由是:`sys_user.deptId` 是**全局**字段,P1 ADMIN 在 P1 的授权范围不应影响 P2 视角下的成员归属。但同一个逻辑在"`req.DeptId > 0` 且 `newDept.DeptType != DEV`"分支里仍然存在: ```140:157:internal/logic/user/updateUserLogic.go if newDept.DeptType == consts.DeptTypeDev && !caller.IsSuperAdmin { return response.ErrForbidden("仅超级管理员可将用户调入研发部门") } // 注意:ADMIN 分支短路 DeptPath 前缀校验,意味着 ADMIN 可以把目标调入任何**非 DEV** // 部门;DEV 目标部门的跨产品权限升级路径由上面 H-R14-1 的显式护栏拦截。 if !caller.IsSuperAdmin && caller.MemberType != consts.MemberTypeAdmin && !strings.HasPrefix(newDept.Path, caller.DeptPath) { return response.ErrForbidden("无权将用户调入非自己管辖的部门") } ``` 这一 `caller.MemberType != consts.MemberTypeAdmin` 短路让 P1 ADMIN 可以把"同时是 P2 成员"的 target `B` 从 P2 的部门子树下挪到 P1 的部门子树下(或任意既非 DEV 又处于 Enabled 的 NORMAL 部门,只要校验通过 `FindOneForShareTx`)。其直接副作用: 1. **P2 视角下的部门链崩坏**:P2 的非 ADMIN 管理员(DEVELOPER、PermsLevel 接近 SuperAdmin 的 MEMBER)依赖 `CheckManageAccess → checkDeptHierarchy` 做"目标必须在 caller 子树下"的判定;B 的 DeptPath 被 P1 ADMIN 单方面改写为 P1 子树的前缀后,P2 的这些管理员对 B 的所有管理操作(降级、授权、改 Profile、冻结)都会落到 `response.ErrForbidden("无权管理其他部门的用户")`——B 在 P2 视角成为一个**结构性不可触达的账号**; 2. **P2 的 DeptTree 里 B 也同时失踪**:`deptTreeLogic.go:52-62` 对非超管做 `strings.HasPrefix(d.Path, caller.DeptPath)` 的裁剪,B 的新 DeptPath 一旦不再以 P2 任何管理员的 `caller.DeptPath` 为前缀,B 在 P2 的部门树渲染里完全消失; 3. **攻击链可达性**:P1 ADMIN 不需要超管权限,不需要绕过 H-R14-1 的 DEV 部门护栏,**仅靠合法的 "给 P1 成员换部门" 操作**就能在 P2 侧制造"隐形成员"——这与 L-R15-1 的 `deptId=0` 场景**完全同构**,只是构造方式换成"挪到 P1 子树"而不是"清空到 0"。 注释里声明的豁免理由("product ADMIN 对产品内既有成员有全面管理权")只在"不共享 target 的单产品场景"成立;一旦 target 同时归属多个产品(`sys_product_member` 允许多对多),ADMIN 改 `sys_user.deptId` 的动作已经穿透了"本产品 ADMIN 的权限天花板"。 **影响** - 与 L-R15-1 同量级的跨产品结构性扰动,差别只是"orphan(deptId=0)"换成"挪到 P1 子树";运维可观测性更差——L-R15-1 的 orphan 至少可以通过 `WHERE deptId=0 AND isSuperAdmin=0` 直接捞出,而本条的 B 依然有正常 DeptPath,P2 运维必须跨产品比对才能发现"B 明明在 P2 有成员行,但 DeptPath 已经不在 P2 子树"的异常; - P1 ADMIN 对 B 的"单方面去 P2 化"是一种隐蔽的服务降级工具——B 在 P2 里还能调接口(jwt 依然有效、MemberType 还在 P2),但 P2 的管理台侧完全管不了他; - 与 `CreateUser` 的不对称性:`CreateUser` 对 ADMIN 也强制 DeptPath 前缀校验(`createUserLogic.go:102-109`),`UpdateUser` 对 ADMIN 却短路了这一校验——两者语义应该对齐(同样是"让一个用户落在某部门下"的动作)。 **修复方案** 删除 `UpdateUser` 的 `caller.MemberType != consts.MemberTypeAdmin` 短路,把 ADMIN 也纳入 DeptPath 前缀校验范围;同时与 `CreateUser` 的校验口径保持一致。这条修改不会破坏 ADMIN 的正当业务:ADMIN 作为产品管理员,其 `caller.DeptPath` 本来就是其部门子树前缀,调整同子树内的成员部门归属不会被拦;真正被拦住的是"跨子树、跨产品"的越权改写。 ```go // internal/logic/user/updateUserLogic.go if *req.DeptId > 0 { newDept, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, *req.DeptId) if err != nil { return response.ErrBadRequest("部门不存在") } if newDept.Status != consts.StatusEnabled { return response.ErrBadRequest("目标部门已停用") } if newDept.DeptType == consts.DeptTypeDev && !caller.IsSuperAdmin { return response.ErrForbidden("仅超级管理员可将用户调入研发部门") } // 审计 L-R16-1:与 CreateUser 口径对齐,删除 ADMIN 豁免。`sys_user.deptId` 是全局字段, // ADMIN 把共享成员挪到自己部门子树外会让其它产品的部门链校验对该 target 失效——与 L-R15-1 // 的 deptId=0 场景完全同构,仅改"目标落点"而非"结构性副作用"。SuperAdmin 仍可跨子树调度。 if !caller.IsSuperAdmin { if caller.DeptPath == "" { return response.ErrForbidden("您未归属任何部门,无权调整用户部门") } if !strings.HasPrefix(newDept.Path, caller.DeptPath) { return response.ErrForbidden("无权将用户调入非自己管辖的部门") } } } ``` **回归验证要点** - P1 ADMIN 在 P1 子树内挪动自己部门的成员:放行(与现网一致); - P1 ADMIN 挪动"只属于 P1"的成员到 P1 子树外的 NORMAL 部门:拦截为 403(**本轮新增**;若业务确有此需求,应改走 SuperAdmin 审批流); - P1 ADMIN 挪动"同时也是 P2 成员"的 target 到 P1 子树外的部门:同上 403,避免跨产品结构性扰动; - SuperAdmin 行为不变:任何 NORMAL 部门均可挪入;DEV 部门同样不受本条改动影响。 --- ### L-R16-2 · `UpdateDept` DeptType/Status 收窄 + `UpdateUser` 跨 DEV/NORMAL 边界调 deptId 均未吊销 tokenVersion —— 与 M-R15-1 同构的缓存失效 TOCTOU **位置** - `internal/logic/dept/updateDeptLogic.go:91-106`(`deptTypeChanged || statusChanged` 只走 `UserDetailsLoader.CleanByUserIds`,**无** `BatchIncrementTokenVersionWithTx`) - `internal/logic/user/updateUserLogic.go:195-250`(`UpdateProfile` / `UpdateProfileWithTx` 仅在 `statusChanged` 触发 `tokenVersion+1`;**deptId 跨越 DEV↔NORMAL 边界不递增**) - 对比参照:`internal/logic/product/updateProductLogic.go:77-104`(L-R15-3 已落地"禁用产品即批量吊销成员 session")、`internal/logic/member/updateMemberLogic.go:94-134`(M-R15-1 已落地) **描述** `loadPerms` 的"是否走全权分支"明确地受三个字段驱动:`IsSuperAdmin` / `MemberType` / `DeptType + DeptStatus`: ```539:554:internal/loaders/userDetailsLoader.go // 超管 / ADMIN / DEVELOPER / 研发部门的有效成员 → 全量权限 if ud.IsSuperAdmin || ud.MemberType == consts.MemberTypeAdmin || ud.MemberType == consts.MemberTypeDeveloper || (ud.MemberType != "" && ud.DeptType == consts.DeptTypeDev && ud.DeptStatus == consts.StatusEnabled) { codes, err := l.models.SysPermModel.FindAllCodesByProductCode(ctx, ud.ProductCode) ... } ``` M-R15-1 / L-R15-3 已经为 `MemberType` 的收窄路径(UpdateMember 降级 / UpdateProduct 禁用)建立了"tx 内 `tokenVersion+1` → 签发层吊销"的闭环,避免 Redis 抖动时 5min TTL 让旧全权继续生效。但 **`DeptType` / `DeptStatus` 同样是 loadPerms 全权分支的直接输入**,它们的收窄路径现在还是"仅 `UserDetailsLoader.CleanByUserIds` 尽力而为": ```91:106:internal/logic/dept/updateDeptLogic.go if deptTypeChanged || statusChanged { userIds, err := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id) if err != nil { l.Errorf("UpdateDept id=%d deptType=%s status=%d 部门已更新但 FindIdsByDeptId 失败,用户权限缓存未能主动失效,将等待 TTL 自然过期: %v", req.Id, dept.DeptType, dept.Status, err) return nil } if len(userIds) > 0 { cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx) defer cancel() l.svcCtx.UserDetailsLoader.CleanByUserIds(cleanCtx, userIds) l.Infof("UpdateDept id=%d deptType=%s status=%d affectedUsers=%d", req.Id, dept.DeptType, dept.Status, len(userIds)) } } ``` 相同问题也出现在 `UpdateUser` 改 `deptId` 的路径:`UpdateProfileWithTx` 只认 `statusChanged` 去递增 `tokenVersion`,`deptId` 跨越 DEV/NORMAL 边界时没有任何签发层吊销,只依赖 `UserDetailsLoader.Clean` 的尽力而为失效。 收窄方向的具体触发条件(这些全都是"权限从全权收回"的场景): 1. `UpdateDept`:`DeptType` 从 `DEV → NORMAL`——该部门所有在编成员在所属产品里的 `loadPerms` 从"全量权限"降级为"角色/allow-deny 计算"; 2. `UpdateDept`:`Status` 从 `Enabled → Disabled`——DEV 部门全权分支要求 `DeptStatus == StatusEnabled`,禁用即失去全权;NORMAL 部门成员是否被禁用则改变 `jwtauthMiddleware` 对 `ud.Status` 的放行,但 dept.Status 对用户而言不是直接阻断字段,主要影响仍是 DEV 部门的全权分支; 3. `UpdateUser`:把用户 `deptId` 从 DEV 部门挪到 NORMAL 部门——单一用户的全权被收回。 这三条路径的 TOCTOU 窗口与 M-R15-1 完全一致: 1. SuperAdmin 改部门/用户归属;事务 COMMIT; 2. post-commit `UserDetailsLoader.Clean*` 因 Redis 抖动失败(3s `DetachCacheCleanCtx` 超时 + 日志标记 `cache_invalidation_skipped_*`,不重试); 3. 缓存里 `{DeptType=DEV, DeptStatus=Enabled, Perms=[全量]}` 保留到 TTL 过期(最长 5min); 4. 受影响用户的 access token `TokenVersion` 未递增(DeptType/Status 变更不触发 UpdateProfile 的 `tokenVersion+1`),`jwtauthMiddleware` 的 `TokenVersion != ud.TokenVersion` 兜底不触发; 5. 用户在这 5min 内继续以 "DEV 全权" 身份调业务接口。 与 R15 不同的是:`UpdateDept` / `UpdateUser.deptId` 的典型使用者是 **SuperAdmin**(`UpdateDept` 已经 `RequireSuperAdmin`;`UpdateUser` 改 deptId 跨 DEV 边界也被 H-R14-1 收敛给 SuperAdmin),因此这条 TOCTOU 的触发点比 `UpdateMember` 更低频——但**影响面更广**:`UpdateDept` 一次性影响"该部门所有成员";`UpdateProduct` 影响"该产品所有成员";两者叠加时,Redis 抖动可以让一个完整部门在 5min 窗口内保留已经被收回的 DEV 全权。 **影响** - Redis 抖动或长时间网络分区(backfill / 故障演练期间)下,"DEV→NORMAL"或"禁用部门"的操作在 5min 窗口内不生效; - 与 M-R15-1 / L-R15-3 的"签发层吊销"设计哲学不一致,审计回归矩阵需要解释"为什么 dept 收窄走尽力而为、member/product 收窄走强制吊销"——当前代码没有任何注释给出差异理由,容易被后续维护者当作遗漏; - 若组合 H-R16-1 攻击(tokenVersion 不变),一个原本在 DEV 部门的 ADMIN 被 SuperAdmin 从 DEV 挪到 NORMAL,其全权仍在 5min 内有效——与"先 DEV→NORMAL 后立即 RemoveMember"的组合相比,本路径更难被监控感知(无 `RemoveMember` 事件、只有 `UpdateUser/UpdateDept` 的低敏感事件)。 **修复方案** 两处收窄路径在 tx 内补齐 `BatchIncrementTokenVersionWithTx`——复用 `UpdateProduct` 的 L-R15-3 模式,把"找出受影响 userIds"和"批量 +1"收敛进同一个事务,整体回滚语义天然成立。关键判定:只在**真正构成"权限收窄"**时递增,避免 `NORMAL→DEV`(升权)或 `Disabled→Enabled`(重启用)场景误踢用户下线。 **1. `UpdateDept` 修复(DEV→NORMAL 或 DEV 部门 Enabled→Disabled 才递增):** ```go // internal/logic/dept/updateDeptLogic.go // 判定"收窄方向":仅在以下三种情况下需要 tokenVersion+1: // (a) DeptType: DEV → NORMAL(该部门所有成员失去 DEV 全权分支) // (b) DeptType 不变但 DEV 部门 Status: Enabled → Disabled(DEV 成员失去全权) // (c) DeptType 不变但 NORMAL 部门 Status: Enabled → Disabled(无直接授权影响,可选是否递增;保守起见建议递增,因为业务语义是"冻结部门所有活动") // NORMAL→DEV(升权)与 Disabled→Enabled(恢复)不递增。 prevType := req.dept.DeptType // 即原 dept.DeptType prevStatus := req.dept.Status // 即原 dept.Status nextType := dept.DeptType // 应用 req 之后 nextStatus := dept.Status devFullAccessRevoked := (prevType == consts.DeptTypeDev && nextType == consts.DeptTypeNormal) || (prevType == consts.DeptTypeDev && prevStatus == consts.StatusEnabled && nextStatus == consts.StatusDisabled) normalDeptFrozen := prevType == consts.DeptTypeNormal && nextType == consts.DeptTypeNormal && prevStatus == consts.StatusEnabled && nextStatus == consts.StatusDisabled var revokedUserIds []int64 err := l.svcCtx.SysDeptModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error { if err := l.svcCtx.SysDeptModel.UpdateWithOptLockTx(ctx, session, dept, expectedUpdateTime); err != nil { return err } if devFullAccessRevoked || normalDeptFrozen { // FindIdsByDeptId 需要提供 Tx 版本;若 tx 版本不存在,可先 pre-commit 拿 userIds,但必须收进同一个事务以避免 TOCTOU。 ids, err := l.svcCtx.SysUserModel.FindIdsByDeptIdForShareTx(ctx, session, req.Id) if err != nil { return err } if len(ids) > 0 { if err := l.svcCtx.SysUserModel.BatchIncrementTokenVersionWithTx(ctx, session, ids); err != nil { return err } revokedUserIds = ids } } return nil }) // post-commit 继续走 CleanByUserIds + InvalidateProfileCache(与 UpdateProduct 对齐) ``` 若 `SysUserModel` 当前没有 `FindIdsByDeptIdForShareTx`,需补一个带 `LOCK IN SHARE MODE` 的版本——比照 `FindActiveMemberUserIdsByProductCodeTx` 的实现(`internal/model/productmember/sysProductMemberModel.go:102-110`),与并发 `UpdateProfileWithTx`(X 锁 sys_user)互斥,防止"列出 userIds 期间有人刚被挪出本部门"造成吊销漏挂。 **2. `UpdateUser` 修复(仅在 deptId 跨 DEV↔NORMAL 边界且方向为收窄时递增):** ```go // internal/logic/user/updateUserLogic.go // 读取旧 dept(user.DeptId)与新 dept(newDept)的 DeptType: // 收窄方向 = (old.DeptType==DEV && new.DeptType==NORMAL) // 升权方向 = (old.DeptType==NORMAL && new.DeptType==DEV)——已由 H-R14-1 收敛给 SuperAdmin devAccessRevoked := false if req.DeptId != nil && *req.DeptId != user.DeptId { var oldDept *deptModel.SysDept if user.DeptId > 0 { oldDept, _ = l.svcCtx.SysDeptModel.FindOne(l.ctx, user.DeptId) } if oldDept != nil && oldDept.DeptType == consts.DeptTypeDev && newDept.DeptType == consts.DeptTypeNormal { devAccessRevoked = true } } // tx 体内补: if devAccessRevoked { if _, err := l.svcCtx.SysUserModel.IncrementTokenVersionWithTx(ctx, session, req.Id); err != nil { return err } } ``` **权衡说明** 本条优先级明确低于 M-R15-1 / H-R16-1:触发者几乎都是 SuperAdmin(`UpdateDept` 强制超管;`UpdateUser` 改 deptId 跨 DEV 边界也被 H-R14-1 限制为超管),攻击窗口需要 "SuperAdmin 误操作 + Redis 故障" 双重触发;但一旦落在监管敏感的产品上,5min 的残余 DEV 全权可能覆盖多次敏感接口调用。按"与 L-R15-3 的口径对称"的原则把这条修了对长期维护一致性有利,单独不修也可以在审计注释里明确声明"本路径 TOCTOU 由 SuperAdmin 信任边界 + Clean 尽力而为组合承担"——但这会让后续维护者难以推理"为什么同样是授权收窄,member/product 走强吊销、dept/user.deptId 走尽力而为"。 --- ## R15 回归验证(附录) | 条目 | 期望修复 | 代码现状 | 判定 | | --- | --- | --- | --- | | M-R15-1 MemberType TOCTOU(UpdateMember 降级/禁用不吊销 session) | tx 内 `IncrementTokenVersionWithTx`,post-commit 同步失效 sysUser 低层缓存 | `updateMemberLogic.go:94-154` 新增 `typeDowngraded/statusRevoked` 判定 + `IncrementTokenVersionWithTx` + `InvalidateProfileCache`;`tokenVersionRevocation` 结构封装闭包跨域传 userId,避免事务失败时脏状态泄漏 | ✅ 已闭合(方案 A) | | L-R15-1 UpdateUser.deptId=0 改写全局字段 | `deptId=0` 收敛给 SuperAdmin | `updateUserLogic.go:158-170` 非超管直接 403;注释显式声明与 H-R14-1 的结构对称性 | ✅ 已闭合 | | L-R15-2 DeptTree fullAccess 泄露组织架构 | `fullAccess = caller.IsSuperAdmin` | `deptTreeLogic.go:50` 已改;产品 ADMIN 走 DeptPath 前缀裁剪;注释清晰说明 sys_dept 全局命名空间与 M-2 的最小授权精神 | ✅ 已闭合 | | L-R15-3 UpdateProduct 禁用不吊销成员 session | tx 内 `BatchIncrementTokenVersionWithTx` + FOR SHARE 锁成员行 | `updateProductLogic.go:77-127` 已落地;`FindActiveMemberUserIdsByProductCodeTx` 用 `LOCK IN SHARE MODE` 阻塞并发 AddMember/UpdateMember/RemoveMember 的 X 锁 | ✅ 已闭合 | | L-R15-4 降权路径语义需在注释里显式声明 | updateMemberLogic / updateProductLogic 顶部注释说明 | `updateMemberLogic.go:35-43` / `updateProductLogic.go:36-42` 均已补齐,引用 M-R15-1 / L-R15-3 审计条目 | ✅ 已闭合 | --- ## 本轮总结 本轮新增 1 条 High(**H-R16-1**,`RemoveMember` 降权路径与 M-R15-1 同构但未同步吊销 session),1 条 Medium(**M-R16-1**,`UserList` / `UserDetail` 对同产品 MEMBER 暴露 PII),2 条 Low(**L-R16-1**,`UpdateUser` ADMIN 分支短路 DeptPath 前缀校验,构成 L-R15-1 的非零 deptId 同构缺口;**L-R16-2**,`UpdateDept` / `UpdateUser.deptId` 的权限收窄路径仍只走缓存失效、未 tokenVersion+1,与 M-R15-1 的签发层吊销口径不一致)。 语义关联: - **H-R16-1** 与 **L-R16-2** 同属"降权吊销覆盖对称性"的残留工单;修完 H-R16-1 应顺带把 L-R16-2 一并处置,否则每轮审计都要重新解释"为什么有些降权走强吊销、有些走尽力而为"; - **L-R16-1** 是 L-R15-1 在另一方向的同构项,两者合计描述的是"`sys_user.deptId` 作为全局字段,其任何写入都应该是 SuperAdmin 的权限维度"这一不变式; - **M-R16-1** 是独立的 PII 最小授权工单,与上述三条互相正交,但同样与 R14 的 H-R14-1 / L-R15-1 共享"产品 ADMIN 的权限边界不应穿透到全局字段"的设计哲学——MemberType 应当反向收敛读权限,不应因"同产品"就默认放行全部 PII。 优先处置顺序:**H-R16-1 → M-R16-1 → L-R16-1 → L-R16-2**。H-R16-1 最低代价(tx 内加一次 `IncrementTokenVersionWithTx`,与 M-R15-1 逻辑完全同构)但关掉一个 High 级攻击窗口;M-R16-1 对合规侧影响最大,修复面集中在两个 logic 文件;L-R16-1 / L-R16-2 可并入下一轮安全变更统一回归。