Creating & Using Bouncer Policies

We'll learn about policies and how we can use them to group resource-based actions. We'll also learn how to create and share hooks with policies.

Published
Jan 02, 22
Duration
19m 4s

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

Policies allow us to group Bouncer actions together, typically by resource, so that we don’t end up with a giant list of actions within our start/bouncer.ts file. This will make things easier to maintain and scale as our application grows and ages. Since we have two resources within our project, posts, and comments, we’ll end up extracting our actions into two policies.

Creating Policies

When we configured Bouncer within our project it added a single new Ace CLI command for us. This command is for creating Bouncer Policies, node ace make:policy <name>.

If we take a look at the options this command contains we’ll see the following.

node ace make:policy -h                                                      

# Make a new bouncer policy

# Usage: make:policy <name>

# Arguments
#   name                     Name of the policy to create

# Flags
#   --resource-model string  Name of the resource model to authorize
#   --user-model string      Name of the user model to be authorized
#   --actions string[]       Actions to implement

The make:policy command takes the name of the policy as its argument. In addition to that, we also have three optional flags at our disposal.

  • --resource-model which allows us to specify a model name, for example, Post.

  • --user-model which allows us to specify what user model should be used, this will default to the default user model.

  • --actions which we can use to specify a list of actions that should be stubbed.

If we omit the --resource-model or --user-model flag, Bouncer will ask us about them after we run the command as well, so if you prefer that approach you can do that instead.

If we provide a resource model, Bouncer does have a default list of actions it’ll ask us about as well and it’ll stub the selected actions within our policy.

Creating Our Policies

First, let's go ahead and create both our policies and inspect the generated files.

node ace make:policy Post --resource-model Post

# ❯ Enter the name of the user model to be authorized · User
# ❯ Select the actions you want to authorize · viewList, view, create, update, delete

# CREATE: app/Policies/PostPolicy.ts

Be sure to enter User as the user model to be authorized, this should be the default. Also, when asked about actions select the following.

  • viewList - for authorizing post lists

  • view - for authorizing viewing a single post

  • create - for authorizing the creation of a post

  • update - for authorizing update a post

  • delete - for authorizing deleting a post

This will create our PostPolicy file at app/Policies/PostPolicy.ts and our default file will be the below.

// app/Policies/PostPolicy.ts

import { BasePolicy } from '@ioc:Adonis/Addons/Bouncer'
import User from 'App/Models/User'
import post from 'App/Models/post'

export default class PostPolicy extends BasePolicy {
  public async viewList(user: User) {}
  public async view(user: User, post: post) {}
  public async create(user: User) {}
  public async update(user: User, post: post) {}
  public async delete(user: User, post: post) {}
}

Our policy as a whole is our class PostPolicy that extends BasePolicy from the Bouncer package. Each public method within our class then acts as a Bouncer action.

Note that our policy actions view, update, and delete were stubbed expecting a post record as well since these deal with authorizing a single post record.

Next, let's do that again for our comments.

node ace make:policy Comment --resource-model Comment

# ❯ Enter the name of the user model to be authorized · User
# ❯ Select the actions you want to authorize · viewList, create, update, delete

# CREATE: app/Policies/CommentPolicy.ts

Note this time we don’t need the singular view action since the only display we’ll have is the list of comments on a post show page.

Registering Policies

Now that we’ve created our policies, we next need to register them. So, within our start/bouncer.ts file scroll down to the bottom and you should see a block like this:

export const { policies } = Bouncer.registerPolicies({})

Within the object passed to the registerPolicies method is where we’ll want to register our policies. We’ll do this almost identically to how named middleware are registered.

export const { policies } = Bouncer.registerPolicies({
  PostPolicy: () => import('App/Policies/PostPolicy'),
  CommentPolicy: () => import('App/Policies/CommentPolicy')
})

We register the policies with a name, for example PostPolicy, then import the policy from within our application. With that, our policies are now registered.

Migrating Actions to Policies

Next, let’s migrate our actions out of our start/bouncer.ts file and into our post and comment policies.

Migrating Post Actions to the PostPolicy

Let’s start with our post actions. To summarize from past lessons here are our actions specific to our post, remember admins are granted access to everything via our before hook (omitted here).

// start/bouncer.ts

export const { actions } = Bouncer
  /* POST
  /***************************************/
  .define('createPost', (user: User) => {
    return user.roleId === Role.EDITOR
  })
  .define('viewPost', (user: User | null, post: Post) => {
    if (post.userId === user?.id) {
      return true
    }

    if (!post.isPublished) {
      return Bouncer.deny('This post is not yet published', 404)
    }

    return true
  }, { allowGuest: true })
  .define('editPost', (user: User, post: Post) => {
    return post.userId === user.id
  })
  .define('destroyPost', (user: User, post: Post) => {
    return post.userId === user.id
  })

To migrate these to our PostPolicy, we’ll first want to identify what each method in our policies each action maps to.

  • PostPolicy.viewList will be new, we’ll just be returning true here to allow everyone.

  • PostPolicy.view will come from viewPost

  • PostPolicy.create will come from createPost

  • PostPolicy.update will come from editPost

  • PostPolicy.delete will come from destroyPost

Then, we’ll take the inner contents of our current action’s callback and instead apply it as the inner code of our policy class methods.

For example, for create we’ll end up moving this:

return user.roleId === Role.EDITOR

to here:

// app/Policies/PostPolicy.ts

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

export default class PostPolicy extends BasePolicy {
  public async viewList(user: User) {}
  public async view(user: User, post: Post) {}
	
  // 👇 move inner code of createPost here
  public async create(user: User) {
    return user.roleId === Role.EDITOR
  }

  public async update(user: User, post: Post) {}
  public async delete(user: User, post: Post) {}
}

So, the remainder of our policy will look like the following.

// app/Policies/PostPolicy.ts

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

export default class PostPolicy extends BasePolicy {
  public async viewList(user: User) {
    return true // allow all
  }
  
  public async view(user: User, post: Post) {
    if (post.userId === user?.id) {
      return true
    }

    if (!post.isPublished) {
      return Bouncer.deny('This post is not yet published', 404)
    }

    return true
  }
	
  public async create(user: User) {
    return user.roleId === Role.EDITOR
  }

  public async update(user: User, post: Post) {
    return post.userId === user.id
  }

  public async delete(user: User, post: Post) {
    return post.userId === user.id
  }
}

Allowing Guests in Policy Actions

Now, our viewPost action allowed guests, meaning our user could be null. We want to maintain that when we move the action into the policy. We’re also going to want the new viewList policy action to allow guests as well.

To specify guests are allowed when we’re using policies, we can use the @action() decorator to apply options for the specific action. So, we can decorate the view method like so to allow guests.

// app/Policies/PostPolicy.ts

import { BasePolicy, action } from '@ioc:Adonis/Addons/Bouncer' // 👈 import action decorator
import User from 'App/Models/User'
import Post from 'App/Models/Post'

export default class PostPolicy extends BasePolicy {
  @action({ allowGuest: true }) // 👈 decorate method
	public async viewList(user: User) {
    return true
  }
	
  @action({ allowGuest: true }) // 👈 decorate method
  public async view(user: User | null, post: Post) { // 👈 allow user to be null
    if (post.userId === user?.id) {
      return true
    }

    if (!post.isPublished) {
      return Bouncer.deny('This post is not yet published', 404)
    }

    return true
  }
	
  public async create(user: User) {/*...*/}
  public async update(user: User, post: Post) {/*...*/}
  public async delete(user: User, post: Post) {/*...*/}
}

Migrating Comment Actions to the CommentPolicy

Next, let’s do the same for our comment actions. When we move these actions over into our CommentPolicy, the CommentPolicy will end up looking like so.

// app/Policies/CommentPolicy.ts

import { BasePolicy } from '@ioc:Adonis/Addons/Bouncer'
import User from 'App/Models/User'
import Comment from 'App/Models/Comment'
import Post from 'App/Models/Post'
import Role from 'Contracts/enums/Role'

export default class CommentPolicy extends BasePolicy {
  public async viewList(user: User, post: Post) {
    return post.isPublished
  }
	
  public async create(user: User, post: Post) {
    return post.isPublished
  }

  public async update(user: User, comment: Comment) {
    return comment.userId === user.id
  }

  public async delete(user: User, comment: Comment) {
    const allowedRoles = [Role.MODERATOR, Role.EDITOR, Role.ADMIN]

    return comment.userId === user.id || allowedRoles.includes(user.roleId)
  }
}

Note we’re still utilizing the post for our viewList and create actions.

Policy Hooks

Policies contain the same hooks as Bouncer, before and after. These are essentially the same as the main Bouncer hooks except they’ll only be applied before or after the specific policy’s actions. It’s also worth noting the hooks defined within our start/bouncer.ts file won’t impact our Policy actions either.

So, if we want our administrators to still have access to perform every action within our application, we’ll need to ensure the same before hook is defined within our policies as well.

// app/Policies/CommentPolicy.ts

import { BasePolicy } from '@ioc:Adonis/Addons/Bouncer'
import User from 'App/Models/User'
import Comment from 'App/Models/Comment'
import Post from 'App/Models/Post'
import Role from 'Contracts/enums/Role'

export default class CommentPolicy extends BasePolicy {
  public async before(user: User | null) {
    // allow admins authorization to perform all comment actions
    if (user?.roleId === Role.ADMIN) {
      return true
    }
  }

  public async after(user: User | null, actionName, actionResult) {
    // perform some action after action is completed
  }

  public async viewList(user: User, post: Post) {/*...*/}
  public async create(user: User, post: Post) {/*...*/}
  public async update(user: User, comment: Comment) {/*...*/}
  public async delete(user: User, comment: Comment) {/*...*/}
}

Sharing Policy Hooks

Now, we could also define this exact same before hook within our PostPolicy or, we can create our own BasePolicy that extends Bouncer’s BasePolicy. Our BasePolicy is then where we can define our before hook to provide our admins authorization on all actions.

// app/Policies/BasePolicy.ts

import { BasePolicy as BouncerBasePolicy } from "@ioc:Adonis/Addons/Bouncer";
import User from "App/Models/User";
import Role from "Contracts/enums/Role";

export default class BasePolicy extends BouncerBasePolicy {
  public async before(user: User | null) {
    if (user?.roleId === Role.ADMIN) {
      return true
    }
  }
}

// app/Policies/CommentPolicy.ts

import {action} from '@ioc:Adonis/Addons/Bouncer'
import User from 'App/Models/User'
import Comment from 'App/Models/Comment'
import Post from "App/Models/Post";
import Role from "Contracts/enums/Role";
import BasePolicy from "App/Policies/BasePolicy";

export default class CommentPolicy extends BasePolicy {
  @action({ allowGuest: true })
	public async viewList(_: User, post: Post) {
    return post.isPublished
  }

	public async create(_: User, post: Post) {
    return post.isPublished
  }

  public async update(user: User, comment: Comment) {
    return comment.userId === user.id
  }

  public async delete(user: User, comment: Comment) {
    const allowedRoles = [Role.MODERATOR, Role.EDITOR]

    return comment.userId === user.id || allowedRoles.includes(user.roleId)
  }
}

// app/Policies/PostPolicy.ts

import Bouncer, {action} from '@ioc:Adonis/Addons/Bouncer'
import User from 'App/Models/User'
import Post from 'App/Models/Post'
import Role from "Contracts/enums/Role";
import BasePolicy from "App/Policies/BasePolicy";

export default class PostPolicy extends BasePolicy {
  @action({ allowGuest: true })
	public async viewList(_: User | null) {
    return true
  }

  @action({ allowGuest: true })
	public async view(user: User | null, post: Post) {
    if (post.userId === user?.id) {
      return true
    }

    if (!post.isPublished) {
      return Bouncer.deny('This post is not yet published', 404)
    }

    return true
  }

  public async create(user: User) {
    return user.roleId === Role.EDITOR
  }

  public async update(user: User, post: Post) {
    return post.userId === user.id
  }

  public async delete(user: User, post: Post) {
    return post.userId === user.id
  }
}

Note the only file importing the BasePolicy from Bouncer is our app/Policies/BasePolicy.ts file. The PostPolicy and CommentPolicy files are importing our BasePolicy so they contain our before hook giving admins all authorizations.

Using Policy Actions

Now that we have a full understanding of defining actions within policies, let’s finish out our lesson by changing our authorization checks to utilize our policy actions.

Controller Checks

When we’re within controllers, services, or anywhere else that has access to a Bouncer instance within the HttpContext we can specify we want to use a policy action by prefixing our authorize, allows, or denies method calls with with. The with method is how we’ll specify to run an authorization check with a specified policy.

So, for example, to check the create action within our PostPolicy we can do the following.

await bouncer.with('PostPolicy').authorize('create')

Here we’re specifying to Bouncer to use the create action defined within the PostPolicy.

So, we’ll want to update all our authorize, allows, and denies checks to use with and simplify the action name to match the corresponding policy action name.

PostsController

Our updated PostsController now looks like the following.

// app/Controllers/Http/PostsController.ts

export default class PostsController {
  public async index({ view, bouncer }: HttpContextContract) {
    await bouncer.with('PostPolicy').authorize('viewList') // 👈 add new check
  
    const posts = await Post.query()
      .preload('user')
      .where('isPublished', true)

    return view.render('index', { posts })
  }

  public async create({ view, bouncer }: HttpContextContract) {
    await bouncer.with('PostPolicy').authorize('create') // 👈

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

  public async store({ request, response, auth, bouncer }: HttpContextContract) {
    await bouncer.with('PostPolicy').authorize('create') // 👈

    const data = await request.validate(PostValidator)

    const post = await Post.create({
      ...data,
      userId: auth.user!.id
    })

    return response.redirect().toRoute('posts.show', { id: post.id })
  }

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

    // 👇 update allows check to use CommentPolicy
    if (await bouncer.with('CommentPolicy').allows('viewList', post)) {
      await post.load('comments', query => query.preload('user'))
    }

    await bouncer.with('PostPolicy').authorize('view', post) // 👈

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

  public async edit({ view, params, bouncer }: HttpContextContract) {
    const post = await Post.findOrFail(params.id)

    await bouncer.with('PostPolicy').authorize('update', post) // 👈

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

  public async update({ request, response, params, bouncer }: HttpContextContract) {
    const post = await Post.findOrFail(params.id)

    await bouncer.with('PostPolicy').authorize('update', post) // 👈

    const data = await request.validate(PostValidator)

    await post.merge(data).save()

    return response.redirect().toRoute('posts.show', { id: post.id })
  }

  public async destroy({ response, params, bouncer }: HttpContextContract) {
    const post = await Post.findOrFail(params.id)

    await bouncer.with('PostPolicy').authorize('delete', post) // 👈

    await post.related('comments').query().delete()
    await post.delete()

    return response.redirect().toRoute('home')
  }
}

CommentsController

Our updated CommentsController will end up looking like the following:

// app/Controllers/Http/CommentsController.ts

export default class CommentsController {
  public async store({ request, response, auth, params, bouncer }: HttpContextContract) {
    const post = await Post.findOrFail(params.post_id)

    await bouncer.with('CommentPolicy').authorize('create', post) // 👈

    const data = await request.validate(CommentValidator)

    await Comment.create({
      ...data,
      userId: auth.user!.id,
      postId: params.post_id
    })

    return response.redirect().toRoute('posts.show', { id: params.post_id })
  }

  public async update({ request, response, params, bouncer }: HttpContextContract) {
    const comment = await Comment.findOrFail(params.id)

    await bouncer.with('CommentPolicy').authorize('update', comment) // 👈

    const data = await request.validate(CommentValidator)

    await comment.merge(data).save()

    return response.redirect().toRoute('posts.show', { id: comment.postId })
  }

  public async destroy({ response, params, bouncer }: HttpContextContract) {
    const comment = await Comment.findOrFail(params.id)

    await bouncer.with('CommentPolicy').authorize('delete', comment) // 👈

    await comment.delete()

    return response.redirect().toRoute('posts.show', { id: comment.postId })
  }
}

Edge Checks

When we’re within the Edge Templating Engine, the @can and @cannot tags behave a little differently than controllers when it comes to checking policy actions. Instead of calling an additional method, we’ll instead use a dot notation string to specify which policy to use.

So, for example, to check the create action within our PostPolicy we can do the following.

@can('PostPolicy.create')
  <p>Can create posts!</p>
@endcan

So, throughout our Edge views, all we need to do is update the names provided to the first argument of the @can and @cannot tags to include the policy name and the new action name.

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

  {{-- 👇 change from createPost to PostPolicy.create --}}
  @can('PostPolicy.create')
    <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>
{{-- resources/views/posts/show.edge --}}

<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">
    {{-- 👇 change from editPost --}}
    @can('PostPolicy.update', post)
      <a href="{{ route('posts.edit', { id: post.id }) }}">Edit Post</a>
    @endcan

    {{-- 👇 change from destroyPost --}}
    @can('PostPolicy.delete', post)
      <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>
    @endcan
  </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 class="mt-3 pt-3 border-t border-gray-300">
  <h3 class="text-lg font-semibold">Comments</h3>

  {{-- 👇 change from createComment --}}
  @can('CommentPolicy.create', post)
    <form action="{{ route('posts.comments.store', { post_id: post.id }) }}" method="POST" class="mb-3">
      {{ csrfField() }}

      @!input({
        type: 'textarea',
        name: 'body',
        value: flashMessages.get('body'),
        errors: flashMessages.get('errors.body')
      })

      <div class="-mt-2 text-right">
        <button type="submit">Comment</button>
      </div>
    </form>
  @endcan

  @each (comment in post.comments)
    <div class="mb-3">
      <p>{{ comment.body }}</p>
      <div class="flex items-center space-x-3 text-xs ">
        <p class="text-gray-400">By {{ comment.user.username }}</p>
   
        {{-- 👇 change from destroyComment --}}
        @can('CommentPolicy.delete', comment)
          <form action="{{ route('posts.comments.destroy', { id: comment.id }, { qs: { _method: 'DELETE' } }) }}" method="POST">
            {{ csrfField() }}
            <button type="submit" class="text-red-400 hover:text-red-600">Delete Comment</button>
          </form>
        @endcan
      </div>
    </div>
  @endeach

  {{-- 👇 change from viewCommentList --}}
  @cannot('CommentPolicy.viewList', post)
    <p class="text-gray-600">Comments for this post are turned off</p>
  @elseif (!post?.comments?.length)
    <p class="text-gray-400">No comments, be the first to comment!</p>
  @endcannot
</div>

Cleaning Up Main Actions

Now that we’ve moved our post and comment actions into policies and updated all authorization checks to use the policy versions, we can now remove all defined actions and hooks from our start/bouncer.ts file. Remember to leave the exported constant actions!

// start/bouncer.ts

export const { actions } = Bouncer

Wrapping Up

That’s a wrap! We’ve learned all about Bouncer actions and how to check user authorization using them. We’ve also now learned how we can group our actions into policies allowing for easier management of actions. Lastly, we’ve learned how we can use hooks in our policies and share hooks across policies by creating our own BasePolicy.

Hopefully, this series has helped provide some insight into how you can perform authorization checks within your AdonisJS application! Thank you all for viewing/reading this series and for your support!

Join The Discussion! (23 Comments)

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

  1. Commented 2 years ago

    Awesome like always!

    0

    Please sign in or sign up for free to reply

    1. Commented 2 years ago

      <3

      0

      Please sign in or sign up for free to reply

    2. Commented 2 years ago

      <3

      0

      Please sign in or sign up for free to reply

    3. Commented 2 years ago

      <3

      0

      Please sign in or sign up for free to reply

    4. Commented 2 years ago

      <3

      0

      Please sign in or sign up for free to reply

    5. Commented 2 years ago

      <3<3<3

      0

      Please sign in or sign up for free to reply

    6. Commented 2 years ago

      <3<3<3

      0

      Please sign in or sign up for free to reply

    7. Commented 2 years ago

      <3<3<3

      0

      Please sign in or sign up for free to reply

    8. Commented 2 years ago

      <3<3<3

      0

      Please sign in or sign up for free to reply

    9. Commented 2 years ago

      <3<3<3

      0

      Please sign in or sign up for free to reply

    10. Commented 2 years ago

      <3<3<3

      0

      Please sign in or sign up for free to reply

    11. Commented 2 years ago

      <3<3<3

      0

      Please sign in or sign up for free to reply

    12. Commented 2 years ago

      Thank you, Dawid!! :)

      0

      Please sign in or sign up for free to reply

  2. Anonymous (GorillaAinslie198)
    Commented 1 year ago

    Awesome. could you make or update this post like this link https://laravel-news.com/authorization-gates, I want to know how to implement function hasAccess in the model if use adonisjs

    1

    Please sign in or sign up for free to reply

    1. Commented 1 year ago

      Yeah, I'll add this to my lesson to-do list as this is a different approach than what's discussed in this lesson. Thank you!

      0

      Please sign in or sign up for free to reply

  3. Commented 1 year ago

    what if a hacker access the database and change the role . How can we make it more secure.

    0

    Please sign in or sign up for free to reply

    1. Commented 1 year ago

      The goal should be to block the hacker from accessing your database in the first place. If they can access your database, what's to stop them from just dumping all the tables? I wouldn't worry about a hacker changing themselves to an admin, but rather focus on blocking the hacker from having access to your database.

      This would include ensuring your database is IP/SSH restricted, protected by a strong password, protected by a firewall (your whole app, really), etc. This topic, if you'd like to learn more, falls under "DevOps".

      0

      Please sign in or sign up for free to reply

  4. Anonymous (AntelopeMaryanne481)
    Commented 1 year ago

    thanks for this tuts tom

    1

    Please sign in or sign up for free to reply

    1. Commented 1 year ago

      Thanks for watching/reading!!

      0

      Please sign in or sign up for free to reply

  5. Anonymous (RoseCornela847)
    Commented 1 year ago

    I am getting this error:
    Cannot use "class UserPolicy extends Bouncer_2.BasePolicy {

    0

    Please sign in or sign up for free to reply

    1. Commented 1 year ago

      Hard to tell what it could be without more context. I'd say to check and make sure your BasePolicy is importing from the correct location.

      0

      Please sign in or sign up for free to reply

  6. Anonymous (BirdJenine92)
    Commented 1 year ago

    Cannot use "class UserPolicy extends Bouncer_2.BasePolicy {

    I am getting this error. I have properly define the policy inside start/bouncer file.

    Could you tell what might have gone wrong?

    0

    Please sign in or sign up for free to reply

    1. Commented 1 year ago

      I'd say to double-check and make sure your BasePolicy is importing from the correct location. Sometimes VS Code likes to reach into the build folder instead of using the IoC definitions. It should be: import { BasePolicy } from '@ioc:Adonis/Addons/Bouncer'

      0

      Please sign in or sign up for free to reply