access.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. package auth
  2. import (
  3. "context"
  4. "errors"
  5. "math"
  6. "strings"
  7. "perms-system-server/internal/consts"
  8. "perms-system-server/internal/loaders"
  9. "perms-system-server/internal/middleware"
  10. memberModel "perms-system-server/internal/model/productmember"
  11. roleModel "perms-system-server/internal/model/role"
  12. userModel "perms-system-server/internal/model/user"
  13. "perms-system-server/internal/response"
  14. "perms-system-server/internal/svc"
  15. "github.com/zeromicro/go-zero/core/stores/sqlx"
  16. )
  17. func memberTypePriority(memberType string) int {
  18. switch memberType {
  19. case consts.MemberTypeSuperAdmin:
  20. return 0
  21. case consts.MemberTypeAdmin:
  22. return 1
  23. case consts.MemberTypeDeveloper:
  24. return 2
  25. case consts.MemberTypeMember:
  26. return 3
  27. default:
  28. return math.MaxInt32
  29. }
  30. }
  31. // ManageAccessOption 给 CheckManageAccess 的可选参数;主要用来传递调用方已经拿到的目标用户对象,
  32. // 避免 checkDeptHierarchy 内部再做一次 FindOne(targetUserId)(见审计 M-5)。
  33. type ManageAccessOption func(*manageAccessOpts)
  34. type manageAccessOpts struct {
  35. prefetchedTarget *userModel.SysUser
  36. memberSink **memberModel.SysProductMember
  37. }
  38. // WithPrefetchedTarget 供调用方透传已获取的目标用户数据。仅在 target.Id == targetUserId 时有效,
  39. // 调用方负责保证一致性;不一致时该选项被忽略,回落到普通 FindOne。
  40. func WithPrefetchedTarget(target *userModel.SysUser) ManageAccessOption {
  41. return func(o *manageAccessOpts) {
  42. o.prefetchedTarget = target
  43. }
  44. }
  45. // WithMemberSink 让调用方接住 checkPermLevel 内部顺带 FindOneByProductCodeUserId 取到的
  46. // targetMember,避免调用方再打一次同查询(审计 M-R10-5)。注意:
  47. // - 仅在走进 checkPermLevel 的分支(caller 非 SuperAdmin 且非本人)才会被写入;
  48. // - caller=SuperAdmin 时整个 CheckManageAccess 短路不查 member,sink 将保持 nil,调用方
  49. // 需要在 sink==nil 的情况下自己兜底 FindOneByProductCodeUserId(或按业务规则跳过)。
  50. func WithMemberSink(sink **memberModel.SysProductMember) ManageAccessOption {
  51. return func(o *manageAccessOpts) {
  52. o.memberSink = sink
  53. }
  54. }
  55. // CheckManageAccess 检查当前操作者是否有权管理目标用户。
  56. // 规则:
  57. // 1. SUPER_ADMIN 完全豁免
  58. // 2. 操作自己豁免
  59. // 3. 部门检查:目标用户须在操作者本部门或下级子部门(ADMIN 豁免)
  60. // 4. 权限级别检查:操作者的级别必须严格高于目标用户
  61. // - 先比 memberType 优先级(SUPER_ADMIN > ADMIN > DEVELOPER > MEMBER)
  62. // - 同 memberType 时比 permsLevel(数值越小权限越高)
  63. func CheckManageAccess(ctx context.Context, svcCtx *svc.ServiceContext, targetUserId int64, productCode string, opts ...ManageAccessOption) error {
  64. caller := middleware.GetUserDetails(ctx)
  65. if caller == nil {
  66. return response.ErrUnauthorized("未登录")
  67. }
  68. if caller.IsSuperAdmin {
  69. return nil
  70. }
  71. if caller.UserId == targetUserId {
  72. return nil
  73. }
  74. options := &manageAccessOpts{}
  75. for _, opt := range opts {
  76. opt(options)
  77. }
  78. prefetched := options.prefetchedTarget
  79. if prefetched != nil && prefetched.Id != targetUserId {
  80. prefetched = nil
  81. }
  82. if err := checkDeptHierarchy(ctx, svcCtx, caller, targetUserId, prefetched); err != nil {
  83. return err
  84. }
  85. return checkPermLevel(ctx, svcCtx, caller, targetUserId, productCode, options.memberSink)
  86. }
  87. // CheckMemberTypeAssignment 检查操作者是否有权分配指定的 memberType。
  88. func CheckMemberTypeAssignment(ctx context.Context, assignedType string) error {
  89. caller := middleware.GetUserDetails(ctx)
  90. if caller == nil {
  91. return response.ErrUnauthorized("未登录")
  92. }
  93. if caller.IsSuperAdmin {
  94. return nil
  95. }
  96. if caller.MemberType == "" {
  97. return response.ErrForbidden("缺少产品成员上下文")
  98. }
  99. if memberTypePriority(caller.MemberType) >= memberTypePriority(assignedType) {
  100. return response.ErrForbidden("无权分配该成员类型,不能分配与自己同级或更高级别的类型")
  101. }
  102. return nil
  103. }
  104. // RequireSuperAdmin 要求当前操作者必须是超级管理员。
  105. func RequireSuperAdmin(ctx context.Context) error {
  106. caller := middleware.GetUserDetails(ctx)
  107. if caller == nil {
  108. return response.ErrUnauthorized("未登录")
  109. }
  110. if !caller.IsSuperAdmin {
  111. return response.ErrForbidden("仅超级管理员可执行此操作")
  112. }
  113. return nil
  114. }
  115. // RequireProductAdminFor 要求当前操作者是超级管理员或指定产品的管理员。
  116. func RequireProductAdminFor(ctx context.Context, targetProductCode string) error {
  117. caller := middleware.GetUserDetails(ctx)
  118. if caller == nil {
  119. return response.ErrUnauthorized("未登录")
  120. }
  121. if caller.IsSuperAdmin {
  122. return nil
  123. }
  124. if caller.MemberType == consts.MemberTypeAdmin && caller.ProductCode == targetProductCode {
  125. return nil
  126. }
  127. return response.ErrForbidden("仅超级管理员或该产品的管理员可执行此操作")
  128. }
  129. // ResolveOwnRoleOr404 把"根据 roleId 拉取角色 + 归属产品判定"这段通用前置抽出来供
  130. // UpdateRole / DeleteRole / BindRolePerms 等 role 维度的管理接口复用(审计 L-R14-1)。
  131. //
  132. // 必须与 RoleDetailLogic(M-N3)的收敛口径保持一致:非超管调用者看到"属于其他产品的
  133. // 角色"时,必须返回与"角色不存在"完全一致的 404 + 文案 "角色不存在",否则任何已登录
  134. // 用户都能靠 404 vs 403 的响应码差异做顺序 id 扫描,画出跨产品 roleId 分布图。
  135. //
  136. // 返回值约定:
  137. // - 成功:返回带完整字段的 *SysRole,调用方随后可直接 `authHelper.RequireProductAdminFor(role.ProductCode)`
  138. // 做最终授权判定;
  139. // - err != nil:调用方应立即返回该 err,不要再访问 role(除了超管路径外,我们不向响应体暴露 role.ProductCode)。
  140. //
  141. // 契约说明:
  142. // - 未登录 → 返回 ErrUnauthorized,避免把"未登录"与"无此角色"混为 404(登录态下的
  143. // 401 响应对攻击者意义不同,且让中间件/运维识别"缺登录态"路径更容易);
  144. // - 超管:即使 role.ProductCode 与 caller.ProductCode 不同也直接放行,用于平台级维护
  145. // 场景(超管本来就能跨产品,不存在 enum oracle)。
  146. func ResolveOwnRoleOr404(ctx context.Context, svcCtx *svc.ServiceContext, roleId int64) (*roleModel.SysRole, error) {
  147. caller := middleware.GetUserDetails(ctx)
  148. if caller == nil {
  149. return nil, response.ErrUnauthorized("未登录")
  150. }
  151. role, err := svcCtx.SysRoleModel.FindOne(ctx, roleId)
  152. if err != nil {
  153. return nil, response.ErrNotFound("角色不存在")
  154. }
  155. if !caller.IsSuperAdmin && role.ProductCode != caller.ProductCode {
  156. return nil, response.ErrNotFound("角色不存在")
  157. }
  158. return role, nil
  159. }
  160. // GuardRoleLevelAssignable 校验调用者能否把 rolePermsLevel 这一等级的角色分配给他人。
  161. // 约束:"只能分配严格低于自身的等级"(数字更大 = 更低),与 checkPermLevel 的 ">=" 拦截口径对齐,
  162. // 避免调用者把下属拉到与自己平级后彻底失去管控(见审计 H-3)。
  163. // 拥有产品全权(SuperAdmin / ADMIN / DEVELOPER)的调用者直接放行。
  164. //
  165. // 授权依据直接走 DB 强一致查询,而不是 caller 的 UD 缓存:
  166. // 超管刚把 caller 从高级降到中级时,如果 UD 缓存的 Clean 因为 Redis 抖动失败,caller 的缓存里还
  167. // 是旧的高 MinPermsLevel;缓存 TTL 窗口内 caller 仍可凭旧级别批量分配超出当前权限的角色
  168. // (审计 M-3 TOCTOU + 缓存失效延迟)。这里按"最小代价避开缓存"的原则,只在 assignment 决策点
  169. // 打一条 FindMinPermsLevelByUserIdAndProductCode 走 NoCache 查询。
  170. func GuardRoleLevelAssignable(ctx context.Context, svcCtx *svc.ServiceContext, caller *loaders.UserDetails, rolePermsLevel int64) error {
  171. snap, err := LoadCallerAssignableLevel(ctx, svcCtx, caller)
  172. if err != nil {
  173. return err
  174. }
  175. return CheckRoleLevelAgainst(snap, rolePermsLevel)
  176. }
  177. // AssignableLevelSnapshot 是 "caller 在分配角色时的可分配等级快照"。调用方通常在一次业务
  178. // 请求里对多个 role 做同一 caller 的等级校验,拿着这个快照重复 CheckRoleLevelAgainst 不再打 DB。
  179. type AssignableLevelSnapshot struct {
  180. // HasFullPerms 为 true 时直接全放行(SuperAdmin / ADMIN / DEVELOPER)。
  181. HasFullPerms bool
  182. // NoRole 表示 caller 在当前产品下没有任何活跃角色;即便 HasFullPerms=false 也不能分配任何角色。
  183. NoRole bool
  184. // Level 是 caller 的最小 permsLevel;仅在 HasFullPerms=false && NoRole=false 时有效。
  185. Level int64
  186. }
  187. // LoadCallerAssignableLevel 封装"拉取一次 caller 的可分配等级 snapshot"的 DB 读。用于把 BindRoles
  188. // 这类批量分配循环里的 N 次 loadFreshMinPermsLevel 合并为 1 次(见审计 M-R10-3)。
  189. // 对 SuperAdmin / ADMIN / DEVELOPER 走短路,不打 DB;其他情况走 NoCache DB 读,保持与
  190. // GuardRoleLevelAssignable 完全一致的 TOCTOU 闭环契约。
  191. func LoadCallerAssignableLevel(ctx context.Context, svcCtx *svc.ServiceContext, caller *loaders.UserDetails) (AssignableLevelSnapshot, error) {
  192. if HasFullProductPerms(caller) {
  193. return AssignableLevelSnapshot{HasFullPerms: true}, nil
  194. }
  195. if caller == nil {
  196. return AssignableLevelSnapshot{NoRole: true}, nil
  197. }
  198. level, notFound, err := loadFreshMinPermsLevel(ctx, svcCtx, caller.UserId, caller.ProductCode)
  199. if err != nil {
  200. return AssignableLevelSnapshot{}, err
  201. }
  202. if notFound {
  203. return AssignableLevelSnapshot{NoRole: true}, nil
  204. }
  205. return AssignableLevelSnapshot{Level: level}, nil
  206. }
  207. // CheckRoleLevelAgainst 用预取的 snapshot 判定一个角色等级是否可被 caller 分配。保持与
  208. // GuardRoleLevelAssignable 完全一致的错误文案与边界条件(">=" 含同级拦截,ErrNotFound→403)。
  209. func CheckRoleLevelAgainst(snap AssignableLevelSnapshot, rolePermsLevel int64) error {
  210. if snap.HasFullPerms {
  211. return nil
  212. }
  213. if snap.NoRole {
  214. return response.ErrForbidden("您没有可分配的角色等级")
  215. }
  216. if rolePermsLevel <= snap.Level {
  217. return response.ErrForbidden("不能分配权限级别高于自身的角色(含同级)")
  218. }
  219. return nil
  220. }
  221. // GuardCreateRolePermsLevel 校验 caller 能否在本产品下创建 `reqLevel` 等级的新角色(审计 H-R17-3)。
  222. //
  223. // 与 GuardRoleLevelAssignable / CheckRoleLevelAgainst 的差异:
  224. // - **分配侧**(BindRoles / SetUserPerms)允许 HasFullPerms 的 caller 无条件分配任意 permsLevel
  225. // 的既有角色——因为 ADMIN / DEVELOPER 在本产品内已经是全权,多分配不会凭空拉高下属权级;
  226. // 真正的越权边界由既有角色的 permsLevel 事先决定(UpdateRole 的 L-R12-3 拒非超管提升)。
  227. // - **创建侧**却是弹药制造:product ADMIN 可以 CreateRole(PermsLevel=1) 造出"顶格角色",再
  228. // 走 BindRoles(userId=D, roleIds=[R_super]) 把下属 MEMBER/DEVELOPER 的 UD.MinPermsLevel
  229. // 顶到 1,绕过 GuardRoleLevelAssignable 的同级拦截——等价于横向提权。因此 CreateRole
  230. // 必须在 caller permsLevel 与 reqLevel 之间建立与 assignment 侧**不**对称的新约束。
  231. //
  232. // 规则:
  233. // - SuperAdmin:任意 reqLevel 放行(系统管理员不受产品内 perm 等级约束);
  234. // - 非超管的 HasFullPerms 调用者(product ADMIN):reqLevel 必须 >= 2,把"permsLevel=1"
  235. // 的顶格角色语义保留给 SuperAdmin 所生;同级(ADMIN 自己对应 sentinel 0)不可建;
  236. // - 无 FullPerms 的普通调用者:走 CheckRoleLevelAgainst 的标准路径(严格低于 snap.Level)。
  237. // 目前 RequireProductAdminFor 已经把 DEVELOPER/MEMBER 挡在 CreateRole 外,但把语义写全
  238. // 可以应对未来把"DEVELOPER 可建 sub-role"放开的扩展(到时候只需松开 RequireProductAdminFor)。
  239. func GuardCreateRolePermsLevel(ctx context.Context, svcCtx *svc.ServiceContext, caller *loaders.UserDetails, reqLevel int64) error {
  240. if caller == nil {
  241. return response.ErrUnauthorized("未登录")
  242. }
  243. if caller.IsSuperAdmin {
  244. return nil
  245. }
  246. snap, err := LoadCallerAssignableLevel(ctx, svcCtx, caller)
  247. if err != nil {
  248. return err
  249. }
  250. if snap.HasFullPerms {
  251. // Product ADMIN / DEVELOPER 的 snap 在 assignment 侧等价于顶格(sentinel 0),
  252. // 创建侧必须人为把 permsLevel=1 留给 SuperAdmin,避免 ADMIN 通过"建 R_super + BindRoles"
  253. // 间接把下属提到 ADMIN 线——横向提权攻击路径(见审计 H-R17-3 描述)。
  254. if reqLevel <= 1 {
  255. return response.ErrForbidden("非超级管理员不能创建权限级别为 1 的顶格角色")
  256. }
  257. return nil
  258. }
  259. return CheckRoleLevelAgainst(snap, reqLevel)
  260. }
  261. // loadFreshMinPermsLevel 统一的"授权决策点"接口:强一致读 DB,取 userId 在 productCode 下的
  262. // 最小 permsLevel。返回三元组方便调用方按业务语义决定"无角色"对应放行还是拒绝。
  263. // - level: 命中时的最小 permsLevel
  264. // - notFound: caller 在该产品下没有任何活跃角色(底层 sqlx.ErrNotFound)
  265. // - err: 其他 DB 错误,已包装成 500 fail-close;避免抖动被同化为"无角色 = 最低级"放行超权
  266. //
  267. // GuardRoleLevelAssignable(分配侧)与 checkPermLevel(管理侧)共享此 helper,保证两条 TOCTOU
  268. // 路径的 DB 口径完全对称(审计 M-3 封了"授角色"一半 / H-2 封"直接管人"另一半)。
  269. func loadFreshMinPermsLevel(ctx context.Context, svcCtx *svc.ServiceContext, userId int64, productCode string) (int64, bool, error) {
  270. level, err := svcCtx.SysRoleModel.FindMinPermsLevelByUserIdAndProductCode(ctx, userId, productCode)
  271. if err != nil {
  272. if errors.Is(err, sqlx.ErrNotFound) {
  273. return 0, true, nil
  274. }
  275. return 0, false, response.NewCodeError(500, "校验权限级别失败,请稍后重试")
  276. }
  277. return level, false, nil
  278. }
  279. // HasFullProductPerms 判断调用者是否拥有当前产品的全部权限(无需做 permsLevel 校验)。
  280. // SuperAdmin / ADMIN / DEVELOPER 均视为全权;loadPerms 对此三者走全权分支。
  281. // 所有依赖"调用者已拥有全权"的短路逻辑应复用此函数,变更只需改一处。
  282. func HasFullProductPerms(caller *loaders.UserDetails) bool {
  283. if caller == nil {
  284. return false
  285. }
  286. return caller.IsSuperAdmin ||
  287. caller.MemberType == consts.MemberTypeAdmin ||
  288. caller.MemberType == consts.MemberTypeDeveloper
  289. }
  290. // CheckAddMemberAccess 校验 caller 是否有权把 target 作为**新成员**拉进产品。与 CheckManageAccess 的
  291. // 差异:
  292. // 1. 不做 memberType / permsLevel 比对——AddMember 时 target 还不是成员,checkPermLevel 的
  293. // FindOneByProductCodeUserId 必定落空报 403,整个流程对产品 ADMIN 直接不可用;
  294. // 2. 对产品 ADMIN 也强制执行部门链校验(checkDeptHierarchy 的 ADMIN bypass 不生效),防止
  295. // ADMIN 从部门树外 / HR / 财务把人强拉入自己的产品(见审计 H-3);
  296. // 3. SuperAdmin 仍完全豁免,自改自加场景由上层业务规则另行屏蔽。
  297. //
  298. // 调用方应先走 RequireProductAdminFor 保证 caller 本身有"添加成员"的基线权限,再调这一步过部门链。
  299. func CheckAddMemberAccess(ctx context.Context, svcCtx *svc.ServiceContext, target *userModel.SysUser) error {
  300. caller := middleware.GetUserDetails(ctx)
  301. if caller == nil {
  302. return response.ErrUnauthorized("未登录")
  303. }
  304. if caller.IsSuperAdmin {
  305. return nil
  306. }
  307. if target == nil {
  308. return response.ErrBadRequest("缺少目标用户信息")
  309. }
  310. if caller.UserId == target.Id {
  311. return nil
  312. }
  313. // 不走 checkDeptHierarchy 的 ADMIN 分支:ADMIN bypass 设计本意是"product ADMIN 对产品内既有
  314. // 成员有全面管理权",但 AddMember 还没把 target 拉进产品,这里的 ADMIN bypass 会变成一个
  315. // "随手拉部门树外的人进来"的漏洞(审计 H-3)。
  316. if caller.DeptId == 0 || caller.DeptPath == "" {
  317. return response.ErrForbidden("您未归属任何部门,无权添加产品成员")
  318. }
  319. if target.DeptId == 0 {
  320. return response.ErrForbidden("目标用户未归属部门,仅超管可将其添加为成员")
  321. }
  322. targetDept, err := svcCtx.SysDeptModel.FindOne(ctx, target.DeptId)
  323. if err != nil {
  324. return response.ErrForbidden("无法校验目标用户部门")
  325. }
  326. if !strings.HasPrefix(targetDept.Path, caller.DeptPath) {
  327. return response.ErrForbidden("无权将其他部门的用户添加为产品成员")
  328. }
  329. return nil
  330. }
  331. // ValidateStatusChange 校验状态变更的合法性(不允许自改状态、不允许冻结超管)。
  332. // UpdateUser 和 UpdateUserStatus 共用此函数以确保校验逻辑一致。
  333. //
  334. // 返回校验通过时对应的目标用户对象,方便调用方透传给 CheckManageAccess 的 WithPrefetchedTarget
  335. // 选项和后续业务使用,避免同一请求内重复 FindOne(见审计 M-5)。
  336. func ValidateStatusChange(ctx context.Context, svcCtx *svc.ServiceContext, callerId, targetUserId int64) (*userModel.SysUser, error) {
  337. if callerId == targetUserId {
  338. return nil, response.ErrBadRequest("不能修改自己的状态")
  339. }
  340. target, err := svcCtx.SysUserModel.FindOne(ctx, targetUserId)
  341. if err != nil {
  342. return nil, response.ErrNotFound("用户不存在")
  343. }
  344. if target.IsSuperAdmin == consts.IsSuperAdminYes {
  345. return nil, response.ErrForbidden("不能修改超级管理员的状态")
  346. }
  347. return target, nil
  348. }
  349. func checkDeptHierarchy(ctx context.Context, svcCtx *svc.ServiceContext, caller *loaders.UserDetails, targetUserId int64, prefetchedTarget *userModel.SysUser) error {
  350. if caller.MemberType == consts.MemberTypeAdmin {
  351. return nil
  352. }
  353. // TODO(L-3 / L-6 / L-7): H-4 落地之后,新建 MEMBER/DEVELOPER 不会再出现 DeptId=0;迁移/老数据
  354. // 里仍可能存在 "MemberType!=ADMIN 且 DeptId=0" 的幽灵账号。
  355. // - 管理自己的路径:已经在 CheckManageAccess 顶部的 `caller.UserId == targetUserId` 短路
  356. // 里放行(L-7),这里不再重复兜底;
  357. // - 管理其他用户的路径:幽灵账号必须由运维一次性 data fix 归入默认部门(审计给的示例 SQL:
  358. // UPDATE sys_user SET deptId = <DEFAULT_NORMAL_DEPT_ID>
  359. // WHERE deptId = 0 AND isSuperAdmin = 0 AND (userId NOT IN SysProductMember OR ...);
  360. // 并同步 UserDetailsLoader.CleanByUserIds 批量刷缓存),本层维持 fail-close 403,
  361. // 避免"没部门 → 默认放行"被用作绕过部门边界的旁路。若未来确有业务需要放宽,记得连带收紧
  362. // checkPermLevel,不要把"部门校验绕过"默默扩大成"部门+级别都绕过"。
  363. //
  364. // TC-0993:两条分叉(DeptId==0 与 DeptPath=="")对运维都是"幽灵账号 → 需要数据迁移"
  365. // 的同一类信号,合一为单一文案 "您未归属任何部门,无权管理其他用户",便于前端按固定
  366. // 关键字触发迁移工单。原先 DeptPath=="" 的 "部门信息异常" 虽更"精确"(deptId 存在但
  367. // dept 行丢失),但对业务侧而言处置动作完全相同,拆两条只是增加了运维分类负担。
  368. if caller.DeptId == 0 || caller.DeptPath == "" {
  369. return response.ErrForbidden("您未归属任何部门,无权管理其他用户")
  370. }
  371. target := prefetchedTarget
  372. if target == nil {
  373. t, err := svcCtx.SysUserModel.FindOne(ctx, targetUserId)
  374. if err != nil {
  375. return response.ErrNotFound("目标用户不存在")
  376. }
  377. target = t
  378. }
  379. if target.DeptId == 0 {
  380. return response.ErrForbidden("目标用户未归属部门,仅超管或产品管理员可管理")
  381. }
  382. targetDept, err := svcCtx.SysDeptModel.FindOne(ctx, target.DeptId)
  383. if err != nil {
  384. return response.ErrForbidden("无权操作")
  385. }
  386. if !strings.HasPrefix(targetDept.Path, caller.DeptPath) {
  387. return response.ErrForbidden("无权管理其他部门的用户")
  388. }
  389. return nil
  390. }
  391. func checkPermLevel(ctx context.Context, svcCtx *svc.ServiceContext, caller *loaders.UserDetails, targetUserId int64, productCode string, memberSink **memberModel.SysProductMember) error {
  392. if productCode == "" {
  393. return response.ErrBadRequest("缺少产品上下文,无法进行权限级别判定")
  394. }
  395. targetMember, err := svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(ctx, productCode, targetUserId)
  396. if err != nil {
  397. return response.ErrForbidden("目标用户不是当前产品的成员,无法执行管理操作")
  398. }
  399. // 把 targetMember 写回调用方的 sink(若请求了),避免调用方再 FindOneByProductCodeUserId
  400. // 一次做 status 或 memberType 判定(审计 M-R10-5)。
  401. if memberSink != nil {
  402. *memberSink = targetMember
  403. }
  404. targetMemberType := targetMember.MemberType
  405. callerPri := memberTypePriority(caller.MemberType)
  406. targetPri := memberTypePriority(targetMemberType)
  407. if callerPri > targetPri {
  408. return response.ErrForbidden("无权管理权限级别高于您的用户")
  409. }
  410. if callerPri < targetPri {
  411. return nil
  412. }
  413. // memberType 相同,比较 permsLevel
  414. targetLevel, err := svcCtx.SysRoleModel.FindMinPermsLevelByUserIdAndProductCode(ctx, targetUserId, productCode)
  415. if err != nil {
  416. // 区分"无角色 → 等价最低等级"与"DB 抖动 → 未知":只有 ErrNotFound 语义的场景才允许
  417. // 降级为 MaxInt64 放行管辖;其余错误一律视作不确定,fail-close 返回 500,避免 DB 抖动
  418. // 被同化成"目标无角色"造成越权放行(见审计 L-4)。
  419. if !errors.Is(err, sqlx.ErrNotFound) {
  420. return response.NewCodeError(500, "校验权限级别失败,请稍后重试")
  421. }
  422. targetLevel = math.MaxInt64
  423. }
  424. // 审计 H-2:caller.MinPermsLevel 来自 UserDetailsLoader 5 分钟 TTL 缓存;超管刚把 caller 降级
  425. // 时若 Clean 因 Redis 抖动失败,缓存里的旧级别会让降级 admin 继续管辖本应够不到的目标。
  426. // 与 GuardRoleLevelAssignable 对称:决策点一律走 DB 强一致复核(loadFreshMinPermsLevel),
  427. // 把 TOCTOU 窗口从 TTL 级压到单次查询级。caller 当前无产品角色时等同最低级,对同级管辖拒绝。
  428. callerLevel, callerNoRole, err := loadFreshMinPermsLevel(ctx, svcCtx, caller.UserId, productCode)
  429. if err != nil {
  430. return err
  431. }
  432. if callerNoRole || callerLevel >= targetLevel {
  433. return response.ErrForbidden("无权管理权限级别高于或等于您的用户")
  434. }
  435. return nil
  436. }