Skip to content

权限资源(开发视角) ​

适用端:后端 + Web | 类型:机制

sys_resource 是整套权限体系的对象表:它回答「这套系统里存在哪些可授权的东西」。授权行(sys_grant)、行级规则(sys_rule)、语义槽位(sys_slot_mapping)全都挂在它上面——挂错一个节点,后面三层一起算错。

前置阅读:权限体系总览。

它解决什么问题 ​

上游 yudao 用 system_menu 同时承担两件事:导航结构 + 可授权对象。这两件事在本项目里被拆开了,因为在线表单的「列」「字段」「被引用」这些东西根本不该出现在导航树里,但它们必须能被授权。

树结构不是数据,是从权限串推导出来的:PermissionTreeDeriver 直接取 permission.substring(0, lastIndexOf(':')) 当父串,DatasetResourceNaming#childOf 同样按串拼父子。这一点决定了后面很多「为什么不能这么修」。

代码在哪 ​

后端路径前缀 enlev-module-system/src/main/java/cn/enlev/ainb/module/system/:

职责文件关键方法
DO(含全局表踩坑记录)dal/dataobject/permission/ResourceDO.javaPATH_SEPARATOR:59、PATH_ROOT_PREFIX:63、ID_ROOT:67、defaultEffect:123-127(🔴 过期)
类型枚举enums/permission/ResourceTypeEnum.java7 个取值、defaultEffect():73-75、parse():77
Serviceservice/permission/ResourceServiceImpl.javasyncFromMenu:278、shouldHaveResource:267、syncCarrierFromMenu:387、carrierDisplayName:440、repointCarrierToSuccessor:463、applyTypeByPermission:596、fillPathAndDepth:610
Mapperdal/mysql/permission/ResourceMapper.javaselectByPermission、selectByPermissionIncludeDisabled、selectByMenuId、selectCountByParentId、selectListByParentIdAndTypes、selectListByDatasetTable
树推导framework/permission/core/PermissionTreeDeriver.java—
命名约定framework/permission/core/dataset/DatasetResourceNaming.javadataset()、child()、childOf()、RUN_PERMISSION_PREFIX
塌缩计划器framework/permission/core/**scan**/ResourceCollapsePlanner.javablockedIdentities:290、applyStrictestDefaultEffect:584、isStricter:610、chooseMenuId:653
塌缩定位器framework/permission/core/collapse/CollapsedResourceLocator.javaidentityOf、identityOfAll:99、collapseEnabled
权限串解析framework/permission/core/collapse/PermissionStringResolver.javaTier(6 级)
Controllercontroller/admin/permission/ResourceAdminController.java8 个端点(🔴 类注释说「只提供查询」,实际有 5 个写端点)
前端资源页ainb-ui-admin-vue3/src/views/system/resource/只读树 + 动作词表 constants.ts

⚠️ 塌缩计划器在 core/scan/ 而不是 core/collapse/,定位器与解析器在 core/collapse/。三个类分在两个包里。

核心机制 ​

七种类型 ​

ResourceTypeEnum 逐字(枚举名直接落库 varchar,不要改名):

类型是什么出厂 defaultEffect()备注
MENU导航菜单(目录 / 菜单,有 path/component 的那层)DENY通常不直接授权,通过它的子 ACTION 控制
ACTION操作权限(对应原 system_menu type=BUTTON,有 permission 串的那层)DENY如 system:user:query
PROCESS流程定义(BPM)DENY🔴 库里实测有 18 行 ALLOW、9 行 DENY,与出厂值不符
CATEGORY流程分类(BPM),是 PROCESS 的父资源DENY分类继承就是靠资源树实现的——给分类授权,其下所有流程自动继承,不需要为「分类继承」单独写一套逻辑
DATASET数据集(在线表单的整张表 / @PermissionDataset 声明的原生表)ALLOW🔴 它不是访问控制点,见下
COLUMN在线表单列表里的一列ALLOW按需落行
FIELD在线表单详情里的一个字段ALLOW按需落行

defaultEffect():73-75 全文:

java
public String defaultEffect() {
    return (this == DATASET || this == COLUMN || this == FIELD) ? "ALLOW" : "DENY";
}

:59-71 的 javadoc 解释了这个分叉的两套理由:

  • COLUMN/FIELD 是 ALLOW:「加字段忘配权限 → 线上看不见且不报错」是最贵的故障形态,所以默认让它显示。
  • ACTION/MENU/PROCESS 是 DENY:必须显式授权才能用。
  • DATASET 也是 ALLOW,但理由不同:它不是访问控制点。「能不能进这张表单」由它下面的六个 ACTION 资源承担,那些是 DENY 的;DATASET 只是列/字段资源的挂载点,以及行过滤规则与语义槽位的锚。若把 DATASET 设成 DENY,零配置环境下所有在线表单会被全面拒访——而授权页要到 S8a 才有,那期间没有任何人能把它配回来。

这套出厂值是塌缩缺口的根源

DATASET=ALLOW 的前提是「控制点是它下面 6 个 DENY 的 ACTION 行」。塌缩把那 6 个 ACTION 成员行退役之后,判定改查 DATASET 身份行,于是「刻意设计的非控制点」被当成了控制点。完整口径见授权与决策引擎。

parse() 解析不出返回 null,不抛异常

ResourceTypeEnum.parse():77-87 对空串和不认识的值都返回 null。调用方必须处理 null——漏处理的表现是这一行资源在树上不显示,而不是报错。

default_effect:两个不相等的东西 ​

这是全体系最容易算错的一处:

ResourceTypeEnum.defaultEffect()sys_resource.default_effect 列
谁读它只有 ResourceAdminController#createResource 填默认值时判定时读的是它(GrantDecisionEngine:182-183)
粒度按类型按行
能被谁改改代码塌缩计划器、DatasetResourceServiceImpl.tightenOnly、管理员在界面上手改

引擎读列而不读枚举,GrantDecisionEngine:178-181 给了实测反例:库里 PROCESS 类型有 18 行 default_effect=ALLOW、9 行 DENY,而枚举说 PROCESS 该是 DENY——按 type 推就会算错那 18 个节点。

⚠️ 判「严不严」的口径必须与引擎逐字节一致:那边写的是 "ALLOW".equals(self.getDefaultEffect()),也就是 null、空串、脏值一律等价于 DENY,不是第三态。ResourceCollapsePlanner.isStricter:610 与 DatasetResourceServiceImpl.tightenOnly:621 都照这条实现。

三处注释都还写着 DATASET 是 DENY

  • ResourceDO.java:123-127 的 javadoc:「COLUMN/FIELD 默认 ALLOW(加字段忘配权限 → 显示),其余默认 DENY」
  • DDL 的 default_effect 列注释:同上
  • DDL 的 type 列注释:「MENU/ACTION/PROCESS/DATASET/COLUMN/FIELD」——漏了 CATEGORY(6 种,实际 7 种)

三处均已过期。以 ResourceTypeEnum.defaultEffect() 与库里的实际值为准。

物化路径 ​

path 存祖先链,格式 /root/{层1}/{层2}/…/{本节点ident}/,depth 是冗余的深度。查一个资源的所有祖先:

sql
WHERE :myPath LIKE CONCAT(path, '%')

不递归。ResourceDO:100-107 明确 path 由 Service 层维护,不要直接 set——树结构变动时 fillPathAndDepth:610 会级联更新后代。

ident 的取法有一个坑(:628-638):有 permission 用 permission,没有的用 menu:<menuId>。原来写的是「没有 permission 就用 resource.getId()」,而本方法在 insert 之前调用——那时 id 还是 null,会整批落到 _tmp_ 兜底值上。以前不暴露是因为无 permission 的资源根本建不出来;能建了之后不修就是 163 个资源共用同一条物化路径,祖先链上溯直接乱套。

父节点不存在时退化为挂根节点并打 warn(:618-622):比报错好,迁移时不应该因为一个父不存在就整批失败。

从菜单同步:四条分支,塌缩走单独一条 ​

syncFromMenu:278 是菜单管理改动后同步资源行的入口,@Transactional 且一次失效三个缓存(PERMISSION_MENU_ID_LIST / GRANT_USER_PERMISSIONS / GRANT_USER_MENU_IDS)。

塌缩之后「菜单的权限串 ≠ 承载它的资源行的权限串」

:319-327 记着实测后果:R1 把 64 个 menu_id 重指到锚点上之后,94 个存活资源行的 permission 与它 menu_id 所指菜单的 permission 不一致,其中 85 个菜单是按钮。于是管理员在界面上编辑其中任意一个按钮,老路的分支 4 就会把身份行 system:role 改名回 system:role:query 并级联重算后代路径——而判定侧查的是 system:role,权限当场消失,且没有任何日志说明原因。

shouldHaveResource:267 的判据只有一条:不是目录。注释 :265 说明了理由——「勾了没用」正是这个子系统最贵的那类缺陷。

applyTypeByPermission:596-602 按「有没有 permission 串」决定 ACTION 还是 MENU,并顺带重置 default_effect。:590-595 记着这一支为什么不能删:删掉它的后果是 119 行在下次菜单同步时被改成 type=ACTION 且权限串为空,而 PermissionDriftDetector 的「陈旧」判据正是 type == ACTION——119 个空字符串会灌进那份报告,把刚拆开的两类陈旧又搅成一团。

承载行与代表菜单 ​

塌缩后一个身份(system:user)由一行承载,同页的多个按钮(system:user:query / :create / …)共用它。syncCarrierFromMenu:387 的两条判据因此换了:

  • 「该不该新建」从「有没有行指着这个菜单」变成「这个身份有没有行」。
  • 「该不该改显示字段」多了一条前置:本菜单是不是那一行的代表菜单(menu.getId().equals(carrier.getMenuId()))。不是的话什么都不做。

代表菜单的 status 会带动整行

:378-380:停用代表菜单等于停用整个身份的承载行。这是「一行代表一族」的固有后果,也是 R1 优先挑非按钮菜单当代表的原因——页面菜单比某个按钮更适合代表整页。

🔴 但「非按钮优先」只是偏好,不是保证:页面菜单的 permission 是空串,压根进不了塌缩的候选集,所以 R1 找不到非按钮候选时只能挑按钮。本库实测 infra:data-model、system:role 这些行的 menu_id 都指着按钮。

于是「名字取谁的」必须单独定一条规则。carrierDisplayName:440-449:代表菜单是按钮时,改用它的父级页面菜单的名字。理由(:420-423)是承载行代表整个身份(infra:data-model =「数据模型」这一页),而按钮只代表其中一个动作(infra:data-model:query =「数据模型查询」)——拿动作名当整页的名字,授权树上就会显示「数据模型查询」底下挂着创建、更新、删除,读不通。

🔴 这条路径是可达的,不是理论问题(:425-430):管理员在菜单管理里编辑那个按钮(哪怕只是调 sort 后点保存),syncFromMenu 就会带着按钮名走到这里。而承载行不是菜单、菜单管理里看不到它,资源树又是只读的——名字一旦被冲掉,没有任何界面能改回去。

⚠️ 只管 name 这一个展示字段。sort / status 仍然取自代表菜单:它们是这一行的运行属性。

⚠️ :410-413 刻意不调 applyTypeByPermission:承载行的类型是它自己的事。塌缩新建的 33 行是 ACTION,而 64 个复用既有锚点的行里有一批本来就是 MENU。跟着菜单改类型会顺带改 default_effect——那是一次没人请求过的授权语义变更。

父级不是页面菜单时(按钮挂在顶层、或父级是目录)退回按钮自己的名字。目录名(如「数据管理」)刻意不采纳:它是容器名,比动作名更不像这一页。

塌缩:把一族动词串收成一行 ​

ResourceCollapsePlanner(core/scan/)负责计划,ResourceCollapseApplier 负责落库。规则编号 R1…R11 写在类注释的表格里。

applyStrictestDefaultEffect:584 处理跨类型合并时的 default_effect:只要有任何一个成员比锚点严,就把锚点收紧成那个值。:574-582 强调判据是值不是 type:

原来的写法是 !LEGACY_TYPES.contains(anchor.getType())(锚点不是 MENU/ACTION 就告警)。那是在拿 type 当 default_effect 的代理,而引擎读的是列不是枚举……今天两种写法结果相同——快照里启用的 575 个 ACTION 行 default_effect 无一例外是 DENY、178 个 DATASET 行无一例外是 ALLOW;但只有比值的这一种在数据变化后仍然正确(PROCESS 那 18+9 行就是现成的反例)。

被改掉的身份串记进 CollapsePlan#getDefaultEffectNarrowings(),自校验拿它当白名单:断言「收紧 ⊆ 白名单」且「放开 = 0」。本轮实测改了 7 行。

blockedIdentities:290 是 R11:身份行下面还有存活后代的族,整族不塌。两条理由:

  • 🔴 为什么不是「把存活后代改挂到别处」(:262-269):那是最直觉的修法,但在本仓库里修不住——资源树的父子关系不是数据,是从权限串推导出来的。改了 parent_id 与 path,下一次菜单同步或表单发布就会按串把它们挂回去,而且不报错。要让改挂生效就得改权限串本身,那是换一套命名,不是迁移。
  • 为什么也不能只拦 ALLOW、放过 DENY(:271-275):拦不拦取决于「身份行有没有存活后代」,与上提的是哪种效果无关。今天只拦 ALLOW 的话,明天谁给 agent:workflow:create 加一条 DENY,agent:workflow:test 就会跟着被禁——同一条继承边,两个方向都得堵。

代价(:277-280,实测):117 个族里拦下 20 个,少退役 83 行成员,迁移后资源行 598 而不是 515,仍然比现状的 1022 行少 41%。

🔴 两侧判据必须同源(:282-286):被拦下的族在判定侧也要跟着不塌缩,否则成员串的授权已经不上提、而 lookupOf 照样把 system:user:query 解析成 (system:user, VIEW),查的是身份行——那一行上没有这条授权,权限凭空消失。计划器看「成员行还在不在库里」,GrantDecisionService#lookupOf 也看同一件事,数据本身就是那面旗子。

数据模型 ​

sys_resource,完整列清单见 docs/.generated/tables-permission.md(脚本生成物,不是站点页面)。

全局表,但 tenant_id 列还在

ResourceDO 打了 @TenantIgnore 且继承 BaseDO——列保留但不参与读写。:42-45 记着踩过的坑:

本类最初继承 TenantBaseDO,而资源行是启动期迁移进来的(tenant_id = 0)。于是租户 1 的用户查资源时带上 tenant_id = 1,一行都查不到——所有 @ss.hasPermission 对非超管用户恒为 false,且没有任何报错。

理由是「资源回答的是『这套系统里存在哪些可授权对象』,这与租户无关——system_menu 本来就是 @TenantIgnore 的」。租户差异体现在 sys_grant。

8 个索引,没有一个是唯一索引(除主键):

索引列
PRIMARYid
idx_permissiontenant_id, permission
idx_parenttenant_id, parent_id
idx_pathtenant_id, path(255)
idx_path_onlypath(255)
idx_resource_path_lookuptenant_id, deleted, path(255)
idx_menumenu_id
idx_dataset_tabletenant_id, dataset_table

permission 上没有唯一约束

idx_permission(tenant_id, permission) 是普通索引。数据库层不阻止两行资源有同一个权限串,而 selectByPermission 系列方法的语义假设它唯一。重复行的后果是判定读到哪一行不确定——findFirst 会拿到其中一个,而两个的 default_effect 可能不同。

写资源的任何路径都要自己查重,别指望数据库。

⚠️ path 列是 varchar(512),但 idx_path / idx_path_only / idx_resource_path_lookup 都只索引前 255 字符。深树的前缀可能撞在一起——正确性不受影响(LIKE 在完整值上求值),只是索引选择性下降。

menu_id 列的 javadoc(ResourceDO:135-140)写着「迁移完成并验证无误后,这个字段可以置空或删掉」,并有一条硬约束:任何判定逻辑都不许读它——它只是迁移期的临时映射。

接口清单 ​

ResourceAdminController(/system/resource),8 个端点:

方法路径权限串
GET/system/resource/listsystem:resource:query
GET/system/resource/getsystem:resource:query
GET/system/resource/list-childrensystem:resource:query
POST/system/resource/createsystem:resource:update
PUT/system/resource/updatesystem:resource:update
DELETE/system/resource/deletesystem:resource:update
DELETE/system/resource/delete-listsystem:resource:update
PUT/system/resource/update-share-policysystem:resource:update

类注释说「只提供查询」,实际有 5 个写端点

:44-45 逐字写着:

只提供查询:资源由发布钩子与迁移 Runner 自动登记,没有手动 CRUD(改 permission 会直接改变鉴权结果,不该是一个随手能点的操作)。

而 create / update / delete / delete-list / update-share-policy 五个写端点都在。注释已过期,别照着它判断这个 Controller 能干什么。

⚠️ 五个写端点共用同一个权限串 system:resource:update(不是 :create / :delete)。所以「能建资源的人一定能删资源」,粒度上分不开。

delete-list 的约束是「子节点必须一起勾选」——单条 deleteResource 见子节点就抛 RESOURCE_HAS_CHILDREN。DatasetResourceServiceImpl.unregister:146-149 用它而不是逐个删,还因为 deleteResourceList 会一次性把三个权限缓存失效掉,逐个删要失效 N 次且中间态会被别的请求读到。

怎么扩展 ​

加一种资源类型 ​

  1. ResourceTypeEnum 加取值,并决定 defaultEffect() 返回什么。🔴 这一步是授权语义决策,不是编码细节:它决定「零配置时放行还是拒绝」。返回 ALLOW 的代价是可能越权且无人报告,返回 DENY 的代价是「配了但忘了授权 ⟹ 功能凭空消失」。
  2. DDL 的 type 列注释同步——它现在已经漏了 CATEGORY,别再漏一个。
  3. ResourceDO.java:123-127 的 javadoc 同步(它也已经过期)。
  4. 前端 views/system/resource/ 的类型标签映射(resourceTypeTagType)加一项。⚠️ 它返回的是 string 不是 Element Plus 的字面量联合类型,调用点需要 as any——**不要「修好」**成返回 undefined。
  5. 如果新类型要参与判定,检查 PermissionStringResolver.Tier 的 6 级解析能不能认出它。
  6. 如果它是 DATASET 类的(要挂列/字段、要行过滤),得走 @PermissionDataset 或发布事件登记,并让表名进 RuleDataPermissionRule 的集合。

改一个资源的 default_effect ​

  • 界面上改:资源页是只读的,改不了。只能通过授权页的入口或直连数据库。
  • 🔴 改之前先想清楚塌缩:如果这一行是承载行(同族有多个动词串被它代表),改它会一次性影响整族。applyStrictestDefaultEffect 只往严的方向改,人工往松的方向改没有任何守卫拦着。
  • 改完要失效三个缓存(PERMISSION_MENU_ID_LIST / GRANT_USER_PERMISSIONS / GRANT_USER_MENU_IDS)。走 ResourceService 的方法会自动失效;直连数据库改则要等 resourceChainCache(没有 TTL)自然过期,也就是不会过期。

坑与注意 ​

  1. 🔴 sys_resource 是全局表,tenant_id 列不参与读写。
  2. 🔴 permission 上没有唯一索引,查重是调用方的责任。
  3. 🔴 ResourceAdminController 的类注释已过期(说只读,实际 5 个写端点)。
  4. 🔴 ResourceDO:123-127 与两处 DDL 列注释都还写着 DATASET 是 DENY,且 type 注释漏了 CATEGORY。
  5. 🔴 塌缩后「菜单的权限串 ≠ 承载行的权限串」,syncFromMenu 为此单独开了一条路。老路的两条判据全部失效。
  6. 代表菜单的 status 带动整行:停用代表菜单 = 停用整个身份。
  7. 代表菜单可能是按钮(页面菜单 permission 为空串进不了候选集),所以 carrierDisplayName 要往上取父级页面菜单的名字。
  8. path 由 Service 维护,不要直接 set。
  9. menu_id 任何判定逻辑都不许读,它只是迁移期的临时映射。
  10. 资源树的父子关系是从权限串推导的,不是数据。改 parent_id 会在下次同步时被按串挂回去,且不报错。
  11. 父节点不存在时退化为挂根并打 warn,不报错。
  12. resourceChainCache 没有 TTL,改了资源树要显式失效。
  13. idx_path 只索引前 255 字符,而列宽 512。
  14. delete-list 必须把子节点一起勾选,单条删见子节点就抛 RESOURCE_HAS_CHILDREN。

→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。

测试 ​

测试守什么
ResourceCollapsePlannerTestR1…R11 计划规则、blockedIdentities、applyStrictestDefaultEffect
ResourceCollapseApplierTest落库、事务内后置守卫
CollapseMigrationSelfCheckTest拿计划器重放冻结快照 _scratch/s2c_snapshot.json,5/5
PermissionStringResolverTest6 级 Tier 解析
DatasetResourceServiceImplTest22 个 @Test,登记五步

全部纯 Mockito,无 DB 集成测试。前端 views/system/resource/ 另有 spec,见前端授权页开发。

换判据会让自校验失去意义

CollapseMigrationSelfCheckTest 校验的是「计划器对冻结快照重放的结果 == 迁移真实产出的世界」。改了塌缩判据之后,它校验的变成「一份从未被执行过的计划」,而迁移不可重跑。

未证实 ​

  • PermissionTreeDeriver 只读到「按 lastIndexOf(':') 取父串」这一层,完整推导规则(怎么处理空串、怎么处理 col: / fld: 两段后缀)没有逐行核。
  • repointCarrierToSuccessor:463 只读了调用点与它「改指后继菜单」的语义,选后继的判据没有核。
  • chooseMenuId:653 未读。
  • 「94 个不一致 / 85 个按钮」「117 个族拦下 20 个」「598 / 515 / 1022」「575 / 178」「119 行」「163 个资源」「33 行新建 / 64 个复用」全部来自源码注释,是历史快照。
  • PermissionDriftDetector 未读,只知道它的「陈旧」判据是 type == ACTION。
  • ResourceMapper 的方法只从调用点反推了签名,没有逐个读 SQL。

相关页面 ​