OpenFGA:把关系授权做成可验证的共享服务
一个协作文档服务把“项目成员可以阅读项目文档”同步成关系数据。某次模型发布增加了 blocked 关系,但部分应用副本没有固定 authorization model ID,自动读取了 store 中最新模型;另一些副本仍显式使用旧 ID。相同用户刷新两次,一次允许、一次拒绝。团队最初把它归咎于负载均衡,真正的故障却是授权语义被一个隐式的“最新版本”开关分裂了。
另一次事故发生在离职撤权后。身份目录已经移除成员关系,OpenFGA 中对应 tuple 也删除成功,但 API 网关保留着旧 allow,应用调用又使用偏向低延迟的一致性模式。管理员在控制台看到关系已不存在,离职账号却还能下载文件。删除成功只证明写入路径接受了变更,不能证明每个执行点都已经看见并执行了新决定。
先分清模型、事实、查询和执行
OpenFGA 是 Zanzibar 风格的集中式细粒度授权服务。它把授权模型和关系元组放进 store,通过 Check、BatchCheck、ListObjects、ListUsers、Read 与 Expand 等接口计算关系。它不验证用户密码,不替业务服务读取资源,也不会自动拦截 HTTP 请求;真正拒绝下载、修改或管理操作的仍是业务入口处的策略执行点 PEP。
四类对象承担不同职责:
store 隔离一套授权系统及其模型和关系数据。多租户既可以一租户一 store,也可以共享 store 并把租户编码进对象类型与 ID;前者隔离清晰但 store 数、连接与迁移成本更高,后者容量效率更好但必须证明不存在跨租户关系。authorization model 定义对象类型、relation、类型限制和关系重写。模型是不可变对象,每次写入都会产生新的 model ID;回滚应重新固定旧 ID,而不是覆盖旧模型。
relationship tuple 保存事实,形式可读作 object#relation@user,例如 document:roadmap#viewer@user:anne。user 也可以是 team:platform#member 这样的 userset。query 根据指定模型和 tuple 求值。Check 回答单个主体是否拥有关系;ListObjects 和 ListUsers 解决两个反向集合问题;Read 只读物化 tuple,不会自动返回所有推导权限。
一次安全调用从认证后的稳定主体 ID 开始,业务代码从路由和数据库得到资源 ID,把固定 action 映射成 relation,随后携带 store ID、model ID 和一致性要求调用 OpenFGA。PEP 只在明确 allowed: true 时执行副作用;400、超时、模型缺失和依赖错误都不是 allow。
建立一个可丢弃的本地实验室
本地可以用官方容器和 fga CLI 认识完整对象链。服务端示例固定到 v1.18.1;实施时应从 OpenFGA Releases 选择团队审核过的精确版本并固定镜像摘要,不能把 latest 带进可复现环境。CLI 可通过 Homebrew、官方发行包或容器安装,入口和参数以 CLI 使用手册 为准。
docker pull openfga/openfga:v1.18.1
docker run --rm --name openfga-lab \
-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=false8080 提供 HTTP API,8081 提供 gRPC。memory 数据随进程消失,只适合语义实验;--playground-enabled=false 避免启动只适合无认证开发环境且已经弃用的 Playground。另开终端确认服务健康:
curl -fsS http://127.0.0.1:8080/healthz
fga version
export FGA_API_URL=http://127.0.0.1:8080预期健康请求成功,fga version 输出本机 CLI 版本。若容器立即退出,依次查看 docker logs openfga-lab、端口占用和镜像架构;不要通过换成浮动 tag 隐去版本问题。
把下面内容保存为临时 docs.fga:
model
schema 1.1
type user
type team
relations
define member: [user]
type folder
relations
define viewer: [user, team#member]
type document
relations
define parent: [folder]
define editor: [user]
define viewer: [user, team#member] or editor or viewer from parent创建 store 并同时发布模型:
fga store create --model docs.fga命令会返回 store.id 与 model.authorization_model_id。把它们保存到当前 shell;下面的占位值必须替换成真实输出:
export FGA_STORE_ID=<store-id>
export FGA_MODEL_ID=<authorization-model-id>
fga model get --store-id "$FGA_STORE_ID" \
--model-id "$FGA_MODEL_ID"预期模型输出包含 schema 1.1、四种类型以及 viewer from parent。model ID 是部署配置,不是临时调试信息;请求省略它时服务可能选择最新模型,生产客户端应把省略视作配置错误。
用 tuple 证明直接关系与继承关系
先准备 tuples.yaml:
- user: user:anne
relation: member
object: team:platform
- user: team:platform#member
relation: viewer
object: folder:roadmap
- user: folder:roadmap
relation: parent
object: document:q3写入并读取物化事实:
fga tuple write --store-id "$FGA_STORE_ID" \
--model-id "$FGA_MODEL_ID" \
--file tuples.yaml
fga tuple read --store-id "$FGA_STORE_ID" \
--model-id "$FGA_MODEL_ID"预期写入摘要中 failed_count 为 0,Read 返回三条 tuple。它不会直接返回 document:q3#viewer@user:anne,因为这条权限是模型沿 document.parent -> folder.viewer -> team.member 推导出来的,不是物化事实。
用 Check 验证推导,再用两个列表方向验证业务查询:
fga query check --store-id "$FGA_STORE_ID" \
--model-id "$FGA_MODEL_ID" \
user:anne viewer document:q3
fga query list-objects --store-id "$FGA_STORE_ID" \
--model-id "$FGA_MODEL_ID" \
user:anne viewer document
fga query list-users --store-id "$FGA_STORE_ID" \
--model-id "$FGA_MODEL_ID" \
--object document:q3 \
--relation viewer \
--user-filter user预期依次看到 {"allowed":true}、对象集合包含 document:q3、用户集合包含 user:anne。Check、ListObjects 与 ListUsers 的计算方向和成本不同,三者都通过才证明单点授权和列表入口使用了同一模型。把所有文档从业务库读出后逐个 Check 会产生网络扇出、空页和尾延迟;优先让 ListObjects 产生候选 ID,再由业务库按租户和状态读取内容。
Contextual tuples 只活在一次请求里
有些关系只在请求时存在。例如认证令牌声明 Anne 当前选择了 organization:acme,而组织选择不需要持久写入授权库。这时可把临时关系作为 contextual tuple 传入 Check。它在该次求值中像普通 tuple 一样参与计算,但不会写入 datastore,也不会影响下一次请求。Contextual tuples 同样受模型校验,且单请求有数量上限。
一个典型请求可以携带:
{
"authorization_model_id": "<authorization-model-id>",
"tuple_key": {
"user": "user:anne",
"relation": "viewer",
"object": "document:q3"
},
"contextual_tuples": {
"tuple_keys": [
{
"user": "user:anne",
"relation": "active_member",
"object": "organization:acme"
}
]
}
}临时关系的来源必须由服务端 PIP 建立,不能接受浏览器任意提交。若它来自 token 中的组声明,底层组关系撤销后,权限可能持续到 token 过期;因此高风险权限应使用短 token、令牌撤销或权威关系查询。contextual tuple 与数据库中同 key 的 tuple 冲突时,临时值优先,这种覆盖语义也应进入反向测试。
用撤权反例暴露缓存与一致性错误
先把成员 tuple 保存成 revoke.yaml:
- user: user:anne
relation: member
object: team:platform删除关系,然后立刻用较高一致性复查:
fga tuple delete --store-id "$FGA_STORE_ID" \
--model-id "$FGA_MODEL_ID" \
--file revoke.yaml
curl -fsS -X POST \
"$FGA_API_URL/stores/$FGA_STORE_ID/check" \
-H 'content-type: application/json' \
-d "{
\"authorization_model_id\": \"$FGA_MODEL_ID\",
\"tuple_key\": {
\"user\": \"user:anne\",
\"relation\": \"viewer\",
\"object\": \"document:q3\"
},
\"consistency\": \"HIGHER_CONSISTENCY\"
}"预期得到 {"allowed":false}。OpenFGA 的 MINIMIZE_LATENCY 是默认查询模式,启用服务端缓存后可能返回旧结果;HIGHER_CONSISTENCY 会绕过缓存直接查询数据库。一致性模式 是延迟、负载和新鲜度的取舍,不是 Zanzibar Zookie 等价物,也不能携带某次写入 revision 来建立跨请求因果链。
反向实验还应保留三类故障证据:
删除后循环比较两种一致性模式,记录默认模式最后一次旧 allow 和较高一致性第一次稳定 deny 的时间。撤权暴露目标应按动作设定,不能只写一个缓存 TTL。再发布一个不兼容模型 M2,但请求继续固定 M1;结果应保持 M1 语义。去掉 model ID 的调用应被客户端合同测试阻断。把 relation 改成模型中不存在的 reader。预期 API 返回 400,而不是 allowed:false;PEP 必须把模型错误视为依赖失败,不能包装成可缓存的普通拒绝,更不能变成允许。
在 Node.js 项目中封装窄客户端
安装官方 SDK,并由锁文件固定实际版本:
npm install @openfga/sdk不要把 SDK 对象直接暴露给每个控制器。项目内部建立一个只接受领域动作的适配器:
import { OpenFgaClient } from "@openfga/sdk";
const client = new OpenFgaClient({
apiUrl: process.env.FGA_API_URL!,
storeId: process.env.FGA_STORE_ID!,
authorizationModelId: process.env.FGA_MODEL_ID!,
});
type Decision =
| { kind: "allow"; modelId: string }
| { kind: "deny"; modelId: string; reason: string }
| { kind: "indeterminate"; reason: string };
export async function canReadDocument(input: {
authenticatedUserId: string;
tenantId: string;
documentId: string;
highRisk: boolean;
}): Promise<Decision> {
const modelId = process.env.FGA_MODEL_ID!;
try {
const response = await client.check(
{
user: `user:${input.authenticatedUserId}`,
relation: "viewer",
object: `document:${input.tenantId}|${input.documentId}`,
},
{
authorizationModelId: modelId,
consistency: input.highRisk ? "HIGHER_CONSISTENCY" : "MINIMIZE_LATENCY",
},
);
return response.allowed
? { kind: "allow", modelId }
: { kind: "deny", modelId, reason: "relationship_not_found" };
} catch (error) {
return { kind: "indeterminate", reason: "authorization_dependency_failed" };
}
}SDK 方法签名会随版本演进,合并前要以锁定 SDK 的类型定义校正 options;稳定合同是主体、动作、资源、model ID 和一致性语义,而不是某个临时参数位置。主体只能来自验证后的身份上下文,租户和资源由服务端解析,relation 来自受控映射。调用结果为 indeterminate 时,下载、写入、管理等高风险动作失败关闭,并且不得先写业务库再补授权检查。
关系写入也应集中。资源创建、成员变更和删除先在业务事务中写 outbox,消费者按 operation ID 幂等更新 tuple;新资源在 owner tuple 确认前保持不可见。对象删除先进入 deleting 状态,阻止新访问,再清理它作为 object 和 userset subject 的全部关系,最后删除内容。业务 ID 不复用,可以避免旧 tuple 在同名资源重建后复活。
从 memory 进程迁到持久化服务
OpenFGA 的独立二进制、容器和 Helm 部署最终都依赖 datastore。生产常用 PostgreSQL 或 MySQL;SQLite 更适合单进程实验。先独立执行数据库迁移,再启动服务,避免每个副本同时争抢 migration:
docker run --rm --network <authorization-network> \
openfga/openfga:v1.18.1 migrate \
--datastore-engine postgres \
--datastore-uri 'postgres://<user>:<secret>@postgres:5432/openfga?sslmode=require'
docker run --rm --name openfga \
--network <authorization-network> \
-p 127.0.0.1:8080:8080 \
-e OPENFGA_DATASTORE_ENGINE=postgres \
-e OPENFGA_DATASTORE_URI='postgres://<user>:<secret>@postgres:5432/openfga?sslmode=require' \
-e OPENFGA_AUTHN_METHOD=oidc \
-e OPENFGA_AUTHN_OIDC_ISSUER='<oidc-issuer>' \
-e OPENFGA_AUTHN_OIDC_AUDIENCE='openfga-api' \
-e OPENFGA_LOG_FORMAT=json \
-e OPENFGA_PLAYGROUND_ENABLED=false \
openfga/openfga:v1.18.1 run示例中的 URI 和 OIDC 值必须由 Secret 管理器注入,不能出现在镜像、Compose 文件或 shell history。真实 TLS 可以在 OpenFGA HTTP/gRPC 层启用,也可以由受控入口终止,但 OpenFGA 到 datastore 的链路仍应验证 CA 和主机名。生产配置建议直接对照 配置项清单 与目标二进制 openfga run --help。
关键字段改变的是故障形态,不只是性能数字:
| 配置族 | 影响 | 配错时的信号 |
|---|---|---|
datastore.engine / datastore.uri | 持久性、连接协议与迁移路径 | 重启丢数据、migration 不匹配、连接风暴 |
authn.method 与 OIDC issuer/audience | 谁可以调用服务 API | 无认证暴露、错误 audience 被接受、全部 401 |
| Check/List deadline | 单请求允许占用的时间 | 深图拖垮线程池,或正常长尾被过早切断 |
| 最大并发读取与 node/breadth limit | 图遍历的深度、宽度和数据库压力 | List 抢占 Check、CPU/IO 峰值、解析上限错误 |
| List 最大结果数 | 单次集合查询的内存和延迟 | 大租户请求超时、响应过大、分页体验不稳定 |
| cache 与 cache TTL | 命中率、成本和撤权陈旧窗口 | 刚授权仍 deny、刚撤权仍 allow、副本结果不一致 |
| metrics / tracing / log format | 故障归因和成本 | 只有 403 而没有模型、接口和延迟证据 |
配置优先级是命令行参数高于环境变量、环境变量高于配置文件。团队应只选择一个主要来源,防止 Helm values、环境变量和启动参数互相覆盖。进程启动日志要输出非敏感有效配置摘要,凭据与连接串必须擦除。
服务访问控制不能依赖实验开关兜底
OpenFGA 的 OIDC 或预共享密钥先解决“谁能连 API”,但普通认证不等于 store、模型和 tuple 的细粒度管理授权。内建 access control 使用控制 store、控制模型和关系来限制客户端能力,不过官方仍明确标为实验能力,并且初始化与管理员 bootstrap 需要外部流程。Access Control 配置 要求显式打开实验标志和相关字段,不能被当作唯一生产保护。
保守做法是多层收敛:网络层只允许授权客户端和受控管理作业访问;OIDC 为读查询、模型发布、tuple 写入使用不同 client;网关按客户端、路径和方法限制 API;每个 store 的管理映射由平台服务控制;审计区分读决定与写模型。即使实验访问控制被启用,也要保留外层网络与身份边界,并准备关闭实验开关后的回退路径。
凭据轮换时先加入新 key/client,验证新客户端,再撤销旧凭据;不要同时重启所有副本和删除旧 key。日志不得记录 bearer token、完整 contextual tuples、用户属性和数据库 URI。主体与资源 ID 若可识别个人,也应哈希化或令牌化后进入集中日志。
容量由关系图形状和查询方向决定
关系总数不能单独预测成本。Check 的尾延迟还受 userset 嵌套深度、热门资源入度、union 分支、tuple-to-userset 扇出、条件求值和缓存局部性影响;ListObjects/ListUsers 还要为候选集合和反向遍历付费。官方生产建议也把 Check、ListObjects、ListUsers 的并发读取和最大结果数分开配置。
容量基线至少记录每类 tuple 数、写入与删除峰值、Check/BatchCheck/List 峰值、模型深度与分支分布、P50/P95/P99 延迟、超时、数据库连接和 IOPS、缓存命中以及撤权可见时间。测试集要保留“大团队、深层组织、热门文件夹”的长尾形状,只把真实 ID 替换成不可逆标识;均匀随机小图无法暴露生产瓶颈。
多副本服务应跨故障域部署,但每个副本的进程内缓存可能不同。增加副本会分散命中率并扩大观察差异,数据库连接上限也要按副本数分配。健康检查不能只看进程端口,应增加 datastore 可达、目标 model 可读、最小 Check 和受控 List 探针。故障注入时逐个终止副本、隔离 datastore、制造慢查询,预期高风险动作拒绝或返回稳定依赖错误,不能出现旧 allow 兜底。
模型发布、回滚和退出都围绕不可变 ID
模型变更先用 fixture 执行 fga model test --tests <store-file>,再对脱敏历史请求回放。差异必须按 allow -> deny、deny -> allow、新错误和列表集合差异分类;总一致率很高也可能隐藏一条严重权限扩大。模型发布产生 M2 后,先让影子客户端固定 M2,权威 PEP 仍固定 M1;差异得到批准后按租户和动作灰度切换。
删除或重命名 relation 时,模型变化和 tuple 迁移分开。兼容期先新增 relation,让写路径双写;后台按游标迁移存量 tuple;新旧模型并行回放;切读后停止旧写,最后清理旧 tuple。OpenFGA 会忽略对指定模型无效的 tuple,但遗留数据仍占存储并可能增加读取成本,不能把“查询不再使用”当成数据已经删除。
回滚模型只需把 PEP 配置重新固定到已知旧 model ID,前提是旧语义需要的 tuple 仍在且客户端仍兼容。数据库 migration 与服务二进制是另一条版本轴:升级前做备份恢复演练、固定镜像 digest、验证相邻版本兼容,再滚动副本。只回滚容器而不确认 datastore schema,可能让所有副本同时无法启动。
迁出时冻结模型变更,导出每个 store 的全部模型及 ID、全量 tuple、分页游标和计数摘要、tenant/store 映射、条件数据、PEP action 映射及黄金请求。新系统导入后同时比较 Check 和两个 List 方向,确认撤权传播,再按资源域切换执行点。最后撤销客户端凭据、网络策略、数据库账号、备份和审计出口;仅保存“最新模型文本”不足以重建历史语义。
按证据顺序排障
| 现象 | 先取证 | 常见原因 | 修复后的证明 |
|---|---|---|---|
| 应允许却 403 | store/model ID、直接 tuple、Expand、PEP action | 写错 store、模型未固定、userset 缺边、action 映射错误 | 同一 model 下 Check 与业务请求都允许 |
| 刚撤权仍允许 | consistency、OpenFGA cache、网关/应用 cache、长连接 | 默认低延迟读取、缓存键漏 model、旧连接未重验 | 较高一致性稳定拒绝,撤权时间满足动作目标 |
| Check 正确但列表漏项 | List API、模型、结果上限、分页和业务过滤 | 逐条过滤、页间模型变化、候选索引陈旧 | Check、ListObjects、ListUsers 对黄金语料一致 |
| 多副本结果漂移 | model ID、有效配置、cache 命中与数据库路由 | 隐式最新模型、配置覆盖、每副本旧缓存 | 所有副本报告同 model,差异回放为零 |
| List 拖慢 Check | 查询类型延迟、并发读取、node/breadth 与 DB IOPS | 大 userset、深图、大结果数抢占资源 | 独立限流后 Check SLO 恢复且 List 有界失败 |
| 服务健康但全部报错 | migration tag、datastore、OIDC audience、模型读取 | schema 不兼容、数据库连接耗尽、令牌配置错误 | 端到端探针完成认证、Check 和受控 List |
清理本地实验时先删除练习 store,或直接停止内存容器:
fga store delete --store-id "$FGA_STORE_ID"
docker stop openfga-lab
unset FGA_API_URL FGA_STORE_ID FGA_MODEL_ID
rm -f docs.fga tuples.yaml revoke.yaml若使用 PowerShell,改用 $env:FGA_API_URL = $null 等方式清理环境变量。持久环境不能直接删库:先冻结写入、导出模型和 tuple、核对审计保留,再按审批删除 store、数据库和密钥。
上线签字应能给出具体证据:所有客户端固定 store/model,PEP 无旁路;正向 Check 与两个 List 方向一致;删除关系后的强新鲜度查询按目标拒绝;缓存键包含租户、模型、主体、资源、relation、条件摘要和一致性;OIDC、TLS、Secret、日志脱敏、数据库备份恢复和 migration 已演练;模型 owner、tuple 生命周期 owner、PEP owner 与 datastore owner 都有人负责。做到这些,OpenFGA 才是可运营的授权基础设施,而不是把散落的 if 换成一次远程布尔调用。
