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

feat(s8): 建立异常操作权限模型,并堵住跨租户角色越权

修的是一个实测存在的跨租户权限泄漏,不是新增功能那么简单。

平台的权限解析链 SysUserRole → SysRoleMenu → SysMenu(Btn) 只对**菜单**做了
租户校验(InnerJoin SysTenantMenu ... t.TenantId == 当前租户),**对角色没有**;
SysUserRole 本身也不要求用户与角色同租户。真库取证(本地隔离库,只读):
UATAdminA(TenantId=838257186181189)被授予了 ROLE_S6_IPQC_SUPERVISOR,
而该角色 SysRole.TenantId=1300000000001(默认租户),它由此拿到
s8:exception:assign / claim / close / reject / upgrade —— 这些能力在它自己的
租户里没有任何角色授予。同租户的 UATExceptionA 调转派得 403、UATAdminA 却成功,
差别完全来自那个别家租户的角色。迁移留证显示这不是孤例:3 个租户的多个账号
共持有 3 个默认租户角色,每个带 15 个 s8:* 能力。

本次把「谁能对异常单做什么」收回 S8 自己回答:

- S8ExceptionActionCatalog:11 个动作,每个都对应一个**真实存在的 API**。
  刻意不造 EXCEPTION_CLOSE —— S8 没有独立关闭接口,关闭是 approve-verification
  通过后的状态机结果,给它权限位只会配出一个永远不被检查的开关。
- ado_s8_exception_action_role:身份是 (tenant_id, action_code, role_id),
  无 factory、无 user。只存 Action↔Role,角色成员继续由平台角色管理维护。
- S8TenantRoleResolver:「该账号在本租户持有哪些角色 / 能力」的唯一实现。
  S8PermissionAttribute 也接入它做交集 —— 交集只可能收窄、不可能引入新权限,
  对权限修复而言这是唯一可接受的方向,配置面同一个洞不能只堵一半。
- S8ExceptionActionAuthorizer:唯一判定入口,三段判据(动作在目录内 /
  该动作在本租户配了角色 / 账号持有其中之一),任一不满足即 403。
  **fail closed 是硬要求**:配置表为空不等于所有人拥有全部权限——那类默认值
  是权限系统最典型的事故,上线时表还没灌数据就全员放行且不报错。
- 首次 Provisioning 从**本租户既有** SysRoleMenu 语义推导默认值,
  原来谁能做还是谁能做;跨租户角色不带入,推不出角色的动作留空保持关闭,
  不给兜底角色(那等于把刚堵上的越权换个形式放回去)。
  只对完全没有授权行的租户做首次供给,避免每次启动把管理员取消的授权悄悄加回来。
- 「异常操作权限」页面取代已退役的「操作员账号绑定」,复用既有菜单与权限位,
  未授权动作显式标注「未配置」并给出总数提醒。

本地验证:UATAdminA 的转派 / 升级 / 驳回由 200 变 403(越权关闭),
本租户角色真实授予的查看 / 提报仍可用;管理员在页面上把转派授权给本租户角色后
恢复 200;尝试授权别家租户角色被拒(文案与"角色不存在"一致,不泄漏存在性)。

迁移 1.0.497 建唯一索引并断言「不存在跨租户角色授权」,同时把迁移前的跨租户
授予原样留证 —— 明天有人报「我的转派按钮不见了」时,要能当场区分修复与回归。
YY968XX 5 дней назад
Родитель
Сommit
7d31f8faab
19 измененных файлов с 1548 добавлено и 44 удалено
  1. 1 1
      Web/package.json
  2. 28 0
      Web/src/views/aidop/s8/api/s8ConfigApi.ts
  3. 0 0
      Web/src/views/aidop/s8/config/S8ActionPermissionConfigPage.vue
  4. 6 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  5. 150 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.497.sql
  6. 93 20
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8AuthorizationGuardTests.cs
  7. 315 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8ExceptionActionPermissionTests.cs
  8. 113 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Const/S8/S8ExceptionActionCatalog.cs
  9. 85 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigActionPermissionController.cs
  10. 15 15
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ExceptionsController.cs
  11. 4 4
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ReportsController.cs
  12. 42 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8ExceptionActionRole.cs
  13. 78 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8ExceptionActionAttribute.cs
  14. 121 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8ExceptionActionAuthorizer.cs
  15. 33 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8PermissionAttribute.cs
  16. 77 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8TenantRoleResolver.cs
  17. 146 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ExceptionActionRoleProvisioningService.cs
  18. 217 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ExceptionActionRoleService.cs
  19. 24 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

+ 1 - 1
Web/package.json

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

+ 28 - 0
Web/src/views/aidop/s8/api/s8ConfigApi.ts

@@ -5,6 +5,25 @@ function unwrap<T>(res: { data: T }): T {
 	return res.data;
 }
 
+/** S8-ACTION-PERMISSION-1:权限矩阵一行。 */
+export interface S8ActionRoleRow {
+	actionCode: string;
+	displayName: string;
+	apiHint: string;
+	orderNo: number;
+	roleIds: number[];
+	/** 未授权任何角色 —— 该动作对所有非超管账号关闭(fail closed)。 */
+	notConfigured: boolean;
+}
+
+/** 可授权角色(只含与 S8 相关的本租户角色)。 */
+export interface S8AssignableRole {
+	id: number;
+	code: string;
+	name: string;
+	userCount: number;
+}
+
 export interface S8ConfigSysUserRow {
 	id: number;
 	account?: string | null;
@@ -332,6 +351,15 @@ export const s8ConfigApi = {
 		service.get<S8DatasetOption[]>('/api/aidop/s8/config/datasets', { params: withAidopTenantFactoryParams({}) }).then(unwrap),
 	importRolesFromSys: (params?: { tenantId?: number; factoryId?: number }) =>
 		service.post('/api/aidop/s8/config/roles/import-from-sys', null, { params: withAidopTenantFactoryParams(params ?? {}) }).then(unwrap),
+	/** S8-ACTION-PERMISSION-1:异常操作权限(全局配置,非规则级)。 */
+	actionPermissions: {
+		matrix: () => service.get<S8ActionRoleRow[]>('/api/aidop/s8/config/action-permissions', { params: withAidopTenantFactoryParams({}) }).then(unwrap),
+		roles: () => service.get<S8AssignableRole[]>('/api/aidop/s8/config/action-permissions/roles', { params: withAidopTenantFactoryParams({}) }).then(unwrap),
+		/** 当前账号被允许的动作码 —— 前端按钮可见性用,避免点了才 403。 */
+		mine: () => service.get<string[]>('/api/aidop/s8/config/action-permissions/mine', { params: withAidopTenantFactoryParams({}) }).then(unwrap),
+		set: (actionCode: string, roleIds: number[]) =>
+			service.put(`/api/aidop/s8/config/action-permissions/${actionCode}`, withAidopTenantFactoryParams({ roleIds })).then(unwrap),
+	},
 	exceptionTypes: {
 		list: (params?: { tenantId?: number; factoryId?: number; enabledOnly?: boolean }) =>
 			service.get<S8ExceptionTypeConfigRow[]>('/api/aidop/s8/config/exception-types', { params: withAidopTenantFactoryParams(params ?? {}) }).then(unwrap),

+ 0 - 0
Web/src/views/aidop/s8/config/S8RolePermissionConfigPage.vue → Web/src/views/aidop/s8/config/S8ActionPermissionConfigPage.vue


+ 6 - 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.496</AssemblyVersion>
-    <FileVersion>1.0.496</FileVersion>
-    <Version>1.0.496</Version>
+    <AssemblyVersion>1.0.497</AssemblyVersion>
+    <FileVersion>1.0.497</FileVersion>
+    <Version>1.0.497</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -691,6 +691,9 @@
     <None Update="UpdateScripts\1.0.496.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.497.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 150 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.497.sql

@@ -0,0 +1,150 @@
+-- =====================================================================================
+-- 1.0.497  S8-ACTION-PERMISSION-1 · 异常动作授权矩阵
+--
+-- 背景(真库取证,只读):
+--   平台的权限解析链 SysUserRole → SysRoleMenu → SysMenu(Btn) 只对**菜单**做了租户校验
+--   (InnerJoin SysTenantMenu ... t.TenantId == 当前租户),**对角色没有**;
+--   SysUserRole 本身也不要求用户与角色同租户。
+--
+--   实测后果:账号 UATAdminA(TenantId = 838257186181189)被授予了角色
+--   ROLE_S6_IPQC_SUPERVISOR,而该角色 SysRole.TenantId = 1300000000001(默认租户)。
+--   它由此获得 s8:exception:assign / claim / close / reject / upgrade ——
+--   这些能力在它自己的租户里**没有任何角色授予**。同租户的 UATExceptionA 调转派得 403,
+--   UATAdminA 却成功,差别完全来自那个别家租户的角色。
+--
+-- 本次动作:把「谁能对异常单做什么」收回 S8 自己回答,tenant_id 成为行上的一等字段,
+-- 跨租户在结构上不成立。授权矩阵的初值由启动期 Provisioning 从**本租户既有**
+-- SysRoleMenu 语义推导(跨租户角色不带入),推不出角色的动作保持空 = fail closed。
+--
+-- 表结构本身由 CodeFirst(InitTables) 负责创建;本脚本只做:
+--   · 幂等地补齐唯一索引(CodeFirst 对既有表不一定补索引)
+--   · postcheck 断言
+-- 不在此写任何授权数据 —— 授权是 per-tenant 推导结果,写死在迁移里无法适配新租户。
+-- =====================================================================================
+
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- PHASE 1 · PREFLIGHT
+-- ─────────────────────────────────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS `ado_s8_exception_action_role` (
+  `id`         BIGINT      NOT NULL AUTO_INCREMENT,
+  `tenant_id`  BIGINT      NOT NULL COMMENT '租户(唯一安全边界)',
+  `action_code` VARCHAR(64) NOT NULL COMMENT '动作码,取值须在 S8ExceptionActionCatalog 内',
+  `role_id`    BIGINT      NOT NULL COMMENT '被授权角色 SysRole.Id,必须属于 tenant_id',
+  `created_at` DATETIME    NOT NULL,
+  `created_by` VARCHAR(64) NULL,
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='S8 异常动作角色授权';
+
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- PHASE 2 · DIAGNOSTICS
+--   把「本次迁移之前,有多少账号是靠跨租户角色拿到 S8 能力的」原样记下来。
+--   这些账号在迁移后会失去对应能力 —— 那是修复的预期结果,但必须留下可核对的清单,
+--   否则明天有人报「我的转派按钮不见了」时无从判断是回归还是修复。
+-- ─────────────────────────────────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS `ado_s8_cross_tenant_grant_1_0_497` (
+  `id`             BIGINT      NOT NULL AUTO_INCREMENT,
+  `user_id`        BIGINT      NOT NULL,
+  `user_account`   VARCHAR(64) NULL,
+  `user_tenant_id` BIGINT      NULL,
+  `role_id`        BIGINT      NOT NULL,
+  `role_code`      VARCHAR(64) NULL,
+  `role_tenant_id` BIGINT      NULL,
+  `permission`     VARCHAR(128) NULL,
+  `noted_at`       DATETIME    NOT NULL,
+  PRIMARY KEY (`id`),
+  KEY `idx_cross_grant_497_user` (`user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='1.0.497 迁移前的跨租户 S8 能力授予取证';
+
+DELETE FROM `ado_s8_cross_tenant_grant_1_0_497`;
+
+INSERT INTO `ado_s8_cross_tenant_grant_1_0_497`
+  (`user_id`,`user_account`,`user_tenant_id`,`role_id`,`role_code`,`role_tenant_id`,`permission`,`noted_at`)
+SELECT u.`Id`, u.`Account`, u.`TenantId`, r.`Id`, r.`Code`, r.`TenantId`, m.`Permission`, NOW()
+  FROM `SysUserRole` ur
+  JOIN `SysUser`  u ON u.`Id` = ur.`UserId`
+  JOIN `SysRole`  r ON r.`Id` = ur.`RoleId`
+  JOIN `SysRoleMenu` rm ON rm.`RoleId` = r.`Id`
+  JOIN `SysMenu`  m ON m.`Id` = rm.`MenuId`
+ WHERE m.`Type` = 3            -- MenuTypeEnum.Btn(Dir=1 / 菜单=2 / 按钮=3)
+   AND m.`Permission` LIKE 's8:%'
+   AND r.`TenantId` <> u.`TenantId`;
+
+-- 诊断自检:本脚本执行时若一条跨租户授予都没记到,要么真的没有,要么上面的判据写错了。
+-- 这两种情况后果完全不同(前者无事,后者是"看起来成功、实际什么都没做"),
+-- 所以把当时的可比对基数一并记下来,让人能当场分辨。
+INSERT INTO `ado_s8_cross_tenant_grant_1_0_497`
+  (`user_id`,`user_account`,`user_tenant_id`,`role_id`,`role_code`,`role_tenant_id`,`permission`,`noted_at`)
+SELECT 0, '__DIAGNOSTIC__', NULL, 0, '__s8_btn_menu_total__', NULL,
+       CAST(COUNT(*) AS CHAR), NOW()
+  FROM `SysMenu` WHERE `Type` = 3 AND `Permission` LIKE 's8:%';
+
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- PHASE 3 · SCHEMA
+--   唯一索引:同一 (租户, 动作, 角色) 只允许一行。没有它,重复授权会让
+--   「取消授权」变成删一行、剩一行,界面显示已取消而实际仍然放行。
+-- ─────────────────────────────────────────────────────────────────────────────────────
+SET @has_idx := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_exception_action_role'
+    AND INDEX_NAME='uk_s8_action_role');
+
+-- 建唯一索引前先去重(CodeFirst 先建表、后加索引的场景下可能已有重复行)。
+SET @sql := IF(@has_idx = 0, '
+DELETE a FROM `ado_s8_exception_action_role` a
+  JOIN `ado_s8_exception_action_role` b
+    ON a.`tenant_id` = b.`tenant_id`
+   AND a.`action_code` = b.`action_code`
+   AND a.`role_id` = b.`role_id`
+   AND a.`id` > b.`id`', 'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @sql := IF(@has_idx = 0,
+  'ALTER TABLE `ado_s8_exception_action_role` ADD UNIQUE INDEX `uk_s8_action_role` (`tenant_id`,`action_code`,`role_id`)',
+  'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- PHASE 4 · POSTCHECK
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- ① 唯一索引必须存在且覆盖三列
+SET @cols := (SELECT COUNT(*) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='ado_s8_exception_action_role'
+    AND INDEX_NAME='uk_s8_action_role'
+    AND COLUMN_NAME IN ('tenant_id','action_code','role_id'));
+DROP TABLE IF EXISTS `tmp_s8_497_guard`;
+CREATE TABLE `tmp_s8_497_guard` (
+  probe BIGINT NOT NULL,
+  CONSTRAINT s8_497_BLOCKED_unique_index_missing CHECK (probe = 0)
+) COMMENT '1.0.497 断言门禁;通过后自动删除';
+INSERT INTO `tmp_s8_497_guard` (probe) VALUES ((3 - @cols));
+DROP TABLE IF EXISTS `tmp_s8_497_guard`;
+
+-- ② 不允许出现「授权角色不属于该行租户」的数据 —— 这正是本批要消灭的形态
+SET @bad := (SELECT COUNT(*) FROM `ado_s8_exception_action_role` a
+  JOIN `SysRole` r ON r.`Id` = a.`role_id`
+ WHERE r.`TenantId` <> a.`tenant_id`);
+DROP TABLE IF EXISTS `tmp_s8_497_guard`;
+CREATE TABLE `tmp_s8_497_guard` (
+  probe BIGINT NOT NULL,
+  CONSTRAINT s8_497_BLOCKED_cross_tenant_role_granted CHECK (probe = 0)
+) COMMENT '1.0.497 断言门禁;通过后自动删除';
+INSERT INTO `tmp_s8_497_guard` (probe) VALUES (@bad);
+DROP TABLE IF EXISTS `tmp_s8_497_guard`;
+
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- PHASE 5 · IDEMPOTENT RERUN GUARD
+--   第二次执行:CREATE TABLE IF NOT EXISTS 跳过、索引已存在跳过、断言依旧成立。
+--   本脚本**不写任何授权数据**,因此重跑不会改变任何一条授权。
+-- ─────────────────────────────────────────────────────────────────────────────────────
+
+-- ─────────────────────────────────────────────────────────────────────────────────────
+-- PHASE 6 · 菜单重指
+--   /aidop/s8/config/roles 原指向「操作员账号绑定」。该页面在 S8-SYSUSER-ONLY-1 已退役
+--   (它唯一的用途是把员工绑成 S8 可用处理人,账号即身份之后不再有对应概念)。
+--   本批把这个位置改为「异常操作权限」—— 复用既有菜单与既有 s8:config:* 权限位,
+--   不新增菜单节点,避免用户在配置中心里看到一个空壳入口和一个新入口并存。
+-- ─────────────────────────────────────────────────────────────────────────────────────
+UPDATE `SysMenu`
+   SET `Title` = '异常操作权限',
+       `Component` = '/aidop/s8/config/S8ActionPermissionConfigPage'
+ WHERE `Path` = '/aidop/s8/config/roles'
+   AND `Type` = 2;

+ 93 - 20
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8AuthorizationGuardTests.cs

@@ -43,7 +43,19 @@ public class S8AuthorizationGuardTests
         return data;
     }
 
-    private sealed record ActionInfo(Type Controller, MethodInfo Method, string Verb, string Template, string? Permission);
+    /// <summary>
+    /// S8-ACTION-PERMISSION-1:一个 Action 现在可能挂两种门禁之一 ——
+    /// 配置面仍用能力码 <see cref="S8PermissionAttribute"/>,
+    /// 异常单动作改用 <see cref="S8ExceptionActionAttribute"/>(租户内 Action↔Role 判定)。
+    /// 两者<b>互斥</b>:同一个 Action 不允许同时挂,否则同一问题两个 authority。
+    /// </summary>
+    private sealed record ActionInfo(
+        Type Controller, MethodInfo Method, string Verb, string Template,
+        string? Permission, string? ExceptionAction)
+    {
+        /// <summary>是否已被任一门禁保护。未保护 = 退回平台「未登记路由默认放行」。</summary>
+        public bool Guarded => !string.IsNullOrWhiteSpace(Permission) || !string.IsNullOrWhiteSpace(ExceptionAction);
+    }
 
     private static IEnumerable<ActionInfo> AllActions()
     {
@@ -57,7 +69,8 @@ public class S8AuthorizationGuardTests
                 var verb = http.HttpMethods.FirstOrDefault() ?? "GET";
                 var template = (http as IRouteTemplateProvider)?.Template ?? string.Empty;
                 var perm = m.GetCustomAttribute<S8PermissionAttribute>()?.Code;
-                yield return new ActionInfo(c, m, verb.ToUpperInvariant(), template, perm);
+                var act = m.GetCustomAttribute<S8ExceptionActionAttribute>()?.Code;
+                yield return new ActionInfo(c, m, verb.ToUpperInvariant(), template, perm, act);
             }
         }
     }
@@ -88,12 +101,20 @@ public class S8AuthorizationGuardTests
     public void EveryS8Action_DeclaresAPermission()
     {
         var missing = AllActions()
-            .Where(a => string.IsNullOrWhiteSpace(a.Permission))
+            .Where(a => !a.Guarded)
             .Select(a => $"{a.Controller.Name}.{a.Method.Name} [{a.Verb} {a.Template}]")
             .ToList();
 
         Assert.True(missing.Count == 0,
-            "以下 S8 Action 未声明 [S8Permission],将退回平台『未登记路由默认放行』:\n" + string.Join('\n', missing));
+            "以下 S8 Action 未声明 [S8Permission] 或 [S8ExceptionAction],将退回平台『未登记路由默认放行』:\n"
+            + string.Join('\n', missing));
+
+        // S8-ACTION-PERMISSION-1:两种门禁互斥。同时挂 = 同一问题两个 authority,判据迟早分叉。
+        var doubled = AllActions()
+            .Where(a => !string.IsNullOrWhiteSpace(a.Permission) && !string.IsNullOrWhiteSpace(a.ExceptionAction))
+            .Select(a => $"{a.Controller.Name}.{a.Method.Name}")
+            .ToList();
+        Assert.True(doubled.Count == 0, "以下 Action 同时挂了两种门禁:\n" + string.Join('\n', doubled));
     }
 
     /// <summary>声明的权限码必须在正式目录内,杜绝写错字符串导致永远匹配不到。</summary>
@@ -107,6 +128,13 @@ public class S8AuthorizationGuardTests
             .Distinct().ToList();
 
         Assert.True(unknown.Count == 0, "以下权限码不在 S8PermissionCatalog 内:\n" + string.Join('\n', unknown));
+
+        var unknownActions = AllActions()
+            .Where(a => a.ExceptionAction != null && !S8ExceptionActionCatalog.IsKnown(a.ExceptionAction))
+            .Select(a => $"{a.Controller.Name}.{a.Method.Name} → {a.ExceptionAction}")
+            .Distinct().ToList();
+        Assert.True(unknownActions.Count == 0,
+            "以下动作码不在 S8ExceptionActionCatalog 内:\n" + string.Join('\n', unknownActions));
     }
 
     // ───────────────────────── ② 写动作不得落到只读能力 ─────────────────────────
@@ -119,8 +147,13 @@ public class S8AuthorizationGuardTests
     public void MutationActions_DoNotUseReadOnlyCapabilities()
     {
         var offenders = AllActions()
-            .Where(a => a.Verb != "GET" && a.Permission != null && IsReadCapability(a.Permission))
-            .Select(a => $"{a.Controller.Name}.{a.Method.Name} [{a.Verb}] → {a.Permission}")
+            .Where(a => a.Verb != "GET"
+                        && ((a.Permission != null && IsReadCapability(a.Permission))
+                            // 新门禁同理:写动作不得落在"查看异常"上,
+                            // 否则有查看权限的人就能改业务状态。
+                            || a.ExceptionAction == S8ExceptionActionCode.View
+                            || a.ExceptionAction == S8ExceptionActionCode.ViewAll))
+            .Select(a => $"{a.Controller.Name}.{a.Method.Name} [{a.Verb}] → {a.Permission ?? a.ExceptionAction}")
             .ToList();
 
         Assert.True(offenders.Count == 0, "以下写动作落在只读能力上:\n" + string.Join('\n', offenders));
@@ -170,15 +203,20 @@ public class S8AuthorizationGuardTests
 
     /// <summary>复检通过 / 退回必须是 quality 能力;提交复检、认领、开始处理必须是 operator 能力。</summary>
     [Theory]
-    [InlineData("approve-verification", S8PermissionCatalog.VerificationApprove)]
-    [InlineData("reject-verification", S8PermissionCatalog.VerificationReject)]
-    [InlineData("submit-verification", S8PermissionCatalog.VerificationSubmit)]
-    [InlineData("claim", S8PermissionCatalog.ExceptionClaim)]
-    [InlineData("start-progress", S8PermissionCatalog.ExceptionStart)]
-    [InlineData("transfer", S8PermissionCatalog.ExceptionAssign)]
-    [InlineData("upgrade", S8PermissionCatalog.ExceptionUpgrade)]
-    [InlineData("comment", S8PermissionCatalog.ExceptionComment)]
-    public void ExceptionAction_MapsToExpectedCapability(string routeFragment, string expected)
+    // S8-ACTION-PERMISSION-1:映射对象由能力码改为**动作码**。
+    // 旧能力码在本批之后只剩一个用途:首次 Provisioning 从「本租户原本谁能做」推导默认授权;
+    // 运行期判定改由 S8ExceptionActionAttribute + 租户内 Action↔Role 回答,
+    // 原因是旧链路不校验角色的租户(实测 UATAdminA 经别家租户角色拿到 assign)。
+    // 守卫对象随之改变,守的仍是同一件事:这个路由挂对了门禁。
+    [InlineData("approve-verification", S8ExceptionActionCode.Verify)]
+    [InlineData("reject-verification", S8ExceptionActionCode.Verify)]
+    [InlineData("submit-verification", S8ExceptionActionCode.SubmitVerify)]
+    [InlineData("claim", S8ExceptionActionCode.Claim)]
+    [InlineData("start-progress", S8ExceptionActionCode.Start)]
+    [InlineData("transfer", S8ExceptionActionCode.Transfer)]
+    [InlineData("upgrade", S8ExceptionActionCode.Upgrade)]
+    [InlineData("comment", S8ExceptionActionCode.Comment)]
+    public void ExceptionAction_MapsToExpectedActionCode(string routeFragment, string expected)
     {
         var action = AllActions().SingleOrDefault(a =>
             a.Controller == typeof(AdoS8ExceptionsController)
@@ -186,7 +224,7 @@ public class S8AuthorizationGuardTests
             && a.Template.EndsWith(routeFragment, StringComparison.Ordinal));
 
         Assert.NotNull(action);
-        Assert.Equal(expected, action!.Permission);
+        Assert.Equal(expected, action!.ExceptionAction);
     }
 
     /// <summary>「检验通过 / 退回」绝不能与「提交复检」共用能力码,否则处理人可自检自过。</summary>
@@ -232,8 +270,25 @@ public class S8AuthorizationGuardTests
         var src = File.ReadAllText(Path.Combine(root, "Infrastructure/S8/S8PermissionAttribute.cs"));
 
         Assert.Contains("GetOwnBtnPermList", src);
-        Assert.DoesNotContain("AdoS8RolePermissionConfig", src);
-        Assert.DoesNotContain("PermissionCodes", src);
+
+        // S8-ACTION-PERMISSION-1:新授权链路一并纳入本守卫。新表 ado_s8_exception_action_role
+        // 取代了那张 0 消费方的死表,绝不能有人顺手把 permission_codes 接回运行链。
+        foreach (var f in new[]
+                 {
+                     "Infrastructure/S8/S8PermissionAttribute.cs",
+                     "Infrastructure/S8/S8ExceptionActionAuthorizer.cs",
+                     "Infrastructure/S8/S8ExceptionActionAttribute.cs",
+                     "Infrastructure/S8/S8TenantRoleResolver.cs",
+                 })
+        {
+            var one = File.ReadAllText(Path.Combine(root, f));
+            // 判据必须精确到「那张死表的实体 / 属性 / 列」。
+            // 裸子串 "PermissionCodes" 会误伤合法方法名(如按租户解析能力码的
+            // GetPermissionCodesAsync),把一条真守卫变成噪音,最后被人整条删掉。
+            Assert.DoesNotContain("AdoS8RolePermissionConfig", one);
+            Assert.DoesNotContain(".PermissionCodes", one);
+            Assert.DoesNotContain("permission_codes", one);
+        }
     }
 
     /// <summary>权限门必须 fail-closed:拿不到权限服务 / 抛异常时一律 403,绝不放行。</summary>
@@ -247,6 +302,13 @@ public class S8AuthorizationGuardTests
         Assert.Contains("catch", src);
         // 空码构造必须抛,防止 [S8Permission("")] 变成静默放行。
         Assert.Throws<ArgumentException>(() => new S8PermissionAttribute(""));
+
+        // S8-ACTION-PERMISSION-1:新门禁同一条要求,且未知动作码同样在构造期就炸。
+        var actionSrc = File.ReadAllText(Path.Combine(root, "Infrastructure/S8/S8ExceptionActionAttribute.cs"));
+        Assert.Contains("Status403Forbidden", actionSrc);
+        Assert.Contains("catch", actionSrc);
+        Assert.Throws<ArgumentException>(() => new S8ExceptionActionAttribute(""));
+        Assert.Throws<ArgumentException>(() => new S8ExceptionActionAttribute("EXCEPTION_NOT_REAL"));
     }
 
     /// <summary>
@@ -258,12 +320,23 @@ public class S8AuthorizationGuardTests
     public void CatalogHasNoOrphanCapability()
     {
         var used = AllActions().Where(a => a.Permission != null).Select(a => a.Permission!).ToHashSet(StringComparer.Ordinal);
+
+        // S8-ACTION-PERMISSION-1:归属现在有两种 —— ① 仍被某个 Action 直接使用(配置面);
+        // ② 被 S8ExceptionActionCatalog 引为某动作的 LegacyPermissionCode。后者不是孤儿,
+        // 而是首次 Provisioning 推导默认授权的输入;删掉它会让「本租户原本谁能做这件事」失传。
+        var legacyMapped = S8ExceptionActionCatalog.All
+            .SelectMany(d => new[] { d.LegacyPermissionCode }.Concat(d.LegacyPermissionAliases))
+            .Where(c => !string.IsNullOrWhiteSpace(c))
+            .Select(c => c!)
+            .ToHashSet(StringComparer.Ordinal);
+
         var orphans = S8PermissionCatalog.All
             .Where(x => !x.Deprecated)
             .Select(x => x.Code)
-            .Where(c => !used.Contains(c))
+            .Where(c => !used.Contains(c) && !legacyMapped.Contains(c))
             .ToList();
-        Assert.True(orphans.Count == 0, "以下能力码在目录内但无任何 Action 使用:\n" + string.Join('\n', orphans));
+        Assert.True(orphans.Count == 0,
+            "以下能力码既无 Action 使用、也未被动作目录引为 Legacy 推导输入:\n" + string.Join('\n', orphans));
     }
 
     /// <summary>已废弃能力码不得被任何 Action 引用(否则等于用一个没人维护的码当门禁)。</summary>

+ 315 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8ExceptionActionPermissionTests.cs

@@ -0,0 +1,315 @@
+using System.Reflection;
+using Admin.NET.Plugin.AiDOP.Const.S8;
+using Admin.NET.Plugin.AiDOP.Controllers.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-ACTION-PERMISSION-1:异常单动作授权模型。
+///
+/// <para><b>本批修的是一个实测存在的跨租户权限泄漏。</b>平台的
+/// <c>SysMenuService.GetOwnBtnPermList()</c> 只对<b>菜单</b>做租户校验
+/// (<c>InnerJoin SysTenantMenu</c>),<b>对角色不做</b>;<c>SysUserRole</c> 也不要求
+/// 用户与角色同租户。真库取证:<c>UATAdminA</c>(租户 838257186181189)经
+/// <c>ROLE_S6_IPQC_SUPERVISOR</c>(<c>SysRole.TenantId = 1300000000001</c>)
+/// 拿到 <c>s8:exception:assign</c> 等能力,而它自己租户里没有任何角色授予这些能力。</para>
+///
+/// <para><b>因此本文件的断言大量是反向的</b>:这类缺陷的特征是「加回一个谓词 / 少一个
+/// 租户过滤就静默放宽」,正向断言抓不到。</para>
+/// </summary>
+public class S8ExceptionActionPermissionTests
+{
+    private const long TenantA = 838257186181189L;
+    private const long TenantB = 838257212780613L;
+
+    private static readonly string PluginRoot =
+        Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
+
+    /// <summary>只取可执行代码行:注释里为留档会复述旧写法,不应算违规。</summary>
+    private static string CodeOnly(string relativePath)
+    {
+        var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
+        Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
+        return string.Join('\n', File.ReadAllLines(full)
+            .Where(l =>
+            {
+                var t = l.TrimStart();
+                return !t.StartsWith("///", StringComparison.Ordinal)
+                       && !t.StartsWith("//", StringComparison.Ordinal);
+            }));
+    }
+
+    // ══════════════════════════════════════════════════════════════════════════════
+    // 动作目录:只能有真实存在的动作
+    // ══════════════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 目录里的每个动作都必须标注它对应的真实接口。
+    ///
+    /// <para>顺带守住一条设计约束:<b>不得出现 <c>EXCEPTION_CLOSE</c></b>。
+    /// S8 没有独立的「关闭」接口 —— 关闭是 <c>approve-verification</c> 通过后的状态机结果。
+    /// 给它一个可配置权限位,管理员会配一个永远不被检查的开关。</para>
+    /// </summary>
+    [Fact]
+    public void ActionCatalog_ContainsOnlyRealActions()
+    {
+        Assert.NotEmpty(S8ExceptionActionCatalog.All);
+        foreach (var d in S8ExceptionActionCatalog.All)
+        {
+            Assert.False(string.IsNullOrWhiteSpace(d.Code));
+            Assert.False(string.IsNullOrWhiteSpace(d.DisplayName));
+            Assert.False(string.IsNullOrWhiteSpace(d.ApiHint));
+        }
+        Assert.DoesNotContain(S8ExceptionActionCatalog.All, d =>
+            d.Code.Equals("EXCEPTION_CLOSE", StringComparison.OrdinalIgnoreCase));
+
+        // 动作码唯一
+        Assert.Equal(S8ExceptionActionCatalog.All.Count,
+            S8ExceptionActionCatalog.All.Select(d => d.Code).Distinct(StringComparer.OrdinalIgnoreCase).Count());
+    }
+
+    /// <summary>
+    /// 目录必须完整覆盖异常控制器上的每一个 Action —— 漏一个就意味着那个接口没有授权门。
+    /// </summary>
+    [Fact]
+    public void EveryExceptionEndpoint_IsGuardedByActionAttribute()
+    {
+        foreach (var controller in new[] { typeof(AdoS8ExceptionsController), typeof(AdoS8ReportsController) })
+        {
+            var actions = controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)
+                .Where(m => !m.IsSpecialName)
+                .ToList();
+            Assert.NotEmpty(actions);
+
+            foreach (var a in actions)
+            {
+                var attr = a.GetCustomAttribute<S8ExceptionActionAttribute>();
+                Assert.True(attr != null, $"{controller.Name}.{a.Name} 缺少 S8ExceptionAction 授权门");
+                Assert.True(S8ExceptionActionCatalog.IsKnown(attr!.Code),
+                    $"{controller.Name}.{a.Name} 使用了目录外的动作码 {attr.Code}");
+
+                // 旧门禁不得与新门禁并存 —— 同一问题两个 authority 迟早判据分叉。
+                Assert.Null(a.GetCustomAttribute<S8PermissionAttribute>());
+            }
+        }
+    }
+
+    /// <summary>未知动作码在构造期就必须炸,而不是等运行时静默放行。</summary>
+    [Fact]
+    public void UnknownAction_RejectedAtConstruction()
+    {
+        Assert.Throws<ArgumentException>(() => new S8ExceptionActionAttribute("EXCEPTION_NOT_A_REAL_ACTION"));
+        Assert.Throws<ArgumentException>(() => new S8ExceptionActionAttribute(""));
+        Assert.False(S8ExceptionActionCatalog.IsKnown("EXCEPTION_NOT_A_REAL_ACTION"));
+        Assert.False(S8ExceptionActionCatalog.IsKnown(null));
+    }
+
+    // ══════════════════════════════════════════════════════════════════════════════
+    // 授权器判据
+    // ══════════════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// T-unknown-action / T-fail-closed:
+    /// 未知动作拒绝;<b>该动作一个角色都没配也必须拒绝</b>。
+    ///
+    /// <para>「配置表为空 = 所有人拥有全部权限」是权限系统最典型的事故 ——
+    /// 上线时表还没灌数据,于是全员拥有全部动作,而且没有任何报错。</para>
+    /// </summary>
+    [Fact]
+    public void Authorizer_FailsClosed_OnUnknownActionAndEmptyConfig()
+    {
+        var code = CodeOnly("Infrastructure/S8/S8ExceptionActionAuthorizer.cs");
+
+        // 未知动作先判、直接拒
+        Assert.Contains("if (!S8ExceptionActionCatalog.IsKnown(actionCode))", code);
+        Assert.Contains(S8ActionDenyReason.UnknownAction, code);
+
+        // 空配置 = 拒绝
+        Assert.Contains("if (grantedRoleIds.Count == 0)", code);
+        Assert.Contains(S8ActionDenyReason.ActionNotConfigured, code);
+
+        // 不得出现任何「空即放行」的写法
+        Assert.DoesNotContain("Count == 0) return S8ActionAuthorizationResult.Pass()", code);
+    }
+
+    /// <summary>
+    /// T-cross-tenant-role-reject:账号持有的角色必须属于<b>当前租户</b>。
+    ///
+    /// <para>授权器不自己查用户角色,而是走 <see cref="IS8TenantRoleResolver"/> ——
+    /// 跨租户角色在那一步就已经被排除,授权器这一侧不可能再漏。</para>
+    /// </summary>
+    [Fact]
+    public void Authorizer_ResolvesRolesThroughTenantScopedResolver()
+    {
+        var code = CodeOnly("Infrastructure/S8/S8ExceptionActionAuthorizer.cs");
+        Assert.Contains("_roleResolver.GetRoleIdsAsync(tenantId, userId)", code);
+
+        // 不得绕过 resolver 自己拼 SysUserRole 查询
+        Assert.DoesNotContain("Queryable<SysUserRole>", code);
+        Assert.DoesNotContain("SysRoleMenu", code);
+    }
+
+    /// <summary>租户角色解析器本身必须带 <c>SysRole.TenantId == 传入租户</c>,且不含工厂谓词。</summary>
+    [Fact]
+    public void TenantRoleResolver_IsTenantScopedAndFactoryFree()
+    {
+        var code = CodeOnly("Infrastructure/S8/S8TenantRoleResolver.cs");
+        Assert.Contains("r.TenantId == tenantId", code);
+        Assert.DoesNotContain("FactoryId", code);
+        Assert.DoesNotContain("FactoryRefId", code);
+        Assert.DoesNotContain("EmployeeMaster", code);
+    }
+
+    /// <summary>
+    /// T-factory-irrelevant / T-no-employee:
+    /// 授权链上任何一环都不得出现工厂或员工。
+    /// </summary>
+    [Theory]
+    [InlineData("Infrastructure/S8/S8ExceptionActionAuthorizer.cs")]
+    [InlineData("Infrastructure/S8/S8TenantRoleResolver.cs")]
+    [InlineData("Infrastructure/S8/S8ExceptionActionAttribute.cs")]
+    [InlineData("Service/S8/S8ExceptionActionRoleService.cs")]
+    [InlineData("Service/S8/S8ExceptionActionRoleProvisioningService.cs")]
+    public void AuthorizationChain_HasNoFactoryOrEmployee(string relativePath)
+    {
+        var code = CodeOnly(relativePath);
+        Assert.DoesNotContain("FactoryRefId", code);
+        Assert.DoesNotContain("EmployeeMaster", code);
+        Assert.DoesNotContain("AdoS0EmployeeMaster", code);
+    }
+
+    /// <summary>
+    /// T-multiple-roles-union:多角色取并集 —— 持有任一被授权角色即通过,
+    /// 而不是要求全部持有。
+    /// </summary>
+    [Fact]
+    public void Authorizer_UnionsMultipleRoles()
+    {
+        var code = CodeOnly("Infrastructure/S8/S8ExceptionActionAuthorizer.cs");
+        // Any(...) = 并集语义;若写成 All(...) 就变成"必须持有全部角色"
+        Assert.Contains("userRoleIds.Any(grantedRoleIds.Contains)", code);
+        Assert.DoesNotContain("userRoleIds.All(", code);
+    }
+
+    /// <summary>授权判定必须<b>只有一份实现</b>;各 Service 不得自己散落角色判断。</summary>
+    [Fact]
+    public void ActionAuthorization_HasSingleImplementation()
+    {
+        var impls = typeof(IS8ExceptionActionAuthorizer).Assembly.GetTypes()
+            .Where(t => t is { IsAbstract: false, IsInterface: false }
+                        && typeof(IS8ExceptionActionAuthorizer).IsAssignableFrom(t))
+            .ToList();
+        Assert.Single(impls);
+        Assert.Equal(typeof(S8ExceptionActionAuthorizer), impls[0]);
+
+        var resolvers = typeof(IS8TenantRoleResolver).Assembly.GetTypes()
+            .Where(t => t is { IsAbstract: false, IsInterface: false }
+                        && typeof(IS8TenantRoleResolver).IsAssignableFrom(t))
+            .ToList();
+        Assert.Single(resolvers);
+
+        // 业务 Service 里不得再出现自己的角色/权限判断
+        foreach (var f in new[]
+                 {
+                     "Service/S8/S8TaskFlowService.cs",
+                     "Service/S8/S8ExceptionService.cs",
+                     "Service/S8/S8ManualReportService.cs",
+                 })
+        {
+            var code = CodeOnly(f);
+            Assert.DoesNotContain("SysRoleMenu", code);
+            Assert.DoesNotContain("GetOwnBtnPermList", code);
+        }
+    }
+
+    // ══════════════════════════════════════════════════════════════════════════════
+    // 状态机仍然独立生效
+    // ══════════════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// T-state-rule-still-enforced:权限通过<b>不等于</b>状态允许。
+    /// 两道门必须各自独立 —— 权限只回答「这个角色能不能做这类事」,
+    /// 状态回答「这张单现在能不能做」。
+    /// </summary>
+    [Fact]
+    public void StateMachine_RemainsIndependentOfPermission()
+    {
+        var code = CodeOnly("Service/S8/S8TaskFlowService.cs");
+        Assert.Contains("S8StatusRules.IsAllowedTransition", code);
+        // 状态判断不得被改写成"有权限就跳过状态检查"
+        Assert.DoesNotContain("IsAllowedTransition(e.Status, \"ASSIGNED\") ||", code);
+
+        // 认领的原子谓词仍在(First Claim Wins 不因权限改造而失效)
+        Assert.Contains("x.AssigneeUserId == null", code);
+        Assert.Contains("该异常已被其他人员认领", code);
+    }
+
+    // ══════════════════════════════════════════════════════════════════════════════
+    // 写入侧 & 供给
+    // ══════════════════════════════════════════════════════════════════════════════
+
+    /// <summary>写入侧必须校验角色属于当前租户,否则刚堵的洞会从配置页漏回来。</summary>
+    [Fact]
+    public void Write_RejectsCrossTenantRole()
+    {
+        var code = CodeOnly("Service/S8/S8ExceptionActionRoleService.cs");
+        Assert.Contains("r.TenantId == scope.TenantId", code);
+        Assert.Contains("所选角色不存在或不属于当前租户", code);
+    }
+
+    /// <summary>
+    /// 供给只推导<b>本租户</b>角色,且推不出角色时留空(fail closed),不给兜底角色。
+    /// </summary>
+    [Fact]
+    public void Provisioning_DerivesFromSameTenantRolesOnly()
+    {
+        var code = CodeOnly("Service/S8/S8ExceptionActionRoleProvisioningService.cs");
+        Assert.Contains("pending.Contains(r.TenantId.Value)", code);
+        Assert.Contains("if (roleIds.Count == 0) { result.UnconfiguredCount++; continue; }", code);
+
+        // 只做首次供给:已有任意授权行的租户整体跳过,避免把管理员取消的授权悄悄加回来
+        Assert.Contains("tenantsWithRows", code);
+    }
+
+    /// <summary>存储身份是 (租户, 动作, 角色),不含工厂;且只存 Action↔Role,不存 Action↔User。</summary>
+    [Fact]
+    public void Storage_IdentityIsTenantActionRole()
+    {
+        var t = typeof(AdoS8ExceptionActionRole);
+        Assert.NotNull(t.GetProperty("TenantId"));
+        Assert.NotNull(t.GetProperty("ActionCode"));
+        Assert.NotNull(t.GetProperty("RoleId"));
+        Assert.Null(t.GetProperty("FactoryId"));
+        Assert.Null(t.GetProperty("UserId"));
+    }
+
+    /// <summary>迁移必须建唯一索引并断言"不存在跨租户角色授权"。</summary>
+    [Fact]
+    public void Migration_GuardsUniquenessAndCrossTenant()
+    {
+        var sql = File.ReadAllText(Path.GetFullPath(Path.Combine(
+            PluginRoot, "../../Admin.NET.Web.Entry/UpdateScripts/1.0.497.sql")));
+
+        Assert.Contains("uk_s8_action_role", sql);
+        Assert.Contains("s8_497_BLOCKED_unique_index_missing", sql);
+        Assert.Contains("s8_497_BLOCKED_cross_tenant_role_granted", sql);
+        // 迁移前的跨租户授予必须留证,否则明天没人能区分"修复"与"回归"
+        Assert.Contains("ado_s8_cross_tenant_grant_1_0_497", sql);
+        // AutoVersionUpdate 对含该关键字的脚本一律拒绝执行(朴素子串匹配,注释也算)
+        Assert.DoesNotContain("DELIMITER", sql);
+    }
+
+    /// <summary>配置面的旧门禁也必须补上租户交集 —— 同一个洞不能只堵一半。</summary>
+    [Fact]
+    public void ConfigPermissionGate_AlsoIntersectsTenantRoles()
+    {
+        var code = CodeOnly("Infrastructure/S8/S8PermissionAttribute.cs");
+        Assert.Contains("IS8TenantRoleResolver", code);
+        Assert.Contains("GetPermissionCodesAsync", code);
+        // 交集只可能收窄;不得出现"任一满足即放行"的或逻辑
+        Assert.Contains("if (tenantCodes.Contains(Code)) return;", code);
+    }
+}

+ 113 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Const/S8/S8ExceptionActionCatalog.cs

@@ -0,0 +1,113 @@
+namespace Admin.NET.Plugin.AiDOP.Const.S8;
+
+/// <summary>
+/// S8-ACTION-PERMISSION-1:异常单「操作」的规范编码。
+///
+/// <para><b>每一条都对应一个真实存在的 API</b>(见 <see cref="S8ExceptionActionCatalog.All"/> 里的
+/// <c>ApiHint</c>)。刻意<b>不</b>凭空造 <c>EXCEPTION_CLOSE</c> 之类的动作 ——
+/// S8 没有独立的「关闭」接口,关闭是 <c>approve-verification</c> 通过后的状态机结果,
+/// 给它一个可配置的权限位只会让管理员配了一个永远不会被检查的开关。</para>
+/// </summary>
+public static class S8ExceptionActionCode
+{
+    /// <summary>查看异常(列表 / 详情 / 时间线 / 决策 / 证据 / 筛选项)。</summary>
+    public const string View = "EXCEPTION_VIEW";
+
+    /// <summary>
+    /// 查看全部异常(跨规则责任池)。
+    /// <para>在 P1-B 建立配置位,由 P1-C 的可见性收敛真正消费 ——
+    /// 届时「非责任池成员」将只能看到自己有责任的规则所产生的异常。</para>
+    /// </summary>
+    public const string ViewAll = "EXCEPTION_VIEW_ALL";
+
+    /// <summary>人工提报异常(<c>POST /reports</c>)。</summary>
+    public const string Create = "EXCEPTION_CREATE";
+
+    /// <summary>认领(<c>POST {id}/claim</c>)。处理人恒为当前登录账号。</summary>
+    public const string Claim = "EXCEPTION_CLAIM";
+
+    /// <summary>转派 / 指派他人(<c>POST {id}/transfer</c>)。与认领分开,权限强于认领。</summary>
+    public const string Transfer = "EXCEPTION_TRANSFER";
+
+    /// <summary>开始处理(<c>POST {id}/start-progress</c>)。</summary>
+    public const string Start = "EXCEPTION_START";
+
+    /// <summary>提交复核(<c>POST {id}/submit-verification</c>)。</summary>
+    public const string SubmitVerify = "EXCEPTION_SUBMIT_VERIFY";
+
+    /// <summary>审核通过 / 退回(<c>POST {id}/approve-verification</c> · <c>{id}/reject-verification</c>)。</summary>
+    public const string Verify = "EXCEPTION_VERIFY";
+
+    /// <summary>升级(<c>POST {id}/upgrade</c>)。</summary>
+    public const string Upgrade = "EXCEPTION_UPGRADE";
+
+    /// <summary>驳回(<c>POST {id}/reject</c>)。</summary>
+    public const string Reject = "EXCEPTION_REJECT";
+
+    /// <summary>补充说明(<c>POST {id}/comment</c>)。</summary>
+    public const string Comment = "EXCEPTION_COMMENT";
+}
+
+/// <summary>一个可配置动作的元信息。</summary>
+public sealed record S8ExceptionActionDefinition(
+    string Code,
+    string DisplayName,
+    string ApiHint,
+    /// <summary>
+    /// 该动作在<b>旧权限体系</b>中的对应能力码(<c>SysMenu.Permission</c>)。
+    /// 仅供首次 Provisioning 推导默认值使用 —— 让「本租户原本谁能做」原样延续,
+    /// 而不是拍脑袋给一套新默认。为 null 表示旧体系里没有对应位(如 ViewAll)。
+    /// </summary>
+    string? LegacyPermissionCode,
+    /// <summary>
+    /// 该动作还等价于哪些旧能力码(并集推导)。例如「审核」在旧体系里被拆成
+    /// approve / reject 两个码,任一持有即视为拥有审核能力。
+    /// </summary>
+    string[] LegacyPermissionAliases,
+    int OrderNo);
+
+/// <summary>
+/// 动作目录。<b>这是「S8 异常单到底有哪些可授权动作」的唯一出处</b> ——
+/// 后端授权、Provisioning 默认值、前端权限矩阵三方共用同一份,
+/// 避免出现「页面上能配、后端根本不检查」或反之。
+/// </summary>
+public static class S8ExceptionActionCatalog
+{
+    public static readonly IReadOnlyList<S8ExceptionActionDefinition> All = new List<S8ExceptionActionDefinition>
+    {
+        new(S8ExceptionActionCode.View, "查看异常", "GET /exceptions · {id} · timeline · decisions · evidences",
+            S8PermissionCatalog.ExceptionRead, Array.Empty<string>(), 10),
+        new(S8ExceptionActionCode.ViewAll, "查看全部异常", "(P1-C 可见性收敛消费)",
+            null, Array.Empty<string>(), 20),
+        new(S8ExceptionActionCode.Create, "提报异常", "POST /reports",
+            S8PermissionCatalog.ExceptionCreate, Array.Empty<string>(), 30),
+        new(S8ExceptionActionCode.Claim, "认领", "POST /exceptions/{id}/claim",
+            S8PermissionCatalog.ExceptionClaim, Array.Empty<string>(), 40),
+        new(S8ExceptionActionCode.Transfer, "转派", "POST /exceptions/{id}/transfer",
+            S8PermissionCatalog.ExceptionAssign, Array.Empty<string>(), 50),
+        new(S8ExceptionActionCode.Start, "开始处理", "POST /exceptions/{id}/start-progress",
+            S8PermissionCatalog.ExceptionStart, Array.Empty<string>(), 60),
+        new(S8ExceptionActionCode.SubmitVerify, "提交复核", "POST /exceptions/{id}/submit-verification",
+            S8PermissionCatalog.VerificationSubmit, Array.Empty<string>(), 70),
+        new(S8ExceptionActionCode.Verify, "审核(通过 / 退回)",
+            "POST /exceptions/{id}/approve-verification · reject-verification",
+            S8PermissionCatalog.VerificationApprove,
+            new[] { S8PermissionCatalog.VerificationReject }, 80),
+        new(S8ExceptionActionCode.Upgrade, "升级", "POST /exceptions/{id}/upgrade",
+            S8PermissionCatalog.ExceptionUpgrade, Array.Empty<string>(), 90),
+        new(S8ExceptionActionCode.Reject, "驳回", "POST /exceptions/{id}/reject",
+            S8PermissionCatalog.ExceptionReject, Array.Empty<string>(), 100),
+        new(S8ExceptionActionCode.Comment, "补充说明", "POST /exceptions/{id}/comment",
+            S8PermissionCatalog.ExceptionComment, Array.Empty<string>(), 110),
+    };
+
+    private static readonly Dictionary<string, S8ExceptionActionDefinition> ByCode =
+        All.ToDictionary(x => x.Code, StringComparer.OrdinalIgnoreCase);
+
+    /// <summary>动作码是否在目录中。<b>不在目录 = 未知动作 = 一律拒绝</b>(fail closed)。</summary>
+    public static bool IsKnown(string? code) =>
+        !string.IsNullOrWhiteSpace(code) && ByCode.ContainsKey(code!);
+
+    public static S8ExceptionActionDefinition? Find(string? code) =>
+        string.IsNullOrWhiteSpace(code) ? null : ByCode.GetValueOrDefault(code!);
+}

+ 85 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigActionPermissionController.cs

@@ -0,0 +1,85 @@
+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;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
+
+/// <summary>
+/// S8-ACTION-PERMISSION-1:「异常操作权限」配置。
+///
+/// <para>这是 <b>S8 全局</b>配置,<b>不属于某一条规则</b> —— 它回答的是
+/// 「哪些角色能对异常单做哪些动作」,与「这条规则由谁负责」(Rule Handler Pool)
+/// 是两个正交问题。刻意不放进规则弹窗:混在一起会让管理员以为改的是这条规则的权限,
+/// 实际改的是全租户。</para>
+/// </summary>
+[ApiController]
+[Route("api/aidop/s8/config/action-permissions")]
+[NonUnify]
+public class AdoS8ConfigActionPermissionController : ControllerBase
+{
+    private readonly S8ExceptionActionRoleService _svc;
+    private readonly S8TrustedScopeResolver _scope;
+    private readonly IS8ExceptionActionAuthorizer _authorizer;
+
+    public AdoS8ConfigActionPermissionController(
+        S8ExceptionActionRoleService svc,
+        S8TrustedScopeResolver scope,
+        IS8ExceptionActionAuthorizer authorizer)
+    {
+        _svc = svc;
+        _scope = scope;
+        _authorizer = authorizer;
+    }
+
+    /// <summary>权限矩阵(目录内每个动作一行,未配置的也返回,前端要能显示"未配置")。</summary>
+    [HttpGet]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
+    public async Task<IActionResult> MatrixAsync()
+    {
+        try { return Ok(await _svc.GetMatrixAsync(await _scope.ResolveAsync())); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    /// <summary>可授权角色(只列与 S8 相关的本租户角色,不机械返回全部)。</summary>
+    [HttpGet("roles")]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
+    public async Task<IActionResult> RolesAsync()
+    {
+        try { return Ok(await _svc.GetAssignableRolesAsync(await _scope.ResolveAsync())); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    /// <summary>当前账号被允许的动作码 —— 前端据此控制按钮可见性,避免点了才 403。</summary>
+    [HttpGet("mine")]
+    [S8ExceptionAction(S8ExceptionActionCode.View)]
+    public async Task<IActionResult> MineAsync()
+    {
+        try
+        {
+            var allowed = await _svc.GetMyAllowedActionsAsync(await _scope.ResolveAsync(), _authorizer);
+            return Ok(allowed.ToList());
+        }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    /// <summary>覆盖某动作的授权角色(全量替换)。角色必须属于当前租户。</summary>
+    [HttpPut("{actionCode}")]
+    [S8Permission(S8PermissionCatalog.ConfigRoleWrite)]
+    public async Task<IActionResult> SetAsync(string actionCode, [FromBody] AdoS8ActionRoleSetDto? body)
+    {
+        try
+        {
+            await _svc.SetActionRolesAsync(await _scope.ResolveAsync(), actionCode, body?.RoleIds);
+            return Ok(new { actionCode, roleCount = body?.RoleIds?.Count ?? 0 });
+        }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+}
+
+public class AdoS8ActionRoleSetDto
+{
+    /// <summary>该动作的完整授权角色集合。传空数组 = 关闭该动作(fail closed)。</summary>
+    public List<long>? RoleIds { get; set; }
+}

+ 15 - 15
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ExceptionsController.cs

@@ -29,7 +29,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpGet]
-    [S8Permission(S8PermissionCatalog.ExceptionRead)]
+    [S8ExceptionAction(S8ExceptionActionCode.View)]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS8ExceptionQueryDto q)
     {
         // S8-TENANT-FACTORY-P0-CLOSURE-1:作用域一律服务端解析,覆盖客户端传入值。
@@ -40,7 +40,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpGet("filter-options")]
-    [S8Permission(S8PermissionCatalog.ExceptionRead)]
+    [S8ExceptionAction(S8ExceptionActionCode.View)]
     public async Task<IActionResult> GetFilterOptionsAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1)
     {
         // Compatibility-only. Security scope is resolved server-side.
@@ -50,7 +50,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpGet("{id:long}")]
-    [S8Permission(S8PermissionCatalog.ExceptionRead)]
+    [S8ExceptionAction(S8ExceptionActionCode.View)]
     public async Task<IActionResult> GetDetailAsync(long id, [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1)
     {
         // Compatibility-only. Security scope is resolved server-side.
@@ -61,7 +61,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpGet("{id:long}/timeline")]
-    [S8Permission(S8PermissionCatalog.ExceptionRead)]
+    [S8ExceptionAction(S8ExceptionActionCode.View)]
     public async Task<IActionResult> GetTimelineAsync(long id)
     {
         if (!await IsInScopeAsync(id)) return NotFound();
@@ -69,7 +69,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpGet("{id:long}/decisions")]
-    [S8Permission(S8PermissionCatalog.ExceptionRead)]
+    [S8ExceptionAction(S8ExceptionActionCode.View)]
     public async Task<IActionResult> GetDecisionsAsync(long id)
     {
         if (!await IsInScopeAsync(id)) return NotFound();
@@ -77,7 +77,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpGet("{id:long}/evidences")]
-    [S8Permission(S8PermissionCatalog.ExceptionRead)]
+    [S8ExceptionAction(S8ExceptionActionCode.View)]
     public async Task<IActionResult> GetEvidencesAsync(long id)
     {
         if (!await IsInScopeAsync(id)) return NotFound();
@@ -92,7 +92,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpPost("{id:long}/claim")]
-    [S8Permission(S8PermissionCatalog.ExceptionClaim)]
+    [S8ExceptionAction(S8ExceptionActionCode.Claim)]
     /// <summary>
     /// 认领。<b>处理人恒为当前登录账号</b>,请求体只带备注 ——
     /// 「替别人指派」是 <c>POST {id}/transfer</c>(权限位 <c>ExceptionAssign</c>)。
@@ -112,7 +112,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpPost("{id:long}/transfer")]
-    [S8Permission(S8PermissionCatalog.ExceptionAssign)]
+    [S8ExceptionAction(S8ExceptionActionCode.Transfer)]
     public async Task<IActionResult> TransferAsync(long id, [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
         [FromBody] AdoS8TransferDto? body = null)
     {
@@ -127,7 +127,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpPost("{id:long}/start-progress")]
-    [S8Permission(S8PermissionCatalog.ExceptionStart)]
+    [S8ExceptionAction(S8ExceptionActionCode.Start)]
     public async Task<IActionResult> StartProgressAsync(long id, [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
         [FromBody] AdoS8CommentDto? body = null)
     {
@@ -142,7 +142,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpPost("{id:long}/upgrade")]
-    [S8Permission(S8PermissionCatalog.ExceptionUpgrade)]
+    [S8ExceptionAction(S8ExceptionActionCode.Upgrade)]
     public async Task<IActionResult> UpgradeAsync(long id, [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
         [FromBody] AdoS8CommentDto? body = null)
     {
@@ -157,7 +157,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpPost("{id:long}/reject")]
-    [S8Permission(S8PermissionCatalog.ExceptionReject)]
+    [S8ExceptionAction(S8ExceptionActionCode.Reject)]
     public async Task<IActionResult> RejectAsync(long id, [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
         [FromBody] AdoS8CommentDto? body = null)
     {
@@ -172,7 +172,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpPost("{id:long}/submit-verification")]
-    [S8Permission(S8PermissionCatalog.VerificationSubmit)]
+    [S8ExceptionAction(S8ExceptionActionCode.SubmitVerify)]
     public async Task<IActionResult> SubmitVerificationAsync(long id,
         [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
         [FromBody] AdoS8SubmitVerificationDto? body = null)
@@ -189,7 +189,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpPost("{id:long}/approve-verification")]
-    [S8Permission(S8PermissionCatalog.VerificationApprove)]
+    [S8ExceptionAction(S8ExceptionActionCode.Verify)]
     public async Task<IActionResult> ApproveVerificationAsync(long id,
         [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
         [FromBody] AdoS8ApproveVerificationDto? body = null)
@@ -205,7 +205,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpPost("{id:long}/reject-verification")]
-    [S8Permission(S8PermissionCatalog.VerificationReject)]
+    [S8ExceptionAction(S8ExceptionActionCode.Verify)]
     public async Task<IActionResult> RejectVerificationAsync(long id,
         [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
         [FromBody] AdoS8RejectVerificationDto? body = null)
@@ -221,7 +221,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpPost("{id:long}/comment")]
-    [S8Permission(S8PermissionCatalog.ExceptionComment)]
+    [S8ExceptionAction(S8ExceptionActionCode.Comment)]
     public async Task<IActionResult> CommentAsync(long id, [FromBody] AdoS8CommentDto? body = null)
     {
         try

+ 4 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ReportsController.cs

@@ -15,7 +15,7 @@ public class AdoS8ReportsController : ControllerBase
     public AdoS8ReportsController(S8ManualReportService svc) => _svc = svc;
 
     [HttpGet("form-options")]
-    [S8Permission(S8PermissionCatalog.ExceptionRead)]
+    [S8ExceptionAction(S8ExceptionActionCode.View)]
     // Compatibility-only. Security scope is resolved server-side
     // (S8ManualReportService.ResolveTrustedTenantId 忽略入参)。
     public async Task<IActionResult> FormOptionsAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1)
@@ -25,7 +25,7 @@ public class AdoS8ReportsController : ControllerBase
     }
 
     [HttpPost]
-    [S8Permission(S8PermissionCatalog.ExceptionCreate)]
+    [S8ExceptionAction(S8ExceptionActionCode.Create)]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS8ManualReportCreateDto dto)
     {
         try { return Ok(await _svc.CreateAsync(dto)); }
@@ -34,7 +34,7 @@ public class AdoS8ReportsController : ControllerBase
     }
 
     [HttpGet("{id:long}")]
-    [S8Permission(S8PermissionCatalog.ExceptionRead)]
+    [S8ExceptionAction(S8ExceptionActionCode.View)]
     public async Task<IActionResult> GetAsync(long id)
     {
         var e = await _svc.GetAsync(id);
@@ -42,7 +42,7 @@ public class AdoS8ReportsController : ControllerBase
     }
 
     [HttpPost("{id:long}/attachments")]
-    [S8Permission(S8PermissionCatalog.ExceptionCreate)]
+    [S8ExceptionAction(S8ExceptionActionCode.Create)]
     public async Task<IActionResult> AttachmentsAsync(long id, [FromBody] AdoS8AttachmentCreateDto dto)
     {
         try { return Ok(await _svc.AddAttachmentAsync(id, dto)); }

+ 42 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8ExceptionActionRole.cs

@@ -0,0 +1,42 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S8;
+
+/// <summary>
+/// S8-ACTION-PERMISSION-1:异常单「动作 ↔ 角色」授权行。
+///
+/// <para><b>身份是 (tenant_id, action_code, role_id)</b>,不含 factory ——
+/// 工厂从来不是隔离维度(真库 <c>factory_ref_id = 1000</c> 横跨两个租户)。</para>
+///
+/// <para><b>为什么 S8 自己存一份,而不是继续用 <c>SysRoleMenu</c></b>:
+/// 平台那条链的语义是「这个角色能看见哪个菜单按钮」,租户校验只覆盖菜单、不覆盖角色,
+/// 于是别家租户的角色可以把 S8 能力带进本租户(见 <see cref="Infrastructure.S8.S8TenantRoleResolver"/>
+/// 的取证)。本表把「谁能对异常单做什么」这个问题收回 S8 自己回答,
+/// <c>tenant_id</c> 是行上的一等字段,跨租户在结构上就不成立。</para>
+///
+/// <para><b>只存 Action ↔ Role,不存 Action ↔ User</b>:角色成员继续由平台的角色管理维护,
+/// S8 不再造第二套「谁属于哪个角色」。</para>
+/// </summary>
+[SugarTable("ado_s8_exception_action_role", "S8 异常动作角色授权")]
+[SugarIndex("uk_s8_action_role", nameof(TenantId), OrderByType.Asc, nameof(ActionCode), OrderByType.Asc, nameof(RoleId), OrderByType.Asc, IsUnique = true)]
+public class AdoS8ExceptionActionRole
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    /// <summary>租户。<b>唯一安全边界</b>。</summary>
+    [SugarColumn(ColumnName = "tenant_id", ColumnDataType = "bigint")]
+    public long TenantId { get; set; }
+
+    /// <summary>动作码,取值必须在 <c>S8ExceptionActionCatalog</c> 内;不在目录 = 未知动作 = 拒绝。</summary>
+    [SugarColumn(ColumnName = "action_code", Length = 64)]
+    public string ActionCode { get; set; } = string.Empty;
+
+    /// <summary>被授权的角色(<c>SysRole.Id</c>)。写入时校验该角色属于 <see cref="TenantId"/>。</summary>
+    [SugarColumn(ColumnName = "role_id", ColumnDataType = "bigint")]
+    public long RoleId { get; set; }
+
+    [SugarColumn(ColumnName = "created_at")]
+    public DateTime CreatedAt { get; set; } = DateTime.Now;
+
+    [SugarColumn(ColumnName = "created_by", Length = 64, IsNullable = true)]
+    public string? CreatedBy { get; set; }
+}

+ 78 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8ExceptionActionAttribute.cs

@@ -0,0 +1,78 @@
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Const.S8;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+
+/// <summary>
+/// S8-ACTION-PERMISSION-1:异常单动作的授权门。取代这些 Action 上原有的
+/// <see cref="S8PermissionAttribute"/>。
+///
+/// <para><b>为什么要换掉</b>:<see cref="S8PermissionAttribute"/> 依赖平台的
+/// <c>GetOwnBtnPermList()</c>,而那条链<b>不校验角色的租户</b>,实测可被别家租户的角色
+/// 带进 <c>s8:exception:assign</c> 等能力(取证见 <see cref="S8TenantRoleResolver"/>)。
+/// 同一个问题不能有两个 authority,所以异常动作统一改由
+/// <see cref="IS8ExceptionActionAuthorizer"/> 回答;<see cref="S8PermissionAttribute"/>
+/// 继续负责配置面(<c>s8:config:*</c>),并同样补上租户交集。</para>
+///
+/// <para><b>仍在 Action 之前返回 403</b>:与旧实现同一位置、同一文案,
+/// DB 零写入,不把权限拓扑回显给调用方。</para>
+/// </summary>
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
+public sealed class S8ExceptionActionAttribute : Attribute, IAsyncAuthorizationFilter
+{
+    /// <summary>本 Action 要求的动作码,必须取自 <see cref="S8ExceptionActionCode"/>。</summary>
+    public string Code { get; }
+
+    public S8ExceptionActionAttribute(string code)
+    {
+        if (string.IsNullOrWhiteSpace(code))
+            throw new ArgumentException("S8 动作码不能为空", nameof(code));
+        if (!S8ExceptionActionCatalog.IsKnown(code))
+            throw new ArgumentException($"S8 动作码不在目录中:{code}", nameof(code));
+        Code = code;
+    }
+
+    public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
+    {
+        var user = context.HttpContext.User;
+        if (user?.Identity?.IsAuthenticated != true) return;   // 401 交回平台管道
+
+        var services = context.HttpContext.RequestServices;
+
+        // 超管豁免:与 JwtHandler.CheckAuthorizeAsync / S8PermissionAttribute 同口径。
+        var userManager = services.GetService<UserManager>();
+        if (userManager is { SuperAdmin: true }) return;
+
+        var authorizer = services.GetService<IS8ExceptionActionAuthorizer>();
+        var scopeResolver = services.GetService<S8TrustedScopeResolver>();
+        if (authorizer == null || scopeResolver == null || userManager == null)
+        {
+            // 拿不到判定所需服务时按拒绝处理。绝不 fail-open ——
+            // 本模块的事故类型全部是「本不该放行却放行了」。
+            context.Result = Forbid();
+            return;
+        }
+
+        try
+        {
+            var scope = await scopeResolver.ResolveAsync();
+            var result = await authorizer.AuthorizeAsync(scope.TenantId, userManager.UserId, Code);
+            if (result.Allowed) return;
+        }
+        catch
+        {
+            // 作用域解析失败(如超管未选租户)同样按拒绝处理,语义与既有 400/403 边界一致。
+        }
+
+        context.Result = Forbid();
+    }
+
+    private static ObjectResult Forbid() => new(new { message = "没有权限执行该操作" })
+    {
+        StatusCode = StatusCodes.Status403Forbidden
+    };
+}

+ 121 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8ExceptionActionAuthorizer.cs

@@ -0,0 +1,121 @@
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Const.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+
+/// <summary>授权判定结果。拒绝时带原因码,便于前端与日志区分「没配」与「没权」。</summary>
+public sealed record S8ActionAuthorizationResult(bool Allowed, string? ReasonCode, string? Message)
+{
+    public static S8ActionAuthorizationResult Pass() => new(true, null, null);
+    public static S8ActionAuthorizationResult Deny(string reason, string message) => new(false, reason, message);
+}
+
+/// <summary>授权失败原因码。</summary>
+public static class S8ActionDenyReason
+{
+    /// <summary>动作码不在目录里 —— 调用方拼错或用了已删除的动作。</summary>
+    public const string UnknownAction = "action_unknown";
+
+    /// <summary>该动作在本租户下一个角色都没配 —— 未配置即禁止,不是「未配置即放行」。</summary>
+    public const string ActionNotConfigured = "action_not_configured";
+
+    /// <summary>账号在本租户下的角色都不在该动作的授权名单里。</summary>
+    public const string RoleNotGranted = "action_role_not_granted";
+}
+
+/// <summary>
+/// S8-ACTION-PERMISSION-1:异常单动作授权的<b>唯一</b>判定入口。
+///
+/// <para><b>禁止各 Service 自己散落 HasRole 判断</b>。同一个问题一旦有两处实现,
+/// 迟早出现「Controller 放行、Service 又拦一次且判据不同」或反之 ——
+/// 本仓在 <c>S8OperatorBindingService</c> 上已经发生过一次同类事故
+/// (List 用 ClearFilter + 可选谓词、Bind 用另一套口径,导致跨租户返回 500 行)。</para>
+///
+/// <para><b>判据三段,缺一不可</b>:
+/// ① 动作在目录内;② 该动作在本租户配了至少一个角色;
+/// ③ 账号在<b>本租户</b>持有其中任一角色。</para>
+///
+/// <para><b>fail closed 是硬要求</b>:配置表为空<b>不等于</b>所有人拥有全部权限。
+/// 这类"空即放行"的默认值是权限系统最典型的事故来源 —— 上线时表还没灌数据,
+/// 于是全员拥有全部动作,而且没有任何报错。</para>
+///
+/// <para><b>Factory 不参与</b>,且不得加回来。<b>Employee 不参与</b>:
+/// S8 的人就是 <c>SysUser</c>,不存在「先查员工再查账号」。</para>
+/// </summary>
+public interface IS8ExceptionActionAuthorizer
+{
+    /// <summary>判定账号在租户内是否可执行该动作。不抛异常。</summary>
+    Task<S8ActionAuthorizationResult> AuthorizeAsync(long tenantId, long userId, string actionCode);
+
+    /// <summary>批量取该账号在本租户被允许的全部动作码(供前端按钮可见性使用)。</summary>
+    Task<IReadOnlySet<string>> GetAllowedActionsAsync(long tenantId, long userId);
+}
+
+/// <summary>正式实现。生产代码里只有这一份;接口只为测试留 seam。</summary>
+public sealed class S8ExceptionActionAuthorizer : IS8ExceptionActionAuthorizer, ITransient
+{
+    private readonly SqlSugarRepository<AdoS8ExceptionActionRole> _rep;
+    private readonly IS8TenantRoleResolver _roleResolver;
+
+    public S8ExceptionActionAuthorizer(
+        SqlSugarRepository<AdoS8ExceptionActionRole> rep,
+        IS8TenantRoleResolver roleResolver)
+    {
+        _rep = rep;
+        _roleResolver = roleResolver;
+    }
+
+    public async Task<S8ActionAuthorizationResult> AuthorizeAsync(long tenantId, long userId, string actionCode)
+    {
+        // ① 未知动作:拒绝。绝不因为「没听说过这个动作」就放行。
+        if (!S8ExceptionActionCatalog.IsKnown(actionCode))
+            return S8ActionAuthorizationResult.Deny(S8ActionDenyReason.UnknownAction, "没有权限执行该操作");
+
+        if (tenantId <= 0 || userId <= 0)
+            return S8ActionAuthorizationResult.Deny(S8ActionDenyReason.RoleNotGranted, "没有权限执行该操作");
+
+        var grantedRoleIds = await GrantedRoleIdsAsync(tenantId, actionCode);
+
+        // ② 该动作在本租户一个角色都没配 —— fail closed。
+        if (grantedRoleIds.Count == 0)
+            return S8ActionAuthorizationResult.Deny(
+                S8ActionDenyReason.ActionNotConfigured, "没有权限执行该操作");
+
+        // ③ 账号在本租户持有的角色(跨租户角色在这里就已经被排除,见 S8TenantRoleResolver)。
+        var userRoleIds = await _roleResolver.GetRoleIdsAsync(tenantId, userId);
+        if (userRoleIds.Count == 0 || !userRoleIds.Any(grantedRoleIds.Contains))
+            return S8ActionAuthorizationResult.Deny(
+                S8ActionDenyReason.RoleNotGranted, "没有权限执行该操作");
+
+        return S8ActionAuthorizationResult.Pass();
+    }
+
+    public async Task<IReadOnlySet<string>> GetAllowedActionsAsync(long tenantId, long userId)
+    {
+        var allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+        if (tenantId <= 0 || userId <= 0) return allowed;
+
+        var userRoleIds = await _roleResolver.GetRoleIdsAsync(tenantId, userId);
+        if (userRoleIds.Count == 0) return allowed;
+
+        var rows = await _rep.AsQueryable().ClearFilter()
+            .Where(x => x.TenantId == tenantId && userRoleIds.Contains(x.RoleId))
+            .Select(x => x.ActionCode)
+            .ToListAsync();
+
+        foreach (var code in rows.Where(S8ExceptionActionCatalog.IsKnown))
+            allowed.Add(code);
+        return allowed;
+    }
+
+    private async Task<HashSet<long>> GrantedRoleIdsAsync(long tenantId, string actionCode)
+    {
+        // ClearFilter:边界由本查询显式声明(tenant_id == 传入租户)。
+        var ids = await _rep.AsQueryable().ClearFilter()
+            .Where(x => x.TenantId == tenantId && x.ActionCode == actionCode)
+            .Select(x => x.RoleId)
+            .ToListAsync();
+        return ids.ToHashSet();
+    }
+}

+ 33 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8PermissionAttribute.cs

@@ -89,7 +89,39 @@ public sealed class S8PermissionAttribute : Attribute, IAsyncAuthorizationFilter
             return;
         }
 
-        if (owned != null && owned.Contains(Code, StringComparer.OrdinalIgnoreCase)) return;
+        if (owned == null || !owned.Contains(Code, StringComparer.OrdinalIgnoreCase))
+        {
+            context.Result = Forbid();
+            return;
+        }
+
+        // ── S8-ACTION-PERMISSION-1:再与「同租户角色可达能力」取交集 ──
+        //
+        // 平台的 GetOwnBtnPermList() 只对**菜单**做了租户校验(InnerJoin SysTenantMenu),
+        // 对**角色**没有:SysUserRole 不要求用户与角色同租户,解析链也不过滤 SysRole.TenantId。
+        // 真库实测 UATAdminA(租户 838257186181189)经别家租户的 ROLE_S6_IPQC_SUPERVISOR
+        // (SysRole.TenantId = 1300000000001)拿到了 s8:exception:assign 等能力,
+        // 而它自己的租户里没有任何角色授予这些能力。
+        //
+        // 取交集只可能**收窄**结果,不可能引入任何新权限 —— 对权限修复而言这是唯一可接受的方向。
+        var tenantResolver = services.GetService<IS8TenantRoleResolver>();
+        var scopeResolver = services.GetService<S8TrustedScopeResolver>();
+        if (tenantResolver == null || scopeResolver == null)
+        {
+            context.Result = Forbid();
+            return;
+        }
+
+        try
+        {
+            var scope = await scopeResolver.ResolveAsync();
+            var tenantCodes = await tenantResolver.GetPermissionCodesAsync(scope.TenantId, userManager!.UserId);
+            if (tenantCodes.Contains(Code)) return;
+        }
+        catch
+        {
+            // 作用域解析失败(如超管未选目标租户)按拒绝处理,与既有 400/403 边界一致。
+        }
 
         context.Result = Forbid();
     }

+ 77 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8TenantRoleResolver.cs

@@ -0,0 +1,77 @@
+using Admin.NET.Core;
+
+namespace Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+
+/// <summary>
+/// S8-ACTION-PERMISSION-1:「这个账号在**当前租户**下持有哪些角色 / 哪些能力码」的<b>唯一</b>实现。
+///
+/// <para><b>它修的是一个实测存在的跨租户权限泄漏。</b>平台的
+/// <c>SysMenuService.GetOwnBtnPermList()</c> 解析链是
+/// <c>SysUserRole → SysRoleMenu → SysMenu(Btn)</c>,其中<b>只对「菜单」做了租户校验</b>
+/// (<c>InnerJoin SysTenantMenu ... t.TenantId == 当前租户</c>),
+/// <b>对「角色」没有任何租户校验</b>;而 <c>SysUserRole</c> 本身也不要求用户与角色同租户。</para>
+///
+/// <para>真库取证(aidopdev_s8_local,只读):账号 <c>UATAdminA</c>
+/// (<c>TenantId = 838257186181189</c>)被授予了角色 <c>ROLE_S6_IPQC_SUPERVISOR</c>,
+/// 而该角色的 <c>SysRole.TenantId = 1300000000001</c>(默认租户)。它由此拿到了
+/// <c>s8:exception:assign / claim / close / reject / upgrade</c> —— 这些能力
+/// <b>在它自己的租户里没有任何角色授予</b>。同租户的 <c>UATExceptionA</c> 调转派得到 403,
+/// 而 <c>UATAdminA</c> 成功,差别完全来自那个别家租户的角色。</para>
+///
+/// <para><b>修法:取交集,只收窄不放宽。</b>平台原结果保留(它负责菜单租户校验、
+/// 超管语义与缓存),再与「同租户角色可达的能力码」求交。交集在数学上不可能
+/// 引入任何新权限,因此这个改动只可能让原本能做的事变成不能做,
+/// 不可能反过来 —— 对权限修复而言这是唯一可接受的方向。</para>
+///
+/// <para><b>超管</b>:与 <c>JwtHandler.CheckAuthorizeAsync</c> 同口径豁免,
+/// 由调用方在进入本解析器之前判断,本类不重复实现那条分支。</para>
+/// </summary>
+public interface IS8TenantRoleResolver
+{
+    /// <summary>该账号在指定租户下持有的角色 Id(角色本身必须属于该租户)。</summary>
+    Task<IReadOnlyList<long>> GetRoleIdsAsync(long tenantId, long userId);
+
+    /// <summary>该账号经<b>同租户角色</b>可达的能力码集合(<c>SysMenu.Permission</c>)。</summary>
+    Task<IReadOnlySet<string>> GetPermissionCodesAsync(long tenantId, long userId);
+}
+
+/// <summary>正式实现。生产代码里只有这一份;接口只为测试留 seam。</summary>
+public sealed class S8TenantRoleResolver : IS8TenantRoleResolver, ITransient
+{
+    private readonly SqlSugarRepository<SysUserRole> _userRoleRep;
+
+    public S8TenantRoleResolver(SqlSugarRepository<SysUserRole> userRoleRep) => _userRoleRep = userRoleRep;
+
+    public async Task<IReadOnlyList<long>> GetRoleIdsAsync(long tenantId, long userId)
+    {
+        if (tenantId <= 0 || userId <= 0) return Array.Empty<long>();
+
+        // ClearFilter:边界由本查询显式声明(SysRole.TenantId == 传入租户),
+        // 不依赖全局 multi-tenant AOP 的当前行为。
+        return await _userRoleRep.AsQueryable().ClearFilter()
+            .InnerJoin<SysRole>((ur, r) => ur.RoleId == r.Id)
+            .Where((ur, r) => ur.UserId == userId && r.TenantId == tenantId)
+            .Select((ur, r) => r.Id)
+            .Distinct()
+            .ToListAsync();
+    }
+
+    public async Task<IReadOnlySet<string>> GetPermissionCodesAsync(long tenantId, long userId)
+    {
+        var roleIds = await GetRoleIdsAsync(tenantId, userId);
+        if (roleIds.Count == 0) return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+
+        var codes = await _userRoleRep.Context.Queryable<SysRoleMenu>()
+            .ClearFilter()
+            .InnerJoin<SysMenu>((rm, m) => rm.MenuId == m.Id)
+            .Where((rm, m) => roleIds.Contains(rm.RoleId)
+                              && m.Type == MenuTypeEnum.Btn
+                              && m.Permission != null
+                              && m.Permission != "")
+            .Select((rm, m) => m.Permission)
+            .Distinct()
+            .ToListAsync();
+
+        return new HashSet<string>(codes.Where(c => !string.IsNullOrWhiteSpace(c))!, StringComparer.OrdinalIgnoreCase);
+    }
+}

+ 146 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ExceptionActionRoleProvisioningService.cs

@@ -0,0 +1,146 @@
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Const.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>供给结果。分项计数,运维要能看出「这次到底做了什么」。</summary>
+public sealed class S8ActionRoleProvisioningResult
+{
+    public int TenantCount { get; set; }
+    public int ActionCount { get; set; }
+    /// <summary>新建的授权行数。</summary>
+    public int CreatedCount { get; set; }
+    /// <summary>已有授权、本次跳过的租户数(只对<b>完全没有任何授权行</b>的租户做首次供给)。</summary>
+    public int SkippedTenantCount { get; set; }
+    /// <summary>推导后仍然一个角色都没有的 (租户, 动作) 数 —— 这些动作保持关闭,需人工配置。</summary>
+    public int UnconfiguredCount { get; set; }
+}
+
+/// <summary>
+/// S8-ACTION-PERMISSION-1:把「本租户原有的 S8 权限语义」一次性投射成动作授权矩阵的初值。
+///
+/// <para><b>只做首次供给</b>:一个租户只要已经存在<b>任意一行</b>授权,本服务就整体跳过它。
+/// 理由是这张表随后由管理员在页面上维护 —— 每次启动都去"补齐默认"会把管理员
+/// 刻意取消的授权悄悄加回来,那是比缺权限更难发现的故障(页面显示没配,实际有权)。</para>
+///
+/// <para><b>跨租户角色不带入</b>:推导链是
+/// <c>SysRole(TenantId == 目标租户) → SysRoleMenu → SysMenu(Btn).Permission</c>。
+/// 真库里 <c>UATAdminA</c> 经别家租户的 <c>ROLE_S6_IPQC_SUPERVISOR</c> 持有
+/// <c>s8:exception:assign</c>,该角色因租户不符<b>不会</b>进入本租户的转派授权 ——
+/// 迁移后它失去转派能力属预期修复结果。</para>
+///
+/// <para><b>推不出角色的动作留空</b>(如上述租户的「转派」),保持 fail closed。
+/// 不给兜底角色:那等于把刚堵上的越权换个形式放回去。</para>
+/// </summary>
+public class S8ExceptionActionRoleProvisioningService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8ExceptionActionRole> _rep;
+    private readonly ILogger<S8ExceptionActionRoleProvisioningService> _logger;
+
+    public S8ExceptionActionRoleProvisioningService(
+        SqlSugarRepository<AdoS8ExceptionActionRole> rep,
+        ILogger<S8ExceptionActionRoleProvisioningService> logger)
+    {
+        _rep = rep;
+        _logger = logger;
+    }
+
+    public async Task<S8ActionRoleProvisioningResult> SyncAsync(CancellationToken ct = default)
+    {
+        // 「租户有效」沿用本仓既有口径(Status = Enable),与 S8RuleProvisioningService 一致。
+        var tenantIds = await _rep.Context.Queryable<SysTenant>()
+            .Where(t => t.Status == StatusEnum.Enable)
+            .Select(t => t.Id)
+            .ToListAsync(ct);
+
+        var result = new S8ActionRoleProvisioningResult
+        {
+            TenantCount = tenantIds.Count,
+            ActionCount = S8ExceptionActionCatalog.All.Count
+        };
+        if (tenantIds.Count == 0) return result;
+
+        var tenantsWithRows = (await _rep.AsQueryable().ClearFilter()
+            .Where(x => tenantIds.Contains(x.TenantId))
+            .Select(x => x.TenantId)
+            .Distinct()
+            .ToListAsync(ct)).ToHashSet();
+
+        var pending = tenantIds.Where(t => !tenantsWithRows.Contains(t)).ToList();
+        result.SkippedTenantCount = tenantIds.Count - pending.Count;
+        if (pending.Count == 0)
+        {
+            _logger.LogInformation(
+                "s8_action_role_provisioning_skipped tenants={Tenants}(全部已有授权配置)", tenantIds.Count);
+            return result;
+        }
+
+        // 一次取回待供给租户的 (租户, 角色, 能力码) 三元组。角色租户在 join 条件里就已经限死。
+        var grants = await _rep.Context.Queryable<SysRole>().ClearFilter()
+            .InnerJoin<SysRoleMenu>((r, rm) => r.Id == rm.RoleId)
+            .InnerJoin<SysMenu>((r, rm, m) => rm.MenuId == m.Id)
+            .Where((r, rm, m) => r.TenantId != null && pending.Contains(r.TenantId.Value)
+                                 && m.Type == MenuTypeEnum.Btn
+                                 && m.Permission != null
+                                 && m.Permission.StartsWith("s8:"))
+            .Select((r, rm, m) => new { TenantId = r.TenantId!.Value, RoleId = r.Id, m.Permission })
+            .ToListAsync(ct);
+
+        var now = DateTime.Now;
+        var inserts = new List<AdoS8ExceptionActionRole>();
+
+        foreach (var tenantId in pending)
+        {
+            var tenantGrants = grants.Where(g => g.TenantId == tenantId).ToList();
+
+            foreach (var action in S8ExceptionActionCatalog.All)
+            {
+                var codes = new List<string>();
+                if (!string.IsNullOrWhiteSpace(action.LegacyPermissionCode)) codes.Add(action.LegacyPermissionCode!);
+                codes.AddRange(action.LegacyPermissionAliases);
+
+                // 旧体系里没有对应能力码的动作(ViewAll)无从推导 —— 留空,由管理员显式配置。
+                if (codes.Count == 0) { result.UnconfiguredCount++; continue; }
+
+                var roleIds = tenantGrants
+                    .Where(g => codes.Contains(g.Permission!, StringComparer.OrdinalIgnoreCase))
+                    .Select(g => g.RoleId)
+                    .Distinct()
+                    .ToList();
+
+                if (roleIds.Count == 0) { result.UnconfiguredCount++; continue; }
+
+                inserts.AddRange(roleIds.Select(rid => new AdoS8ExceptionActionRole
+                {
+                    TenantId = tenantId,
+                    ActionCode = action.Code,
+                    RoleId = rid,
+                    CreatedAt = now,
+                    CreatedBy = "PROVISIONING"
+                }));
+            }
+        }
+
+        if (inserts.Count > 0)
+        {
+            await _rep.AsInsertable(inserts).ExecuteCommandAsync();
+            result.CreatedCount = inserts.Count;
+        }
+
+        _logger.LogInformation(
+            "s8_action_role_provisioning_done tenants={Tenants} provisioned={Pending} skipped={Skipped} "
+            + "actions={Actions} created={Created} unconfigured={Unconfigured}",
+            result.TenantCount, pending.Count, result.SkippedTenantCount,
+            result.ActionCount, result.CreatedCount, result.UnconfiguredCount);
+
+        if (result.UnconfiguredCount > 0)
+            _logger.LogWarning(
+                "s8_action_role_unconfigured count={Count};这些 (租户, 动作) 推导不出任何本租户角色,"
+                + "按 fail-closed 保持关闭,需管理员在「异常操作权限」页面显式配置",
+                result.UnconfiguredCount);
+
+        return result;
+    }
+}

+ 217 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ExceptionActionRoleService.cs

@@ -0,0 +1,217 @@
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Const.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>权限矩阵一行:一个动作 + 它当前授权的角色。</summary>
+public sealed class S8ActionRoleRowDto
+{
+    public string ActionCode { get; set; } = string.Empty;
+    public string DisplayName { get; set; } = string.Empty;
+    public string ApiHint { get; set; } = string.Empty;
+    public int OrderNo { get; set; }
+    public List<long> RoleIds { get; set; } = new();
+
+    /// <summary>未配置任何角色 —— 该动作在本租户对<b>所有非超管账号</b>关闭。前端必须显式提示。</summary>
+    public bool NotConfigured => RoleIds.Count == 0;
+}
+
+/// <summary>可授权角色选项。</summary>
+public sealed class S8AssignableRoleDto
+{
+    public long Id { get; set; }
+    public string Code { get; set; } = string.Empty;
+    public string Name { get; set; } = string.Empty;
+    public int UserCount { get; set; }
+}
+
+/// <summary>
+/// S8-ACTION-PERMISSION-1:异常动作权限矩阵的读写服务 + 启动期默认供给。
+///
+/// <para><b>默认值从哪来</b>:从<b>本租户既有的</b><c>SysRole → SysRoleMenu → SysMenu.Permission</c>
+/// 授权语义推导 —— 原来在这个租户里谁能做这件事,迁移后还是谁能做。
+/// 刻意不拍一套新默认:那会在管理员毫无察觉的情况下改变现网的可操作范围。</para>
+///
+/// <para><b>跨租户角色一律不带入</b>。真库实测 <c>UATAdminA</c> 通过别家租户的
+/// <c>ROLE_S6_IPQC_SUPERVISOR</c> 拿到了 <c>s8:exception:assign</c>,
+/// 而它自己的租户里没有任何角色有这个能力。推导时按 <c>SysRole.TenantId == 目标租户</c>
+/// 过滤,所以这类越权<b>不会被固化进新表</b> —— 迁移之后该账号会失去转派能力,
+/// 这是修复的预期结果,不是回归。</para>
+///
+/// <para><b>推导不出角色的动作保持空</b>(fail closed),由管理员在页面上显式配置。
+/// 不做「空就给个兜底角色」——那等于把刚修掉的越权换个形式放回来。</para>
+/// </summary>
+public class S8ExceptionActionRoleService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8ExceptionActionRole> _rep;
+    private readonly IS8TenantRoleResolver _roleResolver;
+    private readonly UserManager _userManager;
+    private readonly ILogger<S8ExceptionActionRoleService> _logger;
+
+    public S8ExceptionActionRoleService(
+        SqlSugarRepository<AdoS8ExceptionActionRole> rep,
+        IS8TenantRoleResolver roleResolver,
+        UserManager userManager,
+        ILogger<S8ExceptionActionRoleService> logger)
+    {
+        _rep = rep;
+        _roleResolver = roleResolver;
+        _userManager = userManager;
+        _logger = logger;
+    }
+
+    // ────────────────────────────── 读 ──────────────────────────────
+
+    /// <summary>权限矩阵:目录里的<b>每个</b>动作都出一行,没配角色的也要出现(前端要能看见"未配置")。</summary>
+    public async Task<List<S8ActionRoleRowDto>> GetMatrixAsync(S8TrustedScope scope)
+    {
+        var rows = await _rep.AsQueryable().ClearFilter()
+            .Where(x => x.TenantId == scope.TenantId)
+            .Select(x => new { x.ActionCode, x.RoleId })
+            .ToListAsync();
+
+        var byAction = rows
+            .GroupBy(x => x.ActionCode, StringComparer.OrdinalIgnoreCase)
+            .ToDictionary(g => g.Key, g => g.Select(x => x.RoleId).Distinct().ToList(), StringComparer.OrdinalIgnoreCase);
+
+        return S8ExceptionActionCatalog.All
+            .OrderBy(d => d.OrderNo)
+            .Select(d => new S8ActionRoleRowDto
+            {
+                ActionCode = d.Code,
+                DisplayName = d.DisplayName,
+                ApiHint = d.ApiHint,
+                OrderNo = d.OrderNo,
+                RoleIds = byAction.GetValueOrDefault(d.Code) ?? new List<long>()
+            })
+            .ToList();
+    }
+
+    /// <summary>
+    /// 可授权角色列表。
+    ///
+    /// <para><b>不机械返回全部 SysRole</b>:本租户角色动辄十几个,其中大量与 S8 无关
+    /// (报表、看板、其它模块)。把它们全塞进权限矩阵会让管理员在几十个复选框里找
+    /// 那三四个真正相关的,反而更容易配错。判据是<b>该角色在本租户里已经承担 S8 或
+    /// 业务处理职责</b>:持有任一 <c>s8:*</c> 能力,或属于 S8 认定的业务角色
+    /// (<see cref="S8CanonicalRole.BusinessRoleCodes"/>)。</para>
+    ///
+    /// <para>已被本表授权过的角色<b>无条件包含</b>,否则管理员会看到一条自己无法取消的授权。</para>
+    /// </summary>
+    public async Task<List<S8AssignableRoleDto>> GetAssignableRolesAsync(S8TrustedScope scope)
+    {
+        var tenantRoles = await _rep.Context.Queryable<SysRole>().ClearFilter()
+            .Where(r => r.TenantId == scope.TenantId)
+            .Select(r => new { r.Id, r.Code, r.Name })
+            .ToListAsync();
+        if (tenantRoles.Count == 0) return new List<S8AssignableRoleDto>();
+
+        var roleIds = tenantRoles.Select(r => r.Id).ToList();
+
+        // 已持有任一 s8:* 能力的角色
+        var s8CapableRoleIds = (await _rep.Context.Queryable<SysRoleMenu>().ClearFilter()
+            .InnerJoin<SysMenu>((rm, m) => rm.MenuId == m.Id)
+            .Where((rm, m) => roleIds.Contains(rm.RoleId)
+                              && m.Type == MenuTypeEnum.Btn
+                              && m.Permission != null
+                              && m.Permission.StartsWith("s8:"))
+            .Select((rm, m) => rm.RoleId)
+            .Distinct()
+            .ToListAsync()).ToHashSet();
+
+        // 已被本表授权过的角色
+        var alreadyGranted = (await _rep.AsQueryable().ClearFilter()
+            .Where(x => x.TenantId == scope.TenantId)
+            .Select(x => x.RoleId)
+            .ToListAsync()).ToHashSet();
+
+        var businessCodes = S8CanonicalRole.BusinessRoleCodes.ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+        var candidates = tenantRoles
+            .Where(r => s8CapableRoleIds.Contains(r.Id)
+                        || alreadyGranted.Contains(r.Id)
+                        || businessCodes.Contains(r.Code ?? string.Empty))
+            .ToList();
+
+        var memberCounts = (await _rep.Context.Queryable<SysUserRole>().ClearFilter()
+            .Where(ur => candidates.Select(c => c.Id).Contains(ur.RoleId))
+            .Select(ur => new { ur.RoleId, ur.UserId })
+            .ToListAsync())
+            .GroupBy(x => x.RoleId)
+            .ToDictionary(g => g.Key, g => g.Select(x => x.UserId).Distinct().Count());
+
+        return candidates
+            .Select(r => new S8AssignableRoleDto
+            {
+                Id = r.Id,
+                Code = r.Code ?? string.Empty,
+                Name = string.IsNullOrWhiteSpace(r.Name) ? (r.Code ?? string.Empty) : r.Name,
+                UserCount = memberCounts.GetValueOrDefault(r.Id)
+            })
+            .OrderBy(r => r.Code, StringComparer.Ordinal)
+            .ToList();
+    }
+
+    // ────────────────────────────── 写 ──────────────────────────────
+
+    /// <summary>
+    /// 覆盖某个动作的授权角色(全量替换)。
+    /// 角色必须属于当前租户,否则拒绝 —— 这正是本批要堵的那个洞,不能从写入口再漏回去。
+    /// </summary>
+    public async Task SetActionRolesAsync(S8TrustedScope scope, string actionCode, IEnumerable<long>? roleIds)
+    {
+        if (!S8ExceptionActionCatalog.IsKnown(actionCode))
+            throw new S8BizException($"未知动作码:{actionCode}");
+
+        var wanted = (roleIds ?? Enumerable.Empty<long>()).Where(x => x > 0).Distinct().ToList();
+
+        if (wanted.Count > 0)
+        {
+            var validIds = (await _rep.Context.Queryable<SysRole>().ClearFilter()
+                .Where(r => r.TenantId == scope.TenantId && wanted.Contains(r.Id))
+                .Select(r => r.Id)
+                .ToListAsync()).ToHashSet();
+
+            var invalid = wanted.Where(id => !validIds.Contains(id)).ToList();
+            if (invalid.Count > 0)
+                // 与人员校验同口径:跨租户与不存在共用同一文案,不泄漏他租户角色是否存在。
+                throw new S8BizException("所选角色不存在或不属于当前租户");
+        }
+
+        var existing = await _rep.AsQueryable().ClearFilter()
+            .Where(x => x.TenantId == scope.TenantId && x.ActionCode == actionCode)
+            .ToListAsync();
+
+        var existingIds = existing.Select(x => x.RoleId).ToHashSet();
+        var toRemove = existing.Where(x => !wanted.Contains(x.RoleId)).Select(x => x.Id).ToList();
+        var toAdd = wanted.Where(id => !existingIds.Contains(id)).ToList();
+
+        if (toRemove.Count > 0)
+            await _rep.AsDeleteable().Where(x => toRemove.Contains(x.Id)).ExecuteCommandAsync();
+
+        if (toAdd.Count > 0)
+        {
+            var now = DateTime.Now;
+            await _rep.AsInsertable(toAdd.Select(id => new AdoS8ExceptionActionRole
+            {
+                TenantId = scope.TenantId,
+                ActionCode = actionCode,
+                RoleId = id,
+                CreatedAt = now,
+                CreatedBy = _userManager.Account
+            }).ToList()).ExecuteCommandAsync();
+        }
+
+        _logger.LogInformation(
+            "s8_action_role_updated tenant={Tenant} action={Action} added={Added} removed={Removed} total={Total}",
+            scope.TenantId, actionCode, toAdd.Count, toRemove.Count, wanted.Count);
+    }
+
+    /// <summary>当前登录账号在本租户被允许的动作码(前端按钮可见性)。</summary>
+    public Task<IReadOnlySet<string>> GetMyAllowedActionsAsync(S8TrustedScope scope, IS8ExceptionActionAuthorizer authorizer) =>
+        authorizer.GetAllowedActionsAsync(scope.TenantId, _userManager.UserId);
+}

+ 24 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

@@ -93,6 +93,9 @@ public class Startup : AppStartup
         // S8-RULE-GOVERNANCE-BATCH2:代码定义 → 每租户运行策略的供给服务。
         // ITransient 在本仓不做按接口自动注册,且它没有接口,故显式登记具体类型。
         services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.S8RuleProvisioningService>();
+
+        // S8-ACTION-PERMISSION-1:异常动作授权矩阵的首次供给(同 S8RuleProvisioningService,无接口需显式登记)。
+        services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.S8ExceptionActionRoleProvisioningService>();
     }
 
     /// <summary>
@@ -144,6 +147,25 @@ public class Startup : AppStartup
             Trace.TraceError("Ai-DOP S8RuleProvisioning FAILED(本次启动未完成规则运行策略对账): " + ex);
         }
 
+        // S8-ACTION-PERMISSION-1:动作授权矩阵首次供给。
+        // 失败策略与上方规则供给一致:记录后放行,不阻断启动(本宿主与 S0–S7 共用)。
+        // 后果是"该租户的异常动作暂时全部关闭"——fail closed,不是数据错误,且日志可见。
+        try
+        {
+            using var actionScope = app.ApplicationServices.CreateScope();
+            var actionProvisioning = actionScope.ServiceProvider
+                .GetRequiredService<Admin.NET.Plugin.AiDOP.Service.S8.S8ExceptionActionRoleProvisioningService>();
+            var actionSummary = actionProvisioning.SyncAsync().GetAwaiter().GetResult();
+            Trace.TraceInformation(
+                $"Ai-DOP S8ActionRoleProvisioning: tenants={actionSummary.TenantCount} actions={actionSummary.ActionCount} "
+                + $"created={actionSummary.CreatedCount} skippedTenants={actionSummary.SkippedTenantCount} "
+                + $"unconfigured={actionSummary.UnconfiguredCount}");
+        }
+        catch (Exception ex)
+        {
+            Trace.TraceError("Ai-DOP S8ActionRoleProvisioning FAILED(本次启动未完成动作授权供给): " + ex);
+        }
+
         try
         {
             using var scopeS4 = app.ApplicationServices.CreateScope();
@@ -230,6 +252,8 @@ public class Startup : AppStartup
                 typeof(AdoS8NotificationLayer),
                 typeof(AdoS8WatchRule),
                 typeof(AdoS8RolePermissionConfig),
+                // S8-ACTION-PERMISSION-1:异常动作 ↔ 角色授权矩阵。
+                typeof(AdoS8ExceptionActionRole),
                 typeof(AdoS8NotificationLog),
                 typeof(AdoS8DashboardCellConfig),
                 typeof(AdoS8ExceptionType),