第十四章:OpenFGA — 细粒度授权引擎
“OpenFGA 让你像 Google 一样管理权限 — 基于关系,而非角色。”
mindmap
root((OpenFGA))
核心概念
Type
Relation
Tuple
API
Check
ListObjects
Expand
Write
授权模型
DSL
Google Drive
GitHub
多租户
部署
Server
PostgreSQL
SDK
14.1 OpenFGA 是什么
OpenFGA(Fine-Grained Authorization)是由 Auth0/Okta 开源的细粒度授权引擎,基于 Google Zanzibar 论文实现。它是 CNCF Sandbox 项目。
flowchart LR
subgraph 应用层
A1[Web 应用]
A2[API 服务]
A3[微服务]
end
subgraph OpenFGA Server
direction TB
AM[授权模型<br/>Authorization Model]
RT[关系元组<br/>Relationship Tuples]
GE[图遍历引擎<br/>Graph Engine]
AM --> GE
RT --> GE
end
subgraph 数据存储
PG[(PostgreSQL)]
MY[(MySQL)]
end
A1 -->|Check / Write / ListObjects| GE
A2 -->|gRPC / HTTP| GE
A3 -->|SDK| GE
GE --> PG
GE -.-> MY
授权检查流程
sequenceDiagram
participant App as 应用
participant FGA as OpenFGA Server
participant DB as PostgreSQL
App->>FGA: Check(user:bob, viewer, document:budget)
FGA->>DB: 查询 document:budget 的关系元组
DB-->>FGA: 返回元组列表
FGA->>FGA: 图遍历:bob 是 editor → editor 隐含 viewer
FGA-->>App: { allowed: true }
App->>FGA: ListObjects(user:carol, viewer, type:document)
FGA->>DB: 查询 carol 相关的所有关系元组
DB-->>FGA: 返回元组列表
FGA->>FGA: 图遍历:展开所有可达的 document
FGA-->>App: { objects: ["document:roadmap", "document:budget"] }
关系模型示例
flowchart TB
U1([user:alice]) -->|admin| Org[organization:acme]
U2([user:bob]) -->|member| Org
U3([user:carol]) -->|member| Org
Org -->|member → viewer| D1[document:roadmap]
Org -->|member → editor| D2[document:budget]
U1 -->|owner| D1
U2 -->|editor| D2
U3 -->|viewer| D2
style U1 fill:#e1f5fe
style U2 fill:#e1f5fe
style U3 fill:#e1f5fe
style Org fill:#fff3e0
style D1 fill:#e8f5e9
style D2 fill:#e8f5e9
14.2 核心概念
关系元组(Relationship Tuple)
# 格式:object#relation@subject
# Alice 是 budget 文档的 owner
document:budget#owner@user:alice
# Bob 是 budget 文档的 editor
document:budget#editor@user:bob
# Acme 组织的所有 member 都是 roadmap 文档的 viewer
document:roadmap#viewer@organization:acme#member
# Carol 是 Acme 组织的 member
organization:acme#member@user:carol
→ 因此 Carol 可以查看 roadmap 文档(通过关系链推导)
API 操作
API |
功能 |
示例 |
|---|---|---|
Check |
检查是否有权限 |
Carol 能查看 roadmap 吗?→ ✅ |
ListObjects |
列出有权限的对象 |
Carol 能查看哪些文档? |
ListUsers |
列出有权限的用户 |
谁能编辑 budget? |
Expand |
展开权限关系树 |
budget 的 viewer 包含哪些人? |
Write |
写入关系元组 |
添加/删除权限关系 |
Read |
读取关系元组 |
查询已有的权限关系 |
14.3 实战场景:Google Drive 权限模型
model
schema 1.1
type user
type group
relations
define member: [user, group#member]
type folder
relations
define owner: [user]
define editor: [user, group#member] or owner
define viewer: [user, group#member] or editor
define parent: [folder]
# 继承父文件夹的权限
define can_edit: editor or owner or can_edit from parent
define can_view: viewer or can_edit or can_view from parent
type document
relations
define owner: [user]
define editor: [user, group#member] or owner
define viewer: [user, group#member] or editor
define parent: [folder]
define can_edit: editor or owner or can_edit from parent
define can_view: viewer or can_edit or can_view from parent
define can_share: owner
define can_delete: owner
关系元组示例:
# 文件夹层次
folder:engineering#parent@folder:root
folder:backend#parent@folder:engineering
# 文档归属
document:api-spec#parent@folder:backend
# 权限分配
folder:root#viewer@group:all-employees#member
folder:engineering#editor@group:eng-team#member
document:api-spec#owner@user:alice
# 权限推导:
# Bob 是 eng-team 的 member
# → Bob 是 engineering 文件夹的 editor
# → Bob 是 backend 文件夹的 editor(继承)
# → Bob 可以编辑 api-spec 文档(继承)
14.4 授权模型设计模式
模式一:文档共享(Google Docs 风格)
model
schema 1.1
type user
type document
relations
define owner: [user]
define editor: [user] or owner
define commenter: [user] or editor
define viewer: [user] or commenter
# 分享链接:任何人可查看
define public_viewer: [user:*]
define can_view: viewer or public_viewer
define can_comment: commenter
define can_edit: editor
define can_share: owner
define can_delete: owner
模式二:组织层级(多租户 SaaS)
model
schema 1.1
type user
type organization
relations
define owner: [user]
define admin: [user] or owner
define member: [user] or admin
type team
relations
define org: [organization]
define lead: [user]
define member: [user] or lead
# 组织管理员自动成为团队管理者
define admin: lead or admin from org
type project
relations
define team: [team]
define manager: [user]
define contributor: [user, team#member]
define viewer: [user, team#member, organization#member] or contributor
define can_edit: contributor or manager
define can_view: viewer
define can_admin: manager or admin from team
模式三:GitHub 仓库权限
model
schema 1.1
type user
type organization
relations
define owner: [user]
define member: [user] or owner
type team
relations
define maintainer: [user]
define member: [user] or maintainer
type repository
relations
define org: [organization]
define admin: [user, team#member] or owner from org
define maintainer: [user, team#member] or admin
define writer: [user, team#member] or maintainer
define reader: [user, team#member, organization#member] or writer
define can_push: writer
define can_pull: reader
define can_admin: admin
14.5 Python SDK 完整示例
import asyncio
from openfga_sdk import (
ClientConfiguration,
OpenFgaClient,
ClientWriteRequest,
ClientTupleKey,
ClientCheckRequest,
ClientListObjectsRequest,
)
from openfga_sdk.client.models import (
WriteAuthorizationModelRequest,
CreateStoreRequest,
)
async def main():
# 1. 配置客户端(无 Store)
config = ClientConfiguration(
api_url="http://localhost:8080",
)
async with OpenFgaClient(config) as client:
# 2. 创建 Store
store = await client.create_store(
CreateStoreRequest(name="my-app-store")
)
print(f"Store ID: {store.id}")
# 更新配置以使用新 Store
config.store_id = store.id
# 3. 使用新 Store 重新连接
async with OpenFgaClient(config) as client:
# 4. 创建授权模型
model = WriteAuthorizationModelRequest(
schema_version="1.1",
type_definitions=[
{"type": "user"},
{
"type": "document",
"relations": {
"owner": {"this": {}},
"editor": {"this": {}},
"viewer": {
"union": {
"child": [
{"this": {}},
{"computedUserset": {"relation": "editor"}},
{"computedUserset": {"relation": "owner"}},
]
}
},
},
"metadata": {
"relations": {
"owner": {
"directly_related_user_types": [{"type": "user"}]
},
"editor": {
"directly_related_user_types": [{"type": "user"}]
},
"viewer": {
"directly_related_user_types": [{"type": "user"}]
},
}
},
},
],
)
auth_model = await client.write_authorization_model(model)
print(f"模型 ID: {auth_model.authorization_model_id}")
# 5. 写入关系元组
await client.write(
ClientWriteRequest(
writes=[
ClientTupleKey(
user="user:alice",
relation="owner",
object="document:budget",
),
ClientTupleKey(
user="user:bob",
relation="editor",
object="document:budget",
),
ClientTupleKey(
user="user:carol",
relation="viewer",
object="document:budget",
),
]
)
)
print("关系元组写入成功")
# 6. 检查权限
result = await client.check(
ClientCheckRequest(
user="user:bob",
relation="viewer",
object="document:budget",
)
)
print(f"Bob 能查看 budget? {result.allowed}") # True(editor 隐含 viewer)
result = await client.check(
ClientCheckRequest(
user="user:carol",
relation="editor",
object="document:budget",
)
)
print(f"Carol 能编辑 budget? {result.allowed}") # False
# 7. 列出用户可访问的文档
objects = await client.list_objects(
ClientListObjectsRequest(
user="user:alice",
relation="viewer",
type="document",
)
)
print(f"Alice 可查看的文档: {objects.objects}")
# 8. 删除关系元组
await client.write(
ClientWriteRequest(
deletes=[
ClientTupleKey(
user="user:carol",
relation="viewer",
object="document:budget",
),
]
)
)
print("已移除 Carol 的 viewer 权限")
asyncio.run(main())
FastAPI + OpenFGA 授权中间件
"""FastAPI + OpenFGA 授权中间件完整示例"""
import asyncio
from contextlib import asynccontextmanager
from typing import Optional
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from openfga_sdk import (
ClientConfiguration,
OpenFgaClient,
ClientCheckRequest,
ClientWriteRequest,
ClientTupleKey,
ClientListObjectsRequest,
)
# ── 全局 OpenFGA 客户端 ──────────────────────────────────────
fga_client: Optional[OpenFgaClient] = None
FGA_CONFIG = ClientConfiguration(
api_url="http://localhost:8080",
store_id="your-store-id",
authorization_model_id="your-model-id",
)
@asynccontextmanager
async def lifespan(app: FastAPI):
global fga_client
fga_client = OpenFgaClient(FGA_CONFIG)
yield
await fga_client.__aexit__(None, None, None)
app = FastAPI(title="OpenFGA Demo", lifespan=lifespan)
security = HTTPBearer()
# ── 用户解析(简化示例,实际应验证 JWT) ─────────────────────
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
# 实际项目中应验证 JWT 并提取用户信息
token = credentials.credentials
return {"id": token, "name": token}
# ── OpenFGA 授权依赖 ─────────────────────────────────────────
class FGAAuthorize:
"""可复用的 OpenFGA 授权依赖"""
def __init__(self, relation: str, object_type: str, object_id_param: str):
self.relation = relation
self.object_type = object_type
self.object_id_param = object_id_param
async def __call__(
self,
request: Request,
user: dict = Depends(get_current_user),
) -> dict:
object_id = request.path_params.get(self.object_id_param)
if not object_id:
raise HTTPException(status_code=400, detail="Missing resource ID")
result = await fga_client.check(
ClientCheckRequest(
user=f"user:{user['id']}",
relation=self.relation,
object=f"{self.object_type}:{object_id}",
)
)
if not result.allowed:
raise HTTPException(
status_code=403,
detail=f"No '{self.relation}' permission on {self.object_type}:{object_id}",
)
return user
# ── 路由 ─────────────────────────────────────────────────────
@app.get("/documents/{doc_id}")
async def get_document(
doc_id: str,
user: dict = Depends(FGAAuthorize("viewer", "document", "doc_id")),
):
return {"doc_id": doc_id, "content": "...", "accessed_by": user["id"]}
@app.put("/documents/{doc_id}")
async def update_document(
doc_id: str,
user: dict = Depends(FGAAuthorize("editor", "document", "doc_id")),
):
return {"doc_id": doc_id, "updated_by": user["id"]}
@app.delete("/documents/{doc_id}")
async def delete_document(
doc_id: str,
user: dict = Depends(FGAAuthorize("owner", "document", "doc_id")),
):
return {"doc_id": doc_id, "deleted_by": user["id"]}
@app.post("/documents/{doc_id}/share")
async def share_document(
doc_id: str,
target_user: str,
relation: str = "viewer",
user: dict = Depends(FGAAuthorize("owner", "document", "doc_id")),
):
"""文档所有者可以分享文档给其他用户"""
await fga_client.write(
ClientWriteRequest(
writes=[
ClientTupleKey(
user=f"user:{target_user}",
relation=relation,
object=f"document:{doc_id}",
)
]
)
)
return {"shared": True, "doc_id": doc_id, "target": target_user, "relation": relation}
@app.get("/my/documents")
async def list_my_documents(
user: dict = Depends(get_current_user),
):
"""列出当前用户可查看的所有文档"""
result = await fga_client.list_objects(
ClientListObjectsRequest(
user=f"user:{user['id']}",
relation="viewer",
type="document",
)
)
return {"user": user["id"], "documents": result.objects}
14.6 Java SDK 完整示例
// build.gradle
// dependencies {
// implementation 'dev.openfga:openfga-sdk:0.7.+'
// }
import dev.openfga.sdk.api.client.OpenFgaClient;
import dev.openfga.sdk.api.client.ClientConfiguration;
import dev.openfga.sdk.api.client.model.*;
import dev.openfga.sdk.api.model.*;
import java.util.List;
public class OpenFGAExample {
public static void main(String[] args) throws Exception {
// 1. 配置客户端
var config = new ClientConfiguration()
.apiUrl("http://localhost:8080")
.storeId("your-store-id");
var client = new OpenFgaClient(config);
// 2. 创建 Store
var createStoreResponse = client
.createStore(new CreateStoreRequest().name("my-java-store"))
.get();
System.out.println("Store ID: " + createStoreResponse.getId());
config.storeId(createStoreResponse.getId());
// 3. 写入授权模型
var model = new WriteAuthorizationModelRequest()
.schemaVersion("1.1")
.typeDefinitions(List.of(
new TypeDefinition().type("user"),
new TypeDefinition()
.type("document")
.relations(java.util.Map.of(
"owner", new Userset().direct(new DirectUserset()),
"editor", new Userset().direct(new DirectUserset()),
"viewer", new Userset().union(new Usersets().child(List.of(
new Userset().direct(new DirectUserset()),
new Userset().computedUserset(
new ObjectRelation().relation("editor")),
new Userset().computedUserset(
new ObjectRelation().relation("owner"))
)))
))
.metadata(new Metadata().relations(java.util.Map.of(
"owner", new RelationMetadata()
.directlyRelatedUserTypes(List.of(
new RelationReference().type("user"))),
"editor", new RelationMetadata()
.directlyRelatedUserTypes(List.of(
new RelationReference().type("user"))),
"viewer", new RelationMetadata()
.directlyRelatedUserTypes(List.of(
new RelationReference().type("user")))
)))
));
var modelResponse = client.writeAuthorizationModel(model).get();
System.out.println("模型 ID: " + modelResponse.getAuthorizationModelId());
// 4. 写入关系元组
var writeRequest = new ClientWriteRequest()
.writes(List.of(
new ClientTupleKey()
.user("user:alice")
.relation("owner")
._object("document:budget"),
new ClientTupleKey()
.user("user:bob")
.relation("editor")
._object("document:budget"),
new ClientTupleKey()
.user("user:carol")
.relation("viewer")
._object("document:budget")
));
client.write(writeRequest).get();
System.out.println("关系元组写入成功");
// 5. 检查权限
var checkRequest = new ClientCheckRequest()
.user("user:bob")
.relation("viewer")
._object("document:budget");
var checkResponse = client.check(checkRequest).get();
System.out.println("Bob 能查看 budget? " + checkResponse.getAllowed());
// true(editor 隐含 viewer)
// 6. 列出用户可访问的文档
var listRequest = new ClientListObjectsRequest()
.user("user:alice")
.relation("viewer")
.type("document");
var listResponse = client.listObjects(listRequest).get();
System.out.println("Alice 可查看的文档: " + listResponse.getObjects());
}
}
Spring Boot + OpenFGA 集成
// ── OpenFGA 配置 ────────────────────────────────────────────
// application.yml:
// openfga:
// api-url: http://localhost:8080
// store-id: your-store-id
// authorization-model-id: your-model-id
import dev.openfga.sdk.api.client.OpenFgaClient;
import dev.openfga.sdk.api.client.ClientConfiguration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ConfigurationProperties(prefix = "openfga")
record OpenFGAProperties(String apiUrl, String storeId, String authorizationModelId) {}
@Configuration
class OpenFGAConfig {
@Bean
OpenFgaClient openFgaClient(OpenFGAProperties props) {
var config = new ClientConfiguration()
.apiUrl(props.apiUrl())
.storeId(props.storeId())
.authorizationModelId(props.authorizationModelId());
return new OpenFgaClient(config);
}
}
// ── 授权服务 ────────────────────────────────────────────────
import dev.openfga.sdk.api.client.OpenFgaClient;
import dev.openfga.sdk.api.client.model.ClientCheckRequest;
import org.springframework.stereotype.Service;
@Service
class FGAAuthorizationService {
private final OpenFgaClient fgaClient;
FGAAuthorizationService(OpenFgaClient fgaClient) {
this.fgaClient = fgaClient;
}
public boolean check(String userId, String relation, String objectType, String objectId) {
try {
var request = new ClientCheckRequest()
.user("user:" + userId)
.relation(relation)
._object(objectType + ":" + objectId);
var response = fgaClient.check(request).get();
return Boolean.TRUE.equals(response.getAllowed());
} catch (Exception e) {
throw new RuntimeException("OpenFGA check failed", e);
}
}
}
// ── 自定义注解 + AOP ────────────────────────────────────────
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@interface FGAPreAuthorize {
String relation();
String objectType();
/** SpEL 表达式,从方法参数中提取 objectId */
String objectId();
}
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
@Aspect
@Component
class FGAAuthorizationAspect {
private final FGAAuthorizationService authService;
private final SpelExpressionParser parser = new SpelExpressionParser();
FGAAuthorizationAspect(FGAAuthorizationService authService) {
this.authService = authService;
}
@Around("@annotation(fgaAuth)")
public Object checkPermission(ProceedingJoinPoint joinPoint, FGAPreAuthorize fgaAuth)
throws Throwable {
// 从 SecurityContext 获取当前用户
String userId = SecurityContextHolder.getContext()
.getAuthentication().getName();
// 用 SpEL 解析 objectId
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
String[] paramNames = sig.getParameterNames();
Object[] args = joinPoint.getArgs();
StandardEvaluationContext ctx = new StandardEvaluationContext();
for (int i = 0; i < paramNames.length; i++) {
ctx.setVariable(paramNames[i], args[i]);
}
String objectId = parser.parseExpression(fgaAuth.objectId()).getValue(ctx, String.class);
// 调用 OpenFGA 检查
boolean allowed = authService.check(
userId, fgaAuth.relation(), fgaAuth.objectType(), objectId);
if (!allowed) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"No '" + fgaAuth.relation() + "' permission on "
+ fgaAuth.objectType() + ":" + objectId);
}
return joinPoint.proceed();
}
}
// ── Controller 使用示例 ─────────────────────────────────────
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/documents")
class DocumentController {
@GetMapping("/{docId}")
@FGAPreAuthorize(relation = "viewer", objectType = "document", objectId = "#docId")
public Map<String, Object> getDocument(@PathVariable String docId) {
return Map.of("docId", docId, "content", "...");
}
@PutMapping("/{docId}")
@FGAPreAuthorize(relation = "editor", objectType = "document", objectId = "#docId")
public Map<String, Object> updateDocument(@PathVariable String docId,
@RequestBody Map<String, Object> body) {
return Map.of("docId", docId, "updated", true);
}
@DeleteMapping("/{docId}")
@FGAPreAuthorize(relation = "owner", objectType = "document", objectId = "#docId")
public Map<String, Object> deleteDocument(@PathVariable String docId) {
return Map.of("docId", docId, "deleted", true);
}
}
14.7 Go SDK 完整示例
package main
import (
"context"
"fmt"
"log"
openfga "github.com/openfga/go-sdk"
"github.com/openfga/go-sdk/client"
)
func main() {
ctx := context.Background()
// 1. 配置客户端
fgaClient, err := client.NewSdkClient(&client.ClientConfiguration{
ApiUrl: "http://localhost:8080",
})
if err != nil {
log.Fatalf("创建客户端失败: %v", err)
}
// 2. 创建 Store
storeResp, err := fgaClient.CreateStore(ctx).Body(client.ClientCreateStoreRequest{
Name: "my-go-store",
}).Execute()
if err != nil {
log.Fatalf("创建 Store 失败: %v", err)
}
fmt.Printf("Store ID: %s\n", storeResp.GetId())
// 更新客户端配置
fgaClient, err = client.NewSdkClient(&client.ClientConfiguration{
ApiUrl: "http://localhost:8080",
StoreId: storeResp.GetId(),
})
if err != nil {
log.Fatalf("重新创建客户端失败: %v", err)
}
// 3. 写入授权模型
schemaVersion := "1.1"
modelResp, err := fgaClient.WriteAuthorizationModel(ctx).Body(openfga.WriteAuthorizationModelRequest{
SchemaVersion: schemaVersion,
TypeDefinitions: []openfga.TypeDefinition{
{Type: "user"},
{
Type: "document",
Relations: &map[string]openfga.Userset{
"owner": {This: &openfga.DirectUserset{}},
"editor": {This: &openfga.DirectUserset{}},
"viewer": {
Union: &openfga.Usersets{
Child: []openfga.Userset{
{This: &openfga.DirectUserset{}},
{ComputedUserset: &openfga.ObjectRelation{Relation: strPtr("editor")}},
{ComputedUserset: &openfga.ObjectRelation{Relation: strPtr("owner")}},
},
},
},
},
Metadata: &openfga.Metadata{
Relations: &map[string]openfga.RelationMetadata{
"owner": {DirectlyRelatedUserTypes: &[]openfga.RelationReference{
{Type: "user"},
}},
"editor": {DirectlyRelatedUserTypes: &[]openfga.RelationReference{
{Type: "user"},
}},
"viewer": {DirectlyRelatedUserTypes: &[]openfga.RelationReference{
{Type: "user"},
}},
},
},
},
},
}).Execute()
if err != nil {
log.Fatalf("写入模型失败: %v", err)
}
fmt.Printf("模型 ID: %s\n", modelResp.GetAuthorizationModelId())
// 4. 写入关系元组
_, err = fgaClient.Write(ctx).Body(client.ClientWriteRequest{
Writes: []client.ClientTupleKey{
{User: "user:alice", Relation: "owner", Object: "document:budget"},
{User: "user:bob", Relation: "editor", Object: "document:budget"},
{User: "user:carol", Relation: "viewer", Object: "document:budget"},
},
}).Execute()
if err != nil {
log.Fatalf("写入元组失败: %v", err)
}
fmt.Println("关系元组写入成功")
// 5. 检查权限
checkResp, err := fgaClient.Check(ctx).Body(client.ClientCheckRequest{
User: "user:bob",
Relation: "viewer",
Object: "document:budget",
}).Execute()
if err != nil {
log.Fatalf("检查权限失败: %v", err)
}
fmt.Printf("Bob 能查看 budget? %v\n", checkResp.GetAllowed()) // true
checkResp, err = fgaClient.Check(ctx).Body(client.ClientCheckRequest{
User: "user:carol",
Relation: "editor",
Object: "document:budget",
}).Execute()
if err != nil {
log.Fatalf("检查权限失败: %v", err)
}
fmt.Printf("Carol 能编辑 budget? %v\n", checkResp.GetAllowed()) // false
// 6. 列出用户可访问的文档
listResp, err := fgaClient.ListObjects(ctx).Body(client.ClientListObjectsRequest{
User: "user:alice",
Relation: "viewer",
Type: "document",
}).Execute()
if err != nil {
log.Fatalf("列出对象失败: %v", err)
}
fmt.Printf("Alice 可查看的文档: %v\n", listResp.GetObjects())
}
func strPtr(s string) *string { return &s }
Gin + OpenFGA 授权中间件
package main
import (
"context"
"fmt"
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/openfga/go-sdk/client"
)
// ── 全局 OpenFGA 客户端 ─────────────────────────────────────
var fgaClient *client.OpenFgaClient
func initFGAClient() {
var err error
fgaClient, err = client.NewSdkClient(&client.ClientConfiguration{
ApiUrl: "http://localhost:8080",
StoreId: "your-store-id",
AuthorizationModelId: "your-model-id",
})
if err != nil {
log.Fatalf("初始化 OpenFGA 客户端失败: %v", err)
}
}
// ── 授权中间件 ──────────────────────────────────────────────
func FGAAuthorize(relation, objectType, paramName string) gin.HandlerFunc {
return func(c *gin.Context) {
// 从上下文获取用户 ID(由认证中间件设置)
userID, exists := c.Get("userID")
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
return
}
objectID := c.Param(paramName)
if objectID == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "缺少资源 ID"})
return
}
// 调用 OpenFGA 检查权限
resp, err := fgaClient.Check(context.Background()).Body(client.ClientCheckRequest{
User: fmt.Sprintf("user:%s", userID),
Relation: relation,
Object: fmt.Sprintf("%s:%s", objectType, objectID),
}).Execute()
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError,
gin.H{"error": "授权检查失败"})
return
}
if !resp.GetAllowed() {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": fmt.Sprintf("无 '%s' 权限: %s:%s", relation, objectType, objectID),
})
return
}
c.Next()
}
}
// ── 认证中间件(简化示例) ──────────────────────────────────
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("Authorization")
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "缺少 token"})
return
}
// 实际项目中应验证 JWT
c.Set("userID", token)
c.Next()
}
}
func main() {
initFGAClient()
r := gin.Default()
api := r.Group("/api", AuthMiddleware())
{
docs := api.Group("/documents")
{
docs.GET("/:docId",
FGAAuthorize("viewer", "document", "docId"),
getDocument)
docs.PUT("/:docId",
FGAAuthorize("editor", "document", "docId"),
updateDocument)
docs.DELETE("/:docId",
FGAAuthorize("owner", "document", "docId"),
deleteDocument)
}
}
r.Run(":9090")
}
func getDocument(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"docId": c.Param("docId"),
"content": "...",
})
}
func updateDocument(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"docId": c.Param("docId"),
"updated": true,
})
}
func deleteDocument(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"docId": c.Param("docId"),
"deleted": true,
})
}
14.8 OpenFGA 部署
# docker-compose.yml
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: openfga
POSTGRES_PASSWORD: openfga_password
POSTGRES_DB: openfga
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U openfga"]
interval: 5s
timeout: 5s
retries: 5
openfga-migrate:
image: openfga/openfga:latest
command: migrate
environment:
OPENFGA_DATASTORE_ENGINE: postgres
OPENFGA_DATASTORE_URI: postgres://openfga:openfga_password@postgres:5432/openfga?sslmode=disable
depends_on:
postgres:
condition: service_healthy
openfga:
image: openfga/openfga:latest
command: run
environment:
OPENFGA_DATASTORE_ENGINE: postgres
OPENFGA_DATASTORE_URI: postgres://openfga:openfga_password@postgres:5432/openfga?sslmode=disable
OPENFGA_PLAYGROUND_ENABLED: "true"
OPENFGA_LOG_LEVEL: info
ports:
- "8080:8080" # HTTP API
- "8081:8081" # gRPC API
- "3000:3000" # Playground UI
depends_on:
openfga-migrate:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "/usr/local/bin/grpc_health_probe", "-addr=:8081"]
interval: 10s
timeout: 5s
retries: 3
volumes:
pgdata:
# 启动
docker compose up -d
# 验证
curl http://localhost:8080/healthz
# 打开 Playground
open http://localhost:3000
# 创建 Store
curl -X POST http://localhost:8080/stores \
-H 'Content-Type: application/json' \
-d '{"name": "my-store"}'
14.9 OpenFGA vs Casbin vs OPA 对比
维度 |
OpenFGA |
Casbin |
OPA |
|---|---|---|---|
模型 |
ReBAC(关系驱动) |
RBAC/ABAC/RESTful |
ABAC(策略驱动) |
语言 |
DSL(类型+关系) |
PERM 模型 + 策略文件 |
Rego |
数据存储 |
PostgreSQL/MySQL |
文件/DB/Redis |
内存/Bundle |
核心能力 |
关系图遍历 |
模型适配器 |
通用策略评估 |
Check 性能 |
~5ms(图遍历+缓存) |
~0.1ms(内存) |
~1ms(内存) |
ListObjects |
✅ 原生支持 |
❌ 需自行实现 |
❌ 需自行实现 |
权限继承 |
✅ 关系链自动推导 |
⚠️ 需手动配置 |
⚠️ 需 Rego 编写 |
多租户 |
✅ Store 隔离 |
⚠️ 需自行实现 |
⚠️ 需策略设计 |
部署 |
独立服务 |
嵌入式库 |
独立服务/嵌入式 |
适用场景 |
协作应用、SaaS 权限 |
简单 RBAC、中小项目 |
K8s 策略、合规审计 |
CNCF |
Sandbox |
❌ |
Graduated |
灵感来源 |
Google Zanzibar |
ACL 模型 |
Datalog |
选型建议:
资源级细粒度权限(谁能访问哪个文档)→ OpenFGA
简单角色权限(管理员/编辑/查看者)→ Casbin
复杂条件策略(时间、IP、合规规则)→ OPA
组合使用:OpenFGA 管理关系 + OPA 管理条件策略
14.10 OpenFGA vs RBAC
维度 |
RBAC |
OpenFGA (ReBAC) |
|---|---|---|
授权依据 |
角色 |
关系 |
粒度 |
角色级 |
对象级 |
“谁能访问X” |
需要遍历所有角色 |
直接查询 |
“X能访问什么” |
需要遍历所有资源 |
ListObjects API |
继承 |
角色层次 |
关系链(更灵活) |
性能 |
O(1) 角色检查 |
图遍历(有缓存) |
适用场景 |
简单权限 |
协作应用、SaaS |
14.11 小结
OpenFGA 基于 Google Zanzibar,提供关系驱动的细粒度授权
核心是关系元组:
object#relation@subject支持权限继承(通过关系链推导)
提供 Check、ListObjects、ListUsers 等丰富的 API
适合协作应用(文档、项目管理)和多租户 SaaS
与 RBAC 相比,OpenFGA 在资源级别的细粒度控制上更有优势
提供 Python、Java、Go 等多语言 SDK,可与主流 Web 框架深度集成