Skip to content

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.

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

The policy

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
  }
}

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

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:

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:

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

curl -i -X PUT /admin/posts/13 -d 'title=Rewritten+by+somebody+else&…'
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:

[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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close