Skip to content

授权与决策引擎 ​

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

定制功能

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.javahasPermission / hasAnyPermissions
超管短路 + 开关分流service/permission/PermissionServiceImpl.javahasAnyPermissions:96、collapseEnabled:90、syncRoleMenuGrants:233、filterMenuDerived:468
引擎(纯函数)framework/permission/core/decision/GrantDecisionEngine.javaexplain:132、isAllowed:114、firstMatch:222、subjectMatches、Decision:54、DecisionReason:77
装配 + 缓存 + 塌缩service/permission/GrantDecisionService.javahasPermission:416/446、lookupOf:172、chainsOf:294、resolveFieldModes:589、listAllowedMenuIds:783、listAllowedPermissions:936、listEffectiveTree:1145、resolveResourceChain:746
主体闭包service/permission/SubjectClosureServiceImpl.javagetSubjectKeysByUserId
主体寻址framework/permission/core/subject/{SubjectKey, SubjectTypeEnum}.javaisRefBased:105、parse:116、ARRAYS:92
授权行 DOdal/dataobject/permission/GrantDO.javaisActive()、isGlobal()、actionMatches()
塌缩解析framework/permission/core/collapse/{PermissionStringResolver, CollapsedResourceLocator}.javaTier(6 级)、identityOf、identityOfAll:99
Controllercontroller/admin/permission/GrantAdminController.java10 个端点
前端ainb-ui-admin-vue3/src/views/system/role/grant/、src/api/system/grant/index.ts见前端授权页开发

核心机制 ​

① 超管短路在引擎之外 ​

PermissionServiceImpl.hasAnyPermissions:96-105:

java
// 如果为空,说明已经有权限
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,不要改名):

类型寻址备注
USERref_id = system_users.id⚠️ 用户不会在 sys_subject 里落行,只作为寻址串与成员关系的一端出现
ROLEref_id = system_role.id—
DEPTref_id = system_dept.id默认不作为授权主体,由 subject.dept-enabled 控制(yaml 里是 true)
POSTref_id = system_post.id由 subject.post-enabled 控制(默认 true)
GROUPref_id = sys_subject.id 自身没有独立业务表,成员关系直接落 sys_subject_member。过渡期内 bpm_user_group 仍然存在
SYSTEMcode 寻址,如 SYSTEM:bpm-engineisRefBased() = false。触发器、监听器、引擎回写状态不是人的行为
ALL固定 code = all-usersisRefBased() = 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:

java
// 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):

java
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_SUBJECTfalse主体集合为空。🔴 不回落 default_effect
EMPTY_CHAINfalse资源链为空,无从判起
DEFAULT_EFFECT看列值链上没有任何命中行,回落到最具体资源的 default_effect 列
GLOBAL_DENYfalse全局 DENY(优先级最高的一票)
CONTEXT_DENYfalse上下文 DENY
CONTEXT_ALLOWtrue上下文 ALLOW
GLOBAL_ALLOWtrue全局 ALLOW(优先级最低的一票)
NO_MATCHING_EFFECTfalse命中了行,但没有任何一行的 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:

java
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)都必须与它逐字节一致。

详见权限资源 · default_effect。

🔴 塌缩回落打穿已发布表单 ​

这是本体系唯一已知的、已实测的、根因未修的安全缺口。权威副本在 GrantDecisionService.lookupOf:200-278 的注释里,application.yaml:371-377 有一份摘要并指回它。下面按源码原话转述。

机制 ​

塌缩的语义是「把编码在身份串里的动词挪进动作维度」:system:user:query 被翻译成 (身份 = system:user, 动作 = VIEW) 去查。判据是一行代码(:201):

java
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/configcode=0,返回 formConf + formFields(整张表单定义)
POST .../runtime/test_leave/pagecode=0,total=6(真实业务数据行)
POST .../runtime/test_leave_audit/pagecode=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>。三态:

取值码语义
EDITABLE2可编辑
READONLY1只读
HIDDEN0隐藏

三层合并(授权行 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_idNULL = 全局。目前仅 BPM 节点使用,S2b 启用
action13 个取值,含 WILDCARD("*")
effectALLOW / DENY。DENY 一票否决优先于所有 ALLOW
rule_id行过滤规则编号,NULL = 不过滤
field_modeEDITABLE / READONLY / HIDDEN;NULL 使用默认
expire_timeNULL = 永不过期

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/pagesystem:grant:query
GET/system/grant/list-by-resourcesystem:grant:query
GET/system/grant/list-by-subjectsystem:grant:query
GET/system/grant/effective-treesystem:grant:query
POST/system/grant/createsystem:grant:create
POST/system/grant/create-listsystem:grant:create
PUT/system/grant/updatesystem:grant:update
DELETE/system/grant/deletesystem:grant:delete
DELETE/system/grant/delete-listsystem: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 步,顺序不可逆——先加枚举再改闭包,反过来会让存量行算不出来:

  1. SubjectTypeEnum 加取值,决定 isRefBased() 返回什么。
  2. GrantSaveReqVO#subjectType 的 @InEnum 自动跟上(ARRAYS 与 parse 同源)。
  3. SubjectClosureServiceImpl 加展开分支。
  4. SubjectKey 的寻址串格式。
  5. SubjectDisplayNameResolver 加展示名解析。
  6. 前端 SUBJECT_TYPE_OPTIONS 加一项。⚠️ 它只用于展示,POST 请求曾经漏传过 subjectType,结果列表里那些行渲染成裸的 POST。
  7. 前端主体选择器(每种类型的选择 UI 不同)。
  8. DDL 的 subject_type 列注释同步。
  9. @InEnum 的提示语会自动列出全部合法值——检查一遍读得通。

加一个动作 ​

🔴 7 步,最容易漏的是第 3 步:

  1. GrantActionEnum 加取值。
  2. DDL 的 action 列注释同步(它已经漏了 VIEW_ALL)。
  3. 🔴 VERB_TO_ACTION 加映射。漏一项会让权限串排序错乱——实测 39 行错分,档 C 从 35 涨到 153。
  4. 前端 GRANT_ACTION_OPTIONS(views/system/resource/constants.ts)加一项。⚠️ 那份表与后端枚举没有任何自动对齐,只有一份手抄的黄金副本在 constants.spec.ts,外加 GrantActionHelp.vue 里硬编码的说明文字。三处都要改。
  5. 判断它是否 effective:判据是「后端会不会把它当 action 参数传给 isAllowed」。
  6. 检查 DESIGN ⊂ MANAGE 这类真子集关系——本项目禁止动作之间有包含关系,否则「授了 MANAGE 算不算授了 DESIGN」会成为一个没有答案的问题。
  7. 如果它对应一个 HTTP 端点,去 Controller 加 @PreAuthorize 或编程式校验。

让一个新表参与判定 ​

  1. 原生模块:DO 上标 @PermissionDataset(value = "...", name = "..."),NativeDatasetRegisterRunner(@Order 6) 启动期自动登记。全仓目前只有 DeptDO.java:19 与 AdminUserDO.java:23 两个。
  2. 在线表单:发布时 DatasetResourceServiceImpl.register:88 五步自动派生。
  3. 🔴 两者的 @Order 都必须排在 DatasetTableRegisterRunner(5) 之后,否则表名集合会被重建时冲掉。
  4. 要行级过滤还得配 sys_rule 并让 sys_grant.rule_id 指过去。详见行过滤规则。

坑与注意 ​

  1. 🔴 塌缩回落已打穿 3 张已发布表单,根因未修,管理员再删一次就再出现一次。见上文专节。
  2. 🔴 超管短路在引擎之外,超管账号测不出任何权限缺陷。
  3. 🔴 TENANT_ADMIN 没有短路,只有 super_admin 有。
  4. 🔴 一个权限串可能对应多行资源,代码必须按「N 行」写,不能退化成 get(0)。
  5. 🔴 isAllowed 不许另写一套判定,它是 explain 的薄封装,这是构造上的保证。
  6. 🔴 PermissionFieldHelper.fields() 不许用 getWriteMethod(),lombok.accessors.chain=true 会让 Introspector 对每一个 VO 都报告「没有写方法」。
  7. 🔴 filterMenuDerived 的判据是 sys_resource.menu_id,与塌缩的 chooseMenuId 是一对,改一侧要看另一侧。实测事故:一次在线表单发布触发套餐重算,把超管在 system:dept / system:user 上的「全部数据」授权删了,于是组织管理只显示 1 个部门、用户管理只显示 1 个用户,且不报任何错。反方向更糟:普通角色唯一的行级授权被删后,DataRowFilterResolver 查不到任何命中授权 → 返回 pass() → 整表不过滤,权限凭空放大。
  8. 🔴 PermissionServiceImpl:112 的注释已过期:写着「grant-enabled = false(默认)→ 走旧路径」,而 yaml 里它早就是 true。
  9. @DataPermission(enable = false) / executeIgnore 不是可选的:算权限这件事本身不该再被权限过滤一遍,否则求值过程触发新的求值。
  10. @Cacheable 必须配 readOnly=true + executeIgnore,理由同上。
  11. resourceChainCache 没有 TTL,改了资源树要显式失效。
  12. 有效权限树刻意不加 @Cacheable,有 5 个显式失效点。
  13. 🔴 1900 个元素的 IN 列表实测耗时 36 秒。批量查授权必须用 selectListActiveBySubjects,不要拼大 IN。
  14. buildSubjectWrapper 的 null 与空 wrapper 语义不同,传错会查出全表。
  15. syncRoleMenuGrants 必须比较 (resource_id, action) 对,只比 resource_id 会漏掉动作维度的变化。
  16. FieldModeEnum 没有 VISIBLE,解析不出的行被静默忽略并回落到「可编辑」。
  17. 两套 isSuperAdmin 实现,刻意不同(DataRowFilterResolver:341 与 PermissionServiceImpl),别「统一」它们。
  18. 裁剪切面出异常时返回未裁剪的结果,是刻意的(宁可多显示也不让接口 500)——但这也意味着异常路径下字段权限不生效。
  19. listAllowedMenuIdsInternal 的塌缩分支判据是「任一动作」而不是 VIEW,与 hasPermission 的默认动作不同。
  20. assembleTree:1463 必须自顶向下 + 带 visited 集合,否则环形数据会让 Jackson 序列化 StackOverflowError。
  21. completeMenuAncestors:886 必须用 system_menu 的父链,不能用资源树:466 个菜单没有 permission。实测事故是「733 个权限但菜单是空的」。

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

测试 ​

测试守什么
GrantDecisionEngineTest八步判定、优先级、两个早退、回落读列
GrantDecisionServiceTest:224塌缩翻译、多链、缓存键
CollapseMigrationSelfCheckTest🔴 重放冻结快照,5/5;hasPermission 的 617 个差异全部落在声明白名单内(464 个收紧来自 7 行 default_effect 翻转,153 个放开来自 57 个裸身份串)
PermissionStringResolverTest6 级 Tier
PermissionFieldHelperTest🔴 改回写方法实现会立刻复现「一个字段都没裁掉」
RecordShareServiceTest / DataRowFilterRecordShareTest6 种拒绝原因
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 只读到签名与关键注释。

相关页面 ​