Unread Notifications

Latest Notifications

No Notifications

You're all set! Start a discussion by leaving a comment on a lesson or replying to an existing comment.

AdonisJS Bouncer

Implementing Authorization Actions

5 MIN READ
2 MONTHS AGO

We'll take what we learned about AdonisJS Bouncer actions in the last lesson to finalize the needed authorization checks for our blog application.

Watch on YouTube

Since the contents of this lesson are mostly altering code within the application in order to define and enforce out authorization checks, I’m going to recommend the video version of this lesson. As for the written portion. I’ll just be focusing on summarizing the code changes needed.

Bouncer Actions

Below you’ll find the final list of our defined Bouncer actions.

// start/bouncer.ts

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

    // log when users are authorized or denied authorization
    actionResult.authorized
      ? Logger.info(`${userType} was authorized to ${actionName}`)
      : Logger.info(`${userType} was denied to ${actionName} for ${actionResult.errorResponse}`)
  })

  /* POST
  /***************************************/
  .define('createPost', (user: User) => {
    // allow editors and admins (admins handled in before hook)
    return user.roleId === Role.EDITOR
  })
  .define('viewPost', (user: User | null, post: Post) => {
    // if post author or admin (before hook) allow
    if (post.userId === user?.id) {
      return true
    }

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

    return true
  }, { allowGuest: true })
  .define('editPost', (user: User, post: Post) => {
    // allow post author and admins (before hook)
    return post.userId === user.id
  })
  .define('destroyPost', (user: User, post: Post) => {
    // allow post author and admins (before hook)
    return post.userId === user.id
  })

  /* COMMENT
  /***************************************/
  .define('viewCommentList', (user: User | null, post: Post) => {
    // allow all + guests to view if post is published
    return post.isPublished
  }, { allowGuest: true })
  .define('createComment', (user: User, post: Post) => {
    // allow all to view if post is published
    return post.isPublished
  })
  .define('editComment', (user: User, comment: Comment) => {
    // allow comment creator
    return comment.userId === user.id
  })
  .define('destroyComment', (user: User, comment: Comment) => {
    const allowedRoles = [Role.MODERATOR, Role.EDITOR]

    // allow comment creator + moderators + editors to delete
    return comment.userId === user.id || allowedRoles.includes(user.roleId)
  })

Post Controller

Below is the final PostsController containing the authorization checks needed for both posts and viewing comments.

// app/Controllers/Http/PostsController.ts

export default class PostsController {
  public async index({ view }: HttpContextContract) {
    const posts = await Post.query()
      .preload('user')
      .where('isPublished', true)

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

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

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

  public async store({ request, response, auth }: HttpContextContract) {
    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()

    // 👇 we moved the comments out of the post query into here
    // so we can only load if user is authorized to view them
    if (await bouncer.allows('viewCommentList', post)) {
      await post.load('comments', query => query.preload('user'))
    }

    await bouncer.authorize('viewPost', post)

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

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

    await bouncer.authorize('editPost', post) // 👈

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

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

    await bouncer.authorize('editPost', 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.authorize('destroyPost', post) // 👈

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

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

One thing to note here is that we’re checking the authorizations after we query the needed post record but before we validate or do anything else within the method. The reason for this when an unauthorized user sends invalid data, we want to tell them they’re unauthorized instead of having our validation fail and tell them their information is invalid. Overall it saves everyone time that’d be otherwise wasted. We don’t need to validate when the user is unauthorized anyways and they don’t need to correct their form to find out they’re unauthorized.

Comments Controller

Below is the final CommentsController containing the authorization checks needed for our comments.

// app/Controllers/Http/CommentsController

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

    await bouncer.authorize('createComment', 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.authorize('editComment', 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.authorize('destroyComment', comment) // 👈

    await comment.delete()

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

Again, we’re checking whether the user is authorized after we query our comment record, so we have it to provide to our Bouncer action, but before we do anything else within the method.

Front-End Changes

Below you can find the applicable front-end changes we did using the Edge template engine.

Post Edit & Delete

{{-- 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">
    {{-- only show edit if the user can edit --}}
    @can('editPost', post)
      <a href="{{ route('posts.edit', { id: post.id }) }}">Edit Post</a>
    @endcan

    {{-- only show delete if user can destroy --}}
    @can('destroyPost', 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>

Post Comments

<div class="mt-3 pt-3 border-t border-gray-300">
  <h3 class="text-lg font-semibold">Comments</h3>

  {{-- only show comment create form, when user is allowed --}}
  @can('createComment', 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>

        {{-- only show delete when user can destroy --}}
        @can('destroyComment', 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

  {{-- if user can't view comments, inform them they're turned off --}}
  @cannot('viewCommentList', 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>

Comment

  1. claudio-barca
    Commented 6 months ago

    Hi, thank you for these best series on AdonisJS, are very precise and clear.
    On this case, the Partners table is One to Many Customers.
    1 partner = n customers.
    The result of query is

     {           "id": 10,
    “customer_name” : “xxxxxxxxxxx”

                "partner": {
                    "partner_name": "yyyyyyyyyy"
                    …
                }
    }

    I need to pass to the bouncer the “partner” object.

        const customers = await Customer.query().preload('partner').paginate(page, perPage)
        await bouncer.authorize('viewCustomerList', —> partner )

    How to ?
    Grazie

    1. tomgobich
      Commented 5 months ago

      Thank you, Claudio!

      I make assumptions on what you’re trying to do here, please disregard if these assumptions are wrong!

      When displaying lists I would only use Bouncer to determine if the user can view the list as a whole instead of individual items within a list. If you attempt to use it for individual items, you’ll end up with pagination pages containing a varied number of records per page because you’ll be removing items from a page the user shouldn’t be viewing. Or you could end up querying tons of records you don’t need.

      For example, if I should only be viewing my customers as opposed to everyone’s customer, if I try to use Bouncer to omit the records I shouldn’t be seeing I could end up with a paginated page only displaying one record because I wasn’t authorized to see any of the other records my query pulled for the page.

      Instead, for individual items within a list, I’d use query filtering using where statements to omit the records the user shouldn’t be seeing. This way, your query will be omitting what the user shouldn’t be seeing from the list and you won’t need to remove items from an already queried page.

      So, depending on what all columns you have at your disposal, you could do something like this:

      const customers = await Customer.query()
        .where('ownerId', auth.user!.id) // restrict records to auth user
        .preload('partner')
        .paginate(page, perPage)

      Here’s the answer to your question in case my assumptions are wrong ;)

      Since I don’t know your exact use case, I believe you should be able to get an array of your queried customer’s partners by doing something like this:

      const partners = customers.map(customer => customer.partner)

      You could alternatively pass your customers variable into the authorize call and grab the partner on each customer in a loop.

      customers.forEach(customer => {
        const partner = customer.partner
      })

      Hope this helps!!

Prepared By

Tom Gobich

Burlington, KY

Owner of Adocasts, JavaScript developer, educator, PlayStation gamer, burrito eater.

Visit Website