Scalable ReBAC with OpenFGA and Go: A Guide for Multi-Tenant SaaS
Most developers begin their authorization journey with a simple is_admin flag in a database. As requirements grow, they graduate to Role-Based Access Control (RBAC), mapping users to roles like 'Editor' or 'Viewer'. However, for modern multi-tenant SaaS applications—think platforms like Notion, Slack, or GitHub—RBAC quickly becomes a bottleneck.
When your requirements shift from 'Editors can edit posts' to 'User A can edit Document B because they belong to Team C, which has access to Folder D,' you have entered the realm of Relationship-Based Access Control (ReBAC). Implementing this logic using traditional SQL joins or complex application-level middleware leads to 'Authorization Spaghetti'—a fragile, unscalable mess that is difficult to audit and even harder to maintain.
In this article, we will explore how to implement a scalable, Zanzibar-inspired authorization system using OpenFGA and Go. We will look at why ReBAC is the right choice for complex SaaS, how to model relationships, and how to integrate this into a high-performance Go backend.
The Problem: When RBAC Fails the Multi-Tenant Test
RBAC is inherently flat. It assumes that permissions are tied to a user's identity regardless of the resource's context. In a complex SaaS environment, access is often hierarchical and contextual.
Consider a project management tool. A user might be an 'Admin' in Workspace A but only a 'Viewer' in Workspace B. Furthermore, they might have specific 'Editor' rights on a single Project within Workspace B because it was explicitly shared with them.
Trying to solve this with RBAC usually results in an explosion of roles (e.g., Workspace_A_Admin, Project_B_Editor) or a massive permissions table that requires 10-way joins for every API request. This is where Google’s Zanzibar paper changed the landscape. It proposed a global, consistent, and high-performance authorization service based on a graph of relationships rather than a list of roles.
Enter OpenFGA: Zanzibar for the Rest of Us
OpenFGA (Fine-Grained Authorization) is an open-source implementation of the Zanzibar concepts, donated to the CNCF by Okta/Auth0. It allows you to define an authorization model based on relationships and then query that model with sub-millisecond latency.
At its core, OpenFGA treats authorization as a graph. You define Types (e.g., user, workspace, document), Relations (e.g., owner, member, reader), and Tuples (the actual data, like user:alice is member of workspace:acme).
Designing the Authorization Model
Before writing a single line of Go code, we must define our DSL (Domain Specific Language) model. Let’s design a model for a typical multi-tenant SaaS that supports organizations, folders, and documents.
Defining Types and Relations
In OpenFGA, we define how types relate to one another. Here is a conceptual model:
type user type organization relations define admin: [user] define member: [user] or admin type folder relations define parent: [organization] define viewer: [user] or admin from parent type document relations define parent: [folder] define owner: [user] define viewer: [user] or owner or viewer from parent
In this model:
- An
organizationhas admins and members. Admins are automatically members. - A
folderbelongs to an organization. Anyone who is anadminof that organization is automatically aviewerof the folder. - A
documentbelongs to a folder. It inherits viewers from that folder, but also allows for an explicitowner.
This transitive property ("viewer from parent") is the superpower of ReBAC. It allows you to model complex hierarchies without duplicating data.
Implementing ReBAC in Go
Go is an ideal language for implementing authorization middleware due to its performance and excellent concurrency primitives. We will use the official openfga-go-sdk to interact with our OpenFGA server.
Initializing the OpenFGA Client
First, we need to set up the client. In a production environment, you would typically run OpenFGA as a sidecar or a central service.
import ( fga "github.com/openfga/go-sdk" "github.com/openfga/go-sdk/client" ) func NewFgaClient() (*client.OpenFgaClient, error) { configuration, err := client.NewConfiguration(client.ClientConfiguration{ ApiUrl: "http://localhost:8080", StoreId: "YOUR_STORE_ID", AuthorizationModelId: "YOUR_MODEL_ID", }) if err != nil { return nil, err } return client.NewOpenFgaClient(configuration), }
Writing Relationship Tuples
When a user creates a resource in your application, you must record that relationship in OpenFGA. For example, when Alice creates a new document in Folder 456:
func (s *AuthService) GrantOwnership(ctx context.Context, userID, docID string) error { body := client.ClientWriteRequest{ Writes: []client.ClientTupleKey{ { User: fmt.Sprintf("user:%s", userID), Relation: "owner", Object: fmt.Sprintf("document:%s", docID), }, dreams } _, err := s.fgaClient.Write(ctx).Body(body).Execute() return err }
Checking Permissions
This is the most frequent operation. Instead of writing a complex SQL query to see if user:alice can view document:123, you simply ask OpenFGA. OpenFGA will traverse the graph (checking if she is the owner, or if she is a viewer of the parent folder, or an admin of the parent organization) and return a boolean.
func (s *AuthService) CanViewDocument(ctx context.Context, userID, docID string) (bool, error) { body := client.ClientCheckRequest{ User: fmt.Sprintf("user:%s", userID), Relation: "viewer", Object: fmt.Sprintf("document:%s", docID), } resp, err := s.fgaClient.Check(ctx).Body(body).Execute() if err != nil { return false, err } return *resp.Allowed, nil }
Handling Multi-Tenancy and Scale
In a multi-tenant environment, isolation is paramount. OpenFGA handles this through Stores. Each tenant can theoretically have its own store, but more commonly, you use a single store and include the tenant ID within the object names (e.g., document:tenantA_123).
Performance and Caching
One common concern with externalizing authorization is latency. Adding a network hop to every API request sounds expensive. However, OpenFGA is designed for this:
- Check Latency: Most
Checkcalls return in <10ms. - Contextual Tuples: You can pass temporary relationships (like a one-time sharing token) in the request itself without persisting them to the database.
- Consistency Models: Zanzibar introduced the concept of "Zookies" (consistency tokens). OpenFGA allows you to specify consistency requirements to ensure that a user who was just granted access can immediately use it, while maintaining high availability.
In Go, you can further optimize by implementing a local cache for high-frequency checks, though you must be careful with cache invalidation when relationships change.
Real-World Pattern: The "Collaborative Workspace"
Let’s look at a more complex scenario: Inherited Permissions with Exceptions.
Imagine a workspace where everyone in the Engineering group can view all documents, except for those in the HR folder. In a traditional system, you would have to manage an 'exclusion list'. In ReBAC, you can model this using 'intersection' or 'difference' operators (though OpenFGA primarily focuses on unions and inheritance).
To handle the "Engineering can view all" requirement, you simply create a relationship between the Engineering group and the Workspace:
workspace:acme#viewer -> group:engineering#member
Because your documents inherit from the workspace, the Go backend doesn't need to change. The authorization logic lives entirely within the model, decoupled from your business logic. When the product team decides that 'Project Managers' also need access, you update the OpenFGA model, and the Go application reflects the change instantly without a redeploy.
Best Practices for Senior Engineers
- Keep Business Logic Out of FGA: OpenFGA should know who can do what, but not why in a business sense. For example, don't store a user's subscription status in FGA. Check the subscription in your app, and then check FGA for resource-level access.
- Auditability: Every
Writeto OpenFGA is a record of a permission change. This provides a natural audit log for security compliance (SOC2, etc.). - Testing the Model: Use the OpenFGA CLI or VS Code extension to write 'assertions'. These are unit tests for your authorization logic, ensuring that your graph traversals work as expected before you deploy.
- Batching: If you need to check permissions for a list of items (e.g., filtering a search result), use the
ListObjectsAPI rather than callingCheckin a loop.
Conclusion: Making the Move to ReBAC
Moving to Relationship-Based Access Control is a significant architectural decision, but for multi-tenant SaaS, it is often the only way to remain agile. By using OpenFGA and Go, you decouple your authorization logic from your database schema, allowing for complex, nested permissions that scale with your user base.
Actionable Steps:
- Identify the Graph: Look at your current authorization logic. Are you performing multiple joins to check access? That's your relationship graph.
- Start Small: Don't migrate everything at once. Start by moving one complex resource (like 'Shared Folders') to OpenFGA.
- Model First: Use the OpenFGA Playground to draft your DSL and run assertions before writing Go code.
- Middleware Integration: Implement a Go middleware that extracts the User ID and Resource ID from requests to perform
Checkcalls transparently.
By adopting ReBAC early, you build a foundation that can handle the most complex enterprise requirements without rewriting your entire authorization layer every time a new feature is added.