Quickback Docs

Arrows

Traverse a foreign key to inherit authority from a parent object — "you can see this row because you administer the organization it belongs to", or "because you control an ancestor in the tree". Declared once under authz.arrows, referenced from a permission, lowered to an FK-hop subquery or a bounded WITH RECURSIVE CTE.

A relationship answers "does the caller have a row linking them to this resource?" An arrow answers a different question: "the caller has authority over another object — does that authority flow across a foreign key to this one?" An event's announcements inherit the authority of the event's organization; a folder's documents inherit the authority of the folder; a child section inherits the authority of its ancestors.

An arrow is a declared FK hop: it names a foreign-key column on a from table and the to table that FK resolves to. Referenced from a permission, it resolves the row's FK to its target object and requires a permission there — SpiceDB's relation->permission, lowered to SQL. Two emit shapes: a single FK hop lowers to a subquery; a self-referential hop (folder.parentId -> folder) lowers to a depth-bounded WITH RECURSIVE CTE.

Declaring arrows

Arrows live in quickback.config.ts under authz.arrows, alongside authz.relationships and authz.permissions:

// quickback.config.ts
import { defineConfig } from '@quickback/compiler';

export default defineConfig({
  // ...providers...
  authz: {
    arrows: {
      // single FK hop: an event row → the organization that owns it
      eventOrg:   { from: 'event',  fk: 'organizationId', to: 'organization' },
      // self-referential hierarchy: a folder → its parent folder
      folderTree: { from: 'folder', fk: 'parentId', to: 'folder', recursive: true },
    },
    permissions: {
      'org:admin':   { anyOf: [{ role: 'admin' }, { role: 'owner' }] },
      // org admins of an event's org inherit access to the event
      'event:view':  { anyOf: ['organizerOf', { arrowRef: 'eventOrg', permission: 'org:admin' }] },
      // anyone who can administer an ancestor folder can read this folder
      'folder:read': { anyOf: [{ arrowRef: 'folderTree', permission: 'org:admin' }] },
    },
  },
});
FieldMeaning
fromSource table the FK column lives on.
fkForeign-key column on from. For a non-recursive arrow this is the tenant column the claim binds against (see below); for a recursive arrow it is the recursion pointer (parentId).
toTarget table the FK resolves to. A self-hop has from === to.
recursive?Forces the recursive-CTE lowering. When from === to the compiler auto-detects recursion even if this is omitted. Default false.
maxDepth?Per-arrow recursion bound (positive integer). Ignored for non-recursive arrows. Default is the global bound, 8.
tenantColumn?The tenant column the seed of a recursive arrow binds the org claim against — defaults to organizationId. Set it when the hierarchy table spells the column differently (e.g. organisationId). Ignored for non-recursive arrows.

Referencing an arrow from a permission

An arrow is never used directly. It is a leaf inside a permission expression, written as { arrowRef: '<name>', permission: '<perm-at-target>' }:

'event:view': { anyOf: ['organizerOf', { arrowRef: 'eventOrg', permission: 'org:admin' }] },

Read it as: "event:view holds if the caller is an organizerOf the event, or if — hopping across eventOrg to the event's organization — the caller has org:admin there." The permission named on the leaf is the permission required at the target object, and it participates in the permission composition graph (resolution + cycle detection) exactly like a { permissionRef }.

Target restriction. An arrow's target permission may reduce to either:

  • Org-membership roles (admin / owner / member) — directly or through a permissionRef / anyOf / allOf of org roles. The hop binds to the caller's verified active-org claim (ctx.activeOrgId). So org:admin = anyOf(role('admin'), role('owner')) is the canonical org target.
  • A single relationship (relation-backed target, non-recursive hops only) — the FK hop inherits a relationship the caller holds on the hopped-to parent object. "Organizers of the parent event can see its sessions" is a { arrowRef: 'sessionEvent', permission: 'event:organizer' } arm where event:organizer reduces to the organizerOf relationship. The arrow's fk and the relationship's resource.column must address the same parent-id space (the column the FK points at) — for a multi-hop relationship, its first hop's on column, exactly as a via: arm reads it. See Emit shape 3 for the composition.

A target that references a scoped role, a pseudo-role, a not, or a nested arrow — or that mixes a relation with org roles, or pairs a relation-backed target with a recursive hop — is a compile error (never a silent allow). Those remain a documented fast-follow.

Using the permission in a firewall

The permission that contains the arrow is referenced from a resource's firewall with a { field, permission } arm, like any other named permission. field is the resource-side column the lowered subquery constrains:

// features/event-documents/event-documents.ts
firewall: {
  all: [
    { field: 'eventId', permission: 'event:view' },
    { field: 'deletedAt', isNull: true },
  ],
}

The arrow arm of event:view lowers to an FK-hop subquery on eventId; the relationship arms (organizerOf) lower to relationship subqueries; the anyOf OR's them under the null-safe empty-guard (anyGuarded) so an absent claim denies rather than reading the whole table.

Emit shape 1 — non-recursive (FK-hop subquery)

For eventOrg = { from: 'event', fk: 'organizationId', to: 'organization' } with target permission org:admin, an { arrowRef: 'eventOrg' } arm on a resource constrained by eventId lowers to:

WHERE event_documents.eventId IN (
        SELECT event.id FROM event
        WHERE event.organizationId = ?   -- ? = ctx.activeOrgId (signed claim)
      )

In Drizzle terms:

inArray(event_documents.eventId,
  db.select({ x: event.id }).from(event)
    .where(eq(event.organizationId, ctx.activeOrgId!)))

The seed of every non-recursive hop binds the fk column to the active-org claim — so for a non-recursive arrow, fk is the org/tenant column on from, not an unrelated FK. (A self-FK hierarchy is the recursive case below; that's where fk is a recursion pointer and the tenant column is separate.)

Emit shape 2 — recursive (bounded WITH RECURSIVE CTE)

A self-referential arrow expresses inherited authority down a hierarchy: a row is visible if the caller controls it directly or controls any of its ancestors. For folderTree = { from: 'folder', fk: 'parentId', to: 'folder', recursive: true } the arrow lowers to a recursive CTE bounded by maxDepth:

WITH RECURSIVE __arrow_closure(id, depth) AS (
  -- seed: the folders the caller directly controls (tenant-constrained)
  SELECT folder.id, 0 FROM folder
   WHERE folder.organizationId = ?            -- ? = ctx.activeOrgId
  UNION ALL
  -- step: walk parent → child, RE-constraining tenancy on every walked row
  SELECT folder.id, c.depth + 1
    FROM folder JOIN __arrow_closure c ON folder.parentId = c.id
   WHERE c.depth < 8 AND folder.organizationId = ?
)
SELECT id FROM __arrow_closure

Both D1 (SQLite) and Neon (Postgres) speak WITH RECURSIVE, so the same arrow is valid on either provider. Drizzle interpolates the column/table objects (dialect-correct identifiers) and binds the claim as a parameter — never string-concatenated, so an arrow is never an injection surface (the FK column and tables are compile-time identifiers; the only runtime value is the signed org claim).

Emit shape 3 — relation-backed target

When the target permission reduces to a single relationship, the FK hop composes with that relationship's subquery on the parent object. For sessionEvent: { from: 'session', fk: 'eventId', to: 'event' } with target organizerOf (keyed by eventId), an { arrowRef: 'sessionEvent' } arm on a resource constrained by sessionId lowers to:

agenda_item.sessionId IN (
  SELECT session.id FROM session
   WHERE session.eventId IN (
     -- the organizerOf relationship subquery: the events the caller organizes
     SELECT event_staff.eventId FROM event_staff
      WHERE event_staff.userId = ?               -- ? = ctx.userId
        AND event_staff.role = 'organizer'
        AND <event_staff's own firewall — soft-delete>
   )
)

The inner subquery is the same via: relationship subquery — it AND's the relationship table's own firewall (soft-delete) and binds ctx.userId fail-closed (a null caller short-circuits to 1 = 0). So a relation-backed arrow inherits every via: guarantee; the FK hop just maps the resource's column to the parent id space the relationship returns. The outer resource firewall stays the outermost AND, so tenant isolation is never bypassed.

Relation-backed targets are non-recursive only — a recursive self-hop with a relation target is a compile error (the bounded closure seed needs the org-membership form; relation seeding is a fast-follow).

On Postgres the RLS policy enforces the same composition. The arrow lowers to a SECURITY DEFINER helper that returns the session rows whose eventId is in the caller's public.qb_rel_organizer_of() set — the same relationship helper a via: arm calls — and the generated API reads through that same helper, so the database and the API run one lookup:

agenda_item.session_id IN (SELECT r.id FROM public.qb_arrow_session_event_id_<hash>() r)
-- qb_arrow_…: SELECT f.* FROM session f
--              WHERE f.event_id IN (SELECT r.event_id FROM public.qb_rel_organizer_of() r)

A relationship whose subject binds a claim the Worker never writes into the transaction stays API-enforced there, exactly as its via: arm does.

Tenant isolation semantics

Arrows are tenant-safe by construction. Three guarantees, all enforced at the SQL level:

  • The seed is tenant-constrained. The CTE root (and the non-recursive hop's WHERE) binds the tenant column to ctx.activeOrgId. A caller only ever seeds the closure with rows in their own org.
  • The recursive arm re-constrains tenancy on every walked row — not just the seed. The step is JOIN __arrow_closure c ON folder.parentId = c.id AND folder.organizationId = ctx.activeOrgId. A row that was reparented under an in-tenant parent but whose own org differs is excluded. This is defense-in-depth: cross-org inheritance must never happen, and the SQL enforces it regardless of what the write path allows. Strict per-row tenancy is the correct default for an org-membership arrow.
  • A missing claim fails closed. When ctx.activeOrgId is null (anonymous or cross-tenant caller), the subquery short-circuits to sql\1 = 0`— a deny, never an unconstrained read and never anundefined` bind.

Bounded recursion

Recursion is always finite. The recursive arm's WHERE c.depth < maxDepth caps the walk so a deep or cyclic hierarchy can never run away. The bound is resolved with this precedence:

  1. Per-permission override — authz.permissionMaxDepth['<perm>'].
  2. Per-arrow maxDepth.
  3. The global default, 8.
authz: {
  arrows: {
    sectionTree: { from: 'sections', fk: 'parentId', to: 'sections', recursive: true, maxDepth: 6 },
  },
  permissions: {
    'section:inTree': { anyOf: [{ arrowRef: 'sectionTree', permission: 'org:admin' }] },
  },
  // optional: raise the bound for one permission without touching the arrow
  permissionMaxDepth: { 'section:inTree': 16 },
}

An author who explicitly opts out of the bound — unbounded: true — with no token-closure path is a compile error: there is no safe finite SQL for unbounded recursion, so the compiler refuses to emit a non-terminating CTE. The fix is to set a maxDepth. Quickback never silently allows an unbounded traversal.

Worked example — q-events

The q-events example ships both shapes.

A recursive self-FK on the sections feature. sections.parentId points back at sections; access to an ancestor section confers access to all descendants:

// quickback.config.ts
authz: {
  arrows: {
    sectionTree: { from: 'sections', fk: 'parentId', to: 'sections', recursive: true, maxDepth: 6 },
  },
  permissions: {
    'org:admin':      { anyOf: [{ role: 'admin' }, { role: 'owner' }] },
    'section:inTree': { anyOf: [{ arrowRef: 'sectionTree', permission: 'org:admin' }] },
  },
}

// features/sections/sections.ts — the permission arm rides alongside the org scope
firewall: {
  all: [
    { field: 'organizationId', equals: 'ctx.activeOrgId' },
    { field: 'id', permission: 'section:inTree' },   // emits the tenant-gated WITH RECURSIVE CTE
    { field: 'deletedAt', isNull: true },
  ],
}

The org equals arm and the recursive arrow's per-row tenant re-constraint are belt-and-suspenders — both pin the closure to the caller's org. The arrow adds the ancestor-authority dimension on top of the flat org scope.

Reference

// authz.arrows.<name>
interface ArrowDefinition {
  from: string;          // source table the FK lives on
  fk: string;            // FK column (non-recursive: the tenant column; recursive: the parent pointer)
  to: string;            // target table the FK resolves to (self-hop: from === to)
  recursive?: boolean;   // force the recursive-CTE lowering (auto when from === to)
  maxDepth?: number;     // per-arrow recursion bound (default 8)
  tenantColumn?: string; // recursive-seed tenant column override (default 'organizationId')
}

// referenced from a permission as a leaf:
{ arrowRef: '<arrowName>', permission: '<permissionAtTarget>' }

// per-permission depth override (overrides per-arrow maxDepth + the global 8):
authz.permissionMaxDepth?: Record<string, number>
  • Every { arrowRef } in a permission must name a declared authz.arrows[*] entry, and the arrow's fk / to are checked against the real schema at compile time.
  • The target permission must reduce to org-membership roles or a single relationship (relation-backed targets are non-recursive only).
  • See Permissions for the expression DSL the arrow leaf lives in, and Firewall — named permissions for how the resulting { permission } arm sits inside a firewall. The compile-time analyzer reports the subquery / CTE depth of every arrow-bearing permission — see Diagnostics.

On this page