Quickback Docs

Named Access Rules - authz.roles & authz.rules

Define an access expression once in authz.roles or authz.rules and reference it by name from every feature. Rung 2 of the authorization ladder.

Rung 2. Once the same role expression appears across many features, give it a name and declare it once. If you only have a couple of repeats, inline them — stay on rung 1.

Centrally defined access rules

Instead of repeating the same roles: [...] expression across every feature, define it once in authz.roles and reference it by name everywhere. This is the recommended way to manage access as your app grows: one source of truth per rule, changed in one place.

Named roles (and named gates, tenant anchors, scope kinds) can be declared at two levels: at root authz in quickback.config.ts (visible everywhere), or inside a feature area's _area.ts (visible to that area's subtree only — a feature outside the area referencing the name is a compile error, and root authz bodies may reference root-declared names only). Declare each name in exactly one place: re-declaring an inherited name is a compile error.

// quickback.config.ts — define ONCE
export default defineConfig({
  // ...providers...
  authz: {
    roles: {
      // Compose from BA roles, pseudo-roles, and other named rules:
      staff:     { roles: ['admin', 'member'] },
      // Boolean combinators:
      eventStaff: { or: [{ roles: ['staff'] }, { via: 'organizerOf' }] },
      // Relationship-backed (see below):
      attendee:   { via: 'attendeeOf' },
    },
  },
});
// any feature — REFERENCE by bare name
import { q, defineTable } from '@quickback/compiler';

export const agendaItems = q.table('agendaItems', {
  id:             q.id(),
  title:          q.text().required(),
  startsAt:       q.text().required(),
  organizationId: q.scope('organization'),
  ...q.audit(),
  ...q.softDelete(),
});

export default defineTable(agendaItems, {
  read:   { access: { roles: ['eventStaff'] } },
  update: { access: { roles: ['eventStaff'] } },
});

// features/events/actions/checkIn.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Check a guest in to the event and record the arrival time.",
  input: z.object({ guestId: z.string() }),
  access: { roles: ['eventStaff', 'attendee'] },
  async execute({ db, input, ctx }) { /* ... */ },
});

Rule shapes

Each entry in authz.roles is one of four shapes:

ShapeMeaning
{ roles: ['admin', 'member'] }Composition — matches any of the listed roles (BA, pseudo, or other named rules)
{ or: [ruleA, ruleB] }Matches if any sub-rule matches
{ and: [ruleA, ruleB] }Matches only if all sub-rules match
{ via: 'relationshipName' }Caller proves a declared relationship — see Relationship-based authorization below

Rules compose freely — a rule may reference another rule by name (cycles are a compile error).

Where references resolve

A named rule works in the roles: [...] array of every access surface. The compiler expands it at build time:

Surface
crud.*.access
read.access / read.views.*.access
masking.<field>.show✓ for role/composite rules; relationship (via) rules refused at compile (v1)
actions.<name>.access
realtime.requiredRoles✓ — relationship arms fail closed at the WS handshake and are enforced by the row-stream firewall

Names that aren't declared in authz.roles — Better Auth roles, pseudo-roles like AUTHENTICATED — pass through untouched, so you can mix named rules and raw roles in the same array.

Beyond roles: named permissions

For access that traverses the data graph (FK hops, SpiceDB-style permission composition), see authz.permissions + authz.arrows — the same define-once-reference-by-name model, one level richer than authz.roles.

Named gates (authz.rules)

authz.roles centralizes the who. A gate (authz.rules.<name>) bundles a full access rule — who + an optional row predicate (record:) + optional firewall lanes — under ONE name, declared once:

// quickback.config.ts
authz: {
  tenants: { org: { column: 'organizationId', equals: 'ctx.activeOrgId' } },
  rules: {
    // who + record: "org staff, but only rows still active"
    activeStaff: {
      who: { roles: ['member', 'admin', 'owner'] },
      record: { status: { equals: 'active' } },
    },
    // who + firewall: an org-OR-event-scope row gate, declared ONCE
    eventRow: {
      who: { roles: ['owner', 'admin', 'member'] },
      firewall: [{ any: [
        { tenant: 'org' },
        { field: 'eventId', equals: 'ctx.scope.event' },
      ] }],
    },
  },
}
// any feature — the gate name works like a role name
export default defineTable(eventLinks, {
  firewall: [{ rule: 'eventRow' }],               // firewall stamp
  read: { access: { roles: ['eventRow'] } },      // access stamp
});

The two-stamp contract

One shared name, two independent stamps — a single reference never applies both:

  • An access-position reference (the bare name in any roles: [...] array) applies the gate's who + record arms only. It expands exactly like an inline rule: a who-only gate splices as the flat role list; a who+record gate as { and: [who, { record }] }.
  • A firewall reference (firewall: [{ rule: '<name>' }]) applies the gate's firewall lanes only, spliced into the resource's top-level AND array. The who of a gate referenced only from firewall: is not consulted — that is the documented contract, not a silent drop. Firewall-who is legitimately wider than access-who (e.g. the firewall admits scope-token holders to rows while read access stays staff-only).

Both directions are enforced fail-closed at compile time:

You wroteCompiler says
Access reference to a firewall-bearing gate, but the resource's firewall: doesn't reference itError — the row lanes would silently not apply
firewall: [{ rule }] naming a gate with no firewall armError — a who/record-only gate would splice zero lanes
A firewall-bearing gate referenced from an action's access or from masking.showError, unconditional — no firewall array exists at those sites (nested show.or / show.and arms included)
A who-less (firewall-only) gate referenced from an access positionError — row lanes can't masquerade as a who-grant
A gate's record: field is not a column of the referencing tableError, per reference — a missing field evaluates against undefined at runtime (notEquals/notIn would silently grant)
A spliced gate's firewall lane filters on a column the stamped table doesn't haveError, per reference — the lane could never scope rows there

Naming rules

Rule names share the reference namespace with roles, so the compiler rejects (at declaration time) a rule name that collides with an authz.roles entry, a reserved/provider role, any scoped-role name (bare sugar like attendee or the canonical scope:<kind>:<role> spelling), or a name containing + or : (the scope: prefix always means a token-verified scope principal — a colon-named rule could capture references meant for one).

Inside authz.roles / gate who bodies, + hierarchy sugar is banned — write the expanded list (['member', 'admin', 'owner']), because + expansion runs at resource sites only and a member+ inside a body would silently never match. Roles may not reference rules (the dependency arrow is one-way), and gates never nest.

Typo safety

A roles: [...] name that matches nothing declared is fail-closed at runtime (it never grants) but historically silent. The compiler now emits a warning-level AUTHZ_UNKNOWN_ROLE_NAME diagnostic for any name that is not a provider/hierarchy role, pseudo-role, scoped role, or declared role/rule — a typo'd gate name is visible at compile time without breaking projects that use dynamic auth-provider role names. The heuristic runs only for projects that use the authz naming system (any of authz.roles, authz.rules, or authz.scopes declared); projects outside it keep the pre-existing silent-passthrough behavior for dynamic role names.

Outgrown this?

If the answer depends on a row in one of your domain tables ("is this caller a collaborator on this project?") rather than on org membership, that's rung 3 → Relationships.

On this page