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:
| Combinator | Meaning |
|---|---|
{ 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.
| Leaf | Resolves 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.
Firewall-pushable permission rules
A firewall permission compiles all the way down to a SQL WHERE expression.
The production-ready subset includes relationship leaves, composed
{ permissionRef } leaves, and FK-traversal arrows under anyOf / allOf.
Those rules run in the database alongside the rest of the row firewall.
Two expression shapes belong at a different enforcement site, and the compiler routes you there with a build error instead of weakening the policy:
- Claim checks.
{ role },{ scopeRole }, and{ pseudoRole }are decided from verified request claims rather than row subqueries. Put them onread.access/crud.*.access, then keep the permission focused on row reachability. - Negated SQL relationships.
notover a relationship site is refused because nullableNOT IN (subquery)semantics create the classic null trap. Express the positive relationship set or enforce the exclusion at a runtime access site.
The wider DSL remains available for permission definitions and future enforcement sites, while every firewall reference is checked for SQL pushability today. FK-traversal arrows 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
fromtable's own firewall (tenant scope + soft-delete) is AND'd into each subquery, exactly as with avia:arm. - See Scopes & capability grants for scoped roles and Access — relationships for the relationship shape.
Action Examples
End-to-end action files.
Scopes & Capability Grants
Let non-org principals (event attendees, shuttle drivers, vendors) reach a narrow slice of your data through a server-verified, relationship-derived scope carried in a signed JWT — with least-privilege scoped roles enforced across every pillar. Accountless principals (invite codes, partner tokens) mint the same scope via ctx.mintScope.