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

fix(s8): enforce debug tenant boundary | server 1.0.560

AdoS8WatchDebugController 是 S8 中唯一把 query 上的 tenantId 当执行上下文的入口:
run-once 直接把它传给 S8WatchSchedulerService.CreateExceptionsAsync(tenantId),
可在任意租户建单,并从出参 createdExceptionId / relatedObjectCode /
matchedExceptionId 读回该租户的业务事实;test-cleanup 同理可软删任意租户的
TEST_G09_ 数据。此前唯一阻挡是 WatchScheduler:DebugEndpointEnabled ——
那是功能开关,不是信任边界。

现在租户一律由 S8TrustedScopeResolver 从认证身份解析(最终落到
AidopTenantScope.ResolveOrThrow,只信 JWT),与 S8 其余 Controller 同一个
authority,不新增第二套租户解析。query 上的 tenantId / factoryId 仅保留形参
以兼容既有调用方,不承担任何安全边界作用,显式丢弃。

设计目标:即使 DebugEndpointEnabled 被误开到生产,本入口也不可能跨租户 ——
开关只决定“能不能用”,不再决定“能动谁的数据”。

超管口径沿用平台既有策略,不在此另开全租户调试权:ResolveOrThrow 对
“超管 + 未选目标租户”直接抛异常,超管须先切到目标租户后以该租户身份执行。

新增 S8DebugTenantGuardTests(9 例)钉住该边界:去注释后的源码契约 + 反射元数据,
覆盖作用域解析、兼容形参必须丢弃、读写均限于已解析租户、未启用时 404 早于任何
作用域解析与 service 调用、超管不得在此获得跨租户能力、不得引入第二套租户 authority。
对修复前的实现 9 例中 6 例失败(RED 验证),确保不是空守卫。

验证:build 0 error;插件 S8 用例 820/821 通过,唯一失败为既有基线
MutationActions_DoNotUseReadOnlyCapabilities(AdoS8OrderReviewKpiController.RefreshAsync
→ s8:dashboard:read),已确认在 origin/master 原样复现,净新增失败 0。
YY968XX 2 дней назад
Родитель
Сommit
ff6f8e2bba

+ 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.559</AssemblyVersion>
-    <FileVersion>1.0.559</FileVersion>
-    <Version>1.0.559</Version>
+    <AssemblyVersion>1.0.560</AssemblyVersion>
+    <FileVersion>1.0.560</FileVersion>
+    <Version>1.0.560</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 215 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8DebugTenantGuardTests.cs

@@ -0,0 +1,215 @@
+using Admin.NET.Plugin.AiDOP.Const.S8;
+using Admin.NET.Plugin.AiDOP.Controllers.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using System.Reflection;
+using System.Text.RegularExpressions;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-DEBUG-TENANT-GUARD-1:<see cref="AdoS8WatchDebugController"/> 的租户信任边界。
+///
+/// <para><b>修复前的事实</b>(2026-09-20 只读审计):本 Controller 是 S8 中唯一把 query 上的
+/// <c>tenantId</c> 当执行上下文的入口。<c>run-once</c> 把它直接传给
+/// <c>S8WatchSchedulerService.CreateExceptionsAsync(tenantId)</c>,可在<b>任意租户</b>建单,
+/// 并从出参读回该租户的 <c>createdExceptionId</c> / <c>relatedObjectCode</c> / <c>matchedExceptionId</c>;
+/// <c>test-cleanup</c> 可软删任意租户的 TEST_G09_ 数据。唯一的阻挡是
+/// <c>WatchScheduler:DebugEndpointEnabled</c> —— 一个<b>功能开关</b>,不是信任边界。</para>
+///
+/// <para><b>为什么是源码契约测试而不是运行时测试</b>:本 Controller 依赖
+/// <c>S8WatchSchedulerService</c>(需要 SqlSugar 实库)与 <c>S8TrustedScopeResolver</c>(需要
+/// 认证后的 <c>UserManager</c>),在进程内无法构造 —— 与
+/// <c>S8ExceptionFlowTenantContextContractTests</c> 同一处境,沿用同一手法:
+/// <b>反射元数据 + 去注释后的源码契约</b>。本测试<b>不</b>断言运行时会返回 403/404,
+/// 它断言的是「代码里不存在那条跨租户通路」。</para>
+///
+/// <para>断言一律在<b>剥离注释后</b>的源码上进行:否则文档里提到 <c>tenantId</c> 就会把
+/// 守卫变成噪音,或在有人改文案时假失败。</para>
+/// </summary>
+public class S8DebugTenantGuardTests
+{
+    private static string PluginRoot => Path.GetFullPath(
+        Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
+
+    private static string ReadCode(string relativePath)
+    {
+        var raw = File.ReadAllText(Path.Combine(PluginRoot, relativePath));
+        // 先删块注释,再删行注释(含 /// 文档注释)。字符串字面量里不含 "//",本文件范围内安全。
+        var noBlock = Regex.Replace(raw, @"/\*.*?\*/", string.Empty, RegexOptions.Singleline);
+        return Regex.Replace(noBlock, @"^\s*//.*$", string.Empty, RegexOptions.Multiline);
+    }
+
+    private static string DebugControllerCode => ReadCode("Controllers/S8/AdoS8WatchDebugController.cs");
+
+    private static int Count(string haystack, string needle)
+    {
+        var n = 0;
+        for (var i = haystack.IndexOf(needle, StringComparison.Ordinal); i >= 0;
+             i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal)) n++;
+        return n;
+    }
+
+    private static IEnumerable<MethodInfo> DebugActions => typeof(AdoS8WatchDebugController)
+        .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
+        .Where(m => !m.IsSpecialName);
+
+    // ───────────────── ① 当前租户可正常执行 ─────────────────
+
+    /// <summary>
+    /// 两个 Action 都必须从可信作用域取租户并据此执行。
+    /// 这既是「当前租户可用」的正面断言,也是租户来源的唯一合法形态。
+    /// </summary>
+    [Fact]
+    public void CurrentTenant_DebugExecution_UsesResolvedScope()
+    {
+        var code = DebugControllerCode;
+
+        Assert.Equal(2, Count(code, "_scope.ResolveAsync()"));
+        Assert.Contains("CreateExceptionsAsync(scope.TenantId)", code);
+        Assert.Contains("SoftDeleteTestPrefixAsync(scope.TenantId)", code);
+    }
+
+    /// <summary>可信作用域解析器必须由构造函数注入 —— 反射元数据,不可被改文案绕过。</summary>
+    [Fact]
+    public void Controller_InjectsTrustedScopeResolver()
+    {
+        var ctor = Assert.Single(typeof(AdoS8WatchDebugController).GetConstructors());
+        Assert.Contains(ctor.GetParameters(), p => p.ParameterType == typeof(S8TrustedScopeResolver));
+    }
+
+    // ───────────────── ② 客户端传入的 tenantId 不得切换执行上下文 ─────────────────
+
+    /// <summary>
+    /// query 上的 tenantId / factoryId 只能被显式丢弃,绝不能流入任何 service 调用。
+    /// </summary>
+    [Fact]
+    public void ClientSuppliedOtherTenant_CannotSwitchTenant()
+    {
+        var code = DebugControllerCode;
+
+        // 兼容形参必须被显式丢弃(run-once 的 tenantId+factoryId、test-cleanup 的 tenantId)。
+        Assert.Equal(2, Count(code, "_ = tenantId;"));
+        Assert.Contains("_ = factoryId;", code);
+
+        // 修复前的两条跨租户通路,必须不复存在。
+        Assert.DoesNotContain("CreateExceptionsAsync(tenantId)", code);
+        Assert.DoesNotContain("SoftDeleteTestPrefixAsync(tenantId", code);
+
+        // 不得以任何形式把 query 参数重新当作租户使用。
+        Assert.DoesNotContain("tenantId.Value", code);
+        Assert.DoesNotContain("tenantId ??", code);
+    }
+
+    /// <summary>
+    /// ③/④ 合并断言:读回(run-once 出参)与写入(建单 / 软删)都只能发生在已解析租户上。
+    /// 两个 service 调用是本 Controller 仅有的数据出入口,全部以 scope.TenantId 为准,
+    /// 因此不存在「读到别家租户 exception」或「在别家租户建单」的通路。
+    /// </summary>
+    [Fact]
+    public void DebugReadAndWrite_AreConfinedToResolvedTenant()
+    {
+        var code = DebugControllerCode;
+
+        var serviceCalls = new[] { "CreateExceptionsAsync(", "SoftDeleteTestPrefixAsync(" };
+        foreach (var call in serviceCalls)
+        {
+            Assert.Equal(1, Count(code, call));
+            var idx = code.IndexOf(call, StringComparison.Ordinal);
+            var arg = code[(idx + call.Length)..code.IndexOf(')', idx)];
+            Assert.Equal("scope.TenantId", arg.Trim());
+        }
+    }
+
+    // ───────────────── ⑤ 未启用时的拒绝语义 ─────────────────
+
+    /// <summary>
+    /// DebugEndpointEnabled=false 时两个 Action 都必须 404,且该门必须在
+    /// 作用域解析与任何 service 调用<b>之前</b>返回(DB 零触达)。
+    /// </summary>
+    [Fact]
+    public void DebugDisabled_ReturnsNotFound_BeforeAnyWork()
+    {
+        var code = DebugControllerCode;
+        const string gate = "if (!_debugEndpointEnabled) return NotFound();";
+
+        Assert.Equal(2, Count(code, gate));
+
+        // 逐个 Action 校验顺序:第 n 道门必须早于第 n 次作用域解析。
+        var gateIdx = -1;
+        var scopeIdx = -1;
+        for (var i = 0; i < 2; i++)
+        {
+            gateIdx = code.IndexOf(gate, gateIdx + 1, StringComparison.Ordinal);
+            scopeIdx = code.IndexOf("_scope.ResolveAsync()", scopeIdx + 1, StringComparison.Ordinal);
+            Assert.True(gateIdx >= 0 && scopeIdx >= 0 && gateIdx < scopeIdx,
+                $"第 {i + 1} 个 Action 的 DebugEndpointEnabled 门禁必须早于作用域解析。");
+        }
+
+        // 默认值必须是 false(fail-closed):缺配置时不得默认开启。
+        Assert.Contains("GetValue(\"WatchScheduler:DebugEndpointEnabled\", false)", code);
+    }
+
+    // ───────────────── ⑥ 超管:沿用平台既有策略,不自行放权 ─────────────────
+
+    /// <summary>
+    /// 本 Controller 不得出现任何超管特判 —— 调试入口不是新开全租户权限的地方。
+    /// 超管口径完全由 <see cref="AidopTenantScope"/> 决定。
+    /// </summary>
+    [Fact]
+    public void SuperAdmin_GetsNoCrossTenantDebugPrivilegeHere()
+    {
+        var code = DebugControllerCode;
+
+        Assert.DoesNotContain("SuperAdmin", code);
+        Assert.DoesNotContain("ClearFilter", code);
+        Assert.DoesNotContain("IgnoreQueryFilters", code);
+    }
+
+    /// <summary>
+    /// 平台既有超管策略必须仍然是「未选目标租户 → 直接拒绝」(保守拒绝,不默认全租户)。
+    /// 本批不改这条策略,只确认调试入口继承的是它。
+    /// </summary>
+    [Fact]
+    public void TenantAuthority_StillRejectsSuperAdminWithoutTargetTenant()
+    {
+        var authority = ReadCode("Infrastructure/AidopTenantScope.cs");
+
+        Assert.Contains("userManager.SuperAdmin && tid == MainTenantId", authority);
+        Assert.Contains("throw Oops.Oh", authority);
+        Assert.Contains("if (tid <= 0)", authority);
+    }
+
+    // ───────────────── 单一 authority:不得新增第二套租户解析 ─────────────────
+
+    /// <summary>
+    /// 调试入口必须复用 S8 统一的租户 authority,不得自建第二套
+    /// (直接读 UserManager / 从配置或请求头取租户都算)。
+    /// </summary>
+    [Fact]
+    public void DebugEndpoint_DoesNotIntroduceSecondTenantAuthority()
+    {
+        var code = DebugControllerCode;
+
+        Assert.Contains("S8TrustedScopeResolver", code);
+        Assert.DoesNotContain("UserManager", code);
+        Assert.DoesNotContain("HttpContext", code);
+        Assert.DoesNotContain("Request.Headers", code);
+    }
+
+    /// <summary>两个 Action 仍须挂 s8:debug:run 能力门(本批不得顺手放松授权)。</summary>
+    [Fact]
+    public void DebugActions_StillRequireDebugRunPermission()
+    {
+        var actions = DebugActions.ToList();
+        Assert.Equal(2, actions.Count);
+
+        Assert.All(actions, m =>
+        {
+            var attr = m.GetCustomAttribute<S8PermissionAttribute>();
+            Assert.NotNull(attr);
+            Assert.Equal(S8PermissionCatalog.DebugRun, attr!.Code);
+        });
+    }
+}

+ 44 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8WatchDebugController.cs

@@ -1,5 +1,6 @@
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 using Admin.NET.Plugin.AiDOP.Const.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Service.S8;
 using Microsoft.Extensions.Configuration;
 
@@ -11,6 +12,28 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
 /// 不对外暴露业务语义,不作为长期 API。
 /// G08-06:移除匿名访问;由 appsettings 的 `WatchScheduler:DebugEndpointEnabled` 控制是否启用,
 /// 未启用时所有 Action 返回 404(语义等价于"未注册")。
+///
+/// <para><b>S8-DEBUG-TENANT-GUARD-1(2026-09-20):租户边界收口。</b>
+/// 修复前本 Controller 是 S8 里<b>唯一</b>把 <c>tenantId</c> 当作执行上下文的入口:
+/// <c>run-once</c> 直接把 query 里的 <c>tenantId</c>(默认值 1)传给
+/// <see cref="S8WatchSchedulerService.CreateExceptionsAsync"/>,于是调用方可以
+/// <b>在任意租户下建单</b>,并从出参 <c>createdExceptionId</c> / <c>relatedObjectCode</c> /
+/// <c>matchedExceptionId</c> <b>读回该租户的业务事实</b>;<c>test-cleanup</c> 同理可软删任意租户的
+/// <c>TEST_G09_</c> 数据。两者都只被 <c>DebugEndpointEnabled</c> 这一个<b>功能开关</b>挡着 ——
+/// 而功能开关不是信任边界。</para>
+///
+/// <para>现在租户一律由 <see cref="S8TrustedScopeResolver"/> 从认证身份解析
+/// (最终落到 <see cref="AidopTenantScope.ResolveOrThrow"/>,只信 JWT),
+/// 与 S8 其余 Controller <b>同一个</b> authority,不新增第二套租户解析。
+/// query 上的 <c>tenantId</c> / <c>factoryId</c> 仅保留形参以兼容既有调用方,
+/// <b>不承担任何安全边界作用</b>,显式丢弃。</para>
+///
+/// <para><b>设计目标</b>:即使 <c>DebugEndpointEnabled</c> 被误开到生产,
+/// 本入口也<b>不可能</b>跨租户 —— 开关只决定「能不能用」,不再决定「能动谁的数据」。</para>
+///
+/// <para><b>超管口径</b>:沿用平台既有策略,不在此另开全租户调试权。
+/// <see cref="AidopTenantScope.ResolveOrThrow"/> 对「超管 + 未选目标租户」直接抛异常;
+/// 超管须先切到目标租户(平台既有机制),切换后即以该租户身份执行。</para>
 /// </summary>
 [ApiController]
 [Route("api/aidop/s8/watch-debug")]
@@ -18,11 +41,16 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
 public class AdoS8WatchDebugController : ControllerBase
 {
     private readonly S8WatchSchedulerService _svc;
+    private readonly S8TrustedScopeResolver _scope;
     private readonly bool _debugEndpointEnabled;
 
-    public AdoS8WatchDebugController(S8WatchSchedulerService svc, IConfiguration configuration)
+    public AdoS8WatchDebugController(
+        S8WatchSchedulerService svc,
+        S8TrustedScopeResolver scope,
+        IConfiguration configuration)
     {
         _svc = svc;
+        _scope = scope;
         _debugEndpointEnabled = configuration.GetValue("WatchScheduler:DebugEndpointEnabled", false);
     }
 
@@ -34,7 +62,11 @@ public class AdoS8WatchDebugController : ControllerBase
     {
         if (!_debugEndpointEnabled) return NotFound();
 
-        var results = await _svc.CreateExceptionsAsync(tenantId);
+        // S8-DEBUG-TENANT-GUARD-1:兼容形参,安全作用域一律服务端解析。
+        _ = tenantId; _ = factoryId;
+        var scope = await _scope.ResolveAsync();
+
+        var results = await _svc.CreateExceptionsAsync(scope.TenantId);
         return Ok(new
         {
             count = results.Count,
@@ -56,7 +88,10 @@ public class AdoS8WatchDebugController : ControllerBase
     /// S2-EW:仅供 e2e fixture 使用的 TEST_G09_ 数据软删入口。
     /// 安全契约:
     /// - 与 run-once 共用 _debugEndpointEnabled;未启用时 404。
-    /// - tenantId 必须显式传入(无默认值),缺参由 ASP.NET 返回 400。
+    /// - S8-DEBUG-TENANT-GUARD-1:租户由认证身份解析,<b>不再</b>取 query 的 tenantId。
+    ///   原先「tenantId 必须显式传入、缺参 400」是当时唯一的防护手段(逼调用方自己写对租户);
+    ///   现在边界由服务端盖章,该 400 既不再提供安全价值,又会要求调用方传一个已被忽略的参数,
+    ///   故一并移除;形参保留仅为兼容既有调用方。
     /// - 前缀 TEST_G09_ 在 service 端硬编码,本接口不接受 prefix 入参。
     /// - 仅 soft delete(SET IsDeleted=true),不触发物理 DELETE。
     /// </summary>
@@ -67,9 +102,12 @@ public class AdoS8WatchDebugController : ControllerBase
         [FromServices] S8ExceptionService exceptionSvc)
     {
         if (!_debugEndpointEnabled) return NotFound();
-        if (tenantId is null)
-            return BadRequest(new { message = "tenantId is required (no default)." });
-        var deletedCount = await exceptionSvc.SoftDeleteTestPrefixAsync(tenantId.Value);
+
+        // S8-DEBUG-TENANT-GUARD-1:兼容形参,安全作用域一律服务端解析。
+        _ = tenantId;
+        var scope = await _scope.ResolveAsync();
+
+        var deletedCount = await exceptionSvc.SoftDeleteTestPrefixAsync(scope.TenantId);
         return Ok(new { deletedCount });
     }
 }