Implementing ReBAC: Scalable Zanzibar-Inspired Auth with OpenFGA and Go
The Authorization Wall: Why RBAC Eventually Fails
Most SaaS applications start with a simple roles table. You have an admin, an editor, and a viewer. This Role-Based Access Control (RBAC) works perfectly until your product grows. Suddenly, customers want to share specific documents with external contractors, group users into teams that span multiple departments, or grant 'manager' permissions that inherit all rights from 'subordinates' across specific project folders.
When you try to shoehorn these requirements into traditional RBAC, your codebase becomes a graveyard of complex SQL joins and brittle if/else logic. You find yourself asking questions like: "Does User A have access to Document B because they are in Team C, which has 'editor' rights on Folder D?"
This is where Relationship-Based Access Control (ReBAC) comes in. Inspired by Google’s Zanzibar whitepaper, ReBAC shifts the focus from what a user is (a role) to how a user relates to an object.
In this article, we will explore how to implement a production-grade ReBAC system using OpenFGA (an open-source implementation of Zanzibar) and Go.
Understanding the Zanzibar Philosophy
Google Zanzibar was designed to handle authorization for products like Google Drive, YouTube, and Cloud IAM. It treats authorization as a graph problem. Instead of checking a database column, you query a centralized service to ask: "Does this relationship exist?"
OpenFGA adopts this model using three core components:
- The Authorization Model: A DSL (Domain Specific Language) that defines your types and relations (e.g., a
documenthas aviewer). - Relationship Tuples: Data points that represent actual instances of relationships (e.g.,
user:aliceis aviewerofdocument:plan_2024). - The Check API: A high-performance query engine that traverses the graph to return a boolean
allowedordeniedresponse.
Designing the Model: A Collaborative Workspace Example
Let’s imagine we are building a Notion-like SaaS. We have organizations, folders, and documents. Access is hierarchical: if you are an admin of an organization, you should have access to everything inside it.
Here is how we define that in OpenFGA DSL:
model schema 1.1 type user type organization relations define admin: [user] define member: [user] type folder relations define parent: [organization] define viewer: [user, folder#viewer] or admin from parent type document relations define parent: [folder] define viewer: [user] or viewer from parent
In this model, we’ve used Relation Composition. The viewer of a document is either someone explicitly assigned the role or anyone who is a viewer of the parent folder. This recursive logic is handled entirely by the authorization engine, not your application code.
Integrating OpenFGA with Go
Go is an ideal language for building high-performance middleware that interacts with OpenFGA. To get started, we'll use the official OpenFGA Go SDK.
1. Initializing the Client
First, we wrap the OpenFGA client. In a real-world multi-tenant app, you might have one OpenFGA Store per customer or a shared store with tenant-prefixed IDs.
import ( openfga "github.com/openfga/go-sdk" "context" ) type AuthzService struct { client *openfga.APIClient storeID string } func NewAuthzService(apiUrl, storeID string) (*AuthzService, error) { configuration, err := openfga.NewConfiguration(openfga.UserConfiguration{ ApiUrl: apiUrl, }) if err != nil { return nil, err } return &AuthzService{ client: openfga.NewAPIClient(configuration), storeID: storeID, }, nil }
2. Performing a Permission Check
When a user tries to access an API endpoint, your middleware should call the Check API. This replaces your traditional database lookup.
func (s *AuthzService) CanViewDocument(ctx context.Context, userID, docID string) (bool, error) { body := openfga.CheckRequest{ TupleKey: &openfga.TupleKey{ User: openfga.PtrString("user:" + userID), Relation: openfga.PtrString("viewer"), Object: openfga.PtrString("document:" + docID), }, } resp, _, err := s.client.OpenFgaApi.Check(ctx).Body(body).Execute() if err != nil { return false, err } return *resp.Allowed, nil }
3. Writing Relationship Tuples
Authorization data must be kept in sync with your application state. When a user creates a document or shares a folder, you must write a "tuple" to OpenFGA.
func (s *AuthzService) GrantAccess(ctx context.Context, userID, docID, relation string) error { body := openfga.WriteRequest{ Writes: &openfga.TupleKeys{ TupleKeys: []openfga.TupleKey{ { User: openfga.PtrString("user:" + userID), Relation: openfga.PtrString(relation), Object: openfga.PtrString("document:" + docID), }, }, }, } _, _, err := s.client.OpenFgaApi.Write(ctx).Body(body).Execute() return err }
Handling Multi-Tenancy and Scale
In a multi-tenant SaaS environment, you face two primary challenges: isolation and performance.
Data Isolation
OpenFGA provides Stores as a logical boundary. For a large-scale SaaS, you should decide between:
- One Store per Tenant: Highest isolation, but harder to manage global cross-tenant features.
- Shared Store with Prefixes: Lower overhead. You ensure IDs are formatted as
tenant_id|user_id. This is generally preferred for SaaS where you might have centralized admin tools.
Performance: The Consistency Trade-off
Zanzibar-style systems are designed for sub-10ms response times. However, because OpenFGA is a separate service, you introduce network latency. To mitigate this:
- Local Caching: Cache the results of a
Checkcall for a few seconds using a TTL cache (likeristrettoorfreecache) in your Go service. Be careful: this introduces a "revocation lag." - Contextual Tuples: Sometimes you have ephemeral state (e.g., "is this user currently in 'Emergency Mode'?"). Instead of writing this to the database, you can pass "Contextual Tuples" in the
Checkrequest itself. These are evaluated on the fly without being persisted.
Real-World Implementation Gotchas
1. The "New Stack" Problem
By moving authorization to OpenFGA, you are introducing a new dependency. If OpenFGA is down, your app is effectively down. Ensure you run OpenFGA in a highly available configuration (Kubernetes with multiple replicas) and use a robust database like PostgreSQL or CockroachDB for the storage backend.
2. Dual Writes
When a user is deleted in your primary database, you must also delete their relationship tuples in OpenFGA. This is a classic distributed systems problem. The best approach is to use an out-of-box transactional outbox pattern or listen to CDC (Change Data Capture) events from your primary database to keep OpenFGA in sync asynchronously.
3. Avoid "God" Objects
Don't create a single object that every user relates to. This creates a "hot spot" in the graph. If you have a global configuration, consider modeling it as a specific relation rather than a single node that millions of users point to.
Testing Your Authorization Logic
One of the best features of OpenFGA is the ability to run "assertions" against your model. Before deploying a model change, you can run a test suite that says: "User Alice should have access to Doc B, and User Bob should not."
In Go, you can write unit tests that spin up an ephemeral OpenFGA instance using Testcontainers. This allows you to verify your authorization logic in your CI/CD pipeline without mocking the entire security layer.
func TestAuthorizationLogic(t *testing.T) { container, _ := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: testcontainers.ContainerRequest{ Image: "openfga/openfga:latest", ExposedPorts: []string{"8080/tcp"}, }, Started: true, }) // Run checks against the real FGA container }
Conclusion: The Path Forward
Moving to ReBAC with OpenFGA and Go is a significant architectural decision, but for complex SaaS products, it is the only way to maintain a clean, scalable codebase. By decoupling your authorization logic from your business logic, you gain the ability to support complex sharing models, hierarchical permissions, and fine-grained access control that would be a nightmare to maintain in SQL.
Actionable Next Steps:
- Audit your current
ifstatements: Identify where you are manually traversing ownership chains in your code. - Prototype a DSL: Use the OpenFGA Playground to model your most complex permission scenario.
- Start Small: Don't migrate your whole app. Start by moving one specific feature (like "Project Sharing") to OpenFGA and expand from there.