KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

06 · RBAC 的边界与 ACL:为什么「能改」不能是布尔值 — keel 龙骨

RBAC 很好用,直到你遇到这三个问题:角色开始爆炸、同一个岗位在不同空间要不同权限、有人需要"能看不能改"。这一章讲清 RBAC 在哪里到头,以及资源级 ACL 怎么补上——尤其是访问级别带权重这个设计,它决定了 ACL 能不能长期维护。

RBAC 很好用,直到你遇到这三个问题:角色开始爆炸、同一个岗位在不同空间要不同权限、有人需要"能看不能改"。这一章讲清 RBAC 在哪里到头,以及资源级 ACL 怎么补上——尤其是访问级别带权重这个设计,它决定了 ACL 能不能长期维护。


一、现场:为了三条数据规则,加了三个角色

一个协作平台出现这样的需求:

  1. 客服组要能看客户会话,但不能导出;
  2. 主管要能导出本组的,不能导出全公司;
  3. 外包人员要能看被分配给自己的会话。

最初的解法是加角色:加"客服"、加"客服主管"、加"外包客服"……三个月后,角色表里 30 个角色,
新同事入职要配 4 个角色,权限组合爆炸。

问题不在实现,在于用错了模型:这些都是"对具体资源的访问级别"问题,
被硬塞进了"角色能做什么动作"的模型里。

二、概念边界:RBAC 与 ACL 回答的不是同一个问题

RBAC ACL
提问方式 这个岗位能做什么动作? 这个人对这一条数据能做什么?
授权粒度 角色 资源 × 主体
条目数增长 随角色数线性 随「资源数 × 主体数」增长
变更成本 改角色 → 影响所有人 改一条 ACL → 只影响这一条
适合 岗位稳定的后台系统 协作型、共享型的资源

这不是"哪个更先进"的选择,而是"你的权限需求属于哪一类问题"。
一个系统通常两者都要:RBAC 管功能权限(能不能调这个接口),ACL 管资源级可见与操作。

配套项目里两种是分开的:

rbac.py  → 功能权限:system:user:list 这类权限点
acl.py   → 资源级别:某人在某个资源上是 use / observe / edit / manage

三、一次完整运行:带权重的访问级别

ACL 最容易犯的错是用布尔值:

-- 布尔式:组合会迅速失控
(resource_id, subject_id, can_view, can_edit, can_delete, can_share)

每加一种操作就要加一列,加两种操作就要考虑"能编辑但不能查看"是否合法。

带权重的做法是一条有序级别:

LEVEL_WEIGHTS = {"use": 1, "observe": 2, "edit": 3, "manage": 4}

于是"能否编辑"变成一次数值比较:

def level_at_least(actual, required):
    return LEVEL_WEIGHTS[actual] >= LEVEL_WEIGHTS[required] if actual else False

配套项目 test_level_weights_are_ordered 把这个序关系钉成了测试:

def test_level_weights_are_ordered():
    assert level_at_least("edit", "observe")     # 高可以覆盖低
    assert level_at_least("manage", "edit")
    assert not level_at_least("use", "observe")  # 低不能覆盖高
    assert not level_at_least(None, "use")       # 没授权就是没授权

权重带来的能力:新增"审批"这一级别时,只要它的权重落在 3 和 4 之间,
所有已有判定逻辑一行都不用改。布尔式模型则必须回头改每一处判定。

多来源授权取最高

同一个资源上,一个人的有效权限可能来自四个方向:

直接授权给他        resource_acl(subject_type='member', subject_id=...)
他所在的组被授权     resource_acl(subject_type='group',  subject_id=...)
他所在的工作空间     resource_acl(subject_type='workspace', ...)
他是属主            resource.owner_member_id == member_id  → 隐含 manage

配套项目取的是最大值(acl.effective_level),实测输出(python demo.py):

=== 06 ACL:多来源授权取最高级别(bob:属主 + 组) ===
  ACL 表: [('group', 'g_rnd', 'observe'), ('member', 'm_bob_acme', 'edit'), ('member', 'm_carol_acme', 'use')]
  bob 实际级别: manage (属主 manage 与组 observe 取高)
  需要 use      -> 通过(实际 manage)
  需要 observe  -> 通过(实际 manage)
  需要 edit     -> 通过(实际 manage)
  需要 manage   -> 通过(实际 manage)

这一段是配套项目里最值得逐行读的输出:Bob 显式被授予 edit,所在组是 observe,
但因为他是属主(隐含 manage),最终拿到 manage。如果实现写成"取第一条匹配"或
"取最后一条匹配",结果就会依赖 SQL 返回顺序——这是一个只在数据变多后才暴露的 bug。

对应的测试:

def test_acl_takes_highest_of_multiple_sources(conn):
    """bob 既是属主(manage)又在组里(observe),取高者。"""
    as_user(conn, "u_bob")
    assert effective_level(conn, "r_agent_x", "m_bob_acme") == "manage"

而级别不足时的拒绝,长这样:

=== 06 carol 只有 use:observe 失败 ===
  carol 实际级别: use
  需要 observe -> AccessLevelDenied | access_level_denied:observe<use | 实际 use

注意错误信息里同时给了要求的级别和实际级别。这是排障时最需要的信息:
只说"权限不足"的话,管理员得自己去查 ACL 表;给出 observe<use 一眼就知道差在哪。

四、失败注入:把"取最高"改坏

把 effective_level 里的 max(..., key=权重) 改成"返回第一条匹配",
在配套项目里立刻能看出区别(Bob 会从 manage 掉到 observe 或 edit):

原实现(取最高): bob 实际级别 manage → 需要 manage 通过
改后(取第一条): bob 实际级别 observe → 需要 manage 403

这就是"看起来能跑、换个数据就错"的典型。为什么难发现:数据少时,
一个主体往往只匹配一条 ACL 记录;等协作组多了,才会开始出现多来源冲突。

配套项目之所以在演示数据里就放了"属主 + 组"两种来源,就是为了让这个 bug 一跑就暴露。

另一个坑:ACL 表没有唯一性约束时的重复授权

resource_acl 的主键是 (resource_id, subject_type, subject_id),
它同时保证了"同一个主体对同一资源只能有一条授权"。

如果没有这个主键,重复执行授权会插入多行,取最高仍然"看起来对",
但 SELECT * FROM resource_acl WHERE resource_id = ? 返回的行数会随时间增长——
这类问题在 ACL 场景里特别常见,因为授权动作往往是幂等性最差的那类("给这个人也加一条")。

五、角色爆炸的识别与处理

角色爆炸有可识别的信号,配套项目的 test_admin_bypasses_rbac_only 顺便暴露了其中一个:

  alice 是 tenant_admin,但 r_agent_x 属主是 bob → 仍被拒

管理员在 RBAC 层全权,却拿不到别人的资源 ACL。这说明两件事:

  1. 功能权限与资源权限确实是两套机制(本章的核心论点);
  2. 如果产品期望"管理员什么都能做",就需要在 ACL 层显式给管理员开后门——
    而这又是一个安全决策(05 章末尾的取舍)。

识别信号:

信号 说明
角色数 > 30 且增速快 大概率在用角色表达资源级差异
大量角色只差一两个权限点 说明权限点在错位(该用 ACL 的地方用了角色)
新同事需要配多个角色 角色之间存在重叠但无包含关系
权限相关代码里有大量 if role == 角色已经变成了事实上的权限判断

处理方式(按优先级):

  1. 先检查权限点命名:如果权限点粒度太粗(比如 data:export 而不是
    data:export:self / data:export:org),拆细能消掉一批角色;
  2. 引入数据范围(02 章)承接"能看哪些行"的需求,这是最容易被错塞进角色的;
  3. 再引入 ACL 承接"这一条数据能到什么程度";
  4. 剩下的才是真角色(岗位)。

六、误判澄清

误解 核对 结论
"ACL 更先进,用 ACL 替代 RBAC" ACL 条目数 = 资源数 × 主体数 用 ACL 管功能权限会立刻爆炸
"布尔字段够用" 4 个布尔列组合出 16 种状态,其中一半非法 用有序级别,新增操作不改判定
"多个来源取第一条就行" 实测 bob 因为取最高才是 manage 必须取最高,且要有测试锁住
"管理员应该绕过一切" 实测管理员在 ACL 层默认无权 这是取舍,不是 bug;绕过要有明确开关与审计
"权重可以随便定" 序关系是语义的一部分 权重表达"包含关系",加新级别时必须落在正确位置

七、生产环境怎样替换

教学替身 生产替换 要点
四级固定权重 从配置/字典表读级别定义 级别语义要能被业务和审计理解
max() 在应用层算 数据库侧聚合或缓存 ACL 行数大时要注意查询成本(07 章)
无主键约束 严格主键 + 幂等授权接口 重复授权是最常见的脏数据来源
管理员绕过 ACL 显式开关 + 审计日志 别用"角色码 == admin"这种隐式判断

八、练习与验收

练习 1:统计你所在系统的角色数、权限点数、以及"只差一两个权限点的角色"有多少对;
用第 05 节的信号表判断是否已经角色爆炸。

练习 2:把配套项目 acl.effective_level 的"取最高"改成"取第一条",跑 test_auth.py,
确认哪个测试先失败——这个实验的目的是理解"顺序依赖 bug"为什么难发现。

练习 3:为你的资源设计带权重的访问级别(至少三级),并列出"新增一个级别时需要改哪些代码"
(正确答案应该是:一处都不改)。

验收点:不说资料,说出 RBAC 与 ACL 各自回答什么问题;解释带权重相对布尔字段的优势(新增级别不改判定);
说明多来源授权必须取最高以及取错时的症状;用信号表判断角色爆炸。


现在能解释什么:RBAC 与 ACL 的分工与管理成本差异;带权重的访问级别设计与序关系语义;多来源授权取最高及其测试方法;角色爆炸的四个识别信号与处理顺序。

下一步:第 07 章讲一个运维问题——权限改了以后,多久生效。

进入 keel 阅读