| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217 |
- 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;
- /// <summary>权限矩阵一行:一个动作 + 它当前授权的角色。</summary>
- 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<long> RoleIds { get; set; } = new();
- /// <summary>未配置任何角色 —— 该动作在本租户对<b>所有非超管账号</b>关闭。前端必须显式提示。</summary>
- public bool NotConfigured => RoleIds.Count == 0;
- }
- /// <summary>可授权角色选项。</summary>
- 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; }
- }
- /// <summary>
- /// S8-ACTION-PERMISSION-1:异常动作权限矩阵的读写服务 + 启动期默认供给。
- ///
- /// <para><b>默认值从哪来</b>:从<b>本租户既有的</b><c>SysRole → SysRoleMenu → SysMenu.Permission</c>
- /// 授权语义推导 —— 原来在这个租户里谁能做这件事,迁移后还是谁能做。
- /// 刻意不拍一套新默认:那会在管理员毫无察觉的情况下改变现网的可操作范围。</para>
- ///
- /// <para><b>跨租户角色一律不带入</b>。真库实测 <c>UATAdminA</c> 通过别家租户的
- /// <c>ROLE_S6_IPQC_SUPERVISOR</c> 拿到了 <c>s8:exception:assign</c>,
- /// 而它自己的租户里没有任何角色有这个能力。推导时按 <c>SysRole.TenantId == 目标租户</c>
- /// 过滤,所以这类越权<b>不会被固化进新表</b> —— 迁移之后该账号会失去转派能力,
- /// 这是修复的预期结果,不是回归。</para>
- ///
- /// <para><b>推导不出角色的动作保持空</b>(fail closed),由管理员在页面上显式配置。
- /// 不做「空就给个兜底角色」——那等于把刚修掉的越权换个形式放回来。</para>
- /// </summary>
- public class S8ExceptionActionRoleService : ITransient
- {
- private readonly SqlSugarRepository<AdoS8ExceptionActionRole> _rep;
- private readonly IS8TenantRoleResolver _roleResolver;
- private readonly UserManager _userManager;
- private readonly ILogger<S8ExceptionActionRoleService> _logger;
- public S8ExceptionActionRoleService(
- SqlSugarRepository<AdoS8ExceptionActionRole> rep,
- IS8TenantRoleResolver roleResolver,
- UserManager userManager,
- ILogger<S8ExceptionActionRoleService> logger)
- {
- _rep = rep;
- _roleResolver = roleResolver;
- _userManager = userManager;
- _logger = logger;
- }
- // ────────────────────────────── 读 ──────────────────────────────
- /// <summary>权限矩阵:目录里的<b>每个</b>动作都出一行,没配角色的也要出现(前端要能看见"未配置")。</summary>
- public async Task<List<S8ActionRoleRowDto>> 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<long>()
- })
- .ToList();
- }
- /// <summary>
- /// 可授权角色列表。
- ///
- /// <para><b>不机械返回全部 SysRole</b>:本租户角色动辄十几个,其中大量与 S8 无关
- /// (报表、看板、其它模块)。把它们全塞进权限矩阵会让管理员在几十个复选框里找
- /// 那三四个真正相关的,反而更容易配错。判据是<b>该角色在本租户里已经承担 S8 或
- /// 业务处理职责</b>:持有任一 <c>s8:*</c> 能力,或属于 S8 认定的业务角色
- /// (<see cref="S8CanonicalRole.BusinessRoleCodes"/>)。</para>
- ///
- /// <para>已被本表授权过的角色<b>无条件包含</b>,否则管理员会看到一条自己无法取消的授权。</para>
- /// </summary>
- public async Task<List<S8AssignableRoleDto>> GetAssignableRolesAsync(S8TrustedScope scope)
- {
- var tenantRoles = await _rep.Context.Queryable<SysRole>().ClearFilter()
- .Where(r => r.TenantId == scope.TenantId)
- .Select(r => new { r.Id, r.Code, r.Name })
- .ToListAsync();
- if (tenantRoles.Count == 0) return new List<S8AssignableRoleDto>();
- var roleIds = tenantRoles.Select(r => r.Id).ToList();
- // 已持有任一 s8:* 能力的角色
- var s8CapableRoleIds = (await _rep.Context.Queryable<SysRoleMenu>().ClearFilter()
- .InnerJoin<SysMenu>((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<SysUserRole>().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();
- }
- // ────────────────────────────── 写 ──────────────────────────────
- /// <summary>
- /// 覆盖某个动作的授权角色(全量替换)。
- /// 角色必须属于当前租户,否则拒绝 —— 这正是本批要堵的那个洞,不能从写入口再漏回去。
- /// </summary>
- public async Task SetActionRolesAsync(S8TrustedScope scope, string actionCode, IEnumerable<long>? roleIds)
- {
- if (!S8ExceptionActionCatalog.IsKnown(actionCode))
- throw new S8BizException($"未知动作码:{actionCode}");
- var wanted = (roleIds ?? Enumerable.Empty<long>()).Where(x => x > 0).Distinct().ToList();
- if (wanted.Count > 0)
- {
- var validIds = (await _rep.Context.Queryable<SysRole>().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);
- }
- /// <summary>当前登录账号在本租户被允许的动作码(前端按钮可见性)。</summary>
- public Task<IReadOnlySet<string>> GetMyAllowedActionsAsync(S8TrustedScope scope, IS8ExceptionActionAuthorizer authorizer) =>
- authorizer.GetAllowedActionsAsync(scope.TenantId, _userManager.UserId);
- }
|