行过滤规则
适用端:后端 | 类型:机制
权限三层里的第二层:功能权限回答「能不能进这个页面」,行级权限回答「进去之后能看到哪几行」。配置只有两张表——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 个) |
| 变量 SPI | framework/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 |
| 拼进 WHERE | framework/formdesign/core/sql/FormDesignSqlBuilder |
DTO 放在 enlev-framework/enlev-common/.../biz/system/permission/dto/DataRowFilterRespDTO——infra 不依赖 system,只能靠 common 里的契约类通信。
核心机制
判定链:resolve() 的八个出口
DataRowFilterResolver#resolve:164。按顺序,每一个出口的方向都是刻意选的:
| # | 条件 | 返回 | 为什么是这个方向 |
|---|---|---|---|
| 1 | tableName 空白 | pass() | 无从过滤 |
| 2 | userId == null | pass() | 定时任务、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:
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,四种分支:
| allow | deny | 产出 |
|---|---|---|
| 空 | 空 | pass()——🔴 整表不过滤 |
| 空 | 有 | NOT (denySql) |
| 有 | 空 | (allowSql) |
| 有 | 有 | (allowSql) AND NOT (denySql) |
第一行就是全篇最贵的那个陷阱:任何让 allow 变空而 deny 也为空的改动,都会把「配了规则」变成「不过滤」。下面三处 denyAll / 恒假处置全部是为了不让它落到第一行。
「求不出来」必须能被调用方看见
ConditionTreeEvaluator 的 SqlFragment 带三个字段,其中 unevaluable 是后加的:
早先的实现是:某个条件求不出来就返回空串,
buildGroupSql把它从 parts 里滤掉。看着无害,实际是个提权缺陷——一棵AND(owner, dept)的树,若 dept 槽位没配映射,产出就成了(owner = ?),比管理员配的那条规则宽;只有整棵树全求不出来时才会产出空片段。也就是说「配错一半」比「全配错」放得更开,且全程不报错。
现在的口径是:只要有一个条件求不出来,整条规则就算求不出来。调用方按效果分别处置(resolve:255-283):
| 效果 | 求不出来时 | 理由 |
|---|---|---|
ALLOW | allow.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 类注释列的三道,加上两道额外的:
- 操作符白名单——SQL 里的操作符来自
ConditionOperatorEnum.sqlOp,不来自输入。parse()返回 null ⟹unevaluable。 - 列名白名单——真实列名只从
SlotMappingDO.columnName取,ConditionNode.slot是语义名。取到之后还要再过一次IDENTIFIER_REGEX = "[a-zA-Z_][a-zA-Z0-9_]*":「列名即便来自 DB 里的可审计配置也再过一道——数据污染时宁可让规则失效,也不能把它拼进 SQL」。 - 参数化——值要么是变量引用(解析后的 Java 对象),要么是字面量(
?占位)。
额外的两道:
- 写入口核实列真的存在——
SlotMappingAdminController#validateColumnExists查information_schema,表不在或列不在直接拒。理由:「『配错了』和『没配』在这里长得一模一样:都是条件求不出来、都是恒假、都不报错」。 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
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_ 前缀 |
params | MySQL JSON 列。空串不是合法 JSON 文档,直接写会抛 Data truncation: Invalid JSON text |
description | 内置种子里存的是模板表达式({owner} = :currentUser),由 BuiltInRuleSeedRunner 从 RuleTemplateEnum.expression 抄来 |
status | 0 启用 / 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 注释):
- 用户删掉一个槽位(软删,行还在)
- 想用同样的 slot 名重建——
selectByResourceIdAndSlot被@TableLogic自动加了deleted=0,查不到那条软删行,于是判定「不重复」放行- 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-list | system:rule:query | 只返回 status=0 的,授权配置页下拉用 |
| GET | /list | system:rule:query | 全部(含停用) |
| GET | /page | system:rule:query | 分页,支持 name / template / status / builtIn / createTime |
| GET | /get | system:rule:query | 单条 |
| GET | /template-meta | system:rule:query | 模板 + 槽位 + 操作符 + 变量四份词表,「供规则编辑器动态渲染,避免前端硬编码与后端枚举漂移」 |
| POST | /create | system:rule:create | 强制 setId(null) |
| PUT | /update | system:rule:update | 内置规则拒改(RULE_BUILT_IN_UNEDITABLE) |
| DELETE | /delete | system:rule:delete | 内置拒删 + 被引用拒删 |
| DELETE | /delete-list | system: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 | /list | system:slot-mapping:query | 全量。存在的唯一理由是让批量授权能在提交之前算出槽位覆盖度 |
| GET | /list-by-resource | system:slot-mapping:query | 逐行回填 columnExists,把存量坏行标出来 |
| POST | /create | system:slot-mapping:create | 保存时核实表与列真的存在 |
| PUT | /update | system:slot-mapping:update | 同上 |
| GET | /options | system:slot-mapping:query | 编辑器候选项 |
| GET | /table-columns | system:slot-mapping:query | 读 information_schema 取列 |
| DELETE | /delete | system: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 做穷尽性校验,加了枚举不登记注册表会直接测试失败。
RuleTemplateEnum加常量。code 用BUILT_IN_前缀(isBuiltIn()就是判这个前缀,而它决定BuiltInRuleSeedRunner会不会给它建种子行)。expression只作说明用,不参与 SQL 生成。- 决定它属于哪一类:
isDerived()(expression以DERIVED:开头)走派生通道、isCustomTree()走params.conditionTree,两者都不是就走expandBuiltInTemplate。 - 在
expandBuiltInTemplate的switch里加一支,用cond()/group()构建节点。槽位名只能用PermissionSlotEnum里已有的,否则要求目标资源上配了这个槽位的映射。 - 若要读
params:在RuleTemplateMetaRegistry加一个PARAM_*常量,两边共用——这是防漂移的唯一手段。 - 在
RuleTemplateMetaRegistry的static块里登记Meta:分组、给业务人员看的白话说明、requiredSlots、paramSpecs。 paramSpecs的kind决定前端渲染哪个子编辑器(STATUS_LIST/DEPT_MULTI/SLOT/TEXT/CONDITION_TREE),valueType决定前端序列化时按 number 还是 string。新 kind 要在前端加对应组件,否则编辑器渲染不出来。- 跑
RuleTemplateMetaRegistryTest。 - 库里已有部署要靠
BuiltInRuleSeedRunner补种子行——它按template判重、幂等。⚠️ 见坑与注意,补出来的行不受删除保护。
requiredSlots 只是提示,不是约束
RuleTemplateMetaRegistry.Meta#requiredSlots 的注释写着「为空表示『取决于参数』(如 FIELD_EQUALS 的槽位由 field 参数决定)」。它给前端做「这个模板需要哪些槽位」的提示与覆盖度核对,运行时不校验。真正决定成败的是 sys_slot_mapping 里有没有那一行。
加一个语义槽位
PermissionSlotEnum加常量,给出slot/description/candidates。candidates宁缺毋滥——只收本项目建表脚本里真实出现过的列名。- 想让它对在线表单自动推断生效:
SlotInferencer的兜底循环会遍历values(),加了就自动参与;但如果它像owner/bizKey那样有显式来源(AutoFillConfig/TableBinding),要在infer()里单独写一段,优先级高于猜。 - 想让它对原生表生效:
@PermissionDataset加一个属性,NativeDatasetScanner里putSlot(...)。 - 存量数据集不会自动补——要么重新发布表单,要么在槽位映射页手工加。
加一个变量
实现 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:
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
Set<Long> roleIds = Set.of();
// 角色 id 集合通过 SubjectClosureService 的缓存比较权威,但这里为了不引入循环依赖,
// 先从 SecurityFrameworkUtils 提供的 LoginUser 取(S2a 里已经有完整闭包,后续可以替换)
vars.put("currentRoleIds", roleIds);注释说「先从 LoginUser 取」,但代码一行都没取——loginUserId 算出来之后没有被用过,roleIds 直接是 Set.of()。全仓只有这一个 RuleVariableProvider 实现,没有别人覆盖它。
后果链(只在自定义条件树里能触发,十个内置模板没有一个引用它):
/system/rule/template-meta把currentRoleIds标成collection=true交给编辑器——UI 主动邀请你把它选给IN/NOT_IN。RuleTemplateMetaRegistryTest:109还断言了这三个集合变量正好是{currentDeptAndChildren, currentRoleIds, currentPostIds},但断言的是元数据,不是provide()真的给出了值。resolveValue拿到的是一个非 null 的空 Set,所以不算unevaluable——fail-closed 那套保护完全不会启动。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:
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 张真的按它过滤」是同一件事。
→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。
测试
| 文件 | 用例数 | 盯什么 |
|---|---|---|
ConditionTreeEvaluatorTest | 17 | 白名单、unevaluable、限定符 |
ConditionTreeSqlTest | 5 | 产出 SQL 的形状 |
DataRowFilterUnevaluableRuleTest | 6 | 🔴 ALLOW 落恒假 / DENY 落全拒 |
DataRowFilterUnrestrictedGrantTest | 7 | 🔴 「无规则的 ALLOW」贡献 1=1 |
DataRowFilterSuperAdminTest | 5 | 超管短路 + 判定失败按非超管 |
DataRowFilterDerivedTest | 8 | 派生可见性的四个恒假出口 |
DataRowFilterRecordShareTest | 8 | 记录共享,与派生逐字对齐 |
RuleTemplateMetaRegistryTest | 7 | 枚举 ↔ 注册表穷尽性、集合变量清单 |
SlotInferencerTest | 10 | 显式声明优先、物理列全集、推不出来就不写 |
SlotMappingAdminControllerTest | 9 | 写入侧核实列存在 |
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的自定义条件树规则 + 一个非超管账号。
