Authorization
You can write authorization checks in your AdonisJS application using the @adonisjs/bouncer
package. Bouncer provides a JavaScript first API for writing authorization checks as abilities and policies.
The goal of abilities and policies is to abstract the logic of authorizing an action to a single place and reuse it across the rest of the codebase.
-
Abilities are defined as functions and can be a great fit if your application has fewer and simpler authorization checks.
-
Policies are defined as classes, and you must create one policy for every resource in your application. Policies can also benefit from automatic dependency injection.
Bouncer is not an implementation of RBAC or ACL. Instead, it provides a low-level API with fine-grained control to authorize actions in your AdonisJS applications.
Installation
Install and configure the package using the following command :
node ace add @adonisjs/bouncer
-
Installs the
@adonisjs/bouncer
package using the detected package manager. -
Registers the following service provider and command inside the
adonisrc.ts
file.{commands: [// ...other commands() => import('@adonisjs/bouncer/commands')],providers: [// ...other providers() => import('@adonisjs/bouncer/bouncer_provider')]} -
Creates the
app/abilities/main.ts
file to define and export abilities. -
Creates the
app/policies/main.ts
file to export all policies as a collection. -
Creates
initialize_bouncer_middleware
inside themiddleware
directory. -
Register the following middleware inside the
start/kernel.ts
file.router.use([() => import('#middleware/initialize_bouncer_middleware')])
Are you more of a visual learner? - Checkout the AdonisJS Bouncer free screencasts series from our friends at Adocasts.
The Initialize bouncer middleware
During setup, we create and register the #middleware/initialize_bouncer_middleware
middleware within your application. The initialize middleware is responsible for creating an instance of the Bouncer class for the currently authenticated user and shares it via the ctx.bouncer
property with the rest of the request.
Also, we share the same Bouncer instance with Edge templates using the ctx.view.share
method. Feel free to remove the following lines of code from the middleware if you are not using Edge inside your application.
You own your application's source code, including the files created during the initial setup. So, do not hesitate to change them and make them work with your application environment.
async handle(ctx: HttpContext, next: NextFn) {
ctx.bouncer = new Bouncer(
() => ctx.auth.user || null,
abilities,
policies
).setContainerResolver(ctx.containerResolver)
/**
* Remove if not using Edge
*/
if ('view' in ctx) {
ctx.view.share(ctx.bouncer.edgeHelpers)
}
return next()
}
Defining abilities
Abilities are JavaScript functions usually written inside the ./app/abilities/main.ts
file. You may export multiple abilities from this file.
In the following example, we define an ability called editPost
using the Bouncer.ability
method. The implementation callback must return true
to authorize the user and return false
to deny access.
An ability should always accept the User
as the first parameter, followed by additional parameters needed for the authorization check.
import User from '#models/user'
import Post from '#models/post'
import { Bouncer } from '@adonisjs/bouncer'
export const editPost = Bouncer.ability((user: User, post: Post) => {
return user.id === post.userId
})
Performing authorization
Once you have defined an ability, you may perform an authorization check using the ctx.bouncer.allows
method.
Bouncer will automatically pass the currently logged-in user to the ability callback as the first parameter, and you must supply the rest of the parameters manually.
import Post from '#models/post'
import { editPost } from '#abilities/main'
import router from '@adonisjs/core/services/router'
router.put('posts/:id', async ({ bouncer, params, response }) => {
/**
* Find a post by ID so that we can perform an
* authorization check for it.
*/
const post = await Post.findOrFail(params.id)
/**
* Use the ability to see if the logged-in user
* is allowed to perform the action.
*/
if (await bouncer.allows(editPost, post)) {
return 'You can edit the post'
}
return response.forbidden('You cannot edit the post')
})
The opposite of bouncer.allows
method is the bouncer.denies
method. You may prefer this method instead of writing an if not
statement.
if (await bouncer.denies(editPost, post)) {
response.abort('Your cannot edit the post', 403)
}
Allowing guest users
By default, Bouncer denies authorization checks for non-logged-in users without invoking the ability callback.
However, you may want to define certain abilities that can work with a guest user. For example, allow guests to view published posts but allow the creator of the post to view drafts as well.
You may define an ability that allows guest users using the allowGuest
option. In this case, the options will be defined as the first parameter, and callback will be the second parameter.
export const viewPost = Bouncer.ability(
{ allowGuest: true },
(user: User | null, post: Post) => {
/**
* Allow everyone to access published posts
*/
if (post.isPublished) {
return true
}
/**
* Guest cannot view non-published posts
*/
if (!user) {
return false
}
/**
* The creator of the post can view non-published posts
* as well.
*/
return user.id === post.userId
}
)
Authorizing users other than the logged-in user
If you want to authorize a user other than the logged-in user, you may use the Bouncer
constructor to create a new bouncer instance for a given user.
import User from '#models/user'
import { Bouncer } from '@adonisjs/bouncer'
const user = await User.findOrFail(1)
const bouncer = new Bouncer(user)
if (await bouncer.allows(editPost, post)) {
}
Defining policies
Policies offer an abstraction layer to organize the authorization checks as classes. It is recommended to create one policy per resource. For example, if your application has a Post model, you must create a PostPolicy
class to authorize actions such as creating or updating posts.
The policies are stored inside the ./app/policies
directory, and each file represents a single policy. You may create a new policy by running the following command.
See also: Make policy command
node ace make:policy post
The policy class extends the BasePolicy class, and you may implement methods for the authorization checks you want to perform. In the following example, we define authorization checks to create
, edit
, and delete
a post.
import User from '#models/user'
import Post from '#models/post'
import { BasePolicy } from '@adonisjs/bouncer'
import { AuthorizerResponse } from '@adonisjs/bouncer/types'
export default class PostPolicy extends BasePolicy {
/**
* Every logged-in user can create a post
*/
create(user: User): AuthorizerResponse {
return true
}
/**
* Only the post creator can edit the post
*/
edit(user: User, post: Post): AuthorizerResponse {
return user.id === post.userId
}
/**
* Only the post creator can delete the post
*/
delete(user: User, post: Post): AuthorizerResponse {
return user.id === post.userId
}
}
Performing authorization
Once you have created a policy, you may use the bouncer.with
method to specify the policy you want to use for authorization and then chain the bouncer.allows
or bouncer.denies
methods to perform the authorization check.
The allows
and denies
methods chained after the bouncer.with
methods are type-safe and will show a list of completions based on the methods you have defined on the policy class.
import Post from '#models/post'
import PostPolicy from '#policies/post_policy'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async create({ bouncer, response }: HttpContext) {
if (await bouncer.with(PostPolicy).denies('create')) {
return response.forbidden('Cannot create a post')
}
//Continue with the controller logic
}
async edit({ bouncer, params, response }: HttpContext) {
const post = await Post.findOrFail(params.id)
if (await bouncer.with(PostPolicy).denies('edit', post)) {
return response.forbidden('Cannot edit the post')
}
//Continue with the controller logic
}
async delete({ bouncer, params, response }: HttpContext) {
const post = await Post.findOrFail(params.id)
if (await bouncer.with(PostPolicy).denies('delete', post)) {
return response.forbidden('Cannot delete the post')
}
//Continue with the controller logic
}
}
Allowing guest users
Similar to abilities, policies can also define authorization checks for guest users using the @allowGuest
decorator. For example:
import User from '#models/user'
import Post from '#models/post'
import { BasePolicy, allowGuest } from '@adonisjs/bouncer'
import { AuthorizerResponse } from '@adonisjs/bouncer/types'
export default class PostPolicy extends BasePolicy {
@allowGuest()
view(user: User | null, post: Post): AuthorizerResponse {
/**
* Allow everyone to access published posts
*/
if (post.isPublished) {
return true
}
/**
* Guest cannot view non-published posts
*/
if (!user) {
return false
}
/**
* The creator of the post can view non-published posts
* as well.
*/
return user.id === post.userId
}
}
Policy hooks
You may define the before
and the after
template methods on a policy class to run actions around an authorization check. A common use case is always allowing or denying access to a certain user.
The before
and the after
methods are always invoked, regardless of a logged-in user. So make sure to handle the case where the value of user
will be null
.
The response from the before
is interpreted as follows.
- The
true
value will be considered successful authorization, and the action method will not be called. - The
false
value will be considered access denied, and the action method will not be called. - With an
undefined
return value, the bouncer will execute the action method to perform the authorization check.
export default class PostPolicy extends BasePolicy {
async before(user: User | null, action: string, ...params: any[]) {
/**
* Always allow an admin user without performing any check
*/
if (user && user.isAdmin) {
return true
}
}
}
The after
method receives the raw response from the action method and can override the previous response by returning a new value. The response from the after
is interpreted as follows.
- The
true
value will be considered successful authorization, and the old response will be discarded. - The
false
value will be considered access denied, and the old response will be discarded. - With an
undefined
return value, the bouncer will continue to use the old response.
import { AuthorizerResponse } from '@adonisjs/bouncer/types'
export default class PostPolicy extends BasePolicy {
async after(
user: User | null,
action: string,
response: AuthorizerResponse,
...params: any[]
) {
if (user && user.isAdmin) {
return true
}
}
}
Dependency injection
The policy classes are created using the IoC container; therefore, you can type-hint and inject dependencies inside the policy constructor using the @inject
decorator.
import { inject } from '@adonisjs/core'
import { PermissionsResolver } from '#services/permissions_resolver'
@inject()
export class PostPolicy extends BasePolicy {
constructor(
protected permissionsResolver: PermissionsResolver
) {
super()
}
}
If a Policy class is created during an HTTP request, you may also inject an instance of HttpContext inside it.
import { HttpContext } from '@adonisjs/core/http'
import { PermissionsResolver } from '#services/permissions_resolver'
@inject()
export class PostPolicy extends BasePolicy {
constructor(protected ctx: HttpContext) {
super()
}
}
Throwing AuthorizationException
Alongside the allows
and the denies
methods, you may use the bouncer.authorize
method to perform the authorization check. This method will throw the AuthorizationException when the check fails.
router.put('posts/:id', async ({ bouncer, params }) => {
const post = await Post.findOrFail(post)
await bouncer.authorize(editPost, post)
/**
* If no exception was raised, you can consider the user
* is allowed to edit the post.
*/
})
AdonisJS will convert the AuthorizationException
to a 403 - Forbidden
HTTP response using the following content negotiation rules.
-
HTTP requests with the
Accept=application/json
header will receive an array of error messages. Each array element will be an object with themessage
property. -
HTTP requests with
Accept=application/vnd.api+json
header will receive an array of error messages formatted as per the JSON API spec. -
All other requests will receive a plain text response message. However, you may use status pages to show a custom error page for authorization errors.
You may also self-handle AuthorizationException
errors within the global exception handler.
import { errors } from '@adonisjs/bouncer'
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
export default class HttpExceptionHandler extends ExceptionHandler {
protected debug = !app.inProduction
protected renderStatusPages = app.inProduction
async handle(error: unknown, ctx: HttpContext) {
if (error instanceof errors.E_AUTHORIZATION_FAILURE) {
return ctx
.response
.status(error.status)
.send(error.getResponseMessage(ctx))
}
return super.handle(error, ctx)
}
}
Customizing Authorization response
Instead of returning a boolean value from abilities and policies, you may construct an error response using the AuthorizationResponse class.
The AuthorizationResponse
class gives you fine grained control to define a custom HTTP status code and the error message.
import User from '#models/user'
import Post from '#models/post'
import { Bouncer, AuthorizationResponse } from '@adonisjs/bouncer'
export const editPost = Bouncer.ability((user: User, post: Post) => {
if (user.id === post.userId) {
return true
}
return AuthorizationResponse.deny('Post not found', 404)
})
If you are using the @adonisjs/i18n package, you may return a localized response using the .t
method. The translation message will be used over the default message during an HTTP request based on the user's language.
export const editPost = Bouncer.ability((user: User, post: Post) => {
if (user.id === post.userId) {
return true
}
return AuthorizationResponse
.deny('Post not found', 404) // default message
.t('errors.not_found') // translation identifier
})
Using a custom response builder
The flexibility to define custom error messages for individual authorization checks is great. However, if you always want to return the same response, it might be cumbersome to repeat the same code everytime.
Therefore, you can override the default response builder for Bouncer as follows.
import { Bouncer, AuthorizationResponse } from '@adonisjs/bouncer'
Bouncer.responseBuilder = (response: boolean | AuthorizationResponse) => {
if (response instanceof AuthorizationResponse) {
return response
}
if (response === true) {
return AuthorizationResponse.allow()
}
return AuthorizationResponse
.deny('Resource not found', 404)
.t('errors.not_found')
}
Pre-registering abilities and policies
So far, in this guide, we explicitly import an ability or a policy whenever we want to use it. However, once you pre-register them, you can reference an ability or a policy by its name as a string.
Pre-registering abilities and policies might be less useful within your TypeScript codebase than just cleaning up the imports. However, they offer far better DX within Edge templates.
Look at the following code examples of Edge templates with and without pre-registering a policy.
{{-- First import the ability --}}
@let(editPost = (await import('#abilities/main')).editPost)
@can(editPost, post)
{{-- Can edit post --}}
@end
{{-- Reference ability name as a string --}}
@can('editPost', post)
{{-- Can edit post --}}
@end
If you open the initialize_bouncer_middleware.ts
file, you will find us already importing and pre-registering abilities and policies when creating the Bouncer instance.
import * as abilities from '#abilities/main'
import { policies } from '#policies/main'
export default InitializeBouncerMiddleware {
async handle(ctx, next) {
ctx.bouncer = new Bouncer(
() => ctx.auth.user,
abilities,
policies
)
}
}
Points to note
-
If you decide to define abilities in other parts of your codebase, then make sure to import and pre-register them inside the middleware.
-
In the case of policies, every time you run the
make:policy
command, make sure to accept the prompt to register the policy inside the policies collection. The policies collection is defined inside the./app/policies/main.ts
file.app/policies/main.tsexport const policies = {PostPolicy: () => import('#polices/post_policy'),CommentPolicy: () => import('#polices/comment_policy')}
Referencing pre-registered abilities and policies
In the following example, we get rid of the imports and reference abilities and policies by their name. Do note the string-based API is also type-safe, but your code editor's "Go to Definition" feature may not work.
import { editPost } from '#abilities/main'
router.put('posts/:id', async ({ bouncer, params, response }) => {
const post = await Post.findOrFail(params.id)
if (await bouncer.allows(editPost, post)) {
if (await bouncer.allows('editPost', post)) {
return 'You can edit the post'
}
})
import PostPolicy from '#policies/post_policy'
export default class PostsController {
async create({ bouncer, response }: HttpContext) {
if (await bouncer.with(PostPolicy).denies('create')) {
if (await bouncer.with('PostPolicy').denies('create')) {
return response.forbidden('Cannot create a post')
}
//Continue with the controller logic
}
}
Authorization checks inside Edge templates
Before you can perform authorization checks inside Edge templates, make sure to pre-register abilities and policies. Once done, you may use the @can
and @cannot
tags to perform the authorization checks.
These tags accept the ability
name or the policy.method
name as the first parameter, followed by the rest of the parameters accepted by the ability or a policy.
@can('editPost', post)
{{-- Can edit post --}}
@end
@cannot('editPost', post)
{{-- Cannot edit post --}}
@end
@can('PostPolicy.edit', post)
{{-- Can edit post --}}
@end
@cannot('PostPolicy.edit', post)
{{-- Cannot edit post --}}
@end
Events
Please check the events reference guide to view the list of events dispatched by the @adonisjs/bouncer
package.