权限资源(开发视角)
适用端:后端 + 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.java | PATH_SEPARATOR:59、PATH_ROOT_PREFIX:63、ID_ROOT:67、defaultEffect:123-127(🔴 过期) |
| 类型枚举 | enums/permission/ResourceTypeEnum.java | 7 个取值、defaultEffect():73-75、parse():77 |
| Service | service/permission/ResourceServiceImpl.java | syncFromMenu:278、shouldHaveResource:267、syncCarrierFromMenu:387、carrierDisplayName:440、repointCarrierToSuccessor:463、applyTypeByPermission:596、fillPathAndDepth:610 |
| Mapper | dal/mysql/permission/ResourceMapper.java | selectByPermission、selectByPermissionIncludeDisabled、selectByMenuId、selectCountByParentId、selectListByParentIdAndTypes、selectListByDatasetTable |
| 树推导 | framework/permission/core/PermissionTreeDeriver.java | — |
| 命名约定 | framework/permission/core/dataset/DatasetResourceNaming.java | dataset()、child()、childOf()、RUN_PERMISSION_PREFIX |
| 塌缩计划器 | framework/permission/core/**scan**/ResourceCollapsePlanner.java | blockedIdentities:290、applyStrictestDefaultEffect:584、isStricter:610、chooseMenuId:653 |
| 塌缩定位器 | framework/permission/core/collapse/CollapsedResourceLocator.java | identityOf、identityOfAll:99、collapseEnabled |
| 权限串解析 | framework/permission/core/collapse/PermissionStringResolver.java | Tier(6 级) |
| Controller | controller/admin/permission/ResourceAdminController.java | 8 个端点(🔴 类注释说「只提供查询」,实际有 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 全文:
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 是冗余的深度。查一个资源的所有祖先:
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 个索引,没有一个是唯一索引(除主键):
| 索引 | 列 |
|---|---|
PRIMARY | id |
idx_permission | tenant_id, permission |
idx_parent | tenant_id, parent_id |
idx_path | tenant_id, path(255) |
idx_path_only | path(255) |
idx_resource_path_lookup | tenant_id, deleted, path(255) |
idx_menu | menu_id |
idx_dataset_table | tenant_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/list | system:resource:query |
| GET | /system/resource/get | system:resource:query |
| GET | /system/resource/list-children | system:resource:query |
| POST | /system/resource/create | system:resource:update |
| PUT | /system/resource/update | system:resource:update |
| DELETE | /system/resource/delete | system:resource:update |
| DELETE | /system/resource/delete-list | system:resource:update |
| PUT | /system/resource/update-share-policy | system: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 次且中间态会被别的请求读到。
怎么扩展
加一种资源类型
ResourceTypeEnum加取值,并决定defaultEffect()返回什么。🔴 这一步是授权语义决策,不是编码细节:它决定「零配置时放行还是拒绝」。返回 ALLOW 的代价是可能越权且无人报告,返回 DENY 的代价是「配了但忘了授权 ⟹ 功能凭空消失」。- DDL 的
type列注释同步——它现在已经漏了 CATEGORY,别再漏一个。 ResourceDO.java:123-127的 javadoc 同步(它也已经过期)。- 前端
views/system/resource/的类型标签映射(resourceTypeTagType)加一项。⚠️ 它返回的是string不是 Element Plus 的字面量联合类型,调用点需要as any——**不要「修好」**成返回undefined。 - 如果新类型要参与判定,检查
PermissionStringResolver.Tier的 6 级解析能不能认出它。 - 如果它是 DATASET 类的(要挂列/字段、要行过滤),得走
@PermissionDataset或发布事件登记,并让表名进RuleDataPermissionRule的集合。
改一个资源的 default_effect
- 界面上改:资源页是只读的,改不了。只能通过授权页的入口或直连数据库。
- 🔴 改之前先想清楚塌缩:如果这一行是承载行(同族有多个动词串被它代表),改它会一次性影响整族。
applyStrictestDefaultEffect只往严的方向改,人工往松的方向改没有任何守卫拦着。 - 改完要失效三个缓存(
PERMISSION_MENU_ID_LIST/GRANT_USER_PERMISSIONS/GRANT_USER_MENU_IDS)。走ResourceService的方法会自动失效;直连数据库改则要等resourceChainCache(没有 TTL)自然过期,也就是不会过期。
坑与注意
- 🔴
sys_resource是全局表,tenant_id列不参与读写。 - 🔴
permission上没有唯一索引,查重是调用方的责任。 - 🔴
ResourceAdminController的类注释已过期(说只读,实际 5 个写端点)。 - 🔴
ResourceDO:123-127与两处 DDL 列注释都还写着 DATASET 是 DENY,且type注释漏了 CATEGORY。 - 🔴 塌缩后「菜单的权限串 ≠ 承载行的权限串」,
syncFromMenu为此单独开了一条路。老路的两条判据全部失效。 - 代表菜单的
status带动整行:停用代表菜单 = 停用整个身份。 - 代表菜单可能是按钮(页面菜单
permission为空串进不了候选集),所以carrierDisplayName要往上取父级页面菜单的名字。 path由 Service 维护,不要直接 set。menu_id任何判定逻辑都不许读,它只是迁移期的临时映射。- 资源树的父子关系是从权限串推导的,不是数据。改
parent_id会在下次同步时被按串挂回去,且不报错。 - 父节点不存在时退化为挂根并打 warn,不报错。
resourceChainCache没有 TTL,改了资源树要显式失效。idx_path只索引前 255 字符,而列宽 512。delete-list必须把子节点一起勾选,单条删见子节点就抛RESOURCE_HAS_CHILDREN。
→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。
测试
| 测试 | 守什么 |
|---|---|
ResourceCollapsePlannerTest | R1…R11 计划规则、blockedIdentities、applyStrictestDefaultEffect |
ResourceCollapseApplierTest | 落库、事务内后置守卫 |
CollapseMigrationSelfCheckTest | 拿计划器重放冻结快照 _scratch/s2c_snapshot.json,5/5 |
PermissionStringResolverTest | 6 级 Tier 解析 |
DatasetResourceServiceImplTest | 22 个 @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。
