Pārlūkot izejas kodu

fix(flow): reject cross-tenant role refs for S6 IPQC | server 1.0.538

接 c9aa3c94e(迁移)。迁移完成并通过 Runtime 验证后,为两条 S6 链启用严格的
跨租户审批角色守卫,把「静默解析成 0 人」变成显式可诊断的失败。

原行为:FlowJson 里写死的数字 RoleId 被无条件信任,唯一的租户谓词落在
「成员」而不是「角色」上。挡得住跨租户泄漏,挡不住跨租户引用 —— 角色属于
别的租户时静默解析为 0 人,报错却是「未配置审批人」,把配置缺陷伪装成配置缺失。

守卫范围严格限定 IPQC_INSPECTION / S6_PROCESS_INSPECTION 两个 BizType:
S5_IQC_INSPBILL / S7_FQC_INSPBILL 等同样写死默认租户物理 RoleId,但它们
当前正靠跨租户 SysUserRole 绑定工作(实测 28 个节点组合),本批不在迁移范围,
无条件启用会把仍可用的节点当场打成 0 审批人。各模块完成自己的迁移后再加入白名单。

三类失败可区分,日志带 BizType / FlowId / NodeId / RoleRef / EffectiveTenantId:
  CROSS_TENANT_ROLE_REFERENCE  角色属于其它租户
  INVALID_ROLE                 角色不存在
  LOCAL_ROLE_HAS_NO_MEMBER     角色正确但本租户无成员
对外文案不暴露其它租户的 Id 与角色名称。

刻意不做 auto-heal(不按 Code 隐式替换成本租户角色):那会让坏定义继续成功
运行、永远没人去修。Branch B 保留,不是删除 RoleId 支持 —— 历史定义仍存 RoleId。

Runtime 验证(真实环境,非推演):
- R11 天然场景:租户 824585161322565 的 B-IPQC-001 命中 legacy 定义
  828860542427205(approverIds=1329916010001,属默认租户)→ 400
  「引用了不属于当前租户的审批角色」,日志分类正确;事务全回滚,
  Flow 四表计数与基线逐字节相同(276/52426),0 泄漏实例。未修改任何 FlowJson。
- R12 回归:686824d5e 的 S6 通用审批旁路守卫未受影响,仍正常阻断。
- 838 已迁移租户在守卫启用后链路不受影响。

验证:build 0 Error;全量 2397 passed / 1 failed
(S8AuthorizationGuardTests.MutationActions_DoNotUseReadOnlyCapabilities,
master 既有失败,与本批无关)。
YY968XX 3 dienas atpakaļ
vecāks
revīzija
46040ef3cc

+ 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.537</AssemblyVersion>
-    <FileVersion>1.0.537</FileVersion>
-    <Version>1.0.537</Version>
+    <AssemblyVersion>1.0.538</AssemblyVersion>
+    <FileVersion>1.0.538</FileVersion>
+    <Version>1.0.538</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 146 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/ApprovalFlow/S6TenantAuthorityGuardContractTests.cs

@@ -0,0 +1,146 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow;
+
+/// <summary>
+/// S6-TENANT-AUTHORITY-1 源码契约测试(Phase E · Resolver 守卫)。
+///
+/// 与 <see cref="S6TenantAuthorityMigrationContractTests"/> 分文件:迁移(角色 / 成员 / 定义)
+/// 与守卫是**两个可独立发布的阶段** —— 守卫必须在迁移完成并通过 Runtime 验证之后才允许启用,
+/// 否则会把仍靠 legacy 跨租户 RoleId 工作的租户当场打成 0 审批人。拆开后两个提交各自自洽。
+///
+/// 守卫的范围铁律:只覆盖 IPQC_INSPECTION / S6_PROCESS_INSPECTION。
+/// S5_IQC_INSPBILL / S7_FQC_INSPBILL 等同样写死了默认租户物理 RoleId,但它们当前正靠跨租户
+/// SysUserRole 绑定工作(实测 28 个节点组合),本批不在迁移范围,无条件启用会直接打断它们。
+///
+/// 沿用本仓既有做法——源码契约断言而非行为级集成测试:FlowEngineService 依赖
+/// SqlSugarRepository&lt;T&gt;,裸 xUnit 进程无法实例化(见 S8ExceptionFlowTenantContextContractTests)。
+/// </summary>
+public class S6TenantAuthorityGuardContractTests
+{
+    private static string Engine() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs"));
+
+    // ───────────── Resolver 守卫:S6 专属 fail-closed ─────────────
+
+    /// <summary>守卫必须只覆盖两条 S6 链。</summary>
+    [Fact]
+    public void Guard_ScopedToTheTwoS6BizTypes()
+    {
+        var set = Slice(Engine(), "TenantStrictRoleBizTypes = new(StringComparer.Ordinal)", "};");
+        Assert.Contains("\"IPQC_INSPECTION\"", set);
+        Assert.Contains("\"S6_PROCESS_INSPECTION\"", set);
+    }
+
+    /// <summary>S5 / S7 不得被本批守卫波及——它们仍持默认租户物理 RoleId 且当前正靠它工作。</summary>
+    [Theory]
+    [InlineData("S5_IQC_INSPBILL")]
+    [InlineData("S7_FQC_INSPBILL")]
+    [InlineData("SMART_OPS_IMPROVEMENT")]
+    [InlineData("EXCEPTION_REPORT")]
+    public void Guard_DoesNotAffectOtherBizTypes(string bizType)
+    {
+        var set = Slice(Engine(), "TenantStrictRoleBizTypes = new(StringComparer.Ordinal)", "};");
+        Assert.DoesNotContain(bizType, set);
+    }
+
+    /// <summary>守卫必须是精确白名单查表,不得退化成前缀/模糊匹配把无关业务一起挡掉。</summary>
+    [Fact]
+    public void Guard_UsesExactAllowList_NotFuzzyMatch()
+    {
+        var s = Engine();
+        Assert.Contains("TenantStrictRoleBizTypes.Contains(bizType)", s);
+        Assert.DoesNotContain("BizType.StartsWith(\"S6", s);
+        Assert.DoesNotContain("BizType.Contains(\"IPQC", s);
+    }
+
+    /// <summary>守卫必须在 Code 解析之前跑,针对的是 FlowJson 里写死的数字 RoleId。</summary>
+    [Fact]
+    public void Guard_RunsBeforeRoleCodeResolution()
+    {
+        var s = Engine();
+        var guardAt = s.IndexOf("await EnsureRoleAuthorityTenantScopedAsync(ids, effectiveTenantId, authorityContext);", StringComparison.Ordinal);
+        var codeAt = s.IndexOf("codeRoleIds", StringComparison.Ordinal);
+        Assert.True(guardAt > 0, "未找到守卫调用点");
+        Assert.True(codeAt > guardAt, "守卫必须先于 RoleCode 解析执行");
+    }
+
+    /// <summary>跨租户 RoleId 必须 fail-closed;且刻意不做 auto-heal(不按 Code 隐式替换)。</summary>
+    [Fact]
+    public void Guard_FailsClosedOnCrossTenantRole_AndDoesNotAutoHeal()
+    {
+        var body = Slice(Engine(),
+            "private async Task EnsureRoleAuthorityTenantScopedAsync",
+            "/// <summary>\n    /// S8-EXCEPTION-FLOW-TENANT-CONTEXT-1");
+        Assert.Contains("r.TenantId != effectiveTenantId", body);
+        Assert.Contains("throw Oops.Oh(", body);
+        // auto-heal 会让坏定义继续成功运行、永远没人去修
+        Assert.DoesNotContain("codes.Contains(r.Code)", body);
+        Assert.DoesNotContain("roleIds.Add(", body);
+    }
+
+    /// <summary>三类失败必须可区分,且日志带全部定位字段。</summary>
+    [Theory]
+    [InlineData("CROSS_TENANT_ROLE_REFERENCE")]
+    [InlineData("INVALID_ROLE")]
+    [InlineData("LOCAL_ROLE_HAS_NO_MEMBER")]
+    public void Guard_ClassifiesFailures(string classification)
+    {
+        Assert.Contains(classification, Engine());
+    }
+
+    /// <summary>日志必须能定位到具体定义与节点。</summary>
+    [Fact]
+    public void Guard_LogsDiagnosticFields()
+    {
+        var s = Engine();
+        foreach (var field in new[] { "BizType={BizType}", "FlowId={FlowId}", "NodeId={NodeId}", "RoleRef={RoleRef}", "EffectiveTenantId={TenantId}" })
+            Assert.Contains(field, s);
+    }
+
+    /// <summary>Branch B 必须保留——历史定义仍可能存 RoleId,本批不是删除 RoleId 支持。</summary>
+    [Fact]
+    public void Guard_KeepsRoleIdBranch()
+    {
+        var s = Engine();
+        Assert.Contains("var userIds = await _userRoleRep.AsQueryable()", s);
+        Assert.Contains("ids.Contains(ur.RoleId)", s);
+    }
+
+    /// <summary>RoleCode 分支(目标路径)必须始终按当前租户 + 启用状态解析,本批不得改动它。</summary>
+    [Fact]
+    public void RoleCodeBranch_StaysTenantScoped()
+    {
+        var s = Engine();
+        Assert.Contains("r.TenantId == effectiveTenantId", s);
+        Assert.Contains("r.Status == StatusEnum.Enable", s);
+        Assert.Contains("codes.Contains(r.Code)", s);
+    }
+
+    /// <summary>成员过滤这道租户收口不得被移除——它是跨租户泄漏的最后一道闸。</summary>
+    [Fact]
+    public void MemberResolution_KeepsTenantScope()
+    {
+        Assert.Contains("u.TenantId == effectiveTenantId", Engine());
+    }
+
+    private static string Slice(string src, string from, string to)
+    {
+        var a = src.IndexOf(from, StringComparison.Ordinal);
+        Assert.True(a >= 0, $"未找到起点:{from}");
+        var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal);
+        return b > a ? src[a..b] : src[a..];
+    }
+
+    private static string FindFile(params string[] parts)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null)
+        {
+            var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
+            if (File.Exists(candidate)) return candidate;
+            dir = dir.Parent;
+        }
+        throw new FileNotFoundException(string.Join("/", parts));
+    }
+}

+ 3 - 1
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/ApprovalFlow/S8ExceptionFlowTenantContextContractTests.cs

@@ -90,8 +90,10 @@ public class S8ExceptionFlowTenantContextContractTests
     [Fact]
     public void ResolveApprovers_Signature_TakesExplicitEffectiveTenantId()
     {
+        // S6-TENANT-AUTHORITY-1 追加了可选的诊断上下文参数(默认 null,不影响本断言的原意:
+        // effectiveTenantId 必须由调用方显式传入,而不是方法内部去读 _userManager.TenantId)。
         Assert.Contains(
-            "private async Task<List<(long userId, string userName)>> ResolveApprovers(FlowProperties? props, long initiatorId, long effectiveTenantId)",
+            "private async Task<List<(long userId, string userName)>> ResolveApprovers(FlowProperties? props, long initiatorId, long effectiveTenantId,",
             FlowEngine());
     }
 

+ 84 - 2
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Service/FlowEngine/FlowEngineService.cs

@@ -827,7 +827,8 @@ public class FlowEngineService : ITransient
         // S8-EXCEPTION-FLOW-TENANT-CONTEXT-1:trustedTenantId 仅由 StartFlowCore 的受信任路径透传;
         // 其余既有推进路径(Approve/ReturnToPrev/超时自动通过等)不传,回退到 _userManager.TenantId,行为不变。
         var effectiveTenantId = trustedTenantId ?? _userManager.TenantId;
-        var approvers = await ResolveApprovers(node.Properties, instance.InitiatorId, effectiveTenantId);
+        var approvers = await ResolveApprovers(node.Properties, instance.InitiatorId, effectiveTenantId,
+            new FlowAuthorityContext(instance.BizType, instance.FlowId, nodeId, node.Properties?.NodeName));
         if (approvers.Count == 0)
             throw Oops.Oh($"节点 [{node.Properties?.NodeName ?? nodeId}] 未配置审批人或审批人列表为空");
 
@@ -928,12 +929,77 @@ public class FlowEngineService : ITransient
         await _taskRep.AsUpdateable(paired).ExecuteCommandAsync();
     }
 
+    /// <summary>
+    /// S6-TENANT-AUTHORITY-1:审批人解析的诊断上下文,供跨租户权威守卫判定 BizType 并输出可定位日志。
+    /// 不参与任何业务判断,缺省为 null 时守卫整体不生效(保持既有行为)。
+    /// </summary>
+    private sealed record FlowAuthorityContext(string? BizType, long FlowId, string? NodeId, string? NodeName);
+
+    /// <summary>
+    /// S6-TENANT-AUTHORITY-1:**已完成**租户本地化迁移的 BizType 白名单。
+    ///
+    /// 为什么是白名单而不是全局生效:S5_IQC_INSPBILL / S7_FQC_INSPBILL 等 BizType 的流程定义同样
+    /// 写死了默认租户物理 RoleId,而它们**当前正靠跨租户 SysUserRole 绑定在工作**(实测 28 个节点组合)。
+    /// 无条件启用会把这些仍可用的节点当场打成 0 审批人,且这些模块本批不在迁移范围内。
+    /// 各模块完成「租户本地角色 + 同租户成员 + RoleCode 定义」迁移后,再把自己的 BizType 加进来。
+    /// </summary>
+    private static readonly HashSet<string> TenantStrictRoleBizTypes = new(StringComparer.Ordinal)
+    {
+        "IPQC_INSPECTION",
+        "S6_PROCESS_INSPECTION",
+    };
+
+    private static bool IsTenantStrictBizType(string? bizType) =>
+        bizType != null && TenantStrictRoleBizTypes.Contains(bizType);
+
+    /// <summary>
+    /// S6-TENANT-AUTHORITY-1:对白名单内的 BizType,FlowJson 中写死的数字 RoleId 必须属于当前租户。
+    ///
+    /// 原行为是无条件信任该 RoleId,只在最后按 <c>SysUser.TenantId</c> 过滤成员——那是「成员归属过滤」,
+    /// 挡得住跨租户泄漏,挡不住跨租户**引用**:角色属于别的租户时静默解析成 0 人,
+    /// 报错却是「未配置审批人」,把配置缺陷伪装成配置缺失。此处改为显式 fail-closed 并分类。
+    ///
+    /// 刻意**不做** auto-heal(不按 Code 隐式替换成本租户角色):那会让错误定义继续成功运行、
+    /// 永远没人去修,迁移也就永远不会真正完成。正确路径是把定义改成 RoleCode。
+    /// </summary>
+    private async Task EnsureRoleAuthorityTenantScopedAsync(List<long> roleIds, long effectiveTenantId, FlowAuthorityContext? ctx)
+    {
+        if (roleIds.Count == 0 || !IsTenantStrictBizType(ctx?.BizType)) return;
+
+        var roles = await _userRep.Context.Queryable<SysRole>()
+            .ClearFilter()
+            .Where(r => roleIds.Contains(r.Id))
+            .ToListAsync();
+
+        var missing = roleIds.Except(roles.Select(r => r.Id)).ToList();
+        if (missing.Count > 0)
+        {
+            _logger.LogWarning(
+                "INVALID_ROLE: BizType={BizType}, FlowId={FlowId}, NodeId={NodeId}, RoleRef={RoleRef}, EffectiveTenantId={TenantId}",
+                ctx!.BizType, ctx.FlowId, ctx.NodeId, string.Join(",", missing), effectiveTenantId);
+            throw Oops.Oh($"节点 [{ctx.NodeName ?? ctx.NodeId}] 配置的审批角色不存在,请检查流程定义。");
+        }
+
+        var foreign = roles.Where(r => r.TenantId != effectiveTenantId).ToList();
+        if (foreign.Count == 0) return;
+
+        // 日志带全部定位字段;对外文案不暴露其它租户的 Id 与角色名称。
+        _logger.LogWarning(
+            "CROSS_TENANT_ROLE_REFERENCE: BizType={BizType}, FlowId={FlowId}, NodeId={NodeId}, RoleRef={RoleRef}, RoleTenants={RoleTenants}, EffectiveTenantId={TenantId}",
+            ctx!.BizType, ctx.FlowId, ctx.NodeId,
+            string.Join(",", foreign.Select(r => r.Id)),
+            string.Join(",", foreign.Select(r => r.TenantId)),
+            effectiveTenantId);
+        throw Oops.Oh($"节点 [{ctx.NodeName ?? ctx.NodeId}] 引用了不属于当前租户的审批角色,请将流程定义改为按角色编码配置。");
+    }
+
     /// <summary>
     /// S8-EXCEPTION-FLOW-TENANT-CONTEXT-1:<paramref name="effectiveTenantId"/> 是本方法内所有租户相关判断
     /// 的唯一真值源——Role Code → RoleId 查询、RoleId → 用户查询,一律使用它,不得再读 _userManager.TenantId。
     /// 调用方负责传入正确值:HTTP 场景传 <c>_userManager.TenantId</c>;受信任后台场景传调用方已持有的业务租户。
     /// </summary>
-    private async Task<List<(long userId, string userName)>> ResolveApprovers(FlowProperties? props, long initiatorId, long effectiveTenantId)
+    private async Task<List<(long userId, string userName)>> ResolveApprovers(FlowProperties? props, long initiatorId, long effectiveTenantId,
+        FlowAuthorityContext? authorityContext = null)
     {
         if (props == null || string.IsNullOrWhiteSpace(props.ApproverType))
             return new List<(long, string)>();
@@ -965,6 +1031,11 @@ public class FlowEngineService : ITransient
 
         if (approverType == nameof(ApproverTypeEnum.Role))
         {
+            // S6-TENANT-AUTHORITY-1:对已完成租户本地化迁移的 BizType,数字 RoleId 必须属于当前租户。
+            // 放在 Code 解析之前,针对的是 FlowJson 里原样写死的物理 RoleId(codes 分支解析出的
+            // RoleId 天生就带 TenantId == effectiveTenantId 条件,不在检查范围)。
+            await EnsureRoleAuthorityTenantScopedAsync(ids, effectiveTenantId, authorityContext);
+
             if (codes.Count > 0)
             {
                 var codeRoleIds = await _userRep.Context.Queryable<SysRole>()
@@ -993,6 +1064,17 @@ public class FlowEngineService : ITransient
                 .ClearFilter()
                 .Where(u => userIds.Contains(u.Id) && u.TenantId == effectiveTenantId)
                 .ToListAsync();
+
+            // S6-TENANT-AUTHORITY-1:第三类失败——角色配置正确、就是本租户内没人。
+            // 原先与「跨租户误配」一样退化成空列表,最终只报一句语义误导的「未配置审批人」。
+            if (users.Count == 0 && IsTenantStrictBizType(authorityContext?.BizType))
+            {
+                _logger.LogWarning(
+                    "LOCAL_ROLE_HAS_NO_MEMBER: BizType={BizType}, FlowId={FlowId}, NodeId={NodeId}, RoleRef={RoleRef}, EffectiveTenantId={TenantId}",
+                    authorityContext!.BizType, authorityContext.FlowId, authorityContext.NodeId, props.ApproverIds, effectiveTenantId);
+                throw Oops.Oh($"节点 [{authorityContext.NodeName ?? authorityContext.NodeId}] 的审批角色在当前租户下没有成员,请先为该角色分配人员。");
+            }
+
             return users.Select(u => (u.Id, u.RealName ?? "")).ToList();
         }