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 }.

M2 target restriction. The target permission must reduce to org-membership roles (admin / owner / member) — directly or through a permissionRef / anyOf / allOf of org roles. That's what lets the hop bind to the caller's verified active-org claim (ctx.activeOrgId). A target permission that references a relation, a scoped role, a pseudo-role, a not, or a nested arrow is a compile error in M2 (never a silent allow) — those richer targets are a documented fast-follow. So org:admin = anyOf(role('admin'), role('owner')) is the canonical target.

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).

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 in M2.
  • 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