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.

Subscription tiers

When the subscriptions plugin is enabled, every tier you declare becomes an ordinary role string — pro becomes tier_pro — and the auth middleware stamps it into the caller's roles alongside their organization roles. There is no new access key and no new syntax: tiers compose in authz.roles exactly like everything else on this page.

// quickback.config.ts
export default defineConfig({
  // ...providers, with plugins: { subscriptions: { tiers: { free: {...}, pro: {...} } } }
  authz: {
    roles: {
      // `tier_pro` is stamped by the middleware — you never declare it.
      paid:      { roles: ['tier_pro'] },
      // Compose it like any other name:
      paidStaff: { and: [{ roles: ['paid'] }, { roles: ['admin', 'owner'] }] },
    },
  },
});
// any feature
import { q, defineTable } from '@quickback/compiler';

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

export default defineTable(reports, {
  read: {
    access: { roles: ['owner', 'admin', 'member'] },
    views: {
      // Pro and Enterprise subscribers; nobody else.
      forecast: { fields: ['id', 'title'], access: { roles: ['paid'] } },
    },
  },
});

tier_pro means "pro or better". A granting subscription stamps its own tier and every tier below it in your declared order, so an Enterprise subscriber carries ['tier_enterprise', 'tier_pro', 'tier_free']. Gate on the lowest tier that should get in and higher tiers follow automatically — there is no hierarchy to configure, because tier order is already the order you declared them in.

A caller with no subscription gets no tier roles at all — not even tier_free. The base tier describes what checkout sells, not what a signed-up user is; only an actual subscription row with a granting status stamps anything. So roles: ['tier_free'] means "someone who holds a free-tier subscription", and "any logged-in user" is still AUTHENTICATED.

Tier names are reserved. Declaring authz.roles.tier_pro is a compile error — it would shadow the stamped role rather than gate on it — and so is a tier whose derived name collides with a pseudo-role, a provider role, or an auth.roleHierarchy entry. Give the gate its own name, as paid does above.

Two independent bounds decide whether a subscription grants: its status (only active and trialing do) and its expiry. A row whose expiresAt passed more than 48 hours ago stops granting whatever its status claims — that grace window absorbs the normal renewal lag, while still bounding a row whose status froze because a webhook never arrived. A row with no expiresAt at all (admin-created, no Stripe period) keeps granting.

A downgrade is immediate on session auth. On a JWT it lasts until the token expires — one auth.jwt.expiresIn for a plain bearer, up to 15 minutes plus one TTL for a scope-bearing one, and expiresIn has no upper bound. For which statuses grant, which transports carry tier roles, and the full staleness picture, see Entitlements.

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