KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
06 · RBAC 的边界与 ACL:为什么「能改」不能是布尔值 — keel 龙骨
RBAC 很好用,直到你遇到这三个问题:角色开始爆炸、同一个岗位在不同空间要不同权限、有人需要"能看不能改"。这一章讲清 RBAC 在哪里到头,以及资源级 ACL 怎么补上——尤其是访问级别带权重这个设计,它决定了 ACL 能不能长期维护。
RBAC 很好用,直到你遇到这三个问题:角色开始爆炸、同一个岗位在不同空间要不同权限、有人需要"能看不能改"。这一章讲清 RBAC 在哪里到头,以及资源级 ACL 怎么补上——尤其是访问级别带权重这个设计,它决定了 ACL 能不能长期维护。
一、现场:为了三条数据规则,加了三个角色
一个协作平台出现这样的需求:
- 客服组要能看客户会话,但不能导出;
- 主管要能导出本组的,不能导出全公司;
- 外包人员要能看被分配给自己的会话。
最初的解法是加角色:加"客服"、加"客服主管"、加"外包客服"……三个月后,角色表里 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。这说明两件事:
- 功能权限与资源权限确实是两套机制(本章的核心论点);
- 如果产品期望"管理员什么都能做",就需要在 ACL 层显式给管理员开后门——
而这又是一个安全决策(05 章末尾的取舍)。
识别信号:
| 信号 | 说明 |
|---|---|
| 角色数 > 30 且增速快 | 大概率在用角色表达资源级差异 |
| 大量角色只差一两个权限点 | 说明权限点在错位(该用 ACL 的地方用了角色) |
| 新同事需要配多个角色 | 角色之间存在重叠但无包含关系 |
权限相关代码里有大量 if role == |
角色已经变成了事实上的权限判断 |
处理方式(按优先级):
- 先检查权限点命名:如果权限点粒度太粗(比如
data:export而不是data:export:self/data:export:org),拆细能消掉一批角色; - 引入数据范围(02 章)承接"能看哪些行"的需求,这是最容易被错塞进角色的;
- 再引入 ACL 承接"这一条数据能到什么程度";
- 剩下的才是真角色(岗位)。
六、误判澄清
| 误解 | 核对 | 结论 |
|---|---|---|
| "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 章讲一个运维问题——权限改了以后,多久生效。