using Admin.NET.Core; using Admin.NET.Plugin.AiDOP.Const.S8; using Admin.NET.Plugin.AiDOP.Entity.S8; namespace Admin.NET.Plugin.AiDOP.Infrastructure.S8; /// 授权判定结果。拒绝时带原因码,便于前端与日志区分「没配」与「没权」。 public sealed record S8ActionAuthorizationResult(bool Allowed, string? ReasonCode, string? Message) { public static S8ActionAuthorizationResult Pass() => new(true, null, null); public static S8ActionAuthorizationResult Deny(string reason, string message) => new(false, reason, message); } /// 授权失败原因码。 public static class S8ActionDenyReason { /// 动作码不在目录里 —— 调用方拼错或用了已删除的动作。 public const string UnknownAction = "action_unknown"; /// 该动作在本租户下一个角色都没配 —— 未配置即禁止,不是「未配置即放行」。 public const string ActionNotConfigured = "action_not_configured"; /// 账号在本租户下的角色都不在该动作的授权名单里。 public const string RoleNotGranted = "action_role_not_granted"; } /// /// S8-ACTION-PERMISSION-1:异常单动作授权的唯一判定入口。 /// /// 禁止各 Service 自己散落 HasRole 判断。同一个问题一旦有两处实现, /// 迟早出现「Controller 放行、Service 又拦一次且判据不同」或反之 —— /// 本仓在 S8OperatorBindingService 上已经发生过一次同类事故 /// (List 用 ClearFilter + 可选谓词、Bind 用另一套口径,导致跨租户返回 500 行)。 /// /// 判据三段,缺一不可: /// ① 动作在目录内;② 该动作在本租户配了至少一个角色; /// ③ 账号在本租户持有其中任一角色。 /// /// fail closed 是硬要求:配置表为空不等于所有人拥有全部权限。 /// 这类"空即放行"的默认值是权限系统最典型的事故来源 —— 上线时表还没灌数据, /// 于是全员拥有全部动作,而且没有任何报错。 /// /// Factory 不参与,且不得加回来。Employee 不参与: /// S8 的人就是 SysUser,不存在「先查员工再查账号」。 /// public interface IS8ExceptionActionAuthorizer { /// 判定账号在租户内是否可执行该动作。不抛异常。 Task AuthorizeAsync(long tenantId, long userId, string actionCode); /// 批量取该账号在本租户被允许的全部动作码(供前端按钮可见性使用)。 Task> GetAllowedActionsAsync(long tenantId, long userId); } /// 正式实现。生产代码里只有这一份;接口只为测试留 seam。 public sealed class S8ExceptionActionAuthorizer : IS8ExceptionActionAuthorizer, ITransient { private readonly SqlSugarRepository _rep; private readonly IS8TenantRoleResolver _roleResolver; public S8ExceptionActionAuthorizer( SqlSugarRepository rep, IS8TenantRoleResolver roleResolver) { _rep = rep; _roleResolver = roleResolver; } public async Task AuthorizeAsync(long tenantId, long userId, string actionCode) { // ① 未知动作:拒绝。绝不因为「没听说过这个动作」就放行。 if (!S8ExceptionActionCatalog.IsKnown(actionCode)) return S8ActionAuthorizationResult.Deny(S8ActionDenyReason.UnknownAction, "没有权限执行该操作"); if (tenantId <= 0 || userId <= 0) return S8ActionAuthorizationResult.Deny(S8ActionDenyReason.RoleNotGranted, "没有权限执行该操作"); var grantedRoleIds = await GrantedRoleIdsAsync(tenantId, actionCode); // ② 该动作在本租户一个角色都没配 —— fail closed。 if (grantedRoleIds.Count == 0) return S8ActionAuthorizationResult.Deny( S8ActionDenyReason.ActionNotConfigured, "没有权限执行该操作"); // ③ 账号在本租户持有的角色(跨租户角色在这里就已经被排除,见 S8TenantRoleResolver)。 var userRoleIds = await _roleResolver.GetRoleIdsAsync(tenantId, userId); if (userRoleIds.Count == 0 || !userRoleIds.Any(grantedRoleIds.Contains)) return S8ActionAuthorizationResult.Deny( S8ActionDenyReason.RoleNotGranted, "没有权限执行该操作"); return S8ActionAuthorizationResult.Pass(); } public async Task> GetAllowedActionsAsync(long tenantId, long userId) { var allowed = new HashSet(StringComparer.OrdinalIgnoreCase); if (tenantId <= 0 || userId <= 0) return allowed; var userRoleIds = await _roleResolver.GetRoleIdsAsync(tenantId, userId); if (userRoleIds.Count == 0) return allowed; var rows = await _rep.AsQueryable().ClearFilter() .Where(x => x.TenantId == tenantId && userRoleIds.Contains(x.RoleId)) .Select(x => x.ActionCode) .ToListAsync(); foreach (var code in rows.Where(S8ExceptionActionCatalog.IsKnown)) allowed.Add(code); return allowed; } private async Task> GrantedRoleIdsAsync(long tenantId, string actionCode) { // ClearFilter:边界由本查询显式声明(tenant_id == 传入租户)。 var ids = await _rep.AsQueryable().ClearFilter() .Where(x => x.TenantId == tenantId && x.ActionCode == actionCode) .Select(x => x.RoleId) .ToListAsync(); return ids.ToHashSet(); } }