授权与决策引擎
适用端:后端 | 类型:机制
定制功能
sys_grant + GrantDecisionEngine + 主体闭包 + 上下文维度,整套都是本项目自研。上游 yudao 的判定就一句 roleMenuMapper.selectListByUserIdAndMenuIds 判非空——没有 DENY、没有动作维度、没有继承、没有理由。
这一页讲一次鉴权到底怎么算出来的。它也是整套体系里唯一有 🔴 已知未修缺口的地方:塌缩回落已经实测打穿了 3 张已发布表单。那一节请逐字读。
它解决什么问题
引擎是纯函数:入参是 (主体集合, 资源链, 授权行, 动作, 上下文),不查库、不读缓存、不看当前用户。所有装配(查闭包、查链、查授权)都在 GrantDecisionService 里做完再喂进去。这是它能被单测覆盖到每个分支的原因。
代码在哪
后端路径前缀 enlev-module-system/src/main/java/cn/enlev/ainb/module/system/:
| 职责 | 文件 | 关键方法 |
|---|---|---|
@ss 实现 | framework/security/core/service/SecurityFrameworkServiceImpl.java | hasPermission / hasAnyPermissions |
| 超管短路 + 开关分流 | service/permission/PermissionServiceImpl.java | hasAnyPermissions:96、collapseEnabled:90、syncRoleMenuGrants:233、filterMenuDerived:468 |
| 引擎(纯函数) | framework/permission/core/decision/GrantDecisionEngine.java | explain:132、isAllowed:114、firstMatch:222、subjectMatches、Decision:54、DecisionReason:77 |
| 装配 + 缓存 + 塌缩 | service/permission/GrantDecisionService.java | hasPermission:416/446、lookupOf:172、chainsOf:294、resolveFieldModes:589、listAllowedMenuIds:783、listAllowedPermissions:936、listEffectiveTree:1145、resolveResourceChain:746 |
| 主体闭包 | service/permission/SubjectClosureServiceImpl.java | getSubjectKeysByUserId |
| 主体寻址 | framework/permission/core/subject/{SubjectKey, SubjectTypeEnum}.java | isRefBased:105、parse:116、ARRAYS:92 |
| 授权行 DO | dal/dataobject/permission/GrantDO.java | isActive()、isGlobal()、actionMatches() |
| 塌缩解析 | framework/permission/core/collapse/{PermissionStringResolver, CollapsedResourceLocator}.java | Tier(6 级)、identityOf、identityOfAll:99 |
| Controller | controller/admin/permission/GrantAdminController.java | 10 个端点 |
| 前端 | ainb-ui-admin-vue3/src/views/system/role/grant/、src/api/system/grant/index.ts | 见前端授权页开发 |
核心机制
① 超管短路在引擎之外
PermissionServiceImpl.hasAnyPermissions:96-105:
// 如果为空,说明已经有权限
if (ArrayUtil.isEmpty(permissions)) {
return true;
}
// 情况一:超管直接放行(新旧路径都保留这一条)
List<RoleDO> roles = getEnableUserRoleListByUserIdFromCache(userId);
if (roleService.hasAnySuperAdmin(convertSet(roles, RoleDO::getId))) {
return true;
}超管账号测不出任何权限缺陷
短路发生在进引擎之前,所以超管对一切资源恒为 true,与 sys_resource / sys_grant 里有什么完全无关。验证任何权限改动必须用非超管账号。
⚠️ TENANT_ADMIN 没有短路(只有 super_admin 有,RoleServiceImpl.java:240)。租户管理员走的是正常判定路径。
② 主体闭包
SubjectClosureServiceImpl.getSubjectKeysByUserId 把一个用户展开成 Set<SubjectKey>。七种主体类型(SubjectTypeEnum,枚举名直接落库 varchar,不要改名):
| 类型 | 寻址 | 备注 |
|---|---|---|
USER | ref_id = system_users.id | ⚠️ 用户不会在 sys_subject 里落行,只作为寻址串与成员关系的一端出现 |
ROLE | ref_id = system_role.id | — |
DEPT | ref_id = system_dept.id | 默认不作为授权主体,由 subject.dept-enabled 控制(yaml 里是 true) |
POST | ref_id = system_post.id | 由 subject.post-enabled 控制(默认 true) |
GROUP | ref_id = sys_subject.id 自身 | 没有独立业务表,成员关系直接落 sys_subject_member。过渡期内 bpm_user_group 仍然存在 |
SYSTEM | code 寻址,如 SYSTEM:bpm-engine | isRefBased() = false。触发器、监听器、引擎回写状态不是人的行为 |
ALL | 固定 code = all-users | isRefBased() = false。永远在每个登录用户的闭包里 |
ALL 存在的理由(SubjectTypeEnum:69-78)值得记住:
「不限制」在授权模型里必须是一条显式的授权,不能是「没有授权」。流程资源的
default_effect是 DENY,所以「这个流程谁都能发起」表达不出来——只能靠startUserIds两个范围都为空时的那条回落分支兜着。回落一摘,所有没配 START 授权的流程立刻变成谁都发不了。有了 ALL 主体,「不限制」就成了一条看得见、可审计、可在授权界面上撤销的授权行,而不是「缺少某行」这种没法展示也没法解释的状态。
所以给 ALL 配 DENY 等于对所有人关闭——这是刻意的,「先全关再逐个开」是常见的收紧手法。
parse() 返回 null 不抛异常,写入路径必须自己拦
SubjectTypeEnum.parse:116-126 对不认识的值返回 null。:19-25 解释了这个宽容度的边界:
那是给「读库存量数据」的路径用的(一行脏数据不该让整个权限求值炸掉)。但同一个宽容度用在写入路径上就是缺陷:存进去一个
ROLES(复数,最常见的笔误)或role(小写),授权行建得出来、列表里看得见、也不报错,而闭包永远算不出这个主体——这一行是死的。如果它是一条 DENY,配的人以为封住了,其实什么都没封。
GrantSaveReqVO#subjectType 上因此挂了 @InEnum。ARRAYS:92 与 parse 必须同源(两边都用 name()),否则会出现「校验放行、解析返回 null」的缝。
③ 资源祖先链:一个串可能对应多行
chainsOf:294 返回的是 List<List<ResourceDO>>——链的列表,不是单条链。物化路径查询:WHERE :myPath LIKE CONCAT(path, '%')。
hasPermission:456-461:
// 3. 资源链。⚠️ 同一个 permission 可能对应【多行】资源,必须每行各算一条链
List<List<ResourceDO>> chains = chainsOf(lookup.permission());
if (chains.isEmpty()) {
// 资源表里没有该 permission,严格模式下视为没有权限
return false;
}第 5 步是「任意一条链判定通过即通过」。:475-484 有一段必须逐字读的警告:
🔴 同一权限串对应多行资源的情形一直存在,但条数一直在降:这条注释最早写「76 个」,阶段 0 复测是 4 个串 / 8 行,塌缩落库后只剩 2 个串(
agent:usage:query、infra:job:update);含软删行则始终有 12 个串。76 是含软删、且早于几轮清理的历史口径。⚠️ 所以别把任何一个数字当常量用:这里的代码必须按「N 行」写,而不能因为「反正只剩两行」就退化成
get(0)——同一权限挂在多个菜单上是上游的常见做法,新增一个就会把缺陷带回来。
原实现只用 get(0) 建链,而清单侧 listAllowedPermissions 是遍历全部资源、命中即收——两者读的不是同一行,于是:把授权配在非 get(0) 的那行上,前端把按钮显示出来了,点下去后端 403。已用实验复现。
chainsOf 抽出来是为了缓存键
:289-293:hasPermission 与 resolveResourceChain 原本各自内联了一份完全相同的 computeIfAbsent。两份的坏处不是重复,而是缓存键:塌缩后两边都必须按「解析出的身份串」而不是原始串入缓存,漏改一处就会让同一个资源在缓存里存两份、且诊断页显示的链与鉴权用的链不同。
④⑤ 引擎判定:八个理由,顺序即优先级
GrantDecisionEngine.explain:132-217。先两个不回落的早退(:137-143):主体集合为空 → NO_SUBJECT;资源链为空 → EMPTY_CHAIN。没有身份 / 没有资源,就没有权限可言。
然后五个 filter 选出命中行(:167-173):
List<GrantDO> matched = grants.stream()
.filter(g -> g.isActive())
.filter(g -> chainIds.contains(g.getResourceId()))
.filter(g -> subjectMatches(g, subjects))
.filter(g -> g.actionMatches(action) || g.actionMatches(GrantActionEnum.WILDCARD.getCode()))
.filter(g -> g.isGlobal() || (contextId != null && contextId.equals(g.getContextResourceId())))
.toList();最后那个「上下文」过滤修的是一个反直觉缺陷
:152-166 记着:原来 matched 把【上下文限定的授权】也算进来,于是 matched 不为空 → 跳过了 default_effect 的回落;而后面 contextDeny/contextAllow 都被 contextId != null 挡住;最终落到 isGlobal && ALLOW → false → 判 DENY。
后果是:给某个审批节点配一条 ALLOW,反而让这个字段在其它所有地方变成不可见。实测表现是「发起流程时必填字段报『请填写』,而用户明明填了」——因为该字段在发起路径上被裁掉了。
正确语义:与当前上下文无关的授权,对本次判定什么都没说,应当完全不参与,从而让 default_effect 正常回落。
DecisionReason 八个取值,枚举声明顺序即优先级顺序:
| 理由 | 判决 | 含义 |
|---|---|---|
NO_SUBJECT | false | 主体集合为空。🔴 不回落 default_effect |
EMPTY_CHAIN | false | 资源链为空,无从判起 |
DEFAULT_EFFECT | 看列值 | 链上没有任何命中行,回落到最具体资源的 default_effect 列 |
GLOBAL_DENY | false | 全局 DENY(优先级最高的一票) |
CONTEXT_DENY | false | 上下文 DENY |
CONTEXT_ALLOW | true | 上下文 ALLOW |
GLOBAL_ALLOW | true | 全局 ALLOW(优先级最低的一票) |
NO_MATCHING_EFFECT | false | 命中了行,但没有任何一行的 effect 是 ALLOW 或 DENY(null / 脏值)。🔴 静默拒绝:既不是回落,也不是被谁否决,就是一路走到了最后。脏数据不该放大成放行,但也值得在诊断里看得见 |
⚠️ context_resource_id 列存在但当前阶段恒 NULL(S2b 才启用),所以实际执行路径是:全局 DENY > 全局 ALLOW。
四处一律用 findFirst 而不是 anyMatch
:189-190:判定结果完全一样,但顺手拿到了「是谁投的这一票」,权限地图靠它区分「本资源配置」与「继承自上级」。
Decision.sourceResource 的 javadoc(:49-51)特别提醒:它未必是被判定的那个资源——祖先链上的行同样参与判定,这正是「继承放行 / 上级否决」的来源;回落 default_effect 时是链上最具体的那个(chain.get(0))。
isAllowed 是 explain 的薄封装
isAllowed:114-120 全文一句 return explain(...).allowed()。Decision 的类注释(:36-44)说明了为什么必须这样:
权限地图(
listEffectiveTree)要展示「这项权限从哪来」,而鉴权只要一个 boolean。如果两边各算各的,就又多了一份会漂移的口径——本类所在的这套体系已经漂过两次了:buildResourceChain(沿 path 上溯、不过滤 status)与buildChainFromMap(沿 parentId 上溯、只含启用资源)在「停用祖先」场景分叉;GrantAdminController#listByResource与toRespVOs两处 expired 口径也不一致。所以这里把「判决」和「理由」焊死在同一段代码里,两者在构造上不可能不一致。
🔴 这是本项目权限代码的一条硬规则:不要在 isAllowed 里另写一套判定。
回落读的是列,不是枚举
:175-184:
if (matched.isEmpty()) {
// 🔴 读的是 sys_resource.default_effect 列,不是 ResourceTypeEnum#defaultEffect()。
// 那个枚举方法只在 ResourceAdminController#createResource 里当填充默认值用;
// 实测库里 PROCESS 类型有 18 行 default_effect=ALLOW、9 行 DENY,
// 而枚举说 PROCESS 该是 DENY —— 按 type 推就会算错那 18 个节点
ResourceDO self = resourceChain.get(0);
return Decision.ofDefault("ALLOW".equals(self.getDefaultEffect()), self);
}"ALLOW".equals(...) 这个写法意味着 null、空串、脏值一律等价于 DENY,不是第三态。所有比较 default_effect 的地方(ResourceCollapsePlanner.isStricter:610、DatasetResourceServiceImpl.tightenOnly:621)都必须与它逐字节一致。
🔴 塌缩回落打穿已发布表单
这是本体系唯一已知的、已实测的、根因未修的安全缺口。权威副本在 GrantDecisionService.lookupOf:200-278 的注释里,application.yaml:371-377 有一份摘要并指回它。下面按源码原话转述。
机制
塌缩的语义是「把编码在身份串里的动词挪进动作维度」:system:user:query 被翻译成 (身份 = system:user, 动作 = VIEW) 去查。判据是一行代码(:201):
boolean memberRowExists = !chainsOf(permission).isEmpty();:202-208 的已知缺口:
⚠️ 已知缺口(阶段 3 待修):这个布尔值分不清「成员行被塌缩退役」与「成员行被管理员从 UI 删掉」——两者在数据里都是「查不到」,都会走到塌缩。
后果是删掉一个按钮资源不再表示拒绝:塌缩前该串没有存活资源行,
hasPermission的chains.isEmpty()直接return false;塌缩后它被翻译成身份行 + 动作,而身份行(DATASET)的default_effect出厂是 ALLOW,链上又往往一条授权都没有,于是GrantDecisionEngine回落到 ALLOW——放行。
一句话版本:DATASET=ALLOW 是刻意设计(它不是访问控制点,控制点是它下面 6 个 DENY 的 ACTION 行);塌缩把 6 个 ACTION 成员行退役后,判定改查 DATASET 身份行,于是「刻意设计的非控制点」被当成了控制点。
严重度:必须分开说
源码 :222-229 专门写了一段严重度纪律,逐字照抄:
⚠️ 严重度要按实测说,别照抄「84 个权限点被越权放行」这种口径:那 12 张表单一张都没有发布过(
status=0、published_version=0,已逐张复核),运行时校验在权限门之后还要过「表单存在 / 已发布」两道,实测连超管拿到的都是FORM_DESIGN_RUNTIME_EXECUTE_FAIL与「表单未发布」。所以那 84 个串是潜伏的,不是当场可利用的——真正被观察到的是get-permission-info里多出 84 个幽灵权限串(前端v-hasPermi会照着放行按钮),以及「任何一张发布出去就立刻变成真越权」。把潜伏写成已发生,会让下一个人对这一类缺口的警戒心用错地方。
🔴 但「潜伏」只对那 12 张成立,对这个机制的整个群体不成立。同一形状(DATASET +
default_effect=ALLOW+menu_id为 NULL + 没有存活子行)清理后库里还有 157 个,而这 157 个整条链上一条活授权都没有(自身 / 父行infra:form-design-run/ 祖父行infra全为 0)。其中 154 个连infra_form_design行都不存在(潜伏),3 个是已发布的:test_leave、test_leave_audit、e2e_ld_1789517946540。
这 3 个已经实测打穿。主体是自定义角色「演示角色」下的非超管用户,在这 3 条链上零授权:
| 请求 | 结果 |
|---|---|
GET .../runtime/test_leave/config | code=0,返回 formConf + formFields(整张表单定义) |
POST .../runtime/test_leave/page | code=0,total=6(真实业务数据行) |
POST .../runtime/test_leave_audit/page | code=0,total=2 |
对照组证明这不是「一律放行」的假象:
- 库里没有资源行的 code(
no_such_form_zzz)→ 正确拒绝 - 本轮收窄成 DENY 的
device_manage→ 正确拒绝
塌缩前这 3 个请求是拒绝的(成员行不存在 ⟹ 直接 return false),所以这是本次改造引入的回归。FormDesignRuntimeController#checkReferencePermission 的 javadoc 里那句「hasPermission 对资源行不存在直接返回 false」描述的正是塌缩前的语义,如今已不成立——它是这条缺口的现成文档证据。
已做的处置
:215-220:
那 84 行连同另外 7 行「菜单已删、资源还在」的残留一起软删,同时把它们的 12 个身份锚点
default_effect由 ALLOW 收窄成 DENY。🔴 收窄不是可选的顺手清理,是删除的安全前提:只删成员行,本条机制会立刻把这 84 个串翻成放行。
那 157 个刻意没有一并收窄
:251-254:
它不是机械清理,而是有真实功能代价的决策:那 3 张已发布表单在资源路径上零授权,今天之所以有人能用,靠的就是这个 ALLOW 回落。一律收窄成 DENY 等于当场切断所有非超管对它们的访问,而「谁本该有权限」无法凭空推断——得先配授权。留给决策,不代做。
根因未修:三条路线都查过,一条都不能动
:256-278 逐字:
| 路线 | 为什么今天不能动 |
|---|---|
① 给 sys_resource 加一列标记「这一行是塌缩目标」 | 判据最干净,但本仓库 DDL 不可回滚、也没有草稿态,动表结构必须先取得明确授权 |
② 换判据:只在身份行 default_effect=DENY 时才塌缩 | 零 DDL,但两个代价:① 把「塌缩与否」和「默认放行与否」两个语义压在同一列上,管理员把某个数据集改回 ALLOW 就会让整族动词串集体失效;② 🔴 会让自校验失去意义——CollapseMigrationSelfCheckTest 是拿计划器去重放迁移前的冻结快照(_scratch/s2c_snapshot.json),改判据之后它校验的就是一份从未被执行过的计划,「评审过的那份」与「落库的那份」从此不是同一个东西,而迁移不可重跑。要走这条路必须同时重建快照基线,那等于把阶段 2c 重做一遍 |
| ③ 启动时扫一遍「身份行是 ALLOW 且成员行查不到」的形状并告警/自愈 | 看似最轻,但它必须能读到软删行才分得清「被塌缩退役」与「被管理员删掉」——而 ResourceMapper 里没有任何一个方法能做到:全部走 MyBatis-Plus 逻辑删除,连名字里带 IncludeDisabled 的那个过滤的也是 status 而不是 deleted。绕过去就得写裸 SQL,那是给这一层开一个从此没人守的口子 |
所以本轮的选择是:把数据摆回安全侧 + 把缺口和三条路线的阻塞点写在这里,而不是挑一条看起来能跑的路线先落地。
管理员再删一次,缺口就再出现一次
这不是「一次性清理完就好了」的问题。任何 DATASET 形状的资源(default_effect=ALLOW + 无存活子行 + menu_id NULL),只要它下面的动作资源被从 UI 删掉,就会翻成放行。改这块之前先读 lookupOf 那段注释。
塌缩的其它两处必须同源的判据
ResourceCollapsePlanner.blockedIdentities:282-286:被拦下的族在判定侧也要跟着不塌缩,否则成员串的授权已经不上提、而lookupOf照样解析成身份行——那一行上没有这条授权,权限凭空消失。两侧的判据必须同源:计划器看「成员行还在不在库里」,lookupOf也看同一件事,数据本身就是那面旗子。PermissionServiceImpl.filterMenuDerived:455-466:塌缩打开后第 4 步新建的授权落在身份行上,而身份行是PermissionTreeDeriver建出来的中间节点——实测 84 个这样的锚点行menu_id全是 NULL。若迁移不给它们挑一个menu_id(ResourceCollapsePlanner#chooseMenuId),套餐重算就不认它们是套餐派生的:第 3 步会跳过它们 ⟹ 套餐降级收不回权限,且不报错;第 4 步照样新建 ⟹ 下一次重算还是收不回,行越攒越多。所以「R1 必须挑出menu_id」与「本方法的判据是menu_id」是一对,改任何一侧都要看另一侧。
字段模式(列/字段级)
resolveFieldModes:589 返回 Map<字段名, FieldModeEnum>。三态:
| 取值 | 码 | 语义 |
|---|---|---|
EDITABLE | 2 | 可编辑 |
READONLY | 1 | 只读 |
HIDDEN | 0 | 隐藏 |
三层合并(授权行 field_mode → 资源 default_effect → 出厂值),最严的一层胜出。
裁剪是「查询后反射置 null」,不是「不查那一列」
PermissionFieldHelper 直接反射写字段。:243-255 解释了为什么不能用 PropertyDescriptor.getWriteMethod():
本项目根目录的
lombok.config里写着lombok.accessors.chain=true——所有 Lombok 生成的 setter 返回this而不是 void。java.beans.Introspector按 JavaBeans 规范只认返回 void 的 setter,于是它对本项目的每一个 VO 都报告「没有写方法」。用写方法实现的话,表现是:注解标了、判定跑了、日志干净、而字段一个都没被裁掉——又一次「配了、成功了、没生效、没提示」。这个坑是靠单测撞出来的(
PermissionFieldHelperTest),改回写方法实现会立刻复现。
直接写字段还顺带对手写 setter、Builder、无 setter 的 DTO 都成立。
⚠️ stripNonWritable 会把 HIDDEN 和 READONLY 都置 null。理由:前端的 disabled 不是安全边界,抓包照样能提交。
⚠️ FieldModeEnum 没有 VISIBLE 取值。解析不出来的行被静默忽略并回落到「可编辑」——一条脏数据的方向是放开而不是收紧。
在线表单那侧的裁剪是另一份实现(FormFieldCropper),三个出口 config/get/page 必须一起裁,只裁 config 的话抓包就能看到明文。详见表单运行时。
数据模型
sys_grant
完整列清单见 docs/.generated/tables-permission.md(脚本生成物,不是站点页面)。语义是「主体 × 资源 × 上下文 × 动作 × 效果」五元组:
| 列 | 说明 |
|---|---|
subject_type / subject_ref_id / subject_code | 主体。ref_id 给 isRefBased() 的类型用,code 给 SYSTEM / ALL 用 |
resource_id | 关联 sys_resource.id |
context_resource_id | NULL = 全局。目前仅 BPM 节点使用,S2b 启用 |
action | 13 个取值,含 WILDCARD("*") |
effect | ALLOW / DENY。DENY 一票否决优先于所有 ALLOW |
rule_id | 行过滤规则编号,NULL = 不过滤 |
field_mode | EDITABLE / READONLY / HIDDEN;NULL 使用默认 |
expire_time | NULL = 永不过期 |
4 个索引,没有唯一索引:idx_grant_decision(tenant_id, deleted, action, resource_id, subject_type, subject_ref_id)、idx_resource、idx_subject、idx_context。
DDL 的 action 列注释漏了 VIEW_ALL
列注释写「VIEW/CREATE/UPDATE/DELETE/EXPORT/IMPORT/PRINT/START/DESIGN/MANAGE/REFERENCE/* 等」——13 个取值里漏了 VIEW_ALL。以 GrantActionEnum 为准。
过期时间对 ALLOW 和 DENY 一视同仁
这是刻意的设计(一条过期的 DENY 不该继续封人),但后果是:给一条 DENY 设了到期时间,到期后它会静默消失。且 SQL 与内存两处比较一个用 > 一个用 >=,边界上差一秒。
sys_subject / sys_subject_member
sys_subject 只给 GROUP 类型用(用户组自己的实体)。USER 不在这里落行。
sys_subject_member 的方向反了就是静默提权
语义是「subject_id 这个组里有 member_type:member_ref_id 这个成员」。写反方向不会报错——闭包照样算得出来,只是算出来的是「谁属于我」而不是「我属于谁」,结果是静默提权。这张表没有任何数据库层约束能挡住方向写反。
sys_record_share
单条记录分享给某主体。字段是 dataset_code / record_id / subject_type / subject_ref_id / subject_code / expire_time / remark(RecordShareDO.java:46-96)——没有 slot 列。
uk_resource_slot 属于 sys_slot_mapping,不属于这张表
uk_resource_slot(tenant_id, resource_id, slot) 是 sys_slot_mapping 的唯一索引,且不含 deleted 字段(SlotMappingMapper.java:28 的 javadoc 明说)——所以槽位映射必须物理删除,软删会被墓碑永久占住。
RecordSharePolicyEnum 落在 sys_resource.share_policy:OWNER_ONLY(默认)/ ANY_VIEWER / DISABLED。parse() 解析不出返回 OWNER_ONLY(最保守档),不退化成 ANY_VIEWER。
RecordShareService#checkShareable:73-104 返回 String 原因(null = 可分享),6 种拒绝:① 未登录;② 数据集没登记;③ policy DISABLED;④ policy ANY_VIEWER 但 !canSeeRecord(...);⑤ resolveOwnerUserId 返回 null;⑥ owner != userId。
取消共享 ≠ 禁止查看
revoke 只删共享行。被取消的人若通过别的途径(是创建人 / 本部门 / 有角色授权)本来就看得到,照样看得到。要真挡住得用 sys_grant 的 DENY。
另:OWNER_ONLY 判不出归属人时拒绝分享(不是放行)——放行的话,一个没配好槽位的数据集会悄悄变成人人可分享,而这件事没有任何提示。ANY_VIEWER 意味着可二次转发(A→B,B→C,A 不知道)。DISABLED 只关掉本通道,管理员照样可用 sys_grant 授权。
share():120- 是 @Transactional 且幂等:同一 (数据集, 记录, 主体) 重复分享只更新到期时间与说明,不再插一行(否则「分享→取消→再分享」留一堆同义行,取消时删哪行成问题)。
接口清单
GrantAdminController(/system/grant),10 个端点:
| 方法 | 路径 | 权限串 |
|---|---|---|
| GET | /system/grant/my-field-modes | 🔴 不挂(返回的是调用者自己的权限) |
| GET | /system/grant/page | system:grant:query |
| GET | /system/grant/list-by-resource | system:grant:query |
| GET | /system/grant/list-by-subject | system:grant:query |
| GET | /system/grant/effective-tree | system:grant:query |
| POST | /system/grant/create | system:grant:create |
| POST | /system/grant/create-list | system:grant:create |
| PUT | /system/grant/update | system:grant:update |
| DELETE | /system/grant/delete | system:grant:delete |
| DELETE | /system/grant/delete-list | system:grant:delete |
RecordShareController(/system/record-share)4 个端点,全部刻意不挂 @PreAuthorize:POST /share、DELETE /revoke、GET /list-by-record(含已过期,便于续期)、GET /check-shareable。SHARE_FORBIDDEN_CODE = 1_002_031_000,用 exception0 带 service 给出的具体原因。
GrantAdminController 的读路径规则
类注释禁止把查询搬进 GrantAdminService(那会造出第三份鉴权口径),所以读路径直接打 Mapper。唯一例外是 /effective-tree(:84-93):它要的是「引擎判出来是什么」而不是「台账上写了什么」,委托给 GrantDecisionService 恰恰是复用那份权威口径。在 Controller 里自己拼一遍判定才是真正的第三份。
怎么扩展
加一种主体类型
🔴 9 步,顺序不可逆——先加枚举再改闭包,反过来会让存量行算不出来:
SubjectTypeEnum加取值,决定isRefBased()返回什么。GrantSaveReqVO#subjectType的@InEnum自动跟上(ARRAYS与parse同源)。SubjectClosureServiceImpl加展开分支。SubjectKey的寻址串格式。SubjectDisplayNameResolver加展示名解析。- 前端
SUBJECT_TYPE_OPTIONS加一项。⚠️ 它只用于展示,POST 请求曾经漏传过subjectType,结果列表里那些行渲染成裸的POST。 - 前端主体选择器(每种类型的选择 UI 不同)。
- DDL 的
subject_type列注释同步。 @InEnum的提示语会自动列出全部合法值——检查一遍读得通。
加一个动作
🔴 7 步,最容易漏的是第 3 步:
GrantActionEnum加取值。- DDL 的
action列注释同步(它已经漏了VIEW_ALL)。 - 🔴
VERB_TO_ACTION加映射。漏一项会让权限串排序错乱——实测 39 行错分,档 C 从 35 涨到 153。 - 前端
GRANT_ACTION_OPTIONS(views/system/resource/constants.ts)加一项。⚠️ 那份表与后端枚举没有任何自动对齐,只有一份手抄的黄金副本在constants.spec.ts,外加GrantActionHelp.vue里硬编码的说明文字。三处都要改。 - 判断它是否
effective:判据是「后端会不会把它当action参数传给isAllowed」。 - 检查
DESIGN ⊂ MANAGE这类真子集关系——本项目禁止动作之间有包含关系,否则「授了 MANAGE 算不算授了 DESIGN」会成为一个没有答案的问题。 - 如果它对应一个 HTTP 端点,去 Controller 加
@PreAuthorize或编程式校验。
让一个新表参与判定
- 原生模块:DO 上标
@PermissionDataset(value = "...", name = "..."),NativeDatasetRegisterRunner(@Order 6) 启动期自动登记。全仓目前只有DeptDO.java:19与AdminUserDO.java:23两个。 - 在线表单:发布时
DatasetResourceServiceImpl.register:88五步自动派生。 - 🔴 两者的
@Order都必须排在DatasetTableRegisterRunner(5) 之后,否则表名集合会被重建时冲掉。 - 要行级过滤还得配
sys_rule并让sys_grant.rule_id指过去。详见行过滤规则。
坑与注意
- 🔴 塌缩回落已打穿 3 张已发布表单,根因未修,管理员再删一次就再出现一次。见上文专节。
- 🔴 超管短路在引擎之外,超管账号测不出任何权限缺陷。
- 🔴
TENANT_ADMIN没有短路,只有super_admin有。 - 🔴 一个权限串可能对应多行资源,代码必须按「N 行」写,不能退化成
get(0)。 - 🔴
isAllowed不许另写一套判定,它是explain的薄封装,这是构造上的保证。 - 🔴
PermissionFieldHelper.fields()不许用getWriteMethod(),lombok.accessors.chain=true会让Introspector对每一个 VO 都报告「没有写方法」。 - 🔴
filterMenuDerived的判据是sys_resource.menu_id,与塌缩的chooseMenuId是一对,改一侧要看另一侧。实测事故:一次在线表单发布触发套餐重算,把超管在system:dept/system:user上的「全部数据」授权删了,于是组织管理只显示 1 个部门、用户管理只显示 1 个用户,且不报任何错。反方向更糟:普通角色唯一的行级授权被删后,DataRowFilterResolver查不到任何命中授权 → 返回pass()→ 整表不过滤,权限凭空放大。 - 🔴
PermissionServiceImpl:112的注释已过期:写着「grant-enabled = false(默认)→ 走旧路径」,而 yaml 里它早就是true。 @DataPermission(enable = false)/executeIgnore不是可选的:算权限这件事本身不该再被权限过滤一遍,否则求值过程触发新的求值。@Cacheable必须配readOnly=true+executeIgnore,理由同上。resourceChainCache没有 TTL,改了资源树要显式失效。- 有效权限树刻意不加
@Cacheable,有 5 个显式失效点。 - 🔴 1900 个元素的 IN 列表实测耗时 36 秒。批量查授权必须用
selectListActiveBySubjects,不要拼大 IN。 buildSubjectWrapper的 null 与空 wrapper 语义不同,传错会查出全表。syncRoleMenuGrants必须比较(resource_id, action)对,只比resource_id会漏掉动作维度的变化。FieldModeEnum没有VISIBLE,解析不出的行被静默忽略并回落到「可编辑」。- 两套
isSuperAdmin实现,刻意不同(DataRowFilterResolver:341与PermissionServiceImpl),别「统一」它们。 - 裁剪切面出异常时返回未裁剪的结果,是刻意的(宁可多显示也不让接口 500)——但这也意味着异常路径下字段权限不生效。
listAllowedMenuIdsInternal的塌缩分支判据是「任一动作」而不是 VIEW,与hasPermission的默认动作不同。assembleTree:1463必须自顶向下 + 带 visited 集合,否则环形数据会让 Jackson 序列化StackOverflowError。completeMenuAncestors:886必须用system_menu的父链,不能用资源树:466 个菜单没有 permission。实测事故是「733 个权限但菜单是空的」。
→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。
测试
| 测试 | 守什么 |
|---|---|
GrantDecisionEngineTest | 八步判定、优先级、两个早退、回落读列 |
GrantDecisionServiceTest:224 | 塌缩翻译、多链、缓存键 |
CollapseMigrationSelfCheckTest | 🔴 重放冻结快照,5/5;hasPermission 的 617 个差异全部落在声明白名单内(464 个收紧来自 7 行 default_effect 翻转,153 个放开来自 57 个裸身份串) |
PermissionStringResolverTest | 6 级 Tier |
PermissionFieldHelperTest | 🔴 改回写方法实现会立刻复现「一个字段都没裁掉」 |
RecordShareServiceTest / DataRowFilterRecordShareTest | 6 种拒绝原因 |
FormDesignUnpublishTest(infra) | menu_id 置 NULL |
全部纯 Mockito,无 DB 集成测试。
没有端到端回归守着那条组合路径
「动词成员行缺失 + DATASET ALLOW → 运行时放行」这条组合路径没有端到端回归测试。回落机制本身有单测,但「删掉按钮资源 ⟹ 表单被打穿」这个跨模块的因果链没有任何自动化守卫。上面那 3 张表单是手工实测发现的。
那 153 个「放开」别读成无害
yaml 的注释专门补了一句:它只说明裸身份串没有生产代码在比;而 FormDesignRuntimeController#checkPermission 比的是带动词的串,塌缩后会被翻译成同一个身份行去查。自校验枚举不到这批串(它只从 system_menu.permission 取,而这些锚点 menu_id 为 NULL)。
未证实
- 上面那 3 张被打穿的表单是历史实测(记在源码注释里),本轮没有复现。库里它们现在是什么状态没有查。
- 所有数字(157 / 154 / 3 / 84 / 12 / 7 / 617 / 464 / 153 / 57 / 39 / 36 秒 / 1900 / 76 / 94 / 85 / 119 / 163 / 466 / 733)全部来自源码注释,是历史快照。
GrantDecisionService:482-484专门警告「别把任何一个数字当常量用」。 SubjectClosureServiceImpl只读到「七种类型 +ALL恒在闭包里 +dept-ancestor-enabled」这一层,展开算法(怎么处理嵌套组、怎么防环)没有逐行核。PermissionStringResolver.Tier的 6 级只从调用点反推了语义,没有逐级读判据。resolveResourceChain:746只从Decision的类注释知道它「返回chains.get(0)、用基于 path 且不过滤 status 的buildResourceChain」,这是诊断页与真实判定的三处口径漂移之一,另两处是「reason字符串单独算」与「那条路径没有超管短路」。三处都没有逐一验证。_scratch/s2c_snapshot.json未读。listEffectiveTree:1145、listAllowedPermissions:936、listAllowedMenuIds:783只读到签名与关键注释。
