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