Skip to content

权限体系总览 ​

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

定制功能

sys_ 前缀的 7 张表、决策引擎、行级规则、字段模式、记录共享、权限诊断——整套都是本项目自研。上游 yudao 只有 system_role_menu(角色↔菜单的多对多)加一个 @DataScope 注解。本页讲的是这套自研体系;上游那套(system_menu / system_role_menu / @DataScope)见路由与权限机制,逐域的约定与扩展点留给 04 上游域开发章(待整理)。

这一页是 02 域的地图。权限体系有三层(功能 / 行 / 列字段),三层各有独立的存储、判定入口和失效模式,而且新旧两套并存(开关切换)。搞不清自己在改哪一层,是这套体系里最常见的错误来源。

前置阅读:路由与权限机制(前端那层:路由守卫、菜单渲染、v-hasPermi)、数据库约定与表总览。

它解决什么问题 ​

三层各管一段,任何一层放行都不代表整体放行

  • 功能权限判「能不能调这个接口」,返回 boolean。
  • 行级判「这个接口能查出哪些行」,产物是一段 SQL 条件。
  • 列/字段级判「查出来的行里哪些列能看/能改」,产物是 EDITABLE/READONLY/HIDDEN 三态。

三层不共享判定结果。给一个用户配了 query 的 ALLOW,不会自动让他看到任何一行数据;行过滤放开了,也不会自动让他看到敏感列。

新旧两套并存 ​

两个开关都在 enlev.permission 下,PermissionProperties 的默认值与 application.yaml 的实际值不一致,以 yaml 为准:

配置项PermissionProperties 默认application.yaml 实际含义
grant-enabledfalse(:27)true(:351)走新引擎(sys_resource + sys_grant)还是旧路径(system_role_menu)
collapse-enabledfalse(:47)true(:378)走「资源身份 × 动作」塌缩后的读路径
grant-migrate-on-startupfalse(:60)未配置启动期是否跑 system_role_menu → sys_grant 迁移
derived-key-warn-threshold5000(:78)5000(:381)派生可见性的业务主键数告警阈值
subject.post-enabledtrue(:88)未配置岗位是否作为授权主体
subject.dept-enabledfalse(:100)true(:396)部门是否作为授权主体
subject.dept-ancestor-enabledtrue(:109)未配置部门主体是否含祖先部门

yaml 对 grant-enabled 的注释记着切换前的对账(S5c):旧路径放行 1549 对(角色,权限),新引擎 1550 对,旧有新无 = 0,即没有任何权限点会因切换而被误杀。

PermissionServiceImpl:112 的注释已过期

那段注释写着「grant-enabled = false(默认)→ 走旧路径,保证现有环境不受影响……迁移脚本跑完、验证权限点都正常后,改为 true 才真正走新路径」。而 yaml 里它早就是 true 了。读代码时别被这段注释带走——它描述的是一个已经完成的迁移过程。

塌缩开关是本项目已知的最严重缺口

application.yaml:371-377 的注释逐字承认:FormDesignRuntimeController#checkPermission 比的是带动词的 infra:form-design-run:{code}:{action},塌缩后它被翻译成同一个身份行去查;身份行 default_effect=ALLOW 且链上零授权时,判定回落成放行。实测已打穿 3 张已发布表单(非超管、零授权,/config 与 /page 均返回 code=0 与真实数据行)。权威副本在 GrantDecisionService#lookupOf 的注释里。完整口径、处置与三条被否掉的根治路线见授权与决策引擎。

代码在哪 ​

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

判定链 ​

职责文件关键方法
@ss 的实现(@PreAuthorize 打到这里)framework/security/core/service/SecurityFrameworkServiceImpl.javahasPermission / hasAnyPermissions
超管短路 + 新旧开关service/permission/PermissionServiceImpl.javahasAnyPermissions:96、syncRoleMenuGrants:233、filterMenuDerived:468
决策引擎(纯函数)framework/permission/core/decision/GrantDecisionEngine.javaexplain:132、isAllowed:114、Decision:54、DecisionReason:77
决策服务(装配 + 缓存 + 塌缩翻译)service/permission/GrantDecisionService.javahasPermission:416/446、lookupOf:172、chainsOf:294、listAllowedPermissions:936、listAllowedMenuIds:783、listEffectiveTree:1145、resolveFieldModes:589
主体闭包service/permission/SubjectClosureServiceImpl.java—
主体寻址framework/permission/core/subject/{SubjectKey, SubjectTypeEnum}.javaisRefBased:105、parse:116
配置framework/permission/config/PermissionProperties.java前缀 enlev.permission

行级与列级 ​

职责文件关键方法
行过滤求值framework/permission/core/rowfilter/DataRowFilterResolver.javaresolve:164、isSuperAdmin:341、combine:363、expandBuiltInTemplate:674
MyBatis 拦截器接入framework/permission/core/rule/RuleDataPermissionRule.javagetTableNames()、registerTable()
条件树求值framework/permission/core/rule/ConditionTreeEvaluator.javaresolveColumn:279、resolveQualifier:303
字段裁剪(原生模块)enlev-framework/enlev-spring-boot-starter-biz-data-permission 下 PermissionFieldHelper / PermissionFieldCropAspectfields(Class)
字段裁剪(在线表单)infra service/formdesign/FormFieldCropper.javaresolve:79
行过滤(在线表单)infra framework/formdesign/core/FormRowFilter.javaresolve:91、executeIgnore:123

资源、规则、共享、诊断 ​

职责文件关键方法
资源 CRUD + 菜单同步service/permission/ResourceServiceImpl.javasyncFromMenu:278、syncCarrierFromMenu:387、carrierDisplayName:440、repointCarrierToSuccessor:463、buildFromMenu:601
命名约定framework/permission/core/dataset/DatasetResourceNaming.javadataset()、child()、RUN_PERMISSION_PREFIX
权限串解析(塌缩翻译)framework/permission/core/collapse/PermissionStringResolver.javaTier(6 级)
树推导framework/permission/core/PermissionTreeDeriver.java—
塌缩计划器framework/permission/core/collapse/ResourceCollapsePlanner.javaapplyStrictestDefaultEffect:584、blockedIdentities:290、chooseMenuId:653
塌缩定位器framework/permission/core/collapse/CollapsedResourceLocator.javaidentityOfAll:99
数据集登记(表单/原生)service/permission/DatasetResourceServiceImpl.javaregister:88、applyDefaultEffectOnTypeUpgrade:293
记录共享service/permission/RecordShareService.java、controller/admin/permission/RecordShareController.javacheckShareable:73-104、share:120
诊断controller/admin/permission/PermissionDiagController.java1 个端点
启动 Runner ×6framework/permission/runner/@Order(1)→@Order(6),见低代码总览

枚举(全部逐字核对) ​

枚举取值落库位置
ResourceTypeEnumMENU / ACTION / PROCESS / CATEGORY / DATASET / COLUMN / FIELD(7 个)sys_resource.type
GrantActionEnumVIEW / CREATE / UPDATE / DELETE / EXPORT / IMPORT / PRINT / START / DESIGN / MANAGE / VIEW_ALL / REFERENCE / WILDCARD("*")(13 个)sys_grant.action
GrantEffectEnumALLOW / DENYsys_grant.effect
SubjectTypeEnumUSER / ROLE / DEPT / POST / GROUP / SYSTEM / ALL(7 个)sys_grant.subject_type
FieldModeEnumEDITABLE(2) / READONLY(1) / HIDDEN(0)sys_grant.field_mode
PermissionSlotEnumowner / dept / status / bizKeysys_slot_mapping.slot
RuleTemplateEnum10 个内置模板(见行过滤规则)sys_rule.template
ConditionOperatorEnumEQ / NE / GT / GTE / LT / LTE / IN / NOT_IN / IS_NULL / IS_NOT_NULL / LIKE / NOT_LIKE(12 个)条件树 JSON
RecordSharePolicyEnumOWNER_ONLY(默认)/ ANY_VIEWER / DISABLEDsys_resource.share_policy
RoleCodeEnumsuper_admin / tenant_admin / crm_adminsystem_role.code
GrantDecisionEngine.DecisionReasonNO_SUBJECT / EMPTY_CHAIN / DEFAULT_EFFECT / GLOBAL_DENY / CONTEXT_DENY / CONTEXT_ALLOW / GLOBAL_ALLOW / NO_MATCHING_EFFECT(8 个,顺序即优先级)不落库

⚠️ 枚举名直接落库(varchar),不要改名。SubjectTypeEnum:14-15 说明了用 varchar 而不是 tinyint 的理由:主体表与成员表是低频配置数据,量级在千行以内,省那几个字节没有意义,而排查权限问题时能直接 select 出 ROLE/DEPT 而不是 2/3,价值很大。

核心机制 ​

权限串命名约定 ​

在线表单派生的权限串有严格形态,DatasetResourceNaming 是唯一出处:

形态例子资源类型
infra:form-design-run:{formCode}infra:form-design-run:doc_device_ledgerDATASET(身份行)
infra:form-design-run:{formCode}:{action}...:doc_device_ledger:queryACTION(塌缩后退役)
infra:form-design-run:{formCode}:col:{key}...:doc_device_ledger:col:phoneCOLUMN
infra:form-design-run:{formCode}:fld:{key}...:doc_device_ledger:fld:phoneFIELD

原生模块走 @PermissionDataset(value = "system:user"),串就是那个 value,子级同 :col: / :fld: 规则。

用错重载会静默产出一个「不成树」的权限串

DatasetResourceNaming.child() 有多个重载。传错参数不会抛异常,只会拼出一个形态合法但在树里找不到父节点的串——表现是「资源建出来了、授权也配了、判定永远算不到它」。改这块前先读方法签名,别凭参数名猜。

主体闭包:谁在替我说话 ​

一个用户不是单一主体。SubjectClosureServiceImpl 把他展开成一个 Set<SubjectKey>:

ALL 主体永远在每个登录用户的闭包里(SubjectTypeEnum:80)。所以给 ALL 配 DENY 等于对所有人关闭——这是刻意的,「先全关再逐个开」是常见的收紧手法。

它存在的理由(:69-78)值得记住:「不限制」在授权模型里必须是一条显式的授权,不能是「没有授权」。流程资源的 default_effect 是 DENY,所以「这个流程谁都能发起」原本表达不出来,只能靠 startUserIds 两个范围都为空时的回落分支兜着;回落一摘,所有没配 START 授权的流程立刻变成谁都发不了。有了 ALL,「不限制」就成了一条看得见、可审计、可在界面上撤销的授权行。

sys_subject_member 的方向反了就是静默提权

成员表的语义是「subject_id 这个组里有 member_type:member_ref_id 这个成员」。写反方向不会报错——闭包照样算得出来,只是算出来的是「谁属于我」而不是「我属于谁」,结果是静默提权。这张表没有任何数据库层约束能挡住方向写反。

决策:八步 ​

GrantDecisionService.hasPermission → GrantDecisionEngine.explain 的完整链路:

  1. 超管短路。PermissionServiceImpl.hasAnyPermissions:96 先查角色,hasAnySuperAdmin 为真直接 return true,根本不进引擎。
  2. 开关分流。grant-enabled=true 走新引擎,否则走旧路径 hasAnyPermissionLegacy。
  3. 塌缩翻译。collapse-enabled=true 时,带动词的串被 PermissionStringResolver 翻译成「身份行 + 动作」去查(lookupOf:172)。
  4. 算主体闭包。
  5. 算资源祖先链。物化路径 path,WHERE :myPath LIKE CONCAT(path, '%'),不递归。
  6. 引擎判定。explain:167-173 用五个 filter 选出命中行:isActive()(有效期)、chainIds.contains(resourceId)、subjectMatches、actionMatches(action) || actionMatches("*")、isGlobal() || contextId 匹配。
  7. 优先级。全局 DENY > 上下文 DENY > 上下文 ALLOW > 全局 ALLOW。
  8. 回落。命中行为空时,读链上最具体那个资源的 default_effect 列(不是枚举方法)。

isAllowed 是 explain 的薄封装,这是构造上的保证

isAllowed:114-120 全文就一句 return explain(...).allowed()。Decision 的类注释(:36-44)解释了为什么必须这样:权限地图(listEffectiveTree)要展示「这项权限从哪来」,而鉴权只要一个 boolean;如果两边各算各的,就又多了一份会漂移的口径——这套体系已经漂过两次了(buildResourceChain 沿 path 上溯不过滤 status vs buildChainFromMap 沿 parentId 上溯只含启用资源,在「停用祖先」场景分叉;GrantAdminController#listByResource 与 toRespVOs 两处 expired 口径也不一致)。焊死在同一段代码里,两者在构造上不可能不一致。

四处优先级判定一律用 findFirst 而不是 anyMatch(:189-190):判定结果完全一样,但顺手拿到了「是谁投的这一票」,权限地图靠它区分「本资源配置」与「继承自上级」。

default_effect:一个刻意的分叉 ​

这是整套体系里最容易算错的地方,因为有两个 default_effect,且它们不相等:

ResourceTypeEnum.defaultEffect()sys_resource.default_effect 列
是什么枚举方法,按类型返回出厂值数据库列,每行各自的值
谁读它只有 ResourceAdminController#createResource 填默认值时判定时读的是它
DATASETALLOW可能被塌缩计划器或管理员改成 DENY

ResourceTypeEnum.defaultEffect():73-75:(DATASET || COLUMN || FIELD) ? "ALLOW" : "DENY"。

DATASET 出厂 ALLOW 是刻意设计——:65-71 的 javadoc 写着它「不是访问控制点……若把 DATASET 设成 DENY,零配置环境下所有在线表单会被全面拒访」。真正的控制点是它下面那 6 个 default_effect=DENY 的 ACTION 行。

一句话解释塌缩缺口

DATASET=ALLOW 是刻意设计(它不是访问控制点,控制点是它下面 6 个 DENY 的 ACTION 行);塌缩把那 6 个 ACTION 成员行退役后,判定改查 DATASET 身份行,于是「刻意设计的非控制点」被当成了控制点。

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

⚠️ ResourceDO.java:123-127 的 javadoc 与 DDL 的列注释都还写着 DATASET 是 DENY,均已过期。

两个早退不回落 ​

explain:137-143 的两个早退都不回落 default_effect:主体集合为空 → NO_SUBJECT,资源链为空 → EMPTY_CHAIN,直接拒绝。没有身份 / 没有资源,就没有权限可言。

塌缩之后「资源链为空」不再等于拒绝

塌缩前 chains.isEmpty() 直接 return false。塌缩后动词串被翻译成身份行 + 动作,于是删掉一个按钮资源不再表示拒绝:身份行 default_effect=ALLOW、链上零授权 → 回落放行。GrantDecisionService:200-285 把这个缺口连同实测数据、严重度纪律和三条被否掉的根治路线写全了。

三层怎么在一次请求里叠加 ​

以在线表单 POST /infra/form-design-run/{code}/page 为例:

层在哪介入产物失败模式
功能权限FormDesignRuntimeController.checkPermission:363-368,编程式(串里含 code,@PreAuthorize 静态字符串做不到)boolean,拒绝则抛业务异常塌缩回落放行
行级FormRowFilter → FormDesignSqlBuilder 6 个注入点(:281,404,420,446,491,659),JdbcTemplate 拼原生 SQL,不经过 MyBatis 执行器一段 WHERE两个降级方向相反:PermissionCommonApi 不可用 → pass() 不过滤;求值抛异常 → denyAll()
列/字段级FormFieldCropper 在 Service 层,三个出口 config(:292,295)/get(:1236)/page 必须一起裁反射把值置 null(不是不查那一列)只裁 config 的话抓包就能看到明文

原生模块(system_users 这些)走的是另一套:行级在 RuleDataPermissionRule(MyBatis 拦截器),列级在 PermissionFieldCropAspect(AOP)。详见行过滤规则。

数据模型 ​

7 张表,全部 sys_ 前缀(上游用 system_),完整列清单见 docs/.generated/tables-permission.md(由 tools/gen-tables.mjs 从 information_schema 生成,不是站点页面)。

表一句话租户维度
sys_resource可授权对象的树(7 种类型)🔴 @TenantIgnore 全局表
sys_grant授权行:谁 × 哪个资源 × 什么动作 × ALLOW/DENYTenantBaseDO 分租户
sys_rule行级规则(模板 + 条件树 + 参数)🔴 @TenantIgnore 全局表
sys_slot_mapping语义槽位 → 物理列名🔴 @TenantIgnore 全局表
sys_subject用户组(GROUP 主体自己的实体)TenantBaseDO 分租户
sys_subject_member组成员关系TenantBaseDO 分租户
sys_record_share单条记录分享给某主体TenantBaseDO 分租户

7 张表都有 tenant_id 列,但只有 4 张真的按它过滤

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

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

写查询时别看到列就以为它生效。逐表核对见权限资源。

设计上的分工是:sys_resource 回答「这套系统里存在哪些可授权对象」(与租户无关,system_menu 本来就是 @TenantIgnore 的),租户差异体现在 sys_grant(谁被授予了什么)。

接口清单 ​

9 个 Controller(controller/admin/permission/)。完整逐端点清单见各子页,这里只给全局视图与刻意不挂权限点的端点:

Controller端点数@PreAuthorize 数说明
ResourceAdminController88资源 CRUD、树、有效树
GrantAdminController109授权 CRUD、批量、有效树、my-field-modes
RuleAdminController99行级规则 CRUD、模板元数据
SlotMappingAdminController77槽位映射 CRUD
SubjectAdminController11用户组
RecordShareController40🔴 全部刻意不挂
PermissionController55角色↔菜单(旧路径)
PermissionDiagController11诊断
DataScopeMigrateController22@DataScope 迁移

RecordShareController 为什么一个权限点都不挂

:34 的注释:分享是每个人对自己单据的日常动作,挂权限点要么给所有人授(等于没挂)要么功能作废。真正的边界在 RecordShareService#checkShareable,它返回 6 种具体拒绝原因,由 Controller 用 exception0 带出去(SHARE_FORBIDDEN_CODE = 1_002_031_000)——拒绝原因有五六种,合并成一句「无权分享」用户不知道该改什么。

my-field-modes 为什么不挂

GrantAdminController:102-104 的 @Operation 逐字写着:「不挂权限点——它返回的是调用者自己的权限,挂了反而会让没有授权管理权限的普通用户拿不到自己的字段权限」。

GrantAdminController 的读路径规则

类注释禁止把查询搬进 GrantAdminService(那会造出第三份鉴权口径),所以读路径直接打 Mapper。唯一例外是 /effective-tree(:84-93):它要的是「引擎判出来是什么」而不是「台账上写了什么」,委托给 GrantDecisionService 恰恰是复用那份权威口径。在 Controller 里自己拼一遍判定才是真正的第三份。

怎么扩展 ​

三条最常见的扩展,各有独立子页:

我要…去哪最容易漏的一步
加一种授权主体类型授权与决策引擎🔴 9 步里顺序不可逆:先加枚举再改闭包,反过来会让存量行算不出来
加一条行级规则模板行过滤规则🔴 expandBuiltInTemplate ↔ RuleTemplateMetaRegistry.paramSpecs 是手工对齐的契约;且表必须先在 tableNames 集合里
加一个动作授权与决策引擎🔴 VERB_TO_ACTION 漏一项会让权限串排序错乱(实测 39 行错分,档 C 从 35 涨到 153)
加一种资源类型权限资源default_effect 出厂值要想清楚:它决定「零配置时放行还是拒绝」
改前端授权页前端授权页开发GRANT_ACTION_OPTIONS 与后端枚举没有任何自动对齐
排查「配了不生效」诊断与排错—

坑与注意 ​

跨子页的、属于体系层面的坑:

  1. 🔴 sys_resource 是全局表,但列里有 tenant_id。见上。
  2. 🔴 超管短路在引擎之外。PermissionServiceImpl:96 直接 return true,所以超管账号测不出任何权限缺陷。验证权限改动必须用非超管账号。
  3. 🔴 TENANT_ADMIN 没有短路。只有 super_admin 有(RoleServiceImpl.java:240)。租户管理员走的是正常判定路径。
  4. 两套 isSuperAdmin 实现,刻意不同。DataRowFilterResolver.isSuperAdmin:341 与 PermissionServiceImpl 那个不是同一份代码,别「统一」它们。
  5. @DataPermission(enable = false) / executeIgnore 不是可选的。权限体系自己的查询(读 sys_grant、算闭包)必须绕过数据权限拦截器,否则求值过程会触发新的求值,无限递归或直接查不到数据。
  6. @Cacheable 必须配 readOnly=true + executeIgnore,理由同上。
  7. resourceChainCache 没有 TTL。资源树改了要显式失效,否则判定读的是旧链。
  8. 有效权限树刻意不加 @Cacheable,有 5 个显式失效点。
  9. 🔴 1900 个元素的 IN 列表实测耗时 36 秒。批量查授权必须用 selectListActiveBySubjects,不要拼大 IN。
  10. FieldModeEnum 没有 VISIBLE 取值。解析不出来的行被静默忽略并回落到「可编辑」——一条脏数据的方向是放开而不是收紧。
  11. 过期时间对 ALLOW 和 DENY 一视同仁(刻意)。且 SQL 与内存两处比较一个用 > 一个用 >=,边界上差一秒。
  12. sys_record_share 只放开不收紧。取消共享 ≠ 禁止查看:被取消的人若通过别的途径本来就看得到,照样看得到。要真挡住得用 sys_grant 的 DENY。

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

测试 ​

测试守什么
GrantDecisionEngineTest八步判定、优先级、两个早退、回落
GrantDecisionServiceTest:224塌缩翻译、缓存、有效树
CollapseMigrationSelfCheckTest🔴 拿计划器重放迁移前冻结快照 _scratch/s2c_snapshot.json,5/5
ResourceCollapsePlannerTest / ResourceCollapseApplierTest塌缩计划与应用
PermissionStringResolverTest6 级 Tier 解析
DatasetResourceServiceImplTest22 个 @Test,登记五步
RecordShareServiceTest / DataRowFilterRecordShareTest共享的 6 种拒绝原因

全部纯 Mockito,无 DB 集成测试。前端侧 src/views/system/{role/grant,resource} 下另有 6 个 spec,见前端授权页开发。

CollapseMigrationSelfCheckTest 换判据就会失去意义

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

未证实 ​

  • 本页的判定链逐段读了源码,但没有端到端跑一遍「配一条授权 → 用非超管账号验证」来确认三层叠加的实际行为。3 张被打穿的表单是历史实测(记在 application.yaml 与 GrantDecisionService 的注释里),不是本轮复现。
  • 两个 MyBatis 拦截器(数据权限、租户)的相对顺序没有代码钉住,只从行为推断。
  • RuleTemplateEnum 的 javadoc 第 14 行写「8 个内置模板」,实际声明了 10 个——注释过期,未确认哪个是权威。
  • 所有「实测 N」(1549/1550、18/9、341/666、1022→598、1802→1787、36 秒…)都来自源码注释,是历史快照。GrantDecisionService:482-484 专门警告「别把任何一个数字当常量用」。
  • _scratch/s2c_snapshot.json 未读。
  • 约 11 个类只读到签名层(SubjectClosureServiceImpl、PermissionTreeDeriver、ConditionTreeEvaluator 的部分方法等)。

相关页面 ​

本域子页

跨域