Ver código fonte

fix(s5): require authentication for stocktake labels

FUNC-S5-012 盘点标签确认此前可被完全未认证调用。

实测(修复前):匿名 GET —— 无 Authorization 头、credentials:'omit' 连 cookie 也不发:
  GET /api/S5StocktakeLabelConfirm/list      → HTTP 200
  GET /api/S5StocktakeLabelConfirm/locations → HTTP 200
当时 WHERE IsTag=1 恰好命中 0 行,故未造成实际数据泄漏,
但端点本身是公开的,一旦出现盘点标签数据即形成匿名读取。

根因单一:类级 [AllowAnonymous](StocktakeLabelConfirmService.cs:17)。
方法级无该属性;服务只有两个 HttpGet,无任何写接口。
去掉该属性后即进入正常认证链,无需其它改动。

本次只改 authentication:查询 SQL、IsTag=1 语义、分页、派生列一行未动
(git diff 除注释与该属性外无其它变更)。

测试:新增 7 个纯反射用例(无 DB 依赖,普通 CI 即执行)——
类级/方法级均不得标注 AllowAnonymous;同目录 008/009/010/013 四个只读服务
一并守住防止回加;并断言本服务保持只读(出现写端点即失败,
因为「匿名已关」的风险论证建立在「无写能力」之上)。实测 7/7 通过。

运行时验收(UATAdminA / 838257186181189):
  匿名 012 list/locations → 401 401
  匿名回归 008 / 013 / 010 / 009 → 401 401 401 401(前批修复未回归)
  已认证 012 list → 200 total=0;locations → 200
  已认证 008 → 200 total=152;010 → 200 total=3231

回归:非集成 1148 passed / 0 failed;本批相关集成 30 passed / 0 failed。

【仍未解决 · DATA-MODEL DEBT】012 的租户边界依旧是 WHERE IsTag=1,无 Tenant/Domain 条件:
MissedPrint 的 tenant_id 列存在但实测 152 行全为 NULL,且 150 行 Location 为空,
既无法按 tenant_id 过滤(会把页面整体查空),也无法拿 Location 当租户边界。
本提交只能表述为 Anonymous access CLOSED,**不代表 tenant isolation 已 CLOSED**。
YY968XX 1 mês atrás
pai
commit
3fa314f475

+ 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.440</AssemblyVersion>
-    <FileVersion>1.0.440</FileVersion>
-    <Version>1.0.440</Version>
+    <AssemblyVersion>1.0.441</AssemblyVersion>
+    <FileVersion>1.0.441</FileVersion>
+    <Version>1.0.441</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 73 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/StocktakeLabelConfirmAuthTests.cs

@@ -0,0 +1,73 @@
+using System.Reflection;
+using Microsoft.AspNetCore.Authorization;
+using Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
+
+/// <summary>
+/// FUNC-S5-012 盘点标签确认:匿名访问收口守卫。
+/// <para>
+/// 纯反射断言,不连库、不起服务,普通 CI 也会执行(无 AIDOP_IT 门禁)。
+/// 背景:该服务原先类级带 [AllowAnonymous],实测匿名 GET(无 Authorization 头、
+/// credentials:'omit')返回 HTTP 200。本组用例把「不得再标注匿名」固化下来。
+/// </para>
+/// <para>
+/// 注意:本组只覆盖 <b>authentication</b>。该服务的租户边界仍是 WHERE IsTag=1,
+/// 属 DATA-MODEL DEBT,未在此断言,也不得据此认为 tenant isolation 已收口。
+/// </para>
+/// </summary>
+public class StocktakeLabelConfirmAuthTests
+{
+    private static readonly Type Svc = typeof(StocktakeLabelConfirmService);
+
+    [Fact]
+    public void Class_MustNotBeMarkedAllowAnonymous()
+    {
+        var attr = Svc.GetCustomAttribute<AllowAnonymousAttribute>(inherit: true);
+        Assert.Null(attr);
+    }
+
+    [Fact]
+    public void NoEndpoint_MayBeMarkedAllowAnonymous()
+    {
+        var offenders = Svc
+            .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
+            .Where(m => m.GetCustomAttribute<AllowAnonymousAttribute>(inherit: true) != null)
+            .Select(m => m.Name)
+            .ToList();
+
+        Assert.True(offenders.Count == 0, $"以下端点仍标注了 AllowAnonymous:{string.Join(", ", offenders)}");
+    }
+
+    /// <summary>
+    /// 同目录其余 MissedPrint 只读服务此前已收口,一并守住,防止有人再加回来。
+    /// </summary>
+    [Theory]
+    [InlineData(typeof(LabelQueryService))]
+    [InlineData(typeof(StocktakeResultService))]
+    [InlineData(typeof(PendingInspectionService))]
+    [InlineData(typeof(StockQueryService))]
+    public void SiblingReadOnlyServices_MustNotBeAnonymous(Type serviceType)
+    {
+        Assert.Null(serviceType.GetCustomAttribute<AllowAnonymousAttribute>(inherit: true));
+    }
+
+    /// <summary>
+    /// 本服务必须保持只读:出现任何写端点都应让本用例失败,
+    /// 因为「匿名已关」的风险论证建立在「无写能力」之上。
+    /// </summary>
+    [Fact]
+    public void Service_MustRemainReadOnly()
+    {
+        var writeVerbs = new[] { "HttpPostAttribute", "HttpPutAttribute", "HttpDeleteAttribute", "HttpPatchAttribute" };
+        var offenders = Svc
+            .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
+            .Where(m => m.GetCustomAttributes(inherit: true)
+                         .Any(a => writeVerbs.Contains(a.GetType().Name)))
+            .Select(m => m.Name)
+            .ToList();
+
+        Assert.True(offenders.Count == 0, $"出现写端点:{string.Join(", ", offenders)}");
+    }
+}

+ 10 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/StocktakeLabelConfirmService.cs

@@ -9,12 +9,20 @@ namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 /// 参考本仓既有 MissedPrint 直读只读投影先例:LabelQueryService / StockQueryService / PendingInspectionService。
 ///
 /// 本服务仅 SELECT:无新增/编辑/删除/确认写回/批量确认/反确认/库存事务/状态流转;扁平列表,无 detail。
-/// MissedPrint 无 tenant_id 列,不做租户/工厂域过滤(对齐既有 MissedPrint 只读投影先例);不加 Domain 占位过滤。
 /// 派生列:diffQty=CompQty-Qty;status(Status='Q'→冻结);stocktakeResult(CompQty&lt;&gt;Qty→有差异/正常)。
+///
+/// 【认证】类级已去 [AllowAnonymous]:原先该接口可被**完全未认证**调用
+/// (实测匿名 GET —— 无 Authorization 头、credentials:'omit' 连 cookie 也不发 —— 返回 HTTP 200)。
+/// 当时 IsTag=1 恰好 0 行故未造成实际泄漏,但端点是公开的,一旦出现盘点标签数据即形成匿名读取。
+/// 现收敛为需认证访问。
+///
+/// 【仍未解决 · DATA-MODEL DEBT】租户边界。查询仍是 WHERE IsTag=1,无 Tenant/Domain 条件:
+/// MissedPrint 的 tenant_id 列存在但实测 152 行全为 NULL,且 150 行 Location 为空,
+/// 既无法按 tenant_id 过滤(会把页面整体查空),也无法拿 Location 当租户边界(会误删绝大多数行)。
+/// 本批只关闭匿名访问,**不代表 tenant isolation 已 CLOSED**。
 /// </summary>
 [ApiDescriptionSettings(Order = 308, Description = "盘点标签确认")]
 [Route("api/S5StocktakeLabelConfirm")]
-[AllowAnonymous]
 [NonUnify]
 public class StocktakeLabelConfirmService : IDynamicApiController, ITransient
 {