S8PermissionAttribute.cs 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. using Admin.NET.Core;
  2. using Admin.NET.Core.Service;
  3. using Admin.NET.Plugin.AiDOP.Const.S8;
  4. using Microsoft.AspNetCore.Http;
  5. using Microsoft.AspNetCore.Mvc;
  6. using Microsoft.AspNetCore.Mvc.Filters;
  7. using Microsoft.Extensions.DependencyInjection;
  8. namespace Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  9. /// <summary>
  10. /// S8-P0-4-API-AUTHORIZATION-1:S8 接口能力级授权门。
  11. ///
  12. /// <para><b>为什么不能直接复用平台的路由名匹配</b>(这是本方案的唯一设计前提,先读这段):
  13. /// <see cref="Admin.NET.Web.Core"/> 的 <c>JwtHandler.CheckAuthorizeAsync</c> 把
  14. /// <c>HttpContext.Request.Path</c> 逐字转成权限名(<c>/</c> → <c>:</c>)再与
  15. /// <c>SysMenu.Permission</c> 比对。平台自身接口全是 <c>/api/sysPos/delete</c> 这类
  16. /// <b>无路径参数</b>的动词式路由,于是 <c>sysPos:delete</c> 天然对得上。</para>
  17. ///
  18. /// <para>S8 是 RESTful 路由,两点结构性不兼容:</para>
  19. /// <list type="number">
  20. /// <item><b>路径参数</b>:<c>/api/aidop/s8/exceptions/1329909430019/claim</c> 会被转成
  21. /// <c>aidop:s8:exceptions:1329909430019:claim</c> —— 含实例 id,
  22. /// 任何静态权限码<b>永远</b>不可能相等;</item>
  23. /// <item><b>命名体系</b>:S8 权限码是能力语义(<c>s8:exception:claim</c>),
  24. /// 而非路由字面量(<c>aidop:s8:exceptions:…</c>)。</item>
  25. /// </list>
  26. ///
  27. /// <para>再加上 <c>JwtHandler</c> 末行 <c>allBtnPermList.TrueForAll(u =&gt; !routeName.Equals(u))</c>
  28. /// —— 未登记路由<b>默认放行</b>,于是全部 S8 接口实测为「登录即可调用」:
  29. /// 2026-09-02 以业务账号 <c>UATExceptionA</c> 实测 <c>PUT /config/watch-rules/{id}/params</c>
  30. /// 与 <c>POST /config/operator-bindings</c> 均直达业务校验返回 400(而非 403)。</para>
  31. ///
  32. /// <para><b>本类做什么</b>:只替换「路由名 → 权限码」这一步为<b>显式声明</b>,
  33. /// 其余全部复用平台既有设施 —— 权限仍存 <see cref="SysMenu"/>(Type=Btn).Permission,
  34. /// 仍经 <c>SysRoleMenu</c> 授予角色,仍由平台的
  35. /// <see cref="SysMenuService.GetOwnBtnPermList"/>(含缓存)取用户权限集。
  36. /// <b>不新造权限存储、不改 JwtHandler</b>(那是所有模块共用的平台代码,改它会外溢到 S0–S7)。</para>
  37. ///
  38. /// <para><b>失败语义</b>:未持有声明的权限码 → 直接 <c>403</c>,
  39. /// 在进入 Controller Action 与任何 Service 之前返回,DB 零写入。</para>
  40. ///
  41. /// <para><b>超管豁免</b>:与 <c>JwtHandler.CheckAuthorizeAsync</c> 完全同口径
  42. /// (<see cref="UserManager.SuperAdmin"/>),避免出现「平台放行、S8 拦截」的不一致。</para>
  43. /// </summary>
  44. [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
  45. public sealed class S8PermissionAttribute : Attribute, IAsyncAuthorizationFilter
  46. {
  47. /// <summary>本 Action 要求的能力码,必须取自 <see cref="S8PermissionCatalog"/> 常量。</summary>
  48. public string Code { get; }
  49. public S8PermissionAttribute(string code)
  50. {
  51. if (string.IsNullOrWhiteSpace(code))
  52. throw new ArgumentException("S8 权限码不能为空", nameof(code));
  53. Code = code;
  54. }
  55. public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
  56. {
  57. // 认证本身由平台管道负责;这里只判「已认证身份是否具备该能力」。
  58. // 未认证时交回平台的 401 语义,不在此重复判定。
  59. var user = context.HttpContext.User;
  60. if (user?.Identity?.IsAuthenticated != true) return;
  61. var services = context.HttpContext.RequestServices;
  62. // 超管豁免:与 JwtHandler 同口径。
  63. var userManager = services.GetService<UserManager>();
  64. if (userManager is { SuperAdmin: true }) return;
  65. var menuService = services.GetService<SysMenuService>();
  66. if (menuService == null)
  67. {
  68. // 拿不到平台权限服务时按拒绝处理(fail-closed)。
  69. // 绝不 fail-open —— 本模块的事故类型全部是「本不该放行却放行了」。
  70. context.Result = Forbid();
  71. return;
  72. }
  73. List<string> owned;
  74. try
  75. {
  76. owned = await menuService.GetOwnBtnPermList();
  77. }
  78. catch
  79. {
  80. context.Result = Forbid();
  81. return;
  82. }
  83. if (owned != null && owned.Contains(Code, StringComparer.OrdinalIgnoreCase)) return;
  84. context.Result = Forbid();
  85. }
  86. /// <summary>
  87. /// 统一 403 出参。文案不回显所需权限码与用户已有权限,避免把权限拓扑透给调用方。
  88. /// </summary>
  89. private static ObjectResult Forbid() => new(new { message = "没有权限执行该操作" })
  90. {
  91. StatusCode = StatusCodes.Status403Forbidden
  92. };
  93. }