-- ===================================================================================== -- 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;