FGA - Relationship-Based Access (Zanzibar)
Graph-shaped authorization with an OpenFGA / Google Zanzibar model. Derived, hierarchical, and group permissions, public sharing, and an embedded Check engine — all compiled into your Cloudflare Worker.
The FGA pillar brings OpenFGA / Google Zanzibar Relationship-Based Access Control (ReBAC) to Quickback. You declare an authorization model in the OpenFGA modeling DSL, store relationship tuples in a compiler-owned table, and resolve graph-shaped permission checks at request time — no external authorization service, all inside your Worker.
It is opt-in and sits alongside the role-based Access
pillar and the lightweight authz.relationships
join-roles. Use whichever fits:
| Need | Use |
|---|---|
| "Admins can edit, members can read" | Access roles |
| "The caller has a row linking them to this record" | authz.relationships |
| "A recruiter is automatically a viewer; a folder's viewer can view its docs; share publicly" | FGA |
When you need a permission graph
Roles and one-hop relationship joins can't express:
- Derived permissions — an
editoris automatically aviewer. - Hierarchical permissions — a document inherits viewers from its folder.
- Group permissions — every member of
team:engis a viewer. - Per-record grants — assign one person to one record without a global role.
- Public sharing — make one object viewable by everyone, with a share link.
These are exactly what Zanzibar's userset rewrites and tuple-to-userset relations solve, and what the FGA pillar gives you.
The model
Declare it with authz.fga.model in quickback.config.ts, using the OpenFGA
modeling DSL verbatim (the same text the OpenFGA CLI and VS Code extension
use):
// quickback.config.ts
export default defineConfig({
// ...
authz: {
fga: {
model: `
model
schema 1.1
type user
type organization
relations
define member: [user]
type job
relations
define org: [organization]
define hiring_manager: [user]
define recruiter: [user] or hiring_manager
define viewer: [user, user:*] or recruiter or member from org
define can_manage: recruiter
type application
relations
define job: [job]
define viewer: viewer from job
define editor: can_manage from job
`,
},
},
});The compiler parses and validates the model at compile time — every type,
relation, and from reference must resolve, or the compile fails loud.
DSL reference
| Syntax | Meaning |
|---|---|
define viewer: [user] | Direct assignment — a stored tuple grants viewer. |
define viewer: [user, user:*] | user:* = type-bound public access (everyone). |
define viewer: [team#member] | A userset — every member of a team is a viewer. |
define viewer: [user] or editor | Union — editors are also viewers (derived). |
define viewer: editor and member | Intersection — must be both. |
define viewer: editor but not blocked | Exclusion. |
define viewer: viewer from parent | Tuple-to-userset — inherit viewer from the parent object. |
Tuples
A relationship tuple is (user, relation, object) — e.g.
(user:anne, recruiter, job:123). Tuples live in a compiler-owned,
organization-scoped fga_tuples table that the compiler emits and migrates
for you (a Check never reads another tenant's tuples).
Write them three ways:
// 1. In an action / handler via ctx.fga
await ctx.fga.write([
{ user: 'user:' + input.userId, relation: 'recruiter', object: 'job:' + record.id },
]);
// 2. Over the REST API (admin / write-role gated)
// POST /api/v1/fga/v1/write
// { "writes": [{ "user": "user:anne", "relation": "recruiter", "object": "job:123" }] }
// 3. A public share link (see Public sharing below)Checking access
ctx.fga in handlers and actions
Every authenticated request gets an org-scoped ctx.fga client:
const allowed = await ctx.fga.check('user:' + ctx.userId, 'can_manage', 'job:' + jobId);
const myJobs = await ctx.fga.listObjects('user:' + ctx.userId, 'viewer', 'job'); // ['job:1', ...]
const tree = await ctx.fga.expand('job:123', 'viewer'); // debug the userset treeThe access: { fga } arm — secure by construction
Enforce a Check declaratively on a resource operation. The compiler fetches the
record and emits an inline ctx.fga.check(...) gate:
export default feature('jobs', {
// ...
// Org owners/admins can edit any job, OR anyone with the FGA `can_manage`
// relation on THIS specific job — without an org-wide admin role.
update: {
access: {
or: [
{ roles: ['owner', 'admin'] },
{ fga: { relation: 'can_manage', object: 'job:{id}' } },
],
},
},
});object is a template — {field} tokens interpolate the fetched record's
columns (job:{id} → job:${record.id}). On per-record operations
(GET /:id, update, delete, and record-based actions) the compiler emits
the inline check. It is fail-closed: if the check can't run, access is
denied.
Filtering a collection by an FGA relation
On read.access (and named views) the fga arm does more than gate the
per-record GET /:id — it filters the collection GET / too. The compiler
resolves the caller's authorized object ids via the engine's ListObjects and
injects an inArray(id, …) into the list query before it runs, so the row
page, total, and hasMore all stay correct (no fetch-then-drop that would
break pagination):
export default feature('jobs', {
// ...
read: {
// Collection GET / returns only jobs the caller is a `viewer` of;
// GET /:id enforces the same relation per record.
access: { fga: { relation: 'viewer', object: 'job:{id}' } },
},
});Three access shapes are supported on a collection-filtering arm:
| Access shape | Collection behavior |
|---|---|
{ fga } | Always filter to the FGA-authorized ids. |
{ or: [{ roles }, { fga }] } | If the role arm passes, return all firewall-scoped rows (no FGA filter); otherwise filter to the authorized ids. |
{ and: [{ roles }, { fga }] } | If the role arm fails, return an empty page; otherwise filter to the authorized ids. |
Deeper nesting (an fga arm under multi-level and/or, or multiple
conflicting fga arms) is a compile error that points you at
POST /fga/v1/list-objects to filter manually.
The cap. The authorized id set is bounded by authz.fga.listObjects.maxIds
(default 1000). If a caller's authorized set exceeds the cap, the collection
request fails with a hard 422 FGA_LIST_TOO_LARGE whose hint points to
POST /fga/v1/list-objects for paginated enumeration — never a silent partial
page. Raise the cap or paginate via the API for collections that legitimately
authorize more than maxIds rows per caller.
Public sharing
The FGA pillar is also Quickback's public-sharing mechanism — two complementary tools.
Type-bound public access (user:*)
A model relation that lists user:* (e.g. define viewer: [user, user:*] …)
can be granted to everyone by writing one tuple:
await ctx.fga.write([{ user: 'user:*', relation: 'viewer', object: 'job:' + id }]);Now every Check for viewer on that object passes.
Share links
Enable signed, unauthenticated share links:
authz: {
fga: {
model: `...`,
sharing: {
enabled: true,
// The dedicated public route (always mounted): GET /share/v1/:token
route: '/share',
// Optional vanity base URL (also add it to your runtime `routes`).
baseUrl: 'https://share.acme.dev',
// Tokens never expire by default; set a TTL in seconds to expire them.
ttlSeconds: 0,
// Public projection: what a `job:<id>` share link may reveal, no login.
resources: {
job: { table: 'jobs', fields: ['id', 'title', 'department', 'status'] },
},
},
},
}- Mint a link:
POST /api/v1/fga/v1/share{ "object": "job:123", "relation": "viewer" }(write-role gated) →{ token, url }. Minting writes the(user:*, relation, object)tuple (making the object publicly viewable per the model) and then signs the link. If the relation has nouser:*arm in the model, minting fails with a 400 rather than handing back a dead link. - Resolve a link:
GET /share/v1/:token— no authentication. The route verifies the HMAC, then checks the stored grant (no contextual shortcut): the object must still have the granteduser:*relation right now. Only then does it return the configured public projection. Nothing else leaks — the token is scoped to exactly that object and relation. - Revoke a link: delete the
(user:*, relation, object)tuple (viaPOST /fga/v1/writedeletes, or your own action). The nextGETCheck fails and the link stops returning content — revocation is real, not just token expiry.
The share URL: route, base URL, or both
You get both by default:
-
A dedicated route on the API origin —
…/share/v1/:token— always available when sharing is enabled. -
An optional vanity base URL (
share.<your-domain>). Setsharing.baseUrland add the host to your runtimeroutes:providers: { runtime: defineRuntime('cloudflare', { routes: [ { pattern: 'api.acme.dev', custom_domain: true }, { pattern: 'share.acme.dev', custom_domain: true }, // share base URL ], }), },Minted links then use
https://share.acme.dev/v1/:token; otherwise they use the API origin + the dedicated route.
REST API
When authz.fga is set, the compiler mounts an OpenFGA-compatible surface at
/api/v1/fga/v1, all tenant-scoped to ctx.activeOrgId:
| Endpoint | Body | Returns |
|---|---|---|
POST /check | { user, relation, object, contextual_tuples? } | { allowed } |
POST /list-objects | { user, relation, type } | { objects } |
POST /expand | { relation, object } | { tree } |
POST /read | { object?, relation?, user? } | { tuples } |
POST /write | { writes?, deletes? } | { ok } (write-role) |
POST /share | { object, relation, ttlSeconds? } | { token, url } (write-role) |
writeRoles (default ['admin']) controls who can write tuples and mint share
links. Pseudo-roles and + hierarchy suffixes are honored.
How it resolves
The embedded engine resolves a Check by walking the model: direct tuples,
type-bound public access (user:*), userset subjects (team:eng#member),
computed usersets (union / intersection / exclusion), and tuple-to-userset
(viewer from parent), plus request-scoped contextual tuples. It has cycle
detection and a configurable resolution-depth cap (authz.fga.maxDepth,
default 25).
The engine enforces the model's direct type restrictions at check time: a
stored tuple whose subject shape isn't allowed for the relation is inert. So a
user:* tuple on a relation declared [user] (no wildcard) can never grant
access, even if it was written out-of-band — the model is the ceiling.
Limitations (v0.35)
access: { fga }covers per-record operations (GET /:id,update,delete, record-based actions) and collection filtering onread.access/ views (see Filtering a collection). It is not valid oncreate(no record yet — rejected at compile) or standalone actions (usectx.fga.checkinexecute).- Collection filtering is bounded by
authz.fga.listObjects.maxIds(default 1000). A caller authorized for more rows than the cap gets a hard 422FGA_LIST_TOO_LARGE, not a partial page — paginate viaPOST /fga/v1/list-objectsfor those cases. Only the bare/or/and-with-roles shapes are filtered; deeper nesting is a compile error. - Conditional tuples (CEL conditions / ABAC) — the schema reserves the columns, but condition evaluation is not yet wired.
ListObjectsenumerates candidate objects from tuples and Checks each — a reverse-index optimization is a planned follow-up.
Access - Role & Condition-Based Access Control
Define who can perform read and write operations and under what conditions. Configure role-based and condition-based access control for your API endpoints.
Read - Unified Read Pipeline
The unified read configuration that owns collection reads, single-record reads, and named view projections. Replaces crud.list and crud.get.