using Admin.NET.Core; using Admin.NET.Plugin.AiDOP.Const.S8; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; using Microsoft.Extensions.Logging; namespace Admin.NET.Plugin.AiDOP.Service.S8; /// 权限矩阵一行:一个动作 + 它当前授权的角色。 public sealed class S8ActionRoleRowDto { public string ActionCode { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty; public string ApiHint { get; set; } = string.Empty; public int OrderNo { get; set; } public List RoleIds { get; set; } = new(); /// 未配置任何角色 —— 该动作在本租户对所有非超管账号关闭。前端必须显式提示。 public bool NotConfigured => RoleIds.Count == 0; } /// 可授权角色选项。 public sealed class S8AssignableRoleDto { public long Id { get; set; } public string Code { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public int UserCount { get; set; } } /// /// S8-ACTION-PERMISSION-1:异常动作权限矩阵的读写服务 + 启动期默认供给。 /// /// 默认值从哪来:从本租户既有的SysRole → SysRoleMenu → SysMenu.Permission /// 授权语义推导 —— 原来在这个租户里谁能做这件事,迁移后还是谁能做。 /// 刻意不拍一套新默认:那会在管理员毫无察觉的情况下改变现网的可操作范围。 /// /// 跨租户角色一律不带入。真库实测 UATAdminA 通过别家租户的 /// ROLE_S6_IPQC_SUPERVISOR 拿到了 s8:exception:assign, /// 而它自己的租户里没有任何角色有这个能力。推导时按 SysRole.TenantId == 目标租户 /// 过滤,所以这类越权不会被固化进新表 —— 迁移之后该账号会失去转派能力, /// 这是修复的预期结果,不是回归。 /// /// 推导不出角色的动作保持空(fail closed),由管理员在页面上显式配置。 /// 不做「空就给个兜底角色」——那等于把刚修掉的越权换个形式放回来。 /// public class S8ExceptionActionRoleService : ITransient { private readonly SqlSugarRepository _rep; private readonly IS8TenantRoleResolver _roleResolver; private readonly UserManager _userManager; private readonly ILogger _logger; public S8ExceptionActionRoleService( SqlSugarRepository rep, IS8TenantRoleResolver roleResolver, UserManager userManager, ILogger logger) { _rep = rep; _roleResolver = roleResolver; _userManager = userManager; _logger = logger; } // ────────────────────────────── 读 ────────────────────────────── /// 权限矩阵:目录里的每个动作都出一行,没配角色的也要出现(前端要能看见"未配置")。 public async Task> GetMatrixAsync(S8TrustedScope scope) { var rows = await _rep.AsQueryable().ClearFilter() .Where(x => x.TenantId == scope.TenantId) .Select(x => new { x.ActionCode, x.RoleId }) .ToListAsync(); var byAction = rows .GroupBy(x => x.ActionCode, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.Select(x => x.RoleId).Distinct().ToList(), StringComparer.OrdinalIgnoreCase); return S8ExceptionActionCatalog.All .OrderBy(d => d.OrderNo) .Select(d => new S8ActionRoleRowDto { ActionCode = d.Code, DisplayName = d.DisplayName, ApiHint = d.ApiHint, OrderNo = d.OrderNo, RoleIds = byAction.GetValueOrDefault(d.Code) ?? new List() }) .ToList(); } /// /// 可授权角色列表。 /// /// 不机械返回全部 SysRole:本租户角色动辄十几个,其中大量与 S8 无关 /// (报表、看板、其它模块)。把它们全塞进权限矩阵会让管理员在几十个复选框里找 /// 那三四个真正相关的,反而更容易配错。判据是该角色在本租户里已经承担 S8 或 /// 业务处理职责:持有任一 s8:* 能力,或属于 S8 认定的业务角色 /// ()。 /// /// 已被本表授权过的角色无条件包含,否则管理员会看到一条自己无法取消的授权。 /// public async Task> GetAssignableRolesAsync(S8TrustedScope scope) { var tenantRoles = await _rep.Context.Queryable().ClearFilter() .Where(r => r.TenantId == scope.TenantId) .Select(r => new { r.Id, r.Code, r.Name }) .ToListAsync(); if (tenantRoles.Count == 0) return new List(); var roleIds = tenantRoles.Select(r => r.Id).ToList(); // 已持有任一 s8:* 能力的角色 var s8CapableRoleIds = (await _rep.Context.Queryable().ClearFilter() .InnerJoin((rm, m) => rm.MenuId == m.Id) .Where((rm, m) => roleIds.Contains(rm.RoleId) && m.Type == MenuTypeEnum.Btn && m.Permission != null && m.Permission.StartsWith("s8:")) .Select((rm, m) => rm.RoleId) .Distinct() .ToListAsync()).ToHashSet(); // 已被本表授权过的角色 var alreadyGranted = (await _rep.AsQueryable().ClearFilter() .Where(x => x.TenantId == scope.TenantId) .Select(x => x.RoleId) .ToListAsync()).ToHashSet(); var businessCodes = S8CanonicalRole.BusinessRoleCodes.ToHashSet(StringComparer.OrdinalIgnoreCase); var candidates = tenantRoles .Where(r => s8CapableRoleIds.Contains(r.Id) || alreadyGranted.Contains(r.Id) || businessCodes.Contains(r.Code ?? string.Empty)) .ToList(); var memberCounts = (await _rep.Context.Queryable().ClearFilter() .Where(ur => candidates.Select(c => c.Id).Contains(ur.RoleId)) .Select(ur => new { ur.RoleId, ur.UserId }) .ToListAsync()) .GroupBy(x => x.RoleId) .ToDictionary(g => g.Key, g => g.Select(x => x.UserId).Distinct().Count()); return candidates .Select(r => new S8AssignableRoleDto { Id = r.Id, Code = r.Code ?? string.Empty, Name = string.IsNullOrWhiteSpace(r.Name) ? (r.Code ?? string.Empty) : r.Name, UserCount = memberCounts.GetValueOrDefault(r.Id) }) .OrderBy(r => r.Code, StringComparer.Ordinal) .ToList(); } // ────────────────────────────── 写 ────────────────────────────── /// /// 覆盖某个动作的授权角色(全量替换)。 /// 角色必须属于当前租户,否则拒绝 —— 这正是本批要堵的那个洞,不能从写入口再漏回去。 /// public async Task SetActionRolesAsync(S8TrustedScope scope, string actionCode, IEnumerable? roleIds) { if (!S8ExceptionActionCatalog.IsKnown(actionCode)) throw new S8BizException($"未知动作码:{actionCode}"); var wanted = (roleIds ?? Enumerable.Empty()).Where(x => x > 0).Distinct().ToList(); if (wanted.Count > 0) { var validIds = (await _rep.Context.Queryable().ClearFilter() .Where(r => r.TenantId == scope.TenantId && wanted.Contains(r.Id)) .Select(r => r.Id) .ToListAsync()).ToHashSet(); var invalid = wanted.Where(id => !validIds.Contains(id)).ToList(); if (invalid.Count > 0) // 与人员校验同口径:跨租户与不存在共用同一文案,不泄漏他租户角色是否存在。 throw new S8BizException("所选角色不存在或不属于当前租户"); } var existing = await _rep.AsQueryable().ClearFilter() .Where(x => x.TenantId == scope.TenantId && x.ActionCode == actionCode) .ToListAsync(); var existingIds = existing.Select(x => x.RoleId).ToHashSet(); var toRemove = existing.Where(x => !wanted.Contains(x.RoleId)).Select(x => x.Id).ToList(); var toAdd = wanted.Where(id => !existingIds.Contains(id)).ToList(); if (toRemove.Count > 0) await _rep.AsDeleteable().Where(x => toRemove.Contains(x.Id)).ExecuteCommandAsync(); if (toAdd.Count > 0) { var now = DateTime.Now; await _rep.AsInsertable(toAdd.Select(id => new AdoS8ExceptionActionRole { TenantId = scope.TenantId, ActionCode = actionCode, RoleId = id, CreatedAt = now, CreatedBy = _userManager.Account }).ToList()).ExecuteCommandAsync(); } _logger.LogInformation( "s8_action_role_updated tenant={Tenant} action={Action} added={Added} removed={Removed} total={Total}", scope.TenantId, actionCode, toAdd.Count, toRemove.Count, wanted.Count); } /// 当前登录账号在本租户被允许的动作码(前端按钮可见性)。 public Task> GetMyAllowedActionsAsync(S8TrustedScope scope, IS8ExceptionActionAuthorizer authorizer) => authorizer.GetAllowedActionsAsync(scope.TenantId, _userManager.UserId); }