Building Melange: Zanzibar‑Style Auth Without a Separate Service
Over the holidays, I finally had some time to breathe and dive into a few "itch-to-scratch" side projects. A fresh repo, a blank README, and the rapid-fire implementation of core features. But like clockwork, I hit the same wall I’ve hit in every project for the last decade.
I had to deal with Authorization.
Authentication
We often lump "Auth" into one bucket, for good reason; they're close cousins, but they are two very different beasts.
Authentication (Authn) is about identity: Who are you? it's usually one of the first problems you solve, I have built more authentication systems than I have built full apps or products. Most unfinished apps have a functioning authentication system. Today, this is essentially a solved problem.
Whether you use a managed provider or a solid library, the path is well-trodden. You set up a login form, handle a session, maybe add 2FA, and you’re done.
The interesting thing about Authn is that it doesn’t really grow with your app. Whether you have ten users or ten million, the "login" logic remains largely the same.
Authorization
Authorization (Authz) is the opposite. It’s about permissions: What are you allowed to do?
In the beginning, Authz is deceptively simple. Let’s look at a standard GitHub-style model:
- Users belong to Orgs.
- Repos belong to Orgs.
- Members of an Org can contribute to its Repos.
In your database, that looks like five tables: users, orgs, repos, and the join tables org_users and org_repos.
Imagine an API route to update a repository: PUT /repos/:id. You have an authenticated user ID (user_id = 'X') and a repository ID (repo_id = 'Y'). You need to ask: Can User X modify Repository Y?
To answer that, you have to verify the chain of ownership:
- Which Org owns this Repo?
- Is the User a member of that Org?
In SQL, it starts out manageable:
SELECT 1 FROM repos r
JOIN org_repos or ON r.id = or.repo_id
JOIN org_users ou ON or.org_id = ou.org_id
WHERE r.id = 'Y' AND ou.user_id = 'X';
A relatively simple query that joins through the ownership chain. It works fine for five tables. But real-world requirements never stay simple.
Growing Pains
You add Issues. Issues belong to Repos. Now, to check if a user can edit an issue, you’re joining issues -> repos -> org_repos -> org_users.
What if an Org has Teams? And Teams can be nested? Now you aren't just checking if a user is in an Org; you're checking if they are in a Team, or a Sub-Team, that has a specific role on a Repo.
Suddenly, you are writing more code to authorize the request than you are to perform the actual business logic. Your queries look like this:
-- Checking permission via nested teams and direct grants
SELECT 1 FROM permissions p
LEFT JOIN team_members tm ON p.accessor_id = tm.team_id
WHERE p.resource_id = 'repo_123'
AND (p.user_id = 'user_abc' OR tm.user_id = 'user_abc')
AND p.capability = 'write';
This is Relationship-Based Access Control (ReBAC), and doing it manually in SQL or in your application code can be cumbersome and error-prone.
The Light at the End of the Tunnel
There are a number of solutions to this problem, and a plethora of services you can run or buy that aim to provide a way of answering these questions for you.
A large portion of these systems are based off a paper published by Google, "Zanzibar: Google’s Consistent, Global Authorization System".
These systems treat your permissions as a graph. They ignore your database tables and look at "tuples", simple strings that define relationships:
user:alice is member of org:acme
repo:engine is child of org:acme
A few notable examples of these Zanzibar or Zanzibar-like systems are:
For the rest of this post I will be talking about OpenFGA.
OpenFGA is an open-source implementation of Zanzibar (donated to the CNCF by Okta/Auth0).
OpenFGA provides a Domain Specific Language (DSL) to model your authorization rules. But it has a high "Entry Fee." Because OpenFGA is a separate service, you face a Synchronization Problem: every time you update your database, you must also update the OpenFGA tuples via an API call. If one fails, your permissions are out of sync.
RoverApp - Pure Postgres ReBAC
I was searching for a middle ground when I found a brilliant post by the team at Rover. They loved the OpenFGA model but hated the sync overhead. Their solution was to implement OpenFGA in Postgres. pgfga.
Rover’s approach was elegant in its simplicity:
- The Model Table: They created an
authz_modeltable to store the OpenFGA schema directly in Postgres. - Dynamic Tuples (The Secret Sauce): Instead of a static table of tuples that you have to sync, they used Database Views. They mapped their existing domain tables (like
org_users) into a view that "looks" like an OpenFGA tuple table. Melange reads tuples from amelange_tuplesview you define over your existing tables. - The Generic Check: They wrote a recursive PL/pgSQL function—
check_permission(user, relation, object)—that traverses these views to find a path between the user and the resource.
This was a game-changer. It meant you could use the power of OpenFGA's modeling while keeping everything "Always-In-Sync" within a single Postgres transaction.
Melange
I took the Rover solution and ran with it, but I eventually hit a ceiling.
The Rover implementation relies on a generalized, recursive function. That’s fine for simpler models, but once I pushed on wildcards (public access) and tuple-to-userset (TTU) chains, the generic interpreter became the hot path.
I got nerd-sniped. I spent the holiday implementing the full spec, but I chose a different architecture: Melange is a compiler. It reads your OpenFGA schema and generates specialized PostgreSQL functions tailored to your relationship graph.
How it works:
melange migrate parses your OpenFGA schema and generates relation-specific SQL functions (plus a dispatcher). Those functions query a melange_tuples view you define over domain tables, with compile-time-inlined role closure and TTU traversal paths. Runtime checks are pure SQL, so there’s no external service or tuple sync.
1. Specialized Dispatching
Here’s a tiny TTU model from the official test suite:
type folder
relations
define viewer: [user]
type document
relations
define parent: [folder]
define viewer: viewer from parent
Melange generates a dedicated check_document_viewer(...) function plus a dispatcher. The generated SQL includes an explicit TTU edge:
-- Recursive access path via parent -> viewer
IF EXISTS (
SELECT 1
FROM melange_tuples AS link
WHERE link.object_type = 'document'
AND link.relation = 'parent'
AND check_permission_internal(
p_subject_type, p_subject_id,
'viewer',
link.subject_type, link.subject_id,
p_visited || v_key
) = 1
) THEN
RETURN 1;
END IF;
The entry point stays small and fast: check_permission(...) simply routes to check_document_viewer(...), check_folder_viewer(...), and so on. Because those functions are generated for your schema, they follow the precomputed paths through the relationship graph. PostgreSQL can plan the joins and indexes ahead of time.
2. List Queries Are First-Class
Real apps need more than binary checks. You often need to populate UI lists with what a user can actually see. Melange generates set-returning functions for this:
- Object Filtering: "Which documents can User X view?"
- Subject Filtering: "Which users can view Document Y?"
Under the hood, you get functions like list_document_viewer_objects(...) and list_document_viewer_subjects(...), routed through list_accessible_objects(...) and list_accessible_subjects(...). They’re joinable straight into your application queries. Recent versions also add cursor-based pagination for these list APIs.
3. Performance & Compatibility
Moving from interpretation to compilation pays off.
- Low-latency checks: Local benchmarks are often sub‑millisecond once the database cache is warm. Network latency between your app and your database is a bigger overhead than the authorization logic itself.
- Schema 1.1 compatibility: Melange is validated against the OpenFGA test suite and supports the full 1.1 executable spec (excluding conditions).
Melange is built for the "Middle Phase." Most of us aren't Google, and we don’t need a globally distributed auth cluster on Day 1. We need something flexible enough to grow, but simple enough to live with. If you truly need multi-region or cross-database authorization later, the standard OpenFGA schema keeps your migration path open.
Melange gives you the modeling power of OpenFGA with the reliability and consistency of in-transaction SQL. It’s designed to carry you from your first user until the day you’re actually big enough to need a dedicated Zanzibar cluster.
Melange is open source and available on GitHub, with docs at melange.sh.