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:
| Shape | Meaning |
|---|---|
{ 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'swho+recordarms 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'sfirewalllanes only, spliced into the resource's top-level AND array. Thewhoof a gate referenced only fromfirewall: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 wrote | Compiler says |
|---|---|
Access reference to a firewall-bearing gate, but the resource's firewall: doesn't reference it | Error — the row lanes would silently not apply |
firewall: [{ rule }] naming a gate with no firewall arm | Error — a who/record-only gate would splice zero lanes |
A firewall-bearing gate referenced from an action's access or from masking.show | Error, 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 position | Error — row lanes can't masquerade as a who-grant |
A gate's record: field is not a column of the referencing table | Error, 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 have | Error, 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.
Rate Limit - Per-Operation Request Caps
Per-resource, per-operation rate limiting backed by Cloudflare's native Rate Limiting binding. Defaults apply to every resource automatically; override or opt out per-resource or project-wide.
Relationship-Based Authorization - authz.relationships
Bind access to a row in your own domain tables — attendee, collaborator, external reviewer — with authz.relationships. Rung 3 of the authorization ladder.