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();
}
}