# 第十四章:OpenFGA — 细粒度授权引擎 > "OpenFGA 让你像 Google 一样管理权限 — 基于关系,而非角色。" ```{mermaid} 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 项目。 ```{mermaid} flowchart LR subgraph 应用层 A1[Web 应用] A2[API 服务] A3[微服务] end subgraph OpenFGA Server direction TB AM[授权模型
Authorization Model] RT[关系元组
Relationship Tuples] GE[图遍历引擎
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 ``` ### 授权检查流程 ```{mermaid} 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"] } ``` ### 关系模型示例 ```{mermaid} 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 核心概念 ### 授权模型(Authorization Model) ``` # OpenFGA DSL 语法 model schema 1.1 type user type organization relations define admin: [user] define member: [user] or admin type document relations define owner: [user] define editor: [user, organization#member] define viewer: [user, organization#member] or editor or owner define can_edit: editor or owner define can_view: viewer define can_delete: owner ``` ### 关系元组(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 完整示例 ```python 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 授权中间件 ```python """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 完整示例 ```java // 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 集成 ```java // ── 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 getDocument(@PathVariable String docId) { return Map.of("docId", docId, "content", "..."); } @PutMapping("/{docId}") @FGAPreAuthorize(relation = "editor", objectType = "document", objectId = "#docId") public Map updateDocument(@PathVariable String docId, @RequestBody Map body) { return Map.of("docId", docId, "updated", true); } @DeleteMapping("/{docId}") @FGAPreAuthorize(relation = "owner", objectType = "document", objectId = "#docId") public Map deleteDocument(@PathVariable String docId) { return Map.of("docId", docId, "deleted", true); } } ``` ## 14.7 Go SDK 完整示例 ```go 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 授权中间件 ```go 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 部署 ```yaml # 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: ``` ```bash # 启动 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 框架深度集成