Quickback Docs

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' }.

A via: arm references one relationship. A roles: [...] array ORs a flat list of role names. When the same grant rule recurs across several resources — "organizer or confirmed attendee", "admin and not suspended" — name it once under authz.permissions and reference it everywhere by name. A permission is a named boolean expression over the authorization primitives you've already declared.

Declaring permissions

Permissions live in quickback.config.ts alongside authz.relationships:

// quickback.config.ts
import { defineConfig } from '@quickback/compiler';

export default defineConfig({
  // ...providers...
  authz: {
    relationships: {
      organizerOf: { from: 'event_staff',  subject: { column: 'userId', equals: 'ctx.userId' }, resource: { column: 'eventId' }, where: { role: 'organizer' } },
      attendeeOf:  { from: 'event_guests', subject: { column: 'userId', equals: 'ctx.userId' }, resource: { column: 'eventId' }, where: { status: 'confirmed' } },
    },
    permissions: {
      'event:view':   { anyOf: ['organizerOf', 'attendeeOf'] },
      'event:manage': { allOf: ['organizerOf', { not: 'suspendedFromEvent' }] },
      'org:admin':    { anyOf: [{ role: 'admin' }, { role: 'owner' }] },
    },
  },
});

The expression DSL

A permission is a tree of three combinators over leaves:

CombinatorMeaning
{ anyOf: [...] }union — satisfied if any arm holds
{ allOf: [...] }intersection — satisfied only if all arms hold
{ not: <expr> }exclusion — satisfied if the operand does not hold

A bare leaf (no combinator) is a one-arm permission — the replacement for a single via:.

Leaves

A leaf names one authorization primitive. The common case is a bare string; object forms are the unambiguous escape hatch.

LeafResolves to
'organizerOf' (bare string, no prefix)a declared relationship
{ relationRef: 'organizerOf' }a declared relationship (explicit)
{ permissionRef: 'event:view' }another permission (composition)
{ role: 'admin' }an org-membership role (ctx.roles)
{ scopeRole: { kind: 'event', role: 'organizer' } }a scoped role
{ pseudoRole: 'AUTHENTICATED' }PUBLIC / AUTHENTICATED / INTERNAL / …
{ arrowRef: 'eventOrg', permission: 'org:admin' }an FK-traversal arrow — resolve the row's FK to its target object and require a permission there

String sugar for the prefixed forms is also accepted inside an expression: 'permission:event:view', 'scope:event:organizer', 'role:admin'. A bare string with no recognized prefix is always a relationship reference.

Referencing a permission

A resource references a permission from its firewall with a { permission } arm. field is the resource-side column the lowered subqueries constrain — the same column a via: arm would carry:

// features/sessions/sessions.ts
firewall: [
  { field: 'organizationId', equals: 'ctx.activeOrgId' },
  { field: 'eventId', permission: 'event:view' },
]

The compiler lowers the permission to the equivalent firewall arms: anyOf becomes an OR of relationship subqueries (any), allOf becomes an AND (all), and { permissionRef } inlines the referenced permission's arms. The result is byte-identical to writing those via: arms by hand — the permission is just the named, reusable spelling.

See Firewall — named permissions for how the arm sits inside a firewall.

M1 limits

Permissions are richer than what a firewall can enforce today. The DSL accepts the full shape so configs are forward-compatible, but a firewall runs as a SQL WHERE clause, so a permission referenced from a firewall must be SQL-pushable. Two cases are rejected at compile time when referenced from a firewall:

  • Claim-site leaves. { role }, { scopeRole }, and { pseudoRole } are decided from the request's verified claims, not from a row subquery. A permission containing one of these can't lower to a WHERE clause, so referencing it from a firewall is a compile error. Gate membership on read.access / crud.*.access instead, where role checks belong.
  • not. A not whose operand resolves to a SQL (relationship) site is rejected — NOT IN (subquery) over a nullable set is the classic null-trap (a NULL row silently passes). It's representable in the DSL for future claim/runtime sites but errors in a firewall in M1.

These shapes ({ role }, not, typed caveats) remain in the DSL because they're the enforcement surfaces of later milestones (claim-site and runtime checks). M1 ships the SQL-pushable subset: relationship and { permissionRef } leaves under anyOf / allOf. FK-traversal arrows ({ arrowRef, permission }) are now shipped and are firewall-pushable — see Arrows.

The compile-time analyzer flags permissions that can never be satisfied, arms shadowed by a broader arm, a not over a SQL site (the null-trap), and the subquery / CTE cost of each rule — see Diagnostics.

Reference

// authz.permissions.<name>
type PermissionExpr =
  | PermissionLeaf
  | { anyOf: PermissionExpr[] }
  | { allOf: PermissionExpr[] }
  | { not: PermissionExpr };

type PermissionLeaf =
  | string                                          // relationship name (bare) or prefixed sugar
  | { relationRef: string }
  | { permissionRef: string }
  | { role: string }                                // claim-site — not firewall-pushable (M1)
  | { scopeRole: { kind: string; role: string } }  // claim-site — not firewall-pushable (M1)
  | { pseudoRole: string }                          // claim-site — not firewall-pushable (M1)
  | { arrowRef: string; permission: string };       // FK-traversal arrow — see Arrows
  • Referenced from a firewall as { field, permission: '<name>' }.
  • Relationship leaves carry every property of the underlying relationship — the from table's own firewall (tenant scope + soft-delete) is AND'd into each subquery, exactly as with a via: arm.
  • See Scopes & capability grants for scoped roles and Access — relationships for the relationship shape.

On this page