我最近开始深入研究认证和授权系统。虽然我一直知道它们非常复杂,但当我真正了解它们的复杂性时,尤其是当你加入实际需求时,以及构建一个可扩展(不仅仅是处理大量用户,还包括功能变化)、一致且准确的授权系统有多么困难时,我感到非常惊讶。
以 GitHub 为例:用户可以属于不同的组织,并且在每个仓库中拥有不同的权限,甚至在不同的团队中也有不同的访问级别。然后,添加个人访问令牌(PAT)用于单独的用户认证,API 速率限制以及长期和短期凭证,每个 PAT 都可以有自己的权限集,这些权限是从用户的权限派生而来的,而用户的权限又是从组织的权限派生而来的——这很快变得相当复杂。
确保这些权限的一致性,并实时管理更新,这又增加了一层难以理解的复杂性,直到你深入研究才会明白。
为了理解这一切,我开始深入研究授权,并学到了很多东西——比我最初预期的要多得多。我学会了如何构建正确的授权(AuthZ)模型,如何避免常见的陷阱,如何确保一致性和准确性。
这篇帖子是我将这段旅程浓缩成一篇15分钟阅读内容的尝试。我们将从什么是授权开始,然后探讨三种最常见的授权建模技术:RBAC、ReBAC 和 ABAC,并理解它们之间的权衡。最好的方法是构建一个我们沿途将要开发的示例应用程序。
到最后,你将更清楚地了解如何思考授权问题,以及为什么授权是现代系统中最关键却又常常被忽视的组成部分之一。
认证 vs 授权?在深入探讨授权的复杂性之前,让我们先从最基本的概念开始。我知道这个问题是每篇博客或 YouTube 视频的开始,但这是因为这是你必须理解的最基本概念之一:认证 ≠ 授权。
认证回答了_谁_在发起请求,而授权回答了该用户被允许做什么。
让我们通过一个实际的例子来说明这一点:一家银行。
任何人都可以走进一家银行,但在取款之前,他们需要证明自己的身份。这可能需要出示身份证或其他形式的验证。这个过程就是 身份验证 — 它确认这个人是谁。
我只是出示了我的身份证,并不意味着我可以从任何账户中取钱。我只能从我自己的账户中取钱,或者做一些其他的事情,比如重置我的借记卡PIN。这就是授权发挥作用的地方——它决定了我可以做什么。即使我已经通过身份验证,我也不能从别人的账户中取钱。
从技术角度来说,授权问题通常形式为:“Actor-X 是否可以对 Resource-Z 执行 Action-Y?” 因此,当我尝试取款时,系统实际上在问:“Sanil 是否可以对 Sanil 的银行账户执行取现操作?”
在整个帖子中,我们将所有授权问题用三个关键元素来描述:一个 Actor(执行者)、一个 Action(动作)和一个 Resource(资源),并将授权视为对以下格式问题的回答:“执行者X能否对资源Z执行动作Y?”
现在我们已经介绍了基础知识,让我们直接开始吧!
一个示例应用总是通过例子来理解任何事情都更容易,所以让我们举一个大家都熟悉的例子,GitHub。我们将从简单的几个实体开始。
-
仓库
- 用户
目前,我们将认为所有仓库都是私有的。每个仓库将有一份可以读取仓库的用户列表、一份可以更新仓库的用户列表以及一份可以删除仓库的用户列表。
并且看看我们如何构建一个授权系统。随着时间的推移,我们将扩展功能需求,并找出如何适应我们的系统。
一个简单的授权方式构建授权最简单的方式是在我们的实体模型中添加权限,并在应用程序代码中使用 if
条件来检查授权。
class RepositoryService {
constructor() {}
getRepository(
repository,
user
) {
if (repository.usersAllowedToGet().contains(user.getID())) {
// 用户可以获取仓库
return http.StatusOK
}
// 用户没有权限访问仓库
return http.StatusForbidden
}
updateRepository(
repository,
user
) {
if (repository.usersAllowedToUpdate().contains(user.getID())) {
// 用户可以更新仓库
return http.StatusOK
}
// 用户没有权限访问仓库
return http.StatusForbidden
}
deleteRepository(
repository,
user
) {
if (repository.usersAllowedToDelete().contains(user.getID())) {
// 用户可以删除仓库
return http.StatusOK
}
// 用户没有权限访问仓库
return http.StatusForbidden
}
}
虽然这个系统目前对我们当前的应用程序来说工作得很好,但它不具备可扩展性。随着更多产品需求的提出,我们的仓库模型将会变得越来越复杂。例如,如果我们想要添加一个配置仓库设置的选项,我们就必须在仓库模型中添加另一个属性。
configureRepositorySettings(
repository,
user
) {
if (repository.usersAllowedToConfigure().contains(user.getID())) {
// 用户可以配置仓库
return http.StatusOK
}
// 用户没有权限访问仓库
return http.StatusForbidden
}
这也将伴随着数据库模式的更改和迁移,这些都不是什么愉快的事情。添加新用户也会很麻烦,你需要定义用户可以执行的不同功能,例如用户是否可以读取仓库,或者用户是否可以更新仓库。
如何构建一个好的授权系统上述模型不可扩展,让我们定义一个好的授权系统应有的属性,
- 应该具有可扩展性以适应新的产品需求 — 随着时间的推移,新的实体、功能和产品使用场景将会出现,我们的授权模型应该能够适应这些变化。例如,如果我们以后决定添加“组织”,我们应该能够用现有的授权模型来实现。
- 添加新的实体,如用户和仓库,应该简单 — 添加新用户或新仓库不应该需要在多个表中进行更改。例如,在上面的授权模型中,添加一个新的管理员用户需要在多个表中添加该用户。
- 编写权限以及每个用户在代码中允许做什么是繁琐、容易出错且紧密耦合的 — 工程师在代码中很容易犯错。我们需要以更声明式的方式来定义权限,使其易于理解。
让我们看看该如何做到这一点。
授权建模所以,我们理解了几点,一是我们不能在实体中建模权限,这样太繁琐,容易出错,并且耦合度太高。随着功能的增多,情况会变得更糟糕。
我们需要一种声明性的方式来定义整个系统的授权。
RBACRBAC 简单来说就是我们之前讨论内容的逻辑迭代。我们不再为每个仓库定义权限,而是定义仓库中的严格角色和桶权限。因此,我们不再对更新、删除和获取操作进行逻辑检查,而是将这些不同的权限分组为管理员和成员这些桶。
在我们的示例中,我们定义了两个角色,admin
和 member
。每个仓库只包含该仓库的管理员和成员。管理员可以执行一些操作,例如删除仓库,而成员可以执行其他操作,例如获取仓库。我们可以将所有这些内容以声明式的方式写入一个 JSON 文件中。
{
"权限": {
"仓库": {
"getRepository": ["admin", "member"],
"updateRepository": ["admin"],
"deleteRepository": ["admin"],
"configureRepositorySettings": ["admin"]
}
}
}
我们可以将用户角色映射到数据库中的一个仓库中 -
这大大简化了我们的应用逻辑,
class RepositoryService {
constructor() {}
getRepository(
repository,
user
) {
userRepositoryObj = UserRepository.get({
userID: user.getID(),
repositoryID: repository.getID()
})
if (userRepositoryObj?.role in permissions.repository.getRepository) {
// 用户可以获取仓库
return http.StatusOK
}
// 用户没有权限访问仓库
return http.StatusForbidden
}
updateRepository(
repository,
user
) {
userRepositoryObj = UserRepository.get({
userID: user.getID(),
repositoryID: repository.getID()
})
if (userRepositoryObj?.role in permissions.repository.updateRepository) {
// 用户可以更新仓库
return http.StatusOK
}
// 用户没有权限访问仓库
return http.StatusForbidden
}
deleteRepository(
repository,
user
) {
userRepositoryObj = UserRepository.get({
userID: user.getID(),
repositoryID: repository.getID()
})
if (userRepositoryObj?.role in permissions.repository.deleteRepository) {
// 用户可以删除仓库
return http.StatusOK
}
// 用户没有权限访问仓库
return http.StatusForbidden
}
configureRepositorySettings(
repository,
user
) {
userRepositoryObj = UserRepository.get({
userID: user.getID(),
repositoryID: repository.getID()
})
if (userRepositoryObj?.role in permissions.repository.configureRepositorySettings) {
// 用户可以配置仓库设置
return http.StatusOK
}
// 用户没有权限访问仓库
return http.StatusForbidden
}
}
通过这种实现,我们将权限分配到一个角色,并为每个用户分配一个角色。
添加任何新功能,并询问谁有权使用此功能变得简单了,我们只需要问哪个角色有权执行此操作。添加新用户也变得更加简单,我们只需要决定用户的角色。
然而,当我们开始在应用程序中添加更多实体时,情况会变得更加复杂。例如,让我们添加两个新的实体,“Organization”和“Workflows”。让我们更清楚地定义它们 -
- 工作流就像 GitHub Actions,用户可以在此运行 CICD 管道。
- 组织是仓库和工作流的父实体。每个仓库和工作流都属于一个组织。
为了便于理解,让我们将它们可视化为父子关系。
这些实体可以有自己的管理员和成员,并且他们有自己的权限。例如,组织管理员可以拥有添加或移除成员的权限。而工作流管理员可以创建或移除工作流。
为了解决这个问题,我们需要像这样调整我们的数据模型——
并且重新定义我们的权限 -
{
"权限": {
"仓库": {
"getRepository": ["owner", "member"],
"updateRepository": ["owner"],
"deleteRepository": ["owner"],
"configureRepositorySettings": ["owner"]
},
"组织": {
"getOrganization": ["admin", "member", "guest"],
"deleteOrganization": ["admin"],
"updateOrganization": ["admin"]
},
"工作流": {
"runWorkflow": ["admin", "member"],
"createWorkflow": ["admin"],
"deleteWorkflow": ["admin"]
}
}
}
这还是很不错的,我们的应用逻辑并不决定谁可以执行什么操作,所有的权限都定义在声明式的 JSON 文件中。
如果你的应用程序只需要这些授权功能,那么最好在这里停下来。
但是当你开始构建更多功能时,你很快就会发现这种模型的缺陷。首先,当你创建一个用户时,你需要在将该用户添加到组织后,再将该用户添加到组织中的每个仓库。这看起来有些冗余,如果每个组织的用户都能自动访问组织中的所有仓库,可能会更高效。此外,组织的管理员应该自动成为仓库和工作流的管理员。
让我们再添加一个实体来进一步突出这个问题,即“问题”。任何有权读取仓库的用户都应该能够创建和评论仓库中的问题。问题的创建者应该能够删除该问题。
问题是一个仓库的“子项” -
每个仓库和工作流都与一个组织相关联,每个问题都与一个仓库相关联。当你开始构建以使其准备好投入生产时,你很快就会添加越来越多的实体,例如 WorkflowRuns 等。我们的完整树可能看起来像这样:
我们当前的 RBAC 模型需要为每个实体定义一个角色,这会很快变得复杂。
让我们看看如何使用 ReBAC 解决这个问题。
ReBAC一种解决方法是将这些实体作为关系来编写。这也是我们思考这个问题的方式。例如,组织中的任何成员都应该被允许读取该组织的仓库。而管理员应该被允许创建和更新仓库。
正如我们看到的,这些实体之间是相互关联的,例如,一个仓库属于一个组织,一个问题属于一个仓库。
这需要一种完全不同的思维方式来思考这些实体。用声明式的方式来写也不直观。我们必须彻底改变我们的思维方式,从“属性”转变为考虑一切为“关系”。
这样思考有点困难,所以我们逐步建立我们的思维模型。我们从实体开始。
现在,让我们开始建模这些关系。每个关系将是在两个实体之间的边。因此,每个关系将定义两个参与的实体和一个关系名称。例如,我们可以表示 user:sanil
是 repository:golangService
的 member
。这里的关联是 member
,而实体是 user:sanil
和 repository:golangService
。
下一步是用声明性的方式来定义这些关系。幸运的是,有许多现成的开源工具可以用来构建这个授权模型。我将使用 OpenFGA,这是一个非常流行的工具(你可以在这里尝试使用它——https://play.fga.dev/)。这是我们在 OpenFGA 的声明性语言中建模实体及其关系的方式:
模型
架构 1.1
类型 user
类型 organization
关系
定义 admin: [user]
定义 member: [user]
类型 repository
关系
定义 organization: [organization]
定义 admin: [user]
定义 member: [user]
类型 workflow
关系
定义 organization: [organization]
定义 admin: [user]
定义 member: [user]
类型 issue
关系
定义 poster: [user]
定义 repository: [repository]
一开始可能有点 confusing,但让我们试着一步步来理解。
每个 type
表示一个 实体类型,例如 user
、organization
、repository
、workflow
和 issue
。这些是系统中的核心对象。relations
部分允许我们定义这些实体之间是如何相互关联的。例如,一个 repository
是 organization
的一部分,并且用户和管理员都可以通过他们的角色与仓库关联。
我们用类似 define admin: [user]
的语句定义实体之间的关系,这意味着一个用户可以被分配为某个实体(如 组织
或 仓库
)的 "admin" 角色。
在 OpenFGA 中,我们使用元组来表达关系。一个元组包含三个值:执行者、关系和资源。例如:
(user:sanil, member, repository:golangService)
这个元组表示 user:sanil
是 repository:golangService
的 成员。同样地,我们也可以为管理员、组织和工作流定义关系。
我们已经建立了实体及其相互之间的关系模型,但是还没有定义系统中的权限。
在 OpenFGA 中,权限也可以被建模为关系。例如,如果一个用户只有在是管理员的情况下才能更新一个仓库,我们通过检查该仓库的管理员关系来表示这一权限。这在模型中可能看起来像这样:
类型 repository
关系
定义 organization: [organization]
定义 admin: [user]
定义 member: [user]
定义 can_update: admin
让我们添加到目前为止讨论的所有权限 -
模型
架构 1.1
类型 用户
类型 组织
关系
定义 管理员: [用户]
定义 成员: [用户]
类型 仓库
关系
定义 组织: [组织]
定义 管理员: [用户]
定义 成员: [用户]
定义 可读: 管理员 或 成员 或 组织的管理员 或 组织的成员
定义 可写: 管理员 或 组织的管理员
定义 可更新: 管理员 或 组织的管理员
定义 可删除: 管理员 或 组织的管理员
定义 可配置: 管理员 或 组织的管理员
类型 工作流
关系
定义 组织: [组织]
定义 管理员: [用户]
定义 成员: [用户]
定义 可读: 成员 或 组织的成员
定义 可写: 管理员 或 组织的管理员
定义 可运行: 成员 或 组织的成员
类型 问题
关系
定义 发布者: [用户]
定义 仓库: [仓库]
定义 可删除: 发布者
定义 可评论: 仓库的成员
当定义谁有权执行某个权限时,例如谁有权在仓库上执行 can_write
,我们不仅定义 admin
,还可以定义该仓库所属组织的任何管理员也拥有此权限。
类型 workflow
关系
定义 organization: [organization]
定义 admin: [user]
定义 member: [user]
定义 can_read: member 或 member from organization
定义 can_write: admin 或 admin from organization
定义 can_run: member 或 member from organization
当我们创建一个仓库时,我们只需向 OpenFGA 添加一个元组即可,
(组织:MyOrg, 组织, 仓库:nodeJSRepository)
这个元组表示 organization:MyOrg
是与 repository:nodeJSRepository
相关联的组织。由于我们在模型中定义了任何组织的成员都会自动获得对所有仓库的 can_read
权限,因此 organization:MyOrg
的所有成员都可以读取 nodeJSRepository
。
OpenFGA 也允许我们查询这些关系以动态确定权限。在查询时,我们以以下形式提出授权问题:“用户 X 是否被允许对资源 Z 执行操作 Y?”
例如:
用户:sanil 是否被允许在 repository:nodeJSRepository 上执行 can_read 操作?
OpenFGA 检查关系,评估权限,并返回结果。由于 user:sanil
是 organization:MyOrg
的成员,因此他们继承了我们在模型中定义的 nodeJSRepository
的 can_read
权限。
通常,这就是你进行授权旅程的终点,因为 ReBAC 覆盖了大多数典型用例。然而,在某些场景中,你可能需要一个更灵活和更广泛的授权模型。
让我们通过一个示例用例来探索一下。虽然 GitHub 当前并没有实现这一点,但我将想象一个新的功能来说明这一点:
假设 GitHub 允许仓库用团队名称进行标记,例如 tag:FeatureTeam
或 tag:PlatformTeam
。同样,每个用户也会被标记上他们的团队。我们希望用户只能访问那些与他们团队标签匹配的仓库,而不是让他们访问组织中的所有仓库。
如果你尝试仅使用 ReBAC 来建模这种情况,你很快就会发现这是不可能的——或者至少不是非常干净利落的。这是因为 ReBAC 主要关注实体之间的关系,在处理基于标签、属性驱动的访问控制时表现不佳。
让我们来理解如何使用 ABAC 模型这些用例。
ABACABAC 是一个更为广泛的授权模型,可以覆盖 RBAC 和 ReBAC 简单无法覆盖的甚至是最极端的使用场景。
那么它是如何工作的呢?在 ABAC 中,访问控制决策是通过评估用户的属性、他们想要访问的资源,有时还包括环境或上下文来做出的。本质上,你定义一些规则来比较这些属性,并确定是否允许特定的操作。这些属性可以是任何东西——团队名称、项目标签、用户角色,甚至是时间或地点等上下文因素。
让我们通过GitHub标签的例子来更好地理解它。想象一下,GitHub中的每个仓库都被标记了一个团队名称,例如 tag:FeatureTeam
或 tag:PlatformTeam
。同样,组织中的每个用户都有一个表示其团队的属性,例如 user:Sanil -> team:FeatureTeam
。使用ABAC,我们可以创建一条规则,即:
"只有当用户的团队属性与仓库的团队标签匹配时,才允许用户读取该仓库。"
在这种情况下,当 user:Sanil
尝试访问 repository:ProjectX
(该仓库被标记为 tag:FeatureTeam
)时,系统会检查用户和仓库的属性。由于 John 的团队与仓库的标签匹配,因此他被授予访问权限。如果 PlatformTeam
团队的用户尝试同样的操作,由于他们的团队属性与仓库的标签不匹配,因此会被拒绝访问。
我也很想在这篇文章中介绍如何实现 ABAC(长期研究过 Casbin),但这篇文章已经太长了。
结论这是一个非常有趣的主题,深入研究和探索令人兴奋。授权是一个非常有趣的问题,即使花费了大量时间研究和理解,仍然有很多东西可以学习。我强烈推荐阅读 Google Zanzibar 论文——
[Zanzibar:Google 的一致的全球授权系统]确定在线用户是否有权访问数字对象是保护隐私的核心。本文……
Google 研究
如果你喜欢这个话题,你可能会喜欢我写的其他一些博客,例如,当我深入探讨 Golang 的线程调度器是如何工作时,以及为什么 Golang 与其他语言如此不同——
理解 Go 调度器及其工作原理了解 Go 如何管理并发并能够每秒调度数百万个 goroutine!medium.com或者当我阅读 ls
的代码并解释为什么它被称为最“过度工程化”的实用程序包时 -
ls
命令是如何工作的?我探索了Unix中最有用且“过度工程化”的命令之一的背后代码betterprogramming.pub
共同学习,写下你的评论
评论加载中...
作者其他优质文章