AdonisJS Bouncer Lesson 3

Implementing Authorization Actions

tomgobich avatar
tomgobich
5 min
Quick Summary

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

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>