---
title: "5. Authorization"
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.

# 5. Authorization

The blog has two kinds of people: admins, who manage everything, and authors,
who manage what they wrote. That is one Bouncer policy.

```sh
node ace add @adonisjs/bouncer   # if the app does not have it yet
node ace make:policy Post
```

## The policy

```ts
import User from '#models/user'
import Post from '#models/post'
import { BasePolicy, allowGuest } from '@adonisjs/bouncer'
import type { AuthorizerResponse } from '@adonisjs/bouncer/types'

export default class PostPolicy extends BasePolicy {
  @allowGuest()
  viewList(): AuthorizerResponse {
return true
  }

  @allowGuest()
  view(user: User | null, post: Post): AuthorizerResponse {
if (post.status === 'published') {
  return true
}
return user !== null && this.manages(user, post)
  }

  create(_user: User): AuthorizerResponse {
return true
  }

  edit(user: User, post: Post): AuthorizerResponse {
return this.manages(user, post)
  }

  delete(user: User, post: Post): AuthorizerResponse {
return this.manages(user, post)
  }

  /** Admins manage every post; authors manage the ones they wrote. */
  private manages(user: User, post: Post): boolean {
return user.role === 'admin' || user.id === post.authorId
  }
}
```

<small>`examples/blog-admin/app/policies/post_policy.ts` (abridged: `restore`
and `forceDelete` delegate to `manages` too)</small>

Method names **are** ability names. The vocabulary is
`viewList | view | create | edit | delete | restore | forceDelete`, plus the
parameterized `runAction:<slug>` and `accessLens:<slug>` used by actions and lenses.
`viewList` and `create` address the resource; the other five address a record,
which Bouncer passes as the last argument.

Point the resource at it:

```ts no-check
export default class PostResource extends BaseResource<typeof Post> {
  static override policy = () => import('#policies/post_policy')
}
```

The lazy-import form is the one Bouncer's own registry uses; the class itself
works too. Resolution is memoized per resource for the life of the process.

## Where it is enforced

Every check goes through one chain — inline `static can` override, then the
policy, then `config.authorization.fallback`. A step with **no opinion**
continues the chain; a step that answers ends it. A policy returning `false` is
a deny that an `allow` fallback never rescues.

That chain runs at four places, and the tutorial app exercises all of them:

1. **Navigation.** A resource the request cannot `viewList` is absent from
   `panel.navigation` — not greyed out, absent.
2. **The route.** `edit`/`update` authorize `edit` *after* the record loads: a
   policy cannot judge a record it has not seen.
3. **The descriptor and the row.** Each serialized row carries a `can` map, so
   the two rows below differ for an author and are uniform for an admin:

```jsonc
   { "id": 12, "title": "Mine",   "can": { "edit": true,  "delete": true  } }
   { "id": 13, "title": "Theirs", "can": { "edit": false, "delete": false } }
```

   A field can be gated the same way with `F.select('authorId').requiresAbility('edit')`,
   which **omits** it — from the tree, the state, the validator and the fill
   set together.
4. **Inside the write transaction.** `store`/`update` re-authorize immediately
   before the write. Between rendering a form and receiving its submit an
   ability can be revoked; re-checking inside the transaction means the denial
   aborts it and the row is untouched.

So a client that ignores `can` gains nothing:

```sh
curl -i -X PUT /admin/posts/13 -d 'title=Rewritten+by+somebody+else&…'
```

```text
HTTP/1.1 403 Forbidden
```

and the row still says "Theirs". That exact exchange is asserted in
`examples/blog-admin/tests/functional/adonia_tutorial.spec.ts`.

## Two things that will bite you once

**The dev fallback grants everything.** The shipped config is
`fallback: 'deny'`, `devFallback: 'allow'` — outside production, any ability no
policy resolves is granted, and the panel says so at boot:

```text
[adonia] authorization.devFallback is "allow" — abilities no policy resolves
are granted outside production.
```

That is convenient before you have policies and misleading after: it makes
every policy test pass. Set `devFallback: 'deny'` in `config/adonia.ts` once
your policies exist.

**`descriptor.resource.abilities` is advisory.** The header carries a verdict
for all seven abilities, but on an index or create page there is no record to
judge, so the five per-record abilities there answer from the inline override
or the fallback — the policy is not asked, precisely so a policy written the
ordinary way (`view(user, post)`) is never called with `undefined`. The
authoritative per-record projection is the row's `can`.

Bouncer itself stays optional. It is never imported — the bridge reads
`ctx.bouncer` structurally — so an app without it simply skips step 2 and every
ability falls through to the fallback. Nothing throws.

Next: [relations and media](/guide/tutorial/relations).

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