S8ExceptionActionAuthorizer.cs 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. using Admin.NET.Core;
  2. using Admin.NET.Plugin.AiDOP.Const.S8;
  3. using Admin.NET.Plugin.AiDOP.Entity.S8;
  4. namespace Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  5. /// <summary>授权判定结果。拒绝时带原因码,便于前端与日志区分「没配」与「没权」。</summary>
  6. public sealed record S8ActionAuthorizationResult(bool Allowed, string? ReasonCode, string? Message)
  7. {
  8. public static S8ActionAuthorizationResult Pass() => new(true, null, null);
  9. public static S8ActionAuthorizationResult Deny(string reason, string message) => new(false, reason, message);
  10. }
  11. /// <summary>授权失败原因码。</summary>
  12. public static class S8ActionDenyReason
  13. {
  14. /// <summary>动作码不在目录里 —— 调用方拼错或用了已删除的动作。</summary>
  15. public const string UnknownAction = "action_unknown";
  16. /// <summary>该动作在本租户下一个角色都没配 —— 未配置即禁止,不是「未配置即放行」。</summary>
  17. public const string ActionNotConfigured = "action_not_configured";
  18. /// <summary>账号在本租户下的角色都不在该动作的授权名单里。</summary>
  19. public const string RoleNotGranted = "action_role_not_granted";
  20. }
  21. /// <summary>
  22. /// S8-ACTION-PERMISSION-1:异常单动作授权的<b>唯一</b>判定入口。
  23. ///
  24. /// <para><b>禁止各 Service 自己散落 HasRole 判断</b>。同一个问题一旦有两处实现,
  25. /// 迟早出现「Controller 放行、Service 又拦一次且判据不同」或反之 ——
  26. /// 本仓在 <c>S8OperatorBindingService</c> 上已经发生过一次同类事故
  27. /// (List 用 ClearFilter + 可选谓词、Bind 用另一套口径,导致跨租户返回 500 行)。</para>
  28. ///
  29. /// <para><b>判据三段,缺一不可</b>:
  30. /// ① 动作在目录内;② 该动作在本租户配了至少一个角色;
  31. /// ③ 账号在<b>本租户</b>持有其中任一角色。</para>
  32. ///
  33. /// <para><b>fail closed 是硬要求</b>:配置表为空<b>不等于</b>所有人拥有全部权限。
  34. /// 这类"空即放行"的默认值是权限系统最典型的事故来源 —— 上线时表还没灌数据,
  35. /// 于是全员拥有全部动作,而且没有任何报错。</para>
  36. ///
  37. /// <para><b>Factory 不参与</b>,且不得加回来。<b>Employee 不参与</b>:
  38. /// S8 的人就是 <c>SysUser</c>,不存在「先查员工再查账号」。</para>
  39. /// </summary>
  40. public interface IS8ExceptionActionAuthorizer
  41. {
  42. /// <summary>判定账号在租户内是否可执行该动作。不抛异常。</summary>
  43. Task<S8ActionAuthorizationResult> AuthorizeAsync(long tenantId, long userId, string actionCode);
  44. /// <summary>批量取该账号在本租户被允许的全部动作码(供前端按钮可见性使用)。</summary>
  45. Task<IReadOnlySet<string>> GetAllowedActionsAsync(long tenantId, long userId);
  46. }
  47. /// <summary>正式实现。生产代码里只有这一份;接口只为测试留 seam。</summary>
  48. public sealed class S8ExceptionActionAuthorizer : IS8ExceptionActionAuthorizer, ITransient
  49. {
  50. private readonly SqlSugarRepository<AdoS8ExceptionActionRole> _rep;
  51. private readonly IS8TenantRoleResolver _roleResolver;
  52. public S8ExceptionActionAuthorizer(
  53. SqlSugarRepository<AdoS8ExceptionActionRole> rep,
  54. IS8TenantRoleResolver roleResolver)
  55. {
  56. _rep = rep;
  57. _roleResolver = roleResolver;
  58. }
  59. public async Task<S8ActionAuthorizationResult> AuthorizeAsync(long tenantId, long userId, string actionCode)
  60. {
  61. // ① 未知动作:拒绝。绝不因为「没听说过这个动作」就放行。
  62. if (!S8ExceptionActionCatalog.IsKnown(actionCode))
  63. return S8ActionAuthorizationResult.Deny(S8ActionDenyReason.UnknownAction, "没有权限执行该操作");
  64. if (tenantId <= 0 || userId <= 0)
  65. return S8ActionAuthorizationResult.Deny(S8ActionDenyReason.RoleNotGranted, "没有权限执行该操作");
  66. var grantedRoleIds = await GrantedRoleIdsAsync(tenantId, actionCode);
  67. // ② 该动作在本租户一个角色都没配 —— fail closed。
  68. if (grantedRoleIds.Count == 0)
  69. return S8ActionAuthorizationResult.Deny(
  70. S8ActionDenyReason.ActionNotConfigured, "没有权限执行该操作");
  71. // ③ 账号在本租户持有的角色(跨租户角色在这里就已经被排除,见 S8TenantRoleResolver)。
  72. var userRoleIds = await _roleResolver.GetRoleIdsAsync(tenantId, userId);
  73. if (userRoleIds.Count == 0 || !userRoleIds.Any(grantedRoleIds.Contains))
  74. return S8ActionAuthorizationResult.Deny(
  75. S8ActionDenyReason.RoleNotGranted, "没有权限执行该操作");
  76. return S8ActionAuthorizationResult.Pass();
  77. }
  78. public async Task<IReadOnlySet<string>> GetAllowedActionsAsync(long tenantId, long userId)
  79. {
  80. var allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  81. if (tenantId <= 0 || userId <= 0) return allowed;
  82. var userRoleIds = await _roleResolver.GetRoleIdsAsync(tenantId, userId);
  83. if (userRoleIds.Count == 0) return allowed;
  84. var rows = await _rep.AsQueryable().ClearFilter()
  85. .Where(x => x.TenantId == tenantId && userRoleIds.Contains(x.RoleId))
  86. .Select(x => x.ActionCode)
  87. .ToListAsync();
  88. foreach (var code in rows.Where(S8ExceptionActionCatalog.IsKnown))
  89. allowed.Add(code);
  90. return allowed;
  91. }
  92. private async Task<HashSet<long>> GrantedRoleIdsAsync(long tenantId, string actionCode)
  93. {
  94. // ClearFilter:边界由本查询显式声明(tenant_id == 传入租户)。
  95. var ids = await _rep.AsQueryable().ClearFilter()
  96. .Where(x => x.TenantId == tenantId && x.ActionCode == actionCode)
  97. .Select(x => x.RoleId)
  98. .ToListAsync();
  99. return ids.ToHashSet();
  100. }
  101. }