ソースを参照

chore(系统): module/system 授权配套 SQL 交付件(写数据,待用户执行)

2a92d65 给 84 个端点挂上守卫后,权限串必须有人持有,否则守卫 = 锁人。
清点读数(sys-authz-inventory-readonly.sql ①②)显示 73 条核心串一条都没授给任何角色,
角色 1「管理员角色」只持有 2 个 module_system 菜单。所以补齐守卫与补齐授权必须成套,
且**授权先跑、后端后部署** —— 反过来的话,新后端上线到授权跑完之间,除超管外的账号
全被挡在系统管理之外,包括分配权限那个端点本身,自己把自己锁在门外。

§0 只读核对。其中 ⓪-A 会列出持有角色 1 的账号与全部超管:若角色 1 被租户账号持有,
把整个 module_system 授给它等于把平台管理权交给那个租户,得换一个角色授。
⓪-B 打印角色 1、2 当前完整的已授权菜单行(含目录),留一份供回滚比对 ——
② 那张表只看了 permission LIKE 'module_system:%' 的行,看不到祖先目录授没授。

§1 补 module_system:invitation:create / :delete 两个权限点。前端
module_system/invitation/index.vue 用这两个串(第 15/16 行的 perm-create/perm-delete、
第 120 行 v-hasPerm),而库里只有 :list(id 243)。不补行的话这两个端点对非超管永久
403,按钮也永远不出现。列形照 har-menu.sql 的权限点段。

§2 把 module_system 全量菜单 + 全部祖先目录授给角色 1,递归取闭包。祖先必须一起授:
UserService#buildMenuTree 对父节点不在授权集合里的行是静默丢弃,只授子节点菜单看不见。
幂等(NOT EXISTS 挡重复)。角色 2「租户角色」本次不动:它现有 5 个菜单对应的端点在
payment 模块的 /payment/api-key,不在本批 84 个守卫里。

§3 回读核对,应返回 0 行。清单是 76 个权限串,从 SystemEndpointAuthzTest.java
**机械提取**(不手抄),生成脚本 logs/_gen-grant-sql.py 写完再反读比对,
SQL 里 76 条 == 测试文件 76 条逐条相同。判据按「串」不按「行」:同一个串可能挂在
多行 sys_menu 上(如 dept:query 同时在 id 12 和 id 47),持有任意一行即满足。

§4 回滚:§2a 先建一次性快照表 _bak_role_menus_role1,回滚即删掉不在快照里的角色 1 行。

本文件是写数据的,与 2a92d65 一样未经执行 —— 执行由用户做。
alphaH 2 週間 前
親
コミット
b8b1733983
1 ファイル変更、248 行追加、0 行削除
  1. 248 0
      java/src/main/resources/db/sys-authz-grant-module-system.sql

+ 248 - 0
java/src/main/resources/db/sys-authz-grant-module-system.sql

@@ -0,0 +1,248 @@
+-- module/system 方法级授权 —— 配套授权(**写数据**,2026-09-28)
+--
+-- 【执行顺序 —— 这是本文件最重要的一句】
+--   先在**旧后端**上跑完本文件,再部署带 @PreAuthorize 的新后端。
+--   反过来做的话,从新后端上线到本文件跑完之间的这段时间里,
+--   **除超管外的所有账号都会被挡在「系统管理」之外** —— 包括分配权限那个
+--   PATCH /system/role/permission/setting,也就是自己把自己锁在门外的死锁:
+--   被锁的账号没有 role:permission 就进不去授权页,进不去就授不了权。
+--   超管不受影响(PermissionCheck 对 ROLE_SUPERUSER 直通),所以只要还有超管能登,
+--   锁了也能救回来;但别指望这一点。
+--
+-- ── 背景 ─────────────────────────────────────────────────────────────
+-- java 侧 module/system/** 此前 102 个端点、@PreAuthorize 出现 0 次
+-- (SecurityConfig 只有 .anyRequest().authenticated()),现已补齐 84 个端点。
+-- 守卫按 sys_menu.permission 的权限串匹配,authorities 的来路是
+--   sys_user_roles → sys_role_menus → sys_menu.permission
+-- (UserDetailsServiceImpl#buildLoginUser,MenuMapper#selectPermissionsByUserId)。
+-- 所以**串存在但没授给任何角色 = 该端点对所有非超管永久 403**,且按钮也不会出现。
+-- 清点读数(sys-authz-inventory-readonly.sql ①②)显示:
+--   角色 1「管理员角色」只持有 2 个 module_system 菜单,角色 2「租户角色」5 个,
+--   73 条核心串**一条都没授给任何角色** —— 不补授权就上守卫 = 锁人。
+--
+-- ── 本文件做什么 ─────────────────────────────────────────────────────
+--   §0 只读核对(不改数据,先跑,把读数贴回)
+--   §1 补 2 个权限点:module_system:invitation:create / :delete
+--   §2 把 module_system 全量菜单 + 其祖先目录授给角色 1
+--   §3 回读核对(应返回 0 行)
+--   §4 回滚
+--
+-- 为什么 §2 要连**祖先目录**一起授:UserService#buildMenuTree 对父节点不在
+-- 授权集合里的行是**静默丢弃**(既不进 tree 也不挂到父上),只授子节点 = 菜单看不见。
+
+
+-- ═══════════════════ §0 只读核对(不改任何数据)═══════════════════
+
+-- ⓪-A 谁持有角色 1 / 谁是超管。**这一条会决定 §2 是否安全**:
+--      若角色 1 被某个**租户**账号持有,那么把整个 module_system 授给角色 1
+--      等于把平台管理权交给那个租户(本次守卫对它也就不构成防线)。
+--      若读数里出现 tenant_id 非空的角色 1 持有者,先停下,改授给平台自己的角色。
+SELECT u.id,
+       u.username,
+       u.is_superuser,
+       u.status,
+       u.tenant_id,
+       (SELECT string_agg(r.id || ':' || r.name, ', ' ORDER BY r.id)
+          FROM sys_user_roles ur JOIN sys_role r ON r.id = ur.role_id
+         WHERE ur.user_id = u.id) AS roles
+  FROM sys_user u
+ WHERE u.is_superuser IS TRUE
+    OR EXISTS (SELECT 1 FROM sys_user_roles ur WHERE ur.user_id = u.id AND ur.role_id = 1)
+ ORDER BY u.is_superuser DESC NULLS LAST, u.id;
+
+-- ⓪-B 角色 1、2 当前**完整**的已授权菜单行(含目录)。② 那张表只看了
+--      permission LIKE 'module_system:%' 的行,看不到祖先目录有没有授,
+--      而祖先目录恰好决定菜单树渲染,所以这里要全量看一遍。
+--      **请把这次的输出留一份**,§4 回滚要用它比对。
+SELECT rm.role_id,
+       m.id,
+       m.parent_id,
+       m.type,
+       m.status,
+       m.name,
+       m.title,
+       m.permission
+  FROM sys_role_menus rm
+  JOIN sys_menu m ON m.id = rm.menu_id
+ WHERE rm.role_id IN (1, 2)
+ ORDER BY rm.role_id, m.id;
+
+
+-- ═══════════════════ §1 补两个权限点(幂等)═══════════════════
+-- 前端 frontend/src/views/module_system/invitation/index.vue 用的是
+--   module_system:invitation:create(第 15 行,:perm-create)
+--   module_system:invitation:delete(第 16 行 :perm-delete,第 120 行 v-hasPerm)
+-- 而 sys_menu 里只有 module_system:invitation:list(清点 ① 第 23 行,id 243)。
+-- 不补这两行的话:挂上守卫后**没有任何角色能持有这两个串** ⇒ 生成/删除邀请码
+-- 对非超管永久 403,且页面上的按钮永远不出现(v-hasPerm 是前端按权限串显示)。
+-- 列形与 har-menu.sql 的权限点段一致;type=3 = 按钮,不进菜单树、只作权限串。
+INSERT INTO sys_menu (parent_id, name, title, permission, type, "order",
+                      hidden, keep_alive, always_show, affix, status, created_time, updated_time)
+SELECT p.id, v.name, v.title, v.permission, 3, v.ord,
+       false, false, false, false, '0', now(), now()
+  FROM (VALUES
+        ('创建邀请码', '创建邀请码', 'module_system:invitation:create', 1),
+        ('删除邀请码', '删除邀请码', 'module_system:invitation:delete', 2)
+       ) AS v(name, title, permission, ord)
+  CROSS JOIN (SELECT id FROM sys_menu
+               WHERE permission = 'module_system:invitation:list'
+               ORDER BY id LIMIT 1) p
+ WHERE NOT EXISTS (SELECT 1 FROM sys_menu m WHERE m.permission = v.permission);
+
+
+-- ═══════════════════ §2a 快照(为了 §4 能精确回滚)═══════════════════
+-- 建一张一次性备份表。跑第二次不会覆盖第一次的快照(IF NOT EXISTS)。
+CREATE TABLE IF NOT EXISTS _bak_role_menus_role1 AS
+SELECT * FROM sys_role_menus WHERE role_id = 1;
+
+
+-- ═══════════════════ §2 把 module_system 全量授给角色 1 ═══════════════════
+-- seed = 所有 status='0' 且 permission LIKE 'module_system:%' 的行(含 type=3 按钮)
+-- closure = seed ∪ 它们的全部祖先(目录),保证菜单树能渲染
+-- 幂等:只插 sys_role_menus 里还没有的 (1, menu_id)。
+WITH RECURSIVE seed AS (
+    SELECT m.id, m.parent_id
+      FROM sys_menu m
+     WHERE m.status = '0'
+       AND m.permission LIKE 'module_system:%'
+),
+closure AS (
+    SELECT id, parent_id FROM seed
+    UNION
+    SELECT m.id, m.parent_id
+      FROM sys_menu m
+      JOIN closure c ON m.id = c.parent_id
+)
+INSERT INTO sys_role_menus (role_id, menu_id)
+SELECT 1, c.id
+  FROM closure c
+ WHERE NOT EXISTS (SELECT 1 FROM sys_role_menus rm
+                    WHERE rm.role_id = 1 AND rm.menu_id = c.id);
+
+-- 角色 2「租户角色」本次**不动**:它现有的 5 个菜单
+-- (module_system:tenant:api-key:query/create/delete、tenant:conf:query/update)
+-- 对应的端点在 payment 模块的 /payment/api-key,不在本批 84 个守卫里,
+-- 所以本次改动对它没有任何影响,不需要补授权。
+
+
+-- ═══════════════════ §3 回读核对(应返回 0 行)═══════════════════
+-- 清单由 java/src/test/.../SystemEndpointAuthzTest.java 机械提取(84 个受守卫端点
+-- 去重后的 76 个权限串),不是手抄。返回 0 行 = 角色 1 已持有每一个受守卫端点所需的串。
+-- 判据按「串」而不是按「行」:同一个串可能挂在多行 sys_menu 上(如 dept:query 同时
+-- 在 id 12 和 id 47),只要持有其中任意一行就算满足。
+WITH required(permission) AS (
+    VALUES
+        ('module_system:dept:create'),
+        ('module_system:dept:delete'),
+        ('module_system:dept:detail'),
+        ('module_system:dept:patch'),
+        ('module_system:dept:query'),
+        ('module_system:dept:update'),
+        ('module_system:dict_data:create'),
+        ('module_system:dict_data:delete'),
+        ('module_system:dict_data:detail'),
+        ('module_system:dict_data:export'),
+        ('module_system:dict_data:patch'),
+        ('module_system:dict_data:query'),
+        ('module_system:dict_data:update'),
+        ('module_system:dict_type:create'),
+        ('module_system:dict_type:delete'),
+        ('module_system:dict_type:detail'),
+        ('module_system:dict_type:export'),
+        ('module_system:dict_type:patch'),
+        ('module_system:dict_type:query'),
+        ('module_system:dict_type:update'),
+        ('module_system:invitation:create'),
+        ('module_system:invitation:delete'),
+        ('module_system:invitation:list'),
+        ('module_system:log:delete'),
+        ('module_system:log:detail'),
+        ('module_system:log:export'),
+        ('module_system:log:query'),
+        ('module_system:menu:create'),
+        ('module_system:menu:delete'),
+        ('module_system:menu:detail'),
+        ('module_system:menu:patch'),
+        ('module_system:menu:query'),
+        ('module_system:menu:update'),
+        ('module_system:notice:create'),
+        ('module_system:notice:delete'),
+        ('module_system:notice:detail'),
+        ('module_system:notice:export'),
+        ('module_system:notice:patch'),
+        ('module_system:notice:query'),
+        ('module_system:notice:update'),
+        ('module_system:param:create'),
+        ('module_system:param:delete'),
+        ('module_system:param:detail'),
+        ('module_system:param:export'),
+        ('module_system:param:query'),
+        ('module_system:param:update'),
+        ('module_system:param:upload'),
+        ('module_system:position:create'),
+        ('module_system:position:delete'),
+        ('module_system:position:detail'),
+        ('module_system:position:export'),
+        ('module_system:position:patch'),
+        ('module_system:position:query'),
+        ('module_system:position:update'),
+        ('module_system:role:create'),
+        ('module_system:role:delete'),
+        ('module_system:role:detail'),
+        ('module_system:role:export'),
+        ('module_system:role:patch'),
+        ('module_system:role:permission'),
+        ('module_system:role:query'),
+        ('module_system:role:update'),
+        ('module_system:tenant:create'),
+        ('module_system:tenant:delete'),
+        ('module_system:tenant:patch'),
+        ('module_system:tenant:query'),
+        ('module_system:tenant:update'),
+        ('module_system:user:create'),
+        ('module_system:user:delete'),
+        ('module_system:user:detail'),
+        ('module_system:user:download'),
+        ('module_system:user:export'),
+        ('module_system:user:import'),
+        ('module_system:user:patch'),
+        ('module_system:user:query'),
+        ('module_system:user:update')
+),
+held AS (
+    SELECT DISTINCT m.permission
+      FROM sys_menu m
+      JOIN sys_role_menus rm ON rm.menu_id = m.id AND rm.role_id = 1
+     WHERE m.permission IS NOT NULL
+),
+live AS (
+    SELECT DISTINCT permission FROM sys_menu
+     WHERE status = '0' AND permission IS NOT NULL
+),
+any_row AS (
+    SELECT DISTINCT permission FROM sys_menu WHERE permission IS NOT NULL
+)
+SELECT r.permission,
+       CASE
+         WHEN NOT EXISTS (SELECT 1 FROM any_row a WHERE a.permission = r.permission)
+              THEN '库里根本没有这个串'
+         WHEN NOT EXISTS (SELECT 1 FROM live l WHERE l.permission = r.permission)
+              THEN '有行但都不是 status=0(权限树里勾不到)'
+         ELSE '有行、但没授给角色 1'
+       END AS verdict
+  FROM required r
+ WHERE NOT EXISTS (SELECT 1 FROM held h WHERE h.permission = r.permission)
+ ORDER BY r.permission;
+
+
+-- ═══════════════════ §4 回滚 ═══════════════════
+-- 只回滚 §2(§1 加的两行权限点是新增数据,不改旧行为,可以留着)。
+-- §2a 的快照存的是「§2 之前」的角色 1 授权集合,所以「§2 新增的」= 现在多出来的那些。
+-- 需要时执行:
+--
+--   DELETE FROM sys_role_menus
+--    WHERE role_id = 1
+--      AND menu_id NOT IN (SELECT menu_id FROM _bak_role_menus_role1);
+--
+-- 然后与 §0-B 留的那份输出比对,确认回到原样,再删快照表:
+--   DROP TABLE _bak_role_menus_role1;