Quellcode durchsuchen

fix(s8): enforce trusted scope for master data bindings

YY968XX vor 3 Tagen
Ursprung
Commit
d287a98a60

+ 171 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8MasterDataScopeGuardTests.cs

@@ -0,0 +1,171 @@
+using Admin.NET.Plugin.AiDOP.Controllers.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using System.Reflection;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-P0-3-MASTERDATA-TRUSTED-SCOPE-1:S8 主数据 / 操作员绑定的跨租户越权守卫。
+///
+/// <para>背景(2026-09-02 以普通租户账号 UATExceptionA 实测,HTTP 200):</para>
+/// <list type="bullet">
+///   <item><c>GET /api/aidop/s8/config/operator-bindings</c>(不带任何参数)→ 500 行,
+///         按工厂分布 {1000: 498, 1329900200002: 2},**全部属于其它租户,本租户 0 行**;</item>
+///   <item><c>GET /api/aidop/s8/config/operator-bindings?factoryRefId=1000</c> → 500 条外租户员工;</item>
+///   <item><c>GET /api/aidop/s8/master-data/employees?factoryRefId=1000</c> → 500 条外租户员工工号 + 真实姓名。</item>
+/// </list>
+///
+/// <para>根因:两个控制器都未接入 <see cref="S8TrustedScopeResolver"/>(KNOWN-ISSUES I-001 的 6 个之二),
+/// 服务层用 <c>ClearFilter()</c> + **客户端传入的** <c>factoryRefId</c> 作边界,
+/// 且 <c>ListAsync</c> 的该谓词还是 <c>WhereIF(factoryRefId.HasValue, …)</c> —— 可选,不传即无边界。</para>
+///
+/// <para>本测试锁住修复后的口径:作用域只能来自服务端认证身份;客户端 factoryRefId / tenantId
+/// 不得出现在任何 Action 或 Service 签名上,杜绝「以后有人又把它接回去」。</para>
+/// </summary>
+public class S8MasterDataScopeGuardTests
+{
+    private static readonly string PluginRoot =
+        Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
+
+    private const string BindingSrc = "Service/S8/S8OperatorBindingService.cs";
+    private const string MasterDataSrc = "Service/S8/S8MasterDataAdapter.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 List<string> CodeLines(string relativePath) =>
+        ReadSource(relativePath)
+            .Split('\n')
+            .Select(l => l.Trim())
+            .Where(l => !l.StartsWith("///", StringComparison.Ordinal)
+                     && !l.StartsWith("//", StringComparison.Ordinal))
+            .ToList();
+
+    private static IEnumerable<MethodInfo> ActionsOf(Type controller) =>
+        controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly);
+
+    // ───────────────────────── 控制器层 ─────────────────────────
+
+    [Theory]
+    [InlineData(typeof(AdoS8ConfigBindingsController))]
+    [InlineData(typeof(AdoS8MasterDataController))]
+    public void Controller_InjectsTrustedScopeResolver(Type controller)
+    {
+        var ctor = controller.GetConstructors().Single();
+        Assert.Contains(ctor.GetParameters(), p => p.ParameterType == typeof(S8TrustedScopeResolver));
+    }
+
+    /// <summary>客户端不得再通过 query / route 指定作用域:这两个名字必须从 Action 签名彻底消失。</summary>
+    [Theory]
+    [InlineData(typeof(AdoS8ConfigBindingsController))]
+    [InlineData(typeof(AdoS8MasterDataController))]
+    public void ControllerActions_DoNotAcceptClientSuppliedScope(Type controller)
+    {
+        foreach (var action in ActionsOf(controller))
+        {
+            foreach (var p in action.GetParameters())
+            {
+                Assert.False(
+                    string.Equals(p.Name, "factoryRefId", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(p.Name, "factoryId", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(p.Name, "tenantId", StringComparison.OrdinalIgnoreCase),
+                    $"{controller.Name}.{action.Name} 仍接受客户端作用域参数 {p.Name}");
+            }
+        }
+    }
+
+    // ───────────────────────── 服务层签名 ─────────────────────────
+
+    /// <summary>五个入口必须以 S8TrustedScope 作首参,且不得再出现 factoryRefId 形参。</summary>
+    [Theory]
+    [InlineData(typeof(S8OperatorBindingService), "ListAsync")]
+    [InlineData(typeof(S8OperatorBindingService), "ListSysUsersAsync")]
+    [InlineData(typeof(S8OperatorBindingService), "BindAsync")]
+    [InlineData(typeof(S8OperatorBindingService), "UnbindAsync")]
+    [InlineData(typeof(S8MasterDataAdapter), "GetDepartmentsAsync")]
+    [InlineData(typeof(S8MasterDataAdapter), "GetEmployeesAsync")]
+    [InlineData(typeof(S8MasterDataAdapter), "GetLinesAsync")]
+    public void ServiceEntryPoints_TakeTrustedScopeAndRejectClientFactory(Type service, string method)
+    {
+        var m = service.GetMethod(method, BindingFlags.Instance | BindingFlags.Public);
+        Assert.NotNull(m);
+
+        var ps = m!.GetParameters();
+        Assert.NotEmpty(ps);
+        Assert.Equal(typeof(S8TrustedScope), ps[0].ParameterType);
+
+        Assert.DoesNotContain(ps, p =>
+            string.Equals(p.Name, "factoryRefId", StringComparison.OrdinalIgnoreCase)
+            || string.Equals(p.Name, "factoryId", StringComparison.OrdinalIgnoreCase)
+            || string.Equals(p.Name, "tenantId", StringComparison.OrdinalIgnoreCase));
+    }
+
+    // ───────────────────────── 服务层查询谓词 ─────────────────────────
+
+    /// <summary>
+    /// 绑定服务里所有 ClearFilter 都必须收口到唯一的 ScopedEmployees 帮助方法,
+    /// 且该方法带 FactoryRefId == scope.FactoryId 强制边界。
+    /// 出现第二处裸 ClearFilter 即视为回退。
+    /// </summary>
+    [Fact]
+    public void OperatorBinding_ClearFilterIsFunneledThroughScopedHelper()
+    {
+        var lines = CodeLines(BindingSrc);
+
+        var clearFilterLines = lines.Where(l => l.Contains("ClearFilter()", StringComparison.Ordinal)).ToList();
+        Assert.Single(clearFilterLines);
+
+        var src = string.Join('\n', lines);
+        Assert.Contains("_empRep.AsQueryable().ClearFilter()", src);
+        Assert.Contains(".Where(x => x.FactoryRefId == scope.FactoryId)", src);
+
+        // 旧的「可选工厂谓词」写法必须消失。
+        Assert.DoesNotContain("WhereIF(factoryRefId.HasValue", src);
+        Assert.DoesNotContain("factoryRefId!.Value", src);
+    }
+
+    /// <summary>SysUser 相关查询必须按可信租户,不得裸主键集合。</summary>
+    [Fact]
+    public void OperatorBinding_SysUserQueriesAreTenantBound()
+    {
+        var src = string.Join('\n', CodeLines(BindingSrc));
+        Assert.Contains("u.TenantId == scope.TenantId", src);
+        Assert.Contains("x.TenantId == scope.TenantId", src);
+    }
+
+    /// <summary>主数据三个入口的工厂边界必须来自 scope,不得来自入参。</summary>
+    [Fact]
+    public void MasterData_ScopeComesFromTrustedScopeOnly()
+    {
+        var src = string.Join('\n', CodeLines(MasterDataSrc));
+
+        Assert.Contains("x.FactoryRefId == scope.FactoryId && x.IsActive", src);
+        Assert.Contains("u.TenantId == scope.TenantId", src);
+
+        // 客户端参数写法必须消失。
+        Assert.DoesNotContain("factoryRefId!.Value", src);
+        Assert.DoesNotContain("factoryRefId.HasValue", src);
+    }
+
+    /// <summary>
+    /// 绑定 / 解绑必须先按可信作用域绑行再写。写路径上不得出现「只按主键取员工」的写法
+    /// (旧实现 <c>_empRep.GetFirstAsync(x =&gt; x.Id == dto.EmployeeId)</c> 正是如此)。
+    /// </summary>
+    [Fact]
+    public void BindAndUnbind_ResolveEmployeeThroughScopedLookup()
+    {
+        var src = string.Join('\n', CodeLines(BindingSrc));
+
+        Assert.Contains("ScopedEmployee(scope, dto.EmployeeId)", src);
+        Assert.Contains("ScopedEmployee(scope, employeeId)", src);
+        Assert.DoesNotContain("_empRep.GetFirstAsync(x => x.Id == dto.EmployeeId)", src);
+        Assert.DoesNotContain("_empRep.GetFirstAsync(x => x.Id == employeeId)", src);
+    }
+}

+ 31 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigBindingsController.cs

@@ -1,4 +1,6 @@
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Dto.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8;
 
@@ -7,6 +9,15 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
 /// <summary>
 /// S8 配置页:操作员(员工)↔ 系统账号绑定 / 可绑账号查询。
 /// 入口收口在 /aidop/s8/config/roles,与 S8 配置中心同源。
+///
+/// <para><b>S8-P0-3-MASTERDATA-TRUSTED-SCOPE-1</b>:本控制器原先是
+/// KNOWN-ISSUES I-001 里「6 个未接入可信作用域解析」之一,且是其中**唯一被实测利用**的越权面
+/// —— 普通租户账号调 <c>GET /config/operator-bindings</c>(不带任何参数)可拿到 500 条
+/// 其它租户员工的工号与真实姓名。现已接入 <see cref="S8TrustedScopeResolver"/>。</para>
+///
+/// <para><b>入参语义</b>:<c>factoryRefId</c> 已从所有 Action 签名移除。客户端(含前端
+/// <c>withAidopTenantFactoryParams</c>)仍可能继续携带该 query/body 字段,ASP.NET 会直接丢弃,
+/// **不产生任何授权效果**。Body 里的 <c>AdoS8OperatorBindingCreateDto.FactoryRefId</c> 同理不被读取。</para>
 /// </summary>
 [ApiController]
 [Route("api/aidop/s8/config")]
@@ -14,33 +25,47 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
 public class AdoS8ConfigBindingsController : ControllerBase
 {
     private readonly S8OperatorBindingService _svc;
+    private readonly S8TrustedScopeResolver _scope;
 
-    public AdoS8ConfigBindingsController(S8OperatorBindingService svc) => _svc = svc;
+    public AdoS8ConfigBindingsController(S8OperatorBindingService svc, S8TrustedScopeResolver scope)
+    {
+        _svc = svc;
+        _scope = scope;
+    }
 
     [HttpGet("operator-bindings")]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
     public async Task<IActionResult> ListAsync(
-        [FromQuery] long? factoryRefId = null,
         [FromQuery] string? bindStatus = null,
         [FromQuery] string? keyword = null)
-        => Ok(await _svc.ListAsync(factoryRefId, bindStatus, keyword));
+    {
+        try { return Ok(await _svc.ListAsync(await _scope.ResolveAsync(), bindStatus, keyword)); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
 
     [HttpPost("operator-bindings")]
+    [S8Permission(S8PermissionCatalog.ConfigOperatorBind)]
     public async Task<IActionResult> BindAsync([FromBody] AdoS8OperatorBindingCreateDto body)
     {
-        try { return Ok(await _svc.BindAsync(body)); }
+        try { return Ok(await _svc.BindAsync(await _scope.ResolveAsync(), body)); }
         catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
     }
 
     [HttpDelete("operator-bindings/{employeeId:long}")]
+    [S8Permission(S8PermissionCatalog.ConfigOperatorBind)]
     public async Task<IActionResult> UnbindAsync(long employeeId)
     {
-        try { await _svc.UnbindAsync(employeeId); return Ok(new { employeeId }); }
+        try { await _svc.UnbindAsync(await _scope.ResolveAsync(), employeeId); return Ok(new { employeeId }); }
         catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
     }
 
     [HttpGet("sys-users")]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
     public async Task<IActionResult> SysUsersAsync(
         [FromQuery] string? keyword = null,
         [FromQuery] long? excludeEmployeeId = null)
-        => Ok(await _svc.ListSysUsersAsync(keyword, excludeEmployeeId));
+    {
+        try { return Ok(await _svc.ListSysUsersAsync(await _scope.ResolveAsync(), keyword, excludeEmployeeId)); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
 }

+ 33 - 7
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8MasterDataController.cs

@@ -1,9 +1,18 @@
+using Admin.NET.Plugin.AiDOP.Const.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8;
 
 namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
 
 /// <summary>
 /// S8 主数据下拉:优先复用 S0 已落库主数据语义(部门等),无则返回空列表,不把「下拉查询」误建成独立业务表。
+///
+/// <para><b>S8-P0-3-MASTERDATA-TRUSTED-SCOPE-1</b>:本控制器原先是 KNOWN-ISSUES I-001 里
+/// 「6 个未接入可信作用域解析」之一。<c>GET /master-data/employees?factoryRefId=&lt;任意工厂&gt;</c>
+/// 实测可返回 500 条其它租户员工的工号与真实姓名,现已接入 <see cref="S8TrustedScopeResolver"/>。</para>
+///
+/// <para><b>入参语义</b>:<c>factoryRefId</c> 已从所有 Action 签名移除;客户端继续携带也不产生授权效果。</para>
 /// </summary>
 [ApiController]
 [Route("api/aidop/s8/master-data")]
@@ -11,18 +20,35 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
 public class AdoS8MasterDataController : ControllerBase
 {
     private readonly S8MasterDataAdapter _svc;
+    private readonly S8TrustedScopeResolver _scope;
 
-    public AdoS8MasterDataController(S8MasterDataAdapter svc) => _svc = svc;
+    public AdoS8MasterDataController(S8MasterDataAdapter svc, S8TrustedScopeResolver scope)
+    {
+        _svc = svc;
+        _scope = scope;
+    }
 
     [HttpGet("departments")]
-    public async Task<IActionResult> DepartmentsAsync([FromQuery] long? factoryRefId = 1) =>
-        Ok(await _svc.GetDepartmentsAsync(factoryRefId));
+    [S8Permission(S8PermissionCatalog.ExceptionRead)]
+    public async Task<IActionResult> DepartmentsAsync()
+    {
+        try { return Ok(await _svc.GetDepartmentsAsync(await _scope.ResolveAsync())); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
 
     [HttpGet("employees")]
-    public async Task<IActionResult> EmployeesAsync([FromQuery] long? factoryRefId = 1) =>
-        Ok(await _svc.GetEmployeesAsync(factoryRefId));
+    [S8Permission(S8PermissionCatalog.ExceptionRead)]
+    public async Task<IActionResult> EmployeesAsync()
+    {
+        try { return Ok(await _svc.GetEmployeesAsync(await _scope.ResolveAsync())); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
 
     [HttpGet("lines")]
-    public async Task<IActionResult> LinesAsync([FromQuery] long? factoryRefId = 1) =>
-        Ok(await _svc.GetLinesAsync(factoryRefId));
+    [S8Permission(S8PermissionCatalog.ExceptionRead)]
+    public async Task<IActionResult> LinesAsync()
+    {
+        try { return Ok(await _svc.GetLinesAsync(await _scope.ResolveAsync())); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
 }

+ 23 - 12
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8MasterDataAdapter.cs

@@ -1,8 +1,21 @@
 using Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing;
 using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8;
 
+/// <summary>
+/// S8 主数据下拉:优先复用 S0 已落库主数据语义(部门等),无则返回空列表,不把「下拉查询」误建成独立业务表。
+///
+/// <para><b>S8-P0-3-MASTERDATA-TRUSTED-SCOPE-1 作用域合同</b>:三个入口的工厂边界
+/// **一律由 <see cref="S8TrustedScopeResolver"/> 从认证身份解析**,
+/// 客户端传入的 <c>factoryRefId</c> 只被视为筛选意图,**当前实现直接忽略、不承担授权作用**。</para>
+///
+/// <para>修复前(实测可被普通租户账号利用):<c>GetEmployeesAsync</c> 是
+/// <c>ClearFilter()</c> + <c>FactoryRefId == 客户端传入值</c>,
+/// 于是 <c>GET /master-data/employees?factoryRefId=1000</c> 会返回 500 条**其它租户**员工的
+/// 工号与真实姓名。</para>
+/// </summary>
 public class S8MasterDataAdapter : ITransient
 {
     private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
@@ -22,23 +35,20 @@ public class S8MasterDataAdapter : ITransient
         _sysUserRep = sysUserRep;
     }
 
-    public async Task<object> GetDepartmentsAsync(long? factoryRefId) =>
+    public async Task<object> GetDepartmentsAsync(S8TrustedScope scope) =>
         await _deptRep.AsQueryable()
-            .WhereIF(factoryRefId.HasValue, x => x.FactoryRefId == factoryRefId!.Value)
+            .Where(x => x.FactoryRefId == scope.FactoryId)
             .Take(500)
             .Select(x => new { id = x.Id, code = x.Department, name = x.Descr ?? x.Department })
             .ToListAsync();
 
-    public async Task<object> GetEmployeesAsync(long? factoryRefId)
+    public async Task<object> GetEmployeesAsync(S8TrustedScope scope)
     {
         // S8 选员工下拉:EmployeeMaster.tenant_id 与 SysUser.TenantId 历史错位(业务租户 ≠ 系统租户),
-        // 走全局 multi-tenant filter 会全过滤为空。本接口仅作 S8 处理人/检验人下拉,必须强约束 factoryRefId
-        // 边界 + IsActive,避免越界;不依赖 token tenant 做隔离。
-        if (!factoryRefId.HasValue || factoryRefId.Value <= 0)
-            return new List<object>();
-
+        // 走全局 multi-tenant filter 会全过滤为空,故必须 ClearFilter;
+        // 但边界**只能**来自可信作用域的 factoryId + IsActive,绝不接受调用方传入的 factoryRefId。
         var emps = await _empRep.AsQueryable().ClearFilter()
-            .Where(x => x.FactoryRefId == factoryRefId!.Value && x.IsActive)
+            .Where(x => x.FactoryRefId == scope.FactoryId && x.IsActive)
             .Take(500)
             .Select(x => new { x.Id, x.Name, x.Employee, x.SysUserId })
             .ToListAsync();
@@ -49,8 +59,9 @@ public class S8MasterDataAdapter : ITransient
         Dictionary<long, (string? RealName, string? Account)> userMap = new();
         if (sysUserIds.Count > 0)
         {
+            // 姓名水合:主键集合 + 可信租户双重边界(原实现只有主键集合)。
             var users = await _sysUserRep.AsQueryable().ClearFilter()
-                .Where(u => sysUserIds.Contains(u.Id))
+                .Where(u => sysUserIds.Contains(u.Id) && u.TenantId == scope.TenantId)
                 .Select(u => new { u.Id, u.RealName, u.Account })
                 .ToListAsync();
             userMap = users.ToDictionary(u => u.Id, u => ((string?)u.RealName, (string?)u.Account));
@@ -77,9 +88,9 @@ public class S8MasterDataAdapter : ITransient
         }).ToList();
     }
 
-    public async Task<object> GetLinesAsync(long? factoryRefId) =>
+    public async Task<object> GetLinesAsync(S8TrustedScope scope) =>
         await _lineRep.AsQueryable()
-            .WhereIF(factoryRefId.HasValue, x => x.FactoryRefId == factoryRefId!.Value)
+            .Where(x => x.FactoryRefId == scope.FactoryId)
             .Take(500)
             .Select(x => new { id = x.Id, code = x.Line, name = x.Describe ?? x.Line })
             .ToListAsync();

+ 49 - 18
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8OperatorBindingService.cs

@@ -1,5 +1,6 @@
 using Admin.NET.Plugin.AiDOP.Dto.S8;
 using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8;
@@ -7,6 +8,23 @@ namespace Admin.NET.Plugin.AiDOP.Service.S8;
 /// <summary>
 /// S8 配置页:操作员(员工)↔ 系统账号绑定。
 /// 仅维护 EmployeeMaster.SysUserId 的 1:1 弱关联;不动 SysUser/SysRole/SysUserRole。
+///
+/// <para><b>S8-P0-3-MASTERDATA-TRUSTED-SCOPE-1 作用域合同(五个入口统一)</b>:
+/// 租户与工厂**一律由 <see cref="S8TrustedScopeResolver"/> 从认证身份解析**,
+/// 客户端传入的 <c>factoryRefId</c> / <c>tenantId</c> / <c>body.FactoryRefId</c>
+/// **只被视为筛选意图、不承担任何授权作用,且当前实现直接忽略**。</para>
+///
+/// <para>修复前(实测可被普通租户账号利用):<c>ListAsync</c> 是
+/// <c>ClearFilter()</c> + <c>WhereIF(factoryRefId.HasValue, …)</c> —— 谓词**可选**,
+/// 不传即全库无边界;`GET /config/operator-bindings` 返回 500 行且全部属于其它租户
+/// (工厂 1000 × 498 / 1329900200002 × 2,本租户 0 行)。
+/// <c>BindAsync</c> / <c>UnbindAsync</c> / <c>ListSysUsersAsync</c> 则各自用不同口径,
+/// 出现过「List 用 ClearFilter、Bind 不用」的不一致。</para>
+///
+/// <para>修复后:`EmployeeMaster` 的 <c>ClearFilter()</c> 保留(其 tenant_id 属 S0 业务域,
+/// 与登录 token 的系统租户历史错位,不清会全过滤为 0 行),但**必须**同时带
+/// <c>FactoryRefId == 可信工厂</c> 这一强制硬边界 —— 与 CURRENT.md 的
+/// 「ClearFilter 必须同时具备显式业务边界」铁律一致。</para>
 /// </summary>
 public class S8OperatorBindingService : ITransient
 {
@@ -24,12 +42,10 @@ public class S8OperatorBindingService : ITransient
         _userManager = userManager;
     }
 
-    public async Task<List<AdoS8OperatorBindingRowDto>> ListAsync(long? factoryRefId, string? bindStatus, string? keyword)
+    /// <summary>按可信作用域列出本工厂员工及其绑定状态。factoryRefId 不再由调用方决定。</summary>
+    public async Task<List<AdoS8OperatorBindingRowDto>> ListAsync(S8TrustedScope scope, string? bindStatus, string? keyword)
     {
-        // ClearFilter:EmployeeMaster.tenant_id 属 S0 域租户,与登录 token TenantId 不一致(同 BUG-S8-EMPLOYEES-TENANT-FILTER-001 第 3 处)。
-        // 安全边界:保留 factoryRefId 显式过滤;仅放开租户全局过滤器;不影响 SysUser 查询。
-        var emps = await _empRep.AsQueryable().ClearFilter()
-            .WhereIF(factoryRefId.HasValue, x => x.FactoryRefId == factoryRefId!.Value)
+        var emps = await ScopedEmployees(scope)
             .WhereIF(!string.IsNullOrWhiteSpace(keyword),
                 x => x.Employee.Contains(keyword!) || (x.Name != null && x.Name.Contains(keyword!)))
             .Take(500)
@@ -41,7 +57,7 @@ public class S8OperatorBindingService : ITransient
         var userMap = sysUserIds.Count == 0
             ? new Dictionary<long, string?>()
             : (await _sysUserRep.AsQueryable()
-                .Where(u => sysUserIds.Contains(u.Id) && u.TenantId == _userManager.TenantId)
+                .Where(u => sysUserIds.Contains(u.Id) && u.TenantId == scope.TenantId)
                 .Select(u => new { u.Id, u.RealName, u.Account })
                 .ToListAsync())
               .ToDictionary(u => u.Id, u => string.IsNullOrWhiteSpace(u.RealName) ? u.Account : u.RealName);
@@ -70,11 +86,10 @@ public class S8OperatorBindingService : ITransient
             .ToList();
     }
 
-    public async Task<List<AdoS8ConfigSysUserRowDto>> ListSysUsersAsync(string? keyword, long? excludeEmployeeId)
+    public async Task<List<AdoS8ConfigSysUserRowDto>> ListSysUsersAsync(S8TrustedScope scope, string? keyword, long? excludeEmployeeId)
     {
-        var tenantId = _userManager.TenantId;
         var users = await _sysUserRep.AsQueryable()
-            .Where(u => u.TenantId == tenantId)
+            .Where(u => u.TenantId == scope.TenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(keyword),
                 u => u.Account.Contains(keyword!) || u.RealName.Contains(keyword!))
             .Take(200)
@@ -85,8 +100,9 @@ public class S8OperatorBindingService : ITransient
 
         // 标注哪些 sysUser 已被其它 employee 绑定(用于前端禁选/隐藏);
         // 当前 employee 自己绑定的 sysUser 在 alreadyBoundEmployeeId == excludeEmployeeId 时会被前端放行。
+        // S8-P0-3:占用查询必须限定在可信工厂内,否则会把其它租户的占用情况泄漏出去。
         var ids = users.Select(u => u.Id).ToList();
-        var bound = await _empRep.AsQueryable()
+        var bound = await ScopedEmployees(scope)
             .Where(e => e.SysUserId.HasValue && ids.Contains(e.SysUserId!.Value))
             .Select(e => new { e.Id, e.SysUserId })
             .ToListAsync();
@@ -103,20 +119,23 @@ public class S8OperatorBindingService : ITransient
         }).ToList();
     }
 
-    public async Task<AdoS8OperatorBindingRowDto> BindAsync(AdoS8OperatorBindingCreateDto dto)
+    public async Task<AdoS8OperatorBindingRowDto> BindAsync(S8TrustedScope scope, AdoS8OperatorBindingCreateDto dto)
     {
         if (dto.EmployeeId <= 0) throw new S8BizException("员工 ID 不能为空");
         if (dto.SysUserId <= 0) throw new S8BizException("系统账号 ID 不能为空");
 
-        var emp = await _empRep.GetFirstAsync(x => x.Id == dto.EmployeeId)
+        // S8-P0-3:员工必须属于可信工厂。跨工厂 / 跨租户的 employeeId 一律按「不存在」处理,
+        // 文案与本租户内真实不存在的情况完全一致,避免被用来探测他租户员工是否存在。
+        var emp = await ScopedEmployee(scope, dto.EmployeeId)
             ?? throw new S8BizException("员工不存在");
 
-        var tenantId = _userManager.TenantId;
-        var user = await _sysUserRep.GetFirstAsync(x => x.Id == dto.SysUserId && x.TenantId == tenantId)
+        var user = await _sysUserRep.GetFirstAsync(x => x.Id == dto.SysUserId && x.TenantId == scope.TenantId)
             ?? throw new S8BizException("系统账号不存在或不在当前租户");
 
-        // 同一 sysUser 不允许绑定到另一个 employee
-        var conflict = await _empRep.GetFirstAsync(x => x.SysUserId == dto.SysUserId && x.Id != dto.EmployeeId);
+        // 同一 sysUser 不允许绑定到另一个 employee(冲突检测同样限定在可信工厂内)
+        var conflict = await ScopedEmployees(scope)
+            .Where(x => x.SysUserId == dto.SysUserId && x.Id != dto.EmployeeId)
+            .FirstAsync();
         if (conflict != null)
             throw new S8BizException($"系统账号 {user.Account} 已绑定至其它员工 {conflict.Name ?? conflict.Employee}");
 
@@ -139,10 +158,10 @@ public class S8OperatorBindingService : ITransient
         };
     }
 
-    public async Task UnbindAsync(long employeeId)
+    public async Task UnbindAsync(S8TrustedScope scope, long employeeId)
     {
         if (employeeId <= 0) throw new S8BizException("员工 ID 不能为空");
-        var emp = await _empRep.GetFirstAsync(x => x.Id == employeeId)
+        var emp = await ScopedEmployee(scope, employeeId)
             ?? throw new S8BizException("员工不存在");
         if (!emp.SysUserId.HasValue) return;
 
@@ -153,4 +172,16 @@ public class S8OperatorBindingService : ITransient
             .UpdateColumns(it => new { it.SysUserId, it.UpdateTime, it.UpdateUser })
             .ExecuteCommandAsync();
     }
+
+    /// <summary>
+    /// S8-P0-3:EmployeeMaster 的唯一合法访问口径。
+    /// ClearFilter 是必要的(EmployeeMaster.tenant_id 属 S0 业务域,与登录 token 的系统租户历史错位),
+    /// 但**必须**同时带 FactoryRefId 强制硬边界 —— 谓词不可选、不接受调用方覆盖。
+    /// </summary>
+    private ISugarQueryable<AdoS0EmployeeMaster> ScopedEmployees(S8TrustedScope scope) =>
+        _empRep.AsQueryable().ClearFilter()
+            .Where(x => x.FactoryRefId == scope.FactoryId);
+
+    private async Task<AdoS0EmployeeMaster?> ScopedEmployee(S8TrustedScope scope, long employeeId) =>
+        await ScopedEmployees(scope).Where(x => x.Id == employeeId).FirstAsync();
 }