用户的权限管理对每个项目来说都至关重要。不同的业务场景决定了不同的权限管理需求,不同的技术栈也有不同的解决方案:如果你在写一个Ruby On Rails应用,那你可能会选择cancan, 如果你正在使用K8S,那你很可能需要与K8S的RBAC系统打交道。那如果你面对一个非常复杂的业务,需要实现极为灵活的权限配置,并且同时对接多个服务怎么办呢?谷歌的一致性全球授权系统Zanzibar以及其开源实现Ory/Keto或许可以帮到你。
Zanzibar
简介
Google Zanzibar是谷歌2016年起上线的一致性全球授权系统。这套系统的主要功能是
- 储存来自各个服务的访问控制列表(Access Control Lists, ACLs),也就是所谓的权限(Permission)。
- 根据储存的ACL,进行权限校验。
这套系统上线后对接的服务有谷歌地图,谷歌图片,谷歌云盘,GCP,以及Youtube等等重要的服务 为了服务如此重要的业务,Zanzibar有着以下特点:
- 一致性:面对并发度如此大的业务场景,Zanzibar在检查权限的同时必须保证按照各个ACL的添加顺序判断。比如A添加了一条规则后立即删除,这两个动作如果没有按照正确的顺序执行,那么会造成权限泄露。
- 灵活性:各个业务场景的鉴权需求都不尽相同,所以Zanzibar灵活地支持不同的权限模式
- 横向扩展:以横向扩展支持数万亿条规则,每秒百万级鉴权
- 性能:95%的请求10毫秒内完成,99%的请求100毫秒内完成
- 可用性:上线三年间保证了99.999%的可用时间
以上的各个特性中除了灵活性之外都是性能或算法上的特点,性能和可靠性上也有很大一部分得益于底层的Spanner数据库。如果有兴趣可以阅读以下这篇论文:Zanzibar: Google’s Consistent, Global Authorization System对Zanzibar进行更深入的了解。下面我们就灵活性这一特点看一下Zanzibar是如何定义鉴权模型的。
概念与定义
关系元组(Relation Tuples)
Relation Tuples是Zanzibar的核心概念,一条Relation Tuples就对应了一条ACL。关系元组由命名空间(Namespace),对象(Object),关系(Relation)和主体(Subject)组成。一条Relation Tuples可以用BNF语法这样描述:
<relation-tuple> ::= <object>'#'relation'@'<subject>
<object> ::= namespace':'object_id
<subject> ::= subject_id | <subject_set>
<subject_set> ::= <object>'#'relation
一条Relation Tuples写作
namespace:object#relation@subject
意味着subject
对namespace
中的object
有一种relation
。
换成更有语义的例子:
videos:cat.mp4#view@felix
就意味着felix
对videos
中的cat.mp4
有view
的关系。
上述BNF定义的第四条subject_set
是由<object>'#'relation
组成的,也就是代表了一群对某种object
有relation
的subject
。举例来说就是
groups:admin#member@felix
groups:admin#member@john
videos:cat.mp4#view@(groups:admin#member)
在这个例子中,felix
和john
都对groups:admin
有member
的关系,而对groups:admin
有member
的关系的subject_set
对videos:cat.mp4
有view
的关系。也就是说felix
和john
都对videos:cat.mp4
有view
的关系。这种嵌套的语法可以有很多层,从而达到了整个ACL规则灵活可配的目的。
命名空间(Namespaces), 对象(Object)与主体(Subject)
Zanzibar中的Namespace并不是起隔离作用的,就像上面的那个例子,在编写videos
Namespace时也可以引用groups
Namespace。这里的命名空间概念更多是用来将数据分为同质的分块(并应用不同的配置),并且在储存层面上也是分离的。所以在多租户的使用场景中,用租户的UUID作为Namespace并不是一个好的选择,而应该使用tenants
作为Namespace,从而实现
tenants:tenant-id-1#member@felix
tenants:tenant-id-1#member@john
这样的Relation Tuples,并且用tenants:tenant-id-1#member
作为鉴权的subject_set
。在命名方面,一般建议Namespace使用单词的复数形式,而Object和Subject使用UUID。
将Relation Tuples转换为图有助于更好地理解object与subject之间的关系,考虑Keto官方文档上的以下例子
// user1 has access on dir1
dir1#access@user1
// Have a look on the subjects concept page if you don't know the empty relation.
dir1#parent@(file1#)
// Everyone with access to dir1 has access to file1. This would probably be defined
// through a subject set rewrite that defines this inherited relation globally.
// In this example, we define this tuple explicitly.
file1#access@(dir1#access)
// Direct access on file2 was granted.
file2#access@user1
// user2 is owner of file2
file2#owner@user2
// Owners of file2 have access to it; possibly defined through subject set rewrites.
file2#access@(file2#owner)
将其转换为图可以得到:
其中实线代表了直接定义的关系,而虚线代表了由Subject Set
继承而来的关系。
Keto
Ory/Keto是谷歌Zanzibar的第一个也是目前唯一一个开源实现。Keto用golang实现并兼容Zanzibar的概念,它作为一个单独的服务部署提供了RestfulAPI以及Grpc两种调用方式。后端存储使用了PostgreSQL/MySQL,官方并未公布其具体的性能表现,但比起使用Spanner的Zanzibar来说,性能应该是差一些的。
动手尝试
Keto提供了一个简单的例子来展示他是如何工作的
# clone the repository if you don't have it yet
git clone git@github.com:ory/keto.git && cd keto
docker-compose -f contrib/cat-videos-example/docker-compose.yml up
注意如果这里如果报出了以下错误
ERROR: The Compose file './contrib/cat-videos-example/docker-compose.yml' is invalid because:
services.keto.volumes contains an invalid type, it should be a string
services.keto-init.volumes contains an invalid type, it should be a string
那么你需要将contrib/cat-videos-example/docker-compose.yml
这个文件的第一行改为version: '3.2'
这里的keto实例使用了内存储存,所以并不需要一个额外的数据库。
执行
docker-compose -f contrib/cat-videos-example/docker-compose.yml exec keto sh
进入到容器中后就可以使用keto的命令行工具了。这里默认装载了contrib/cat-videos-example/relation-tuples
下的所有规则,在生产环境中可以像这样用keto命令行
直接装载relation tuples文件,也可以使用API动态添加/删除。执行
keto relation-tuple get videos
可以输出在videos
namespace 下的所有规则
videos /cats owner cat lady
videos /cats view videos:/cats#owner
videos /cats/1.mp4 owner videos:/cats#owner
videos /cats/1.mp4 view *
videos /cats/1.mp4 view videos:/cats/1.mp4#owner
videos /cats/2.mp4 owner videos:/cats#owner
videos /cats/2.mp4 view videos:/cats/2.mp4#owner
检查所有人对/cats/2.mp4
的访问权限:
keto check "*" view videos /cats/2.mp4
Denied
可以看到输出了拒绝访问, 而cat lady
由于第1,6,7条规则,是可以访问的
keto check "cat lady" view videos /cats/2.mp4
Allowed
如果想搞明白/cats/2.mp4
可以被谁访问,以及其原因,可以使用expand
命令
keto expand view videos /cats/2.mp4
∪ videos:/cats/2.mp4#view
├─ ∪ videos:/cats/2.mp4#owner
│ ├─ ∪ videos:/cats#owner
│ │ ├─ ☘ cat lady️
输出的树中∪
代表了Subject Set
, 而☘
代表了最终可以访问Object
的Subject
。
应用
学会了keto和Zanzibar的基础概念和用法后,让我们通过一个例子看一下如何将keto应用到服务的鉴权中去。
假设我们维护一个多租户的视频服务,我们仅允许特定的视频对某一个租户(tenant)的成员访问。比如下在面这个例子中,我们有一个租户cat
,视频cats.mp4
仅允许租户cat
的member
观看。对于这个业务我们部署一个Profile Service
用于用于的注册登陆和验证,以及一个Video Service
用于为用户提供视频。另外部署了一个Keto服务做集中式的鉴权。
整个鉴权的流程如下图所示:
让我们走一遍整个流程:
- Video Service预先在Keto中写入了一条规则
videos:cats.mp4#view@(tenants:cat#member)
,即仅允许tenants:cat
的member
来view
目标videos:cats.mp4
- 用户
Felix
想要访问cats.mp4
,但他在系统里并没有注册过,所以以匿名用户的身份对Video Service发起请求 - Video Service接收到请求后向Keto确认主体
*
是否可以访问videos:cats.mp4
,由于之前写入的规则,请求被Keto判断为拒绝,随即felix的请求也失败了 - felix向Profile Service发起请求注册为租户
cat
的member
, Profile Service完成注册后向Keto写入了tenants:cat#member@felix
- 注册完成的felix再次向Video Service发起对
cats.mp4
的请求,此时他已经获得了用户felix
的身份,假设这里他将用户的session放在cookie中发起请求 - Video Service将Cookie中的session提取出来后向Profile Service验证用户,通过后向Keto确认主体
felix
是否可以访问videos:cats.mp4
- 此时由于felix已经是
tenants:cat
的member
,Keto判定请求通过,最后Video Service将cats.mp4
返回给Felix
从这个例子我们可以看到,负责提供视频服务的Video Service自始至终只作了两件事:1. 向Keto写入视频权限规则 2. 向Keto询问某个用户是否有权限。而具体用户的角色Role
的管理则是由Profile Service统一负责。在这个架构下,我们自己的两个服务在权限这个问题上完全是解耦的,所有的权限规则写入和检验全都由Keto统一负责,显著地增强了整个系统的弹性和可扩展性。
总结
Zanzibar及其开源实现Ory/Keto致力于实现一套灵活,弹性,可扩展,高可用的鉴权服务体系。Zanzibar在过去的几年中支持了几乎所有的谷歌云上服务,已经印证了它灵活性的价值,而谷歌这一套系统支持全球业务的运维能力也令人赞叹。而Keto也让我们使用这套理念治理自己项目的权限成为可能,但这不意味着Keto就是解决权限问题的银弹,部署一个单独的服务来做中央式权限管理对运维的要求相当高,并且不是每一个项目都需要如此灵活的权限管理。Zanzibar所提出的这套基于Relation Tuple
的权限管理概念十分值得学习,它找到一个灵活性与复杂度的平衡点,使得相对简单的语法也能支持复杂多变的场景。