Zanzibar 与 ReBAC 深度实战:从关系元组、一致性外部化到 SpiceDB / OpenFGA 的工程全解

执行摘要:当你的授权逻辑从"这个用户是不是 admin"变成"这份文档的第 3 层子目录下、被共享给某个团队的成员、且该成员未被显式排除"时,RBAC 就已经失效了。Google Zanzibar 给出的答案不是更复杂的角色模型,而是把授权抽象成一张全局关系图上的可达性问题:权限 = 从用户节点出发,能否沿着边走到资源节点。本文拆解关系元组(Relation Tuple)的数据模型、一致性外部化(zedtoken)这一最容易被误解的设计、Caveat 条件授权的代价,以及 SpiceDB / OpenFGA 在生产落地时的七个工程陷阱。

一、问题的起点:RBAC 到底在哪一层崩掉

大多数系统的权限演进路径是:

  1. 早期:if (user.role == "admin") —— 内联判断,写到业务逻辑里。
  2. 中期:RBAC —— 角色表 + 权限表,user → role → permission。
  3. 后期: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)。

  1. t0:alice 把 doc:readme 分享给 bob,bob 成为 viewer。
  2. t1:alice 移除 bob 所在 group:eng 的成员 charlie,并新增一条敏感评论。
  3. 如果读权限检查命中了旧副本(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
    }(),
})

代价必须讲清楚:

  1. 带 caveat 的元组无法被缓存。同一个 (object, relation, user) 在不同上下文下结果不同,缓存键必须包含上下文——而上下文(IP、时间)基数极高。
  2. LookupResources / LookupSubjects 对 caveat 的支持有限:无法在服务端求值上下文的反向查询,通常只能返回"可能有权"的候选集,由调用方二次过滤。
  3. 表达式求值发生在每个匹配的元组上。一条查询若命中 5000 个带 caveat 的元组,就是 5000 次求值。

建议:caveat 只用于低基数、可枚举的条件(如"租户 ID 匹配"、"文档状态 == published")。时间窗口、IP 段这类高基数条件,放在应用层做二次过滤更划算。


五、生产落地:SpiceDB vs OpenFGA,以及七个坑

5.1 选型

维度SpiceDBOpenFGA
出身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 关系型授权的核心工程机制,可作为构建细粒度授权系统的参考。*

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部