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 PostThe 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:
-
Navigation. A resource the request cannot
viewListis absent frompanel.navigation— not greyed out, absent. -
The route.
edit/updateauthorizeeditafter the record loads: a policy cannot judge a record it has not seen. -
The descriptor and the row. Each serialized row carries a
canmap, 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. -
Inside the write transaction.
store/updatere-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 Forbiddenand 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.