Quickback Docs

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:

NeedUse
"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 editor is automatically a viewer.
  • Hierarchical permissions — a document inherits viewers from its folder.
  • Group permissions — every member of team:eng is 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

SyntaxMeaning
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 editorUnioneditors are also viewers (derived).
define viewer: editor and memberIntersection — must be both.
define viewer: editor but not blockedExclusion.
define viewer: viewer from parentTuple-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 tree

The 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 shapeCollection 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.

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 no user:* arm in the model, minting fails with a 400 rather than handing back a dead link.
  • Resolve a link: GET /share/v1/:tokenno authentication. The route verifies the HMAC, then checks the stored grant (no contextual shortcut): the object must still have the granted user:* 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 (via POST /fga/v1/write deletes, or your own action). The next GET Check 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>). Set sharing.baseUrl and add the host to your runtime routes:

    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:

EndpointBodyReturns
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 on read.access / views (see Filtering a collection). It is not valid on create (no record yet — rejected at compile) or standalone actions (use ctx.fga.check in execute).
  • Collection filtering is bounded by authz.fga.listObjects.maxIds (default 1000). A caller authorized for more rows than the cap gets a hard 422 FGA_LIST_TOO_LARGE, not a partial page — paginate via POST /fga/v1/list-objects for 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.
  • ListObjects enumerates candidate objects from tuples and Checks each — a reverse-index optimization is a planned follow-up.

On this page