- A01:2021 – Broken Access Control
- Design a Fine-grained Authorization System with RBAC and ACL
- Open Policy Agent
- Rego Playground
- Zanzibar: Google’s Consistent, Global Authorization System
- Authzed SpiceDB
Broken Access Control 成为 2021 OWASP Top10 的第一位,反映出访问控制失效依然是最致命的漏洞之一。授权(Authorization, AuthZ)作为访问控制的核心机制,其设计与实现的健壮性直接决定了系统边界的安全性。
粗粒度权限管理
在一个代码管理系统中,你可能会使用管理员、维护者和开发者三种角色控制用户可以进行的操作,就像下面这样:
角色 | 创建仓库 | 删除仓库 | 部署 | 批准合并请求 | 创建 Tag | 评论合并请求 | 创建合并请求 |
---|---|---|---|---|---|---|---|
管理员 | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
维护者 | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ |
开发者 | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
借助 Spring Security 实现鉴权检查的代码看起来长这样:
@PreAuthorize("hasRole('ROLE_ADMIN')")
public boolean deleteRepo(...)
你为 Alice 分配了管理员角色,因此 Alice 可以删除仓库;而 Bob 只被分配了维护者和开发者权限,因此删除仓库的请求被拒绝了。
这是 RBAC,Role-Based Access Control,基于角色访问控制。当然相较于我们这个小例子,实际的 RBAC 会有更多的细节和功能,例如权限继承什么的,可供调整。
在许多信息系统中,用户不仅期望可以通过管理面板分配角色,有的时候还望修改这些角色能做的事情,例如在「批准合并请求」的允许角色里再加上「管理员」。
为了不进行这样的代码修改:
--- @PreAuthorize("hasRole('ROLE_MAINTAINER')")
+++ @PreAuthorize("hasAnyRole('ROLE_ADMIN', 'ROLE_MAINTAINER')")
public boolean acceptPullRequest(...)
你通过实现 PermissionService::hasPermission
来计算用户拥有的角色里是否包含了「批准合并请求」的权限:
@PreAuthorize("@pService.hasPermission(authentication, 'REPO:ACCEPT_PR')")
public boolean acceptPullRequest(...)
ABAC, Attribute-Based Access Control
但是在一个 GitHub 式的代码管理系统中,仓库的所有者(Owner)应当默认具有管理员权限,只不过我们这里为了方便说明把这个概念简化了。你允许 Alice 邀请 Bob 作为自己的仓库 repo-alice-1
的维护者,同时 Bob 又是 repo-bob-1
的所有者。然而 Bob 有 ADMIN
角色不代表可以执行 deleteRepo(repo-alice-1)
操作。
一个可行的方案是在 Repo
对象上维护角色列表,就像下面的伪代码一样:
if <user> in <repo>.admin_list
allow
else:
deny
这个判断意味着 PermissionService::hasPermission
中需要加上一段逻辑,不过问题不大。但是产品经理 Carol 说「如果一个代码仓库被归档了,那么只有「删除仓库」功能可以被使用」。
if deploy:
if <repo>.archived
deny
else
if <user> in <repo>.admin_list
allow
else if <user> in <repo>.maintainer_list
allow
else:
deny
但是安全顾问 Dan 说「如果 admin 的当前登录 IP 不在他的常用 IP 列表中,则不允许使用「删除仓库」功能」。
if delete_repo
if ...
if request.ip not in <user>.common_ips
deny
...
你需要添加的逻辑代码越来越多,放在 PermissionService 里会堆砌成一个庞大的 if-else-if 结构,放在各个业务 Service 里则会和业务逻辑混杂在一起,对添加和修改业务功能都是一种折磨。然后销售 Erin 跑过来说「我需要给潜在客户演示,给我一个绕过这些权限限制的 super user」……
你突然意识到这里的访问权限控制是利用对象的属性动态计算的,这就是 ABAC,基于属性的访问控制,欢迎来到细粒度权限控制的世界:
- 具体到某一个而不是某一类资源;
- 具体到某一个操作;
- 能通过请求的上下文(如时间、地理位置、资源 Tag)动态执行策略;
以这个系统为例提取一些主要概念:
- 主体(Subject):目前只有
User
一种- 角色(Role):绑定具体的仓库,
ADMIN
,MAINTAINER
,CONTRIBUTOR
等
- 角色(Role):绑定具体的仓库,
- 客体(Object,或称为资源,以便和面向对象编程中的 对象 区分):代码仓库
Repo
和合并请求PR
- 动作(Action):
repo:create
,repo:delete
,repo:deploy
,pr:accept
…… - 环境(Environment):请求上下文(请求 IP、时间)等
你想到可以把所有可能性都列举出来,判断用户具有访问权限的方法是找出一条计算结果为 true 的规则。你想到可以使用 Drools 之类的规则引擎来计算,用伪代码表示:
deny if {
<action> = "delete"
<resource>.type = "repo"
request.ip not in <subject>.common_ips
}
allow if {
<subject> is super_user
}
allow if {
not deny
<resource>.type = "repo"
some role in <subject>.<resource>.roles:
role can do <action>
}
其中 role can do <action>
使用类 RBAC 权限表管理和判断。
OPA, Open Policy Agent
笔者的项目(当然不是代码管理系统了)使用 OPA 来计算这个逻辑,上述伪代码被转换成了 Rego 语言和 JSON 格式的数据集。
业务代码中,笔者通过在 Endpoints 上添加 @Policy
注解指明该接口在鉴权时适用的 动作 和 客体:
@Policy(action="delete", resource="repo")
@DeleteMapping("/repo/{repoId}")
public void deleteRepo(@Path("repoId") String repoId) {
...
在运行时 AOP 通过 Spring 容器获取到对应的 DAO 层 Bean,调用方法获得对象,这些信息和用户请求打包成权限判断上下文送给 OPA。
{
"subject": {
"roles_by_repo": {
"bob-repo-1": ["ADMIN"]
},
"common_ips": ["10.27.22.1", "220.11.44.5"]
},
"action": "delete",
"resource": {
"type": "repo",
"id": "bob-repo-1"
},
"request": {
"ip": "10.27.22.1",
"timestamp": "1746784303"
}
OPA 返回 { "allow": "true" }
时说明当前用户有权执行声明的操作。
其中最有门槛的部分在于 Rego 规则的编写,role can do <action>
的部分因为和 RBAC 差不多,可以使用 Excel 表格(也方便在后台控制面板中)表述和实现。好在无论是 Rego Playground 还是 opa-cli
都非常便于测试。
ReBAC, Relationship-Based Access Control
在一个 GitHub 式的代码管理系统中,通常还有组织的概念。
组织 Org-1
邀请 Alice 作为管理员加入,因此 Alice 拥有了 repo-org1-1
的管理员权限。然而可不能简单地将 Alice 加到 repo-org1-1.admin_list
完事 —— 当你将 Alice 移出 Org-1
时,对 repo-org1-1.admin_list
和 repo-alice-1.admin_list
的更新策略应当是完全不同的。
在权限判断中,Alice 对 r1:Repo
的管理员权限来自与组织 o1
的 is_admin
关系,对 r2:Repo
的权限则来自于其与客体的 is_owner
的关系。这种依靠关系判断权限的方法称为 ReBAC,基于关系的访问控制。
按之前的思路用伪代码描述,判定规则一下子膨胀了:
if <user> is <repo>.owner
allow
if some org in <user>.organization
match admin in org.rels
match org is <repo>.organization
不过如果将我们上面的关系图视为一种有向无环图的话,判断用户是否具有访问权限的方法看起来像是在图里寻找从 Subject(主体,Alice)到 Object(客体,代码仓库)的有效路径,而在图中寻找路径的算法可就多了。
这就是 Google 在 Zanzibar 中使用的思路,后者管理了 Google 海量产品的授权。当然,以 Google 的体量,还需要考虑数据同步、New Enemy 等问题,不过这里本文暂时都不会讨论。
Zanzibar 本身只提供了论文,好在我们有 SpiceDB 这样的开源实现。可以使用 SpiceDB Playground 对项目进行建模,评估 ReBAC 的效果:
回到我们的代码管理系统上来,对主客体和关系进行定义,权限判断自己就出现了:
# 定义删除动作可由仓库所有者(个人)和仓库所有者(组织)的管理员执行
definition user {}
definition organization {
relation is_admin: user
relation is_maintainer: user
relation is_contributor: user
}
definition repository {
relation owner: user | organization
permission delete_via_owner = owner
permission delete_via_org = owner->is_admin
permission delete_repo = delete_via_owner + delete_via_org
}
缺点是你必须对所有显式的关系(那些用于推导其他关系的关系)进行预先计算以便构建关系图,对于一些由外部系统管理的数据就没那么好操作了。
此外它的门槛更高了。