From RBAC to ReBAC: Migrating a Role System to OpenFGA Without Downtime
July 19, 2026 · 9 min read · Lire en français
Contents
Role-based access control is the authorization model everyone reaches for first, and for good reason: it is simple, it fits in a few tables, and for a long time it is exactly enough. You have roles, you assign them to users, roles carry permissions, and a check is a join. Then one day someone in product says the sentence that ends RBAC's usefulness: "let the owner share this one document with this one person." And your clean role model has no place to put that fact.
This is the practical companion to the Zanzibar walkthrough. That one explains why relationship-based access control works. This one assumes you are sold on the model and now have a real RBAC system in production that you need to move to OpenFGA without an outage and without a scary big-bang cutover. We will map the tables, run both systems side by side, backfill, flip the read path, and only then add the features roles never could. Along the way, the traps that make this harder than the blog posts admit.
Where RBAC runs out of road
Here is the RBAC most teams actually run. Three tables and a join.
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
name TEXT UNIQUE -- 'admin', 'editor', 'viewer'
);
CREATE TABLE user_roles (
user_id INT,
role_id INT
);
CREATE TABLE role_permissions (
role_id INT,
permission TEXT -- 'document.edit', 'document.view'
);
A permission check is one query: does this user hold any role that grants this permission?
SELECT EXISTS (
SELECT 1 FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
WHERE ur.user_id = $1 AND rp.permission = $2
);
This is clean and fast, and it answers exactly one shape of question: what can this user do, globally, by virtue of their role? The wall you hit is that real products stop asking that question. They start asking:
- "Can Bob edit document 42?" Not documents in general. That one. RBAC has no column for the object.
- "Share this folder with the design team, and let that cascade to every file inside." RBAC has no hierarchy.
- "Why can Bob see this file?" With roles the answer is "he's an editor," which does not explain which grant, because roles are global and objects are not.
You can bolt object IDs onto the permission strings (document:42:edit) and watch the tables explode, or you can accept that the model is wrong-shaped for per-object access. RBAC answers which role; the product is now asking which object, and that gap is the entire reason to migrate.
The mental shift: a role is just a relation
The trick to a calm migration is to stop thinking of ReBAC as a different universe. It is the same facts, relocated. In RBAC, "Bob is an admin" lives as a row in user_roles. In ReBAC, it lives as a tuple: a relation between Bob and an object.
| RBAC concept | Lives as | ReBAC equivalent |
|---|---|---|
user_roles(bob, admin) | a row | tuple organization:acme#admin@user:bob |
the role admin | a table | a relation on a type |
role_permissions(admin, doc.edit) | a row | a rewrite rule in the model, not a tuple |
| the permission check (SQL join) | a query | an OpenFGA Check call |
Two things move differently, and this is the crux. Role assignments become tuples (data you write). Permissions become model rules (schema you define once). In RBAC both live in tables; in ReBAC the "who has which role" is data and the "which role grants what" is the model. Get that split straight and the rest is mechanical.
Step 1: reproduce your current RBAC, exactly
Do not add a single feature yet. The first move is to model your existing roles in OpenFGA so behavior is identical. Global roles map to relations on a single organization object that everything belongs to.
model
schema 1.1
type user
type organization
relations
define admin: [user]
define editor: [user] or admin
define viewer: [user] or editor
Note the rewrites already earn their keep: admin implies editor implies viewer, which in RBAC you had to encode by hand in role_permissions. Your global role assignments become tuples against the org:
[
{ "user": "user:bob", "relation": "admin", "object": "organization:acme" },
{ "user": "user:carol", "relation": "viewer", "object": "organization:acme" }
]
And the old permission check becomes a Check:
check(user:bob, editor, organization:acme) → allowed: true // admin ⇒ editor
The point of this step is parity, not progress. Every question your SQL could answer, OpenFGA now answers the same way. Nothing in the product changes. You have simply relocated the truth, which is exactly what makes the next step safe.
Step 2: run OpenFGA in shadow
Never flip authorization in one commit. Run both systems in parallel and let them disagree loudly before you trust the new one. Keep the SQL check authoritative, and on every check also ask OpenFGA and compare.
func canEdit(ctx context.Context, userID string, docID int) bool {
sqlAllowed := sqlCheck(ctx, userID, "document.edit") // still the source of truth
// shadow: ask OpenFGA, compare, but do not act on it yet
fgaAllowed, err := fga.Check(ctx, fgaReq(userID, "editor", "organization:acme"))
if err != nil {
log.Warn("fga shadow check failed", "err", err)
} else if fgaAllowed != sqlAllowed {
log.Warn("authz mismatch",
"user", userID, "doc", docID,
"sql", sqlAllowed, "fga", fgaAllowed) // investigate every one
}
return sqlAllowed
}
Let this run in production for a week or two. Every logged mismatch is a bug in your model or your tuple-writing, found before it can deny a legitimate user or leak a document. When the mismatch log goes quiet, you have earned the right to trust OpenFGA. This shadow phase is the single most important part of the migration and the one most guides skip.
While shadowing, wire up dual writes: anywhere you currently insert into user_roles, also write the corresponding tuple. New assignments now land in both systems, so the two do not drift while you prepare the cutover.
Step 3: add what RBAC never could
Once shadow is clean, the payoff. The features that forced this migration are now small additions to the model, with no schema migration and no table explosion. Introduce a document type with per-object grants and folder inheritance.
type group
relations
define member: [user]
type folder
relations
define viewer: [user, group#member]
type document
relations
define parent: [folder]
define owner: [user]
define editor: [user] or owner
define viewer: [user, group#member] or editor or viewer from parent
Now the sentence that broke RBAC is one tuple:
[
{ "user": "user:bob", "relation": "viewer", "object": "document:42" }
]
Bob can view document 42 and nothing else. Share a folder with a team and it cascades to every document inside via viewer from parent. Add someone to a group and they inherit every grant that group holds. None of this required a table, a migration, or touching the documents. The same engine that reproduced your old roles now expresses the things roles never could, because relationships compose and role rows do not.
Your check for a specific document now targets that object instead of the org:
check(user:bob, viewer, document:42) → allowed: true
Step 4: backfill, verify, cut over
The cutover has a strict order. Do it out of order and you either deny real users or serve stale allows.
- Backfill. One-time script: read every
user_rolesrow and write the equivalent tuple. Idempotent, because writing the same tuple twice is a no-op. Run it, then reconcile counts. - Verify. Let the shadow comparison from Step 2 keep running against the backfilled data. Zero mismatches over a meaningful window is your green light. Do not rush this.
- Flip the read path. Change the authoritative check from SQL to OpenFGA. The shadow code inverts: now OpenFGA decides and (briefly) SQL is the one you log against, just in case.
- Remove. After a stable period, delete the SQL check and, eventually, the role tables. Keep the dual-write until you are certain nothing else reads them.
The reason this order matters is the same consistency concern from the Zanzibar post: a check must never be staler than the data it guards. During backfill, tuples are still being written, so keep SQL authoritative until the tuple set is provably complete. Flip only when fresh.
The traps nobody warns you about
Do not port your role sprawl. If RBAC grew to forty roles because roles were your only tool for scoping, resist recreating forty relations. Most of that sprawl was people encoding object and context into role names (editor_marketing_eu). In ReBAC, object scoping is a tuple and context is a condition. Migrate the intent, not the forty roles.
Checking one object is cheap; listing is not. Check(user, viewer, document:42) is fast. But "show me every document Bob can view" is ListObjects, which walks the graph in reverse and is a fundamentally heavier operation. If your UI lists resources per user, design for it early: OpenFGA has ListObjects, but understand its cost and cache accordingly. RBAC's WHERE role IN (...) was cheap here; do not assume parity.
Keep the database and tuples in sync, transactionally. The failure mode of dual systems is drift: you commit a row and the tuple write fails, so the two disagree. Write the tuple in the same transaction as the domain change where you can, or use an outbox (record the intent in your DB, a worker syncs it to OpenFGA with retries). A tuple write that is fire-and-forget will eventually desync and produce exactly the "why can't Bob see this?" tickets you migrated to avoid.
Model, then migrate. The biggest wins and the biggest mistakes both happen in the model file, before a single tuple moves. Spend the time getting types and rewrites right; a wrong model backfilled into millions of tuples is expensive to unwind.
Takeaways
- RBAC runs out of road at per-object access. The day you need to share one resource with one person, roles have nowhere to store it. That need is the migration trigger, not a nice-to-have.
- A role is just a relation, and a permission is just a model rule. Assignments become tuples (data); "which role grants what" becomes rewrites (schema). Keeping that split clear makes the mapping mechanical.
- Reproduce before you improve. Model your existing roles first and prove parity, so the migration is a relocation, not a rewrite. Only then add hierarchy and per-object sharing.
- Shadow, then cut over in order. Run OpenFGA alongside SQL until mismatches go quiet, backfill, verify, flip, remove. Never in one commit.
- Watch the three traps: don't port role sprawl, budget for
ListObjects, and keep tuples and rows in sync with a transaction or an outbox.
RBAC is not wrong; it is a model that answers one question well and runs out when the questions change shape. ReBAC does not replace the idea of a role, it generalizes it: a role becomes one kind of relationship among many, and the migration is mostly the work of admitting that your permissions were always a graph, and finally storing them like one.
Subscribe to future posts
Get future posts in your inbox. No spam, unsubscribe any time.
Related posts
Zanzibar Demystified: How Google Answers 'Can This User Do This?'
Authorization looks like a one-line if statement until you run it ten million times a second across every product Google ships. Zanzibar is the system that made that question fast, consistent and global. This walks from the naive permission check to relationship-based access control, the tuple model, consistency with zookies, and the open-source heirs like OpenFGA you can actually run today.
July 18, 2026
Load Balancing Algorithms: Each One Fixes the Last One's Flaw
Spreading requests across N servers sounds like a one-liner: pick a server, send the request. Then one backend is slower, or bigger, or holds a session, and the naive choice falls apart. This walks the classic algorithms as a chain where each one exists to fix the previous one's blind spot: round robin, weighted, least connections, power of two choices, and consistent hashing, in Go, ending with the failure every tutorial forgets.
July 20, 2026
Retry Storms: How Good Clients Take Down Healthy Servers
A retry looks harmless: the request failed, so try again. Multiply that by every client, add one slow dependency, and retries turn into a self-inflicted DDoS. This walks from the naive retry loop to exponential backoff, jitter, retry budgets and circuit breakers, the caller-side half of resilience that pairs with rate limiting on the server.
July 11, 2026