SpiceDB 工程手册:Schema、关系图、ZedToken 与生产运行
一家内容平台把“文档所有者可以阅读”写进 SpiceDB,随后又加入文件夹继承和团队嵌套。功能上线后,部分被移出团队的员工仍能下载刚更新的文件:业务服务在关系删除后立即用默认一致性检查,命中了旧 revision 的缓存;内容更新和权限撤销又分别提交,旧权限恰好保护了新内容。日志里每次 Check 都是成功响应,却没有记录使用的 revision 与一致性模式,团队无法证明旧权限窗口究竟持续了多久。
另一家公司在 Schema 中删除 legacy_viewer,测试环境写入成功,生产却因仍有数百万条 relationship 引用旧 relation 而拒绝变更。值班人员试图直接修改 SpiceDB 底层数据库清理“脏行”,结果绕过 revision、Watch 与缓存失效语义,故障从一次模型迁移扩大成授权数据完整性事故。关系授权系统的难点不在于写出一段 DSL,而在于让 Schema、关系事实、查询快照和业务执行点共享同一条可验证生命周期。
先建立正确的运行模型
SpiceDB 是集中式细粒度授权数据库,不是身份提供方、API 网关或通用策略引擎。它保存关系事实,按 Schema 计算 permission,并通过 CheckPermission、LookupResources、LookupSubjects 等 API 回答授权问题。业务服务仍要完成身份认证、租户解析、资源读取和实际拦截;只有 PEP 在执行副作用之前消费了 SpiceDB 的决定,授权才真正生效。
一个 permissions system 由两类状态组成:Schema 描述允许存在的对象类型、relation、permission 与 Caveat;Relationships 保存运行期事实。relation 是可写边,permission 是计算表达式,不能向 permission 写 relationship。下面这条事实表示 Alice 是文档 q3 的 editor:
document:q3#editor@user:aliceCheckPermission(document:q3, view, user:alice) 并不是查一行。执行器会读取目标 revision 上的关系,按 permission 表达式递归分解子问题,经过 dispatch、datastore 和缓存后得到 HAS_PERMISSION、NO_PERMISSION 或 CONDITIONAL_PERMISSION。最后一种状态表示 Caveat 缺少上下文,绝不能被 SDK 包装成布尔 true。
以下操作固定 SpiceDB v1.54.0。版本号用于锁定镜像、配置字段和行为,不应改成 latest。生产发布前仍要核对目标发行版的 Upgrade Notes 与发布记录、镜像摘要和 Operator 兼容性。
启动一个可丢弃的本地实例
准备 Docker、官方 zed CLI 和一个只用于实验的随机共享密钥。本地 memdb 会随容器退出而丢失,适合验证语义,不构成持久性、容量或 HA 证据。镜像可从 Docker Hub、GHCR 或 Quay 获取,容器安装说明明确要求生产固定 release tag 并建议镜像到自有 registry。
export SPICEDB_KEY="$(openssl rand -hex 24)"
docker run --rm --name spicedb-lab \
-p 127.0.0.1:50051:50051 \
-e SPICEDB_GRPC_PRESHARED_KEY="$SPICEDB_KEY" \
authzed/spicedb:v1.54.0 serve \
--datastore-engine=memory另开终端建立 CLI context:
zed context set local 127.0.0.1:50051 "$SPICEDB_KEY" --insecure
zed context use local
zed schema read空实例的 zed schema read 预期返回 NotFound,并提示尚未定义 Schema。这不是服务未启动;它证明连接、共享密钥和 gRPC 入口已经可用。若得到 Unauthenticated,先确认两个终端使用同一个 key;若是连接拒绝,检查容器日志和 50051 端口占用。不要为了消除错误而开放到 0.0.0.0 或关闭生产 TLS。
--grpc-preshared-key 只是最小认证入口。生产还要为外部 gRPC/HTTP 和内部 dispatch 分别配置 TLS、网络策略与密钥轮换;共享 key 应从 Secret 注入,不能出现在 Compose、CR、Shell 历史和仓库中。
从 Schema 到可写关系
把下面内容保存为 schema.zed:
definition user {}
definition team {
relation member: user | team#member
}
caveat within_network(client_ip ipaddress, cidr string) {
client_ip.in_cidr(cidr)
}
definition folder {
relation parent: folder
relation viewer: user | team#member
permission view = viewer + parent->view
}
definition document {
relation parent: folder
relation editor: user
relation conditional_viewer: user with within_network
permission view = editor + parent->view + conditional_viewer
}definition 声明对象类型;relation 声明可持久化边以及右侧允许的主体类型;permission 组合集合。parent->view 先从 document 找 parent folder,再在 folder 上求 view。team#member 是 userset 引用,不是把团队成员复制到每个资源。
写入并回读 Schema:
zed schema write schema.zed
zed schema read > schema.active.zed
diff -u schema.zed schema.active.zed预期 Schema 写入成功,回读内容与目标语义一致。格式化差异可以审查后接受,relation、permission、Caveat 参数和运算括号的差异不能忽略。SpiceDB 中 + 由于历史原因优先级高于 & 与 -,复杂表达式必须显式加括号或拆成中间 permission;不要按常见布尔运算直觉猜测。
正向实验:继承、嵌套与双向查找
依次写入团队成员、文件夹可见性和文档父级:
zed relationship create team:eng member user:alice
zed relationship create folder:roadmap viewer team:eng#member
zed relationship create document:q3 parent folder:roadmap
zed relationship create document:q3 editor user:bob再执行单点检查和两个方向的反向查询:
zed permission check document:q3 view user:alice --explain
zed permission check document:q3 view user:bob --explain
zed permission lookup-resources document view user:alice
zed permission lookup-subjects document:q3 view user
zed relationship read document:q3预期 Alice 的路径经过 document.parent -> folder.viewer -> team.member,Bob 由 document.editor 获得权限,两次 Check 都是 HAS_PERMISSION;lookup-resources 包含 q3,lookup-subjects 能找到可达主体。最后的 relationship read 只显示物化事实,不会把 Alice 伪装成直接写在 document 上的 viewer。事实读取、权限求值和集合查找是三种不同查询,不能用 N 次 Check 或直接读表互相替代。
列表接口也不是无限结果导出。热门资源、高入度团队、深层嵌套和宽 union 会增加 dispatch 与 datastore 读取;结果超过业务页大小时要使用 cursor、独立 deadline 和并发预算。搜索或业务数据库可以先产生候选集,再用 CheckBulkPermissions 做最终过滤;外部索引只能缩小候选,不能成为授权权威。
Caveat 必须按三态接入
Caveat 是附着在 relationship 上的 CEL 条件,适合轻量动态约束,不等于把任意策略逻辑塞进关系图。先写一条仅允许办公网访问的关系:
zed relationship create document:net-plan conditional_viewer user:carol \
--caveat 'within_network:{"cidr":"10.20.0.0/16"}'分别不传、传匹配和不匹配的请求上下文:
zed permission check document:net-plan view user:carol
zed permission check document:net-plan view user:carol \
--caveat-context '{"client_ip":"10.20.8.9"}'
zed permission check document:net-plan view user:carol \
--caveat-context '{"client_ip":"192.0.2.9"}' \
--error-on-no-permission第一条预期返回 CONDITIONAL_PERMISSION 并给出缺失字段,第二条为 HAS_PERMISSION,第三条为 NO_PERMISSION 且因参数设置返回非零退出码。PEP 应把 conditional 映射为“信息不足”,补齐可信上下文后再查;若无法补齐,高风险动作失败关闭并留下原因。客户端传来的 IP、设备等级和组织属性不能直接相信,应由网关、认证会话或服务端 PIP 生成。
Relationship 上持久化的 Caveat context 会优先于请求中同名字段,同一 resource/relation/subject 不能靠不同 Caveat 重复写两条边。64 位整数通过 protobuf structpb 传递时使用字符串,避免精度损失。稳定组织关系优先建模为 relation;超大列表、频繁变化的风险评分和复杂合规规则应交给专门策略引擎或 PIP。
用 ZedToken 证明读己之写
SpiceDB 默认查询使用 minimize_latency,倾向选择能命中缓存的近期 revision。一致性与 ZedToken说明了四种逐请求模式:写入或删除会返回 written_at ZedToken;后续请求用 at_least_as_fresh(token),表示使用不早于该 token 的 revision,同时仍可利用足够新的缓存。
用 JSON 输出捕获 token,字段名以当前 zed 输出为准:
WRITE_JSON="$(zed relationship create document:q3 editor user:carol --json)"
printf '%s\n' "$WRITE_JSON" | jq .
WRITE_TOKEN="$(printf '%s' "$WRITE_JSON" | jq -r '.writtenAt.token // .written_at.token')"
test -n "$WRITE_TOKEN" && test "$WRITE_TOKEN" != null
zed permission check document:q3 view user:carol \
--consistency-at-least "$WRITE_TOKEN"预期检查为 HAS_PERMISSION。随后删除并捕获新的 token:
DELETE_JSON="$(zed relationship delete document:q3 editor user:carol --json)"
DELETE_TOKEN="$(printf '%s' "$DELETE_JSON" | jq -r '.deletedAt.token // .deleted_at.token')"
printf '%s\n' "$DELETE_JSON" | jq .
zed permission check document:q3 view user:carol \
--consistency-at-least "$DELETE_TOKEN" \
--error-on-no-permission第二次检查预期稳定拒绝。若当前 CLI 的删除响应字段不同,先以打印出的 JSON 和 zed relationship delete --help 修正提取表达式,不能把空 token 当成功。默认 minimize_latency 在删除后可能短暂返回旧 allow;这正是实验要暴露的陈旧窗口,而不是靠 sleep 掩盖的偶发问题。
四种一致性模式承担不同职责:
minimize_latency 适合可接受短暂陈旧的低风险读取。at_least_as_fresh 是写后读和撤权确认的首选,输入写入或删除返回的 token。at_exact_snapshot 固定精确 revision,适合短时间分页;超过 datastore GC window 会返回 Snapshot Expired。
fully_consistent 绕过缓存并提高尾延迟。CockroachDB 部署中它仍不保证刚写后读,必须使用 ZedToken 与 at_least_as_fresh。
ZedToken 是不透明的 datastore 时间点证据。不能解析、比较大小、跨集群复用,也不能在恢复到新 datastore 世代后继续使用。它的有效窗口受 GC 配置约束;若 exact snapshot 过期,分页应明确失败并从新快照重启,而不是静默退回低新鲜度模式。
解决 New Enemy 不只靠一次强读
假设文档内容从 C1 更新到 C2,同时 Alice 的权限被撤销。若内容先更新,权限删除后提交,读请求可能在旧 ACL 快照上看到 C2;若权限先删、内容后写,异步缓存也可能重新组合出错误顺序。这就是 New Enemy 问题的业务形态:授权 revision、内容版本和执行顺序失去因果关系。
可操作的做法是把资源版本与所需最小 ZedToken 一起存进业务库。例如:
ALTER TABLE documents
ADD COLUMN authz_min_token varchar(1024),
ADD COLUMN content_version bigint NOT NULL DEFAULT 0;资源创建、删除、内容变化或权限变化时,通过 outbox/意图日志协调业务事实与 relationship 变更;成功写入 SpiceDB 后,把 token 与对应业务版本关联。读取 C2 前,PEP 用 C2 保存的 token 发起 at_least_as_fresh Check。跨系统无法获得单一数据库事务时,资源在授权确认前保持不可见或 owner-only,失败操作进入可重放状态机,而不是让 HTTP 成功响应掩盖半完成写入。
客户端可以携带 opaque token,但服务端必须验证它属于当前资源和权限系统,不能接受任意客户端 token 降低或改变新鲜度。审计只记录 token 摘要、Schema digest、结果与关联 ID,不记录完整关系图、共享 key 或 Caveat 敏感上下文。
反向实验:让错误留下可判断证据
先尝试把 relationship 写到 permission:
zed relationship create document:q3 view user:alice预期被拒绝,因为 view 是计算 permission,不是 relation。若应用层把这个错误吞掉并返回成功,业务事实与授权事实已经分叉,必须让 outbox 状态保持 FAILED/PENDING 并告警。
再构造关系环:
zed relationship create folder:a parent folder:b
zed relationship create folder:b parent folder:a
zed permission check folder:a view user:nobody --explain在递归不可达路径上,SpiceDB 最终可能返回 max depth exceeded;这是计算错误,不是 NO_PERMISSION。排查时使用 explain/debug 获取子问题路径,删除环或重构模型。--dispatch-max-depth 默认值是防爆炸边界,盲目调大只会把错误转成更慢的 datastore 压力。
第三个反例是 Caveat 缺上下文。任何 SDK 若把 conditional 默认转换成 allow,测试必须立即失败;若统一转换为 deny 却不记录缺失字段,虽然保守,却会制造无法定位的业务拒绝。领域接口应该保留 allow/deny/indeterminate 三态和 revision 证据。
第四个反例是 exact snapshot 过期。在隔离环境缩短 --datastore-gc-window,保存一次查询 token,等待超过窗口后执行:
zed permission check document:q3 view user:alice \
--consistency-at-exactly "$OLD_TOKEN"预期为 Snapshot Expired。生产不要为了复现实验随意缩短 GC window;该窗口同时影响存储回收、长分页和 token 生命周期,应由实际最长查询时间、恢复策略与容量预算共同决定。
项目接入要把执行点收紧
业务代码只暴露领域化授权接口,控制器不能自由拼 relation 名和主体 ID:
type AuthzDecision =
| { kind: "allow"; checkedAt: string }
| { kind: "deny"; checkedAt: string; reason: string }
| { kind: "indeterminate"; reason: string };
interface DocumentAuthorizer {
canView(input: {
tenantId: string;
principalId: string;
documentId: string;
minimumToken?: string;
clientIp?: string;
}): Promise<AuthzDecision>;
}principalId 来自服务端验证后的身份映射,tenantId 与 documentId 来自权威路由和资源查询,permission 固定映射为 view。SDK 请求超时、认证失败、Schema 缺失、max depth、conditional 缺字段和 datastore 错误都返回 indeterminate;下载、导出、写入和管理动作只在明确 allow 时执行。
PEP 的顺序应是:验证身份与租户 -> 解析资源版本和最小 token -> 调 SpiceDB -> 检查三态与 revision -> 执行业务副作用 -> 记录脱敏审计。不要先读取完整敏感内容再授权,也不要在检查和写入之间允许资源 ID、租户或动作发生变化。长连接、后台 consumer、批量接口和内部 RPC 都需要同等 PEP,不能只保护 HTTP 控制器。
从 memdb 迁到持久化和 Operator
单区域自托管通常优先评估 PostgreSQL;CockroachDB 和 Cloud Spanner 用于不同的多区域与托管边界;MySQL 只有在无法采用 PostgreSQL 时再评估,memdb 永远不用于生产。Datastore 决定 revision、GC、备份、故障切换和一致性边界,不能由“团队已有哪种数据库”单独决定。
持久化部署先独立执行 migration,再启动服务:
spicedb datastore migrate head \
--datastore-engine postgres \
--datastore-conn-uri "$SPICEDB_DATASTORE_URI"
spicedb serve \
--datastore-engine postgres \
--datastore-conn-uri "$SPICEDB_DATASTORE_URI" \
--grpc-preshared-key "$SPICEDB_KEY"连接 URI 和 key 由 Secret 管理,命令只展示字段,不应把真实值写进历史。SpiceDB 启动会检查 datastore migration tag,但不会自动完成所有自托管迁移;tag 不匹配时应停止发布并运行受控迁移,不能删除 migration 元数据或常态化依赖 --datastore-allowed-migrations 绕过。
Kubernetes 生产运行优先评估官方 SpiceDB Operator。安装入口为:
kubectl apply --server-side -k github.com/authzed/spicedb-operator/configSpiceDBCluster.spec.config 把普通 flag 去掉 -- 并改为 lowerCamelCase,例如 datastoreEngine、datastoreConnUri;敏感值通过同 namespace Secret 引用。生产必须显式固定 .spec.config.image、副本数、资源、PDB、拓扑分散、TLS Secret 与 NetworkPolicy。升级顺序是先升级 Operator,再按相邻 minor 顺序升级固定版本的 SpiceDB;skipMigrations=true 只有在外部迁移 Job 和门禁已经就绪时才可使用。
HA、容量与成本从图形状计算
SpiceDB 服务节点可以无状态横向扩展,但持久 datastore、gRPC/HTTP2 负载均衡、内部 dispatch、Schema cache 和关系检查缓存共同决定可用性。健康检查至少区分进程、datastore、Schema 可读和最小 Check;“Pod Ready”但 migration tag 错误或 datastore 不可达不算可用。客户端对幂等 Check 设置短超时、有限重试和总 deadline,relationship 写入依赖 operation ID/outbox 保证幂等,不能盲重试制造重复意图。
容量估算不能只数 relationship。还要采集热门资源入度、最大团队规模、嵌套深度、permission 分支、arrow 扇出、Caveat 成本、Check/Lookup 比例、P50/P95/P99 dispatch 深度、datastore 读写、超时和缓存命中。Lookup 超大集合会拖累单点 Check,应使用独立并发池、分页上限和请求预算。
成本包括 SpiceDB 节点、数据库副本与备份、跨区流量、Watch/审计、影子比较、Schema 评审和撤权演练。缓存可以降成本,却会扩大陈旧窗口;fully_consistent 可以简化部分读取,却会降低命中并抬高数据库负载。架构决策要同时给出延迟、撤权暴露目标和 datastore 预算,而不是只比较平均 QPS。
故障诊断沿四层展开
遇到“应该允许却拒绝”,先确认请求使用的 endpoint、permissions system 和 Schema,再用 relationship read 查物化边,随后用 permission check --explain 看推导路径,最后核对 PEP 的租户、资源类型和动作映射。CONDITIONAL_PERMISSION 要检查缺失字段及其可信来源。
遇到“应该拒绝却允许”,先查删除是否返回 token、请求是否使用 at_least_as_fresh、PEP/SDK/网关是否还有本地 allow cache,再检查长连接和后台任务是否绕过重新授权。若 Check 已拒绝而列表仍出现,继续查 Lookup cursor、外部搜索索引和业务结果缓存。
max depth exceeded 优先查环、异常组织层级和高扇出,不先调大上限;Snapshot Expired 检查分页持续时间与 GC window;migration mismatch 检查二进制和 datastore tag;Operator 反复滚动检查 image 是否固定。任何人都不得直接修改底层 SpiceDB 表,Relationship Integrity 也不能替代 API、备份和审计。
Schema 演进、回滚与退出
Schema 变更按数据库迁移治理。先添加兼容 relation/permission,双写新旧关系,回填存量,再对同一输入与可比较 revision 做影子 Check 和 Lookup;差异按 deny -> allow、allow -> deny、错误和条件状态分类。只有权限扩大为零、批准的收缩有变更单、撤权传播达标且回滚演练通过,才切换 PEP。
删除 relation 前先证明现存 relationship 为零。回滚 Schema 不能直接回退 datastore DDL;保留兼容表达式和旧写路径,按发行版兼容窗口逐步恢复。升级前固定镜像 digest,导出 Schema 与 relationships,验证备份恢复,逐相邻 minor 执行 migration;进程启动成功不代表 permission 语义一致。
退出时导出 Schema、关系、Caveat/expiration、分页游标处理记录、计数与分片摘要,重放黄金 Check/Lookup 语料并双写追平。ZedToken 只作为旧系统新鲜度证据,不是可移植业务版本。切换完成后撤销共享 key、TLS 客户端身份、网络入口、数据库账号和备份访问,并按保留策略清理旧数据。成熟的 SpiceDB 落地,最终应能回答每条关系由谁写入、在哪个 revision 生效、由哪一个 PEP 执行,以及删除后怎样证明它不再授权。
