Quickback Docs

Access - Role & Condition-Based Access Control

Define who can perform read and write operations and under what conditions. Configure role-based and condition-based access control for your API endpoints.

Define who can perform read and write operations and under what conditions.

Basic Usage

// features/applications/applications.ts
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { defineTable } from '@quickback/compiler';

export const applications = sqliteTable('applications', {
  id: text('id').primaryKey(),
  candidateId: text('candidate_id').notNull(),
  jobId: text('job_id').notNull(),
  stage: text('stage').notNull(),
  notes: text('notes'),
  organizationId: text('organization_id').notNull(),
});

export default defineTable(applications, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  guards: { createable: ["candidateId", "jobId", "notes"], updatable: ["notes"] },
  read: {
    access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] },
  },
  create: { access: { roles: ["owner", "hiring-manager", "recruiter"] } },
  update: { access: { roles: ["owner", "hiring-manager", "recruiter"] } },
  delete: { access: { roles: ["owner", "hiring-manager"] } },
});

Role Hierarchy

If your project uses a tiered role system (e.g., member < admin < owner), you can define a hierarchy in your config and use the + suffix to mean "this role and above":

// quickback.config.ts
export default defineConfig({
  name: "my-app",
  auth: {
    roleHierarchy: ['member', 'admin', 'owner'],  // lowest → highest
  },
  // ...
});

Then in your resource definitions:

read: { access: { roles: ["member+"] } },   // member, admin, owner
create: { access: { roles: ["admin+"] } },     // admin, owner
delete: { access: { roles: ["owner"] } },      // owner only (no +)

The + suffix expands at compile time — "member+" becomes ["member", "admin", "owner"] in the generated code. You can mix hierarchical and exact roles: roles: ["member+", "finance"] expands to ["member", "admin", "owner", "finance"].

  • Roles without + are exact matches (no expansion)
  • Pseudo-roles (PUBLIC, AUTHENTICATED, USER, ADMIN, SYSADMIN) cannot use the + suffix — they're terminal markers
  • Using + with a role not in the hierarchy throws a compile error
  • Using + without configuring auth.roleHierarchy throws a compile error

Configuration Options

interface Access {
  // Org-membership roles (OR logic — user needs at least one)
  // Matched against ctx.roles. Use "role+" suffix for hierarchy expansion.
  roles?: string[];

  // User-table role (OR logic). Matched against ctx.userRole from the
  // user.role column — independent of org membership. See userRole below.
  userRole?: string[];

  // Record-level conditions
  record?: {
    [field: string]: FieldCondition;
  };

  // Combinators
  or?: Access[];
  and?: Access[];
}

userRole vs roles

roles and userRole check two different fields and are often confused — get this wrong and you'll grant access too broadly.

PrimitiveChecksSourceExample values
rolesctx.rolesOrg membership (multi-tenant) or single-tenant mirror of user.role"owner", "admin", "member"
userRolectx.userRoleThe user.role column (Better Auth admin plugin)"user", "admin"

In multi-tenant mode, roles: ["admin"] means "org admin" — not a platform-wide admin. An org-admin of Acme Corp has no elevated rights across other orgs. If you want to let Better Auth system admins through regardless of org membership (e.g., for support/ops), use userRole: ["admin"].

In single-tenant mode (features.organizations: false), ctx.roles mirrors user.role, so roles: ["admin"] and userRole: ["admin"] behave the same. Using userRole in single-tenant code is still preferred — it documents intent and keeps the gate correct if you later enable orgs.

When to use userRole

// Platform admin can see SSNs on every customer record, regardless of org
masking: {
  ssn: { type: 'ssn', show: { userRole: ['admin'] } },
}

// Support staff (user.role === 'admin') OR the org owner can refund
access: {
  or: [
    { userRole: ['admin'] },
    { roles: ['owner'] },
  ],
}

// CMS-internal resource: only platform admins
read: { access: { userRole: ['admin'] } },
create: { access: { userRole: ['admin'] } },
update: { access: { userRole: ['admin'] } },
delete: { access: { userRole: ['admin'] } },

When roles and userRole both appear in a single Access node, they're combined with AND — the user must satisfy both. Use or: to express "one or the other".

userRole doesn't expand with the + hierarchy suffix — user-table roles are flat (typically just "user" and "admin"). If you've added custom user-table roles via Better Auth, list them explicitly.

Pseudo-roles

Quickback ships five UPPERCASE role markers that are not backed by a Better Auth role column — they're system-level shortcuts handled directly by the compiler. The casing carries the boundary: UPPERCASE = pseudo-role (no plugin context required), lowercase = real role from a plugin's table (e.g. members.role from the org plugin).

RoleAuth gateCompile-time requirementRuntime check
PUBLICbypassednone — explicit opt-in for unauthenticated accessalways passes (auth skipped)
AUTHENTICATEDrequirednonectx.authenticated === true
USERrequiredresource firewall must scope by userIdctx.authenticated AND (ctx.userRole is unset OR ctx.userRole === "user")
ADMINrequiredBetter Auth admin plugin must be enabledctx.authenticated && ctx.userRole === "admin"
SYSADMINrequiredcms: { sysadmin: true } must be set on the projectctx.authenticated && ctx.userRole === "sysadmin"

You can mix pseudo-roles with real roles in the same list — they're OR'd together.

PUBLIC — unauthenticated access

Use roles: ["PUBLIC"] for public-facing endpoints like contact forms, public listings, or webhooks. Skips both the auth gate and the role check.

access: { roles: ["PUBLIC"] }

Important:

  • PUBLIC skips authentication and role checks — anyone can call the endpoint
  • PUBLIC is the explicit, uppercase opt-in for unauthenticated access. It works on every access node — reads, top-level writes (create/update/delete/upsert), views, and actions. The compiler trusts the marker; row-level safety comes from the firewall, masking, and (for token-gated endpoints like RSVP confirm, magic-link redemption, List-Unsubscribe-Post, webhook ingest) your handler's own validation. Anonymous write endpoints typically pair roles: ["PUBLIC"] with firewall: { exception: true } on the resource so the row predicate doesn't reject every unauthenticated caller.
  • Firewall still applies — data is scoped by the firewall (organization, owner, team)
  • Every PUBLIC action invocation is mandatory audit logged to the security audit table (IP address, input, result, timing)
  • The wildcard "*" is not supported — using it throws a compile-time error
  • Tables with no isolation columns and any PUBLIC route do not require firewall: [{ exception: true }]

For org-scoped tables, PUBLIC routes still filter by organization. Since the user isn't authenticated, the organization ID must be passed as a query parameter:

GET /api/v1/listings?organizationId=org_abc123

The firewall WHERE clause is always enforced. If no organizationId is provided, the API returns a 400 with code ORG_REQUIRED.

// Public listing page — anyone can browse, but scoped to an org
export default defineTable(listings, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  read: { access: { roles: ["PUBLIC"] } },              // Public browsing + detail
  create: { access: { roles: ["admin", "member"] } }, // Auth required to create
});

AUTHENTICATED — any signed-in user

Use roles: ["AUTHENTICATED"] for endpoints that any logged-in user can hit, regardless of role or org membership. No plugin context required.

// Public events feed — any signed-in user can list/read; anonymous gets 401
read: { access: { roles: ["AUTHENTICATED"] } }

USER — per-user records

Use roles: ["USER"] for resources where each user only sees their own records. Requires the firewall to scope by userId — the role marker is the access half of the pair, the firewall is the row-filter half.

// "My todos" — each caller sees only their own rows
export default defineTable(todos, {
  firewall: [{ field: 'userId', equals: 'ctx.userId' }],
  read: { access: { roles: ["USER"] } },
  create: { access: { roles: ["USER"] } },
  update: { access: { roles: ["USER"] } },
  delete: { access: { roles: ["USER"] } },
});

If you forget the userId firewall predicate, the compiler throws:

roles: ["USER"] requires the resource to be scoped per-user. Add a userId column (auto-scoped) or an explicit owner predicate. If you want any signed-in user to read all records, use roles: ["AUTHENTICATED"] instead.

When the Better Auth admin plugin is enabled, USER matches user.role === "user" specifically — admins are excluded and should use ["USER", "ADMIN"] if the same route should serve both.

ADMIN — Better Auth admin-plugin admins

Use roles: ["ADMIN"] for system-administrator endpoints. Requires the Better Auth admin plugin so user.role exists. Compiles to ctx.userRole === "admin".

// Platform-admin-only audit log
read: { access: { roles: ["ADMIN"] } }

Without the admin plugin you get a compile error pointing you to either enable the plugin or use lowercase roles: ["admin"] (the org-membership admin role).

SYSADMIN — cross-tenant DB-admin tier

Use roles: ["SYSADMIN"] for endpoints that should only be reachable by platform sysadmins — the cross-tenant DB-admin tier introduced in v0.15.0. Compiles to ctx.userRole === "sysadmin". Requires cms: { sysadmin: true } on the project config; without that flag, using SYSADMIN fails the compile so the role can't sit as dead code.

// Cross-tenant audit dashboard, sysadmin-only
read: { access: { roles: ["SYSADMIN"] } }

SYSADMIN is a strict superset of ADMIN — at runtime, the universal-bypass shortcuts in evaluateAccess admit userRole === 'sysadmin' anywhere they admit userRole === 'admin'. The distinction matters for one thing only: the firewall escape hatch. When cms.sysadmin: true, every buildFirewallConditions emits an if (ctx.userRole === 'sysadmin') return undefined; (or just the soft-delete predicate when present) at the top — so sysadmins see every tenant's rows, not just their own org's. That escape hatch does NOT fire for userRole === 'admin'; admins still go through the firewall like everyone else.

This separation exists so Better Auth admin-plugin powers (user management, impersonation) and DB-admin powers (cross-tenant data inspection) stay decoupled. A user can have user.role === 'admin' without DB-level cross-tenant access; a user can have user.role === 'sysadmin' without org-membership context. See CMS Configuration for the cms.sysadmin flag, the cross-tenant /admin/v1/sysadmin/organizations endpoint, and the SPA's "All organizations" sentinel.

Mixing pseudo-roles with real roles

Pseudo-roles and lowercase real roles compose with OR:

// Record owner OR a system admin can read
read: { access: { roles: ["USER", "ADMIN"] } }

// Org owner OR system admin
delete: { access: { roles: ["owner", "ADMIN"] } }

Note: today, an ADMIN match still goes through the userId firewall scope on USER-scoped resources — admins see only records they own on those routes. Use firewall: { exception: true } on a separate admin-only route if you need cross-user visibility.

Quick reference

// Unauthenticated public
access: { roles: ["PUBLIC"] }

// Any signed-in user, no scoping
access: { roles: ["AUTHENTICATED"] }

// Per-user records (requires userId firewall scope)
access: { roles: ["USER"] }

// System admin (requires admin plugin)
access: { roles: ["ADMIN"] }

// Org-membership roles with hierarchy expansion
access: { roles: ["member+"] }     // expands to ["member", "admin", "owner"]
access: { roles: ["admin+"] }      // expands to ["admin", "owner"]
access: { roles: ["admin"] }       // exact match (no expansion)
// Field conditions - value can be string | number | boolean
type FieldCondition =
  | { equals: value | '$ctx.userId' | '$ctx.activeOrgId' }
  | { notEquals: value }
  | { in: value[] }
  | { notIn: value[] }
  | { lessThan: number }
  | { greaterThan: number }
  | { lessThanOrEqual: number }
  | { greaterThanOrEqual: number };

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.

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.

Today (v1) this works on:

SurfaceStatusNotes
firewall: via: predicate✓ SupportedSubquery composes as outermost AND
crud.*.access roles: ['guest']✓ SupportedInline async lookup per request
read.access / read.views.*.access roles: ['guest']✓ SupportedSame inline path
masking.<field>.show roles: ['guest']⚠ Refused at compilev1 limitation — async/batched mask functions deferred
actions.<name>.access roles: ['guest']⚠ Refused at compilev1 limitation — explicit relationshipBindings deferred

For the surfaces that are not yet supported, the compiler refuses at build time with a clear error pointing to a workaround. The runtime evaluator fails closed on relationship arms regardless, so silent grants are impossible.

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' }] on events is 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:

  1. Always filter by ctx.userId in the relationship lookup. If you accept a userId from the request body and trust it, you've built an IDOR. The example above uses ctx.userId! directly so the only events returned are the caller's.
  2. You take responsibility for soft-delete and other invariants the firewall would otherwise apply. Add the deletedAt filter, 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 authz block (typo guard — realtionships would otherwise silently produce a permanently-denied role).
  • Reserved role-name (auth-provider-derived). Names like guest, speaker, attendee are free; admin/owner/member are reserved when the BA org plugin is on.
  • Cycle in role composition (a → b → a).
  • Relationship from references 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-roles in masking.show / actions.access (deferred surfaces — see the table above).

Read & Write Configuration

Reads (GET / and GET /:id) live under read:; mutations live under top-level create:, update:, delete:, and upsert:. See Read for the full read pipeline reference.

read: {
  // LIST + GET - GET /resource and GET /resource/:id
  access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] },
  pageSize: 25,        // Default page size
  maxPageSize: 100,    // Client can't exceed this
  views: {              // Per-view field projections
    summary: {
      fields: ['id', 'candidateId', 'jobId', 'stage'],
      access: { roles: ["interviewer"] },
    },
  },
}

create: {
  // CREATE - POST /resource
  access: { roles: ["owner", "hiring-manager", "recruiter"] },
  defaults: {           // Default values for new records
    stage: 'applied',
  },
},

update: {
  // UPDATE - PATCH /resource/:id
  access: {
    or: [
      { roles: ["hiring-manager", "recruiter"] },
      { roles: ["interviewer"], record: { stage: { equals: "interview" } } }
    ]
  },
},

delete: {
  // DELETE - DELETE /resource/:id
  access: { roles: ["owner", "hiring-manager"] },
  mode: "soft",  // 'soft' (default) or 'hard'
},

upsert: {
  // PUT - PUT /resource/:id (only when generateId: false + guards: false)
  access: { roles: ["hiring-manager", "sync-service"] },
}

Evaluation Order

Generated /:id and batch handlers evaluate access in this fixed order:

  1. Auth gate — 401 if unauthenticated
  2. Pre-record access — role-only check (skips access.record predicates)
  3. Firewall queryWHERE clause filters by ownership
  4. Post-record access — full check including record-level predicates
  5. Masking — applied to the response

Step 2 closes the 404-vs-403 ID-probe channel: an unauthorized caller without a matching role gets 403 before the database is touched, so they can't tell apart "row exists out-of-scope" from "row doesn't exist." A caller with the right role but the wrong tenant gets 403 by default (firewall reveals via the access layer); set firewallErrorMode: 'hide' to return an opaque 404 instead.

Function-form access (access: async (ctx, record) => ...) is opaque to the pre-record phase and always passes there; it's fully evaluated post-record.

List Filtering (Query Parameters)

The LIST endpoint automatically supports filtering via query params:

GET /jobs?status=open                         # Exact match
GET /jobs?salaryMin.gt=50000                  # Greater than
GET /jobs?salaryMin.gte=50000                 # Greater than or equal
GET /jobs?salaryMax.lt=200000                 # Less than
GET /jobs?salaryMax.lte=200000                # Less than or equal
GET /jobs?status.ne=closed                    # Not equal
GET /jobs?title.like=Engineer                 # Pattern match (LIKE %value%)
GET /jobs?status.in=open,draft                # IN clause
OperatorQuery ParamSQL Equivalent
Equals?field=valueWHERE field = value
Not equals?field.ne=valueWHERE field != value
Greater than?field.gt=valueWHERE field > value
Greater or equal?field.gte=valueWHERE field >= value
Less than?field.lt=valueWHERE field < value
Less or equal?field.lte=valueWHERE field <= value
Pattern match?field.like=valueWHERE field LIKE '%value%'
In list?field.in=a,b,cWHERE field IN ('a','b','c')

Sorting & Pagination

GET /jobs?sort=createdAt&order=desc   # Sort by field
GET /jobs?limit=25&offset=50          # Pagination
  • Default limit: 50
  • Max limit: 100 (or maxPageSize if configured)
  • Default order: asc

Delete Modes

delete: {
  access: { roles: ["owner", "hiring-manager"] },
  mode: "soft",  // Sets deletedAt/deletedBy, record stays in DB
}

delete: {
  access: { roles: ["owner", "hiring-manager"] },
  mode: "hard",  // Permanent deletion from database
}

Context Variables

Use $ctx. prefix to reference context values in conditions:

// User can only view their own records
access: {
  record: { userId: { equals: "$ctx.userId" } }
}

// Nested path support for complex context objects
access: {
  record: { ownerId: { equals: "$ctx.user.id" } }
}

AppContext Reference

PropertyTypeDescription
$ctx.userIdstringCurrent authenticated user's ID
$ctx.activeOrgIdstringUser's active organization ID
$ctx.activeTeamIdstring | nullUser's active team ID (if applicable)
$ctx.rolesstring[]User's roles in current context
$ctx.isAnonymousbooleanWhether user is anonymous
$ctx.userobjectFull user object from auth provider
$ctx.user.idstringUser ID (nested path example)
$ctx.user.emailstringUser's email address
$ctx.{property}anyAny custom context property

Function-Based Access

For complex access logic that can't be expressed declaratively, use a function:

update: {
  access: async (ctx, record) => {
    // Custom logic - return true to allow, false to deny
    if (ctx.roles.includes('admin')) return true;
    if (record.ownerId === ctx.userId) return true;

    // Check custom business logic
    const membership = await checkTeamMembership(ctx.userId, record.teamId);
    return membership.canEdit;
  }
}

Function access receives:

  • ctx: The full AppContext object
  • record: The record being accessed (for get/update/delete operations)

On this page