权限

为什么 AuraBoot 的权限是分层而非堆叠

大多数应用框架只提供一种权限模型。有的选择角色,有的选择访问控制列表,有的选择属性谓词。每一种模型都足以覆盖现实需求的一个切片,却又不足以覆盖整体。

一条真实的企业规则听起来是这样的:

"销售经理可以看到所属组织分支下全部商机,但只能编辑自己创建、金额超过 50,000 美元的那些;除非属于财务角色,否则永远不应看到折扣字段。"

这一句话同时触及功能级访问记录级可见性组织层级属性级条件字段级遮罩。没有任何单一权限范式能覆盖它。如果框架只有角色,这条规则就会埋进处理器代码;如果只有属性策略,每次列表查询都变成一次表达式求值过程,在审计时人都读不懂。

AuraBoot 通过在同一个评估契约下分层组合五种独立权限范式来解决这件事。每一层只回答一个问题。契约决定提问的顺序与答案如何合并。五层的主体统一 —— 是租户成员(tenant member),不是裸用户。

本文逐层解释,讲清评估顺序、权限编码命名规范,并以一个企业场景串完整链路。

五层概览

范式它回答的问题示例
L1 — RBAC角色该成员有没有调用这个功能的资格?sales_manager 角色授予 model.crm_opportunity.update
L2 — ReBAC关系这条具体记录是否被显式共享给了该成员?商机所有者将其分享给同事用于读和编辑。
L3 — 组织数据范围组织层级按组织树位置,该成员被允许看到哪些记录?大区经理看本分支与所有下级;一线只看自己的记录。
L4 — ABAC 策略属性谓词记录的属性结合主体上下文是否满足策略参数?审批仅当 amount <= 50000created_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.action
  • module —— 插件或平台模块(crminvplatformrbactenantautomation 等)
  • resource —— 被管控的模型 code 或平台资源
  • action —— 操作(readcreateupdatedeleteimportexport,以及由自定义命令派生的模型专属动词,例如 approvequalify)

例如:model.crm_lead.readmodel.crm_opportunity.updatemodule.platform.dashboard.read

权限以三层树存储:

层级编码形式含义
1 — Modulemodule.<moduleCode>按插件 / 平台模块分组的节点
2 — Resourcemodel.<modelCode>每个业务模型一个节点
3 — Actionmodel.<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 短路掉范围评估。

一个典型场景:大客户经理休假,把一笔关键商机交给跨大区的同事,并用 readedit 共享。该同事不在原始组织范围内,但仍可看到并操作这一条记录,而不会获得对该大区其他记录的可见性。

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=50000allowedStatuses=["draft","submitted"]

评估时,策略评估器把记录字段值与策略参数比较。多角色合并依据 schema:数值 <= 取最大,枚举 in 取并集。

一个真实例子:sales_manager 角色在 model.crm_opportunity.approve 上的授权挂参数 maxAmount=50000createdBySelf=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.readmodule.platform.dashboard.readmodule.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=50000createdBySelf=true。平台 update 的 policy schema 声明了这两个参数及其运算符。评估时,80,000 美元的商机过不了金额检查;同事创建的商机过不了主体检查。read action 上没有这些条件,所以可见范围比写能力更宽 —— 这正是规则原句的意图。

第 5 步 — 字段级。 在商机模型的 discount 字段上设置:

{
  "code": "discount",
  "extraProps": {
    "fieldPermission": {
      "view": ["finance"],
      "edit": ["finance"]
    }
  }
}

销售经理读取商机时,discount 在响应载荷里被省略;财务角色读取时正常返回。列表接口、详情接口、表单都遵守同一条规则。

销售经理从头到尾没写过自定义处理器,运维也没审过定制代码。规则是元数据。审计日志会准确报告每一次拒绝是被哪一层拦下的。

企业扩展

:::note[企业扩展] 五层模型是社区版完整能力。企业版在各层之上扩展:

  • 更丰富的 ABAC 策略表达式 —— 超越社区版运算符词汇(<=>=innot_in)的策略表达式引擎,支持时间窗口、跨字段比较、对主体近期活动的聚合。
  • 字段级密码学遮罩 —— 对被遮罩字段在服务端加密落库,使遮罩在存储层强制执行,而非仅在响应边界。
  • 跨组织范围委派 —— 在组织边界之间发放临时或基于规则的范围授权(例如基于项目的虚拟部门),无需修改底层 HR 树。
  • 权限策略导出与审计回放 —— 将租户完整权限策略导出为带版本的工件,比较两版差异,并将历史评估对候选策略回放,在上线前验证预期行为。 :::

下一步

  • Command Pipeline —— 权限评估如何嵌入 Command 执行契约
  • Plugin Manifest —— 插件如何与模型、菜单、页面一并声明其权限编码
  • Agent Readiness —— 同一份权限契约如何治理代表成员行动的 AI Agent