Parcourir la source

fix(s8): enforce trusted scope in scheduler execution

YY968XX il y a 4 jours
Parent
commit
b55f7eac56

+ 147 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8SchedulerScopeGuardTests.cs

@@ -0,0 +1,147 @@
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using System.Reflection;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-P0-1-SCHEDULER-TRUSTED-SCOPE-1:Scheduler 多租户执行边界守卫。
+///
+/// <para>背景(2026-09-02 实测事故):Watch Scheduler 是**平台后台任务**,
+/// <c>ListEnabledScopesAsync</c> 按设计遍历全部「有启用规则且 Status=1」的租户。
+/// 该遍历本身合法,但当时链路上有三个缺口,使得一次 tick 会跨租户串味:</para>
+/// <list type="number">
+///   <item>执行入口 <c>RunSingleRuleAsync</c> 只按 <c>lease.RuleId</c> 装载规则,不校验归属;</item>
+///   <item>自动建单算 SLA 的 <c>ResolveSlaDeadlineAsync</c> 用 <c>ClearFilter()</c> + 仅 TypeCode 等值
+///         + <c>ORDER BY FactoryId DESC</c>,在**全库**范围挑「factory_id 最大」的那行;</item>
+///   <item>跨租户扫描的超时升级 Job 按 TypeCode 建全局 map,会把 B 租户的
+///         <c>escalate_role_code</c> 用到 A 租户的异常上。</item>
+/// </list>
+///
+/// <para>本测试锁住修复后的口径:**遍历可以跨租户,但每条规则的执行必须隔离在自己的
+/// trusted (TenantId, FactoryId) 里**,且下游按作用域取配置。断言走源码扫描,
+/// 因为这些约束落在方法体的查询谓词上,反射看不到。</para>
+/// </summary>
+public class S8SchedulerScopeGuardTests
+{
+    private static readonly string PluginRoot =
+        Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
+
+    private const string SchedulerSrc = "Service/S8/S8WatchSchedulerService.cs";
+    private const string ManualReportSrc = "Service/S8/S8ManualReportService.cs";
+    private const string EscalationSrc = "Service/S8/S8TimeoutAutoEscalationService.cs";
+
+    private static string ReadSource(string relativePath)
+    {
+        var full = Path.Combine(PluginRoot, relativePath);
+        Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
+        return File.ReadAllText(full);
+    }
+
+    /// <summary>只取可执行代码行:注释里为留档会复述旧写法,不应算违规。</summary>
+    private static string CodeOnly(string relativePath) =>
+        string.Join('\n', ReadSource(relativePath)
+            .Split('\n')
+            .Select(l => l.Trim())
+            .Where(l => !l.StartsWith("///", StringComparison.Ordinal)
+                     && !l.StartsWith("//", StringComparison.Ordinal)));
+
+    /// <summary>
+    /// 缺口 1:执行入口必须按本次 tick 的 scope 绑行,并对归属不符 fail-fast。
+    /// </summary>
+    [Fact]
+    public void RunSingleRule_BindsRuleToTickScope_AndFailsFastOnMismatch()
+    {
+        var code = CodeOnly(SchedulerSrc);
+
+        // 装载规则时必须同时带 TenantId / FactoryId 谓词,禁止裸 Id 装载。
+        Assert.Contains("x.Id == lease.RuleId && x.TenantId == tenantId && x.FactoryId == factoryId", code);
+
+        // 归属复核 + 明确的失败原因码;不得静默继续执行。
+        Assert.Contains("rule_scope_mismatch", code);
+        Assert.Contains("rule.TenantId != tenantId || rule.FactoryId != factoryId", code);
+    }
+
+    /// <summary>
+    /// 缺口 2:SLA 解析必须接收显式作用域,且查询带 (租户命中 OR 0) AND (工厂命中 OR 0)。
+    /// 签名用反射钉住,避免有人新增无作用域重载。
+    /// </summary>
+    [Fact]
+    public void ResolveSlaDeadline_RequiresExplicitTenantAndFactory()
+    {
+        var methods = typeof(S8ManualReportService)
+            .GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)
+            .Where(m => m.Name == "ResolveSlaDeadlineAsync")
+            .ToList();
+
+        Assert.NotEmpty(methods);
+        Assert.All(methods, m =>
+        {
+            var p = m.GetParameters();
+            Assert.True(p.Length >= 2, "ResolveSlaDeadlineAsync 必须显式接收 tenantId / factoryId");
+            Assert.Equal(typeof(long), p[0].ParameterType);
+            Assert.Equal(typeof(long), p[1].ParameterType);
+        });
+    }
+
+    /// <summary>缺口 2:SLA 查询谓词本身必须带作用域,不能只靠调用方传参而查询里不用。</summary>
+    [Fact]
+    public void ResolveSlaDeadline_QueryCarriesScopePredicate()
+    {
+        var code = CodeOnly(ManualReportSrc);
+        Assert.Contains("(t.TenantId == tenantId || t.TenantId == 0)", code);
+        Assert.Contains("(t.FactoryId == factoryId || t.FactoryId == 0)", code);
+    }
+
+    /// <summary>
+    /// 缺口 3:跨租户扫描的超时升级 Job,异常类型必须按**每条异常自身**的作用域解析,
+    /// 不得再用「按 TypeCode 建全局 map + 取 factory_id 最大」的写法。
+    /// </summary>
+    [Fact]
+    public void TimeoutAutoEscalation_ResolvesExceptionTypePerRowScope()
+    {
+        var code = CodeOnly(EscalationSrc);
+
+        // 旧写法已移除:全局 typeMap 按 TypeCode 直接取用。
+        Assert.DoesNotContain("typeMap.TryGetValue", code);
+
+        // 新写法:逐异常按 (TenantId, FactoryId) 解析。
+        Assert.Contains("ResolveTypeForScope", code);
+        Assert.Contains("t.TenantId == tenantId || t.TenantId == 0", code);
+        Assert.Contains("t.FactoryId == factoryId || t.FactoryId == 0", code);
+        Assert.Contains("ResolveTypeForScope(e.ExceptionTypeCode!, e.TenantId, e.FactoryId)", code);
+    }
+
+    /// <summary>
+    /// 遍历入口本身保持「平台后台任务」语义:按租户枚举 + 只取 Status=1 的租户,
+    /// 但每个 scope 必须带 tenant_id / factory_id 两个维度(不能只按租户)。
+    /// </summary>
+    [Fact]
+    public void EnabledScopeEnumeration_IsTenantAndFactoryPaired()
+    {
+        var code = CodeOnly(SchedulerSrc);
+        Assert.Contains("SELECT DISTINCT r.tenant_id AS TenantId, r.factory_id AS FactoryId", code);
+        Assert.Contains("INNER JOIN SysTenant t ON t.Id = r.tenant_id AND t.Status = 1", code);
+        Assert.Contains("AND r.tenant_id > 0", code);
+        Assert.Contains("AND r.factory_id > 0", code);
+    }
+
+    /// <summary>
+    /// 下游写入口必须继续以显式 (tenantId, factoryId) 承接,不允许退回从 rule / 客户端推导。
+    /// </summary>
+    [Fact]
+    public void DownstreamWriters_KeepExplicitScopeParameters()
+    {
+        var pick = typeof(S8WatchSchedulerService).GetMethod(
+            nameof(S8WatchSchedulerService.PickReadyRulesAsync));
+        Assert.NotNull(pick);
+        Assert.Equal(typeof(long), pick!.GetParameters()[0].ParameterType);
+        Assert.Equal(typeof(long), pick.GetParameters()[1].ParameterType);
+
+        var run = typeof(S8WatchSchedulerService).GetMethod(
+            nameof(S8WatchSchedulerService.RunSingleRuleAsync));
+        Assert.NotNull(run);
+        Assert.Equal(typeof(long), run!.GetParameters()[0].ParameterType);
+        Assert.Equal(typeof(long), run.GetParameters()[1].ParameterType);
+    }
+}

+ 13 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ManualReportService.cs

@@ -134,11 +134,19 @@ public class S8ManualReportService : ITransient
     // S8-SLA-TIMEOUT-RUNTIME-1(P3):按 exception_type.sla_minutes 计算 sla_deadline。
     // S8-SLA-TIMEOUT-RUNTIME-1(P3):按 exception_type.sla_minutes 计算 sla_deadline。
     // typeCode 空 / type 缺失 / sla_minutes <= 0 → 返回 null(不阻断建单,仅 LogWarning)。
     // typeCode 空 / type 缺失 / sla_minutes <= 0 → 返回 null(不阻断建单,仅 LogWarning)。
     // 不写 timeout_flag;timeout_flag 已降级为 legacy 字段,当前超时由读端基于 sla_deadline + status 在线计算。
     // 不写 timeout_flag;timeout_flag 已降级为 legacy 字段,当前超时由读端基于 sla_deadline + status 在线计算。
-    private async Task<DateTime?> ResolveSlaDeadlineAsync(string? exceptionTypeCode, DateTime createdAt)
+    // S8-P0-1-SCHEDULER-TRUSTED-SCOPE-1:补可信作用域谓词。
+    // 原实现只有 ClearFilter() + TypeCode 等值 + ORDER BY FactoryId DESC,**没有任何租户/工厂谓词**,
+    // 会在全库范围内挑「factory_id 最大」的那一行 —— 即 A 租户建单可能取到 B 租户的 sla_minutes。
+    // 现改为与本文件 InferExceptionTypeCodeAsync / ResolveModuleCodeAsync 相同的既有口径:
+    // (TenantId 命中 OR 0) AND (FactoryId 命中 OR 0);ORDER BY FactoryId DESC 保留,
+    // 使「本工厂覆盖行」仍优先于「平台默认 (0,0)」,工厂覆盖语义不变。
+    private async Task<DateTime?> ResolveSlaDeadlineAsync(long tenantId, long factoryId, string? exceptionTypeCode, DateTime createdAt)
     {
     {
         if (string.IsNullOrWhiteSpace(exceptionTypeCode)) return null;
         if (string.IsNullOrWhiteSpace(exceptionTypeCode)) return null;
         var slaMinutes = await _typeRep.AsQueryable().ClearFilter()
         var slaMinutes = await _typeRep.AsQueryable().ClearFilter()
-            .Where(t => t.TypeCode == exceptionTypeCode)
+            .Where(t => t.TypeCode == exceptionTypeCode
+                     && (t.TenantId == tenantId || t.TenantId == 0)
+                     && (t.FactoryId == factoryId || t.FactoryId == 0))
             .OrderByDescending(t => t.FactoryId)
             .OrderByDescending(t => t.FactoryId)
             .Select(t => (int?)t.SlaMinutes)
             .Select(t => (int?)t.SlaMinutes)
             .FirstAsync();
             .FirstAsync();
@@ -598,7 +606,7 @@ public class S8ManualReportService : ITransient
 
 
         // S8-SLA-TIMEOUT-RUNTIME-1(P3):CreatedAt 与 SlaDeadline 共用同一 now,避免漂移。
         // S8-SLA-TIMEOUT-RUNTIME-1(P3):CreatedAt 与 SlaDeadline 共用同一 now,避免漂移。
         var now = DateTime.Now;
         var now = DateTime.Now;
-        var slaDeadline = await ResolveSlaDeadlineAsync(inferredType, now);
+        var slaDeadline = await ResolveSlaDeadlineAsync(trustedScope.TenantId, trustedScope.FactoryId, inferredType, now);
         var code = $"EX-{now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
         var code = $"EX-{now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
         var entity = new AdoS8Exception
         var entity = new AdoS8Exception
         {
         {
@@ -744,7 +752,7 @@ public class S8ManualReportService : ITransient
             ReporterId = null,
             ReporterId = null,
             CreatedAt = now,
             CreatedAt = now,
             // S8-SLA-TIMEOUT-RUNTIME-1:sla_deadline 由 exception_type.sla_minutes 决定(EQUIP_FAULT)。
             // S8-SLA-TIMEOUT-RUNTIME-1:sla_deadline 由 exception_type.sla_minutes 决定(EQUIP_FAULT)。
-            SlaDeadline = await ResolveSlaDeadlineAsync("EQUIP_FAULT", now),
+            SlaDeadline = await ResolveSlaDeadlineAsync(tenantId, factoryId, "EQUIP_FAULT", now),
             IsDeleted = false,
             IsDeleted = false,
             // G-01 首版唯一异常类型映射(baseline 已迁后 EQUIP_FAULT 属 S2 制造协同场景)。
             // G-01 首版唯一异常类型映射(baseline 已迁后 EQUIP_FAULT 属 S2 制造协同场景)。
             ExceptionTypeCode = "EQUIP_FAULT",
             ExceptionTypeCode = "EQUIP_FAULT",
@@ -852,7 +860,7 @@ public class S8ManualReportService : ITransient
         // S8-SLA-TIMEOUT-RUNTIME-1(P3):CreatedAt 与 SlaDeadline 共用同一 now。
         // S8-SLA-TIMEOUT-RUNTIME-1(P3):CreatedAt 与 SlaDeadline 共用同一 now。
         var now = DateTime.Now;
         var now = DateTime.Now;
         var code = $"EX-{now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
         var code = $"EX-{now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
-        var slaDeadline = await ResolveSlaDeadlineAsync(hit.ExceptionTypeCode, now);
+        var slaDeadline = await ResolveSlaDeadlineAsync(tenantId, factoryId, hit.ExceptionTypeCode, now);
         var entity = new AdoS8Exception
         var entity = new AdoS8Exception
         {
         {
             TenantId = tenantId,
             TenantId = tenantId,

+ 17 - 9
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8TimeoutAutoEscalationService.cs

@@ -64,22 +64,30 @@ public class S8TimeoutAutoEscalationService : ITransient
             .Distinct()
             .Distinct()
             .Select(c => c!)
             .Select(c => c!)
             .ToList();
             .ToList();
-        var typeMap = typeCodes.Count == 0
-            ? new Dictionary<string, AdoS8ExceptionType>()
-            : (await _typeRep.AsQueryable().ClearFilter()
+        // S8-P0-1-SCHEDULER-TRUSTED-SCOPE-1:原实现按 TypeCode 全库取「factory_id 最大」的一行,
+        // 无租户/工厂谓词 —— 本 Job 是跨租户扫描,会把 B 租户的 escalate_role_code 用到 A 租户的异常上。
+        // 现改为逐异常按其自身 (TenantId, FactoryId) 解析:候选 = (TenantId 命中 OR 0) AND (FactoryId 命中 OR 0),
+        // 工厂覆盖优先于平台默认 (0,0)。不改变单租户下的既有取值语义。
+        var typeRows = typeCodes.Count == 0
+            ? new List<AdoS8ExceptionType>()
+            : await _typeRep.AsQueryable().ClearFilter()
                 .Where(t => typeCodes.Contains(t.TypeCode))
                 .Where(t => typeCodes.Contains(t.TypeCode))
                 .OrderByDescending(t => t.FactoryId)
                 .OrderByDescending(t => t.FactoryId)
-                .ToListAsync())
-                .GroupBy(t => t.TypeCode)
-                .ToDictionary(g => g.Key, g => g.First());
+                .ToListAsync();
+
+        AdoS8ExceptionType? ResolveTypeForScope(string typeCode, long tenantId, long factoryId) =>
+            typeRows.FirstOrDefault(t => t.TypeCode == typeCode
+                                      && (t.TenantId == tenantId || t.TenantId == 0)
+                                      && (t.FactoryId == factoryId || t.FactoryId == 0));
 
 
         var processed = 0;
         var processed = 0;
         foreach (var e in candidates)
         foreach (var e in candidates)
         {
         {
             ct.ThrowIfCancellationRequested();
             ct.ThrowIfCancellationRequested();
-            if (string.IsNullOrWhiteSpace(e.ExceptionTypeCode)
-                || !typeMap.TryGetValue(e.ExceptionTypeCode, out var type)
-                || string.IsNullOrWhiteSpace(type.EscalateRoleCode))
+            var type = string.IsNullOrWhiteSpace(e.ExceptionTypeCode)
+                ? null
+                : ResolveTypeForScope(e.ExceptionTypeCode!, e.TenantId, e.FactoryId);
+            if (type == null || string.IsNullOrWhiteSpace(type.EscalateRoleCode))
             {
             {
                 _logger.LogInformation(
                 _logger.LogInformation(
                     "s8_timeout_auto_escalate_skip exceptionId={Id} exceptionCode={Code} reason=escalate_role_empty typeCode={TypeCode}",
                     "s8_timeout_auto_escalate_skip exceptionId={Id} exceptionCode={Code} reason=escalate_role_empty typeCode={TypeCode}",

+ 12 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

@@ -1267,13 +1267,24 @@ public class S8WatchSchedulerService : ITransient
     /// </summary>
     /// </summary>
     public async Task<S8RuleRunResult> RunSingleRuleAsync(long tenantId, long factoryId, S8RuleLease lease)
     public async Task<S8RuleRunResult> RunSingleRuleAsync(long tenantId, long factoryId, S8RuleLease lease)
     {
     {
+        // S8-P0-1-SCHEDULER-TRUSTED-SCOPE-1:执行入口必须按本次 tick 的可信作用域绑行。
+        // 原实现只按 lease.RuleId 装载,不校验 rule 归属;一旦 lease 来源被改写 / 未来新增调用方,
+        // 就会在 A 租户的 scope 上下文里执行 B 租户的规则,而下游全部以传入的 tenantId/factoryId 落库。
+        // 这里同时做「谓词绑行」与「归属复核」两层,任一不符即 fail-fast,绝不静默降级。
         var rule = await _ruleRep.AsQueryable()
         var rule = await _ruleRep.AsQueryable()
-            .Where(x => x.Id == lease.RuleId)
+            .Where(x => x.Id == lease.RuleId && x.TenantId == tenantId && x.FactoryId == factoryId)
             .FirstAsync();
             .FirstAsync();
         if (rule == null)
         if (rule == null)
         {
         {
             return new S8RuleRunResult { Success = false, ErrorMessage = "rule_not_found", Stats = new() };
             return new S8RuleRunResult { Success = false, ErrorMessage = "rule_not_found", Stats = new() };
         }
         }
+        if (rule.TenantId != tenantId || rule.FactoryId != factoryId)
+        {
+            _logger.LogError(
+                "rule_scope_mismatch ruleId={RuleId} ruleTenant={RuleTenant} ruleFactory={RuleFactory} scopeTenant={ScopeTenant} scopeFactory={ScopeFactory}",
+                rule.Id, rule.TenantId, rule.FactoryId, tenantId, factoryId);
+            return new S8RuleRunResult { Success = false, ErrorMessage = "rule_scope_mismatch", Stats = new() };
+        }
 
 
         var ruleType = rule.RuleType;
         var ruleType = rule.RuleType;
         if (string.IsNullOrWhiteSpace(ruleType))
         if (string.IsNullOrWhiteSpace(ruleType))