فهرست منبع

fix(s8): 角色解析显式携带租户边界,消除后台派发的跨租户通知风险

缺陷链(P0 SECURITY):
  1. SysRole.Code 跨租户重复——每个业务角色每租户一份,实测 ROLE_PURCHASER 共 9 份;
  2. 平台租户过滤器只在有登录上下文时挂载:SqlSugarSetup 中 App.User 为空则不挂,
     超管直接 return;
  3. S8WatchSchedulerJob 走 IServiceScopeFactory.CreateScope(),没有 App.User。
  ⇒ 后台派发分层通知时 "ROLE_PRODUCTION_PLANNER" 会解析到全部租户的同名角色,
    把 A 租户的异常通知投给 B/C/D 租户的用户。

修法遵循「租户是显式安全边界」,不改平台全局过滤器、不给后台线程伪造 HttpContext:
  · ResolveUserIdsAsync 增加必填 tenantId 形参,SysRole 查询显式带 TenantId ==;
  · 数字 RoleId 路径同样过租户校验并对越界 Id 告警后丢弃——防止调用方或被篡改的
    target_role_ids 传入他租户 RoleId 绕过边界;
  · tenantId <= 0 时直接返回空集:作用域缺失宁可不通知,也不放行全量;
  · 角色缺失保持既有软失败语义(warning + 0 用户),不改成抛异常。

调用方 S8NotificationLayerResolver 透传 input.TenantId(触发异常的租户)而非
layer.TenantId——全局层 tenant_id=0 也必须按触发租户解析收件人,否则全局层解析不到角色。

SysUserRoleService.GetUserIdList(roleId) 未改:RoleId 已在上游经租户校验,
不为「看起来更安全」扩大改动到平台服务。

测试锁定签名契约(防回退到无作用域重载)与作用域缺失守卫(注入 null 仓储,
证明守卫早于任何 DB 访问)。端到端跨租户验证需带 DB 环境,留待部署后运行态确认。

chore: bump version server 1.0.483
YY968XX 1 روز پیش
والد
کامیت
2eb7ad225b

+ 98 - 0
server/Admin.NET.Test/S8/S8RoleResolutionTenantScopeTests.cs

@@ -0,0 +1,98 @@
+using System.Reflection;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Admin.NET.Test.S8;
+
+/// <summary>
+/// S8-ROLE-TENANT-SCOPE-1(P0 SECURITY):角色 → 用户解析必须显式携带租户边界。
+///
+/// <b>修复前的缺陷链</b>:
+///   1. <c>SysRole.Code</c> 跨租户重复(每个业务角色每租户一份,实测 ROLE_PURCHASER 共 9 份);
+///   2. 平台租户过滤器只在有登录上下文时挂载 —— <c>SqlSugarSetup</c> 中
+///      <c>App.User</c> 为空则不挂、超管直接 return;
+///   3. <c>S8WatchSchedulerJob</c> 用 <c>IServiceScopeFactory.CreateScope()</c>,**没有 App.User**;
+///   ⇒ 后台派发分层通知时 "ROLE_PRODUCTION_PLANNER" 会解析到全部租户的同名角色,
+///     把 A 租户的异常通知投递给 B/C/D 租户的用户。
+///
+/// <b>本文件锁定的契约</b>(实例方法依赖 SqlSugarRepository + SysUserRoleService,
+/// 需 DB 才能端到端验证,因此这里锁定「签名」与「前置守卫」两层,
+/// 沿用仓内既有范式:S8TenantIsolationContractTests 亦用反射断言写入口必须带作用域):
+///   A. 方法签名必须以 tenantId 开头 → 防止有人改回无作用域版本;
+///   B. 不存在无 tenantId 的旧重载   → 防止新增调用方走回老路;
+///   C. tenantId &lt;= 0 时直接返回空   → 作用域缺失宁可不通知,绝不放行全量;
+///   D. 守卫必须在任何仓储访问之前   → 用 null 依赖构造,若先碰 DB 会 NullReference。
+///
+/// 不接 DB、不接 DI。
+/// </summary>
+public class S8RoleResolutionTenantScopeTests
+{
+    private static MethodInfo[] ResolveOverloads() =>
+        typeof(S8RoleResolver)
+            .GetMethods(BindingFlags.Public | BindingFlags.Instance)
+            .Where(m => m.Name == nameof(S8RoleResolver.ResolveUserIdsAsync))
+            .ToArray();
+
+    // ============================================================
+    // A/B. 签名契约 —— 防回退
+    // ============================================================
+
+    [Fact]
+    public void ResolveUserIds_MustTakeTenantIdAsFirstParameter()
+    {
+        var overloads = ResolveOverloads();
+        var m = Assert.Single(overloads);
+
+        var ps = m.GetParameters();
+        Assert.Equal(2, ps.Length);
+        Assert.Equal(typeof(long), ps[0].ParameterType);
+        Assert.Equal("tenantId", ps[0].Name);
+        Assert.Equal(typeof(IEnumerable<string>), ps[1].ParameterType);
+    }
+
+    [Fact]
+    public void ResolveUserIds_HasNoTenantlessOverload()
+    {
+        // 若将来有人加回 ResolveUserIdsAsync(IEnumerable<string>),跨租户缺陷会以「新调用方忘记传租户」的形式复发。
+        Assert.DoesNotContain(
+            ResolveOverloads(),
+            m => m.GetParameters().Length == 1);
+    }
+
+    // ============================================================
+    // C/D. 作用域缺失守卫 —— 且必须早于任何 DB 访问
+    // ============================================================
+
+    [Fact]
+    public async Task InvalidTenantScope_ReturnsEmpty_WithoutTouchingRepository()
+    {
+        // 故意注入 null 仓储与 null 服务:守卫若不是第一步,这里会 NullReferenceException 而非返回空集。
+        var resolver = new S8RoleResolver(null, null, NullLogger<S8RoleResolver>.Instance);
+
+        Assert.Empty(await resolver.ResolveUserIdsAsync(0, new[] { "ROLE_PURCHASER" }));
+        Assert.Empty(await resolver.ResolveUserIdsAsync(-1, new[] { "ROLE_PURCHASER", "12345" }));
+    }
+
+    [Fact]
+    public async Task NullTokens_ReturnsEmpty_RegardlessOfTenant()
+    {
+        var resolver = new S8RoleResolver(null, null, NullLogger<S8RoleResolver>.Instance);
+
+        Assert.Empty(await resolver.ResolveUserIdsAsync(838257186181189L, null));
+    }
+
+    // ============================================================
+    // Token 切分(保持既有软失败语义,不因本次收口而改变)
+    // ============================================================
+
+    [Fact]
+    public void SplitTokens_StillPartitionsCodesAndIds()
+    {
+        // 数字 token → RoleId,非数字 token → RoleCode。两条路径在实现里都要过租户校验。
+        Assert.Equal(new[] { "ROLE_A", "123", "ROLE_B" },
+            S8RoleResolver.SplitTokens(" ROLE_A , 123, , ROLE_B  "));
+        Assert.Empty(S8RoleResolver.SplitTokens(null));
+        Assert.Empty(S8RoleResolver.SplitTokens("   "));
+    }
+}

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.482</AssemblyVersion>
-    <FileVersion>1.0.482</FileVersion>
-    <Version>1.0.482</Version>
+    <AssemblyVersion>1.0.483</AssemblyVersion>
+    <FileVersion>1.0.483</FileVersion>
+    <Version>1.0.483</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 4 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationLayerResolver.cs

@@ -155,7 +155,10 @@ public class S8NotificationLayerResolver : ITransient
 					_logger.LogWarning("S8LayerDispatch: layer id={LayerId} target_role_ids empty, skip row", layer.Id);
 					continue;
 				}
-				userIds = await _roleResolver.ResolveUserIdsAsync(tokens);
+				// S8-ROLE-TENANT-SCOPE-1:显式透传租户作为安全边界。
+				// 注意用 input.TenantId 而非 layer.TenantId —— 全局层(tenant_id=0)也必须
+				// 按**触发异常的租户**解析收件人,否则全局层会解析不到任何角色。
+				userIds = await _roleResolver.ResolveUserIdsAsync(input.TenantId, tokens);
 			}
 			catch (Exception ex)
 			{

+ 51 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8RoleResolver.cs

@@ -32,36 +32,59 @@ public class S8RoleResolver : ITransient
 
 	/// <summary>
 	/// 解析 RoleCode / RoleId 混合 token 列表为去重 UserId 列表。
+	///
+	/// <b>S8-ROLE-TENANT-SCOPE-1(P0 SECURITY):租户必须由调用方显式传入。</b>
+	/// 原实现只按 Code / Id 匹配 <c>SysRole</c>,依赖平台的全局租户过滤器兜底;
+	/// 但该过滤器只在有登录上下文时挂载(<c>SqlSugarSetup</c>:<c>App.User</c> 为空则不挂、超管直接跳过),
+	/// 而 <c>S8WatchSchedulerJob</c> 走 <c>IServiceScopeFactory.CreateScope()</c>,**没有 App.User**。
+	/// 又因 <c>SysRole.Code</c> 跨租户重复(每个业务角色每租户一份,实测 9 份),
+	/// 后台派发时 "ROLE_PURCHASER" 会解析到全部租户的角色 → 跨租户通知投递。
+	///
+	/// 因此租户在此作为**显式安全边界**,不依赖任何隐式过滤:
+	/// Code 路径与数字 RoleId 路径**都**必须满足 <c>TenantId == tenantId</c>,
+	/// 后者可防止调用方(或被篡改的配置)传入他租户的 RoleId 绕过边界。
 	/// </summary>
-	public async Task<List<long>> ResolveUserIdsAsync(IEnumerable<string>? roleTokens)
+	/// <param name="tenantId">安全边界租户。必须 &gt; 0,否则直接返回空集合(绝不放行全量)。</param>
+	/// <param name="roleTokens">RoleCode / RoleId 混合 token。</param>
+	public async Task<List<long>> ResolveUserIdsAsync(long tenantId, IEnumerable<string>? roleTokens)
 	{
 		var result = new HashSet<long>();
 		if (roleTokens == null) return result.ToList();
 
-		var roleIds = new HashSet<long>();
+		// 作用域缺失时宁可不通知,也不返回跨租户用户。
+		if (tenantId <= 0)
+		{
+			_logger.LogWarning("S8RoleResolver: tenantId<=0,拒绝解析角色(避免跨租户投递)");
+			return result.ToList();
+		}
+
+		var requestedIds = new HashSet<long>();
 		var codes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 		foreach (var raw in roleTokens)
 		{
 			if (string.IsNullOrWhiteSpace(raw)) continue;
 			var t = raw.Trim();
-			if (long.TryParse(t, out var rid) && rid > 0) roleIds.Add(rid);
+			if (long.TryParse(t, out var rid) && rid > 0) requestedIds.Add(rid);
 			else codes.Add(t);
 		}
 
+		// 经租户校验后的角色 Id,只有进入这里的才允许展开成用户。
+		var roleIds = new HashSet<long>();
+
 		if (codes.Count > 0)
 		{
 			try
 			{
 				var codeList = codes.ToList();
 				var found = await _sysRoleRep.AsQueryable()
-					.Where(r => codeList.Contains(r.Code))
+					.Where(r => codeList.Contains(r.Code) && r.TenantId == tenantId)
 					.Select(r => new { r.Id, r.Code })
 					.ToListAsync();
 				var foundCodes = new HashSet<string>(found.Select(f => f.Code ?? string.Empty), StringComparer.OrdinalIgnoreCase);
 				foreach (var c in codes)
 				{
 					if (!foundCodes.Contains(c))
-						_logger.LogWarning("S8RoleResolver: RoleCode '{Code}' not found in SysRole", c);
+						_logger.LogWarning("S8RoleResolver: RoleCode '{Code}' not found in SysRole for tenant {TenantId}", c, tenantId);
 				}
 				foreach (var f in found) roleIds.Add(f.Id);
 			}
@@ -71,6 +94,29 @@ public class S8RoleResolver : ITransient
 			}
 		}
 
+		if (requestedIds.Count > 0)
+		{
+			try
+			{
+				// 数字 RoleId 同样过租户校验:他租户 Id 一律丢弃并告警,不静默放行。
+				var idList = requestedIds.ToList();
+				var ownedIds = await _sysRoleRep.AsQueryable()
+					.Where(r => idList.Contains(r.Id) && r.TenantId == tenantId)
+					.Select(r => r.Id)
+					.ToListAsync();
+				var owned = new HashSet<long>(ownedIds);
+				foreach (var rid in requestedIds)
+				{
+					if (owned.Contains(rid)) roleIds.Add(rid);
+					else _logger.LogWarning("S8RoleResolver: RoleId {RoleId} 不属于租户 {TenantId},已丢弃", rid, tenantId);
+				}
+			}
+			catch (Exception ex)
+			{
+				_logger.LogWarning(ex, "S8RoleResolver: resolve RoleId batch failed");
+			}
+		}
+
 		foreach (var rid in roleIds)
 		{
 			try