Quellcode durchsuchen

fix(s5): restrict inventory rebuild operations

把 /api/S5InventoryInbound/* 六个管理接口纳入按钮权限管控。

缺陷:JwtHandler.CheckAuthorizeAsync 对「路由名未登记为任何按钮权限」的接口
兜底放行。该 Service 六个 POST 接口全部未登记,任何已登录用户(含
AccountType=777 普通业务账号)都能触发库存贴源、物化、全量重建。
实测 UATAdminA(777)此前可成功调用 reconcile 与 transform-inventory-std。

权限名必须逐接口且等于路由派生名:JwtHandler 把 /api/A/B 映射为 A:B 后与
用户自有权限做忽略大小写全等比较,单个 :admin 权限无法覆盖多路由。
参照系统内已生效先例 sysOrg:list ← /api/sysOrg/list。
(S8 的 s8:exception:* 是前端 v-auth 权限点,其路由为 /api/aidop/s8/...,
不参与 JwtHandler 鉴权,不能作为命名依据。)

1.0.433.sql
  登记 6 个 Type=3 权限点,挂在「库存查询」页面菜单下:
    s5InventoryInbound:bootstrap / run-once / reconcile
    s5InventoryInbound:transform-std / transform-inventory-std / daily-recon
  租户级授权只给 AIDOP 租户 —— 该租户的 AIDopAdmin 为 AccountType=888 且未绑角色,
  按 GetOwnBtnPermList 逻辑会获得「该租户 SysTenantMenu 允许的全部按钮权限」。
  不新建角色、不写任何 SysRoleMenu、不给 UAT 租户授权(其 6 个账号全为 777)。
  全部 INSERT 带 NOT EXISTS,重复执行 affected rows = 0。

1.0.433.verify.sql
  7 条断言全部钉死精确 Permission / TenantId / RoleId,不使用会产生跨租户
  假阳性的 RoleCode JOIN 口径;显式断言 UAT 租户未授权、UAT_FUNCTION_USER
  持有 0 条、且无任何角色持有这些权限。

运行时验收:UATAdminA 直接调用全部 6 个接口均返回 HTTP 403;
mdp_stg_inventory / mdp_std_inventory / mdp_std_inv_trans /
ado_inventory_recon_diff 五项计数与调用前逐字一致,后端日志
[S5InventoryInbound] 出现 0 次 —— action 方法体未执行,鉴权在管线层拦截。
回归:S5 库存查询 STD 3231 行 / LIVE 3058 行,越权均 0,未受影响;
S5 隔离测试 41 passed;非集成全量 1098 passed / 0 failed。

本批不改任何库存同步/STD/LIVE 数据逻辑。
YY968XX vor 5 Stunden
Ursprung
Commit
cb257426aa

+ 9 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.432</AssemblyVersion>
-    <FileVersion>1.0.432</FileVersion>
-    <Version>1.0.432</Version>
+    <AssemblyVersion>1.0.433</AssemblyVersion>
+    <FileVersion>1.0.433</FileVersion>
+    <Version>1.0.433</Version>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>
@@ -595,6 +595,12 @@
     <None Update="UpdateScripts\1.0.431.verify.sql">
     <None Update="UpdateScripts\1.0.431.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
     </None>
+    <None Update="UpdateScripts\1.0.433.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.433.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
     </None>

+ 114 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.433.sql

@@ -0,0 +1,114 @@
+-- ============================================================================
+-- 1.0.433:把 S5 库存冷链入站管理接口纳入按钮权限管控(P0 权限治理)
+--
+-- 缺陷(CODE VERIFIED + RUNTIME VERIFIED):
+--   JwtHandler.CheckAuthorizeAsync(Admin.NET.Web.Core/Handlers/JwtHandler.cs:74-97)
+--   的判定是:
+--     ① 超管(AccountType=999) 直接放行;
+--     ② 命中「自有按钮权限」放行;
+--     ③ 否则——**只要该路由名未登记为任何按钮权限,就兜底放行**。
+--   /api/S5InventoryInbound/* 六个 POST 管理接口全部未登记,一律走 ③ 兜底放行,
+--   任何已登录用户(含 AccountType=777 普通业务账号)都能触发库存贴源/物化/重建。
+--   实测:UATAdminA(777 普通用户)成功调用 reconcile 与 transform-inventory-std。
+--
+-- 权限名为什么必须是路由派生名:
+--   JwtHandler 把 /api/A/B 映射为权限名 A:B(Path.Value[5..] 把 '/' 换成 ':'),
+--   再与用户自有权限做**忽略大小写的全等比较**。因此单个 s5InventoryInbound:admin
+--   无法覆盖多个路由——必须逐接口登记,权限名等于各自路由派生名。
+--   参照系统内已生效的先例 sysOrg:list ← /api/sysOrg/list(SysMenu Id=1300200030201)。
+--   注:S8 的 s8:exception:* 属前端 v-auth 权限点,其路由为 /api/aidop/s8/...
+--   (派生名 aidop:s8:...),并不参与 JwtHandler 鉴权,不能作为本次命名依据。
+--
+-- 授权对象(依据真实 AccountType / RoleId,不按账号名猜):
+--   SysMenuService.GetOwnBtnPermList(Service/Menu/SysMenuService.cs:336-357)对
+--   AccountType=888(SysAdmin) 且未绑定任何角色的账号,返回「该租户 SysTenantMenu
+--   允许的全部按钮权限」。租户 797403760988229 的 AIDopAdmin 正是此形态(888、无角色),
+--   故只需给该租户加租户级菜单授权即可获得管理能力。
+--   → 本脚本**不新建角色、不写任何 SysRoleMenu**,普通业务角色一律无权。
+--
+-- 明确不做:
+--   - 不给 UAT 租户(838257186181189)加租户级授权(该租户 6 个账号全为 777 普通用户,
+--     无任何 SysAdmin,不应具备库存重建能力)
+--   - 不给 UAT_FUNCTION_USER / ROLE_WH_* / ROLE_QC 等任何业务角色授权
+--   - 不使用 GrantRoleMenu(其语义是「删角色全部 SysRoleMenu 再全量重建」)
+--   - 不按 RoleCode 跨租户模糊授权
+--   - 不改任何既有菜单、不改任何既有角色权限
+--   - 不改库存同步/STD/LIVE 数据逻辑
+--
+-- 幂等:全部 INSERT 带 NOT EXISTS(菜单按 Permission,租户授权按 TenantId+MenuId)。
+--       重复执行 affected rows = 0。
+--
+-- ⚠️ 缓存:JwtHandler 用 GetAllBtnPermList(缓存键 sys_user_button:0)判断「是否已登记」,
+--   GetOwnBtnPermList 缓存键 sys_user_button:<userId>,均为进程内 Memory 缓存
+--   (Cache.json:7 CacheType=Memory),SQL 写库不会使其失效。
+--   AutoVersionUpdate 在应用启动时执行本脚本,该进程缓存本就为空,随发布重启天然生效;
+--   若在已运行实例上手工执行,必须重启后端。
+-- ============================================================================
+
+-- ---------------------------------------------------------------------------
+-- 0. 作用域解析(不硬编码租户 Id,按 SysOrg.Code 反查,与 1.0.402/1.0.408 一致)
+-- ---------------------------------------------------------------------------
+SET @admin_tenant := (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id = t.OrgId
+                      WHERE o.Code = 'AIDOP' AND t.Status = 1 LIMIT 1);
+
+-- 挂载点:S5 / 库存数据 / 库存查询(Type=2 页面菜单)。冷链入站即该页数据的来源。
+SET @parent_menu := (SELECT Id FROM SysMenu
+                     WHERE Name = 'aidopS5InventoryStockQuery' AND Type = 2 LIMIT 1);
+
+-- ---------------------------------------------------------------------------
+-- 1. 登记 6 个按钮权限(权限名 = 路由派生名,逐接口)
+-- ---------------------------------------------------------------------------
+INSERT INTO SysMenu (Id, Pid, Type, Name, Permission, Title, Icon,
+                     IsIframe, IsHide, IsKeepAlive, IsAffix, OrderNo, Status, Remark, CreateTime)
+SELECT s.Id, @parent_menu, 3, s.Name, s.Permission, s.Title, 'ele-Menu',
+       0, 0, 1, 0, s.OrderNo, 1, s.Remark, NOW()
+FROM (
+  SELECT 1329015030101 AS Id, 'aidopS5InvInboundBtn01' AS Name,
+         's5InventoryInbound:bootstrap'              AS Permission,
+         '库存冷链首刷'                              AS Title, 910 AS OrderNo,
+         'S5 冷链管理权限点 POST /api/S5InventoryInbound/bootstrap(写 STG + 全量替换 STD)' AS Remark
+  UNION ALL SELECT 1329015030102, 'aidopS5InvInboundBtn02',
+         's5InventoryInbound:run-once',
+         '库存冷链跑一轮', 920,
+         'S5 冷链管理权限点 POST /api/S5InventoryInbound/run-once(写 STG + UPSERT STD)'
+  UNION ALL SELECT 1329015030103, 'aidopS5InvInboundBtn03',
+         's5InventoryInbound:reconcile',
+         '库存冷链全量校准', 930,
+         'S5 冷链管理权限点 POST /api/S5InventoryInbound/reconcile(写 STG + 全量替换 STD)'
+  UNION ALL SELECT 1329015030104, 'aidopS5InvInboundBtn04',
+         's5InventoryInbound:transform-std',
+         '库存流水 stg→std 补转换', 940,
+         'S5 冷链管理权限点 POST /api/S5InventoryInbound/transform-std(写 mdp_std_inv_trans)'
+  UNION ALL SELECT 1329015030105, 'aidopS5InvInboundBtn05',
+         's5InventoryInbound:transform-inventory-std',
+         '库存余额 stg→std 补物化', 950,
+         'S5 冷链管理权限点 POST /api/S5InventoryInbound/transform-inventory-std(写 mdp_std_inventory)'
+  UNION ALL SELECT 1329015030106, 'aidopS5InvInboundBtn06',
+         's5InventoryInbound:daily-recon',
+         '库存日终对账(手工)', 960,
+         'S5 冷链管理权限点 POST /api/S5InventoryInbound/daily-recon(写 ado_inventory_recon_diff)'
+) s
+WHERE @parent_menu IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM SysMenu m WHERE m.Permission = s.Permission AND m.Type = 3);
+
+-- ---------------------------------------------------------------------------
+-- 2. 租户级授权:只给拥有真实 SysAdmin 运维账号的租户(AIDOP)
+--    不给 UAT 租户,不给默认租户(超管走 AccountType=999 旁路,无需授权)
+-- ---------------------------------------------------------------------------
+SET @tm_base := (SELECT IFNULL(MAX(Id), 0) FROM SysTenantMenu);
+
+INSERT INTO SysTenantMenu (Id, TenantId, MenuId)
+SELECT @tm_base + ROW_NUMBER() OVER (ORDER BY m.Id), @admin_tenant, m.Id
+FROM SysMenu m
+WHERE @admin_tenant IS NOT NULL
+  AND m.Type = 3
+  AND m.Permission IN (
+      's5InventoryInbound:bootstrap',
+      's5InventoryInbound:run-once',
+      's5InventoryInbound:reconcile',
+      's5InventoryInbound:transform-std',
+      's5InventoryInbound:transform-inventory-std',
+      's5InventoryInbound:daily-recon')
+  AND NOT EXISTS (
+      SELECT 1 FROM SysTenantMenu tm
+       WHERE tm.TenantId = @admin_tenant AND tm.MenuId = m.Id);

+ 99 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.433.verify.sql

@@ -0,0 +1,99 @@
+-- ============================================================================
+-- 1.0.433 verify:S5 冷链入站六个管理接口必须已登记权限,且未扩散到业务角色。
+--
+-- 约定(AutoVersionUpdate.RunVerifyScriptIfExists):
+--   每条语句取「首行首列」当布尔断言,必须为真;返回 0 行会得到 NULL 判假并中断启动。
+--   故每条断言一律返回标量真值,且不使用 SET(SET 不返回行 → 必然判假)。
+--
+-- 断言口径:全部钉死到精确 Permission / 精确 TenantId / 精确 RoleId,
+--   不使用「RoleCode JOIN 后跨租户 COUNT」这种会产生跨租户假阳性的写法
+--   (该缺陷见 1.0.386.verify.sql,已由 1.0.431.verify.sql 纠正)。
+--
+-- 环境容错:非本项目夹具库(查不到 AIDOP 租户 / 库存查询菜单)上主脚本为空操作,
+--   断言同步短路为真。
+-- ============================================================================
+
+-- 1) 六个权限点全部登记,且 Type=3 / Status=1
+SELECT IF(
+  (SELECT COUNT(*) FROM SysMenu WHERE Name = 'aidopS5InventoryStockQuery' AND Type = 2) = 0,
+  1,
+  (SELECT COUNT(*) = 6 FROM SysMenu
+    WHERE Type = 3 AND Status = 1
+      AND Permission IN (
+        's5InventoryInbound:bootstrap',
+        's5InventoryInbound:run-once',
+        's5InventoryInbound:reconcile',
+        's5InventoryInbound:transform-std',
+        's5InventoryInbound:transform-inventory-std',
+        's5InventoryInbound:daily-recon'))
+) AS ok_six_permissions_registered;
+
+-- 2) 无重复登记(同一 Permission 只允许一条 Type=3)
+SELECT IF(
+  (SELECT COUNT(*) FROM (
+     SELECT Permission FROM SysMenu
+      WHERE Type = 3
+        AND Permission LIKE 's5InventoryInbound:%'
+      GROUP BY Permission HAVING COUNT(*) > 1
+   ) dup) = 0,
+  1, 0
+) AS ok_no_duplicate_permission;
+
+-- 3) 挂载点正确:六个权限点都挂在「库存查询」页面菜单下
+SELECT IF(
+  (SELECT COUNT(*) FROM SysMenu WHERE Name = 'aidopS5InventoryStockQuery' AND Type = 2) = 0,
+  1,
+  (SELECT COUNT(*) = 6 FROM SysMenu m
+    WHERE m.Type = 3
+      AND m.Permission LIKE 's5InventoryInbound:%'
+      AND m.Pid = (SELECT Id FROM SysMenu WHERE Name = 'aidopS5InventoryStockQuery' AND Type = 2 LIMIT 1))
+) AS ok_mounted_under_stock_query;
+
+-- 4) 管理租户(AIDOP,拥有 AccountType=888 且无角色的 AIDopAdmin)已获得全部六条租户级授权
+SELECT IF(
+  (SELECT COUNT(*) FROM SysTenant t JOIN SysOrg o ON o.Id = t.OrgId
+    WHERE o.Code = 'AIDOP' AND t.Status = 1) = 0,
+  1,
+  (SELECT COUNT(*) = 6
+     FROM SysTenantMenu tm
+     JOIN SysMenu m ON m.Id = tm.MenuId
+    WHERE tm.TenantId = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id = t.OrgId
+                          WHERE o.Code = 'AIDOP' AND t.Status = 1 LIMIT 1)
+      AND m.Type = 3
+      AND m.Permission LIKE 's5InventoryInbound:%')
+) AS ok_admin_tenant_granted;
+
+-- 5) UAT 租户(全部账号 AccountType=777)**不得**获得租户级授权
+SELECT IF(
+  (SELECT COUNT(*) FROM SysTenant t JOIN SysOrg o ON o.Id = t.OrgId
+    WHERE o.Code = 'UATTEST_CHL' AND t.Status = 1) = 0,
+  1,
+  (SELECT COUNT(*) = 0
+     FROM SysTenantMenu tm
+     JOIN SysMenu m ON m.Id = tm.MenuId
+    WHERE tm.TenantId = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id = t.OrgId
+                          WHERE o.Code = 'UATTEST_CHL' AND t.Status = 1 LIMIT 1)
+      AND m.Type = 3
+      AND m.Permission LIKE 's5InventoryInbound:%')
+) AS ok_uat_tenant_not_granted;
+
+-- 6) UAT_FUNCTION_USER(精确 RoleId 838258976469061,租户 838257186181189)无任何该权限
+SELECT IF(
+  (SELECT COUNT(*) FROM SysRole
+    WHERE Id = 838258976469061 AND TenantId = 838257186181189) = 0,
+  1,
+  (SELECT COUNT(*) = 0
+     FROM SysRoleMenu rm
+     JOIN SysMenu m ON m.Id = rm.MenuId
+    WHERE rm.RoleId = 838258976469061
+      AND m.Type = 3
+      AND m.Permission LIKE 's5InventoryInbound:%')
+) AS ok_uat_function_user_has_none;
+
+-- 7) 权限未扩散到**任何**角色(本批刻意不做 SysRoleMenu 授权)
+SELECT (
+  SELECT COUNT(*) = 0
+    FROM SysRoleMenu rm
+    JOIN SysMenu m ON m.Id = rm.MenuId
+   WHERE m.Type = 3 AND m.Permission LIKE 's5InventoryInbound:%'
+) AS ok_no_role_holds_permission;