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.
Rung 3. Use a relationship when membership lives in your own domain table — an attendee, a collaborator, an external reviewer who isn't in your org.
Not for org membership
"Is this person in the organization?" is rung 1, not a relationship.
A relationship's from: must be one of your feature tables — the compiler rejects one
pointing at Better Auth's organization_memberships, organizations, or user. Org
membership is already decided before your handler runs and is available as ctx.roles.
A relationship's where: also takes scalar equality only (string | number | boolean). where: { role: ['admin','owner'] } is not valid — declare one relationship
per role, or use org roles.
Relationship-based authorization
Beyond Better Auth roles (org membership, user-table role) and pseudo-roles, Quickback supports relationship-based authorization roles — names that bind to a declared relationship between the caller and a resource. Use these when access should follow a domain-table membership ("user is a confirmed guest of this event") rather than an org/admin gate. These are the { via: '...' } rule shape from Centrally defined access rules above.
authz.relationships
Declare relationships once in quickback.config.ts:
// quickback.config.ts
import { defineConfig } from '@quickback/compiler';
export default defineConfig({
// ...providers...
authz: {
relationships: {
guestOf: {
from: 'event_guests', // table name string
subject: { column: 'userId', equals: 'ctx.userId' }, // caller side
resource: { column: 'eventId' }, // resource side
where: { status: 'confirmed' }, // optional filter
},
},
roles: {
guest: { via: 'guestOf' },
// Composite: any guest OR an org owner/admin
eventStaff: {
or: [
{ via: 'guestOf' },
{ roles: ['admin', 'owner'] },
],
},
},
},
});Using relationship-roles in resources
Resource definitions reference declared roles by name in roles: [...] arrays exactly like BA roles or pseudo-roles. The compiler rewrites the access tree at build time so the runtime route does an inline relationship lookup against the declared table.
Supported relationship-role surfaces
| Surface | Status | Notes |
|---|---|---|
firewall: via: predicate | ✓ Supported | Subquery composes as outermost AND |
crud.*.access roles: ['guest'] | ✓ Supported | Inline async lookup per request |
read.access / read.views.*.access roles: ['guest'] | ✓ Supported | Same inline path |
Standalone actions.<name>.access roles: ['guest'] | ✓ Supported | Inline gate; the surface exposes resource.column as a path param or input field |
Record-based actions.<name>.access roles: ['guest'] | ✓ Supported | Inline lookup uses the already-loaded, firewalled record |
Namespace via lanes | ✓ Supported | Hoisted middleware resolves once and can hydrate ctx.<exposeAs> |
masking.<field>.show roles: ['guest'] | Build-time refusal | Use a relationship-gated view for field projection; per-row async masking is not approximated |
Relationship roles are therefore available across CRUD, reads, standalone
actions, record-based actions, and namespace mounts. Masking is the one
different execution surface: list masking must stay synchronous and batched, so
the compiler rejects a relationship-bearing masking.show rule and points to a
relationship-gated view instead. It never drops the relationship arm or turns
it into a broader role grant.
Standalone actions: surface keys and loads / exposeAs (v0.19+)
When a standalone action's access.roles references a relationship-role, the compiler emits a runtime gate between input validation and execute(). The gate reads the relationship's resource.column value from the action surface — either a path parameter (:eventId) or an input field (input.eventId). If neither is present, the current compiler stops with a clear build error; an action can never ship with a relationship rule it has no key to evaluate.
Pair the relationship with loads + exposeAs to hydrate the loaded resource onto ctx:
authz: {
relationships: {
attendeeOf: {
from: 'guests',
subject: { column: 'userId', equals: 'ctx.userId' },
resource: { column: 'eventId' },
where: { status: 'confirmed' },
loads: 'events', // ← v0.19+: load matching event row
exposeAs: 'event', // ← onto ctx.event for the action body
},
},
roles: {
attendee: { via: 'attendeeOf' },
},
}The resolver (v0.20):
- Cheap in-memory role check first —
ctx.roles?.includes('admin')and friends. If the caller has a qualifying role, this short-circuits and no DB queries fire for the access gate. - Firewall-gated relationship match if the role check failed —
SELECT { organizationId, teamId } FROM guests WHERE userId=ctx.userId AND eventId=input.eventId AND status='confirmed'with thegueststable's full firewall AND-applied. If neither role nor relationship qualifies → 403. - Tenant stamping from the matched row — for any tenant column the
fromtable carries (organizationId,teamId), the resolver stampsctx.activeOrgId ??= matchedRow.organizationId(and similar for team). This is in-memory mutation of the request-scoped ctx only — no JWT remint, no session-record write, no persistent grant. - Firewall-gated load of the loaded resource —
SELECT * FROM events WHERE id = :eventId AND buildEventsFirewallConditions(ctx, db). The events firewall is applied as normal; it succeeds for attendees because tenant was just stamped from the relationship row. - Assigns the loaded row to
ctx.eventfor the action body.
No firewall bypass anywhere. Both queries run with their respective tables' full firewalls. The relationship row's existence is the proof that the caller is authorized; its tenant columns are the source of the stamped claim.
Conditional tenant preconditions (v0.20+)
Standalone actions in features with org/team-scoped tables previously emitted unconditional preconditions:
if (!ctx.activeOrgId) return c.json(AccessErrors.noOrg(), 403);
if (!ctx.activeTeamId) return c.json(AccessErrors.noTeam(), 403);These ran before the relationship resolver, which 403'd attendees whose sessions don't carry an active tenant claim (the canonical event-attendee shape).
v0.20 makes these preconditions conditional on relationship supply. If the access tree contains a relationship arm whose from table carries the matching tenant column, the precondition is skipped — the resolver runs first and stamps the value. Pseudo-role short-circuits (PUBLIC / AUTHENTICATED / USER / SYSADMIN) and firewall: { exception: true } still bypass the preconditions as before. ADMIN is now rejected at compile time.
Typical event-attendee setup (guests table carries organizationId): the org precondition is dropped on every action whose access references attendee or eventStaff. Org admins still get ctx.activeOrgId from their session as today; attendees get it from the relationship row, mid-request. Both paths converge on a valid tenant claim before execute() runs.
Requirement on the relationship's from table
For the v0.20 tenant-stamping to work, the relationship's from table must carry tenant columns:
- For org stamping: an
organizationIdcolumn (or whatever spelling the project's auth-provider defaults pick). - For team stamping: a
teamIdcolumn.
When the from table has neither, the compiler emits a [quickback:authz] warning at compile time:
Relationship "X" at authz.relationships.X has no organization or team columns
on its `from` table ("guests"). Action "Y" will compile, but downstream
scoped-db queries inside execute() may 403 because ctx.activeOrgId /
activeTeamId cannot be stamped from the match row. Either add the tenant
column to "guests", or set firewall: { exception: true } on the action.The relationship match still works (existence check), but no tenant gets stamped — so the conventional precondition still fires and the action still 403s for callers without a session-resident tenant claim. Add the tenant column (or denormalize it onto the relationship table during writes) to enable the stamping path.
The body can then trust ctx.event and drop the hand-rolled SELECT … FROM guests WHERE … boilerplate:
// features/chat/actions/getInbox.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
export default defineAction({
description: "List the caller's chat inbox for one event they attend.",
path: '/chat/get-inbox',
input: z.object({ eventId: z.number().int().positive() }),
access: { roles: ['admin', 'member', 'attendee'] }, // attendee is a relationship-role
execute: async ({ db, ctx, input }) => {
// No hand-rolled attendance check needed — the resolver already ran.
// ctx.event is the loaded events row. Typed as `any` here because the
// hand-rolled `roles: ['attendee']` action gates per-action, not per-
// namespace — see "Narrow ctx.<exposeAs> typing" below for the typed
// import flip available when the same hydration runs through a
// namespace mount.
const event = ctx.event as typeof events.$inferSelect;
// … domain logic only.
},
});loads and exposeAs are co-required (set both or neither). exposeAs must be a valid JS identifier and must not collide with built-in ctx fields (userId, activeOrgId, activeTeamId, db, etc.).
Action firewall escape hatch (v0.19+)
defineAction({ firewall: { exception: true } }) opts the action out of the inherited session-key preconditions (ctx.activeOrgId / ctx.activeTeamId requirements) AND the relationship resolver. The handler receives unsafeDb for raw Drizzle access. Use for cross-tenant or admin routes whose surface intentionally doesn't name a session/relationship key:
// features/admin/actions/crossTenantReport.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
export default defineAction({
description: "Report activity across every tenant for a date range (sysadmin only).",
path: '/admin/cross-tenant-report',
input: z.object({ since: z.string(), until: z.string() }),
firewall: { exception: true },
unsafe: { reason: 'cross-tenant audit', adminOnly: true, targetScope: 'all' },
access: { roles: ['SYSADMIN'] },
execute: async ({ unsafeDb, ctx }) => { /* cross-org query */ },
});firewall: { exception: true } is the declarative twin of unsafe: true — they're conceptually distinct: firewall.exception opts out of the gate-emission pillars; unsafe signals audit/cross-tenant intent (and still applies for logging). Most cross-tenant actions set both.
Recommended pattern: gate at the firewall layer
Most of the time, relationship-based row visibility is what you want, and putting it at the firewall layer is the cleanest expression. The firewall is row-filter; the access layer is route/role-filter; let each do its job:
// features/sessions/sessions.ts
firewall: [
{ field: 'organizationId', equals: 'ctx.activeOrgId' },
{ field: 'eventId', via: 'guestOf' }, // ← row visibility
],
read: {
access: { roles: ['admin', 'member'] }, // ← who can hit the route
views: {
cms: { access: { roles: ['admin', 'member'] }, fields: [...] },
guest: { access: { roles: ['member', 'admin'] }, fields: [...] },
},
},A guest who's in your org as member can hit the endpoint and the firewall filters them down to events they're guests of. Cross-tenant access is impossible because the org filter is the outermost AND.
Cross-tenant "my events" dashboards are NOT this
A common adjacent pattern — "logged-in user wants GET /my-events returning events they're a guest of across all tenants" — is not solved by relationship-roles. The tenant firewall is enforced at the outermost AND, so a guest of an event in another tenant remains correctly blocked.
For cross-tenant aggregation, use one of:
- User-scoped firewall:
firewall: { owner: { column: 'userId' } }— a flat per-user table. - A standalone action that walks the relationship table itself — see worked example below.
Don't reach for relationship-roles first and then get confused when the tenant firewall blocks. The two patterns serve different problems.
Worked example: GET /my-events standalone action
The cleanest expression of the cross-tenant attendee dashboard is a standalone action with a custom path that does its own join against the relationship table. The action is gated by roles: ['AUTHENTICATED'] (any signed-in user can hit it; per-row scoping is the query itself), and the SQL filters by ctx.userId so each caller sees exactly the events they're a guest of:
// features/dashboard/actions/myEvents.ts
import { z } from 'zod';
import { eq, and, inArray } from 'drizzle-orm';
import { defineAction } from '../.quickback/define-action';
import { events } from '../../events/events';
import { guests } from '../../guests/guests';
export default defineAction({
description: "List events the caller is a confirmed guest of, across all tenants.",
path: '/my-events',
method: 'GET',
input: z.object({}),
// Any signed-in user. Row visibility comes from the query, not the role.
access: { roles: ['AUTHENTICATED'] },
async execute({ db, ctx }) {
// 1. Find every event the caller has a confirmed guest row for.
const guestRows = await db
.select({ eventId: guests.eventId })
.from(guests)
.where(
and(
eq(guests.userId, ctx.userId!),
eq(guests.status, 'confirmed'),
// guests.organizationId is NOT filtered here — that's the whole
// point of the cross-tenant lane. The relationship row's org is
// ambient information that this action explicitly traverses.
)
);
if (guestRows.length === 0) return { events: [] };
// 2. Pull the matching events. NOTE: this query also bypasses
// `events.organizationId` scope — by design — so explicitly filter
// out soft-deleted rows yourself if your `events` table uses them.
const eventIds = guestRows.map((r) => r.eventId);
const rows = await db
.select()
.from(events)
.where(inArray(events.id, eventIds));
return { events: rows };
},
});Why this is a standalone action and not a CRUD route:
- Tenant firewall would block it. The default
firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }]oneventsis correct for every other read — only the cross-tenant dashboard wants to step outside it, and that's a deliberate, explicit step taken in handler code. - The join is the access control. A relationship-role at the firewall layer can't express "filter by this user's relationships, ignoring the org context." The handler does that join directly.
- Audit-logged automatically. Standalone actions are mandatorily audit-logged in the runtime — the cross-tenant traversal is recorded with
ctx.userId, IP, and timing.
Two important guardrails when you write one of these:
- Always filter by
ctx.userIdin the relationship lookup. If you accept auserIdfrom the request body and trust it, you've built an IDOR. The example above usesctx.userId!directly so the only events returned are the caller's. - You take responsibility for soft-delete and other invariants the firewall would otherwise apply. Add the
deletedAtfilter, status checks, and any other row-level predicates by hand. The action is OUTSIDE the firewall pipeline by design — the cost is that you can't lean on the auto-applied predicates.
If you find yourself writing more than one of these per project, that's usually a signal to revisit the schema (could a user-owned userEvents materialized view replace the join?) rather than a sign Quickback should ship a higher-level abstraction. Cross-tenant lanes are intentionally explicit.
Compile-time refusals (consolidated)
The validator and resolver reject:
- Unknown key in
authzblock (typo guard —realtionshipswould otherwise silently produce a permanently-denied role). - Reserved role-name (auth-provider-derived). Names like
guest,speaker,attendeeare free;admin/owner/memberare reserved when the BA org plugin is on. - Cycle in role composition (
a → b → a). - Relationship
fromreferences unknown table. - Relationship-role used on a resource missing the
resource.column. - Relationship table with
firewall: { exception: true }(would let cross-tenant grants slip through). lowering: 'session'(reserved for v2 — staleness/invalidation ergonomics not yet shipped).+hierarchy suffix on a relationship-role name (terminal, like pseudo-roles).- Relationship-backed rules in
masking.show(use a relationship-gated view for field projection; action access is fully supported).
Outgrown this?
When several actions share a path prefix and this gate, hoist it into a feature area so the resolver runs once per request. When you need a permission graph — derived, inherited, hierarchical, or public share links — that's rung 4 → FGA.
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.
Read - Unified Read Pipeline
The unified read configuration that owns collection reads, single-record reads, and named view projections. Replaces crud.list and crud.get.