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' }] },
},
},
});| Field | Meaning |
|---|---|
from | Source table the FK column lives on. |
fk | Foreign-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). |
to | Target 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 apermissionRef/anyOf/allOfof org roles. The hop binds to the caller's verified active-org claim (ctx.activeOrgId). Soorg: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 whereevent:organizerreduces to theorganizerOfrelationship. The arrow'sfkand the relationship'sresource.columnmust address the same parent-id space (the column the FK points at) — for a multi-hop relationship, its first hop'soncolumn, exactly as avia: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_closureBoth 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 toctx.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.idAND 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.activeOrgIdis null (anonymous or cross-tenant caller), the subquery short-circuits tosql\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:
- Per-permission override —
authz.permissionMaxDepth['<perm>']. - Per-arrow
maxDepth. - 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 declaredauthz.arrows[*]entry, and the arrow'sfk/toare checked against the real schema at compile time. - The target
permissionmust 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.
Scopes & Capability Grants
Let non-org principals (event attendees, shuttle drivers, vendors) reach a narrow slice of your data through a server-verified, relationship-derived scope carried in a signed JWT — with least-privilege scoped roles enforced across every pillar. Accountless principals (invite codes, partner tokens) mint the same scope via ctx.mintScope.
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.