|
|
@@ -1,278 +1,506 @@
|
|
|
-# 深度审计报告 · Round 15
|
|
|
+# 深度审计报告 · Round 16
|
|
|
|
|
|
-> 基线:R14 审计后的代码库快照。R14 提出的 1 条 High(H-R14-1)、1 条 Medium(M-R14-1)、3 条 Low(L-R14-1 / L-R14-2 / L-R14-3)已全部在对应 logic 文件中落地修复(见文末"R14 回归验证")。本轮聚焦"缓存 TTL 下的 MemberType TOCTOU"、"全局字段 `sys_user.deptId` 的跨产品副作用再审"与"降权路径是否吊销会话"等此前轮次未覆盖的面。
|
|
|
+> 基线: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)
|
|
|
|
|
|
-本轮未新增 High 风险项。R14 的 H-R14-1(DEV 部门跨产品全权升级)已在 `updateUserLogic.go:140-142` 与 `createUserLogic.go:99-101` 双侧落地 SuperAdmin-only 护栏;攻击链已被切断。
|
|
|
-
|
|
|
----
|
|
|
-
|
|
|
-## ⚠️ 健壮性与性能建议 (Medium / Low)
|
|
|
-
|
|
|
-### M-R15-1 · 缓存 TTL 下的 MemberType TOCTOU —— `HasFullProductPerms` / `LoadCallerAssignableLevel` 仅读 UD 缓存的 MemberType,降权期内仍按 ADMIN 放行
|
|
|
+### H-R16-1 · `RemoveMember` 降权路径未随 M-R15-1 同步吊销会话 —— 与 `UpdateMember` 对称缺口
|
|
|
|
|
|
**位置**
|
|
|
|
|
|
-- `internal/logic/auth/access.go:265-272`(`HasFullProductPerms`)
|
|
|
-- `internal/logic/auth/access.go:211-226`(`LoadCallerAssignableLevel` 短路分支)
|
|
|
-- `internal/logic/auth/access.go:337-338`(`checkDeptHierarchy` 的 ADMIN 绕过)
|
|
|
-- `internal/logic/auth/access.go:132-144`(`RequireProductAdminFor`)
|
|
|
-- 所有把 `caller.MemberType` 当授权输入的调用点:BindRoles / CreateUser / CreateRole / BindRolePerms / UpdateRole / DeleteRole / AddMember / UpdateMember / SetUserPerms / RemoveMember / DeptTree 等
|
|
|
+- `internal/logic/member/removeMemberLogic.go:42-74`(事务体只做 `DeleteByUserId* + Delete`,无 `IncrementTokenVersionWithTx`)
|
|
|
+- 对比参照:`internal/logic/member/updateMemberLogic.go:94-134`("降权即 `tokenVersion+1`"已落地)
|
|
|
|
|
|
**描述**
|
|
|
|
|
|
-审计 H-2 / M-R10-3 / GuardRoleLevelAssignable 已经为 **caller.MinPermsLevel** 建立了"授权决策点强制走 `loadFreshMinPermsLevel` 读 DB"的 TOCTOU 闭环,缩短了 UD 缓存 TTL 窗口。但同一份 UD 里另一个同等重要的授权字段 **MemberType** 仍然只走缓存:
|
|
|
+M-R15-1 / L-R15-3 落地后,"降权/禁用"这类"从'有效成员'向'无效成员'迁移"的路径都在 tx 内把目标的 `sys_user.tokenVersion` 做 `+1`,让旧 access token 在 `jwtauthMiddleware` 的 `claims.TokenVersion != ud.TokenVersion` 兜底下立刻 401,即使 Redis `Del`/`Clean` 失败也不会残留特权。但这条口径**漏掉了 `RemoveMember`**:
|
|
|
|
|
|
-```265:272:internal/logic/auth/access.go
|
|
|
-func HasFullProductPerms(caller *loaders.UserDetails) bool {
|
|
|
- if caller == nil {
|
|
|
- return false
|
|
|
+```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("成员不存在")
|
|
|
}
|
|
|
- return caller.IsSuperAdmin ||
|
|
|
- caller.MemberType == consts.MemberTypeAdmin ||
|
|
|
- caller.MemberType == consts.MemberTypeDeveloper
|
|
|
+ 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
|
|
|
```
|
|
|
|
|
|
-`caller` 的来源是 `middleware.GetUserDetails(ctx)` → `UserDetailsLoader.Load` → Redis `ud:<userId>:<productCode>` 键,TTL=5min。一旦 `UpdateMember` 的 post-commit `Del` 因 Redis 抖动未成功(本身已用 `DetachCacheCleanCtx` 3s 超时兜底,但 Redis 真故障时仍会留日志 → 不复查),缓存里的 `MemberType` 会在 **最长 5min** 内保持 ADMIN / DEVELOPER 语义。
|
|
|
+**`RemoveMember` 事实上是 `UpdateMember` 降权路径的极端版**——不是"ADMIN→MEMBER"而是"ADMIN→`MemberType=""`(非成员)",造成的授权语义跳变更剧烈:
|
|
|
+
|
|
|
+- `loadPerms` 对"非成员"会返回 `nil` → 原本的 ADMIN 全权直接清空;
|
|
|
+- `jwtauthMiddleware` 对 `ud.MemberType == ""` 非超管会 403 `"您已不是该产品的有效成员"`;
|
|
|
+- `CheckManageAccess` 的 ADMIN 分支直接跳过 `checkDeptHierarchy`,对成员的管理面彻底开放。
|
|
|
|
|
|
-同时 `UpdateMember` 本身**不递增 sys_user.tokenVersion**(对比 `UpdateUserStatus` 会自动 `tokenVersion = tokenVersion + 1`、`UpdatePassword` / `Logout` 同),降权不触发强制重登录。因此攻击窗口是"缓存读"而非"token 读":
|
|
|
+攻击场景(与 M-R15-1 的描述完全同构,只是触发点从 `UpdateMember` 换成 `RemoveMember`):
|
|
|
|
|
|
-1. A 是产品 P1 的 product ADMIN;
|
|
|
-2. SuperAdmin 通过 `UpdateMember` 把 A 降级为 MEMBER;事务提交,`UserDetailsLoader.Del(A.UserId, P1)` 被调用;
|
|
|
-3. 若 Redis 在这 3s 内出现网络波动,`Del` 的 DelCtx 返回 err,日志打 `cache_invalidation_skipped_*`,缓存保留 `MemberType=ADMIN`;
|
|
|
-4. A 继续用手里的 access token(claims.MemberType=ADMIN,tokenVersion 未变)调用业务接口:
|
|
|
- - `jwtauthMiddleware` 走 `Load(userId, productCode)` 命中 Redis 旧 UD → 把 `MemberType=ADMIN` 的 ud 注入 ctx;
|
|
|
- - `BindRoles` / `UpdateRole` 等看 `caller.MemberType == consts.MemberTypeAdmin` 放行;
|
|
|
- - `CheckManageAccess → checkDeptHierarchy` 的 ADMIN 分支直接 `return nil`,跳过部门链校验;
|
|
|
- - `LoadCallerAssignableLevel` 的 `HasFullProductPerms` 返回 true,`BindRoles` 循环里所有 `CheckRoleLevelAgainst` 全部放行,A 可以把**任意权限等级**(包括超出其当前 MEMBER 身份应有上限)的角色绑给下属;
|
|
|
-5. 攻击窗口持续到 5min TTL 自然过期或下一次 Clean 成功。
|
|
|
+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 才被系统真正视作"非成员"。
|
|
|
|
|
|
-对比之下,R13 在 H-2 的注释里已经准确钉出"UD 缓存 5 分钟 TTL 内旧 MinPermsLevel 可被利用";但同批同类风险的 MemberType 缺少对称防御,形成审计覆盖不对称。
|
|
|
+更严重的是:**此时 A 的 `tokenVersion` 未被递增**,即便运维事后发现 Redis 抖动手动 `Del` 了缓存 key,只要 A 把 access token 保存好,下一次 Load 重建的 UD 仍然是"DB 视角下的非成员"——这是良性的(会 403)。但在 TTL 滞留窗口里 A 的 access token 本身是有效凭据(签名、类型、过期时间、`TokenVersion` 与 `ud.TokenVersion` 都匹配),`jwtauthMiddleware` 无法把这段残留权限踢下线。
|
|
|
|
|
|
**影响**
|
|
|
|
|
|
-- 降权后的 product ADMIN 在 Redis 抖动窗口内保留 ADMIN 权能:可继续创建角色 / 绑权限、管理他人(`checkDeptHierarchy` ADMIN 绕过)、修改产品内成员(`RequireProductAdminFor` 通过)。
|
|
|
-- 降 ADMIN → MEMBER 的典型触发场景就是"怀疑滥权 / 内鬼排查"——恰恰是最不希望有 5 分钟残余窗口的操作。
|
|
|
-- DEVELOPER → MEMBER 类似,但规模小,影响面小一档。
|
|
|
+- 与 M-R15-1 等级相同的权限升级 TOCTOU:被移除的 ADMIN / DEVELOPER 在 Redis 抖动时保留完整产品管理权 ≤5min;
|
|
|
+- 运维侧无任何手段**强制**下线(缓存 TTL 过期是唯一收敛机制);
|
|
|
+- 组合攻击面:如果 5min 内 A 利用残留的 ADMIN 权把自己以不同 userId(例如预埋的备用账号)重新 `AddMember` / `BindRoles`,账号回收动作形同虚设——这正是 M-R15-1 修 `UpdateMember` 时就要求的"必须在签发层吊销而不是在缓存层吊销"语义。
|
|
|
|
|
|
-此风险本身是"缓存读一致性"架构层决定,不是单点 bug。闭环方案有两条,任选其一或组合:
|
|
|
+**修复方案**
|
|
|
|
|
|
-**修复方案(方案 A,最小代价,推荐)**:让 `UpdateMember` 在降级路径(`{ADMIN, DEVELOPER} → MEMBER` 或 `Enabled → Disabled`)里显式递增目标用户的 `tokenVersion`,与 `UpdateUserStatus` 口径对齐:
|
|
|
+把 `RemoveMember` 的事务体补齐"降权即 `tokenVersion+1`"的闭环,与 `UpdateMember` 的 M-R15-1 口径完全对齐。`RemoveMember` 的语义比 `UpdateMember` 更清晰——只要走到事务体,就一定构成"从有效成员(或 ADMIN/DEVELOPER)→ 非成员"的降权,**无需再判定是否为"降权"**,无条件递增即可:
|
|
|
|
|
|
```go
|
|
|
-// internal/logic/member/updateMemberLogic.go,在 tx 内部 update member 之前/之后:
|
|
|
-wasPrivileged := locked.MemberType == consts.MemberTypeAdmin ||
|
|
|
- locked.MemberType == consts.MemberTypeDeveloper ||
|
|
|
- locked.Status == consts.StatusEnabled
|
|
|
-willBePrivileged := (nextType == consts.MemberTypeAdmin || nextType == consts.MemberTypeDeveloper) &&
|
|
|
- nextStatus == consts.StatusEnabled
|
|
|
-
|
|
|
-if wasPrivileged && !willBePrivileged {
|
|
|
- // 事务内递增 sys_user.tokenVersion 强制再登录;
|
|
|
- // 与 UpdateUserStatus 的 WHERE updateTime=? 乐观锁语义对齐,
|
|
|
- // 并通过 DetachCacheCleanCtx 的 Clean 失效 UD 缓存(已存在)。
|
|
|
+// 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
|
|
|
}
|
|
|
-```
|
|
|
|
|
|
-这样不论 Redis 是否抖动,旧 access token 都会在下一次中间件校验时因 `claims.TokenVersion != ud.TokenVersion` 被强制重登录;重登录走 `Login / RefreshToken` 会重新签发含新 `memberType` 的 token 并写入 Redis。注意需要**在 tx 内**递增 tokenVersion,否则如果 tx 回滚,`sys_user.tokenVersion` 会多走一步。目前 `sysUserModel` 只有 `IncrementTokenVersion(ctx, id, username)`(非 tx),需要补一个 `IncrementTokenVersionWithTx(ctx, session, id)`;username 仅用于失效缓存,可以延后到 tx commit 之后配合现有 `UserDetailsLoader.Del` 链路失效。
|
|
|
-
|
|
|
-**修复方案(方案 B,加固授权决策点)**:在所有依赖 `caller.MemberType == ADMIN/DEVELOPER` 的授权决策点(`HasFullProductPerms` / `checkDeptHierarchy` 的 ADMIN 绕过 / `RequireProductAdminFor`)前做一次 `FindOneByProductCodeUserId(productCode, caller.UserId)` 的 DB 复核。代价是每个管理写接口多 1 次(带缓存的)DB 读,但概念上把 MemberType 的 TOCTOU 窗口从 TTL 级压到单查询级,与 `loadFreshMinPermsLevel` 对称:
|
|
|
-
|
|
|
-```go
|
|
|
-// 新增 authHelper.ResolveCallerMembership(ctx, svcCtx, caller) (*SysProductMember, error)
|
|
|
-// 并让 HasFullProductPerms 改签名为 (ctx, svcCtx, caller) 走该 helper;
|
|
|
-// 在 checkDeptHierarchy ADMIN 分支、RequireProductAdminFor、LoadCallerAssignableLevel
|
|
|
-// 三处同步切换。
|
|
|
+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
|
|
|
```
|
|
|
|
|
|
-方案 A 与 B 的本质差异:A 让攻击窗口为 0(token 被废),B 让窗口为"单次 DB 读" ~5ms。优先推荐 A——成本更低、语义更清晰,与现有强制下线 (`UpdateUserStatus`, `ChangePassword`, `Logout`) 的口径一致。
|
|
|
+若不希望在事务外再 `FindOne`(Redis 抖动时可能慢 100ms 级别),也可以在事务体内把 `locked.UserId` 与 `username`(通过 tx 内的 `l.svcCtx.SysUserModel.FindOneForShareTx` 或在进入 tx 前的 `member` 查询里预取)通过闭包透传到 post-commit;由于 `RemoveMember` 没有超高 QPS 预期(行政操作),直接走 post-commit `FindOne`(该查询本身带 sqlc 缓存)也足够。
|
|
|
|
|
|
-**回归测试建议**
|
|
|
+**回归验证要点**
|
|
|
|
|
|
-- 模拟 ADMIN→MEMBER 降级后 **Redis DEL 失败**,验证下一次 API(BindRoles / UpdateRole)能通过 `tokenVersion mismatch` 拒绝,或(方案 B 下)通过 `RequireProductAdminFor` 的 DB 复核拒绝;
|
|
|
-- 事务回滚场景验证 tokenVersion **未**被递增(方案 A),避免"降级操作失败但用户被踢下线"的错误扩大面。
|
|
|
+- tx 体内 `IncrementTokenVersionWithTx` 返回 `ErrUpdateConflict`(竞态:并发 `RemoveMember`/`Logout`/`ChangePassword`)时整体回滚,测试需断言成员行仍存在;
|
|
|
+- Redis 完全不可用场景下,断言被移除的 ADMIN 在下一次 HTTP 请求时被 `jwtauthMiddleware` 401;
|
|
|
+- 与 `UpdateMember` 的测试矩阵对称扩容:ADMIN/DEVELOPER/MEMBER 被移除后,旧 access token 均应被立即拒绝。
|
|
|
|
|
|
---
|
|
|
|
|
|
-### L-R15-1 · 跨产品结构性破坏 —— 产品 ADMIN 可借 `UpdateUser req.DeptId=0` 把共有用户调出部门树
|
|
|
+## ⚠️ 健壮性与性能建议 (Medium / Low)
|
|
|
+
|
|
|
+### M-R16-1 · `UserList` / `UserDetail` 对任意同产品成员暴露 `email` / `phone` —— PII 最小授权缺失
|
|
|
|
|
|
-**位置** `internal/logic/user/updateUserLogic.go:158-165`
|
|
|
+**位置**
|
|
|
+
|
|
|
+- `internal/logic/user/userListLogic.go:38-90`(仅做"同产品"校验,无 MemberType 收敛,`email`/`phone` 直接回落到所有可见成员)
|
|
|
+- `internal/logic/user/userDetailLogic.go:34-76`(仅做"同产品成员"校验,`email`/`phone` 同样全量返回)
|
|
|
|
|
|
**描述**
|
|
|
|
|
|
-`H-R14-1` 已经把"调入 DEV 部门"这条真正能做跨产品**权限升级**的路径收敛给 SuperAdmin。对称地分析 `req.DeptId == 0`(把用户调出部门树)分支,现状:
|
|
|
+R14 / R15 已经把"同产品 ADMIN 可以管理同产品成员"的边界拉紧,但"同产品 MEMBER **读**其他成员信息"的边界还停留在"只要是同产品成员即可":
|
|
|
|
|
|
-```158:165:internal/logic/user/updateUserLogic.go
|
|
|
-} else {
|
|
|
- // deptId=0 意味着"把用户移出部门树";...
|
|
|
- if !caller.IsSuperAdmin && caller.MemberType != consts.MemberTypeAdmin {
|
|
|
- return response.ErrForbidden("仅超级管理员或产品管理员可将用户移出部门")
|
|
|
+```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("无权访问该产品的数据")
|
|
|
}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
-产品 ADMIN 于是被放行。`sys_user.deptId` 是**全局字段**——产品 P1 的 ADMIN 对一位同时是 P2 成员的用户 B 调用 `UpdateUser(deptId=0)`:
|
|
|
+```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("无权查看非本产品成员的用户信息")
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
|
|
|
-1. P1 侧 `CheckManageAccess` 通过(`checkDeptHierarchy` ADMIN 绕过、`checkPermLevel` 对 MEMBER 级目标 callerPri<targetPri 放行);
|
|
|
-2. 提交后 B 的全局 `sys_user.deptId=0`,`DeptPath=""`,`DeptType=""`;
|
|
|
-3. 在 P2 视角里,B 成为组织结构里的**孤儿节点**:
|
|
|
- - P2 的 MEMBER/DEVELOPER 走 `checkDeptHierarchy` 时会命中 `target.DeptId == 0` → 403 "目标用户未归属部门,仅超管或产品管理员可管理";
|
|
|
- - 只有 P2 的 ADMIN 能靠自身 ADMIN 绕过 + `checkPermLevel` 在 P2 成员表找到 B 才可继续管理;
|
|
|
- - `DeptTree` 里 B 不再出现(`caller.DeptPath` 前缀过滤),运营侧看不到 B 的存在。
|
|
|
-4. 与 H-R14-1 最大区别:**不构成权限升级**——B 的 DeptType 空、DeptStatus=0,不会触发 `loadPerms` 的 DEV 全权分支。
|
|
|
+两处响应都**无差别地**把 `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。
|
|
|
|
|
|
**影响**
|
|
|
|
|
|
-- 组织结构可用性攻击:P1 ADMIN 可以制造 P2 的"隐形成员",P2 的日常管理层级(MEMBER/DEVELOPER/产品内子 ADMIN)全部够不到 B;B 仍能正常使用 P2,但 P2 运营侧排障困难;
|
|
|
-- 与 H-R14-1 的攻击面叠加:攻击者可以先调入 DEV 部门拿全权(已被 H-R14-1 修掉),**现在也无法**叠加这条——但"调出部门树"作为次等破坏面仍存在。
|
|
|
-- 可审计性差:`sys_user.deptId` 变更在 P2 的日志链路里看不到变更发起方——整条 `UpdateUser` 调用落在 P1 的审计上下文内。
|
|
|
+- **PII 过度暴露 → 合规红线**:GDPR/PIPL/内部数据分级都要求"联系方式"类字段按职责最小化返回。当前接口对同产品 MEMBER 无差别发放,容易被监管 / 安全评估点名;
|
|
|
+- **社工攻击前置资源充足**:攻击者一旦拿到任意 P1 MEMBER 的凭据(撞库、钓鱼、误 commit token),就能把 P1 全员通讯录导出,为后续的二阶钓鱼 / SIM swap / 账号接管提供精准名单;
|
|
|
+- **审计覆盖面与 `MemberList` 口径不一致**:`MemberListLogic` 已经收敛了响应字段(不泄露 PII),但 `UserListLogic`(同为"按产品分页列成员"用途)没有收敛,两者 API 语义重合但安全边界不同,易被误判。
|
|
|
|
|
|
**修复方案**
|
|
|
|
|
|
-与 H-R14-1 对称收敛给 SuperAdmin:
|
|
|
+按"自己可见全部字段、他人仅超管/ADMIN/DEVELOPER 可见 PII"的分层授权收窄响应体。不建议在 logic 层加复杂 if/else 后再 copy 字段——容易随字段增加漏脱敏。推荐在响应装配前统一做 PII 脱敏:
|
|
|
|
|
|
```go
|
|
|
-// internal/logic/user/updateUserLogic.go
|
|
|
-} else {
|
|
|
- // 审计 L-R15-1:与 H-R14-1 对称——sys_user.deptId 是全局字段,改为 0 会把用户从
|
|
|
- // **所有**产品的部门结构里移除,跨产品影响。产品 ADMIN 的授权范围仅限自己产品,
|
|
|
- // 不应执行改变全局字段的破坏性操作。移出部门树由 SuperAdmin 执行(例如离职流程)。
|
|
|
- if !caller.IsSuperAdmin {
|
|
|
- return response.ErrForbidden("仅超级管理员可将用户移出部门")
|
|
|
+// 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,
|
|
|
+ })
|
|
|
}
|
|
|
```
|
|
|
|
|
|
-如业务上确实需要保留"产品 ADMIN 可把**非跨产品**成员移出部门"的能力,可以加一个"目标是否同时在其他产品"的判断,但复杂度明显提升,不如直接收敛给 SuperAdmin;离职/转岗属于行政 HR 流程,SuperAdmin 执行更合理。
|
|
|
+`UserDetailLogic` 沿用相同 helper;同时在 `UserDetailLogic` 里额外加一层"caller == target 或 caller 有管理职权时才返回完整信息"的自检(目标是 `caller.UserId != req.Id` 且 `caller` 非 ADMIN/DEVELOPER 的场景,可以考虑直接 `return ErrForbidden`,而不是回一个"只有昵称没有 PII"的半成品——后者会让前端误以为目标真的没绑邮箱 / 手机号)。
|
|
|
|
|
|
-**回归测试建议**
|
|
|
+**回归验证要点**
|
|
|
|
|
|
-- caller=P1 ADMIN,target=P1+P2 MEMBER,`req.DeptId=0` → `ErrForbidden("仅超级管理员可将用户移出部门")`;`sys_user.deptId` 不变;`UserDetailsLoader.Clean` 不触发。
|
|
|
-- caller=SuperAdmin 同请求 → 正常放行。
|
|
|
-- caller=P1 ADMIN,target=P1-only MEMBER,`req.DeptId=0` → 根据业务决策(本轮建议改为一致拒绝)。
|
|
|
+- MEMBER 身份调用 `UserList` / `UserDetail`:
|
|
|
+ - 看自己 → Email / Phone 原样返回;
|
|
|
+ - 看他人 → Email / Phone / Remark 为空字符串,其余字段保留;
|
|
|
+- ADMIN / DEVELOPER 调用上述接口:所有字段原样返回(与现网行为一致,避免破坏管理台体验);
|
|
|
+- 前端若强依赖"字段非空"作逻辑分支,需同步升级——建议增加响应 schema 的版本协商或在 item 上新增 `piiVisible bool` 提示,减少默默置空导致的 UI 侧 regression。
|
|
|
|
|
|
---
|
|
|
|
|
|
-### L-R15-2 · 跨产品信息泄露 —— `DeptTree` 对任何产品的 ADMIN 暴露完整组织架构
|
|
|
+### L-R16-1 · `UpdateUser` 的 ADMIN 分支短路 DeptPath 前缀校验 —— 非 `deptId=0` 方向的同构缺口
|
|
|
|
|
|
-**位置** `internal/logic/dept/deptTreeLogic.go:45`
|
|
|
+**位置**
|
|
|
+
|
|
|
+- `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 豁免)
|
|
|
|
|
|
**描述**
|
|
|
|
|
|
-```45:57:internal/logic/dept/deptTreeLogic.go
|
|
|
-fullAccess := caller.IsSuperAdmin || caller.MemberType == consts.MemberTypeAdmin
|
|
|
+L-R15-1 把 `UpdateUser.req.DeptId = 0` 这种"把目标移出全局部门树"的极端路径收敛给了 SuperAdmin,理由是:`sys_user.deptId` 是**全局**字段,P1 ADMIN 在 P1 的授权范围不应影响 P2 视角下的成员归属。但同一个逻辑在"`req.DeptId > 0` 且 `newDept.DeptType != DEV`"分支里仍然存在:
|
|
|
|
|
|
-if !fullAccess {
|
|
|
- if caller.DeptPath == "" {
|
|
|
- return make([]*types.DeptItem, 0), nil
|
|
|
- }
|
|
|
- filtered := make([]*deptModel.SysDept, 0, len(list))
|
|
|
- for _, d := range list {
|
|
|
- if strings.HasPrefix(d.Path, caller.DeptPath) {
|
|
|
- filtered = append(filtered, d)
|
|
|
- }
|
|
|
- }
|
|
|
- list = filtered
|
|
|
+```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("无权将用户调入非自己管辖的部门")
|
|
|
}
|
|
|
```
|
|
|
|
|
|
-`sys_dept` 是**全局**命名空间(一份组织架构服务于所有产品);任何一个产品的 product ADMIN 都能走 `fullAccess=true` 分支拿到**全公司所有部门**——包括其他产品的敏感研发子树、跨 BU 的 HR/财务部门、未归属到任何产品的战略部门等。
|
|
|
+这一 `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"。
|
|
|
|
|
|
-这与 `DeptTreeLogic` 注释里的"超管 / 产品 ADMIN 返回完整组织架构树;其他成员仅返回以其 DeptPath 为根的子树"一致,但在多产品共享组织架构的部署下,对"小产品的 ADMIN 看到大产品的 DEV 子树名称 + 层级"这一点暴露并没有防御。配合 M-2(MEMBER 级不能看全产品列表以防 admin_<code> 撞库)的既有意图——审计链路里"MEMBER 拿不到敏感组织信息"是刻意守住的,但对"任何 product ADMIN 可拿到全量 sys_dept"网开一面,属于覆盖不对称。
|
|
|
+注释里声明的豁免理由("product ADMIN 对产品内既有成员有全面管理权")只在"不共享 target 的单产品场景"成立;一旦 target 同时归属多个产品(`sys_product_member` 允许多对多),ADMIN 改 `sys_user.deptId` 的动作已经穿透了"本产品 ADMIN 的权限天花板"。
|
|
|
|
|
|
**影响**
|
|
|
|
|
|
-- 信息泄露级别,非权限升级:拿到部门结构后可用于社工 / 针对性撞库(例如针对 DEV 部门里 ops_* 命名的账号发起 H-1 维度的 username/ip 限流绕过尝试),以及 H-R14-1 修掉的攻击链在"选定目标部门 id"阶段的侦察输入;
|
|
|
-- 多产品共用一份 sys_dept 的前提下,"小产品 ADMIN 看到大产品 DEV 部门"的设计与后者对前者的最小授权原则冲突;
|
|
|
-- 若未来 `DeptType=DEV` 的子树被用作类似 H-R14-1 的"加入即全权"判据(已被 H-R14-1 的 SuperAdmin-only 护栏堵死),本项就是"知道调去哪个 deptId"的前置侦察接口。
|
|
|
+- 与 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 却短路了这一校验——两者语义应该对齐(同样是"让一个用户落在某部门下"的动作)。
|
|
|
|
|
|
**修复方案**
|
|
|
|
|
|
-根据业务对"产品 ADMIN 是否需要全局部门视图"的真实需求,二选一:
|
|
|
+删除 `UpdateUser` 的 `caller.MemberType != consts.MemberTypeAdmin` 短路,把 ADMIN 也纳入 DeptPath 前缀校验范围;同时与 `CreateUser` 的校验口径保持一致。这条修改不会破坏 ADMIN 的正当业务:ADMIN 作为产品管理员,其 `caller.DeptPath` 本来就是其部门子树前缀,调整同子树内的成员部门归属不会被拦;真正被拦住的是"跨子树、跨产品"的越权改写。
|
|
|
|
|
|
-1. **收敛给 SuperAdmin**:`fullAccess := caller.IsSuperAdmin`。产品 ADMIN 仅看自己 DeptPath 子树(与 DEVELOPER/MEMBER 对齐)。`AddMember` 若需要从其他部门拉人,由 SuperAdmin 批准或另开 `ListAddableDepts` 等独立接口。
|
|
|
-2. **保留产品 ADMIN 全量视图,但脱敏其他产品的 DEV 子树**:在 `fullAccess=true` 分支里,若 caller 不是 SuperAdmin,额外跳过 `DeptType == DEV` 的部门。保留"正常部门"的全公司视图以支持 AddMember 跨部门拉人,但隐藏 DEV 子树名称避免针对性侦察。
|
|
|
+```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("无权将用户调入非自己管辖的部门")
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**回归验证要点**
|
|
|
|
|
|
-从"最小授权"出发推荐方案 1;保留现状的话建议在审计报告里显式钉住"产品 ADMIN 可见全组织架构"这条信任边界,便于未来评审时不被新人误改。
|
|
|
+- P1 ADMIN 在 P1 子树内挪动自己部门的成员:放行(与现网一致);
|
|
|
+- P1 ADMIN 挪动"只属于 P1"的成员到 P1 子树外的 NORMAL 部门:拦截为 403(**本轮新增**;若业务确有此需求,应改走 SuperAdmin 审批流);
|
|
|
+- P1 ADMIN 挪动"同时也是 P2 成员"的 target 到 P1 子树外的部门:同上 403,避免跨产品结构性扰动;
|
|
|
+- SuperAdmin 行为不变:任何 NORMAL 部门均可挪入;DEV 部门同样不受本条改动影响。
|
|
|
|
|
|
---
|
|
|
|
|
|
-### L-R15-3 · 降权 / 禁用不强制吊销会话 —— `UpdateMember` 与 `UpdateProduct`(禁用)均不递增 tokenVersion
|
|
|
+### L-R16-2 · `UpdateDept` DeptType/Status 收窄 + `UpdateUser` 跨 DEV/NORMAL 边界调 deptId 均未吊销 tokenVersion —— 与 M-R15-1 同构的缓存失效 TOCTOU
|
|
|
|
|
|
**位置**
|
|
|
|
|
|
-- `internal/logic/member/updateMemberLogic.go:77-107`(整个事务体不触及 `sys_user.tokenVersion`)
|
|
|
-- `internal/logic/product/updateProductLogic.go:46-73`(产品禁用同样不递增其成员 tokenVersion)
|
|
|
+- `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 已落地)
|
|
|
|
|
|
**描述**
|
|
|
|
|
|
-与 `UpdateUserStatus`(会自动走 `sysUserModel.UpdateStatus` 内的 `tokenVersion = tokenVersion + 1` + 乐观锁)、`ChangePassword`(`UpdatePassword` 内 `tokenVersion = tokenVersion + 1`)、`Logout`(`IncrementTokenVersion`)形成鲜明对比:
|
|
|
+`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))
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
|
|
|
-| 变更 | 旧 access token 失效方式 |
|
|
|
-| --- | --- |
|
|
|
-| UpdateUserStatus Disabled | ✅ tokenVersion +1 → 中间件下次 403 |
|
|
|
-| UpdateUserStatus Enabled(重启用) | ✅ tokenVersion +1(副作用无害) |
|
|
|
-| ChangePassword | ✅ tokenVersion +1 |
|
|
|
-| Logout | ✅ tokenVersion +1 |
|
|
|
-| RefreshToken rotate | ✅ CAS tokenVersion +1 |
|
|
|
-| **UpdateMember 降级** | ❌ 仅刷 UD 缓存(best-effort) |
|
|
|
-| **UpdateMember 禁用** | ❌ 仅刷 UD 缓存 |
|
|
|
-| **UpdateProduct 禁用** | ❌ 仅刷 UD 缓存 |
|
|
|
-| DeleteRole / UpdateRole | ❌ 仅刷 UD 缓存(可接受:角色细粒度) |
|
|
|
+相同问题也出现在 `UpdateUser` 改 `deptId` 的路径:`UpdateProfileWithTx` 只认 `statusChanged` 去递增 `tokenVersion`,`deptId` 跨越 DEV/NORMAL 边界时没有任何签发层吊销,只依赖 `UserDetailsLoader.Clean` 的尽力而为失效。
|
|
|
|
|
|
-后三条依赖 `UserDetailsLoader.Del / CleanByProduct` 的 post-commit 失效(已用 `DetachCacheCleanCtx` 3s 超时 + 5min TTL 兜底)。Redis 抖动时的窗口见 M-R15-1 的详细分析——这里是"同一风险模式"的另一个触发点:
|
|
|
+收窄方向的具体触发条件(这些全都是"权限从全权收回"的场景):
|
|
|
|
|
|
-- `UpdateMember` 降 ADMIN → MEMBER:M-R15-1 已详述;
|
|
|
-- `UpdateMember` 禁用(`Status=Disabled`):被禁成员的 access token 仍然能通过中间件——因为中间件读的是 `ud.Status`(来自 UD 缓存),缓存失效失败就等于"禁用无效" 5 分钟;
|
|
|
-- `UpdateProduct` 禁用产品:产品内所有成员的 `ud.ProductStatus` 需要失效,`CleanByProduct` 触发大范围 SUNION+DEL;任意一步失败就留下"产品禁用但成员仍能查 / 用"的窗口。
|
|
|
+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 全权。
|
|
|
|
|
|
**影响**
|
|
|
|
|
|
-与 M-R15-1 同一个攻击面,不同触发点:在 Redis 不可用期间,"降级 / 禁用 / 产品下线" 三类敏感变更都依赖缓存失效做唯一拦截。本项与 M-R15-1 的关系是"M-R15-1 是其中最危险的一种降权路径,L-R15-3 是全集"。
|
|
|
+- 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` 的低敏感事件)。
|
|
|
|
|
|
**修复方案**
|
|
|
|
|
|
-按 M-R15-1 方案 A 的思路统一让这三个写路径在降权/禁用分支里递增目标 `tokenVersion`:
|
|
|
+两处收窄路径在 tx 内补齐 `BatchIncrementTokenVersionWithTx`——复用 `UpdateProduct` 的 L-R15-3 模式,把"找出受影响 userIds"和"批量 +1"收敛进同一个事务,整体回滚语义天然成立。关键判定:只在**真正构成"权限收窄"**时递增,避免 `NORMAL→DEV`(升权)或 `Disabled→Enabled`(重启用)场景误踢用户下线。
|
|
|
|
|
|
-1. `updateMemberLogic.go`:降 ADMIN/DEVELOPER → MEMBER 或禁用成员时递增目标用户的 `tokenVersion`(见 M-R15-1 方案 A 代码样例)。
|
|
|
-2. `updateProductLogic.go`:产品变更为 `Disabled` 时,**批量**递增该产品下所有成员的 `tokenVersion`——类似 `FindIdsByDeptId` 的接口语义,新增 `sysProductMember.FindActiveMemberUserIdsByProductCode` 拿一次 userIds,再用一条 `UPDATE sys_user SET tokenVersion = tokenVersion + 1 WHERE id IN (...)` 批量递增。注意:数据量就是该产品的成员数(量级一般在数千内),不是全站用户,不会爆 SQL。配合 `UserDetailsLoader.CleanByProduct` 并行清缓存即可。
|
|
|
-3. 对应新增 `IncrementTokenVersionWithTx(ctx, session, id)` 与 `BatchIncrementTokenVersion(ctx, userIds)` 两个 model 方法,共享现有 `cacheSysUserIdPrefix / cacheSysUserUsernamePrefix` 失效链路。
|
|
|
+**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 对齐)
|
|
|
+```
|
|
|
|
|
|
-- Redis 完全不可用场景下,验证降级/禁用用户被上游中间件拒绝;
|
|
|
-- 批量递增 tokenVersion 的 SQL 使用占位符化的 IN(...),避免未来 N 过大时栈溢出或单 SQL 行数超限。
|
|
|
+若 `SysUserModel` 当前没有 `FindIdsByDeptIdForShareTx`,需补一个带 `LOCK IN SHARE MODE` 的版本——比照 `FindActiveMemberUserIdsByProductCodeTx` 的实现(`internal/model/productmember/sysProductMemberModel.go:102-110`),与并发 `UpdateProfileWithTx`(X 锁 sys_user)互斥,防止"列出 userIds 期间有人刚被挪出本部门"造成吊销漏挂。
|
|
|
|
|
|
----
|
|
|
+**2. `UpdateUser` 修复(仅在 deptId 跨 DEV↔NORMAL 边界且方向为收窄时递增):**
|
|
|
|
|
|
-### L-R15-4 · 可读性 —— 降权路径语义需要在注释里显式声明
|
|
|
+```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
|
|
|
+ }
|
|
|
+}
|
|
|
|
|
|
-随着 M-R15-1 / L-R15-3 如果按方案 A 采纳"降权即递增 tokenVersion",需要在以下文件顶部注释钉住语义:
|
|
|
+// tx 体内补:
|
|
|
+if devAccessRevoked {
|
|
|
+ if _, err := l.svcCtx.SysUserModel.IncrementTokenVersionWithTx(ctx, session, req.Id); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
|
|
|
-- `internal/logic/member/updateMemberLogic.go` 顶部:列明"任何从 {ADMIN, DEVELOPER} 向 MEMBER 的迁移、或从 Enabled 向 Disabled 的迁移都会强制对方重登录(tokenVersion+1)",方便未来维护者区分"刷缓存 + 重登录"的双重防御意图;
|
|
|
-- `internal/logic/product/updateProductLogic.go`:同样声明产品禁用会批量递增成员 tokenVersion。
|
|
|
+**权衡说明**
|
|
|
|
|
|
-这条本身不构成代码修改,仅提醒"代码修完后记得同步更新审计注释",与 L-R14-3 同类性质。
|
|
|
+本条优先级明确低于 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 走尽力而为"。
|
|
|
|
|
|
---
|
|
|
|
|
|
-## R14 回归验证(附录)
|
|
|
+## R15 回归验证(附录)
|
|
|
|
|
|
| 条目 | 期望修复 | 代码现状 | 判定 |
|
|
|
| --- | --- | --- | --- |
|
|
|
-| H-R14-1 DEV 部门跨产品全权升级 | UpdateUser + CreateUser 在 `DeptType=DEV` 目标部门分支拒绝非超管 | `updateUserLogic.go:140-142` + `createUserLogic.go:99-101` 两处均有显式 `newDept.DeptType == consts.DeptTypeDev && !caller.IsSuperAdmin → ErrForbidden` | ✅ 已闭合 |
|
|
|
-| M-R14-1 rotateRefreshToken / syncPermsService 缓存失效未 detach | 两处改用 `loaders.DetachCacheCleanCtx` | `rotateRefreshToken.go:87-89` + `syncPermsService.go:177-179` 均已改造;全仓扫描 `UserDetailsLoader.(Clean|Del|CleanByProduct|BatchDel|CleanByUserIds)` 调用点,所有路径都走 detached ctx | ✅ 已闭合 |
|
|
|
-| L-R14-1 UpdateRole/DeleteRole/BindRolePerms 404 vs 403 枚举 | 抽 `authHelper.ResolveOwnRoleOr404` 统一 404 | `access.go:146-176` 新增 `ResolveOwnRoleOr404`;三处调用方(`updateRoleLogic.go:46` / `deleteRoleLogic.go:33` / `bindRolePermsLogic.go:38`)均改用该 helper | ✅ 已闭合 |
|
|
|
-| L-R14-2 BindRoles 跨产品文案区分 | "缺项 / 跨产品 / 已禁用" 折叠为同一 `"包含无效的角色ID"` | `bindRolesLogic.go:91-117` 合并;事务内拿 S 锁失败(race_deleted_or_disabled)的分支 `bindRolesLogic.go:163-170` 也走同一响应体 | ✅ 已闭合 |
|
|
|
-| L-R14-3 UpdateUser ADMIN 分支注释披露 | 在 ADMIN 分支显式声明 DEV 部门语义 | `updateUserLogic.go:131-152` 已补注释;且 H-R14-1 的代码兜底已经让注释描述的路径被代码直接拦截,注释与代码互相印证 | ✅ 已闭合 |
|
|
|
+| 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 条 Medium(M-R15-1,缓存 TTL 下的 MemberType TOCTOU)与 3 条 Low(L-R15-1 / L-R15-2 / L-R15-3)。Low 三条彼此有语义关联:L-R15-3 是 M-R15-1 的泛化,L-R15-1 是 H-R14-1 的结构对称项,L-R15-2 是跨产品信息泄露面的残留。优先处置 **M-R15-1**(按方案 A 让 `UpdateMember` 降级时强制 `tokenVersion+1`),可同时闭合 L-R15-3 的 UpdateMember 分支——代价仅是在 tx 内补一次 UPDATE;`UpdateProduct` 禁用批量递增可分作下一轮单独跟进。
|
|
|
+## 本轮总结
|
|
|
+
|
|
|
+本轮新增 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 可并入下一轮安全变更统一回归。
|