# Named Access Rules - authz.roles & authz.rules

**[Rung 2](/define#the-authorization-ladder).** 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](/define/access).

## 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](/define/areas)'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.

```typescript
// 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' },
    },
  },
});
```

```typescript
// 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](#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`](/define/permissions) — 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](/define/firewall#named-gate-references-rule) — under ONE name, declared once:

```typescript
// 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' },
      ] }],
    },
  },
}
```

{/* doc-compile: skip — cross-fence dependency: the `eventRow` gate is declared in the `quickback.config.ts` fence directly above. Named refs are expanded once at the compiler's normalization seam with the project's authz config in hand, and the gate compiles each fence as an isolated project against a fixed harness config, so `{ rule: 'eventRow' }` can never resolve here. */}
```typescript
// 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 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.


## Subscription tiers

When the [subscriptions plugin](/platform/auth/plugins/subscriptions) 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.

```typescript
// 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'] }] },
    },
  },
});
```

```typescript
// 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](/platform/auth/plugins/subscriptions#entitlements-gating-access-on-tier).

## 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](/define/relationships).
