构建细粒度权限控制系统

2025-05-12, 星期一, 14:36

DevSoftware Architecture

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
  • 客体(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_listrepo-alice-1.admin_list 的更新策略应当是完全不同的。

is_admin

has

is_owner

Alice:User

o1:Org

r1:Repo

r2:Repo

在权限判断中,Alice 对 r1:Repo 的管理员权限来自与组织 o1is_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
}

缺点是你必须对所有显式的关系(那些用于推导其他关系的关系)进行预先计算以便构建关系图,对于一些由外部系统管理的数据就没那么好操作了。

此外它的门槛更高了。