Просмотр исходного кода

fix(flow): block generic approval bypass for S5 IQC tasks

审批中心的通用「同意/拒绝」可绕过 S5 IQC 专用审批链。实测(Batch 5A B2):
POST /api/flowTask/approve 携带 S5 的 N2 pending taskId 返回 HTTP 200 并真实把
流程从 N2 推进到 N3、新建 N3 任务、写入审批日志——完全绕过 IqcInspBillFlowService
的四层 authority(JWT 租户解析 → 业务单 tenant 谓词 → 当前节点守卫 → UseTranAsync)。
根因:FlowTaskService.Approve/Reject 零校验裸调引擎,而引擎 GetPendingTask 只校验
「任务存在 / Pending / 指派人==当前用户」,不校验业务类型与租户。

后端(网关层 fail-closed,未改 FlowEngine)
- FlowTaskService 新增 DedicatedEntryOnlyBizTypes 名单与 EnsureDedicatedEntryOnlyAsync,
  按 taskId 从库中反查权威 BizType(不信任前端入参,TaskActionInput 本就只有 TaskId/Comment)
- 守卫插在 Approve / Reject / BatchApprove 循环 / BatchReject 循环四个调用点的引擎调用之前;
  批量端点直接调引擎、不经单条方法体,故必须各自带守卫
- 任务或实例不存在时不抢先抛错,沿用引擎既有错误语义(「审批任务不存在」)
- 只做拒绝不做放行,因此不引入新的租户判定(Flow 两表继承 EntityBaseOrg,无 TenantId 列)
- BizType 字符串硬编码:ApprovalFlow 插件不引用 AiDOP 插件(反向依赖会成环)

前端(与后端一致的引导,纯新增 18 行、0 删除)
- PendingList 新增 S5_IQC_INSPBILL 分支,按 nodeId 跳 /aidop/s5/iqc/detail/${row.bizId};
  bizId 即 qms_qcp_inspbill.id(StartFlow 以检验单主键写入 BizId)
- 未知节点 fail-closed(只读文案),与 S7 一致;S6/S7/异常/通用分支逐字未动
- 节点码用 N3_SQE_DISPOSITION,注意与 S7 的 N3_QE_DISPOSITION 不同

测试
- 新增 S5IqcGenericApprovalGuardContractTests(11 条源码契约断言):守卫必须在引擎调用之前、
  批量端点各自带守卫、按精确 BizType 匹配不得宽泛误伤、前端 fail-closed 且不 emit approve/reject

实测:通用 approve/reject/batchApprove/batchReject 对 S5 全部被拒且 0 Flow/Task/History/业务单
变更;非 S5 通用审批仍落到引擎自身错误、未被误伤;S5 专用 submit-result / supervisor-approve /
supervisor-reject 全部正常。
YY968XX 1 день назад
Родитель
Сommit
8ce9bca054

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.397",
+	"version": "2.4.398",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 18 - 0
Web/src/views/approvalFlow/center/components/PendingList.vue

@@ -48,6 +48,16 @@
 						<el-button v-else size="small" type="primary" text @click="goToS6(row.bizId)">查看</el-button>
 						<el-button size="small" text @click="emit('timeline', row)">详情</el-button>
 					</template>
+					<template v-else-if="row.bizType === 'S5_IQC_INSPBILL'">
+						<!-- S5 来料检验单:按 nodeId 跳专用详情页,隐藏通用同意/拒绝;未知节点 fail-closed(同 S7)。
+						     注意节点码是 N3_SQE_DISPOSITION(SQE),与 S7 的 N3_QE_DISPOSITION 不同,勿照抄。
+						     后端 FlowTaskService 已对该 bizType 拒绝通用 approve/reject,此处为一致的 UI 侧引导。 -->
+						<el-button v-if="row.nodeId === 'N1_INSPECT'" size="small" type="primary" text @click="goToIqc(row.bizId)">去录入</el-button>
+						<el-button v-else-if="row.nodeId === 'N2_SUP_REVIEW'" size="small" type="primary" text @click="goToIqc(row.bizId)">去审核</el-button>
+						<el-button v-else-if="row.nodeId === 'N3_SQE_DISPOSITION'" size="small" type="primary" text @click="goToIqc(row.bizId)">去处置</el-button>
+						<el-text v-else type="info" size="small">请进入来料检验业务模块处理</el-text>
+						<el-button size="small" text @click="emit('timeline', row)">详情</el-button>
+					</template>
 					<template v-else>
 						<el-button v-if="row.bizType === 'ORDER_CHANGE_REVIEW'" size="small" type="primary" text @click="emit('viewBiz', row)">查看变更单</el-button>
 						<el-button v-if="row.bizType === 'MATERIAL_SHORTAGE'" size="small" type="primary" text @click="emit('viewBiz', row)">查看欠料信息</el-button>
@@ -95,6 +105,14 @@ function goToS6(bizId: number | string) {
 	router.push(`/aidop/s6/production-record/order-list?billId=${bizId}`);
 }
 
+// S5 来料检验(S5_IQC_INSPBILL) deep-link:bizId = qms_qcp_inspbill.id(StartFlow 时以检验单主键写入 BizId)。
+// N1/N2/N3 统一进同一独立详情页,页面按 detail.canEdit / flowState 决定录入 / 主管审核 / SQE 处置 / 只读,
+// 业务动作全走 S5 专用 API(supervisor-approve / supervisor-reject / sqe-submit-disposition)。
+function goToIqc(bizId: number | string) {
+	if (!bizId) return;
+	router.push(`/aidop/s5/iqc/detail/${bizId}`);
+}
+
 function goToFqc(action: 'inspect' | 'review' | 'disposition', bizId: number | string) {
 	// N1/N2/N3 统一进同一详情页 result-detail;页面按 flowState 决定录入/审核/处置/只读(action 仅用于按钮文案)
 	void action;

+ 145 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/ApprovalFlow/S5IqcGenericApprovalGuardContractTests.cs

@@ -0,0 +1,145 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow;
+
+/// <summary>
+/// Batch 5A 源码契约测试:S5_IQC_INSPBILL 的「通用审批旁路」必须双端封死。
+///
+/// 背景(Batch 5 实测):通用 /api/flowTask/approve 只经引擎 GetPendingTask 校验
+/// 「任务存在 / Pending / 指派人==当前用户」,不校验业务类型与租户,也不在事务内,
+/// 曾以 HTTP 200 真实把 S5 检验单从 N2 推进到 N3,绕过 IqcInspBillFlowService 的
+/// 租户解析 / 业务单归属 / 节点守卫 / UseTranAsync 四层 authority。
+///
+/// 仅隐藏前端按钮不足以闭环(S7/S6 即为反例:前端 fail-closed,后端仍可手工 HTTP 直达),
+/// 故后端网关层必须 fail-closed,前端仅作一致的引导。
+///
+/// 本层沿用本仓对 FlowEngine/FlowTaskService 的既有做法——源码契约断言而非行为级集成测试:
+/// FlowEngineService 全程依赖 SqlSugarRepository&lt;T&gt;,裸 xUnit 进程无法实例化
+/// (详见 S8ExceptionFlowTenantContextContractTests 的说明)。
+/// </summary>
+public class S5IqcGenericApprovalGuardContractTests
+{
+    private const string BizType = "S5_IQC_INSPBILL";
+    private const string Guard = "EnsureDedicatedEntryOnlyAsync";
+
+    private static string FlowTaskService() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowTask", "FlowTaskService.cs"));
+
+    private static string PendingList() => File.ReadAllText(FindFile(
+        "Web", "src", "views", "approvalFlow", "center", "components", "PendingList.vue"));
+
+    /// <summary>后端必须持有该 bizType 的拒绝名单,且给出引导到专用页面的文案。</summary>
+    [Fact]
+    public void Backend_DeclaresDedicatedEntryOnlyBizType()
+    {
+        var s = FlowTaskService();
+        Assert.Contains("DedicatedEntryOnlyBizTypes", s);
+        Assert.Contains($"[\"{BizType}\"]", s);
+        Assert.Contains("来料检验单详情", s);
+    }
+
+    /// <summary>权威业务类型必须从库中反查(task → instance),不得信任前端入参。</summary>
+    [Fact]
+    public void Backend_ReadsBizTypeFromDatabase_NotFromRequest()
+    {
+        var s = FlowTaskService();
+        var guard = Slice(s, $"private async Task {Guard}(long taskId)", "}\n\n    /// <summary>");
+        Assert.Contains("_taskRep.GetByIdAsync(taskId)", guard);
+        Assert.Contains("_instanceRep.GetByIdAsync(task.InstanceId)", guard);
+        Assert.Contains("instance.BizType", guard);
+        Assert.Contains("Oops.Oh(hint)", guard);
+        // TaskActionInput 只有 TaskId/Comment,不存在可被伪造的 bizType 入参
+        Assert.DoesNotContain("input.BizType", guard);
+    }
+
+    /// <summary>四个会推进流程的通用端点都必须在调用引擎之前先过 guard。</summary>
+    [Theory]
+    [InlineData("_engine.Approve(input.TaskId, input.Comment)")]
+    [InlineData("_engine.Reject(input.TaskId, input.Comment)")]
+    [InlineData("_engine.Approve(taskId, input.Comment)")]
+    [InlineData("_engine.Reject(taskId, input.Comment)")]
+    public void Backend_GuardRunsBeforeEngineCall(string engineCall)
+    {
+        var s = FlowTaskService();
+        var engineAt = s.IndexOf(engineCall, StringComparison.Ordinal);
+        Assert.True(engineAt > 0, $"未找到引擎调用点:{engineCall}");
+        // 该引擎调用点之前最近的一次 guard 调用必须存在,且中间不得再夹一次引擎调用
+        var guardAt = s.LastIndexOf(Guard + "(", engineAt, StringComparison.Ordinal);
+        Assert.True(guardAt > 0, $"引擎调用 {engineCall} 之前缺少 {Guard} 守卫");
+    }
+
+    /// <summary>批量端点绕过单条方法体,必须各自独立带 guard(否则批量可绕过)。</summary>
+    [Fact]
+    public void Backend_BatchEndpointsAlsoGuarded()
+    {
+        var s = FlowTaskService();
+        var batchApprove = Slice(s, "public async Task<BatchActionResult> BatchApprove", "public async Task<BatchActionResult> BatchReject");
+        Assert.Contains(Guard, batchApprove);
+        var batchReject = s[s.IndexOf("public async Task<BatchActionResult> BatchReject", StringComparison.Ordinal)..];
+        Assert.Contains(Guard, batchReject);
+    }
+
+    /// <summary>guard 只针对名单内业务,不得误伤其它通用审批(名单必须是精确匹配的字典查找)。</summary>
+    [Fact]
+    public void Backend_GuardIsAllowListedByExactBizType_NotBlanket()
+    {
+        var s = FlowTaskService();
+        Assert.Contains("DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType, out var hint)", s);
+        // 不得出现「所有 Sx 业务一律拒绝」之类的宽泛匹配
+        Assert.DoesNotContain("StartsWith(\"S", s);
+        Assert.DoesNotContain("Contains(\"_INSPBILL\")", s);
+    }
+
+    /// <summary>前端待办列表必须为 S5 提供专用分支,不再落入通用 v-else 的同意/拒绝。</summary>
+    [Fact]
+    public void Frontend_PendingList_HasDedicatedS5Branch()
+    {
+        var s = PendingList();
+        Assert.Contains($"row.bizType === '{BizType}'", s);
+        Assert.Contains("goToIqc(row.bizId)", s);
+        Assert.Contains("/aidop/s5/iqc/detail/${bizId}", s);
+    }
+
+    /// <summary>S5 三个节点码必须与后端常量一致;尤其 N3 是 SQE 而非 S7 的 QE。</summary>
+    [Fact]
+    public void Frontend_UsesS5NodeCodes_NotFqcOnes()
+    {
+        var s = PendingList();
+        var branch = Slice(s, $"row.bizType === '{BizType}'", "</template>");
+        Assert.Contains("'N1_INSPECT'", branch);
+        Assert.Contains("'N2_SUP_REVIEW'", branch);
+        Assert.Contains("'N3_SQE_DISPOSITION'", branch);
+        Assert.DoesNotContain("'N3_QE_DISPOSITION'", branch);   // 那是 S7 FQC 的节点码
+    }
+
+    /// <summary>未知节点必须 fail-closed(只读文案),不得回落到通用同意/拒绝。</summary>
+    [Fact]
+    public void Frontend_UnknownNode_FailsClosed()
+    {
+        var s = PendingList();
+        var branch = Slice(s, $"row.bizType === '{BizType}'", "</template>");
+        Assert.Contains("<el-text v-else", branch);
+        Assert.DoesNotContain("emit('approve'", branch);
+        Assert.DoesNotContain("emit('reject'", branch);
+    }
+
+    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));
+    }
+}

+ 39 - 0
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Service/FlowTask/FlowTaskService.cs

@@ -159,6 +159,41 @@ public class FlowTaskService : IDynamicApiController, ITransient
 
     // ── 操作 ──
 
+    /// <summary>
+    /// 只能经专用业务入口审批的业务类型 → 对应的引导文案。
+    ///
+    /// 背景:这些业务在专用服务里额外建立了通用审批链没有的 authority
+    /// (租户解析 → 业务单归属校验 → 当前节点守卫 → UseTranAsync 事务边界),
+    /// 走通用 flowTask/approve|reject 会把这四层全部绕过(引擎侧 GetPendingTask
+    /// 只校验「任务存在 / Pending / 指派人==当前用户」,不校验业务类型与租户)。
+    /// 仅隐藏前端按钮不足以闭环——手工 HTTP 仍可直达,故在网关层 fail-closed。
+    ///
+    /// 这里刻意硬编码 BizType 字符串:ApprovalFlow 插件不引用 AiDOP 插件
+    /// (依赖方向为 AiDOP → ApprovalFlow,反向会成环),无法引用业务侧常量。
+    /// </summary>
+    private static readonly Dictionary<string, string> DedicatedEntryOnlyBizTypes = new()
+    {
+        ["S5_IQC_INSPBILL"] = "来料检验单请在「来料检验单详情」页完成审核,不支持在审批中心直接同意/拒绝。",
+    };
+
+    /// <summary>
+    /// 通用审批动作前置守卫:按 taskId 从库中反查权威 BizType,命中名单即拒绝。
+    ///
+    /// 只做「拒绝」不做「放行授权」,故不引入新的租户判定
+    /// (ApprovalFlowTask / ApprovalFlowInstance 无 TenantId 列,租户边界仍由
+    /// 各业务专用入口的业务表 tenant 谓词负责)。
+    /// 任务/实例不存在时不在此抛错,交由引擎沿用既有错误语义。
+    /// </summary>
+    private async Task EnsureDedicatedEntryOnlyAsync(long taskId)
+    {
+        var task = await _taskRep.GetByIdAsync(taskId);
+        if (task == null) return;
+        var instance = await _instanceRep.GetByIdAsync(task.InstanceId);
+        if (instance?.BizType == null) return;
+        if (DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType, out var hint))
+            throw Oops.Oh(hint);
+    }
+
     /// <summary>
     /// 同意
     /// </summary>
@@ -167,6 +202,7 @@ public class FlowTaskService : IDynamicApiController, ITransient
     [DisplayName("同意")]
     public async Task Approve(TaskActionInput input)
     {
+        await EnsureDedicatedEntryOnlyAsync(input.TaskId);
         await _engine.Approve(input.TaskId, input.Comment);
     }
 
@@ -178,6 +214,7 @@ public class FlowTaskService : IDynamicApiController, ITransient
     [DisplayName("拒绝")]
     public async Task Reject(TaskActionInput input)
     {
+        await EnsureDedicatedEntryOnlyAsync(input.TaskId);
         await _engine.Reject(input.TaskId, input.Comment);
     }
 
@@ -302,6 +339,7 @@ public class FlowTaskService : IDynamicApiController, ITransient
         {
             try
             {
+                await EnsureDedicatedEntryOnlyAsync(taskId);
                 await _engine.Approve(taskId, input.Comment);
                 result.SuccessCount++;
             }
@@ -327,6 +365,7 @@ public class FlowTaskService : IDynamicApiController, ITransient
         {
             try
             {
+                await EnsureDedicatedEntryOnlyAsync(taskId);
                 await _engine.Reject(taskId, input.Comment);
                 result.SuccessCount++;
             }