|
|
@@ -0,0 +1,315 @@
|
|
|
+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);
|
|
|
+ }
|
|
|
+}
|