| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315 |
- using System.Reflection;
- using Admin.NET.Plugin.AiDOP.Const.S8;
- using Admin.NET.Plugin.AiDOP.Controllers.S8;
- using Admin.NET.Plugin.AiDOP.Entity.S8;
- using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
- using Xunit;
- namespace Admin.NET.Plugin.AiDOP.Tests.S8;
- /// <summary>
- /// S8-ACTION-PERMISSION-1:异常单动作授权模型。
- ///
- /// <para><b>本批修的是一个实测存在的跨租户权限泄漏。</b>平台的
- /// <c>SysMenuService.GetOwnBtnPermList()</c> 只对<b>菜单</b>做租户校验
- /// (<c>InnerJoin SysTenantMenu</c>),<b>对角色不做</b>;<c>SysUserRole</c> 也不要求
- /// 用户与角色同租户。真库取证:<c>UATAdminA</c>(租户 838257186181189)经
- /// <c>ROLE_S6_IPQC_SUPERVISOR</c>(<c>SysRole.TenantId = 1300000000001</c>)
- /// 拿到 <c>s8:exception:assign</c> 等能力,而它自己租户里没有任何角色授予这些能力。</para>
- ///
- /// <para><b>因此本文件的断言大量是反向的</b>:这类缺陷的特征是「加回一个谓词 / 少一个
- /// 租户过滤就静默放宽」,正向断言抓不到。</para>
- /// </summary>
- public class S8ExceptionActionPermissionTests
- {
- private const long TenantA = 838257186181189L;
- private const long TenantB = 838257212780613L;
- private static readonly string PluginRoot =
- Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
- /// <summary>只取可执行代码行:注释里为留档会复述旧写法,不应算违规。</summary>
- private static string CodeOnly(string relativePath)
- {
- var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
- Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
- return string.Join('\n', File.ReadAllLines(full)
- .Where(l =>
- {
- var t = l.TrimStart();
- return !t.StartsWith("///", StringComparison.Ordinal)
- && !t.StartsWith("//", StringComparison.Ordinal);
- }));
- }
- // ══════════════════════════════════════════════════════════════════════════════
- // 动作目录:只能有真实存在的动作
- // ══════════════════════════════════════════════════════════════════════════════
- /// <summary>
- /// 目录里的每个动作都必须标注它对应的真实接口。
- ///
- /// <para>顺带守住一条设计约束:<b>不得出现 <c>EXCEPTION_CLOSE</c></b>。
- /// S8 没有独立的「关闭」接口 —— 关闭是 <c>approve-verification</c> 通过后的状态机结果。
- /// 给它一个可配置权限位,管理员会配一个永远不被检查的开关。</para>
- /// </summary>
- [Fact]
- public void ActionCatalog_ContainsOnlyRealActions()
- {
- Assert.NotEmpty(S8ExceptionActionCatalog.All);
- foreach (var d in S8ExceptionActionCatalog.All)
- {
- Assert.False(string.IsNullOrWhiteSpace(d.Code));
- Assert.False(string.IsNullOrWhiteSpace(d.DisplayName));
- Assert.False(string.IsNullOrWhiteSpace(d.ApiHint));
- }
- Assert.DoesNotContain(S8ExceptionActionCatalog.All, d =>
- d.Code.Equals("EXCEPTION_CLOSE", StringComparison.OrdinalIgnoreCase));
- // 动作码唯一
- Assert.Equal(S8ExceptionActionCatalog.All.Count,
- S8ExceptionActionCatalog.All.Select(d => d.Code).Distinct(StringComparer.OrdinalIgnoreCase).Count());
- }
- /// <summary>
- /// 目录必须完整覆盖异常控制器上的每一个 Action —— 漏一个就意味着那个接口没有授权门。
- /// </summary>
- [Fact]
- public void EveryExceptionEndpoint_IsGuardedByActionAttribute()
- {
- foreach (var controller in new[] { typeof(AdoS8ExceptionsController), typeof(AdoS8ReportsController) })
- {
- var actions = controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)
- .Where(m => !m.IsSpecialName)
- .ToList();
- Assert.NotEmpty(actions);
- foreach (var a in actions)
- {
- var attr = a.GetCustomAttribute<S8ExceptionActionAttribute>();
- Assert.True(attr != null, $"{controller.Name}.{a.Name} 缺少 S8ExceptionAction 授权门");
- Assert.True(S8ExceptionActionCatalog.IsKnown(attr!.Code),
- $"{controller.Name}.{a.Name} 使用了目录外的动作码 {attr.Code}");
- // 旧门禁不得与新门禁并存 —— 同一问题两个 authority 迟早判据分叉。
- Assert.Null(a.GetCustomAttribute<S8PermissionAttribute>());
- }
- }
- }
- /// <summary>未知动作码在构造期就必须炸,而不是等运行时静默放行。</summary>
- [Fact]
- public void UnknownAction_RejectedAtConstruction()
- {
- Assert.Throws<ArgumentException>(() => new S8ExceptionActionAttribute("EXCEPTION_NOT_A_REAL_ACTION"));
- Assert.Throws<ArgumentException>(() => new S8ExceptionActionAttribute(""));
- Assert.False(S8ExceptionActionCatalog.IsKnown("EXCEPTION_NOT_A_REAL_ACTION"));
- Assert.False(S8ExceptionActionCatalog.IsKnown(null));
- }
- // ══════════════════════════════════════════════════════════════════════════════
- // 授权器判据
- // ══════════════════════════════════════════════════════════════════════════════
- /// <summary>
- /// T-unknown-action / T-fail-closed:
- /// 未知动作拒绝;<b>该动作一个角色都没配也必须拒绝</b>。
- ///
- /// <para>「配置表为空 = 所有人拥有全部权限」是权限系统最典型的事故 ——
- /// 上线时表还没灌数据,于是全员拥有全部动作,而且没有任何报错。</para>
- /// </summary>
- [Fact]
- public void Authorizer_FailsClosed_OnUnknownActionAndEmptyConfig()
- {
- var code = CodeOnly("Infrastructure/S8/S8ExceptionActionAuthorizer.cs");
- // 未知动作先判、直接拒
- Assert.Contains("if (!S8ExceptionActionCatalog.IsKnown(actionCode))", code);
- Assert.Contains(S8ActionDenyReason.UnknownAction, code);
- // 空配置 = 拒绝
- Assert.Contains("if (grantedRoleIds.Count == 0)", code);
- Assert.Contains(S8ActionDenyReason.ActionNotConfigured, code);
- // 不得出现任何「空即放行」的写法
- Assert.DoesNotContain("Count == 0) return S8ActionAuthorizationResult.Pass()", code);
- }
- /// <summary>
- /// T-cross-tenant-role-reject:账号持有的角色必须属于<b>当前租户</b>。
- ///
- /// <para>授权器不自己查用户角色,而是走 <see cref="IS8TenantRoleResolver"/> ——
- /// 跨租户角色在那一步就已经被排除,授权器这一侧不可能再漏。</para>
- /// </summary>
- [Fact]
- public void Authorizer_ResolvesRolesThroughTenantScopedResolver()
- {
- var code = CodeOnly("Infrastructure/S8/S8ExceptionActionAuthorizer.cs");
- Assert.Contains("_roleResolver.GetRoleIdsAsync(tenantId, userId)", code);
- // 不得绕过 resolver 自己拼 SysUserRole 查询
- Assert.DoesNotContain("Queryable<SysUserRole>", code);
- Assert.DoesNotContain("SysRoleMenu", code);
- }
- /// <summary>租户角色解析器本身必须带 <c>SysRole.TenantId == 传入租户</c>,且不含工厂谓词。</summary>
- [Fact]
- public void TenantRoleResolver_IsTenantScopedAndFactoryFree()
- {
- var code = CodeOnly("Infrastructure/S8/S8TenantRoleResolver.cs");
- Assert.Contains("r.TenantId == tenantId", code);
- Assert.DoesNotContain("FactoryId", code);
- Assert.DoesNotContain("FactoryRefId", code);
- Assert.DoesNotContain("EmployeeMaster", code);
- }
- /// <summary>
- /// T-factory-irrelevant / T-no-employee:
- /// 授权链上任何一环都不得出现工厂或员工。
- /// </summary>
- [Theory]
- [InlineData("Infrastructure/S8/S8ExceptionActionAuthorizer.cs")]
- [InlineData("Infrastructure/S8/S8TenantRoleResolver.cs")]
- [InlineData("Infrastructure/S8/S8ExceptionActionAttribute.cs")]
- [InlineData("Service/S8/S8ExceptionActionRoleService.cs")]
- [InlineData("Service/S8/S8ExceptionActionRoleProvisioningService.cs")]
- public void AuthorizationChain_HasNoFactoryOrEmployee(string relativePath)
- {
- var code = CodeOnly(relativePath);
- Assert.DoesNotContain("FactoryRefId", code);
- Assert.DoesNotContain("EmployeeMaster", code);
- Assert.DoesNotContain("AdoS0EmployeeMaster", code);
- }
- /// <summary>
- /// T-multiple-roles-union:多角色取并集 —— 持有任一被授权角色即通过,
- /// 而不是要求全部持有。
- /// </summary>
- [Fact]
- public void Authorizer_UnionsMultipleRoles()
- {
- var code = CodeOnly("Infrastructure/S8/S8ExceptionActionAuthorizer.cs");
- // Any(...) = 并集语义;若写成 All(...) 就变成"必须持有全部角色"
- Assert.Contains("userRoleIds.Any(grantedRoleIds.Contains)", code);
- Assert.DoesNotContain("userRoleIds.All(", code);
- }
- /// <summary>授权判定必须<b>只有一份实现</b>;各 Service 不得自己散落角色判断。</summary>
- [Fact]
- public void ActionAuthorization_HasSingleImplementation()
- {
- var impls = typeof(IS8ExceptionActionAuthorizer).Assembly.GetTypes()
- .Where(t => t is { IsAbstract: false, IsInterface: false }
- && typeof(IS8ExceptionActionAuthorizer).IsAssignableFrom(t))
- .ToList();
- Assert.Single(impls);
- Assert.Equal(typeof(S8ExceptionActionAuthorizer), impls[0]);
- var resolvers = typeof(IS8TenantRoleResolver).Assembly.GetTypes()
- .Where(t => t is { IsAbstract: false, IsInterface: false }
- && typeof(IS8TenantRoleResolver).IsAssignableFrom(t))
- .ToList();
- Assert.Single(resolvers);
- // 业务 Service 里不得再出现自己的角色/权限判断
- foreach (var f in new[]
- {
- "Service/S8/S8TaskFlowService.cs",
- "Service/S8/S8ExceptionService.cs",
- "Service/S8/S8ManualReportService.cs",
- })
- {
- var code = CodeOnly(f);
- Assert.DoesNotContain("SysRoleMenu", code);
- Assert.DoesNotContain("GetOwnBtnPermList", code);
- }
- }
- // ══════════════════════════════════════════════════════════════════════════════
- // 状态机仍然独立生效
- // ══════════════════════════════════════════════════════════════════════════════
- /// <summary>
- /// T-state-rule-still-enforced:权限通过<b>不等于</b>状态允许。
- /// 两道门必须各自独立 —— 权限只回答「这个角色能不能做这类事」,
- /// 状态回答「这张单现在能不能做」。
- /// </summary>
- [Fact]
- public void StateMachine_RemainsIndependentOfPermission()
- {
- var code = CodeOnly("Service/S8/S8TaskFlowService.cs");
- Assert.Contains("S8StatusRules.IsAllowedTransition", code);
- // 状态判断不得被改写成"有权限就跳过状态检查"
- Assert.DoesNotContain("IsAllowedTransition(e.Status, \"ASSIGNED\") ||", code);
- // 认领的原子谓词仍在(First Claim Wins 不因权限改造而失效)
- Assert.Contains("x.AssigneeUserId == null", code);
- Assert.Contains("该异常已被其他人员认领", code);
- }
- // ══════════════════════════════════════════════════════════════════════════════
- // 写入侧 & 供给
- // ══════════════════════════════════════════════════════════════════════════════
- /// <summary>写入侧必须校验角色属于当前租户,否则刚堵的洞会从配置页漏回来。</summary>
- [Fact]
- public void Write_RejectsCrossTenantRole()
- {
- var code = CodeOnly("Service/S8/S8ExceptionActionRoleService.cs");
- Assert.Contains("r.TenantId == scope.TenantId", code);
- Assert.Contains("所选角色不存在或不属于当前租户", code);
- }
- /// <summary>
- /// 供给只推导<b>本租户</b>角色,且推不出角色时留空(fail closed),不给兜底角色。
- /// </summary>
- [Fact]
- public void Provisioning_DerivesFromSameTenantRolesOnly()
- {
- var code = CodeOnly("Service/S8/S8ExceptionActionRoleProvisioningService.cs");
- Assert.Contains("pending.Contains(r.TenantId.Value)", code);
- Assert.Contains("if (roleIds.Count == 0) { result.UnconfiguredCount++; continue; }", code);
- // 只做首次供给:已有任意授权行的租户整体跳过,避免把管理员取消的授权悄悄加回来
- Assert.Contains("tenantsWithRows", code);
- }
- /// <summary>存储身份是 (租户, 动作, 角色),不含工厂;且只存 Action↔Role,不存 Action↔User。</summary>
- [Fact]
- public void Storage_IdentityIsTenantActionRole()
- {
- var t = typeof(AdoS8ExceptionActionRole);
- Assert.NotNull(t.GetProperty("TenantId"));
- Assert.NotNull(t.GetProperty("ActionCode"));
- Assert.NotNull(t.GetProperty("RoleId"));
- Assert.Null(t.GetProperty("FactoryId"));
- Assert.Null(t.GetProperty("UserId"));
- }
- /// <summary>迁移必须建唯一索引并断言"不存在跨租户角色授权"。</summary>
- [Fact]
- public void Migration_GuardsUniquenessAndCrossTenant()
- {
- var sql = File.ReadAllText(Path.GetFullPath(Path.Combine(
- PluginRoot, "../../Admin.NET.Web.Entry/UpdateScripts/1.0.497.sql")));
- Assert.Contains("uk_s8_action_role", sql);
- Assert.Contains("s8_497_BLOCKED_unique_index_missing", sql);
- Assert.Contains("s8_497_BLOCKED_cross_tenant_role_granted", sql);
- // 迁移前的跨租户授予必须留证,否则明天没人能区分"修复"与"回归"
- Assert.Contains("ado_s8_cross_tenant_grant_1_0_497", sql);
- // AutoVersionUpdate 对含该关键字的脚本一律拒绝执行(朴素子串匹配,注释也算)
- Assert.DoesNotContain("DELIMITER", sql);
- }
- /// <summary>配置面的旧门禁也必须补上租户交集 —— 同一个洞不能只堵一半。</summary>
- [Fact]
- public void ConfigPermissionGate_AlsoIntersectsTenantRoles()
- {
- var code = CodeOnly("Infrastructure/S8/S8PermissionAttribute.cs");
- Assert.Contains("IS8TenantRoleResolver", code);
- Assert.Contains("GetPermissionCodesAsync", code);
- // 交集只可能收窄;不得出现"任一满足即放行"的或逻辑
- Assert.Contains("if (tenantCodes.Contains(Code)) return;", code);
- }
- }
|