从 RBAC 到 ReBAC:用 OpenFGA 把多租户应用的权限判断变成可验证的关系模型
一个应用刚起步时,权限实现往往很直接:用户表有一个 role 字段,后端在路由或 service 中判断管理员、成员和访客。这个方案没有错;对于资源少、角色固定的后台,它甚至是成本最低的方案。
问题通常出现在产品开始协作化之后。一个用户可以是组织成员、某个项目的维护者、某篇文档的作者,也可能通过团队、文件夹或共享链接间接获得访问权。此时,“能否读取这篇文档”不再只取决于一个角色,而取决于用户、组织、团队、资源之间的一组关系。权限判断分散在 ORM 查询、控制器和前端按钮里,规则很难审查,也难以回答一个简单问题:某个用户究竟为什么可以访问某项资源?
OpenFGA 是一个面向开发者的细粒度授权引擎,设计受到 Google Zanzibar 的启发。它采用关系型授权(Relationship-Based Access Control,ReBAC):把“谁对什么资源具有什么关系”存成关系元组,把“哪些关系可推导出某项权限”写成授权模型,然后由统一的 Check API 给出允许或拒绝的结果。OpenFGA 的 GitHub 项目采用 Apache-2.0 许可证,核心服务以 Go 实现。
不要把认证和授权混在一起
接入 OpenFGA 前,先划清边界。OIDC、OAuth、登录会话或 JWT 解决的是“请求者是谁”;OpenFGA 解决的是“这个已知身份此刻能否对这个对象执行这个动作”。前者仍由身份提供商或应用会话承担,后者则可以从业务代码里抽离出来。
这一区分很实用。例如,API 网关验证完 JWT 后可得到 user:anne;业务服务在执行“读取路线图”前,再向授权服务询问:user:anne 对 document:roadmap 是否拥有 can_read?前端隐藏按钮不能替代这一步,真正的写入、下载、导出等敏感接口仍要在服务端做检查。
OpenFGA 的三个对象
OpenFGA 的数据面可以拆成三个对象。
- Store:一套隔离的授权数据空间。多租户系统可按环境或业务边界规划 Store,但不应仅凭租户数量机械创建大量 Store。
- Authorization Model:版本化的规则定义,描述对象类型、关系,以及关系怎样组合为权限。
- Relationship Tuple:实际发生的关系数据,例如
user:anne是document:roadmap的reader。
关键点是模型和数据分离。reader、writer、can_read 的语义写在模型中;Anne、Bob 与某份具体文档的关系写在元组中。新增一份文档或增加一个读者,不需要修改模型;修改“编辑者也可以阅读”的规则,也不必给每份文档重复写入 can_read 元组。
用最小示例跑通一次授权检查
官方 README 提供了内存存储的本地启动方式,适合开发验证,不适合直接用于生产。下面先启动 HTTP API 与本地 Playground:
docker run --rm \ -p 8080:8080 \ -p 3000:3000 \ openfga/openfga run
另开一个终端创建 Store。响应里的 id 是后续请求需要的 Store ID:
curl -sS -X POST 'http://localhost:8080/stores' \
-H 'Content-Type: application/json' \
--data-raw '{"name":"authorization-demo"}'
export STORE_ID='替换为响应中的 id'
在模型层,把文档的直接读者和编辑者定义为两种关系,并把两者合成为 can_read。下面使用官方 DSL 的表达方式:
model
schema 1.1
type user
type document
relations
define reader: [user]
define writer: [user]
define can_read: reader or writer
这里没有让客户端直接写入 can_read。can_read 是一个推导关系:只要对象上存在 reader 或 writer,授权引擎就能得出可读结论。这比在每一个业务分支里复制“读者或编辑者即可读取”的条件更集中,也更容易随模型版本一起评审。
实际授权数据则是元组。以 Anne 为例,它的最小形态就是三个带类型前缀的字符串:
{
"user": "user:anne",
"relation": "reader",
"object": "document:roadmap"
}
写入后,应用对同一对象请求 can_read 检查;预期响应的 allowed 为 true:
curl -sS -X POST \
"http://localhost:8080/stores/${STORE_ID}/check" \
-H 'Content-Type: application/json' \
-d '{
"tuple_key": {
"user": "user:anne",
"relation": "can_read",
"object": "document:roadmap"
}
}'
在真实调用中,请求还应携带当前使用的授权模型 ID,并由服务端保存、校验资源标识。不要相信浏览器提交的 user 字段:用户身份应来自已验证的令牌或会话,资源 ID 也应由后端按业务语义解析。
从“角色判断”迁移到“关系推导”
ReBAC 不意味着要一次替换所有 RBAC。角色仍然有用:组织管理员、账单管理员等稳定职位可以继续建模为关系。变化在于,角色不再是唯一入口。你可以让 organization:acme#member 间接拥有该组织项目的读取权,让项目成员通过文件夹获得文档读取权,再保留“文档作者可编辑”这样的直接关系。
这类建模特别适合多租户 SaaS、协作文档、代码托管、项目管理和企业知识库:资源层级与成员关系会不断变化,而不是只有少数全局角色。相反,如果系统只有两三个固定后台角色、资源不存在共享和继承,独立授权引擎未必值得立刻引入;简单的服务端 RBAC 通常更清晰。
让模型成为可审查的安全边界
把权限关系显式化后,排查方式会改变。过去遇到“为什么这个用户看到了不该看的内容”,团队往往要沿着中间件、SQL scope、缓存和多个服务的条件分支回溯;采用关系模型后,可以先确定授权模型版本,再检查与该用户、资源相关的元组和一次 Check 请求。它并不会自动消除建模错误,但能把规则放到一个可审阅的位置。
这也要求模型演进有发布纪律。给 document 新增 commenter,或者把读取权从直接成员扩展到团队成员,都是安全语义的变化。更稳妥的做法是先在测试环境写入新模型版本,使用代表性的用户、组织和资源元组运行允许与拒绝用例;确认后让应用在受控范围内切换模型 ID。撤销权限的测试尤其不能省略:不仅要断言新授权能通过,还要断言旧关系删除后 Check 会返回拒绝。
在服务边界上,建议把一次业务动作需要的 user、relation 和 object 固定为清晰的协议。例如下载私有附件始终检查 can_download,而不是在某些接口检查 reader、另一些接口检查 member。业务层仍可以决定何时发起检查、如何记录审计上下文;但权限推导本身不要再被复制到每个 endpoint。这样做还能避免前端和后端各自实现一套“看起来相同、实际略有差异”的规则。
关系模型也不是替代数据隔离的万能钥匙。多租户查询仍应在数据库、缓存键和对象存储路径中使用租户边界;OpenFGA 的 Check 是访问控制决策点,而不是替代所有资源定位与数据过滤的机制。尤其是在批量列表接口中,要设计清楚先筛选资源再逐项检查、使用允许列表查询,还是使用产品提供的其他授权查询能力,并评估延迟与一致性要求。
落地时的四个工程约束
第一,把模型文件与应用代码一起纳入 Git,并为关键场景写授权测试:直接授权、团队继承、撤销授权、跨租户隔离和拒绝路径都应覆盖。模型是一种安全策略代码,而不是运维后台里一次性手工配置。
第二,统一对象命名。user:、organization:、project:、document: 这类约定会影响排障和审计;一旦不同服务各自发明格式,关系元组会迅速失去可读性。
第三,给高风险操作明确失败策略。授权检查超时或不可用时,查看公开文档、下载数据、转账等敏感动作通常应采用 fail closed,也就是拒绝访问;低风险只读页面是否允许受控降级,则需要由业务可用性要求决定。无论选择什么,都要把超时、重试和审计日志写进设计,而不是临时吞掉错误。
第四,别把本地演示配置带进生产。官方 README 明确将内存存储标为仅适合开发。生产环境需要按官方部署文档配置持久化存储、服务认证、网络访问边界、备份、指标和模型迁移。Playground 也用于本地建模与验证,不应当作面向互联网的管理界面。
OpenFGA 的价值不在于把一次 if 判断换成一次网络调用,而在于把授权逻辑从散落的条件分支提升为可版本化、可测试、可解释的模型。权限开始跨越组织、项目、团队和资源层级时,这种边界能显著降低后续演进的成本;权限仍很简单时,保留简单方案同样是正确的工程选择。