S8ExceptionActionRoleService.cs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. using Admin.NET.Core;
  2. using Admin.NET.Plugin.AiDOP.Const.S8;
  3. using Admin.NET.Plugin.AiDOP.Entity.S8;
  4. using Admin.NET.Plugin.AiDOP.Infrastructure;
  5. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  6. using Microsoft.Extensions.Logging;
  7. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  8. /// <summary>权限矩阵一行:一个动作 + 它当前授权的角色。</summary>
  9. public sealed class S8ActionRoleRowDto
  10. {
  11. public string ActionCode { get; set; } = string.Empty;
  12. public string DisplayName { get; set; } = string.Empty;
  13. public string ApiHint { get; set; } = string.Empty;
  14. public int OrderNo { get; set; }
  15. public List<long> RoleIds { get; set; } = new();
  16. /// <summary>未配置任何角色 —— 该动作在本租户对<b>所有非超管账号</b>关闭。前端必须显式提示。</summary>
  17. public bool NotConfigured => RoleIds.Count == 0;
  18. }
  19. /// <summary>可授权角色选项。</summary>
  20. public sealed class S8AssignableRoleDto
  21. {
  22. public long Id { get; set; }
  23. public string Code { get; set; } = string.Empty;
  24. public string Name { get; set; } = string.Empty;
  25. public int UserCount { get; set; }
  26. }
  27. /// <summary>
  28. /// S8-ACTION-PERMISSION-1:异常动作权限矩阵的读写服务 + 启动期默认供给。
  29. ///
  30. /// <para><b>默认值从哪来</b>:从<b>本租户既有的</b><c>SysRole → SysRoleMenu → SysMenu.Permission</c>
  31. /// 授权语义推导 —— 原来在这个租户里谁能做这件事,迁移后还是谁能做。
  32. /// 刻意不拍一套新默认:那会在管理员毫无察觉的情况下改变现网的可操作范围。</para>
  33. ///
  34. /// <para><b>跨租户角色一律不带入</b>。真库实测 <c>UATAdminA</c> 通过别家租户的
  35. /// <c>ROLE_S6_IPQC_SUPERVISOR</c> 拿到了 <c>s8:exception:assign</c>,
  36. /// 而它自己的租户里没有任何角色有这个能力。推导时按 <c>SysRole.TenantId == 目标租户</c>
  37. /// 过滤,所以这类越权<b>不会被固化进新表</b> —— 迁移之后该账号会失去转派能力,
  38. /// 这是修复的预期结果,不是回归。</para>
  39. ///
  40. /// <para><b>推导不出角色的动作保持空</b>(fail closed),由管理员在页面上显式配置。
  41. /// 不做「空就给个兜底角色」——那等于把刚修掉的越权换个形式放回来。</para>
  42. /// </summary>
  43. public class S8ExceptionActionRoleService : ITransient
  44. {
  45. private readonly SqlSugarRepository<AdoS8ExceptionActionRole> _rep;
  46. private readonly IS8TenantRoleResolver _roleResolver;
  47. private readonly UserManager _userManager;
  48. private readonly ILogger<S8ExceptionActionRoleService> _logger;
  49. public S8ExceptionActionRoleService(
  50. SqlSugarRepository<AdoS8ExceptionActionRole> rep,
  51. IS8TenantRoleResolver roleResolver,
  52. UserManager userManager,
  53. ILogger<S8ExceptionActionRoleService> logger)
  54. {
  55. _rep = rep;
  56. _roleResolver = roleResolver;
  57. _userManager = userManager;
  58. _logger = logger;
  59. }
  60. // ────────────────────────────── 读 ──────────────────────────────
  61. /// <summary>权限矩阵:目录里的<b>每个</b>动作都出一行,没配角色的也要出现(前端要能看见"未配置")。</summary>
  62. public async Task<List<S8ActionRoleRowDto>> GetMatrixAsync(S8TrustedScope scope)
  63. {
  64. var rows = await _rep.AsQueryable().ClearFilter()
  65. .Where(x => x.TenantId == scope.TenantId)
  66. .Select(x => new { x.ActionCode, x.RoleId })
  67. .ToListAsync();
  68. var byAction = rows
  69. .GroupBy(x => x.ActionCode, StringComparer.OrdinalIgnoreCase)
  70. .ToDictionary(g => g.Key, g => g.Select(x => x.RoleId).Distinct().ToList(), StringComparer.OrdinalIgnoreCase);
  71. return S8ExceptionActionCatalog.All
  72. .OrderBy(d => d.OrderNo)
  73. .Select(d => new S8ActionRoleRowDto
  74. {
  75. ActionCode = d.Code,
  76. DisplayName = d.DisplayName,
  77. ApiHint = d.ApiHint,
  78. OrderNo = d.OrderNo,
  79. RoleIds = byAction.GetValueOrDefault(d.Code) ?? new List<long>()
  80. })
  81. .ToList();
  82. }
  83. /// <summary>
  84. /// 可授权角色列表。
  85. ///
  86. /// <para><b>不机械返回全部 SysRole</b>:本租户角色动辄十几个,其中大量与 S8 无关
  87. /// (报表、看板、其它模块)。把它们全塞进权限矩阵会让管理员在几十个复选框里找
  88. /// 那三四个真正相关的,反而更容易配错。判据是<b>该角色在本租户里已经承担 S8 或
  89. /// 业务处理职责</b>:持有任一 <c>s8:*</c> 能力,或属于 S8 认定的业务角色
  90. /// (<see cref="S8CanonicalRole.BusinessRoleCodes"/>)。</para>
  91. ///
  92. /// <para>已被本表授权过的角色<b>无条件包含</b>,否则管理员会看到一条自己无法取消的授权。</para>
  93. /// </summary>
  94. public async Task<List<S8AssignableRoleDto>> GetAssignableRolesAsync(S8TrustedScope scope)
  95. {
  96. var tenantRoles = await _rep.Context.Queryable<SysRole>().ClearFilter()
  97. .Where(r => r.TenantId == scope.TenantId)
  98. .Select(r => new { r.Id, r.Code, r.Name })
  99. .ToListAsync();
  100. if (tenantRoles.Count == 0) return new List<S8AssignableRoleDto>();
  101. var roleIds = tenantRoles.Select(r => r.Id).ToList();
  102. // 已持有任一 s8:* 能力的角色
  103. var s8CapableRoleIds = (await _rep.Context.Queryable<SysRoleMenu>().ClearFilter()
  104. .InnerJoin<SysMenu>((rm, m) => rm.MenuId == m.Id)
  105. .Where((rm, m) => roleIds.Contains(rm.RoleId)
  106. && m.Type == MenuTypeEnum.Btn
  107. && m.Permission != null
  108. && m.Permission.StartsWith("s8:"))
  109. .Select((rm, m) => rm.RoleId)
  110. .Distinct()
  111. .ToListAsync()).ToHashSet();
  112. // 已被本表授权过的角色
  113. var alreadyGranted = (await _rep.AsQueryable().ClearFilter()
  114. .Where(x => x.TenantId == scope.TenantId)
  115. .Select(x => x.RoleId)
  116. .ToListAsync()).ToHashSet();
  117. var businessCodes = S8CanonicalRole.BusinessRoleCodes.ToHashSet(StringComparer.OrdinalIgnoreCase);
  118. var candidates = tenantRoles
  119. .Where(r => s8CapableRoleIds.Contains(r.Id)
  120. || alreadyGranted.Contains(r.Id)
  121. || businessCodes.Contains(r.Code ?? string.Empty))
  122. .ToList();
  123. var memberCounts = (await _rep.Context.Queryable<SysUserRole>().ClearFilter()
  124. .Where(ur => candidates.Select(c => c.Id).Contains(ur.RoleId))
  125. .Select(ur => new { ur.RoleId, ur.UserId })
  126. .ToListAsync())
  127. .GroupBy(x => x.RoleId)
  128. .ToDictionary(g => g.Key, g => g.Select(x => x.UserId).Distinct().Count());
  129. return candidates
  130. .Select(r => new S8AssignableRoleDto
  131. {
  132. Id = r.Id,
  133. Code = r.Code ?? string.Empty,
  134. Name = string.IsNullOrWhiteSpace(r.Name) ? (r.Code ?? string.Empty) : r.Name,
  135. UserCount = memberCounts.GetValueOrDefault(r.Id)
  136. })
  137. .OrderBy(r => r.Code, StringComparer.Ordinal)
  138. .ToList();
  139. }
  140. // ────────────────────────────── 写 ──────────────────────────────
  141. /// <summary>
  142. /// 覆盖某个动作的授权角色(全量替换)。
  143. /// 角色必须属于当前租户,否则拒绝 —— 这正是本批要堵的那个洞,不能从写入口再漏回去。
  144. /// </summary>
  145. public async Task SetActionRolesAsync(S8TrustedScope scope, string actionCode, IEnumerable<long>? roleIds)
  146. {
  147. if (!S8ExceptionActionCatalog.IsKnown(actionCode))
  148. throw new S8BizException($"未知动作码:{actionCode}");
  149. var wanted = (roleIds ?? Enumerable.Empty<long>()).Where(x => x > 0).Distinct().ToList();
  150. if (wanted.Count > 0)
  151. {
  152. var validIds = (await _rep.Context.Queryable<SysRole>().ClearFilter()
  153. .Where(r => r.TenantId == scope.TenantId && wanted.Contains(r.Id))
  154. .Select(r => r.Id)
  155. .ToListAsync()).ToHashSet();
  156. var invalid = wanted.Where(id => !validIds.Contains(id)).ToList();
  157. if (invalid.Count > 0)
  158. // 与人员校验同口径:跨租户与不存在共用同一文案,不泄漏他租户角色是否存在。
  159. throw new S8BizException("所选角色不存在或不属于当前租户");
  160. }
  161. var existing = await _rep.AsQueryable().ClearFilter()
  162. .Where(x => x.TenantId == scope.TenantId && x.ActionCode == actionCode)
  163. .ToListAsync();
  164. var existingIds = existing.Select(x => x.RoleId).ToHashSet();
  165. var toRemove = existing.Where(x => !wanted.Contains(x.RoleId)).Select(x => x.Id).ToList();
  166. var toAdd = wanted.Where(id => !existingIds.Contains(id)).ToList();
  167. if (toRemove.Count > 0)
  168. await _rep.AsDeleteable().Where(x => toRemove.Contains(x.Id)).ExecuteCommandAsync();
  169. if (toAdd.Count > 0)
  170. {
  171. var now = DateTime.Now;
  172. await _rep.AsInsertable(toAdd.Select(id => new AdoS8ExceptionActionRole
  173. {
  174. TenantId = scope.TenantId,
  175. ActionCode = actionCode,
  176. RoleId = id,
  177. CreatedAt = now,
  178. CreatedBy = _userManager.Account
  179. }).ToList()).ExecuteCommandAsync();
  180. }
  181. _logger.LogInformation(
  182. "s8_action_role_updated tenant={Tenant} action={Action} added={Added} removed={Removed} total={Total}",
  183. scope.TenantId, actionCode, toAdd.Count, toRemove.Count, wanted.Count);
  184. }
  185. /// <summary>当前登录账号在本租户被允许的动作码(前端按钮可见性)。</summary>
  186. public Task<IReadOnlySet<string>> GetMyAllowedActionsAsync(S8TrustedScope scope, IS8ExceptionActionAuthorizer authorizer) =>
  187. authorizer.GetAllowedActionsAsync(scope.TenantId, _userManager.UserId);
  188. }