# FGA - Relationship-Based Access (Zanzibar)

The **FGA pillar** brings [OpenFGA](https://openfga.dev) / 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](/define/access)
pillar and the lightweight [`authz.relationships`](/define/relationships)
join-roles. Use whichever fits:

| Need | Use |
| --- | --- |
| "Admins can edit, members can read" | [Access](/define/access) roles |
| "The caller has a row linking them to this record" | [`authz.relationships`](/define/access) |
| "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):

```typescript
// 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** — `editor`s are also `viewer`s (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:

```typescript
// 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:

```typescript
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:

```typescript
export default feature('jobs', {
  columns: {
    id:             q.id(),
    title:          q.text().required(),
    organizationId: q.scope('organization'),
    ...q.audit(),
    ...q.softDelete(),
  },
  // 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):

```typescript
export default feature('jobs', {
  columns: {
    id:             q.id(),
    title:          q.text().required(),
    organizationId: q.scope('organization'),
    ...q.audit(),
    ...q.softDelete(),
  },
  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:

```typescript
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:

```typescript
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/:token` — **no 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`:

  ```typescript
  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** on
  `read.access` / views (see
  [Filtering a collection](#filtering-a-collection-by-an-fga-relation)). 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) are **not supported, and fail
  closed**. The tuple table reserves `condition_name` / `condition_context`, but
  there is no CEL evaluator — so a tuple carrying a condition is treated as
  unevaluatable. Touching one makes the whole Check **deny**, including through
  `ListObjects` and tuple-to-userset traversal. Denying the Check rather than
  skipping the tuple is deliberate: a condition can sit on the `but not` side of
  an exclusion, where ignoring it would *grant*. `POST /fga/v1/write` rejects a
  conditional tuple with a 400, and `POST /fga/v1/read` still returns one, so a
  row planted out-of-band stays visible to an audit. Nothing Quickback generates
  writes those columns — only raw SQL, a seed, or an OpenFGA import can.
- `ListObjects` enumerates candidate objects from tuples and Checks each — a
  reverse-index optimization is a planned follow-up.
