Zanzibar-style relationship authz
For document/project/folder permissions
Zanzibar is Google's authorization system; OpenFGA and SpiceDB are open-source spiritual descendants. The model: permissions emerge from relationships.
When to use ReBAC
When permissions are about resource-relationships:
- "Alice can edit this document because she's a member of the project that owns it."
- "Bob can view this folder because he was invited by an admin."
- "Carol can manage billing because she's the org's owner."
vs RBAC:
- "Alice is an admin (globally)."
ReBAC fits collaboration (Google Drive, Notion, etc.). RBAC fits SaaS roles.
Model
namespace user
namespace doc
relation owner: user
relation editor: user | group#member
relation viewer: user | group#member
permission read = owner | editor | viewer
permission write = owner | editor
permission delete = owner
namespace group
relation member: userRelationships:
doc:123#owner@user:alice, alice owns doc 123.doc:123#editor@user:bob, bob can edit doc 123.doc:123#viewer@group:project-1#member, group project-1's members can view doc 123.group:project-1#member@user:carol, carol is a member of project-1.
Compute permission:
- Can carol read doc 123? → carol is in project-1, project-1 viewers can read doc 123 → yes.
- Can carol write doc 123? → no (project-1 only viewers).
OpenFGA setup
# Run OpenFGA
podman run -d --name openfga \
-p 8080:8080 \
-p 8081:8081 \
openfga/openfgaDefine model:
fga store create --name "olympus-app"
fga model write --store-id $STORE_ID --file model.fgaAdd tuples (relationships):
import { OpenFgaClient } from "@openfga/sdk";
const fga = new OpenFgaClient({ apiUrl: "http://localhost:8080", storeId: STORE_ID });
await fga.write({
writes: [
{ user: "user:alice", relation: "owner", object: "doc:123" },
{ user: "user:bob", relation: "editor", object: "doc:123" },
],
});Check permissions:
const { allowed } = await fga.check({
user: "user:carol",
relation: "read",
object: "doc:123",
});Hooking into Olympus
// Express middleware
app.get("/docs/:id", async (req, res) => {
const session = await getSession(req);
const { allowed } = await fga.check({
user: `user:${session.identity.id}`,
relation: "read",
object: `doc:${req.params.id}`,
});
if (!allowed) return res.status(403).send("Forbidden");
// ... fetch and return doc
});Hierarchical relationships
folder
parent: folder
reader: user | folder#reader # inherit from parentSo if alice has folder:root#reader, she also has read on folder:nested-1 if parent is root.
Massively expressive.
Performance
OpenFGA / SpiceDB scale to millions of relationships, microsecond reads. Far beyond raw Postgres for these queries.
For < 100k relationships: Postgres is fine. For millions: dedicated.
Sharing UI
// Share a doc
<form action={share}>
<input name="email" placeholder="user@example.com" />
<select name="role">
<option value="viewer">Can view</option>
<option value="editor">Can edit</option>
</select>
<button>Share</button>
</form>async function share(formData: FormData) {
const email = formData.get("email");
const role = formData.get("role");
const identity = await kratos.findIdentityByEmail(email);
await fga.write({
writes: [{ user: `user:${identity.id}`, relation: role, object: `doc:${docId}` }],
});
}Removing access
await fga.write({
deletes: [{ user: `user:${userId}`, relation: "editor", object: `doc:${docId}` }],
});Listing who has access
const tuples = await fga.read({ object: `doc:${docId}` });
// Returns all relationships for this docUI: show people who can view/edit this doc.
Listing what you have access to
const accessible = await fga.listObjects({
user: `user:${userId}`,
relation: "read",
type: "doc",
});
// Returns all docs this user can readFor listing pages: query FGA, then fetch metadata for each.
OpenFGA vs SpiceDB
| OpenFGA | SpiceDB | |
|---|---|---|
| Origin | Auth0 / Okta | AuthZed |
| Hosting | Self-host + cloud | Self-host + cloud |
| API | gRPC + REST | gRPC + REST |
| Maturity | Younger | More mature |
| OSS license | Apache 2.0 | Apache 2.0 |
Both fine. OpenFGA is slightly simpler getting started; SpiceDB has more advanced features.
Storage
FGA stores its own DB. Don't dual-write (FGA + Postgres), diverges.
When a user signs up in Olympus, add a basic relationship if needed:
// Post-registration hook
await fga.write({
writes: [{ user: `user:${identity.id}`, relation: "self", object: `user:${identity.id}` }],
});(Some models have a "self" relation for shorthand.)
Audit
FGA has its own audit log of write operations. Pair with your app's audit:
audit({ event: "doc_shared", actor: alice, target: bob, resource: docId, relation: "editor" });