# Relationship-Based Authorization - authz.relationships

**[Rung 3](/define#the-authorization-ladder).** 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](/define/access)**, 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`.
> 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).
>
> 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](#centrally-defined-access-rules) above.

### `authz.relationships`

Declare relationships once in `quickback.config.ts`:

```typescript
// 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'] },
        ],
      },
    },
  },
});
```

> **userId is not a SQL FK to Better Auth**
>
> `subject: { column: 'userId', equals: 'ctx.userId' }` matches a **plain text** column on your join table. Do not write `.references(() => users.id)` (or `user`, `organization`, `member`) on that column. Better Auth tables live in `AUTH_DB`; feature tables live in `DB`. SQLite cannot FK across those databases, and drizzle-kit fails generating features migrations with `Critical command failed: Generate features database migrations`. Store it as `userId: q.text().required()`. See [Schema → References](/define/schema).


> **The `from:` table needs its own defineTable**
>
> A relationship resolves through the `from:` table's **generated resource module**
> (`<table>.resource.ts`) — that's what supplies the firewall the subquery AND's in. The
> compiler emits that module only for tables with an `export default defineTable(...)`, so a
> join table declared as a bare Drizzle export is a compile error naming the table:
>
> ```typescript
> // features/conversations/conversation_members.ts
> export const conversation_members = sqliteTable('conversation_members', {
>   id: text('id').primaryKey(),
>   userId: text('user_id').notNull(),
>   conversationId: text('conversation_id').notNull(),
>   organizationId: text('organization_id').notNull(),
>   // ── quickback:audit (compiler-managed — edits are validated, not merged) ──
>   // Raw Drizzle tables spell the managed columns out; `...q.audit()` is
>   // q-DSL only. See [Escape hatches](/define/escape-hatches).
>   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'),
> });
>
> // Required — without this, `from: 'conversation_members'` cannot compile.
> export default defineTable(conversation_members, {
>   firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
> });
> ```
>
> Adding `defineTable` does not force a public API onto the join table — gate or omit its
> `read` / `crud` blocks as you would any other table. What it supplies is the firewall.


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

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

1. **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.
2. **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 the `guests` table's full firewall AND-applied. If neither role nor relationship qualifies → 403.
3. **Tenant stamping from the matched row** — for any tenant column the `from` table carries (`organizationId`, `teamId`), the resolver stamps `ctx.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.
4. **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.
5. Assigns the loaded row to `ctx.event` for 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:

```ts
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 `organizationId` column (or whatever spelling the project's auth-provider defaults pick).
- For team stamping: a `teamId` column.

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:

```ts
// 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:

{/* doc-compile: skip — project-config prerequisite: `roles: ["SYSADMIN"]` requires `cms: { sysadmin: true }` in quickback.config.ts (documented above under SYSADMIN), and the gate compiles every fence against one fixed harness config that does not set it. */}
```ts
// 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:

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

```ts
// 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-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](/define/areas#namespace-mounts-hoisted-relationship-middleware) 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](/define/fga).
