S8ExceptionActionPermissionTests.cs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. using System.Reflection;
  2. using Admin.NET.Plugin.AiDOP.Const.S8;
  3. using Admin.NET.Plugin.AiDOP.Controllers.S8;
  4. using Admin.NET.Plugin.AiDOP.Entity.S8;
  5. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  6. using Xunit;
  7. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  8. /// <summary>
  9. /// S8-ACTION-PERMISSION-1:异常单动作授权模型。
  10. ///
  11. /// <para><b>本批修的是一个实测存在的跨租户权限泄漏。</b>平台的
  12. /// <c>SysMenuService.GetOwnBtnPermList()</c> 只对<b>菜单</b>做租户校验
  13. /// (<c>InnerJoin SysTenantMenu</c>),<b>对角色不做</b>;<c>SysUserRole</c> 也不要求
  14. /// 用户与角色同租户。真库取证:<c>UATAdminA</c>(租户 838257186181189)经
  15. /// <c>ROLE_S6_IPQC_SUPERVISOR</c>(<c>SysRole.TenantId = 1300000000001</c>)
  16. /// 拿到 <c>s8:exception:assign</c> 等能力,而它自己租户里没有任何角色授予这些能力。</para>
  17. ///
  18. /// <para><b>因此本文件的断言大量是反向的</b>:这类缺陷的特征是「加回一个谓词 / 少一个
  19. /// 租户过滤就静默放宽」,正向断言抓不到。</para>
  20. /// </summary>
  21. public class S8ExceptionActionPermissionTests
  22. {
  23. private const long TenantA = 838257186181189L;
  24. private const long TenantB = 838257212780613L;
  25. private static readonly string PluginRoot =
  26. Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
  27. /// <summary>只取可执行代码行:注释里为留档会复述旧写法,不应算违规。</summary>
  28. private static string CodeOnly(string relativePath)
  29. {
  30. var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
  31. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  32. return string.Join('\n', File.ReadAllLines(full)
  33. .Where(l =>
  34. {
  35. var t = l.TrimStart();
  36. return !t.StartsWith("///", StringComparison.Ordinal)
  37. && !t.StartsWith("//", StringComparison.Ordinal);
  38. }));
  39. }
  40. // ══════════════════════════════════════════════════════════════════════════════
  41. // 动作目录:只能有真实存在的动作
  42. // ══════════════════════════════════════════════════════════════════════════════
  43. /// <summary>
  44. /// 目录里的每个动作都必须标注它对应的真实接口。
  45. ///
  46. /// <para>顺带守住一条设计约束:<b>不得出现 <c>EXCEPTION_CLOSE</c></b>。
  47. /// S8 没有独立的「关闭」接口 —— 关闭是 <c>approve-verification</c> 通过后的状态机结果。
  48. /// 给它一个可配置权限位,管理员会配一个永远不被检查的开关。</para>
  49. /// </summary>
  50. [Fact]
  51. public void ActionCatalog_ContainsOnlyRealActions()
  52. {
  53. Assert.NotEmpty(S8ExceptionActionCatalog.All);
  54. foreach (var d in S8ExceptionActionCatalog.All)
  55. {
  56. Assert.False(string.IsNullOrWhiteSpace(d.Code));
  57. Assert.False(string.IsNullOrWhiteSpace(d.DisplayName));
  58. Assert.False(string.IsNullOrWhiteSpace(d.ApiHint));
  59. }
  60. Assert.DoesNotContain(S8ExceptionActionCatalog.All, d =>
  61. d.Code.Equals("EXCEPTION_CLOSE", StringComparison.OrdinalIgnoreCase));
  62. // 动作码唯一
  63. Assert.Equal(S8ExceptionActionCatalog.All.Count,
  64. S8ExceptionActionCatalog.All.Select(d => d.Code).Distinct(StringComparer.OrdinalIgnoreCase).Count());
  65. }
  66. /// <summary>
  67. /// 目录必须完整覆盖异常控制器上的每一个 Action —— 漏一个就意味着那个接口没有授权门。
  68. /// </summary>
  69. [Fact]
  70. public void EveryExceptionEndpoint_IsGuardedByActionAttribute()
  71. {
  72. foreach (var controller in new[] { typeof(AdoS8ExceptionsController), typeof(AdoS8ReportsController) })
  73. {
  74. var actions = controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)
  75. .Where(m => !m.IsSpecialName)
  76. .ToList();
  77. Assert.NotEmpty(actions);
  78. foreach (var a in actions)
  79. {
  80. var attr = a.GetCustomAttribute<S8ExceptionActionAttribute>();
  81. Assert.True(attr != null, $"{controller.Name}.{a.Name} 缺少 S8ExceptionAction 授权门");
  82. Assert.True(S8ExceptionActionCatalog.IsKnown(attr!.Code),
  83. $"{controller.Name}.{a.Name} 使用了目录外的动作码 {attr.Code}");
  84. // 旧门禁不得与新门禁并存 —— 同一问题两个 authority 迟早判据分叉。
  85. Assert.Null(a.GetCustomAttribute<S8PermissionAttribute>());
  86. }
  87. }
  88. }
  89. /// <summary>未知动作码在构造期就必须炸,而不是等运行时静默放行。</summary>
  90. [Fact]
  91. public void UnknownAction_RejectedAtConstruction()
  92. {
  93. Assert.Throws<ArgumentException>(() => new S8ExceptionActionAttribute("EXCEPTION_NOT_A_REAL_ACTION"));
  94. Assert.Throws<ArgumentException>(() => new S8ExceptionActionAttribute(""));
  95. Assert.False(S8ExceptionActionCatalog.IsKnown("EXCEPTION_NOT_A_REAL_ACTION"));
  96. Assert.False(S8ExceptionActionCatalog.IsKnown(null));
  97. }
  98. // ══════════════════════════════════════════════════════════════════════════════
  99. // 授权器判据
  100. // ══════════════════════════════════════════════════════════════════════════════
  101. /// <summary>
  102. /// T-unknown-action / T-fail-closed:
  103. /// 未知动作拒绝;<b>该动作一个角色都没配也必须拒绝</b>。
  104. ///
  105. /// <para>「配置表为空 = 所有人拥有全部权限」是权限系统最典型的事故 ——
  106. /// 上线时表还没灌数据,于是全员拥有全部动作,而且没有任何报错。</para>
  107. /// </summary>
  108. [Fact]
  109. public void Authorizer_FailsClosed_OnUnknownActionAndEmptyConfig()
  110. {
  111. var code = CodeOnly("Infrastructure/S8/S8ExceptionActionAuthorizer.cs");
  112. // 未知动作先判、直接拒
  113. Assert.Contains("if (!S8ExceptionActionCatalog.IsKnown(actionCode))", code);
  114. Assert.Contains(S8ActionDenyReason.UnknownAction, code);
  115. // 空配置 = 拒绝
  116. Assert.Contains("if (grantedRoleIds.Count == 0)", code);
  117. Assert.Contains(S8ActionDenyReason.ActionNotConfigured, code);
  118. // 不得出现任何「空即放行」的写法
  119. Assert.DoesNotContain("Count == 0) return S8ActionAuthorizationResult.Pass()", code);
  120. }
  121. /// <summary>
  122. /// T-cross-tenant-role-reject:账号持有的角色必须属于<b>当前租户</b>。
  123. ///
  124. /// <para>授权器不自己查用户角色,而是走 <see cref="IS8TenantRoleResolver"/> ——
  125. /// 跨租户角色在那一步就已经被排除,授权器这一侧不可能再漏。</para>
  126. /// </summary>
  127. [Fact]
  128. public void Authorizer_ResolvesRolesThroughTenantScopedResolver()
  129. {
  130. var code = CodeOnly("Infrastructure/S8/S8ExceptionActionAuthorizer.cs");
  131. Assert.Contains("_roleResolver.GetRoleIdsAsync(tenantId, userId)", code);
  132. // 不得绕过 resolver 自己拼 SysUserRole 查询
  133. Assert.DoesNotContain("Queryable<SysUserRole>", code);
  134. Assert.DoesNotContain("SysRoleMenu", code);
  135. }
  136. /// <summary>租户角色解析器本身必须带 <c>SysRole.TenantId == 传入租户</c>,且不含工厂谓词。</summary>
  137. [Fact]
  138. public void TenantRoleResolver_IsTenantScopedAndFactoryFree()
  139. {
  140. var code = CodeOnly("Infrastructure/S8/S8TenantRoleResolver.cs");
  141. Assert.Contains("r.TenantId == tenantId", code);
  142. Assert.DoesNotContain("FactoryId", code);
  143. Assert.DoesNotContain("FactoryRefId", code);
  144. Assert.DoesNotContain("EmployeeMaster", code);
  145. }
  146. /// <summary>
  147. /// T-factory-irrelevant / T-no-employee:
  148. /// 授权链上任何一环都不得出现工厂或员工。
  149. /// </summary>
  150. [Theory]
  151. [InlineData("Infrastructure/S8/S8ExceptionActionAuthorizer.cs")]
  152. [InlineData("Infrastructure/S8/S8TenantRoleResolver.cs")]
  153. [InlineData("Infrastructure/S8/S8ExceptionActionAttribute.cs")]
  154. [InlineData("Service/S8/S8ExceptionActionRoleService.cs")]
  155. [InlineData("Service/S8/S8ExceptionActionRoleProvisioningService.cs")]
  156. public void AuthorizationChain_HasNoFactoryOrEmployee(string relativePath)
  157. {
  158. var code = CodeOnly(relativePath);
  159. Assert.DoesNotContain("FactoryRefId", code);
  160. Assert.DoesNotContain("EmployeeMaster", code);
  161. Assert.DoesNotContain("AdoS0EmployeeMaster", code);
  162. }
  163. /// <summary>
  164. /// T-multiple-roles-union:多角色取并集 —— 持有任一被授权角色即通过,
  165. /// 而不是要求全部持有。
  166. /// </summary>
  167. [Fact]
  168. public void Authorizer_UnionsMultipleRoles()
  169. {
  170. var code = CodeOnly("Infrastructure/S8/S8ExceptionActionAuthorizer.cs");
  171. // Any(...) = 并集语义;若写成 All(...) 就变成"必须持有全部角色"
  172. Assert.Contains("userRoleIds.Any(grantedRoleIds.Contains)", code);
  173. Assert.DoesNotContain("userRoleIds.All(", code);
  174. }
  175. /// <summary>授权判定必须<b>只有一份实现</b>;各 Service 不得自己散落角色判断。</summary>
  176. [Fact]
  177. public void ActionAuthorization_HasSingleImplementation()
  178. {
  179. var impls = typeof(IS8ExceptionActionAuthorizer).Assembly.GetTypes()
  180. .Where(t => t is { IsAbstract: false, IsInterface: false }
  181. && typeof(IS8ExceptionActionAuthorizer).IsAssignableFrom(t))
  182. .ToList();
  183. Assert.Single(impls);
  184. Assert.Equal(typeof(S8ExceptionActionAuthorizer), impls[0]);
  185. var resolvers = typeof(IS8TenantRoleResolver).Assembly.GetTypes()
  186. .Where(t => t is { IsAbstract: false, IsInterface: false }
  187. && typeof(IS8TenantRoleResolver).IsAssignableFrom(t))
  188. .ToList();
  189. Assert.Single(resolvers);
  190. // 业务 Service 里不得再出现自己的角色/权限判断
  191. foreach (var f in new[]
  192. {
  193. "Service/S8/S8TaskFlowService.cs",
  194. "Service/S8/S8ExceptionService.cs",
  195. "Service/S8/S8ManualReportService.cs",
  196. })
  197. {
  198. var code = CodeOnly(f);
  199. Assert.DoesNotContain("SysRoleMenu", code);
  200. Assert.DoesNotContain("GetOwnBtnPermList", code);
  201. }
  202. }
  203. // ══════════════════════════════════════════════════════════════════════════════
  204. // 状态机仍然独立生效
  205. // ══════════════════════════════════════════════════════════════════════════════
  206. /// <summary>
  207. /// T-state-rule-still-enforced:权限通过<b>不等于</b>状态允许。
  208. /// 两道门必须各自独立 —— 权限只回答「这个角色能不能做这类事」,
  209. /// 状态回答「这张单现在能不能做」。
  210. /// </summary>
  211. [Fact]
  212. public void StateMachine_RemainsIndependentOfPermission()
  213. {
  214. var code = CodeOnly("Service/S8/S8TaskFlowService.cs");
  215. Assert.Contains("S8StatusRules.IsAllowedTransition", code);
  216. // 状态判断不得被改写成"有权限就跳过状态检查"
  217. Assert.DoesNotContain("IsAllowedTransition(e.Status, \"ASSIGNED\") ||", code);
  218. // 认领的原子谓词仍在(First Claim Wins 不因权限改造而失效)
  219. Assert.Contains("x.AssigneeUserId == null", code);
  220. Assert.Contains("该异常已被其他人员认领", code);
  221. }
  222. // ══════════════════════════════════════════════════════════════════════════════
  223. // 写入侧 & 供给
  224. // ══════════════════════════════════════════════════════════════════════════════
  225. /// <summary>写入侧必须校验角色属于当前租户,否则刚堵的洞会从配置页漏回来。</summary>
  226. [Fact]
  227. public void Write_RejectsCrossTenantRole()
  228. {
  229. var code = CodeOnly("Service/S8/S8ExceptionActionRoleService.cs");
  230. Assert.Contains("r.TenantId == scope.TenantId", code);
  231. Assert.Contains("所选角色不存在或不属于当前租户", code);
  232. }
  233. /// <summary>
  234. /// 供给只推导<b>本租户</b>角色,且推不出角色时留空(fail closed),不给兜底角色。
  235. /// </summary>
  236. [Fact]
  237. public void Provisioning_DerivesFromSameTenantRolesOnly()
  238. {
  239. var code = CodeOnly("Service/S8/S8ExceptionActionRoleProvisioningService.cs");
  240. Assert.Contains("pending.Contains(r.TenantId.Value)", code);
  241. Assert.Contains("if (roleIds.Count == 0) { result.UnconfiguredCount++; continue; }", code);
  242. // 只做首次供给:已有任意授权行的租户整体跳过,避免把管理员取消的授权悄悄加回来
  243. Assert.Contains("tenantsWithRows", code);
  244. }
  245. /// <summary>存储身份是 (租户, 动作, 角色),不含工厂;且只存 Action↔Role,不存 Action↔User。</summary>
  246. [Fact]
  247. public void Storage_IdentityIsTenantActionRole()
  248. {
  249. var t = typeof(AdoS8ExceptionActionRole);
  250. Assert.NotNull(t.GetProperty("TenantId"));
  251. Assert.NotNull(t.GetProperty("ActionCode"));
  252. Assert.NotNull(t.GetProperty("RoleId"));
  253. Assert.Null(t.GetProperty("FactoryId"));
  254. Assert.Null(t.GetProperty("UserId"));
  255. }
  256. /// <summary>迁移必须建唯一索引并断言"不存在跨租户角色授权"。</summary>
  257. [Fact]
  258. public void Migration_GuardsUniquenessAndCrossTenant()
  259. {
  260. var sql = File.ReadAllText(Path.GetFullPath(Path.Combine(
  261. PluginRoot, "../../Admin.NET.Web.Entry/UpdateScripts/1.0.497.sql")));
  262. Assert.Contains("uk_s8_action_role", sql);
  263. Assert.Contains("s8_497_BLOCKED_unique_index_missing", sql);
  264. Assert.Contains("s8_497_BLOCKED_cross_tenant_role_granted", sql);
  265. // 迁移前的跨租户授予必须留证,否则明天没人能区分"修复"与"回归"
  266. Assert.Contains("ado_s8_cross_tenant_grant_1_0_497", sql);
  267. // AutoVersionUpdate 对含该关键字的脚本一律拒绝执行(朴素子串匹配,注释也算)
  268. Assert.DoesNotContain("DELIMITER", sql);
  269. }
  270. /// <summary>配置面的旧门禁也必须补上租户交集 —— 同一个洞不能只堵一半。</summary>
  271. [Fact]
  272. public void ConfigPermissionGate_AlsoIntersectsTenantRoles()
  273. {
  274. var code = CodeOnly("Infrastructure/S8/S8PermissionAttribute.cs");
  275. Assert.Contains("IS8TenantRoleResolver", code);
  276. Assert.Contains("GetPermissionCodesAsync", code);
  277. // 交集只可能收窄;不得出现"任一满足即放行"的或逻辑
  278. Assert.Contains("if (tenantCodes.Contains(Code)) return;", code);
  279. }
  280. }