فهرست منبع

fix(进件): Task 8 授权收回 role 2 —— commit 会变成活的跨租户写入路径

`68f6482` 把三行授给了 role 1 与 role 2(依据「惯例:同一子树每一行都授两个角色」)。
安全评审(`_sec-findings.md` Vuln 1,Medium)指出这条链的每一跳都被 role 2 满足,
主控逐跳复现后确认,故收回为**只授 role 1**。

攻击链(**每一跳都是本轮实测,不是推测**):

  1. `/commit` 的租户归属闸证明的是「本租户内存在一行 mch_no 等于该值的映射」,
     不是「这个商户号属于你」—— LsyMerchantService.java:311-338 的两条谓词只有
     tenant_id + mch_no,无任何「这行从哪来」的检查。
  2. 而那张映射表**调用方自己就能写**:
     · LeshouyingMerchantController.java:44 给 save 挂的是
       `module_payment:leshouying:merchant:list` —— **一个读权限守着写和删**;
     · :46 收原始实体,mch_no 由调用方给;
     · LsyMerchantService.java:457-459 对该值**只判非空**,:478 无 id 即走新增分支
       :486 直接 insert,租户由 AutoFillMetaObjectHandler 从认证上下文补;
     · enterprise_id 给一个本租户没用过的非空值即可绕开 uk_lsy_merchant_tenant_ent
       (复合唯一对非空值不限行数)。
  3. ⇒ 持 merchant:list 的人可先插一行 (自己租户, 别人的 mch_no) 喂饱那道闸,
     再调 /commit 把「商户资质 / 门店位置 / 经纬度」提交到**别人的商户号**的入网审核上。
     mch_no 形如 Y52211090004,是可枚举的结构化编号。
  4. 使能条件:role 2(租户角色,17 个用户)**本来就持有 merchant:list**(活库实测)。

今天只有超管能调 /commit(超管对 @PreAuthorize 直通,本不构成新风险);
把 :commit 授给 role 2 会让这条链的每一跳都被 17 个真实用户满足。

代价(如实写):17 个租户用户今天看不到进件页、调不了三个端点;
**admin 仍可全程走通**(它以 tenant_id=1 调 /create,行落在 tenant 1,/commit 对它自己的行成立)
⇒ **联调不被阻塞**。放开只要一条 INSERT,写在文件里。

放开条件(任一成立即可把 role 2 加回来):
  · 让归属由构造保证(映射表留「这行是谁建的」痕,/commit 只收本租户 /create 建出的行);或
  · 把 save/delete 的权限从 merchant:list 换成写专用权限,且不授给 role 2。

另:授权语句同时简化(不再需要 CROSS JOIN 两个角色),语句仍是 1 条,
只读会话 PREPARE 重新验证通过,两条正对照如期报错。
alphaH 3 هفته پیش
والد
کامیت
91383e7204
1فایلهای تغییر یافته به همراه51 افزوده شده و 22 حذف شده
  1. 51 22
      java/src/main/resources/db/lsy-onboarding-menu.sql

+ 51 - 22
java/src/main/resources/db/lsy-onboarding-menu.sql

@@ -6,9 +6,10 @@
 -- 在**已存在**的「收付款」目录下,新增:
 --   · 1 行页面(type=2)「商户进件」 —— 进件页路由
 --   · 2 行按钮(type=3)「建号」「提交入网」 —— 承载两个写权限点
--- 并把这三行授权给 role_id 1(管理员角色)**与** 2(租户角色)——
--- ★ 这与计划/lsy-menu.sql 字面的「role_id = 1」不同,依据是活库现状,见下「★ 授权给谁」。
---   只想给 admin 就把授权语句里的 `(2)` 删掉;代价写在那一节。
+-- 并把这三行授权给 role_id = 1(管理员角色)。
+-- ★★ 本批次**故意不授** role_id = 2(租户角色)—— 不是「它不该有」,而是「现在给会造出一条
+--    **活的跨租户写入路径**」。完整因果、放开条件、以及放开用的那条语句,见下「★ 授权给谁」。
+--    这一条请在执行前读完再决定。
 --
 -- ── ★ 与 lsy-menu.sql 的关键差异(照抄它的写法会出错的地方)──────────────
 -- lsy-menu.sql 的 `dir` CTE 是**插入**收付款目录;本脚本的 `dir` 是**查**它。
@@ -95,20 +96,50 @@
 --    route_name 会成为 vue-router 的 name(菜单高亮与 keep-alive 的 include 都按它匹配),必须唯一。
 -- 2) route_path:本脚本用 `/payment/collection/onboarding`(模块前缀式),照同目录三个 lsy 页面。
 --
--- ── ★ 授权给谁:role_id 1 **与** 2(此处偏离计划字面,依据是活库现状)────────
+-- ── ★★ 授权给谁:本批次只给 role_id = 1(**故意不给 role 2,理由是安全**)────────
+--
 -- **活库实测(2026-09-23)**:
 --   · `sys_role` 一共两行:**id=1「管理员角色」(code=ADMIN),只被 1 个用户持有(`admin`, tenant_id=1)**;
 --     **id=2「租户角色」(code=tenant),被 17 个用户持有**(tenant_id = 2, 9–18, 21–26)。
---   · 「收付款」目录(id=268)与它下面**全部三个** lsy 兄弟页(269 支付订单 / 270 退款记录 /
---     271 商户映射)**都授给了 role 1 和 role 2 两个角色**(实测 8 行 sys_role_menus,两角色各 4)。
---   ⇒ 若只授 role 1,**17 个租户用户一个都看不到这个页面**(菜单树按 sys_role_menus 过滤),
---     而进件本来就是**租户级**业务、租户用户正是它的使用者;
---     更糟的是 `admin` 是超管,`PermissionCheck` 对超管直接放行 ⇒ **本机测不出这个缺口**。
---   ⇒ 故本脚本授两个角色,与这棵子树**现有的每一行**保持一致。
---   ★ 如果你的意图是「进件页只给 admin」,把下面 INSERT 里的 `(2)` 一行删掉即可 ——
---     那是一个可以做的选择,但请知道它的后果:**17 个租户用户看不到进件菜单**。
---     (`lsy-menu.sql` 自己写的是 role_id = 1,可活库里那几行**都**带着 role 2 ——
---      说明那份脚本的授权行不足以复现现状,别把它的字面当权威。)
+--   · `module_payment:leshouying:merchant:list` **role 1 与 role 2 都有**(实测 sys_role_menus)。
+--   · 「收付款」目录(id=268)与三个 lsy 兄弟页(269/270/271)也都授给了两个角色。
+--   ⇒ 从**惯例**看应当给 role 2;本批次不给,是因为下面这条已核实的攻击链。
+--
+-- ★★ 不给 role 2 的理由(**每一跳都实测过,不是推测**):
+--   `onboarding:commit` 的那道「租户归属闸」证明的是「**本租户内存在一行 mch_no 等于该值
+--   的映射**」,不是「这个商户号属于你」。而那张映射表**调用方自己就能写**:
+--     · `LeshouyingMerchantController.save`(`POST /payment/collection/merchant`)的权限是
+--       `module_payment:leshouying:merchant:list` —— **一个读权限守着写和删**;
+--     · 它收原始实体,**mch_no 由调用方给**,`LsyMerchantService.save` 对该值**只判非空**
+--       (实测 LsyMerchantService.java:457-459);
+--     · 没有 id ⇒ 走新增分支直接 insert(`:478` / `:486`),租户由
+--       `AutoFillMetaObjectHandler` 从认证上下文补 ⇒ 落进**调用方自己的租户**;
+--     · `enterprise_id` 给一个**没被本租户用过的非空值**即可绕开 `uk_lsy_merchant_tenant_ent`
+--       (复合唯一对非空值不限行数 —— 与本文档订正的那条 uuid 断言同源,见上);
+--     ⇒ 于是 `/commit` 的第二行 `requireByMchNoForTenant` **必然通过**,
+--       上游就收到一个**我方租户并不拥有**的商户号的入网提交(商户资质 / 门店位置 / 经纬度)。
+--   `mch_no` 形如 `Y52211090004`,是**可枚举**的结构化编号。
+--   ⇒ 今天**只有超管**能调 `/commit`(超管对 `@PreAuthorize` 直通,本就不构成新风险);
+--     一旦把 `:commit` 授给 role 2,这条链的**每一跳都被 17 个真实用户满足**。
+--
+-- ★ 放开条件(两条**任一**成立即可把 role 2 加回来):
+--   1. 让归属**由构造保证**(例如给 `pay_lsy_merchant` 留「这一行是谁建的」的痕,
+--      `/commit` 只接受由本租户 `/create` 建出来的行);**或**
+--   2. 把 `save` / `delete` 的权限从 `merchant:list` 换成写专用权限,且该权限**不授给 role 2**
+--      (即:映射表由运营维护、租户只读)。
+--
+-- ★ 放开时**不要重跑本脚本**(页面行已存在 ⇒ `page` 为空 ⇒ 授权行也不会插,见下面「幂等」)。
+--   单独跑这一条即可:
+--     INSERT INTO sys_role_menus (role_id, menu_id)
+--     SELECT 2, m.id FROM sys_menu m
+--      WHERE m.name IN ('商户进件', '建号', '提交入网') AND m.status = '0';
+--
+-- ★ 代价(如实写):今天 17 个租户用户看不到进件页、也调不了三个端点;**admin 仍可全程走通**
+--   (它以 tenant_id=1 调 `/create`,建出的行就落在 tenant 1,随后 `/commit` 对它自己的行成立),
+--   所以**联调不被阻塞**。这与「惯例上应当给 role 2」是一处取舍,取舍方向是「先不给、别造出
+--   一条活的跨租户写入路径」,放开只要上面那一条 INSERT。
+--   ★ `lsy-menu.sql` 自己写的是 role_id = 1,而活库里那几行**都**带着 role 2 ⇒
+--     那份脚本的授权行不足以复现现状,别把它的字面当权威。
 --
 -- ── 其他 ─────────────────────────────────────────────────────────────
 -- · icon 一律留空:图标集是 src/assets/icons/*.svg,经 `i-svg:<name>` 渲染;
@@ -158,12 +189,10 @@ buttons AS (
     RETURNING id
 )
 -- 页面行与两个按钮行都要授权:菜单树按 sys_role_menus 过滤,只授页面会让按钮权限缺席 ⇒ 403。
--- 两个角色都要(见上「★ 授权给谁」):`page` / `buttons` 各只被引用一次,
--- 免得依赖「数据修改型 CTE 能否被引用两次」这种我没实测过的语义。
+-- 只授 role 1(**故意不授 role 2** —— 理由与放开条件见上「★★ 授权给谁」)。
 INSERT INTO sys_role_menus (role_id, menu_id)
-SELECT v.role_id, x.id
-  FROM (SELECT id FROM page UNION ALL SELECT id FROM buttons) AS x
- CROSS JOIN (VALUES (1), (2)) AS v(role_id);
+SELECT 1, x.id
+  FROM (SELECT id FROM page UNION ALL SELECT id FROM buttons) AS x;
 
 
 -- ── 只读核对(执行后手工跑一遍;前三条**执行前**也建议先跑)────────────────
@@ -188,13 +217,13 @@ SELECT v.role_id, x.id
 --   期望:**2 行**,permission 逐字为
 --         'module_payment:leshouying:onboarding:create' 与 'module_payment:leshouying:onboarding:commit'。
 --
--- 4) 授权行(**两个角色各 3 行**,见上「★ 授权给谁」)
+-- 4) 授权行(**3 行,role_id 全是 1** —— 故意不授 role 2,见上「★★ 授权给谁」)
 --   SELECT rm.role_id, m.name, m.permission
 --     FROM sys_role_menus rm JOIN sys_menu m ON m.id = rm.menu_id
 --    WHERE m.name IN ('商户进件', '建号', '提交入网') AND m.status = '0'
 --    ORDER BY rm.role_id, m.name;
---   期望:**6 行** —— role_id 1 与 2 **各 3 行**。
---   若只有 3 行且 role_id 全是 1 ⇒ 17 个租户用户看不到进件菜单。
+--   期望:**3 行**,role_id 都是 1。
+--   出现 role_id = 2 ⇒ 有人放开了,那时必须先确认上面「放开条件」已成立。
 --
 -- 5) 端到端(**唯一能证明权限串没写错的一条**):用一个**非超管**的租户账号登录,
 --    确认三个`module_payment:leshouying:onboarding:*`都出现在