集中式细粒度授权:从关系模型到可撤销的业务决策
一个文档平台曾把“页面上不显示下载按钮”当作权限控制,后来新增的批量导出接口没有复用页面逻辑:普通成员直接调用 API,就拿到了本应只对项目管理员开放的附件。事故暴露的不是少写了一个 if,而是每个接口都在临时猜测“谁能对哪个对象做什么”,没有统一模型、统一检查入口和可追踪的撤权事实。
另一个常见冲突发生在组织扩张后:安全团队希望把角色收紧到最小权限,业务团队却不断创建“华东外包审核员”“临时项目观察员”之类组合角色。继续堆 RBAC 会产生角色爆炸;把所有属性交给通用策略引擎,又很难回答“某个用户究竟能看到哪些文件夹”。集中式关系授权的价值,正是把对象关系、单点判定与反向列表放进同一套可演进的决策服务。
先把五个对象放到正确位置
初学者最容易把登录、授权和数据过滤混在一起。身份提供方先证明调用者是谁;业务资源库保存文档、项目和租户;授权数据库保存关系事实并计算权限;策略决策点返回 allow、deny 或条件状态;真正阻止请求的策略执行点仍在 API、网关或服务方法里。授权服务不会替业务接口读取文档,也不会因为前端隐藏了按钮就自动保护后端。
一条关系通常可读作 object:id#relation@subject:id。例如 document:q3#viewer@user:alice 表示 Alice 是文档 q3 的直接查看者;folder:roadmap#viewer@team:eng#member 则把一个团队成员集合挂到文件夹。模型定义哪些边合法,relationship/tuple 保存事实,Check 回答一个主体对一个对象是否有指定关系,List/Lookup 从主体反查对象或从对象反查主体。
业务接入链路应稳定为:认证得到不可伪造的主体 ID,资源服务得到稳定对象 ID,授权客户端把三元组交给 Check,PEP 对 allow 执行动作、对 deny 返回拒绝,对超时或条件缺失按高风险路径失败关闭,并把模型版本、决定、原因和请求关联 ID 写入审计日志。列表页不能先拉全库再逐条远程 Check;应使用产品提供的 List/Lookup,或让授权结果约束业务查询候选集。
用两个本地进程认识产品差异
OpenFGA v1.18.1 以 store 隔离权限系统,以不可变 authorization model ID 标识模型版本;配置同一语义可以来自 flag、OPENFGA_* 环境变量或 YAML,生产请求应固定 store ID 与 model ID。下面的容器使用内存存储,只适合学习对象和 API:
docker run --rm --name openfga-lab -p 8080:8080 -p 8081:8081 \
openfga/openfga:v1.18.1 run --datastore-engine memory8080 是 HTTP API,8081 是 gRPC;--datastore-engine 决定数据存储,生产还要配置 --datastore-uri、OIDC issuer 与 audience、日志、指标、deadline 和并发限制。OpenFGA 的 cache 默认关闭;开启检查缓存后,MINIMIZE_LATENCY 允许读旧结果,刚撤权的高风险请求应使用 HIGHER_CONSISTENCY。它没有 Zookie/ZedToken 式 revision token,不能把较高一致性偏好解释成跨业务内容的外部一致性。
SpiceDB v1.54.0 用 schema 与 relationships 组成 permissions system,relation 是可写边,permission 是计算表达式。最小进程同样可以使用易失内存库:
docker run --rm --name spicedb-lab -p 50051:50051 \
authzed/spicedb:v1.54.0 serve \
--grpc-preshared-key dev-only-change-me关键配置遵循 CLI flag 高于 SPICEDB_* 环境变量、高于当前目录 spicedb.env:--datastore-engine 选择存储,--datastore-conn-uri 提供连接,--dispatch-max-depth 限制图遍历深度,--datastore-gc-window 决定精确 revision 可用窗口。生产凭证不能留在命令历史里,还需启用 TLS、持久 datastore、迁移、资源限制与轮转。SpiceDB 写入和删除返回 ZedToken,后续 Check 用 at_least_as_fresh 才能建立读己之写;CONDITIONAL_PERMISSION 也必须作为第三种状态显式处理。
两条启动命令预期分别得到可连接的开发服务,但不能证明持久性、HA、性能或与 Google 内部系统等价。结束实验执行 docker stop openfga-lab spicedb-lab;内存数据随进程消失,这正是安全清理,也是它不能进入生产的原因。
把验证做成“写入、检查、列表、撤销”闭环
选择一个不含敏感数据的练习域:user:alice 属于 team:eng,团队可查看 folder:roadmap,文档 q3 继承父文件夹权限。模型需要同时允许直接用户和 team#member userset,并让文档的查看权限从父文件夹传播。
正向实验按四步推进。先发布模型并记录 model/schema 标识;再写 Alice 的组成员关系、团队到文件夹的 viewer 关系、文档到文件夹的 parent 关系;然后 Check user:alice 对 document:q3 的 view,预期允许;最后用 ListObjects/LookupResources 从 Alice 反查文档,预期包含 q3。ReadRelationships/Read 只应显示物化事实,不能被误当成有效权限列表。
反向实验先删除 Alice 的 team membership。OpenFGA 在启用缓存时用 HIGHER_CONSISTENCY 复查,预期拒绝;SpiceDB 保存删除返回的 token,再以 at_least_as_fresh 复查,预期拒绝。若删除后仍允许,先检查调用是否固定了正确 store/model、是否真的传递一致性选项、业务 PEP 是否还有本地缓存,不要用 sleep 猜传播完成。再尝试把 document:q3#parent 指向 user,预期被类型限制拒绝;这证明模型不仅计算权限,也约束脏关系写入。
项目接入时不要漏掉执行点
授权客户端应封装成一个窄接口,例如 check(subject, permission, resource, consistencyContext) 与 listResources(subject, permission, type, pageToken)。调用者不能自由传租户、主体或模型 ID:主体来自认证上下文,租户和资源来自服务端解析,模型版本来自只读部署配置。一旦让浏览器提供 user_id 或让每个业务模块自行挑 model ID,集中式服务只会把越权入口集中起来。
资源创建常常同时涉及业务库和授权库,二者没有天然分布式事务。可采用 outbox:业务事务写资源与待同步事件,worker 幂等写关系,资源在授权关系确认前保持不可见;删除时先阻断新访问,再删除关系和内容,并对失败步骤补偿。高风险内容更新还要把权限 revision 或模型版本与内容版本绑定,否则旧 ACL 可能保护新内容,形成 New Enemy 窗口。
列表接口需要单独容量设计。Check 是单点问题,List/Lookup 会展开图并受分支、深度、分页和反向索引影响。为两类调用设置不同 deadline、并发池和指标;缓存键至少包含租户、主体、资源、权限、模型版本、条件上下文摘要与一致性模式。遗漏任一维度都可能把别人的 allow 复用过来。
从症状反推故障层
“刚授权仍 deny”先查关系是否写入正确 store/schema、模型版本是否固定、主体与资源 ID 是否规范化,再查一致性选项和缓存;“刚撤权仍 allow”优先检查旧模型、PEP 本地缓存与读取新鲜度。“Check 快、列表慢”通常不是网络问题,而是反向遍历的候选集、分支或深度膨胀;应观察 dispatch/read 次数、列表 deadline、结果上限和分页 token。
“偶发 500”不能降级成 allow。循环引用或深度上限在 SpiceDB 中会产生 max depth exceeded,它是计算错误,不是 deny;条件上下文缺失可能得到 conditional,也不是 allow。安全的 PEP 至少区分明确允许、明确拒绝、条件未决、依赖失败四类结果,并让高风险动作在后两类失败关闭,同时保留可定位的 decision reason。
架构选择取决于关系图与因果要求
只有少量固定角色、权限随代码发布且不需要反向列表时,进程内 RBAC 或 Cedar/Casbin 一类嵌入式引擎通常更简单。对象共享、组织层级、团队嵌套和“列出我能访问的资源”成为核心需求时,集中式 ReBAC 才开始抵消网络跳转、存储和运维成本。需要复杂属性、风险评分或合规规则时,可在关系授权之外组合策略引擎,但两个 PDP 的顺序、超时与撤权必须明确,不能用“任一 allow 即放行”的模糊 OR。
OpenFGA 适合希望用 store 与不可变 model ID 做多环境、模型并存和灰度切换的团队;SpiceDB 更强调 schema、permission、ZedToken、一致性选择与 Operator 生命周期。选择不能只看 DSL 喜好,还要比较反向列表、条件语义、读己之写、datastore 支持、迁移方式、托管选择、团队 Go/Kubernetes 能力与退出数据格式。
容量估算至少拆成关系写入速率、关系总量、Check 峰值、List 峰值、图平均/长尾深度、分支宽度、缓存命中率和 datastore IOPS。成本除了节点与数据库,还包括跨区流量、审计日志、备份、值班、模型评审和业务改造。先用真实关系分布回放,再决定副本、缓存和限流;用均匀随机数据得到的漂亮 QPS 很容易掩盖大组织与热门资源的长尾。
HA、升级、回滚与退出要共用一套证据
生产形态通常是无状态授权服务多副本加持久 datastore。副本健康并不代表可用:还要验证数据库连接、迁移 tag、schema/model 可读、Check 与 List 的端到端探针。跨区部署必须先回答 datastore 的一致性与故障切换语义;把读副本藏在普通负载均衡后面,可能让授权读取失去产品所需的 revision 约束。
升级先备份数据库与模型/关系导出,固定镜像 digest,在影子流量中比较新旧决定,再按相邻版本推进。OpenFGA 的数据库迁移应作为独立作业,尤其 MySQL 大表要评估锁;SpiceDB 启动只检查 migration,不会替你自动迁移,Operator 场景应先升级 Operator、固定 SpiceDB image,再滚动升级。出现决定差异、迁移失败或错误率上升时,先回退流量与二进制;若数据格式已前向迁移,只有经过验证的兼容窗口和备份恢复才能回退 datastore。
退出不是关服务。先冻结模型变更,导出 models/schemas、relationships、条件数据与稳定 ID 映射;双写新旧系统并对同一请求做影子比较;按资源域切换 PEP;确认撤权、列表和条件三类语义一致;最后停止旧写入、保留只读核对窗口,撤销凭证、网络入口、备份与告警。真正的完成信号是旧端点连续无调用、旧关系可核销、恢复演练能在新系统独立完成,而不是旧集群已经缩容。
这个家族后续的阅读顺序也由问题决定:先用 Zanzibar 风格建模理解 object、relation、userset 与递归,再分别进入 OpenFGA 或 SpiceDB 的产品部署;需要把属性策略与关系图组合时,再处理双 PDP 的失败语义。无论选哪条路线,团队都应把模型 owner、关系生命周期 owner、PEP owner 和 datastore owner 写进服务目录,让“谁能访问什么”不再由一段无人负责的中间件配置回答。
