# Access - Role & Condition-Based Access Control

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

## Which primitive do I need?

Start here. **Most apps need only the first row** — this page, and nothing below it.

| The rule you're expressing | Use | Where |
|---|---|---|
| "Anyone in the org, with this role" | `roles: ['member+']` / `['admin+']` | this page |
| "Any signed-in caller" | `roles: ['AUTHENTICATED']` | [below](#authenticated--any-authenticated-caller-the-wrapper) |
| "Only the caller's own rows" | `roles: ['USER']` + an `ownerId` firewall | [below](#user--per-user-records) |
| "Anyone at all, signed in or not" | `roles: ['PUBLIC']` | [below](#public--unauthenticated-access) |
| "Platform operators, regardless of org" | `userRole: ['appmanager']` | [below](#userrole-vs-roles) |
| The same expression, repeated across many features | `authz.roles` / `authz.rules` | [Named rules](/define/authz-roles) |
| "The caller has a row in *my* table linking them to this record" | `authz.relationships` | [Relationships](/define/relationships) |
| A permission *graph* — derived, inherited, share links | FGA | [FGA](/define/fga) |

> **If org membership answers it, stop here**
>
> Org membership is decided before your handler runs and arrives as `ctx.roles`. Do **not**
> declare a relationship to ask "is this person in the org?" — a relationship's `from:` must
> be one of *your* feature tables, and the compiler rejects one pointing at Better Auth's
> `organization_memberships`. See [the authorization ladder](/define#the-authorization-ladder).
>
> To validate a *submitted* user id (an assignee), query AUTH_DB from an action —
> see [Scoped DB](/define/actions/scoped-db#reaching-better-auth-tables-authdb).


## Basic Usage

```typescript
// 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(),
  // ── quickback:audit (compiler-managed — edits are validated, not merged) ──
  createdAt: text('created_at').notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()),
  modifiedAt: text('modified_at').notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()).$onUpdate(() => new Date().toISOString()),
  createdBy: text('created_by'),
  modifiedBy: text('modified_by'),
  deletedAt: text('deleted_at'),
  deletedBy: text('deleted_by'),
});

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":

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

Then in your resource definitions:

```typescript
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 / reserved markers (`PUBLIC`, `AUTHENTICATED`, `SESSION`, `SCOPED`, `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

## Custom org roles

You're not limited to Better Auth's built-in `owner` / `admin` / `member` —
any lowercase role name you reference in an access tree (`"recruiter"`,
`"hiring-manager"`, `"support"`, …) is a custom org-membership role, matched
against `ctx.roles` like the built-ins.

As of v0.46, custom roles declared in access trees are **auto-registered
with the Better Auth organization plugin**, so members can actually be
provisioned into them: `invite-member` and `update-member-role` accept them
(previously they rejected custom roles with `400 ROLE_NOT_FOUND`).
Auto-registered roles carry member-equivalent Better Auth permissions —
registration is about assignability; your access trees remain the
authorization model. If you pass your own `roles` / `ac` to the organization
plugin options, those win and auto-registration is skipped.

## Configuration Options

```typescript
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.

| Primitive | Checks | Source | Example values |
|-----------|--------|--------|----------------|
| `roles`   | `ctx.roles` | Org membership roles | `"owner"`, `"admin"`, `"member"` |
| `userRole` | `ctx.userRole` | The `user.role` column (Better Auth / platform control-plane roles) | `"user"`, `"appmanager"`, `"sysadmin"` |

In multi-tenant mode, **`roles: ["admin"]` means "org admin" — not a platform-wide role.** An org-admin of Acme Corp has no elevated rights across other orgs. If you want platform control-plane operators through regardless of org membership (CMS, Better Auth admin surfaces, support tooling), use `userRole: ["appmanager"]`. If you need true cross-tenant data-plane access, use `roles: ["SYSADMIN"]` (with `cms: { sysadmin: true }`).

Pinned organization mode does not change this split. `roles` still come from organization membership in the pinned org, while `userRole` still comes from Better Auth's `user.role` column.

### When to use `userRole`

```typescript
// Platform control-plane users can see SSNs on every customer record, regardless of org
masking: {
  ssn: { type: 'ssn', show: { userRole: ['appmanager'] } },
}

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

// CMS-internal resource: only platform control-plane users
read: { access: { userRole: ['appmanager'] } },
create: { access: { userRole: ['appmanager'] } },
update: { access: { userRole: ['appmanager'] } },
delete: { access: { userRole: ['appmanager'] } },
```

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"`, `"appmanager"`, and `"sysadmin"`). If you've added custom user-table roles via Better Auth, list them explicitly.

### Pseudo-roles

Quickback ships seven reserved UPPERCASE markers. Six are real pseudo-roles handled directly by the compiler; `ADMIN` is retained only as a reserved legacy marker so the compiler can fail loudly with migration guidance. The casing carries the boundary: UPPERCASE = compiler-owned marker, lowercase = real role from a plugin's table (e.g. `members.role` from the org plugin).

| Role | Auth gate | Compile-time requirement | Runtime check |
|------|-----------|-------------------------|---------------|
| `PUBLIC` | bypassed | none — explicit opt-in for unauthenticated access | always passes (auth skipped) |
| `AUTHENTICATED` | required | none | `ctx.authenticated === true` (a full session **or** a scope principal — the wrapper over `SESSION` ∪ `SCOPED`) |
| `SESSION` | required | none | a full Better Auth session **only** — `ctx.authenticated && !ctx.principal && !ctx.scopePrincipal` |
| `SCOPED` | required | none | a sessionless scope principal **only** — `ctx.authenticated && ctx.scopePrincipal === true` |
| `USER` | required | resource firewall **must** scope by `userId` | a real signed-in user — `ctx.authenticated` AND **not** a scope principal (`!ctx.scopePrincipal`) AND (`ctx.userRole` unset OR `=== "user"`). A sessionless scope caller does **not** satisfy `USER` (use `SCOPED` / `scope:<kind>:<role>`); `AUTHENTICATED` is the marker that admits both flavors. |
| `ADMIN` | required | reserved legacy marker — compiler rejects it | `false` |
| `SYSADMIN` | required | `cms: { sysadmin: true }` **must** be set on the project | `ctx.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.

```typescript
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)
- **Tree semantics:** `PUBLIC` admits anonymous callers from anywhere in the access tree, not just a flat `roles` list. An `or:` arm containing `PUBLIC` admits anonymous callers past the authentication gate; an `and:` group admits them only when *every* arm does. The access, org, and firewall layers still evaluate the full tree afterwards. (Before v0.46 the emitted auth gate only honored `PUBLIC` in a flat `roles` list — a nested `PUBLIC` arm incorrectly answered `401`.)
- 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 `403` with code `ORG_REQUIRED`.

`?organizationId=` is an account/public addressing surface. A request using a
configured delegated principal cannot use it to acquire account organization
authority: the generated route returns `403 CROSS_TENANT_FORBIDDEN` and never
adds `activeOrgId` or account roles to the delegated `AppContext`. Delegated
scope continues to come only from the verified principal claims and database
RLS policy.

```typescript
// quickback/features/listings/listings.ts
import { q, defineTable } from '@quickback/compiler';

// Public listing page — anyone can browse, but scoped to an org
export const listings = q.table('listings', {
  id:             q.id(),
  title:          q.text().required(),
  organizationId: q.scope('organization'),
  ...q.audit(),
  ...q.softDelete(),
});

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 authenticated caller (the wrapper)

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

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

`AUTHENTICATED` is the **wrapper over two auth flavors** — it admits both a full Better Auth session **and** a sessionless scope principal (a scope-token holder, e.g. an event attendee who isn't an org member; see [Scopes](/define/scopes)). It excludes only *external delegated* principals (`ctx.principal`, admitted via `principals:`). When you need to distinguish the two flavors, use `SESSION` or `SCOPED` below; when either is fine, use `AUTHENTICATED`.

#### `SESSION` — a full session only

Use `roles: ["SESSION"]` when a route must admit **only** a full Better Auth session and **not** a sessionless scope principal. This is `AUTHENTICATED` minus scope-token callers.

```typescript
// Account settings — a real logged-in user, never a scope-token attendee
read: { access: { roles: ["SESSION"] } }
```

#### `SCOPED` — a scope principal only

Use `roles: ["SCOPED"]` for a route that is specifically for **sessionless scope-token callers** (a principal-only surface — e.g. a shared event link or kiosk token with no account). It admits any scope principal regardless of *which* scope; for a **specific** scoped role, use the `scope:<kind>:<role>` form instead (e.g. `scope:event:attendee` — see [Scopes](/define/scopes)).

```typescript
// Principal-only event surface — a scope token, no account required
read: { access: { roles: ["SCOPED"] } }
```

> **Firewall coherence:** if an access surface admits a scope caller (`SCOPED` or a `scope:<kind>:<role>` role) but the resource firewall can't filter that caller, the compiler **fails the build** — the caller would reach the handler yet see zero rows (a silent `200 + []`). Give the firewall a way to filter them: a `ctx.scope.<kind>` arm inside an `{ any: [...] }` group (compose org **or** scope — see [Firewall](/define/firewall)), a `via:` relationship, or `firewall: [{ exception: true }]`.

#### `USER` — per-user records

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

The owner column is `ownerId`. Name the column `ownerId` and the firewall is auto-derived (`WHERE ownerId = ctx.userId`, plus auto-stamping on insert); `userId` is **not** auto-detected on feature tables and must be declared explicitly. See [Firewall → `ownerId` vs `userId`](/define/firewall#ownerid-vs-userid).

```typescript
// quickback/features/todos/todos.ts
import { q, defineTable } from '@quickback/compiler';

// "My todos" — each caller sees only their own rows
export const todos = q.table('todos', {
  id:      q.id(),
  title:   q.text().required(),
  done:    q.bool().default(false).required(),
  ownerId: q.text().required(),
  ...q.audit(),
  ...q.softDelete(),
});

export default defineTable(todos, {
  firewall: [{ field: 'ownerId', equals: 'ctx.userId' }],
  read: { access: { roles: ["USER"] } },
  create: { access: { roles: ["USER"] } },
  update: { access: { roles: ["USER"] } },
  delete: { access: { roles: ["USER"] } },
});
```

If the firewall doesn't scope by owner, the compiler throws:

```text
Resource "todos" uses roles: ["USER"] but the firewall does not scope by
ownerId. Add an ownerId column (auto-scoped) or an explicit owner predicate
(firewall: [{ field: "ownerId", equals: "ctx.userId" }, ...]). If the column
is named userId, declare it explicitly:
firewall: [{ field: "userId", equals: "ctx.userId" }]. 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 — appmanagers and sysadmins are excluded. If the same route should serve both, express it explicitly with `or: [{ roles: ["USER"] }, { userRole: ["appmanager"] }]`.

#### `ADMIN` — legacy reserved marker (compile-time error)

`roles: ["ADMIN"]` is no longer supported. The compiler fails hard on any use of `ADMIN` so platform and tenant roles can't be confused.

```typescript
// Platform control-plane audit log
read: { access: { userRole: ["appmanager"] } }
```

Use one of these instead:

- `userRole: ["appmanager"]` for platform control-plane access
- `roles: ["admin"]` for org-membership admin access
- `roles: ["SYSADMIN"]` for true cross-tenant access

#### `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.

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

`SYSADMIN` is distinct from `userRole: ["appmanager"]`. Appmanager is the platform control-plane tier (CMS shell, Better Auth admin surfaces, support tooling); sysadmin is the cross-tenant data-plane tier. The one place sysadmin gets implicit extra powers is **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 === 'appmanager'`; appmanagers still go through the firewall like everyone else.

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

#### Mixing pseudo-roles with real roles

Pseudo-roles and lowercase real roles compose with OR:

```typescript
// Record owner OR a platform control-plane user can read
read: { access: { or: [{ roles: ["USER"] }, { userRole: ["appmanager"] }] } }

// Org owner OR sysadmin
delete: { access: { or: [{ roles: ["owner"] }, { roles: ["SYSADMIN"] }] } }
```

> Note: `userRole: ["appmanager"]` never bypasses a `userId` firewall scope. If you need cross-user visibility, expose a separate route with `firewall: { exception: true }` or a `SYSADMIN`-gated cross-tenant surface.

#### Quick reference

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

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

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

// Platform control-plane
access: { userRole: ["appmanager"] }

// Cross-tenant DB-admin tier
access: { roles: ["SYSADMIN"] }

// Org-membership roles with hierarchy expansion
// ("+" requires auth.roleHierarchy in quickback.config.ts — see Role Hierarchy above)
access: { roles: ["member+"] }     // expands to ["member", "admin", "owner"]
access: { roles: ["admin+"] }      // expands to ["admin", "owner"]
access: { roles: ["admin"] }       // exact match (no expansion)
```

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

## 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 query** — `WHERE` 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'`](/define/firewall) 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.

## Context Variables

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

```typescript
// 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

| Property | Type | Description |
|----------|------|-------------|
| `$ctx.userId` | `string` | Current authenticated user's ID |
| `$ctx.activeOrgId` | `string` | User's active organization ID |
| `$ctx.activeTeamId` | `string \| null` | User's active team ID (if applicable) |
| `$ctx.roles` | `string[]` | User's roles in current context |
| `$ctx.isAnonymous` | `boolean` | Whether user is anonymous |
| `$ctx.user` | `object` | Full user object from auth provider |
| `$ctx.user.id` | `string` | User ID (nested path example) |
| `$ctx.user.email` | `string` | User's email address |
| `$ctx.{property}` | `any` | Any custom context property |

## Function-Based Access

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

```typescript
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)
