土法炼钢兴趣小组的算法知识备份

【身份与访问控制工程】Zanzibar 风格权限系统

文章导航

分类入口
architecturesecurity
标签入口
#zanzibar#authorization#ReBAC#SpiceDB#OpenFGA#permissions

目录

当一个系统的权限规则复杂到「某个文档的查看者包含其所在文件夹的查看者,而文件夹的查看者包含所属组织的管理员」这种递归关系时,传统的 RBAC 表和 ABAC 策略都会开始显得笨拙。Google 在 2019 年发表的 Zanzibar 论文,描述了他们如何用一个全球一致的关系元组存储,为 Drive、YouTube、Photos、Maps、Cloud 等几十亿用户规模的产品提供统一的授权服务。这篇文章拆解 Zanzibar 的设计思想、一致性模型,以及主流开源实现(SpiceDB、OpenFGA、Ory Keto)的工程取舍。

Zanzibar 架构

一、Google Zanzibar 的背景与贡献

1.1 论文出处与业务规模

Zanzibar 的正式名称是「Google’s Consistent, Global Authorization System」,由 Ruoming Pang 等人发表在 USENIX ATC 2019。论文披露了几个关键指标:

这不是一篇理论论文,而是一个跑了十几年、服务全球用户的生产系统的工程总结。因此它的设计选择每一项都有明确的代价与收益——理解这些权衡,比单纯模仿它的数据模型更重要。

1.2 Zanzibar 解决了什么问题

在 Google 之前,每个产品都有自己的授权逻辑:Drive 有文档 ACL,YouTube 有频道权限,Photos 有相册共享。这带来三个头疼的问题:

  1. 跨产品分享:在 Gmail 里分享一个 Drive 文档,需要两个系统协商权限;如果各自实现,一致性极难保证。
  2. 审计和合规:没有统一的「某个用户能访问哪些资源」的视图。
  3. 开发重复:每个团队都在重新实现「继承」「组」「公开 / 私有」这些相似的语义。

Zanzibar 的核心贡献是给出一个统一的、通用的、足够表达力的权限模型,让上层产品只需配置 Namespace,而不必自己写授权代码。

1.3 为什么不用传统 RBAC / ABAC

Zanzibar 属于 ReBAC(Relationship-Based Access Control)家族,把授权问题统一成图上的可达性问题:从 (user, object) 出发,能否通过若干关系边到达?这在 Drive、Docs 这种有「分享给 X,继承自文件夹 Y」语义的产品里极其自然。

关于 RBAC、ABAC、ReBAC 的取舍,可以参考 RBAC、ABAC、ReBAC:权限模型怎么选

二、核心数据模型

2.1 Relation Tuple:最小单元

Zanzibar 的所有权限数据都是关系元组(relation tuple),格式如下:

<namespace>:<object_id>#<relation>@<user_or_userset>

举几个例子(用 Drive 类比):

doc:readme#owner@user:alice
doc:readme#viewer@user:bob
doc:readme#parent@folder:engineering
folder:engineering#editor@group:eng-team#member
group:eng-team#member@user:carol

拆解每个字段:

第四个例子 folder:engineering#editor@group:eng-team#member 读作:「engineering 文件夹的编辑者,是 eng-team 这个组的 member 关系所指向的所有人」。这就是 ReBAC 的精髓——关系可以嵌套指向另一个集合,而不只是具体用户。

2.2 Namespace 配置

光有元组还不够,因为很多权限是推导出来的(「owner 天然是 editor」「文件夹的 viewer 自动成为子文档的 viewer」)。Zanzibar 用 Namespace 配置来描述这些推导规则。下面是一个 Drive 风格的 document namespace(用论文里的 protobuf 语法):

name: "document"

relation { name: "owner" }

relation {
  name: "editor"
  userset_rewrite {
    union {
      child { _this {} }
      child { computed_userset { relation: "owner" } }
    }
  }
}

relation {
  name: "viewer"
  userset_rewrite {
    union {
      child { _this {} }
      child { computed_userset { relation: "editor" } }
      child { tuple_to_userset {
        tupleset { relation: "parent" }
        computed_userset { object: $TUPLE_USERSET_OBJECT relation: "viewer" }
      }}
    }
  }
}

逐段解释:

用一句话描述:owner ⊆ editor ⊆ viewer;同时,文件夹的 viewer 也是文档的 viewer

2.3 三种 Userset Rewrite 原语

Zanzibar 只定义了少量组合原语,就能表达绝大多数实际需求:

原语 含义 典型用法
_this 当前 relation 直接写入的元组 所有基本关系
computed_userset 同一个 object 的另一个 relation 的集合 角色继承(owner → editor)
tuple_to_userset 通过 tupleset 跳到另一个 object,再取它的某个 relation 资源层级(文件夹 → 文档)
union / intersection / exclusion 集合的并、交、差 「是 editor 但不是 banned」这种复合规则

这四类原语的组合构成一个有限的、可静态分析的规则语言,这一点对后面讨论的一致性、缓存和性能非常关键——因为任何 check 请求都可以在编译期确定递归展开路径。

2.4 元组在存储里的样子

为了让后面讨论性能更具体,先把元组的物理形态画清楚。一条 doc:readme#viewer@group:eng-team#member 在 Spanner / Postgres 里通常是八列:

shard_id namespace object_id relation user_type user_id user_relation commit_time
42 doc readme viewer group eng-team member 2026-04-01T10:11:12Z

几条关键的索引设计原则:

这三条索引决策直接决定了 check 的延迟、ListObjects 的成本和 watch 的吞吐,后面几章会反复回到它们。

2.5 用户(User)其实也是对象

一个经常被忽略的细节:在 Zanzibar 里,user:alice 也只是一个 namespace 为 user、object 为 alice 的对象。它没有 relation 配置,本质上是一个叶子节点。这让模型保持高度一致——doc:readme#owner@user:alicefolder:eng#editor@group:team#member 在结构上是同一件事。

某些 namespace 甚至有一种特殊的 ...(通配 relation)写法,表示「any user in this namespace」:doc:public#viewer@user:* 意味着这个文档对所有登录用户可见。

三、Userset Rewrite 与权限推导

3.1 Check API 的语义

Zanzibar 的主接口是 check(namespace, object, relation, user, zookie),返回一个布尔值。它等价于判断:

在所有 rewrite 规则展开之后,user 是否属于 <namespace>:<object>#<relation> 所表达的集合?

展开过程是递归的。以 check("document", "readme", "viewer", "user:bob", ...) 为例:

  1. 读取 documentviewer rewrite,得到一个 union。
  2. 对 union 的每个子节点并行展开:
    • _this:查 doc:readme#viewer@user:bob 是否存在;查 doc:readme#viewer@<some userset> 中是否包含 bob。
    • computed_userset { relation: "editor" }:递归调用 check("document", "readme", "editor", "user:bob", ...)
    • tuple_to_userset:找到所有 doc:readme#parent@folder:X,对每个 X 递归调用 check("folder", X, "viewer", "user:bob", ...)
  3. 任一子节点返回 true 即整体返回 true;对 intersection 则要求全为 true;exclusion 则要求第一个 true 且第二个 false。

3.2 Expand API 与集合可视化

除了 check,Zanzibar 还提供 expand(namespace, object, relation),返回一棵子集树(subset tree),描述该 userset 由哪些子集并 / 交 / 差构成。expand 不直接判断某用户,而是给出集合的结构。

expand 在三种场景特别有用:

expand 的代价比 check 高得多,因为它必须展开整棵树而不能短路返回。生产中通常不作为在线 API 使用。

3.3 Read / Write / Watch API

除了 check / expand,Zanzibar 还暴露:

3.4 一次完整 check 的伪代码

把前面几节串起来,一次 check 的实现用伪代码大致是:

func Check(ns, obj, rel string, user User, zookie Zookie) bool {
    // 1. 查 namespace 配置,拿到这个 relation 的 rewrite
    rewrite := lookupNamespace(ns).Relation(rel).Rewrite

    // 2. 本地缓存命中?
    key := cacheKey(ns, obj, rel, user, zookie)
    if v, ok := cache.Get(key); ok {
        return v
    }

    // 3. 递归展开
    result := evalRewrite(rewrite, ns, obj, user, zookie, visited{})
    cache.Put(key, result)
    return result
}

func evalRewrite(node Node, ns, obj string, user User, z Zookie, v visited) bool {
    switch n := node.(type) {
    case *This:
        return checkDirect(ns, obj, n.relation, user, z) ||
               checkIndirectViaUserset(ns, obj, n.relation, user, z, v)
    case *ComputedUserset:
        return Check(ns, obj, n.relation, user, z)   // 递归
    case *TupleToUserset:
        for _, t := range readTupleset(ns, obj, n.tupleset, z) {
            if Check(t.userType, t.userId, n.targetRelation, user, z) {
                return true
            }
        }
        return false
    case *Union:
        for _, c := range n.children {
            if evalRewrite(c, ns, obj, user, z, v) { return true }
        }
        return false
    case *Intersection:
        for _, c := range n.children {
            if !evalRewrite(c, ns, obj, user, z, v) { return false }
        }
        return true
    case *Exclusion:
        return evalRewrite(n.base, ns, obj, user, z, v) &&
               !evalRewrite(n.subtract, ns, obj, user, z, v)
    }
}

真实实现里还要加并行化(union 子节点并发执行)、短路(union 任何一个 true 就取消其他)、hedging、visited 环检测等,但主干就是这个形状。理解这段代码,就理解了 90% 的 Zanzibar。

3.5 展开图的形状决定延迟

需要额外强调:Userset Rewrite 让授权判断变成图遍历。这意味着:

这也是为什么 namespace 设计时要警惕无限深度的层级——每多一层,check 就慢一点。

四、一致性模型:Zookies 与快照读

4.1 New-Enemy Problem

先讲一个经典陷阱。假设 Alice 和 Bob 本是朋友,Alice 共享了一个文档给 Bob。某天两人闹翻:

  1. Alice 先调用 remove(doc:secret#viewer@user:bob)——把 Bob 从 viewer 里移除。
  2. 紧接着 Alice 写入一条敏感评论:doc:secret#comment 内容「Bob is a jerk」。

现在 Bob 发起请求读取评论:

于是 Bob 读到了本不该看到的、专门骂他的评论。这就是 Zanzibar 论文里专门命名的 new-enemy problem:ACL 变更没有和内容读取严格按顺序看到。

Zanzibar 的解法不是简单地全部强一致读——那样延迟会爆炸——而是引入一种「因果一致性」凭证。

4.2 Zookie 是什么

Zookie 是 Zanzibar 返回给客户端的一个不透明令牌,本质上是一个 Spanner 的 TrueTime 时间戳(或足够精确的等价物)。它的生命周期如下:

  1. 客户端调用 write,Zanzibar 写入 Spanner 成功后返回 zookie_v1。
  2. 客户端把 zookie_v1 和业务内容一起持久化(例如存到评论表里)。
  3. 后续 check 调用时带上这个 zookie;Zanzibar 保证 check 的结果至少反映了 zookie 对应那一刻的权限快照

回到 new-enemy 例子:

  1. Alice 的 T1 remove 返回 zookie_T1。
  2. Alice 写评论时,应用层把 zookie_T1 存到评论行里,或者在写评论前做另一次 write 得到 zookie_T2,存 zookie_T2。
  3. Bob 读评论时拿到 zookie_T2,用它调 check——Zanzibar 保证 check 看到的是 T2 之后的快照,T1 的 remove 一定生效,返回 false。

关键洞察:客户端自己决定「检查时要不少于哪个版本」。如果你读的内容是老内容,就用老 zookie,允许读到便宜的、缓存的权限数据;如果你要读最新内容,就用最新 zookie,代价是可能需要等一小会儿。

4.3 快照读 vs 强一致读

因此 Zanzibar 同时支持两种 check:

实践中 > 99% 的 check 都是 snapshot read,只有涉及安全关键的「改密码后立即撤销所有 session」「revoke share 后立即生效」这种场景才用强一致。

4.4 Zookie 的另一个好处:缓存

因为 zookie 等价于时间戳,缓存键可以天然加上它:(object, relation, user, zookie) -> bool。两次相同 zookie 的 check 可以直接命中缓存。配合 watch API 做失效,缓存命中率可以做到 99%+。

4.5 Zookie 的序列化与不透明性

Zookie 对客户端必须是完全不透明的。论文里反复强调这一点:客户端不应该解析、比较、或者构造 zookie。理由有二:

  1. 实现自由度:Zanzibar 最初用 Spanner 的 TrueTime,后来可能换成混合时钟或其他方案,如果客户端硬编码了格式就锁死了。
  2. 跨区迁移:如果业务数据迁移到另一个 Zanzibar 集群,zookie 的语义可能变化;不透明 token 可以在迁移时做翻译层。

开源实现里,SpiceDB 的 ZedToken 是 base64 编码的 protobuf,OpenFGA 的 consistency token 是 base64 编码的分页 / 版本游标。都按不透明字符串对待。

4.6 一致性与缓存如何共存

初学者容易有个疑问:既然要缓存,又要一致性,不是矛盾吗?zookie 恰好解决了这个矛盾:

所以 Zanzibar 的设计思路不是「一致性 OR 性能」的二选一,而是「把一致性要求下放到客户端,按业务敏感度分档取舍」。

五、开源实现对比

Google 只发了论文没有开源代码。社区基于论文做了多个实现,其中最主流的三个是 SpiceDB(Authzed)OpenFGA(Auth0 / Okta 捐给 CNCF)Ory Keto。还有一些早期项目比如 Warden(Twitch 的内部实现,后来部分思路进入 Twitch 的生产系统但未完全开源)。

5.1 对比表

维度 SpiceDB OpenFGA Ory Keto Warden
发起方 Authzed Auth0 / Okta / CNCF Ory Twitch(部分)
协议 gRPC + REST HTTP + gRPC REST + gRPC gRPC
模式语言 自定义 Schema DSL 自定义 DSL + JSON Keto 配置 + OPA 接近论文 protobuf
Zookie 等价物 ZedToken continuation token 版本化 revision 未完全实现
一致性选项 5 档(minimize_latency → fully_consistent) 3 档(minimize_latency, at_least_as_fresh, higher_consistency) 最终一致为主 -
存储后端 Postgres、CockroachDB、Spanner、MySQL、内存 Postgres、MySQL、内存 Postgres、MySQL、CockroachDB 内部 KV
Watch API -
生态 / 成熟度 高,商业公司主推 高,CNCF 毕业路径 中,Ory 体系集成
语言 Go Go Go Go

5.2 SpiceDB Schema 示例

SpiceDB 的 schema DSL 相当贴近论文的 protobuf,但更易读:

definition user {}

definition usergroup {
  relation member: user | usergroup#member
}

definition folder {
  relation parent: folder
  relation owner: user
  relation viewer: user | usergroup#member

  permission view = viewer + owner + parent->view
}

definition document {
  relation parent: folder
  relation owner: user
  relation editor: user | usergroup#member
  relation viewer: user | usergroup#member

  permission edit = editor + owner
  permission view = viewer + edit + parent->view
}

几个语义要点:

客户端 check 调用(Go 示例):

resp, err := client.CheckPermission(ctx, &v1.CheckPermissionRequest{
    Consistency: &v1.Consistency{
        Requirement: &v1.Consistency_AtLeastAsFresh{
            AtLeastAsFresh: &v1.ZedToken{Token: storedZedToken},
        },
    },
    Resource: &v1.ObjectReference{ObjectType: "document", ObjectId: "readme"},
    Permission: "view",
    Subject: &v1.SubjectReference{
        Object: &v1.ObjectReference{ObjectType: "user", ObjectId: "bob"},
    },
})

5.3 OpenFGA 模型示例

OpenFGA 的 DSL 和 SpiceDB 类似但关键字不同:

model
  schema 1.1

type user

type usergroup
  relations
    define member: [user, usergroup#member]

type folder
  relations
    define parent: [folder]
    define owner: [user]
    define viewer: [user, usergroup#member]
    define view: viewer or owner or view from parent

type document
  relations
    define parent: [folder]
    define owner: [user]
    define editor: [user, usergroup#member]
    define viewer: [user, usergroup#member]
    define edit: editor or owner
    define view: viewer or edit or view from parent

check 调用(HTTP):

POST /stores/{store_id}/check
{
  "tuple_key": {
    "user": "user:bob",
    "relation": "view",
    "object": "document:readme"
  },
  "consistency": "HIGHER_CONSISTENCY"
}

OpenFGA 把「relation 和 permission」合并成了 define——有直接数据的是 relation,有表达式的是 permission。和 SpiceDB 的显式区分是一种风格差异。

5.4 怎么选

5.5 迁移示例:从 RBAC 表到 SpiceDB

假设原来有一张 user_roles 表和一张 role_resources 表,典型 RBAC:

SELECT 1 FROM user_roles ur
JOIN role_resources rr ON ur.role = rr.role
WHERE ur.user_id = ? AND rr.resource_id = ? AND rr.action = 'read';

迁移到 SpiceDB 分三步:

  1. 设计 schema
definition user {}
definition role {
  relation member: user
}
definition resource {
  relation reader: user | role#member
  permission read = reader
}
  1. 批量导入:把现有数据转成 relation tuple。
role:admin#member@user:alice
resource:doc1#reader@role:admin#member
  1. 双写双读:新老系统并行运行一段时间,check 结果做 diff 校验,没有偏差后切换。

这种迁移模式在任何授权系统演进中都值得参考:永远不要一刀切,先并行、再对比、最后切换。

六、工程实践:存储、缓存与延迟

6.1 存储:窄表 vs 宽表

Zanzibar 的存储在 Spanner 里就是一张极窄的关系表:

CREATE TABLE tuples (
  shard_id     INT64,
  namespace    STRING,
  object_id    STRING,
  relation     STRING,
  user_type    STRING,
  user_id      STRING,
  user_relation STRING,
  commit_time  TIMESTAMP,
  PRIMARY KEY (shard_id, namespace, object_id, relation, user_type, user_id, user_relation)
) INTERLEAVE IN PARENT ...;

开源实现也大多沿用这个思路。以 Postgres 为例,SpiceDB 的主表近似:

CREATE TABLE relation_tuple (
  namespace        VARCHAR NOT NULL,
  object_id        VARCHAR NOT NULL,
  relation         VARCHAR NOT NULL,
  userset_namespace VARCHAR NOT NULL,
  userset_object_id VARCHAR NOT NULL,
  userset_relation VARCHAR NOT NULL,
  created_xid      xid8 NOT NULL,
  deleted_xid      xid8 NOT NULL DEFAULT '9223372036854775807',
  PRIMARY KEY (namespace, object_id, relation, userset_namespace, userset_object_id, userset_relation, created_xid)
);

「为什么不做宽表、把一个 object 的所有 viewer 放一行」?三条理由:

  1. 单用户取消:从一条 10 万 viewer 的长 JSON 里删一个,锁争用和写放大都非常恐怖。
  2. 索引和 join:窄表让「给定 user 反向查能看哪些 doc」变成简单的二级索引扫描。
  3. Watch 流:逐条 tuple 的变更天然是一个 changelog;宽表得做 diff,复杂度高。

6.2 缓存:多层 + zookie 分片

Zanzibar 生产集群里每层都在做缓存:

Zookie 在缓存中作为版本一部分,天然避免了脏读:一次写入后新的 zookie 必然与旧 zookie 不同,旧缓存项不会被新 zookie 命中。

6.3 Hedged Requests 与 Tail Latency

论文里提到 Zanzibar 用了 Google 多个系统的老套路 hedged requests:并发给两台 replica 发同一个请求,谁先回就用谁。这把 p99 从数百毫秒压到几十毫秒,代价是后端多 20~30% 的负载。

开源实现里 SpiceDB 支持类似机制(--backend-hedging),在跨区部署时效果明显。

6.4 Fan-Out:Leopard 索引

最棘手的是深嵌套 group。假设 group:eng#member 里有 group:backend#membergroup:backend#member 里有 group:infra#member,这种嵌套五六层后,check 的递归次数爆炸。

Zanzibar 构建了一个专门的索引服务 Leopard:

开源实现里,OpenFGA 有 ListObjects / ListUsers 的专门路径但不是独立服务;SpiceDB 暂时没有完全等价的 Leopard。对嵌套不深(<= 3 层)的场景,单纯的在线递归 + 缓存已经够用。

6.5 延迟预算

Zanzibar 论文报告的典型 check 延迟:

百分位 延迟
p50 约 3 毫秒
p95 约 10 毫秒
p99 约 50 毫秒
p99.9 约 100 毫秒

自建时可以按这个做目标。SpiceDB 在高性能 Postgres + 本地缓存下,单实例 check p95 能做到 5~15 毫秒级别。OpenFGA 视存储不同在 10~30 毫秒。

6.6 容量规划的几个数量级

真实项目上 Zanzibar 风格服务前,几个数量级要心里有数:

6.7 监控指标清单

至少采集以下指标:

七、工程坑点

7.1 递归组环与无限展开

如果用户能自由创建组嵌套,就一定会有人(不管是恶意还是手滑)造出 group:A#member@group:B#membergroup:B#member@group:A#member。朴素递归会栈溢出或死循环。

Zanzibar / SpiceDB / OpenFGA 都会在 check 过程中记录已访问节点,遇到环就截断。但截断只影响这一次 check——环本身存在于数据中,第一次看到环的 expand 会报错或返回部分结果。

建议:写入时做环检测(见 SpiceDB 的 check_permission_relation 策略),或者在 schema 中用类型约束禁止特定嵌套。

7.2 Watch API 滞后导致脏缓存

Watch 本质是 polling 某个全局 commit 时间戳的增量,有几百毫秒到秒级的延迟。如果你用 watch 做跨服务缓存失效,那期间走老缓存的 check 会返回过期结果。

对策

7.3 Schema 迁移

加一个 relation 或 permission 几乎总是安全的;删除或重命名就非常麻烦——因为元组里引用了旧 relation 的名字,schema 变了之后那些老元组要么变成孤儿,要么导致 check 出错。

SpiceDB 提供 Schema Validation 和 Zed CLI 做迁移演练;OpenFGA 用「多模型版本」让新老 tuple 并存。但根本上,权限 schema 应当像数据库表结构一样谨慎演进,不要频繁 breaking change。

7.4 深层次的 check 延迟

前面提到的 parent->view->parent->view->... 这种层层递归,每加一层就多一轮网络 / 存储查询。真实项目里 Drive 风格的文件夹树深度偶尔超过 10 层,在跨区部署里延迟就会飙。

对策

7.5 大 object 的 viewer 列表

一个 namespace 下的 object 如果有 100 万 viewer(例如一个公司全员可见的公告),任何需要枚举 viewer 的操作(expand、ListUsers)都会 OOM 或超时。

对策

7.6 多租户隔离

SaaS 场景里不同租户的权限不能互相影响。Zanzibar 本身没有「tenant」概念,但可以通过 object_id 前缀(doc:tenant_123_readme)或独立 namespace(doc_tenant_123)做隔离。前者 schema 简单但有误拼风险;后者隔离强但 schema 数量爆炸。

实践中一般选前者 + 在 API 网关层做强校验,保证查询里必须带 tenant 前缀。相关模式在 多租户授权设计 里有更详细讨论。

7.7 调试困难

「为什么 Alice 能 / 不能访问这个文档」是运维最常被问的问题。没有 expand 或者 debug 工具的系统,运维只能靠翻日志猜。

所有主流 Zanzibar 实现都提供:

建议:上线前一定要把 check trace / expand 集成到内部工具里,不要等到用户投诉了再补。

7.8 写入幂等与 precondition

Zanzibar 的 write API 支持 precondition:「只有当某条 tuple 存在(或不存在)时,才执行写入」。这对避免并发写冲突极其重要。典型场景:撤销某个分享时,你想保证「如果 viewer 已经被别的并发撤销,就不要报错」。

write {
  delete: doc:readme#viewer@user:bob
  precondition: doc:readme#viewer@user:bob MUST EXIST
}

没有 precondition 的话,客户端就要自己做 read-modify-write 循环,碰到并发时容易死循环或误撤。

7.9 跨 namespace 引用的时序

如果 document namespace 引用了 folder namespace 的 viewer,而 folder 的 schema 还没 deploy,check 会失败。schema 演进必须先加被依赖方,再加依赖方;删除时反过来。这在微服务架构里经常踩——A 团队发布了引用 B schema 的配置,B 还没上,整个权限瘫痪。

7.10 Zookie 存储成本

给每条业务数据附加一个 zookie 字段(通常 40~100 字节)在数据量大的场景里会有感知。如果一张表每天新增千万行,这是每天 GB 级别的额外存储。折中方案:每分钟只存一个 zookie(全表共享),牺牲一点精度换存储。

八、适用与不适用场景

8.1 适用场景

协作型 SaaS(Google Docs、Notion、Figma 风格):资源有所有者、编辑者、查看者;资源有层级(workspace → folder → doc);分享关系频繁变化。Zanzibar 几乎是为这种场景设计的。

文档 / 对象存储权限:S3 式 bucket / object 继承、内部知识库的文档权限。

社交图 / 关注关系:Twitter 的「following」「blocked」本身就是 relation tuple,Pinterest 的 board permission 是 Zanzibar 的典型用例。

多租户 B2B 平台:每个客户组织内部有独立的角色树,客户自己还能嵌套子组织。ReBAC 的表达力在这里很关键。

带继承的大资源树:Kubernetes 集群的 namespace → workload、云控制台的 org → folder → project → resource。

8.2 不适用场景

简单 CRUD 应用,三五个角色:一个用户表加一个 role 字段就能解决的事情,别上 Zanzibar。一个授权服务的运维成本远高于 RBAC。

纯属性决策(合规、密级、地理位置):「只有 HR 部门且在办公网内的员工能看人事数据」属于 ABAC 的天然场景,Zanzibar 的 relation 模型没有属性概念(SpiceDB 的 Caveats 是一种补丁,但不如 OPA / Cedar 灵活)。

亚毫秒级延迟要求:Zanzibar p99 是 10~100 毫秒量级。如果是交易系统的每笔订单都要 check,且不能容忍缓存穿透,请考虑把权限预计算进请求上下文(例如在 JWT 里放权限位图)。

不允许外部依赖:授权服务作为集中组件是一个额外的失败点。如果业务的可用性预算不允许引入新的关键路径,可以考虑把权限数据 sidecar 化(SpiceDB 支持 read-only replica)或者完全嵌入到业务服务里。

权限规则高频变化且无法复用:Zanzibar 的优势在于 schema 稳定、元组频繁变。如果你的权限规则本身每天都在变(比如营销活动的动态白名单),那本质是业务数据,用业务表更合适。

8.3 决策简表

特征 推荐
资源有层级、共享关系复杂 Zanzibar / ReBAC
只有用户 → 角色 → 权限三层 RBAC
决策依赖用户 / 资源属性 ABAC(OPA、Cedar)
纯策略代码、无持久关系数据 策略引擎(见下一篇)
混合:既有关系又有属性 ReBAC + Caveats,或 Zanzibar + OPA 联动

授权引擎的另一派——OPA、Cedar 等策略代码方案——在 OPA、Cedar 与策略引擎落地 里专门展开,它们和 Zanzibar 是互补而非竞争关系:很多大规模系统是 Zanzibar 管关系、OPA / Cedar 管属性规则,两层组合使用。

如果你在下面任一问题上回答「是」,那通常意味着现在还不该自建 Zanzibar

  1. 你的权限模型还在频繁变,连 namespace / relation 命名都没稳定。
  2. 业务里真正复杂的主要是属性判断,而不是共享关系或资源继承。
  3. 团队还没有准备好为授权服务承担独立的可用性、缓存、一致性和审计责任。
  4. 当前系统用 RBAC + 少量 ACL 就能覆盖 90% 需求,痛点只是个别边角。

九、参考资料


上一篇RBAC、ABAC、ReBAC:权限模型怎么选

下一篇OPA、Cedar 与策略引擎落地

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-21 · architecture / security

【身份与访问控制工程】B2B SaaS 多租户权限设计

B2B SaaS 的权限问题远比 B2C 复杂:多个企业客户、各自的内部角色体系、跨租户协作、行列级数据权限、租户自助管理。本文从隔离模型出发,给出租户内 RBAC + 租户间 ReBAC 的混合方案、超级管理员设计、行级权限实现,以及 GitHub、Slack、Notion 的权限模型速览。

2026-04-21 · architecture / security

【身份与访问控制工程】API Gateway、BFF 与边界认证授权

API Gateway 能做粗粒度的认证鉴权,但细粒度授权必须下沉到服务层。本文梳理 Gateway/BFF/Service 三层认证职责边界,讨论 Token 验证位置选择、RFC 8693 Token Exchange、服务间身份传播(JWT/mTLS/SPIFFE),以及 Istio、Kong、APISIX 的认证授权能力对比。


By .