---
title: "Authorization"
description: "Enforce resource and record abilities consistently across routes and projections."
image: "https://adonia.pages.dev/og.png"
version: "next"
---

> Documentation Index
> Fetch the complete documentation index at: https://adonia.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Authorization

Every ability decision in Adonia
goes through one interface, resolves in one order, and is enforced at three
points. There is no second path.

## The vocabulary

```
viewList | view | create | edit | delete | restore | forceDelete
runAction:<slug> | accessLens:<slug>
```

The seven resource abilities are what `descriptor.resource.abilities`
(protocol §3) and a record's `can` (protocol §6) project. The parameterized
two address a single action or lens; actions and lenses themselves are Phase
3, but the abilities resolve through the same chain today.

`view`, `edit`, `delete`, `restore` and `forceDelete` are **per record**;
`viewList` and `create` address the resource, so passing a record to them is
meaningless and the authorizer drops it.

## Resolution order

For every check, in order — a step that has **no opinion** continues the
chain, a step that answers ends it:

1. **`static can` on the resource** — an inline closure. Always available,
   never touches the container.
2. **The resource's Bouncer policy** — `static policy`, consulted through
   `ctx.bouncer`.
3. **`config.authorization.fallback`** — `devFallback` outside production,
   `fallback` (shipped default: `deny`) in it.

An inline `can` returning `false` is a **deny**. A policy returning `false` is
a **deny**. Neither is rescued by an `allow` fallback — only *silence*
continues the chain.

<!-- @sample-preamble
import { BaseResource, C, F, Filter } from '@adonia/core'
import type { HttpContextLike, SchemaBuilder, SchemaComponent, TableBuilder } from '@adonia/core'
import Post from '#models/post'
import User from '#models/user'
declare const isAdmin: (ctx: HttpContextLike, record?: unknown) => boolean
declare const t: TableBuilder
-->

```ts
export default class PostResource extends BaseResource<typeof Post> {
  static override model = Post
  static override slug = 'posts'

  // (1) inline overrides — win over everything
  static override can = {
delete: (ctx: HttpContextLike, record?: unknown) =>
  record instanceof Post &&
  ctx.auth.user instanceof User &&
  record.authorId === ctx.auth.user.id,
  }

  // (2) the policy — consulted for every ability `can` does not override
  static override policy = () => import('#policies/post_policy')

  schema(s: SchemaBuilder): SchemaComponent[] {
return [s.section('Content').components([F.text('title').required()])]
  }
}
```

`HttpContextLike` is the structural context the compiler passes (§21), so
`auth.user` arrives as `unknown` — narrow it before reading a column off it.

### Policy method names

Method names match ability names: `viewList()`, `view(user, post)`,
`create(user)`, … A parameterized ability tries the camel-cased
specialization first and then the generic handler, which receives the slug:

| ability | tried first | then |
|---|---|---|
| `delete` | `delete(user, post)` | — |
| `delete`, **with no record** | `deleteAny(user)` | — |
| `runAction:publish` | `runActionPublish(user, post)` | `runAction(user, 'publish', post)` |
| `accessLens:trashed` | `accessLensTrashed(user)` | `accessLens(user, 'trashed')` |

Bouncer injects the user itself; the record (when the ability has one) is the
last argument. A policy that implements **neither** candidate has no opinion,
and the config fallback answers.

**Record abilities without a record.** `descriptor.resource.abilities` and the
`requiresAbility()` gates that read it are compiled on pages that hold no
record — index, create, store, and the two `field.*` callbacks. `view`, `edit`,
`delete`, `restore` and `forceDelete` can only mean "over anything here at
all" there, so the chain asks a **resource-wide** method instead:
`editAny(user)`, `deleteAny(user)`, … (Laravel's `viewAny` convention;
`viewList` is this framework's spelling of `viewAny`). The record-scoped
`edit(user, post)` is deliberately *not* invoked with `post === undefined` —
that is the ordinary way a policy is written, and calling it would turn every
index page into a `TypeError`. Declare `<ability>Any` when the policy wants a
say; leave it out and the fallback answers, with a dev diagnostic naming what
was missing.

**Method discovery.** Both the prototype form (`delete(user, post) {}`) and the
class-field form (`delete = (user, post) => …`, an own property of the
*instance*) are found. The instance probe runs only when the prototype probe
missed, and constructs the policy at most once per class; a constructor that
demands container injection simply falls back to the prototype.

**A declared policy that answers nothing is reported, not silent.** When
`static policy` resolves but no candidate matches, the bridge emits a dev
diagnostic (`module: 'adonia'`, deduped per policy × ability) before returning
"no opinion". §12 makes only *silence* continue the chain, and silence is
otherwise indistinguishable from "there is no policy" — under the shipped
`devFallback: 'allow'` a written, denying policy nobody can find reads as a
grant.

### `@adonisjs/bouncer` is optional

It is an optional peer dependency and is never imported — not statically, not
dynamically. The bridge reads `ctx.bouncer` structurally, so an app without
Bouncer installed simply skips step 2: a declared `static policy` is never
consulted and every ability falls through to the fallback. Nothing throws.

## The three enforcement points

All mandatory, all through the same `Authorizer`.

### 1. Route entry

Each `§10.1` handler authorizes its page-level ability before doing work:

| handler | ability |
|---|---|
| `index` | `viewList` |
| `show` | `view` |
| `create`, `store` | `create` |
| `edit`, `update` | `edit` |
| `destroy` | `delete` |
| `restore` | `restore` |
| `field.options`, `field.upload` | HMAC-bound `create` or record-scoped `edit` |

Per-record abilities are checked **after** the record loads — a policy cannot
judge a record it has not seen.

The two `field.*` callbacks serve create and edit forms and nothing else. Each
descriptor URL signs its exact mode, route identity, and (for edit) record id
with the app key. The handler verifies that envelope first; edit callbacks then
load the record through the resource detail scope and evaluate `edit`, ability
gates, and `canSee` against that row before invoking author code. Missing or
tampered signatures are a 403, and create callbacks deliberately carry no
record id.

### 2. Descriptor compilation

Two things happen while the descriptor is built:

- `descriptor.resource.abilities` carries the real verdicts, resolved once per
  request. Row `can` projections reuse that resolution (see below).
- Nodes gated with `requiresAbility()` are **omitted**:

```ts
  F.select('ownerId').requiresAbility('edit')
```

  `requiresAbility` is a `canSee`-shaped gate, which is what makes the omission
  total: the node disappears from the descriptor tree, from `state`, from the
  compiled validator **and** from the fill set, in the same pass. A gated
  field cannot be rendered, submitted or mass-assigned (protocol §9 invariant
  1, TECH_SPEC §19).

  **Gates are additive.** `canSee`, `visible` and `requiresAbility` each ADD a
  gate; the node survives only when every one of them passes, in any
  declaration order. Nothing a later call declares can relax an earlier
  restriction.

```ts
  F.select('ownerId').requiresAbility('edit').canSee(isAdmin) // BOTH must pass
```

  A node gated only by `requiresAbility` stays on the §9.4 cached path (the
  ability hash is part of the cache key). Adding a raw `canSee` moves the whole
  node off it — the visibility scope only ever strengthens, never relaxes.

  On the index page the same gate removes the field's **derived column** and
  its allowlist keys, so the value is neither rendered nor orderable through
  `?sort=`/`?columns=` (§8.3/§19). A resource that declares its own `table()`
  owns its column set outright and gates through the column and filter DSL,
  which carries the same two methods:

```ts
  t.columns([C.number('salary').money('USD').sortable().requiresAbility('edit')])
   .filters([Filter.ternary('trashed').requiresAbility('restore')])
```

  A gated-away column leaves the wire table, the `sortable`/`searchable`/
  `columns` allowlists **and** the default sort; a gated-away filter leaves the
  wire block and the `filters[<key>]` allowlist, so a hand-written query key is
  silently dropped rather than applied. A raw `canSee` on a column or filter
  additionally keeps the resolved table out of the shared index-plan memo,
  since a closure's inputs are not in the cache key.

Navigation is gated the same way: the §9.1 share hook omits any resource the
request cannot `viewList`.

### 3. The mutation guard

`store`/`update` re-authorize **inside the write transaction, immediately
before the write**. This is the TOCTOU guard: between rendering a form and
receiving its submit — or between the route check and the write within one
request — an ability can be revoked by a role change or an ownership
transfer. Re-checking inside the transaction means the denial and the write
cannot interleave: the throw aborts the transaction, Lucid rolls it back, and
the caller gets a 403 over an unchanged row.

## Per-record `can` and the query budget

Every serialized record — index rows, edit/detail `record` — carries exactly
the five §12 **record** abilities: `view`, `edit`, `delete`, `restore`,
`forceDelete`. `viewList` and `create` address the resource, not the row, and
never ride along; read them from `descriptor.resource.abilities`. The
projection happens in one place (`recordCan` in `table/serializer.ts`), so no
caller can widen it.

`resolveRowAbilities()` resolves record-independent abilities **once for the
whole page** and re-evaluates only the five per-record abilities against rows
already in memory. It returns a synchronous lookup, which is what lets the row
serializer stay synchronous and index serialization stay O(rows) with zero
per-row queries (TECH_SPEC §18).

Client-side `can` is never authoritative — it enables and disables row actions
and links; every route re-authorizes.

## Row-level scoping (`static query`)

Abilities answer "may this user do X"; `static query()` (§6.1/§8.3 `base`)
answers "which rows exist for this user at all". It is enforced on **every**
row-touching path, not only the index:

<!-- @sample-preamble-reset -->

<!-- @sample-preamble
import { BaseResource } from '@adonia/core'
import type { HttpContextLike, SchemaBuilder, SchemaComponent } from '@adonia/core'
import type { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model'
import type { LucidModel } from '@adonisjs/lucid/types/model'
import Post from '#models/post'
-->

```ts
export default class ScopedPostResource extends BaseResource<typeof Post> {
  static override model = Post
  static override slug = 'posts'

  static override query(query: ModelQueryBuilderContract<LucidModel>, ctx: HttpContextLike) {
const user = ctx.auth.user
if (user === null || typeof user !== 'object' || !('id' in user)) {
  return query.whereRaw('1 = 0')
}
const authorId = user.id
if (typeof authorId !== 'number' && typeof authorId !== 'string') {
  return query.whereRaw('1 = 0')
}
return query.where('authorId', authorId)
  }

  schema(s: SchemaBuilder): SchemaComponent[] {
return [s.section('Content').components([])]
  }
}
```

<!-- @sample-preamble-reset -->

| path | scoped by |
|---|---|
| `index` | `query()` then `indexQuery()` |
| `show`, `edit`, `update`, `destroy`, `restore` | `query()` then `detailQuery()` |
| relation option list + its `exists` check | `query()` |

A record outside the scope is a **404**, not a 403: "exists but is not yours"
and "does not exist" must be indistinguishable from outside (§17).

`indexQuery()` shapes the *list* only — a deep link to `/:resource/:id` still
resolves through it — so row-level **access** belongs in `query()`, never in
`indexQuery()`. `detailQuery()` covers all five single-record paths together,
because a scope that hid a record from the detail page but not from `PUT`
would be a hole rather than a feature.

## Denials

A denied check raises `AbilityDeniedException`, an `UnauthorizedException`
(`E_ADONIA_UNAUTHORIZED`, HTTP 403) carrying the `ability` and the
enforcement `point` as fields alongside the message. Rendering follows the
[error model](/error-model): the panel-chromed 403 page for browsers,
`{ code, message }` for JSON endpoints.

## Configuration

```ts
// config/adonia.ts
import { defineConfig } from '@adonia/core'

export default defineConfig({
  authorization: {
fallback: 'deny', // production default
devFallback: 'allow', // shipped default outside production
  },
})
```

Two boot-time warnings (§20, aggregated by `adonia:doctor`):

- `fallback: 'allow'` **in production** — every unresolved ability is granted.
- `devFallback: 'allow'` — the shipped default. Convenient before a panel has
  policies, and worth naming because it makes every policy test pass. Set it
  to `'deny'` to exercise policies the way production will.

## No authorization layer installed

`adonia.authorizer` is bound by `AdoniaProvider` at boot, so a booted app
always has one. Where the binding is **absent** — a unit test, a host route
calling a controller directly — §12 defines the meaning as "no authorization
layer installed": everything is granted, `requiresAbility()` gates pass, and
navigation stays visible. Consumers must degrade this way rather than treating
an unbound authorizer as a failure.

## Testing

`@adonia/core/testing`'s `makeFakeContext({ bouncer })` accepts a structural
Bouncer double, so step 2 is testable without installing the optional peer.
The conformance suite is `packages/core/tests/authorization.spec.ts`: the
authorization matrix (every ability × every enforcement point × allow/deny),
the resolution-order precedences, the degradation path, per-record divergence,
and the transactional rollback.

Source: https://adonia.pages.dev/authorization/index.mdx
