Skip to content

行过滤规则 ​

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

权限三层里的第二层:功能权限回答「能不能进这个页面」,行级权限回答「进去之后能看到哪几行」。配置只有两张表——sys_rule(一条规则 = 一个模板 + 参数)与 sys_slot_mapping(语义槽位 → 真实列名),通过 sys_grant.rule_id 挂到授权行上。

判定逻辑只有一份,在 DataRowFilterResolver。这一点是整个设计的出发点,也是最容易被后来者破坏的地方。

前置阅读:权限体系总览、授权与决策引擎。

它解决什么问题 ​

一张表,两条通道 ​

行级权限有两个消费方,走的是完全不同的技术通道:

通道谁在用拿到的形态为什么不能共用
MyBatis-Plus 拦截器所有原生模块(system_users、infra_file…)JSqlParser 的 Expression只能改写走 MyBatis 执行器的 SQL
主动取用在线表单运行时SQL 片段 + 参数表它用 JdbcTemplate 拼原生 SQL,压根不经过 MyBatis

DataRowFilterRespDTO 的类注释把这条讲得最直白:

后果是「行级规则配了、表也注册了、日志也打了已注册 N 张表」,但查询照常返回全部数据。没有报错、没有日志、界面完全正常。

于是判定被抽成 DataRowFilterResolver 一份,两个通道各自只做「取产出 → 转成自己需要的形态」。RuleDataPermissionRule 的类注释写明了分工:

只负责「组装」,不负责「判定」。……两边各写一套判定必然漂移,而漂移的表现是「同一个用户,列表看得到的行详情打不开」,极难归因。

全链路 ​

代码在哪 ​

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

关注点位置
判定入口(唯一)service/permission/DataRowFilterResolver
MyBatis 通道framework/permission/core/rule/RuleDataPermissionRule
条件树求值器framework/permission/core/rule/ConditionTreeEvaluator
条件树节点模型framework/permission/core/rule/ConditionNode
操作符白名单framework/permission/core/rule/ConditionOperatorEnum(12 个)
变量 SPIframework/permission/core/rule/RuleVariableProvider
内置变量framework/permission/core/rule/BuiltInVariableProvider(12 个)
模板枚举enums/permission/RuleTemplateEnum(10 个)
模板视图元数据enums/permission/RuleTemplateMetaRegistry
槽位枚举enums/permission/PermissionSlotEnum(4 个)
槽位自动推断(在线表单)framework/permission/core/dataset/SlotInferencer(纯函数)
原生数据集扫描framework/permission/core/dataset/NativeDatasetScanner
表名注册(启动)framework/permission/runner/DatasetTableRegisterRunner(@Order(5))
原生表登记(启动)framework/permission/runner/NativeDatasetRegisterRunner
内置规则种子(启动)framework/permission/runner/BuiltInRuleSeedRunner(@Order(4))
跨模块出口api/permission/PermissionApiImpl#getDataRowFilter(实现 PermissionCommonApi)

enlev-module-infra 侧(在线表单):

关注点位置
取用口 + 忽略开关framework/formdesign/core/permission/FormRowFilter
写后落域校验framework/formdesign/core/permission/FormRowScopeGuard
拼进 WHEREframework/formdesign/core/sql/FormDesignSqlBuilder

DTO 放在 enlev-framework/enlev-common/.../biz/system/permission/dto/DataRowFilterRespDTO——infra 不依赖 system,只能靠 common 里的契约类通信。

核心机制 ​

判定链:resolve() 的八个出口 ​

DataRowFilterResolver#resolve:164。按顺序,每一个出口的方向都是刻意选的:

#条件返回为什么是这个方向
1tableName 空白pass()无从过滤
2userId == nullpass()定时任务、MQ 消费者这类无人上下文;「拒绝与否交给上层」
3主体闭包抛异常 / 为空denyAll()「把异常变成不过滤等于把异常变成越权」
4闭包里有超管角色pass()见下
5这张表没有 DATASET 资源pass()没登记进权限体系的表不受管
6有资源但没有任何命中授权defaultEffectOf(resources)🔴 不能硬编码 pass()
7命中授权全都「无规则且非 DENY」pass()这张表上没有任何行级配置,与历史行为一致
8其余逐条求值 → combine()见下

第 6 步的理由(defaultEffectOf:290-309):

sys_resource.default_effect 的契约就是「没有授权命中时的效果」。硬编码放行的后果是这个配置项永远是死的:管理员把某个数据集的默认效果改成 DENY,界面上显示生效了,判定侧却照旧全表放行——一种不报错的配置失效。

出厂时 DATASET 的 default_effect 全是 ALLOW,所以第 6 步对存量数据零影响。塌缩迁移把 7 行收紧成了 DENY,见权限资源。

第 4 步:行级过滤为什么也要对超管短路 ​

isSuperAdmin:341。功能权限侧三处早就短路了,唯独行级没有,于是:

「超管能看到哪些行」变成了一件取决于 sys_grant 数据是否完好的事:那条「无规则的 ALLOW」一旦被误删(实测会被套餐重算删掉),超管就退化成它身上另一个普通角色的数据范围——表现是「我是超管,怎么只看得到本部门的人」,而且没有任何报错。

两处细节:

  • 读闭包而不重查角色表——闭包上一步已经算好,本方法在每条命中数据集的 SQL 上都要跑一次。
  • 只认启用中的角色(闭包只收启用的),停用的超管角色在这里不短路。判不出来时按非超管处理,catch 里也是 return false。

「无规则的 ALLOW」= 旧 data_scope 的 ALL ​

两个常量片段,DataRowFilterResolver:115-139:

java
private static final SqlFragment DENY_ALL_FRAGMENT = new SqlFragment("1 = 0", List.of());
private static final SqlFragment ALLOW_ALL_FRAGMENT = new SqlFragment("1 = 1", List.of());

它们都不能用 null 或空片段代替,因为那两者的含义是「这条授权不表达任何东西」,会被循环跳过:

⚠️ 不能用 null 或空片段代替:那两者会被当作「这条授权不表达任何东西」而被跳过,于是有 ALL 角色的用户反而被别的角色的规则限制住——迁移后表现为「原本能看全部的人突然只能看本部门」,且不报任何错。

对应地,第 7 步之前有一段「⚠️ 这里不能先按 ruleId != null 过滤」的注释,讲的是同一件事的另一面。

DENY 且无规则 = 这个主体完全不能看这张表,贡献恒真的 DENY,combine 里 NOT(1=1) 把整表挡掉。

合并:OR(ALLOW) AND NOT OR(DENY) ​

combine:363,四种分支:

allowdeny产出
空空pass()——🔴 整表不过滤
空有NOT (denySql)
有空(allowSql)
有有(allowSql) AND NOT (denySql)

第一行就是全篇最贵的那个陷阱:任何让 allow 变空而 deny 也为空的改动,都会把「配了规则」变成「不过滤」。下面三处 denyAll / 恒假处置全部是为了不让它落到第一行。

「求不出来」必须能被调用方看见 ​

ConditionTreeEvaluator 的 SqlFragment 带三个字段,其中 unevaluable 是后加的:

早先的实现是:某个条件求不出来就返回空串,buildGroupSql 把它从 parts 里滤掉。看着无害,实际是个提权缺陷——一棵 AND(owner, dept) 的树,若 dept 槽位没配映射,产出就成了 (owner = ?),比管理员配的那条规则宽;只有整棵树全求不出来时才会产出空片段。也就是说「配错一半」比「全配错」放得更开,且全程不报错。

现在的口径是:只要有一个条件求不出来,整条规则就算求不出来。调用方按效果分别处置(resolve:255-283):

效果求不出来时理由
ALLOWallow.add(DENY_ALL_FRAGMENT)「它什么都没授予」。🔴 不能 continue——若它是唯一的 ALLOW,combine 会落到 pass()
DENY直接 return denyAll()「我们不知道该挡住哪些行。忽略它 = 这条禁止令静默失效」

⚠️ unevaluable 与 isEmpty() 是两件事:前者为 true 时 sql 可能非空(其余条件求出来了)。「拿半截去过滤比不过滤更危险——它看着像生效了,实际比管理员配的规则宽」。

变量取不到刻意不算 unevaluable

buildConditionSql:243-248 在 resolveValue 返回 null 时只打 warn、返回空串,不置 unevaluable:

那说的是当前这个用户的数据状况,不是配置错误。比如 OWNER_OR_DEPT 遇上一个没有部门的用户,退化成「只看自己的」正是想要的行为;判成求不出来会变成一行都看不到。

区分「配置错了」和「这个用户没数据」是这套 fail-closed 的边界,改任何一侧都要想清楚另一侧。

触发 unevaluable 的四种情形:槽位没配映射、列名过不了标识符正则、槽位绑的表不在本次查询里、操作符不在白名单。

三道防注入 ​

ConditionTreeEvaluator 类注释列的三道,加上两道额外的:

  1. 操作符白名单——SQL 里的操作符来自 ConditionOperatorEnum.sqlOp,不来自输入。parse() 返回 null ⟹ unevaluable。
  2. 列名白名单——真实列名只从 SlotMappingDO.columnName 取,ConditionNode.slot 是语义名。取到之后还要再过一次 IDENTIFIER_REGEX = "[a-zA-Z_][a-zA-Z0-9_]*":「列名即便来自 DB 里的可审计配置也再过一道——数据污染时宁可让规则失效,也不能把它拼进 SQL」。
  3. 参数化——值要么是变量引用(解析后的 Java 对象),要么是字面量(? 占位)。

额外的两道:

  1. 写入口核实列真的存在——SlotMappingAdminController#validateColumnExists 查 information_schema,表不在或列不在直接拒。理由:「『配错了』和『没配』在这里长得一模一样:都是条件求不出来、都是恒假、都不报错」。
  2. SlotMappingDO.tableName 永远不进 SQL——只当查限定符的 key 用。

把 slot 设成 '; DROP TABLE... 会怎样

在 sys_slot_mapping 里查不到这个槽位 → resolveColumn 返回 null → unevaluable → ALLOW 落恒假、DENY 落全拒。不会生成任何 SQL。

限定符:为什么 columnPrefix 不够 ​

resolveQualifier:303。槽位映射可以绑到关联表上,而 columnPrefix 说的只是主表:

拿主表前缀去限定一个关联表的列,结果是一条语法合法、语义错误的 SQL——匹配到主表的同名列,或者报 Unknown column。

于是有了第四个参数 tableQualifiers(物理表名小写 → 本次查询里合法的限定前缀)。value 是「限定符」而不是「别名」:JOIN 的关联表带了别名之后,物理表名就不再是合法限定符了,只有拼 FROM/JOIN 的那一层(FormDesignSqlBuilder)知道它这次叫什么。

null 与「只有主表一条」语义不同,别当成省略

  • tableQualifiers == null = 调用方不知道本次查询有哪些表 → 一律退回用 columnPrefix(与加这个参数之前逐字节一致)
  • Map.of(tableName.toLowerCase(), prefix) = 调用方明确说了只有主表 → 绑在别处的槽位按「求不出来」失效(fail-closed)

FormRowFilter#resolve:79 的注释把这条讲得最清楚:交 null 进去「求值器会退回用主表前缀去凑——那可能正好匹配到主表的同名列」。两个单表调用口(RuleDataPermissionRule:97、FormRowFilter:81)都显式给了单条 map,不是省略。

内置模板展开:expandBuiltInTemplate:674 ​

java
return switch (tpl) {
    case OWNER_ONLY -> cond("owner", "EQ", ":currentUserId");
    case DEPT_ONLY -> cond("dept", "EQ", ":currentDeptId");
    case DEPT_AND_CHILDREN -> cond("dept", "IN", ":currentDeptAndChildren");
    case OWNER_OR_DEPT -> group(OR, cond("owner", "EQ", ":currentUserId"),
                                    cond("dept", "EQ", ":currentDeptId"));
    case STATUS_IN -> { /* 从 params 读 allowedStatuses 注入 variables */ }
    case DEPT_IN -> { /* OR 上「自己的部门」,见下 */ }
    case FIELD_EQUALS -> { /* 槽位由 params.field 决定 */ }
    default -> null;   // BPM_PARTICIPANT / RECORD_SHARED / CUSTOM_TREE 不走这里
};

用 Java 构建而不是在 sys_rule.params 里预填 JSON,注释给了三条理由,第一条是决定性的:

内置种子数据已经在库里,无法保证所有部署环境都跑了 UPDATE——用代码展开,不论 params 列是空还是老格式都正确。

这里修掉过一个「从来没生效过」的缺陷

evaluate:619-626 的注释:

⚠️ 这是一个既有缺陷的修复。旧代码只认 params.conditionTree,而 8 条内置种子规则的 params 全是 NULL——于是每一条都静默返回 null,等于不过滤,管理员配的「仅自己的数据」「本部门」等规则从来没生效过。

内置模板的结构是固定的,不需要 params 里的条件树。STATUS_IN / DEPT_IN / FIELD_EQUALS 三个才真的读 params。

DEPT_IN 里那个 OR {dept} = :currentDept 不是多余的

RuleTemplateEnum:94-99:

它是旧实现里一条必须保留的兜底:自定义可见部门时要保证人至少能看到自己所在的部门,否则一些场景会直接坏掉——例如登录时基于 username 查 system_users,会被 dept_id 条件过滤掉而登录失败。

迁移时漏掉这个 OR,表现就是「配了指定部门,结果那个部门的人登不进来」

expandBuiltInTemplate:718-723 里同一段话又写了一遍,两处必须一起改。

派生可见性:两条来源,一条通道 ​

BPM_PARTICIPANT(他参与过的流程对应的单据)与 RECORD_SHARED(别人单独分享给他的那几条)都落在 bizKey 槽位上,都产出一个 IN (?,?,…),只是主键集合的来源不同。

evaluateBpmParticipant:415 的类级约束是本节最要紧的一条:

⚠️ 这个方法里所有失败路径都返回「恒假」,一条都不返回 null。……「配了派生可见性但取不到参与数据」会变成比不配还宽的全表可见。这正是本项目最贵的那类缺陷(配了、存了、没报错、但效果与预期相反)。

所以:表单编码解析不出来、bpm 模块不可用、他一条流程都没参与过、槽位没映射——统统落成 1 = 0,宁可让他什么都看不到。看不到会有人来报,悄悄放开没有人会发现。

evaluateRecordShared:496 的每一条降级方向都与它逐字对齐,理由是「两条派生路径对『取不到数据』给出不同答案的话,会出现『共享的看得到、参与的看不到』这种没人能解释的不一致」。

四个恒假出口:

出口触发条件
资源反查不出 formCode / datasetCode该资源不是数据集资源
BpmParticipantCommonApi 不可用用 ObjectProvider 注入,system 不依赖 bpm;只在首次缺失时 log.error
主键集合为空「他一条都没参与过——这不是异常,是正常的『看不到任何行』」
bizKey 槽位求不出列没配映射,或配了但绑到本次查询里没有的表上

规模告警只告警、不改变行为:

截断会静默少数据、拒绝会让「参与得越多看到的越少」,两者都比慢查询糟糕得多。这条日志的作用是让问题在变成线上事故之前先被人看见。

阈值是 PermissionProperties#derivedKeyWarnThreshold。

表名集合是内存态 ​

RuleDataPermissionRule.tableNames 是一个 LinkedHashSet,getTableNames() 是框架决定「要不要改写这条 SQL」的依据。两个写入时机,缺一不可:

时机位置不跑会怎样
表单发布 / 原生表登记DatasetResourceServiceImpl:108、:220、:431新表单不生效
应用启动DatasetTableRegisterRunner(@Order(5))🔴 重启后集合为空,所有已配好的行级规则静默失效

Runner 的注释:

后果:所有已经配好的行级权限规则静默失效。查询照常返回结果,只是不再有过滤,越权数据就这么出去了,没有报错、没有日志、界面完全正常。这是最危险的一类失效,因为没有任何信号提示你「权限现在是关着的」。

reloadRegisteredTables() 读 sys_resource 里所有 type=DATASET 且 dataset_table 非空的行。sys_resource 是 @TenantIgnore 全局表,启动期没有租户上下文也能查。

写后落域校验:FormRowScopeGuard ​

WHERE 里的行过滤只能挡住「改别人的行」,挡不住「把自己的行改成别人的」:

规则是「只能看本部门的单据」,用户改一张本部门的单,顺手把 dept_id 改成另一个部门——UPDATE 的 WHERE 校验的是改之前的值,完全通过;改完之后这条记录就跑到别的部门去了,用户自己也再看不见。

做法是写入之后用同一份行过滤条件再 SELECT 1 ... WHERE pk = ? AND (filter) 一次,落到域外抛 FORM_DESIGN_ROW_OUT_OF_SCOPE。为什么不用「比对提交值」:

比对提交值需要知道「哪些字段参与了域的判定」,而那取决于行过滤规则的条件树——规则可以引用任意槽位、任意组合。在应用层重新推导一遍等于把规则引擎实现两遍,两份实现迟早不一致。

参数为什么要内联成字面量 ​

DataRowFilterResolver 产出的是参数化片段(? + 参数表),这对 JdbcTemplate 是最安全的形态。但 MyBatis 拦截器改写的是已经定型的 SQL,「没有办法再往 BoundSql 的 ParameterMapping 里追加参数」,所以 RuleDataPermissionRule#inlineParams:117 把值内联回去。安全性依据:值来自服务端变量(当前用户 id、部门 id),不是用户输入;列名来自白名单;字符串值仍做 ' → '' 转义。

alwaysFalse():160 用 1 = 0 而不是构造空的 EqualsTo——「后者在不同 JSqlParser 版本上的 toString 结果不稳定,曾经拼出过语法不合法的 SQL」。解析失败也是 alwaysFalse(),不放行。

数据模型 ​

完整字段表由脚本生成,见 .generated/tables-permission.md(sys_rule / sys_slot_mapping)。这里只讲读表结构读不出来的部分。

sys_rule ​

列要点
template规则模板代码,见 RuleTemplateEnum。内置模板用 BUILT_IN_ 前缀
paramsMySQL JSON 列。空串不是合法 JSON 文档,直接写会抛 Data truncation: Invalid JSON text
description内置种子里存的是模板表达式({owner} = :currentUser),由 BuiltInRuleSeedRunner 从 RuleTemplateEnum.expression 抄来
status0 启用 / 1 停用。🔴 见坑与注意
built_in删除与编辑保护的唯一判据。⚠️ 不是「template 以 BUILT_IN_ 开头」——管理员自己建的规则同样可以选内置模板

RuleDO 标了 @TenantIgnore:

规则是「一段可复用的行过滤定义」,内置模板更是全系统一份。跟 ResourceDO 同样的理由:租户差异体现在 sys_grant——「哪个租户的哪个角色,套用了哪条规则」。

normalizeBlankParams 把空串抹成 null;updateRule 因此不能走 updateById(RuleAdminController:186-199 的 🔴 注释):

MyBatis-Plus 默认字段策略是 NOT_NULL,实体上为 null 的列会被整个从 SET 子句里省掉——那在 SQL 里的含义是「别动这一列」,不是「清成 null」。……把一条参数化规则改成无参模板(如 OWNER_ONLY),旧的 params JSON 会原样留在库里,DataRowFilterResolver 继续按旧参数求值——管理员看到「保存成功」,但行过滤范围不是他刚配的那个。

修法是 ruleMapper.update(entity, wrapper),wrapper 只补 params / description 两列的显式 set null。同一个注释里还有一条反向提醒:builtIn 不在 RuleSaveReqVO 里,BeanUtils 之后恒为 null,正因为默认策略跳过 null 列,它才不会被这条 UPDATE 抹掉——别给它加 set。

sys_slot_mapping ​

列要点
resource_id关联 sys_resource.id,通常是 DATASET 类型
slot语义槽位名,见 PermissionSlotEnum。⚠️ bizKey 是驼峰,写入侧要 toLowerCase() 处理
table_name列所在的物理表名(小写)。空 = 数据集主表。🔴 只当限定符查表的 key 用,永不拼进 SQL
column_name真实列名。求值时还要过 IDENTIFIER_REGEX

uk_resource_slot(tenant_id, resource_id, slot) 不含 deleted

BaseDO 的软删除只把 deleted 置 1、行仍留在表里,两者叠加的后果(SlotMappingMapper#deleteByIdPhysically 注释):

  1. 用户删掉一个槽位(软删,行还在)
  2. 想用同样的 slot 名重建——selectByResourceIdAndSlot 被 @TableLogic 自动加了 deleted=0,查不到那条软删行,于是判定「不重复」放行
  3. INSERT 撞上唯一索引 → 500,报错信息还与「重复」无关

表现就是删掉的槽位名永远被占着,且报错看不出原因。

所以这张表用 @Delete("DELETE FROM sys_slot_mapping WHERE id = #{id}") 物理删除。「槽位映射是纯配置数据,量小、无审计价值」。

同一个唯一索引还意味着:一个资源的一个槽位只能绑一张表。想给主表和关联表各配一个 owner 是做不到的。

四个语义槽位 ​

PermissionSlotEnum。candidates 只服务于自动推断,是「猜」不是「约定」:

槽位说明候选列名(按优先级)
owner记录归属人(创建者)的用户编号create_user_id, creator, owner_id, owner_user_id, user_id
dept记录归属部门编号create_dept_id, dept_id, department_id, org_id
status记录状态status, audit_status, approve_status, state
bizKey业务主键id

猜错的代价不对称,两边都错:

  • 没推出来 → 引用该槽位的规则被跳过并记告警 → 不过滤 → 看得到不该看的
  • 推错了列 → 按错误的列过滤 → 同样是错的可见范围

所以候选名单宁缺毋滥,只收本项目建表脚本里真实出现过的列名,不做泛化猜测。

推断的两个来源,显式声明优先于按列名猜:

  • 在线表单:SlotInferencer(纯函数,不碰数据库)。owner 先看 AutoFillConfig.creatorColumn,bizKey 直接取 TableBinding.mainPk,其余按 candidates 在物理列全集里找。看物理列而不是只看表单字段的理由:「status、create_dept_id 这类列通常不在表单字段里——它们由后端填、由流程写。只看字段映射的话,最常用的两个槽位恰恰都推不出来」。推不出来就什么都不写(不写占位、不写空串)。
  • 原生模块:@PermissionDataset 注解显式写死。NativeDatasetScanner:108 无条件写入 bizKey(注解默认值就是 id)。

两个反直觉的显式声明

  • AdminUserDO:owner = "id"。用户表的「归属人」就是这行自己。
  • DeptDO:dept = "id" 而不是 parent_id。注解上的注释:「判据与旧的 DeptDataPermissionRule 逐字对齐——那边写的就是 rule.addDeptColumn(DeptDO.class, "id"):对部门表来说,『这条记录属于哪个部门』的答案是它自己。指到 parent_id 会变成『按上级部门过滤』,语义完全不同」。

看到 sys_slot_mapping 里 system_users 的 owner 映射到 id,不要当成推断错了去「修」。

十二个内置变量 ​

BuiltInVariableProvider。VARIABLE_METADATA 与 provide() 必须一致,前者通过 /system/rule/template-meta 交给规则编辑器渲染下拉:

变量中文集合来源
currentUserId当前用户入参
currentDeptId当前部门AdminUserDO.deptId
currentDeptAndChildren当前部门及下级✅DeptService#getChildDeptIdListFromCache
currentRoleIds当前用户角色✅🔴 恒为空集合,见坑与注意
currentPostIds当前用户岗位✅AdminUserDO.postIds
currentTenantId当前租户LoginUser.tenantId
currentDate今天日期LocalDate.now()
currentDateTime当前时间LocalDateTime.now()
currentTimestamp当前时间戳now.toInstant(UTC).toEpochMilli()
currentYear今年
currentMonth本月
currentDayOfWeek今天星期几1=周一 … 7=周日

collection=true 的变量才能用于 IN / NOT_IN——求值器对多值操作符走集合展开,字面量会被当成单元素字符串。

processInstanceId / taskId 由调用方通过 requestContext 注入,不在这里。buildVariables:762 遍历所有 RuleVariableProvider Bean 合并,「后来者覆盖前者」,单个 provider 抛异常只 warn 并跳过。

接口清单 ​

RuleAdminController — /system/rule(9 端点) ​

方法路径权限串说明
GET/simple-listsystem:rule:query只返回 status=0 的,授权配置页下拉用
GET/listsystem:rule:query全部(含停用)
GET/pagesystem:rule:query分页,支持 name / template / status / builtIn / createTime
GET/getsystem:rule:query单条
GET/template-metasystem:rule:query模板 + 槽位 + 操作符 + 变量四份词表,「供规则编辑器动态渲染,避免前端硬编码与后端枚举漂移」
POST/createsystem:rule:create强制 setId(null)
PUT/updatesystem:rule:update内置规则拒改(RULE_BUILT_IN_UNEDITABLE)
DELETE/deletesystem:rule:delete内置拒删 + 被引用拒删
DELETE/delete-listsystem:rule:delete校验与单条一致

/template-meta 有一个兜底分支:模板没在 RuleTemplateMetaRegistry 里登记时,「兜底成『高级、无参数』,让编辑器退回 JSON 模式」——理论上不会发生,有穷尽性单测钉住。

被引用拒删的错误消息值得原文抄一遍,它把后果讲清楚了:

RULE_IN_USE:该规则已被 {} 条授权引用,删除会让那些授权指向不存在的规则(判定时整表拒绝)。请先解除引用

对应 resolve:242-247——授权指向不存在的规则是数据损坏,不是「没配规则」,直接 denyAll()。

内置保护的判据换过一次

deleteRule:226-232 的注释:原来判的是「id <= 13」,两个毛病——① 判据脆弱,注释自己写着「后续种子新增 id>13 的规则必须同步提高阈值」,而 @Operation 文档当时已经漂成了「id ≤ 100」;② 阈值就是错的,实际种子最大 id 是 12,id=13 是管理员在界面上建的规则却被当成内置删不掉,「用户报的正是这个」。现在改用显式的 built_in 字段。

SlotMappingAdminController — /system/slot-mapping(7 端点) ​

方法路径权限串说明
GET/listsystem:slot-mapping:query全量。存在的唯一理由是让批量授权能在提交之前算出槽位覆盖度
GET/list-by-resourcesystem:slot-mapping:query逐行回填 columnExists,把存量坏行标出来
POST/createsystem:slot-mapping:create保存时核实表与列真的存在
PUT/updatesystem:slot-mapping:update同上
GET/optionssystem:slot-mapping:query编辑器候选项
GET/table-columnssystem:slot-mapping:query读 information_schema 取列
DELETE/deletesystem:slot-mapping:delete物理删除

/list 不带 slots 过滤参数,注释解释了为什么刻意不加:

前端一次拉全量缓存住,结果与「当前选了哪个规则」无关——加了参数它就是个 no-op 过滤,却白搭一份前后端要对齐的契约(逗号拼接、空列表分支、@Size)。

量级:本库 206 行,比 /system/resource/list 一次拉的 1192 行小一个数量级。因为 SlotMappingDO 是 @TenantIgnore 全局表,「全量返回不扩大暴露面」。

四个错误码:SLOT_MAPPING_DUPLICATE(消息里带上 slot 名,并指明「请直接修改那一行」)、SLOT_MAPPING_UPDATE_ID_REQUIRED、SLOT_MAPPING_TABLE_NOT_IN_DB、SLOT_MAPPING_COLUMN_NOT_EXISTS。

怎么扩展 ​

加一个内置模板 ​

🔴 顺序不能乱:RuleTemplateMetaRegistryTest 做穷尽性校验,加了枚举不登记注册表会直接测试失败。

  1. RuleTemplateEnum 加常量。code 用 BUILT_IN_ 前缀(isBuiltIn() 就是判这个前缀,而它决定 BuiltInRuleSeedRunner 会不会给它建种子行)。expression 只作说明用,不参与 SQL 生成。
  2. 决定它属于哪一类:isDerived()(expression 以 DERIVED: 开头)走派生通道、isCustomTree() 走 params.conditionTree,两者都不是就走 expandBuiltInTemplate。
  3. 在 expandBuiltInTemplate 的 switch 里加一支,用 cond() / group() 构建节点。槽位名只能用 PermissionSlotEnum 里已有的,否则要求目标资源上配了这个槽位的映射。
  4. 若要读 params:在 RuleTemplateMetaRegistry 加一个 PARAM_* 常量,两边共用——这是防漂移的唯一手段。
  5. 在 RuleTemplateMetaRegistry 的 static 块里登记 Meta:分组、给业务人员看的白话说明、requiredSlots、paramSpecs。
  6. paramSpecs 的 kind 决定前端渲染哪个子编辑器(STATUS_LIST / DEPT_MULTI / SLOT / TEXT / CONDITION_TREE),valueType 决定前端序列化时按 number 还是 string。新 kind 要在前端加对应组件,否则编辑器渲染不出来。
  7. 跑 RuleTemplateMetaRegistryTest。
  8. 库里已有部署要靠 BuiltInRuleSeedRunner 补种子行——它按 template 判重、幂等。⚠️ 见坑与注意,补出来的行不受删除保护。

requiredSlots 只是提示,不是约束

RuleTemplateMetaRegistry.Meta#requiredSlots 的注释写着「为空表示『取决于参数』(如 FIELD_EQUALS 的槽位由 field 参数决定)」。它给前端做「这个模板需要哪些槽位」的提示与覆盖度核对,运行时不校验。真正决定成败的是 sys_slot_mapping 里有没有那一行。

加一个语义槽位 ​

  1. PermissionSlotEnum 加常量,给出 slot / description / candidates。
  2. candidates 宁缺毋滥——只收本项目建表脚本里真实出现过的列名。
  3. 想让它对在线表单自动推断生效:SlotInferencer 的兜底循环会遍历 values(),加了就自动参与;但如果它像 owner / bizKey 那样有显式来源(AutoFillConfig / TableBinding),要在 infer() 里单独写一段,优先级高于猜。
  4. 想让它对原生表生效:@PermissionDataset 加一个属性,NativeDatasetScanner 里 putSlot(...)。
  5. 存量数据集不会自动补——要么重新发布表单,要么在槽位映射页手工加。

加一个变量 ​

实现 RuleVariableProvider 并注册成 Bean 即可,接口注释里给了完整示例。两点:

  • 同时更新 BuiltInVariableProvider.VARIABLE_METADATA 才能让规则编辑器在下拉里看到它——/template-meta 只读这一份表。⚠️ 这份表在 BuiltInVariableProvider 里,第三方 provider 的变量进不了编辑器下拉,只能在条件树 JSON 里手写。
  • 集合类型的变量要在 metadata 里标 collection=true,否则编辑器不允许把它选给 IN / NOT_IN。

让一张原生表进入行级权限 ​

标 @PermissionDataset 就够了,NativeDatasetRegisterRunner 会在启动时登记 DATASET 资源、写槽位映射、注册表名。

标上它等于打开这张表的行级过滤

注解自己的注释:

没有人配规则时行为不变(DataRowFilterResolver 对「没配规则」返回不过滤,有对照单测钉住),但一旦有人配了规则就会立刻生效——这正是本注解的目的,也意味着不要给不该被过滤的基础数据表(字典、地区…)随手标上。

目前全仓只有两处:DeptDO 与 AdminUserDO。

坑与注意 ​

🔴 currentRoleIds 永远是空集合 ​

BuiltInVariableProvider:97-102:

java
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
Set<Long> roleIds = Set.of();
// 角色 id 集合通过 SubjectClosureService 的缓存比较权威,但这里为了不引入循环依赖,
// 先从 SecurityFrameworkUtils 提供的 LoginUser 取(S2a 里已经有完整闭包,后续可以替换)
vars.put("currentRoleIds", roleIds);

注释说「先从 LoginUser 取」,但代码一行都没取——loginUserId 算出来之后没有被用过,roleIds 直接是 Set.of()。全仓只有这一个 RuleVariableProvider 实现,没有别人覆盖它。

后果链(只在自定义条件树里能触发,十个内置模板没有一个引用它):

  1. /system/rule/template-meta 把 currentRoleIds 标成 collection=true 交给编辑器——UI 主动邀请你把它选给 IN / NOT_IN。RuleTemplateMetaRegistryTest:109 还断言了这三个集合变量正好是 {currentDeptAndChildren, currentRoleIds, currentPostIds},但断言的是元数据,不是 provide() 真的给出了值。
  2. resolveValue 拿到的是一个非 null 的空 Set,所以不算 unevaluable——fail-closed 那套保护完全不会启动。
  3. buildConditionSql:254-256 走空集合分支:IN → 1=0,NOT IN → 1=1。
用法实际效果方向
作为 ALLOW 的 IN 条件一行都看不到fail-closed,有人会来报
作为 ALLOW 的 NOT IN 条件整表可见🔴 提权,没人会来报
作为 DENY 的条件同上取反🔴 禁止令静默失效

修法要同时动两处:provide() 真的去取角色 id(注释已经指明 SubjectClosureService 是权威来源),并且在取不到时决定是返回 null(触发 unevaluable)还是保持空集合。只改前一半的话,循环依赖那个顾虑还在。

🔴 内置种子行是 built_in=0 ​

BuiltInRuleSeedRunner#run 只 set 了 name / template / description / status——从来没有 setBuiltIn(true)。全仓 grep setBuiltIn 在 main 代码里零命中,DDL 默认值是 b'0'。

而三处保护的判据全是 Boolean.TRUE.equals(getBuiltIn())(RuleAdminController:181、:233、:256)。所以:

一个只靠 Runner 建种子的部署,它的十条内置模板行既能被 /system/rule/delete 删掉,也能被 /system/rule/update 改坏,尽管 @Operation 文档和错误消息都承诺「系统内置规则(built_in=1)不能删除」。

现有库里 id 1–8 与 12 的 built_in 是 b'1',那是别的途径(手工或不在仓库里的迁移)写进去的,Runner 补不出这个值。同一个 Runner 的类注释还写着「把 8 个内置模板的描述行写进 sys_rule」,而 isBuiltIn() 对全部 10 个常量都返回 true(都以 BUILT_IN_ 开头,包括 CUSTOM_TREE 和 DEPT_IN)——数字也是过期的。

顺带一条:selectListByTemplate 走 @TableLogic,查不到软删行。所以软删一条内置种子之后,下次启动 Runner 会再插一条新的(id 不同、built_in=0)。

🔴 规则的 status 在判定侧完全不看 ​

全仓读 RuleDO.status 的地方只有两处:RuleAdminController:60(下拉只列启用的)和 RuleMapper:19(分页过滤条件)。DataRowFilterResolver 从头到尾没有读过它——resolve:241 就是裸的 ruleMapper.selectById(grant.getRuleId())。

于是「停用」的真实语义是从下拉里消失,不是「不再生效」。已经挂在授权行上的 rule_id 照旧参与求值。

错误消息给的正是那条不成立的建议

RULE_BUILT_IN_UNDELETABLE:「系统内置规则不能删除,可以将其停用后不再出现在下拉里」。

管理员照做之后:新授权选不到它了,但存量授权还在按它过滤。方向上是 fail-closed(限制没被解除),所以表现是「我明明停用了,怎么还是只能看本部门」——不报任何错。

要真的停用一条规则,得去授权页把引用它的授权行改掉或删掉。RULE_IN_USE 那条错误消息里的「请先解除引用」是唯一正确的路径。

🔴 uk_resource_slot 不含 deleted ​

见数据模型。这张表必须物理删除,SlotMappingMapper#deleteByIdPhysically 是唯一的正确出口。给它换成 deleteById(软删)会立刻复现「删掉的槽位名永远被占着」。

🔴 表名集合是内存态 ​

getTableNames() 返回的集合重启即空。任何绕过 DatasetResourceServiceImpl 直接写 sys_resource.dataset_table 的做法(手工 SQL、数据修复脚本)都不会让行级过滤生效,直到下次重启 Runner 把它灌回来。反过来,registerTable 只增不减——退役一个数据集之后,那张表还留在集合里,只是查不到资源、走第 5 步 pass()。

evaluate 的 catch 会吞掉一切 ​

evaluate:648-651 是 catch (Exception e) { log.warn(...); return null; }。返回 null 会走 unevaluable 分支(ALLOW 落恒假、DENY 落全拒),方向是安全的,但JSON 反序列化失败、模板解析失败、数据库连不上全都长得一样——只有一条 warn。排查时先捞这条日志。

template 在写入侧不校验 ​

RuleSaveReqVO.template 只有 @NotBlank,没有枚举校验。写错一个字符(BUILT_IN_OWNER_ONLYY)会:保存成功 → 下拉里能选到 → RuleTemplateEnum.parse() 返回 null → 三条分支全不进 → params 空 → 返回 null → 恒假。fail-closed,但同样只有一条 warn。

expression 里的变量名与真实变量名不一致 ​

RuleTemplateEnum.OWNER_ONLY.expression 写的是 {owner} = :currentUser,而 expandBuiltInTemplate 注入的是 :currentUserId。DEPT_ONLY 同理(:currentDept vs :currentDeptId)。expression 的字段注释明确写着「说明用,不直接用于 SQL 生成」,所以这不是 bug——但它会被 BuiltInRuleSeedRunner 抄进 sys_rule.description 展示给管理员,也会被人照着写自定义条件树。照抄就求不出值。

LIKE 的注释与实现不符 ​

ConditionOperatorEnum.LIKE 的注释写「LIKE(前缀匹配):自动加 %」,实现是 params.add("%" + resolvedValue + "%")——两侧都加,是包含匹配。label 字段写的是「包含」,与实现一致。以 label 和代码为准。

IN 空集合的两个方向 ​

buildConditionSql:254-256:

java
if (values.isEmpty()) {
    return op == ConditionOperatorEnum.IN ? "1=0" : "1=1";
}

IN () 恒假是对的。但 NOT IN () 恒真——一个空的排除名单等于不排除任何东西,这在语义上说得通,前提是那个集合本来就该空。currentRoleIds 那条之所以是提权,正是因为它「不该空却空了」。加新的集合变量时要问一句:它取不到值时,走 IN 和走 NOT IN 分别是什么后果。

@DataPermission(enable = false) 与 executeIgnore 不是可选的 ​

resolve() 上标了 @DataPermission(enable = false):「算权限这件事本身不该再被权限过滤一遍」。evaluateBpmParticipant:427 的注释指出这个标注会一路传导到查参与表那次查询,「否则『算权限』这件事本身又会触发一次行级过滤,直接递归」。

在线表单侧对应的是 FormRowFilter.executeIgnore(...),用 ThreadLocal 而不是方法参数:

需要忽略的是一整段调用链(比如导入的一行数据要经过查重、落域校验、写入多个环节),逐层传参的话中间漏传一层就是一个静默的绕过点。

executeIgnore 用 try-finally 恢复原值而不是直接置 false,因为调用可能嵌套。

加忽略开关要非常克制

FormRowFilter 类注释:「每加一处都是一个绕过点。判断标准是『这次查询的结果会不会流向用户』:不会(比如查重只返回布尔)才可以忽略。」

FormRowFilter.append 的返回值容易漏检 ​

append:147 返回 true 表示「调用方应当直接返回空结果」。但它同时也往 whereClauses 里塞了一条 1 = 0:

拼一个恒假条件而不是靠调用方记得检查返回值——漏检一处就是一个越权出口,而恒假条件在任何路径上都是安全的。

所以漏检的后果是「多跑一次注定为空的查询」,不是越权。这是有意的双保险,别把那条 1 = 0 当冗余删掉。

PermissionCommonApi 不可用时是 pass(),不是 denyAll() ​

FormRowFilter:96-106——这是全篇唯一一处「取不到就放行」:

真实部署(enlev-server 同时装配 system 与 infra)里不会走到这里。走到了说明权限模块没起来,此时行级权限是关着的——必须留下痕迹,否则就又变成一次「静默失效」。

用 AtomicBoolean 保证只 log.error 一次。对比 catch (Exception e) 那一支是 denyAll():Bean 不存在 = 装配问题(部署错误,放行 + 大声报错),调用抛异常 = 运行时问题(fail-closed)。两个方向不同是刻意的。

派生可见性的 IN 列表没有上限 ​

businessKeys / recordIds 直接展开成 IN (?,?,…)。超阈值只 log.error,不截断、不拒绝。日志里给了处理方向:「把参与表按 retain_until 归档,或改用子查询(需确认业务表与 bpm_instance_participant 在同一数据源)」。

SlotInferencer 的类注释有两处过期 ​

  • 开头写「推断 owner / dept / status 三个槽位」,实际 infer() 现在推四个(bizKey 从 TableBinding.mainPk 显式取,SlotInferencer:65-76)。
  • 「条件树求值时找不到槽位映射会跳过该条件并记告警(见 ConditionTreeEvaluator.resolveColumnName)」——两处都不对了:现在是标 unevaluable 让整条规则失效(不是跳过单个条件),方法也已改名成 resolveColumn。

PermissionSlotEnum.BIZ_KEY 的注释停在修复之前 ​

那段 🔴 注释详细讲了「S5c 做完派生可见性之后,bizKey 槽位映射从来没有被自动登记过——全库一条都没有」,以及 TODO #28。这个问题在 S7b 已经修了(SlotInferencer:65-76 从 mainPk 显式登记,NativeDatasetScanner:108 从注解默认值登记),注释里也提了一句 S7b,但整段的语气仍然是「现在是坏的」。读到它先去 sys_slot_mapping 查一眼实际有没有 bizKey 行,别直接下结论。

@TenantIgnore 但列里有 tenant_id ​

sys_rule 与 sys_slot_mapping 都是全局表,tenant_id 恒为 0,而 uk_resource_slot 把 tenant_id 放在了第一位。这与权限体系总览里那条「7 张表都有 tenant_id 列,但只有 4 张真的按它过滤」是同一件事。

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

测试 ​

文件用例数盯什么
ConditionTreeEvaluatorTest17白名单、unevaluable、限定符
ConditionTreeSqlTest5产出 SQL 的形状
DataRowFilterUnevaluableRuleTest6🔴 ALLOW 落恒假 / DENY 落全拒
DataRowFilterUnrestrictedGrantTest7🔴 「无规则的 ALLOW」贡献 1=1
DataRowFilterSuperAdminTest5超管短路 + 判定失败按非超管
DataRowFilterDerivedTest8派生可见性的四个恒假出口
DataRowFilterRecordShareTest8记录共享,与派生逐字对齐
RuleTemplateMetaRegistryTest7枚举 ↔ 注册表穷尽性、集合变量清单
SlotInferencerTest10显式声明优先、物理列全集、推不出来就不写
SlotMappingAdminControllerTest9写入侧核实列存在
FormRowFilterSqlTest(infra)10拼进 WHERE、JOIN 限定符

合计 92 个。

没有 RuleDataPermissionRule 的测试

MyBatis 通道那一层——inlineParams 的字面量转义、alwaysFalse()、解析失败落全拒、getTableNames() 的注册与重启——一个用例都没有。上面 92 个测的全是 resolve() 的产出,不是它被转成 Expression 之后的样子。内联那条路尤其值得补:它是唯一一处把参数拼回 SQL 字符串的地方。

未证实 ​

  • NativeDatasetRegisterRunner 与 DatasetResourceServiceImpl 的 register / reloadRegisteredTables 只读了调用点与 :415-437 那一段,完整登记流程没有逐行核(见权限体系总览的 Runner 表)。
  • ConditionNode 的字段模型没有逐行读,只从 cond() / group() 两个工厂方法反推出 type / slot / operator / value / logic / children。
  • FormDesignSqlBuilder 怎么把 tableQualifiers 算出来、JOIN 别名怎么定,没有读。
  • RecordOwnerResolver 只从 grep 结果知道它调 resolve(...) 取 bizKey 槽位的列名,用途没有展开。
  • SlotMappingAdminController 的 markColumnExists / validateColumnExists 只读了类注释与 grep 出的端点清单,实现没有逐行核。
  • 「本库 206 行槽位映射」「1192 行资源」「8 条内置种子」「种子最大 id 是 12」「178 个 DATASET 的 default_effect 全是 ALLOW」全部来自源码注释或 sql/mysql/ainb-init.sql 快照,是历史数字。GrantDecisionService:482-484 对这一类数字的态度可以直接搬过来:别把任何一个数字当常量用。
  • currentRoleIds 的提权路径是从代码推出来的(空集合 → NOT IN → 1=1),没有实测。要复现需要一条引用 :currentRoleIds 的自定义条件树规则 + 一个非超管账号。

相关页面 ​