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 }.
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_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).
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 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.
Permissions
Name a grant rule once with anyOf / allOf / not over relationships, org roles, scoped roles, and pseudo-roles — then reference it from any firewall as { permission: 'x' }.
Diagnostics
The compile-time authz / authn analyzer. Reads the lowered policy IR and flags empty or shadowed permissions, unreachable roles and views, capability-ceiling breaches, the not-over-resource null-trap, and pushdown cost — warn-by-default so upgrades never break, with a QUICKBACK_AUTHZ_STRICT opt-in that turns every warning into a hard compile error.