Bouncer Actions & Authorizations

We'll learn about AdonisJS Bouncer actions and how we can use these actions to check if a user is authorized to perform a specific task. Plus, conditional check authorizations.

Published
Dec 24, 21
Duration
17m 1s

Developer, dog lover, and burrito eater. Currently teaching AdonisJS, a fully featured NodeJS framework, and running Adocasts where I post new lessons weekly. Professionally, I work with JavaScript, .Net C#, and SQL Server.

Adocasts

Burlington, KY

We can use the actions section of our start/bouncer.ts file to define Bouncer actions. Actions are how to give or reject authorization for a user on a specific task, like creating a post. We’ll define our authorization checks within these actions, then use actions to check whether a user is authorized to perform a particular task.

Defining An Action

On the bouncer module is a method called define, this method is what we’ll use to define our actions. The first argument is the name of the action, for example createPost. The second argument is a callback function we’ll define the authorization check within. Lastly, is a third argument which contains options for the definition. We’ll discuss these options in a bit.

The callback, which we provide as the second argument, is provided the user automatically. The user will always be the first argument of the callback. We can also pass additional information and that will come through as the second argument of the callback.

// start/bouncer.ts

import Bouncer from '@ioc:Adonis/Addons/Bouncer'
import User from 'App/Models/User'

Bouncer.define('createPost', (user: User) => {
  return [Role.EDITOR, Role.ADMIN].includes(user.roleId)
})

export const { actions } = Bouncer

Also, I’d like to note real quick in case you’re following along, I forgot to add the roleId to the user model. So, let’s also do that real quick otherwise we’ll run into issues later on.

// app/Models/User.ts

export default class User extends BaseModel {
  @column({ isPrimary: true })
  public id: number

  @column()
  public roleId: number  // 👈 add the roleId as a column

  // ...
}

Denying Authorization

Returning a truthy value will authorize the user to perform the action. Returning a falsy value will deny the user authorization to perform the action. When a user is denied, by default, Bouncer will throw a 403 Forbidden exception. However, if this doesn’t work for your needs you can instead return a call to Bouncer.deny. The first argument is the error message and you can optionally provide a different status code in the second argument.

// start/bouncer.ts

import Bouncer from '@ioc:Adonis/Addons/Bouncer'
import User from 'App/Models/User'
import Post from 'App/Models/Post'

Bouncer.define('viewPost', (user: User, post: Post) => {
  if (!post.isPublished) {
    return Bouncer.deny('Post is not published', 404)
  }

  return true
})

export const { actions } = Bouncer

With the above example, if the post is not published, instead of throwing a 403 Forbidden error, we can use the deny call to throw a 404 error and provide a reason why the user was denied.

One important thing to note with the above example is that it’s going to require all our users to be authenticated in order to view the post. Let’s take a deeper look at why this is.

Allowing Guest Users

Bouncer, by default, requires a user to be authenticated before it calls our action. If the user is not authenticated Bouncer will immediately deny the action. However, in some cases, you’ll want to allow guests (unauthenticated users). To allow guests on a particular action, you can provide a third argument, which are options for the action definition.

In particular, you’ll want to set the allowGuests option to true. By setting this to truthy, our action definition will allow guests, which means our user value can then be nullable.

// start/bouncer.ts

import Bouncer from '@ioc:Adonis/Addons/Bouncer'
import User from 'App/Models/User'
import Post from 'App/Models/Post'

Bouncer.define('viewPost', (user: User | null, post: Post) => {
  if (!post.isPublished) {
    return Bouncer.deny('Post is not published', 404)
  }

  return true
}, { allowGuests: true }) // 👈 set allow guests to true

export const { actions } = Bouncer

In the above example, guest users can now view published posts within our application.

Chaining Definitions Together

So far we’ve been calling Bouncer.define to define our actions, however, we can simplify this by chaining each define call off one another directly off the actions export.

// start/bouncer.ts

import Bouncer from '@ioc:Adonis/Addons/Bouncer'
import User from 'App/Models/User'
import Post from 'App/Models/Post'

export const { actions } = Bouncer
  .define('createPost', (user: User) => {
	  return [Role.EDITOR, Role.ADMIN].includes(user.roleId)
  })
  .define('viewPost', (user: User | null, post: Post) => {
    if (!post.isPublished) {
      return Bouncer.deny('Post is not published', 404)
    }

    return true
  }, { allowGuests: true })

Bouncer Hooks

Bouncer comes with two hooks, before and after. We can use these to perform actions before or after all action checks.

Before Hook

The before hook is always run, even when guests aren’t allowed and the user isn’t authenticated. They’re run before the action itself is executed, and we can use it to grant global access if we wish. The before method takes one argument that’s a callback function. The callback function is then provided with three arguments.

  1. First, is the user, if there is one. User | null

  2. Second, is the name of the action. string

  3. Last, is an object containing any additional arguments provided to the check. any.

When we return a truthy or falsy value from the before hook, Bouncer will skip checking the action itself, and instead, use the truthy/falsy value as the result. If we return undefined, Bouncer will continue onward and perform our action check.

// start/bouncer.ts

import Bouncer from '@ioc:Adonis/Addons/Bouncer'
import User from 'App/Models/User'
import Post from 'App/Models/Post'

export const { actions } = Bouncer
  .before((user: User | null) => {
    // 👇 by returning true here, we're allowing admins to do anything
    if (user?.roleId === Role.ADMIN) {
      return true
    }
  })
  .define('createPost', (user: User) => {
	  return user.roleId === Role.EDITOR // 👈 meaning we can simplify this check
  })
  .define('viewPost', (user: User | null, post: Post) => {
    if (!post.isPublished) {
      return Bouncer.deny('Post is not published', 404)
    }

    return true
  }, { allowGuests: true })

In this example, we’re allowing our administrators to perform any action by checking if the provided user has the admin role. If they do, we’re returning true, which grants them access regardless of what the action would’ve allowed.

Thanks to this, we can now exclude checking for admins throughout our actions, since admins will no longer reach the action checks.

Also, remember the default return value for JavaScript functions is undefined, so by not returning anything if the user isn’t an admin we’re returning undefined, which informs Bouncer to continue onward with its checks.

After Hook

The after hook is run after the execution of the action. It too receives a callback function as its argument. Then the after hook callback is provided four arguments.

  1. First, is the user if there is one. User | null

  2. The second is the action name. string.

  3. Third, is an object with the action’s results. { authorized: boolean, errorResponse: [string, number] | null }

  4. Last, is an object containing any additional arguments provided to the check. any.

When we return a truthy or falsy value from the after hook, that response will overwrite whatever response was set by the action itself. Whereas, if we return undefined, Bouncer will keep the response defined by the action.

The after hook can be useful for logging the results of your action checks or mutating results for particular use-cases.

// start/bouncer.ts

import Bouncer from '@ioc:Adonis/Addons/Bouncer'
import Logger from '@ioc:Adonis/Core/Logger'
import User from 'App/Models/User'
import Post from 'App/Models/Post'

export const { actions } = Bouncer
  .before((user: User | null) => {
    if (user?.roleId === Role.ADMIN) {
      return true
    }
  })
  .after((user: User | null, actionName, actionResult) => {
    const userType = user ? 'User' : 'Guest'

    // 👇 log result for all checks
    actionResult.authorized
      ? Logger.info(`${userType} was authorized to ${actionName}`)
      : Logger.info(`${userType} was denied to ${actionName} for ${actionResult.errorResponse}`)
  })
  .define('createPost', (user: User) => {
	  return user.roleId === Role.EDITOR
  })
  .define('viewPost', (user: User | null, post: Post) => {
    if (!post.isPublished) {
      return Bouncer.deny('Post is not published', 404)
    }

    return true
  }, { allowGuests: true })

Using Actions to Authorize Tasks

Now that we know what actions look like, let’s next learn how we can use them to check whether the user is authorized to perform a task. Bouncer adds an instance tied to the authenticated user, by default, to our HttpContext. This bouncer instance contains a method called authorize that we can use to check whether the instance user is authorized to perform a specific defined action.

// app/Controllers/Http/PostsController.ts

import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import Post from 'App/Models/Post'
import PostValidator from 'App/Validators/PostValidator'

export default class PostsController {
  public async create({ view, bouncer }: HttpContextContract) {
    // 👇 check if auth user can create posts
    await bouncer.authorize('createPost')

    return view.render('posts/createOrEdit')
  }

  public async show({ view, bouncer, params }: HttpContextContract) {
    const post = await Post.query()
      .preload('comments', query => query.preload('user'))
      .preload('user')
      .where('id', params.id)
      .firstOrFail()

    // 👇 check if auth user or guest can view the provided post
    await bouncer.authorize('viewPost', post)

    return view.render('posts/show', { post })
  }
}

If the authenticated user (or guest) is not authorized to perform a task Bouncer will throw an error, which is a 403 Forbidden error by default. If they are authorized, the call will continue like normal.

Allows & Denies

Authorize is great if you need to throw an error, but sometimes you just need to check whether a user can or cannot perform an action. For that, we have allows and denies. These two methods, simply return back a boolean as opposed to throwing an error like authorize does. Conveniently, the argument set for authorize, allows, and denies are all the same.

// app/Controllers/Http/PostsController.ts

import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import Post from 'App/Models/Post'
import PostValidator from 'App/Validators/PostValidator'

export default class PostsController {
  public async create({ response, view, bouncer, session }: HttpContextContract) {
    // 👇 check if auth user can create posts
    if (await bouncer.allows('createPost')) {
	    return view.render('posts/createOrEdit')  
    }

    session.flash('error', 'You are not allowed to create posts')

    return response.redirect('auth.login.show')
  }

  public async show({ response, view, bouncer, params, session }: HttpContextContract) {
    const post = await Post.query()
      .preload('comments', query => query.preload('user'))
      .preload('user')
      .where('id', params.id)
      .firstOrFail()

    // 👇 check if auth user or guest can view the provided post
    if (await bouncer.denies('viewPost', post)) {
      session.flash('error', 'You are not allowed to view this post')
      return response.redirect().back()
    }

    return view.render('posts/show', { post })
  }
}

Using A Specific User

In some cases, you may need to authorize a specific user outside the context of the authenticated user within an HttpContext. In those cases, you can get an instance of Bouncer for that specific user by calling Bouncer.forUser.

// app/Controllers/Http/PostsController.ts

import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import Post from 'App/Models/Post'
import User form 'App/Models/User'
import PostValidator from 'App/Validators/PostValidator'

export default class PostsController {
  public async create({ view, bouncer }: HttpContextContract) {
    // get the user you need to authorize
    const user = await User.findOrFail(1)

    // get a bouncer instance for that user
    const userBouncer = bouncer.forUser(user)
    
    // authorize an action for that user
    await userBouncer.authorize('createPost')

    return view.render('posts/createOrEdit')
  }
}

By calling forUser, we’ll get back an instance of Bouncer that’ll use the provided user to authorize with. Whenever we call authorize with this instance, the provided user will then be the user passed through to the defined action callback.

Authorizing Within The Edge Template Engine

In most cases, authorization checks aren’t just limited to controllers but also control options displayed to the user on the page. For example, if a user cannot create a post there’s no reason for us to show them the “New Post” link in our navigation.

To help us easily check our actions within the Edge Template Engine, Bouncer provides two tags, available globally, within Edge. These tags are can and cannot. We can use these tags to conditionally display things on our pages based on whether the user is authorized to perform an action. The arguments for can and cannot are the same as the authorize method.

{{-- resources/views/layouts/main.edge --}}

{{--  Navigation  --}}
<div class="flex items-center justify-center bg-gray-50 py-3 border-b border-gray-100 space-x-3">
  <a href="{{ route('home') }}">Home</a>

  {{-- 👇 if user can create posts, display New Post link --}}
  @can('createPost')
    <a href="{{ route('posts.create') }}">New Post</a>
  @endcan  

  @if (auth.user)
    <a href="{{ route('auth.logout') }}">Logout</a>
  @else
    <a href="{{ route('auth.register.show') }}">Register</a>
    <a href="{{ route('auth.login.show') }}">Login</a>
  @endif
</div>

The cannot tag is the inverse of the can tag. It’ll display what’s inside the tag if the user is not authorized to perform the action.

{{-- resources/views/posts/show.edge --}}

@cannot('viewPost', post)
  <p>
    Sorry, you're not authorized to view this post
  </p>
@else
  <div class="flex justify-between space-x-3 items-center mb-6">
      <p class="text-gray-400">By {{ post.user.username }}</p>
  
      <div class="flex justify-end items-center space-x-3">
        <a href="{{ route('posts.edit', { id: post.id }) }}">Edit Post</a>
        <form action="{{ route('posts.destroy', { id: post.id }, { qs: { _method: 'DELETE' }}) }}" method="POST">
          {{ csrfField() }}
          <button type="submit" class="text-red-400 hover:text-red-600">Delete Post</a>
        </form>
      </div>
    </div>
  
    <h1 class="text-4xl font-semibold mb-3">{{ post.title }}</h1>
  
    <p class="text-lg text-gray-400 mb-3">{{ post.summary }}</p>
  
    <p>{{ post.body }}</p>
  </div>
@endcannot

This example doesn’t make much sense, since our Bouncer action would throw an error before we reached this point, but it gives you an idea of how you could use the cannot tag.

Next Up

Now that we have a solid understanding of Bouncer actions and how to use them to check whether users are authorized to perform certain tasks within our application, we’re ready to apply these actions throughout our application. So, in the next lesson, we’ll be creating actions for the remainder of the authorization checks we need and applying them within our views and controllers.

Join The Discussion! (4 Comments)

Please sign in or sign up for free to join in on the dicussion.

  1. Commented 2 years ago

    First of all, thank you for these great contents.

    This may be a bad idea, but I have a 'roles' table which is linked to the 'users' table by a 'ManyToMany' relationship. If this is a bad practice, I'm willing to change my schema.

    But if it is acceptable, how to access the user's roles from the Bouncer ?

    0

    Please sign in or sign up for free to reply

    1. Commented 2 years ago

      Hi MisterV, sorry I’m late in responding!

      So long as your users can have many roles, then a many-to-many relationship between users and roles is okay.

      The best way to access the user’s roles from Bouncer is to load them onto the user. I think this is the best way because the user itself is passed by reference throughout the Http lifecycle, so once the roles are loaded onto the user they’ll remain loaded onto the user for the remainder of the request.

      await user.load('roles')
      
      user.roles // the array of user's roles

      This assumes your roles relationship is defined on your User model and called roles.

      0

      Please sign in or sign up for free to reply

      1. Commented 2 years ago

        Hi Tom, thank you for your reply.

        I want to make sure I understand your answer.

        The roles must be loaded inside the handlers of "define"s methods with :

        await user.load('roles')

        So the handler becomes an async (I wasn't sure if this was normal here. Never see it in documentation)

        export const { actions } = Bouncer
            .define('listUsers',
                async (user: User) => {
                    await user.load('roles')
                    if (user.roles.filter(role => role.code === 'TECHNICIAN').length > 0) {
                        return true
                    }
                    return false
                })

        Is this good ?

        0

        Please sign in or sign up for free to reply

        1. Commented 2 years ago

          Sorry for the delayed response.

          Yeah, async handlers should work just fine. I think they default to async when you create a resourceful policy. For example:
          https://github.com/jagr-co/jagr.co/blob/main/app/Policies/PostPolicy.ts

          0

          Please sign in or sign up for free to reply

Playing Next Lesson In
seconds