权限
为什么 AuraBoot 的权限是分层而非堆叠
大多数应用框架只提供一种权限模型。有的选择角色,有的选择访问控制列表,有的选择属性谓词。每一种模型都足以覆盖现实需求的一个切片,却又不足以覆盖整体。
一条真实的企业规则听起来是这样的:
"销售经理可以看到所属组织分支下全部商机,但只能编辑自己创建、金额超过 50,000 美元的那些;除非属于财务角色,否则永远不应看到折扣字段。"
这一句话同时触及功能级访问、记录级可见性、组织层级、属性级条件、字段级遮罩。没有任何单一权限范式能覆盖它。如果框架只有角色,这条规则就会埋进处理器代码;如果只有属性策略,每次列表查询都变成一次表达式求值过程,在审计时人都读不懂。
AuraBoot 通过在同一个评估契约下分层组合五种独立权限范式来解决这件事。每一层只回答一个问题。契约决定提问的顺序与答案如何合并。五层的主体统一 —— 是租户成员(tenant member),不是裸用户。
本文逐层解释,讲清评估顺序、权限编码命名规范,并以一个企业场景串完整链路。
五层概览
| 层 | 范式 | 它回答的问题 | 示例 |
|---|---|---|---|
| L1 — RBAC | 角色 | 该成员有没有调用这个功能的资格? | sales_manager 角色授予 model.crm_opportunity.update。 |
| L2 — ReBAC | 关系 | 这条具体记录是否被显式共享给了该成员? | 商机所有者将其分享给同事用于读和编辑。 |
| L3 — 组织数据范围 | 组织层级 | 按组织树位置,该成员被允许看到哪些记录? | 大区经理看本分支与所有下级;一线只看自己的记录。 |
| L4 — ABAC 策略 | 属性谓词 | 记录的属性结合主体上下文是否满足策略参数? | 审批仅当 amount <= 50000 且 created_by == self。 |
| L5 — 字段级 | 单字段可见性 | 该成员可以读、写或只看遮罩后的哪些字段? | discount 字段对销售角色隐藏,仅财务可见。 |
各层概念独立,但评估时组合在一起。任何一层都不试图越界做别层的事。角色不编码组织层级,组织范围不编码字段遮罩。这种分离是每一层都可审计的前提。
主体:租户成员
AuraBoot 评估的主体不是用户账户,而是 tenant_member —— 把一个用户身份连接到一个特定租户、一组特定角色绑定、组织树上一个特定位置的那条关系。
ab_user (Identity — 谁能登录)
└─ ab_tenant_member (Access — 权限主体)
├── ab_user_role (member_id -> role_id)
│ └── ab_role
│ ├── ab_role_permission (功能授权)
│ └── ab_role_data_scope (按资源的组织范围)
└── mt_org_employee (HR — 组织树上的位置)
├── department
└── position这件事重要,有三个原因。
一个身份,多个成员关系。 同一个用户可以是多个租户的成员。同一个人在自己工作区是 tenant_admin,在合作伙伴工作区是 viewer。权限评估必须始终携带租户上下文,绝不能以裸用户身份评估。
角色挂在访问关系上,不挂在身份上。 授予角色不改变用户是谁,只改变这条成员关系能做什么。撤销角色不会让用户登出 —— 而是收窄这条成员关系的能力面。
HR 位置在 member 上,不在 user 上。 组织数据范围(第三层)读的是成员的 mt_org_employee 记录。这就是"本分支及所有下级"得以变成一个具体集合的依据。
每一个 API 入口 —— 动态 CRUD 控制器、命令分发、审计日志写入、Agent 运行时 —— 都在请求边界解析一次主体,并通过整个评估链路传递 memberId。跳过此解析的代码路径是被禁止的;平台通过请求作用域的上下文持有者强制执行。
第一层:RBAC(基于角色)
RBAC 回答最粗粒度的问题:该成员的角色集是否授予了这个功能权限?
AuraBoot 的权限编码遵守严格的三段式命名:
module.resource.actionmodule—— 插件或平台模块(crm、inv、platform、rbac、tenant、automation等)resource—— 被管控的模型 code 或平台资源action—— 操作(read、create、update、delete、import、export,以及由自定义命令派生的模型专属动词,例如approve、qualify)
例如:model.crm_lead.read、model.crm_opportunity.update、module.platform.dashboard.read。
权限以三层树存储:
| 层级 | 编码形式 | 含义 |
|---|---|---|
| 1 — Module | module.<moduleCode> | 按插件 / 平台模块分组的节点 |
| 2 — Resource | model.<modelCode> | 每个业务模型一个节点 |
| 3 — Action | model.<modelCode>.<action> | 细粒度操作;授权挂在这一层 |
Action 节点在模型发布时自动生成。平台读取模型的命令定义并推导 action 集合:read 总是添加;create / update / delete 在存在对应 exec_type 命令时添加;自定义命令的动词(状态转换、自定义动作)各自贡献一个 action 节点。没有 manage 这种笼统权限,也没有隐式授予。 如果模型没有 delete 命令,model.<x>.delete 这个 action 根本不存在。
角色通过 ab_role_permission 绑定到 action 级权限。菜单可见性、命令分发授权、DSL 页面访问都首先咨询同一个 RBAC 层。
如果 RBAC 拒绝,评估立刻短路返回 403,后续层不再评估。这就是为什么未授权调用者不会触发昂贵的范围或属性匹配运算。
第二层:ReBAC(基于关系)
RBAC 作用在功能上,它不认识具体的某条记录。ReBAC 填补这个缺口。
当一条记录归成员 A 所有,A 把访问权显式分享给成员 B 是常见的 —— 用于委托、协作、移交。这种分享是一种关系,不是角色。AuraBoot 把它存在 ab_record_share:
POST /api/record-share
body: { resourceCode, recordPid, subjectType: "member"|"role"|"dept", subjectId, permissionMask: "read,update" }ReBAC 在 RBAC 之后、组织范围之前评估。这个顺序很关键:被显式分享给某成员的记录,在意图上就是对常规范围规则的一次刻意例外。如果按 B 的组织范围本应看不到,但所有者已经显式共享,则共享胜出 —— ReBAC 以 ALLOW 短路掉范围评估。
一个典型场景:大客户经理休假,把一笔关键商机交给跨大区的同事,并用 read 和 edit 共享。该同事不在原始组织范围内,但仍可看到并操作这一条记录,而不会获得对该大区其他记录的可见性。
ReBAC 不是后门 —— 上游仍需 RBAC。没有 model.<x>.update 的成员无法编辑被分享的记录。共享扩展可见性,不扩展能力。
第三层:组织数据范围
当 RBAC 已授予功能且 ReBAC 没有给出显式放行后,第三层判定该成员在可见集合内能操作哪些记录。
这是把"看本分支,不看同级分支"编码的那一层。它以成员在组织树上的位置为键,通过 mt_org_employee 解析。
AuraBoot 内置五种范围类型(按宽松度从大到小):
| 范围 | 含义 |
|---|---|
ALL | 该租户可见的全部记录 |
DEPT_AND_SUB | 成员所在部门及所有下级部门 |
DEPT | 仅成员所在部门 |
SELF | 该成员创建或被指派的记录 |
NONE | 任何记录都不可见(最严格) |
范围按"角色 × 资源"存储在 ab_role_data_scope 里,这意味着同一角色可以对某模型授予 ALL 而对另一模型授予 SELF。当成员持多角色时,系统应用合并策略(通常取最大 —— 最宽松者胜,按资源可配置)。
评估器把范围翻译为 SQL 谓词,追加到每一次列表查询与每一次记录获取。不存在事后在 Java 侧再过滤一遍。 真正打到数据库的查询已经被收窄到可见集合,这就是即便范围规则非平凡时列表接口仍然快的原因。
对一条记录处于范围外的成员,会拿到和 RBAC 拒绝相同的 403。审计日志会记录是哪一层拒绝 —— 运维排查"为什么看不到数据"时,无需读代码就能区分"缺角色"与"缺组织范围"。
第四层:ABAC(基于属性)
前三层回答该成员能否对这一类记录、这一条记录、以及在其组织范围内动手。第四层回答前三层都无法回答的问题:记录的属性结合主体上下文,是否满足挂在这次授权上的策略参数?
ABAC 由两部分组成:
ab_permission.policy_schema—— 声明该权限接受哪些参数及其运算符。例如maxAmount的运算符为<=,或allowedStatuses的运算符为in。ab_role_permission.conditions—— 当某角色被授予该权限时填入的实际参数值。例如maxAmount=50000与allowedStatuses=["draft","submitted"]。
评估时,策略评估器把记录字段值与策略参数比较。多角色合并依据 schema:数值 <= 取最大,枚举 in 取并集。
一个真实例子:sales_manager 角色在 model.crm_opportunity.approve 上的授权挂参数 maxAmount=50000、createdBySelf=true。某经理尝试审批一笔 80,000 美元、且并非本人创建的商机。RBAC 通过(他持有这个 action);ReBAC 无话可说;组织范围通过(记录在他的分支内);ABAC 失败:amount > maxAmount。该操作被拒绝,拒绝原因是 policy.maxAmount.exceeded,而不是一个泛化的 403。
ABAC 这一层让企业避免造出几十个近乎重复的角色。一个角色,参数化。
第五层:字段级可见性
上面四层判定能不能动。第五层判定动手时看到的是什么。
字段级可见性在 DSL 字段定义里声明:
{
"code": "salary",
"extraProps": {
"fieldPermission": {
"view": ["hr_manager", "finance"],
"edit": ["hr_manager"]
}
}
}在拼装列表或详情响应时,动态数据服务检查成员角色,按字段输出两种读结果之一:
| 结果 | 行为 |
|---|---|
visible | 字段正常返回 |
hidden | 字段从响应中完全省略 |
写侧由同一份字段权限驱动表单渲染。不在 edit 名单的字段被禁用;不可查看的字段不出现在表单上。(更进一步的字段级遮罩 —— 例如电话用 ***-****-{last4} 返回 —— 属于企业版扩展,见下文。)
字段级可见性永远不会改变第 1-4 层的通过 / 拒绝判定。一次成功的读,返回去掉隐藏字段后的记录;一次成功的写,会拒绝该成员无权写入的载荷键。这种分离是刻意的:隐藏一列不改变行是否存在,只改变呈现什么。
评估顺序与短路语义
五层按固定顺序评估:
第 1 步: RBAC —— 角色集是否授予该功能?
拒绝 -> 403(最终)
第 2 步: ReBAC —— 该记录是否被显式分享给该成员?
放行 -> 跳过第 3 步(组织范围)
第 3 步: 组织范围 —— 记录是否在该成员的数据范围内?
范围外 -> 403(最终)
第 4 步: ABAC 策略 —— 属性是否满足策略参数?
违反 -> 403(最终)
第 5 步: 字段级 —— 应用遮罩、隐藏、字段写入规则
(该层自身不会产生拒绝)契约里有三条规则值得强调:
RBAC 拒绝是最终且廉价的。 它在加载任何记录之前、构建任何范围谓词之前、评估任何策略之前发生。已认证但无权的调用者永远不会触发昂贵的计算。
ReBAC 是唯一可以绕过后续层的层。 显式共享会跳过第 3 步,但不会跳过 RBAC 或 ABAC。被共享的记录仍需要功能权限,仍需满足策略参数。
每一次拒绝都知道是哪一层拒绝的。 评估器返回一个 PermissionExplanation,包含每一步的判定及原因。这就是 explain 接口返回的内容、审计日志记录的内容,也是运维不读代码就能排查"为什么用户 X 看不到记录 Y"的依据。
权限编码命名与治理
权限编码不是开发者可以随手写的字符串。它受治理约束。
格式。 每个编码必须匹配 module.resource.action。例如:model.crm_lead.read、module.platform.dashboard.read、module.rbac.role.update。三段,小写,只用点。
注册。 每个编码必须注册在两处之一:
- 平台 bootstrap 模板(
default-bootstrap.json—— 平台级编码) - 某个插件的
permissions.json(插件作用域编码,在插件发布时一并导入)
没有第三条路径。Java 注解、commands.json、菜单定义、前端资源映射里引用的编码必须能解析到一个已注册的编码。
业务模型自动生成。 当插件发布模型,平台自动生成对应的 module.*、model.<code>、model.<code>.<action> 节点。Action 集合来自模型的命令定义;不存在 manage 这种伞形权限。
严格闸门。 一个 CI 闸门会扫描 bootstrap 注册、每个插件的 permissions.json、Java 注解常量、前端资源映射、DSL 文件;任何无法解析的引用都会让构建失败。这个闸门拦下了权限系统里最常见的生产事故:某处(例如 Java 注解常量)引用的编码字符串和已注册版本仅一字之差,任何角色都不可能持有它,导致所有用户都静默 403。闸门把这一整类问题杜绝在合并前。
Java 注解强制。 控制器使用 @RequirePermission(MetaPermission.MODEL_READ),而非裸字符串。常量解析为已注册编码,闸门在构建时校验这种解析。
串场示例
开头那条规则:
销售经理可以看到所属组织分支下全部商机,但只能编辑自己创建、金额超过 50,000 美元的那些;除非属于财务角色,否则永远不应看到折扣字段。
下面是五层如何在不写一行自定义权限代码的前提下,把这条规则表达出来。
第 1 步 — RBAC。 创建 sales_manager 角色并授予:
model.crm_opportunity.read—— 看到记录model.crm_opportunity.update—— 编辑记录
不持有这两个 action 的成员,在列表和详情接口上都拿到 403,后续层不再评估。
第 2 步 — ReBAC。 无需额外配置。如果同级经理把跨分支的一笔商机显式分享出来,共享会让我们的销售经理看到并编辑那一条记录,即便组织范围本应隐藏它。这是有意保留的例外通道。
第 3 步 — 组织数据范围。 在 sales_manager 角色上,把 crm_opportunity 资源的范围设为 DEPT_AND_SUB。平台读取成员的 mt_org_employee.department_id,并向每一次查询追加 IN (本部门及所有下级) 谓词。本分支及所有下级可见,同级分支不可见。
第 4 步 — ABAC 策略。 在授权 sales_manager -> model.crm_opportunity.update 上挂 maxAmount=50000 与 createdBySelf=true。平台 update 的 policy schema 声明了这两个参数及其运算符。评估时,80,000 美元的商机过不了金额检查;同事创建的商机过不了主体检查。read action 上没有这些条件,所以可见范围比写能力更宽 —— 这正是规则原句的意图。
第 5 步 — 字段级。 在商机模型的 discount 字段上设置:
{
"code": "discount",
"extraProps": {
"fieldPermission": {
"view": ["finance"],
"edit": ["finance"]
}
}
}销售经理读取商机时,discount 在响应载荷里被省略;财务角色读取时正常返回。列表接口、详情接口、表单都遵守同一条规则。
销售经理从头到尾没写过自定义处理器,运维也没审过定制代码。规则是元数据。审计日志会准确报告每一次拒绝是被哪一层拦下的。
企业扩展
:::note[企业扩展] 五层模型是社区版完整能力。企业版在各层之上扩展:
- 更丰富的 ABAC 策略表达式 —— 超越社区版运算符词汇(
<=、>=、in、not_in)的策略表达式引擎,支持时间窗口、跨字段比较、对主体近期活动的聚合。 - 字段级密码学遮罩 —— 对被遮罩字段在服务端加密落库,使遮罩在存储层强制执行,而非仅在响应边界。
- 跨组织范围委派 —— 在组织边界之间发放临时或基于规则的范围授权(例如基于项目的虚拟部门),无需修改底层 HR 树。
- 权限策略导出与审计回放 —— 将租户完整权限策略导出为带版本的工件,比较两版差异,并将历史评估对候选策略回放,在上线前验证预期行为。 :::
下一步
- Command Pipeline —— 权限评估如何嵌入 Command 执行契约
- Plugin Manifest —— 插件如何与模型、菜单、页面一并声明其权限编码
- Agent Readiness —— 同一份权限契约如何治理代表成员行动的 AI Agent