Zanzibar 风格 ReBAC 建模:把对象关系、userset 与列表查询讲透
一家协作文档团队先用 owner/editor/viewer 三个角色上线共享功能。企业客户接入后,文件夹继承、团队嵌套、外部访客和项目归档同时出现:为了让孙目录继承权限,开发把父目录 ID 拼进缓存键;为了支持群组,又在业务库里递归查成员。一次团队移除成员后,页面列表已经隐藏文档,旧下载链接却仍被另一个缓存放行。问题不是角色不够多,而是关系事实、推导规则、列表查询和撤权新鲜度分散在四套实现里。
另一场决策冲突来自模型演进。产品想删除 legacy_viewer relation,平台团队在测试模型里删掉定义后认为变更完成,线上却还存着数百万条旧 tuple;新版本拒绝加载或旧数据成为无法解释的悬挂边。业务希望“直接删字段快速收口”,授权团队则要求先迁移关系、双读比较再切换。ReBAC 的难点从来不只是画一张关系图,而是让图的每条边都有创建、读取、演进和删除责任。
先拆开论文模型与可安装产品
Zanzibar 论文描述的是 Google 内部的全球授权系统。论文公开了 relation tuple、namespace configuration、userset rewrite、Check、Read、Expand、Watch、Zookie 与 New Enemy Problem 等模型和架构思想;它不是可以下载的服务器、开放 API 标准,也没有提供一套让第三方产品通过“Zanzibar 兼容认证”的测试。
因此,“Zanzibar 风格”只说明建模思想受到公开论文影响。Google 内部的 Spanner/TrueTime、全球副本布局、aclserver、watchserver、Leopard 索引、容量规模、延迟与可用性指标,都不能自动转授给 OpenFGA、SpiceDB 或自研系统。OpenFGA 的 store/model/tuple 与 SpiceDB 的 schema/relationship/permission 也不是 Google 内部接口的复刻;即使 API 名字相似,请求、分页、一致性 token、错误和索引行为仍由各产品定义。
为了让读者能动手,下面固定 OpenFGA v1.18.1 与官方 fga CLI 作为实验载体。选择它只是因为模型文件易读、容器入口简单;实验验证的是 ReBAC 关系语义,不是 Google Zanzibar 的内部实现、SLO 或一致性。生产选择 OpenFGA 还是 SpiceDB,要重新比较存储、反向查询、一致性、HA 与迁移能力。
object、relation 与 userset 到底是什么
先看最小事实:
document:q3#viewer@user:alicedocument:q3 是 object,表示类型为 document、ID 为 q3 的资源;viewer 是 relation,表示主体与对象之间的命名关系;user:alice 是直接主体。三者合起来是一个物化关系事实,不等于“Alice 一定能执行所有读取动作”。模型可以让 viewer 推导出 can_view,业务 PEP 再把 can_view 映射到下载、预览或读取 API。
Zanzibar 论文中 tuple 右侧的 user 既可以是直接用户,也可以是另一个 object#relation,后者就是 userset:
folder:roadmap#viewer@team:eng#member它不是把 eng 团队当前成员复制到 folder 上,而是引用“team:eng 的 member 集合”。Alice 是否可看文件夹,要先求团队成员集合,再判断她是否在其中。团队成员变更后,所有引用该 userset 的资源都随模型求值改变,避免逐资源扇出更新。
relation 定义的是一类集合,rewrite 定义集合如何计算。公开论文中的叶子包含 this、computed userset 与 tuple-to-userset,并可做 union、intersection 和 exclusion。OpenFGA DSL 会把这些概念表达成直接类型限制、同对象的 computed relation,以及 viewer from parent 一类 tuple-to-userset。名字不同,主线相同:先读取物化边,再按模型组合集合,最终对指定主体求 membership。
安装一个可丢弃的建模实验室
准备 Docker、curl 和官方 fga CLI,把服务固定到 v1.18.1。容器使用 memory datastore,端口只绑定本机时更安全:
docker run --rm --name openfga-rebac \
-p 127.0.0.1:8080:8080 \
-p 127.0.0.1:8081:8081 \
openfga/openfga:v1.18.1 run \
--datastore-engine memory \
--playground-enabled=false这里 --datastore-engine memory 明确选择易失存储,--playground-enabled=false 避免启用已弃用且只支持无认证模式的 Playground。HTTP API 在 8080,gRPC 在 8081。预期 /healthz 返回健康状态;若容器立即退出,先检查镜像版本、端口占用与启动日志,不要改成 latest 掩盖版本错误。
创建练习 store,并从返回 JSON 中保存 id 为 STORE_ID:
curl -sS -X POST http://127.0.0.1:8080/stores \
-H 'content-type: application/json' \
-d '{"name":"rebac-lab"}'
export FGA_API_URL=http://127.0.0.1:8080
export FGA_STORE_ID=<STORE_ID>若使用 PowerShell,可把环境变量改为 $env:FGA_API_URL 与 $env:FGA_STORE_ID。生产配置不能沿用无认证 HTTP:至少需要持久 datastore、OIDC issuer/audience、TLS 终止、只读或管理客户端分权、日志和指标。OpenFGA 关键字段可通过 YAML、flag 或 OPENFGA_* 环境变量设置,例如 datastore.engine / OPENFGA_DATASTORE_ENGINE、datastore.uri / OPENFGA_DATASTORE_URI、authn.method、authn.oidc.issuer、authn.oidc.audience、check/list deadline、并发读取与最大结果数。敏感 URI 应由 Secret 注入,不能提交到模型仓库。
从直接共享推进到嵌套和继承
把下面模型保存为临时 model.fga:
model
schema 1.1
type user
type team
relations
define member: [user, team#member]
type folder
relations
define parent: [folder]
define viewer: [user, team#member] or viewer from parent
type document
relations
define parent: [folder]
define editor: [user]
define viewer: [user, team#member] or editor or viewer from parentteam.member 接受 user,也接受 team#member,于是支持组嵌套;folder.viewer 接受直接用户、团队成员集合,并从父文件夹继承 viewer;document.viewer 再合并直接 viewer、editor 与父文件夹 viewer。这个模型把“事实”和“推导”分开:谁属于团队、文档父级是谁属于事实,editor 是否自然拥有 viewer、父级 viewer 是否传播属于模型。
用 CLI 发布模型:
fga model write --file model.fga预期返回新的 authorization model ID。把它保存为 MODEL_ID,后续写入与查询都显式指定;不指定时服务可能使用 store 中最新模型,模型一旦并存,这会把“最后发布”变成隐蔽配置漂移。OpenFGA 的 authorization model 是不可变对象,修改模型会创建新 ID,这为影子比较和回滚保留了入口。
写入四条事实:
team:platform#member@user:alice
team:eng#member@team:platform#member
folder:roadmap#viewer@team:eng#member
document:q3#parent@folder:roadmap使用 fga tuple write 时按 CLI 当前帮助把 user、relation、object、store ID 和 model ID 传入。不同 CLI 发行版的参数拼写可能调整,所以先执行 fga tuple write --help,但不要省略 store/model 身份。写完后 Check user:alice、viewer、document:q3,预期为 allowed: true。
这条正向路径有四跳:document q3 的 parent 指向 folder roadmap;folder viewer 引用 team eng 的 member userset;eng member 又引用 platform member;Alice 是 platform 直接成员。Read 读取 q3 的 viewer tuple时不会凭推导返回 Alice,因为 q3 没有物化 viewer 边;Check 才会执行 rewrite,Expand 可以展示 userset tree。把 Read 当“谁能访问”会漏掉继承和嵌套。
嵌套不是任意递归
允许 team#member 引用另一个 team#member,不代表可以放心写任意图。合法的组织层级最好是有向无环图,并设置可解释的最大业务深度。下面两条关系构成环:
team:a#member@team:b#member
team:b#member@team:a#member对一个不可达用户做 Check,执行器需要记录已访问子问题、深度和分支来终止搜索。不同产品可能去重、截断或返回深度错误,不能把“没有在预算内找到”默认为 deny,更不能把提高最大深度当作修复。SpiceDB 命中 dispatch-max-depth 会返回 max depth exceeded 请求错误;OpenFGA 同样应避免通用递归元模型,并用 deadline、并发与深度预算约束爆炸。
写入侧应在创建嵌套关系前检查反向可达性:若准备写 a -> b,先确认 b 不能到达 a。并发写仍可能绕过先查后写,因此需要在业务权威库中串行化同一组织域,或用拓扑层级、祖先闭包与唯一约束阻止成环。若授权数据库不是组织结构的权威来源,就在上游目录系统防环,再把经验证的边同步过去。
递归还有另一类成本:即使无环,一条两千层的管理链也可能超过执行预算。团队应为“合理最大层级”和“单节点最大直接成员”建立业务约束,观测 Check 的 dispatch/read 计数、深度、分支、尾延迟与超时。生产阈值来自真实关系分布和 SLO,不应复制实验数字。
列表查询不是 N 次 Check
单点 Check 回答“某人能否看某文档”;列表页回答“某人能看哪些文档”;管理员页还可能问“谁能看某文档”。三个问题的索引方向不同。OpenFGA ListObjects 从 user、relation 与 object type 找对象,ListUsers 从 object 与 relation 找 users/usersets;SpiceDB 对应 LookupResources 与 LookupSubjects。Zanzibar 论文的 Read 可以按 tuple 过滤读取直接事实,Expand 返回有效 userset tree,但论文没有定义与现代产品完全同名、完全兼容的 ListObjects API。
在正向实验里执行 ListObjects(user:alice, relation:viewer, type:document),预期包含 document:q3;再执行面向 q3 的 ListUsers,预期能沿 team 嵌套找到 Alice。随后 Read document:q3 的 viewer 物化 tuple,预期不会直接出现 Alice。这组三联结果证明:事实读取、集合展开与反向列表承担不同职责。
不要从业务库拉出十万文档再发十万次 Check。这样会造成网络扇出、授权服务排队、列表超时与分页漂移。优先使用 List/Lookup;若产品能力与业务排序、全文检索不能直接组合,可维护由关系变更流驱动的候选索引,再用批量 Check 做最终过滤。索引只能缩小候选集,不能绕过最终授权;上下文 tuple 或动态条件若没有进入索引重建链,结果必须标记不完整或回到权威 Check。
分页必须固定模型版本和一致性语义。第一页按 model A、第二页按 model B,会出现重复或漏项;长期持有的快照 token 也可能因 GC 过期。客户端应把 page token 当不透明值,过期时重新开始列表,而不是静默切到更陈旧模式。为 Check 与列表设置独立并发池、deadline、结果上限和指标,避免大列表拖垮登录或单点授权。
用反向实验暴露模型错误
第一个反例是类型越界。模型规定 document.parent 只能引用 folder,尝试写入:
document:q3#parent@user:alice预期模型校验拒绝写入。若自研系统接受它,后续 viewer from parent 就必须面对“user 上没有 viewer relation”的运行时分支,错误从写入期延迟到请求期。类型限制不是文档注释,而是关系数据的 schema。
第二个反例是循环。写入前述 team a/b 环,再对 user:charlie 做 membership 或继承 Check。预期应得到可观察的计算错误、深度限制或明确终止,不应把超时包装成 allow。排查时先取得解释树、dispatch 深度、读取次数和 deadline;删除环边后重试,若尾延迟和深度回落,才能证明根因已消除。
第三个反例是撤权陈旧。删除:
team:platform#member@user:alice随后再次 Check 与 ListObjects。OpenFGA v1.18.1 若启用了检查缓存,默认 MINIMIZE_LATENCY 可能短暂读旧值;高风险复查应传 HIGHER_CONSISTENCY,预期拒绝且列表不再包含 q3。它只是绕过 OpenFGA cache 直接查 datastore,没有返回或接收 Zookie 类 token。SpiceDB 则应保存删除返回的 ZedToken,并用 at_least_as_fresh 复查。两种产品的做法不能混写。
第四个反例是“只删对象,不删关系”。业务库删除 document:q3 后,授权库仍可能保留 q3 的 parent、viewer、条件与反向索引记录,因为关系授权服务通常不知道业务对象是否存在。重建同名 ID 时,旧关系甚至可能复活权限。资源删除流程应先标记 deleting、阻断新访问,幂等删除所有以该对象为 resource 或 subject 的关系,核对列表与变更流,再删除内容或进入保留期;ID 永不复用能进一步降低复活风险。
模型演进要和数据演进分开
假设要把 legacy_viewer 合并到 viewer。危险做法是直接从模型删除 relation,再希望旧 tuple 自动消失。OpenFGA 新模型虽然可以创建成功,旧模型和 tuple 仍可能存在;具体兼容校验取决于写入/查询所用 model ID。SpiceDB 写 schema 时若删除仍被 relationships 引用的 relation,可能直接失败。两者都说明“模型文本可编译”不等于“线上关系已迁移”。
稳妥演进分为五个阶段。先添加新 relation 与兼容推导,让新旧数据都能得到相同决定;再让写路径双写新旧关系,并记录幂等迁移游标;后台把存量旧 tuple 转换成新 tuple;对真实请求用旧模型与新模型影子 Check/List,分别统计 allow→deny、deny→allow、错误与列表差异;差异归零且撤权实验通过后,切读到新模型,停止旧写,最后删除旧关系数据,再删除旧 relation。
回滚点必须放在每个不可逆动作之前。只要旧模型仍可读、旧 tuple 仍在双写,就能把 PEP 配置切回旧 model ID;一旦清理旧 tuple,回滚就需要从导出恢复。迁移日志至少保存 source tuple、target tuple、模型版本、状态、重试次数与错误原因,但不能记录敏感用户属性原文。模型仓库应要求 CODEOWNERS 审查、fixture 测试和语义 diff,避免格式化通过却发生权限扩大。
删除反例还包括主体删除。员工离职时只从身份系统禁用账号,授权图中的直接关系、团队 userset 与外部分享仍会存活;若主体 ID 后来被复用,新员工可能继承旧权限。主体生命周期事件应触发关系清理和反向核对,稳定 ID 使用不可复用标识。删除完成的证据不是“DELETE API 返回成功”,而是直接关系为零、反向列表为空、抽样 Check 拒绝、变更消费者追平且旧缓存过期或被主动清除。
接入项目时保持身份与资源不可伪造
业务代码可以只暴露一个领域接口:
type AuthorizationResult =
| { kind: "allow"; modelId: string; revision?: string }
| { kind: "deny"; modelId: string; reason: string }
| { kind: "indeterminate"; reason: string };
interface AuthorizationService {
check(input: {
tenantId: string;
subjectId: string;
permission: string;
resourceType: string;
resourceId: string;
consistency?: string;
}): Promise<AuthorizationResult>;
}控制器不能直接采用请求体里的 subjectId;它来自认证 token 经服务端映射后的稳定 ID。resource ID 先由路由和租户边界解析,permission 由服务端动作映射,model ID 从部署配置读取。PEP 只对 allow 执行业务动作,对 deny 返回明确拒绝,对超时、循环、模型缺失和条件不完整返回 indeterminate,并让下载、写入、管理等高风险动作失败关闭。
关系写入同样不能散落在控制器。项目成员变更、文件夹移动、文档删除通过领域事件进入 outbox,消费者以事件 ID 幂等写 tuple。业务事务先提交资源和 outbox;在授权关系确认前,新资源保持 owner-only 或不可见。若业务写成功而授权写失败,告警与重试必须能定位具体资源,不能让 API 返回成功后永久缺边。
列表页先请求授权系统得到对象 ID 页,再向业务库按租户、状态和 ID 集合读取内容;若还要按相关度排序,可由搜索系统产生候选,再批量授权过滤,但必须定义补页与上限,否则前一页全被拒绝会出现空页。授权日志只记录哈希化或可控的主体/资源标识、模型版本、结果、原因、延迟与关联 ID,条件上下文中的 IP、设备、组织属性要按敏感数据处理。
排障沿“模型、事实、求值、执行”四层走
出现“应允许却拒绝”时,先确认请求使用的 store/model/schema 与预期一致,再 Read 直接 tuple,接着查看 Expand/解释树是否沿正确 userset 与 parent 展开,最后确认 PEP 没有把 relation 名、resource type 或 tenant ID 拼错。只看业务日志中的 403 无法区分模型缺边、关系未同步、列表分页漂移和执行点误映射。
出现“应拒绝却允许”时,优先检查旧模型 ID、主体 ID 复用、未清理直接关系、PEP 本地缓存和一致性选项。若只有列表仍显示而 Check 已拒绝,问题多半在反向索引、分页快照或业务搜索缓存;若 Check 与列表都允许,继续查关系事实与授权缓存。修复后同时复查单点 Check、两个方向的列表和直接 Read,避免只修一个查询面。
出现“深层组织偶发超时”时,收集模型版本、目标 relation、路径深度、分支数、dispatch/read 次数、deadline 与结果数量。先查环和超大组,再比较热门对象与普通对象。提高并发或最大深度会增加 datastore 压力和故障传播,应在修正模型、限制嵌套、预计算稳定层级之后再评估。
出现“模型发布成功但请求行为漂移”时,检查调用是否固定 model ID、部署配置是否在不同副本间一致、缓存键是否含模型版本,以及影子差异中的 allow→deny/deny→allow。回滚应切回已知模型 ID,而不是重新发布一份“看起来相同”的模型,因为新的 ID 会继续扩大排障变量。
容量与成本由图形状决定
关系总量只是容量的一维。真正决定尾延迟的是热门对象的入度、巨型团队的成员数、嵌套深度、union 分支、tuple-to-userset 扇出、条件求值、列表方向与缓存局部性。测试数据若每个团队都只有十个成员,会漏掉企业根组和全员共享文件夹带来的长尾;容量回放应保留真实分布形状,同时脱敏 ID。
估算至少记录:活跃主体与资源数、每类 relation 数量、写入/删除峰值、Check 与 List 峰值、P50/P95/P99 深度和分支、超时率、缓存命中、datastore 读写与存储增长。列表应单独预算并限制结果数。撤权传播时间也属于容量指标:负载升高时,队列、缓存和变更消费者积压会延长旧权限窗口。
成本不仅是授权节点。持久数据库、副本、跨区流量、备份、审计、变更流、影子比较、值班和模型评审都会持续发生。嵌套 userset 能减少写放大,却增加读取图遍历;把成员扁平复制到每个资源会提高写入和撤权成本,却可能简化查询。应根据成员变化频率、资源引用数量和列表负载选择,而不是把一种形态称为永远更快。
从开发容器走向 HA
memory datastore 只适合语义练习。生产至少需要多副本无状态服务、持久 datastore、认证与 TLS、Secret 轮换、资源限制、PDB、拓扑分散、指标、追踪和备份恢复。健康检查要覆盖服务进程、datastore、模型读取、最小 Check 与列表,避免所有 Pod 都 Ready 但 migration tag 不匹配或数据库不可用。
OpenFGA 自托管可使用 PostgreSQL、MySQL 等受支持存储,Kubernetes Helm chart 的 chart 版本与应用镜像版本必须分别固定;数据库迁移不应随每个应用副本并发启动。SpiceDB 的 datastore 选择会改变 revision、GC、HA 与一致性边界:单区域可优先评估 PostgreSQL,多区域自托管常比较 CockroachDB 或 Cloud Spanner;使用 Spanner 也不等于获得 Google Zanzibar 的内部架构与论文 SLO。
缓存能降低 Check 成本,却扩大撤权陈旧窗口。缓存键必须含权限系统、模型版本、主体、资源、relation/permission、条件摘要与一致性;deny 的 negative cache 同样会让刚授权仍被拒绝。为管理操作、密钥读取和下载设置更强新鲜度,为低风险浏览选择可接受的延迟预算,并用撤权演练证明实际窗口。
升级、回滚与退出从模型数据开始
升级前固定二进制与镜像 digest,导出模型和 relationships,验证 datastore 备份恢复,在影子流量中比较决定。OpenFGA 的不可变 model ID 允许新旧模型并存;数据库 schema migration 仍要单独演练。SpiceDB 应逐相邻 minor 升级、先执行受控 datastore migration;使用 Operator 时先升级 Operator,再升级固定 image 的集群。任何产品都不能把“进程能启动”当作权限语义兼容。
回滚分三层:模型回滚切回旧 ID 或兼容 schema;应用回滚恢复旧 PEP 与 SDK;数据回滚依赖兼容迁移或备份。若新写路径已经产生旧模型无法解释的 tuple,只回滚二进制会继续报错,所以演进期要双写可逆数据并保留停止开关。验证应覆盖直接共享、嵌套组、继承、列表、撤权、循环错误和删除清理,而不只是一条 owner Check。
迁出到另一产品时,先建立语义映射表:object type、relation、userset、computed relation、tuple-to-userset、条件、模型版本与一致性分别如何表示。导出直接关系后,在新系统重建模型和索引;双写关系、影子 Check/List,按资源域切 PEP;差异稳定后停旧写,保留只读核对窗口。最后撤销 API 凭证、网络策略、数据库账号、备份、告警与审计导出,并证明旧端点没有调用、旧关系可核销、主体和资源删除都能在新链路独立完成。
真正成熟的 Zanzibar 风格建模,不是 DSL 写得像论文,而是每条关系都能回答四件事:谁创建,哪条模型推导,如何被 Check 和列表消费,删除时怎样证明不再生效。做到这一步,ReBAC 才从“更灵活的角色系统”变成可运营、可迁移、可退出的授权基础设施。
