Zanzibar 与 ReBAC 深度实战:从关系元组、一致性外部化到 SpiceDB / OpenFGA 的工程全解
执行摘要:当你的授权逻辑从"这个用户是不是 admin"变成"这份文档的第 3 层子目录下、被共享给某个团队的成员、且该成员未被显式排除"时,RBAC 就已经失效了。Google Zanzibar 给出的答案不是更复杂的角色模型,而是把授权抽象成一张全局关系图上的可达性问题:权限 = 从用户节点出发,能否沿着边走到资源节点。本文拆解关系元组(Relation Tuple)的数据模型、一致性外部化(zedtoken)这一最容易被误解的设计、Caveat 条件授权的代价,以及 SpiceDB / OpenFGA 在生产落地时的七个工程陷阱。
一、问题的起点:RBAC 到底在哪一层崩掉
大多数系统的权限演进路径是:
- 早期:
if (user.role == "admin")—— 内联判断,写到业务逻辑里。 - 中期:RBAC —— 角色表 + 权限表,
user → role → permission。 - 后期:ACL / ABAC / 自定义策略 —— 每个业务自己写一遍判断。
崩掉的位置很明确:RBAC 表达的是"谁是什么",而业务需要的是"谁对哪个具体对象能做什么"。一旦权限取决于对象与对象之间的结构关系(父子、归属、共享链),角色就退化成了一个粗糙的布尔开关。
举一个真实场景:
用户 A 能否编辑doc:readme?规则是:文档的所有者可以;文档所在文件夹的editor可以;文件夹继承自其父文件夹;且用户没有被文档的blocked关系排除。
用 RBAC 表达这段逻辑,你需要在写入时把权限物化成一行行的中间表记录,然后在目录移动、团队变更时手动反算。这就是所谓"权限膨胀"(permission sprawl)——数据冗余 + 更新风暴。
Zanzibar 的核心洞察:不要物化,要推导。权限在查询时沿着关系图实时计算。
二、数据模型:一切都是 <namespace>:<object>#<relation>@<subject>
Zanzibar 的全部数据只有一种结构,叫关系元组:
relation_tuple = (object, relation, user, [caveat])
具体形式:
# 直接关系
doc:readme#owner@alice
doc:readme#blocked@bob
# 用户集(userset)作为主语:把一组人整体授予
folder:eng#viewer@group:backend#member
# 主语本身也是一条关系(嵌套)
doc:roadmap#parent@folder:eng
注意 folder:eng#viewer@group:backend#member 这一行——主语不是某个用户,而是另一个对象的关系所指向的用户集合。这是 Zanzibar 表达力的关键:用户集可以嵌套,图上一条边可以指向一片子图。
2.1 Schema:用推导规则把图连起来
光有元组不够,还需要定义"某个 relation 如何被计算出来"。SpiceDB 的 schema DSL:
definition user {}
definition group {
relation member: user
}
definition folder {
relation parent: folder
relation viewer: user | group#member
relation editor: user | group#member
// 继承:父文件夹的 viewer 也是子文件夹的 viewer
permission view = viewer + parent->view
permission edit = editor + parent->edit
}
definition doc {
relation parent: folder
relation owner: user
relation blocked: user
permission view = owner + parent->view
permission edit = owner + parent->edit - blocked
}
几个必须讲清楚的语义:
permission是计算出来的,不是存储的。它不能作为元组写入。parent->view是 tuple-to-userset(TTU) 箭头:取本对象parent关系指向的那个 folder,再求它的view。这是实现"继承"的唯一机制。+是并集,-是差集,&是交集。- blocked让显式排除永远压过继承来的权限——这类否定必须在 schema 层显式写出,Zanzibar 不做"隐式拒绝优先"的假设。user | group#member声明了这个 relation 可以写入的 subject 类型。类型写错了会在写入时被 schema 校验拦下。
2.2 查询就是图可达性
# 单次检查
zed permission check doc:readme edit alice
# 返回: true(因为 alice 是 owner)
# 展开解释(排障神器,一定要开)
zed permission check doc:readme edit alice --explain
LookupResources("列出所有 alice 能编辑的文档")和 LookupSubjects("列出所有能编辑 readme 的人")是反向遍历。这两个 API 在大数据量下是性能重灾区,因为它们无法用单个索引命中,通常需要扫用户集索引。生产上要给它们单独设超时和限流。
三、一致性外部化:zedtoken 是 Zanzibar 最反直觉的设计
这是本文最想强调的一点。
Zanzibar 论文里有个著名场景:"新敌人问题"(New Enemy Problem)。
t0:alice 把doc:readme分享给 bob,bob 成为 viewer。t1:alice 移除 bob 所在group:eng的成员 charlie,并新增一条敏感评论。- 如果读权限检查命中了旧副本(charlie 还在组里),而读到的评论是新数据(含敏感内容),就出现了"新敌人"——charlie 看到了本不该看的东西。
Zanzibar 的解法不是搞分布式事务,而是把一致性选择权交给调用方:
// 写操作返回 zedtoken(一个编码了"至少包含此时间点所有写入"的不透明令牌)
resp, _ := client.WriteRelationships(ctx, &v1.WriteRelationshipsRequest{
Updates: []*v1.RelationshipUpdate{
{
Operation: v1.RelationshipUpdate_OPERATION_CREATE,
Relationship: rel,
},
},
})
zedtoken := resp.WrittenAt.ZedToken // 必须持久化!
// 后续读操作带上它
client.CheckPermission(ctx, &v1.CheckPermissionRequest{
Resource: &v1.ObjectReference{ObjectType: "doc", ObjectId: "readme"},
Permission: "view",
Subject: &v1.SubjectReference{Object: &v1.ObjectReference{ObjectType: "user", ObjectId: "charlie"}},
Consistency: &v1.ConsistencyRequirement{
Requirement: &v1.ConsistencyRequirement_AtLeastAsFresh{
AtLeastAsFresh: zedtoken,
},
},
})
三种一致性级别,语义差别极大:
| 级别 | 语义 | 延迟 | 适用场景 |
|---|---|---|---|
minimize_latency | 可能读到任意旧副本 | 最低 | 列表页、非敏感资源 |
at_least_as_fresh | 至少包含指定 zedtoken 的写入 | 中 | 绝大多数业务默认选这个 |
full_consistency | 走主库,线性一致 | 最高 | 权限变更后的即时校验 |
工程要点:
- zedtoken 必须随资源一起存储。通常把它写进业务表的一列(如
docs.permission_zedtoken),读文档时顺带取出,做权限检查时用。 - 不存 zedtoken 就等于默认
minimize_latency,也就等于默认接受了新敌人问题。这是绝大多数 Zanzibar 落地方案翻车的地方。 - zedtoken 不能跨资源复用——它是全局写入水位,不是 per-object 版本号。
SpiceDB 底层用一份按 revision 排序的元组存储(PostgreSQL / CockroachDB / 内存),zedtoken 编码的就是 revision。读副本落后时,请求会等待或转发到主库。
四、Caveat:条件授权很香,但代价要算清
现实里很多权限带条件:"仅在办公时间内可访问"、"仅在这个 IP 段内可编辑"。Zanzibar 用 Caveat(带上下文的条件表达式)表达:
definition doc {
relation viewer: user with ip_allowlist
permission view = viewer
}
caveat ip_allowlist(cidr string, user_ip ipaddress) {
user_ip.in_cidr(cidr)
}
写入时携带条件与上下文:
zed relationship create doc:roadmap viewer user:alice --caveat 'ip_allowlist={"cidr":"10.0.0.0/8"}'
检查时传入上下文:
client.CheckPermission(ctx, &v1.CheckPermissionRequest{
// ...
Context: func() *structpb.Struct {
s, _ := structpb.NewStruct(map[string]interface{}{
"user_ip": "10.1.2.3",
})
return s
}(),
})
代价必须讲清楚:
- 带 caveat 的元组无法被缓存。同一个
(object, relation, user)在不同上下文下结果不同,缓存键必须包含上下文——而上下文(IP、时间)基数极高。 LookupResources/LookupSubjects对 caveat 的支持有限:无法在服务端求值上下文的反向查询,通常只能返回"可能有权"的候选集,由调用方二次过滤。- 表达式求值发生在每个匹配的元组上。一条查询若命中 5000 个带 caveat 的元组,就是 5000 次求值。
建议:caveat 只用于低基数、可枚举的条件(如"租户 ID 匹配"、"文档状态 == published")。时间窗口、IP 段这类高基数条件,放在应用层做二次过滤更划算。
五、生产落地:SpiceDB vs OpenFGA,以及七个坑
5.1 选型
| 维度 | SpiceDB | OpenFGA |
|---|---|---|
| 出身 | AuthZed,Zanzibar 最忠实实现 | 原 Auth0 FGA,CNCF 项目 |
| 建模语言 | SpiceDB Schema(Zed) | OpenFGA DSL(JSON/YAML 或专用语法) |
| 一致性 | 完整支持 zedtoken 三档 | 提供 consistency 参数,语义略简化 |
| 存储 | PostgreSQL / CockroachDB / Spanner / 内存 | PostgreSQL / MySQL |
| 特色 | Caveat、 --explain 排障、Lookup API 齐全、Watch API | 生态绑定(Auth0/Okta)、模型更轻量 |
需要强一致性保证和复杂条件授权 → SpiceDB。快速接入、已有 Auth0/Okta 体系 → OpenFGA。
5.2 七个坑
1. 把 permission 当成 relation 写。 schema 校验会拦,但很多人会绕过校验直接操作存储层。别这么做。
2. 忘记存 zedtoken。 见第三节。这是头号事故源。
3. 用 Zanzibar 做行级过滤。 LookupResources 返回 ID 列表,你还得拿这批 ID 去业务库查详情 + 排序 + 分页。权限层分页与业务层分页无法合成一个,超过几千条结果就别指望性能了。正确姿势是:业务查询走自己的索引拿候选集,再用 CheckPermission 批量过滤(注意批量上限)。
4. 建模时把关系画反。 "用户属于哪个组"应写成 group:eng#member@alice(主语是用户),不是 user:alice#group@group:eng。方向反了会导致所有箭头推导失效。判断标准:主语(@右边)应当是最终能落到 user 的那个方向。
5. 无限递归。 A -> parent -> A 的环会让推导爆栈。schema 校验能发现静态环,但运行期的环(数据里出现自引用元组)要靠写入校验拦截。
6. 每个请求都查权限。 单次 Check 在 SpiceDB 上是毫秒级,但一个渲染 200 个项目的列表页就是 200 次 RPC。必须做批量 Check(CheckBulkPermissions)或应用层批量合并。
7. 权限变更没有审计。 关系元组是安全边界的核心数据,必须用 Watch API 把所有变更同步到审计日志。谁在什么时候把谁加进了哪个组——这是合规审计的必答题。
5.3 一个最小可跑的接入骨架
import grpc
from authzed.api.v1 import Client, CheckPermissionRequest, ObjectReference, SubjectReference, ConsistencyRequirement
client = Client("localhost:50051", grpc.ssl_channel_credentials())
def can_edit(user_id: str, doc_id: str, zedtoken: str | None) -> bool:
req = CheckPermissionRequest(
resource=ObjectReference(object_type="doc", object_id=doc_id),
permission="edit",
subject=SubjectReference(
object=ObjectReference(object_type="user", object_id=user_id)
),
)
if zedtoken:
req.consistency.CopyFrom(ConsistencyRequirement(
at_least_as_fresh=_parse_zedtoken(zedtoken)
))
else:
req.consistency.CopyFrom(ConsistencyRequirement(
fully_consistent=True # 没有 token 时宁可慢,不要不安全
))
return client.CheckPermission(req).permissionship == CHECK_RESULT_ALLOWED
关键在最后那个 else:拿不到 zedtoken 时的默认策略应该是"慢而正确",不是"快而危险"。
六、结论
Zanzibar 的真正贡献不是"又一个权限系统",而是把授权问题的复杂度从业务代码转移到了一张声明式的图上:
- 权限不再物化,因此没有权限膨胀和更新风暴;
- 一致性不再是分布式事务问题,而是一个由调用方显式携带的令牌;
- 授权逻辑从散落各处的
if收敛为一份可版本化、可 review、可测试的 schema。
但它不是银弹。它解决的是判定(能不能做),不解决过滤(给我能做的所有东西)。后者仍需业务索引配合。评判一个 Zanzibar 落地是否合格,只看一条标准:每一次权限检查,是否都携带了正确的一致性要求。做到了,它是一个可演进十年的权限基座;做不到,它只是一个更慢的 RBAC。
*本文从关系元组数据模型、TTU 推导规则与一致化外部化机制,到 Caveat 条件授权的代价与 SpiceDB / OpenFGA 的生产选型,拆解了 Zanzibar 关系型授权的核心工程机制,可作为构建细粒度授权系统的参考。*

发表评论 取消回复