Authorization
In the previous chapter, we improved DevShow's navigation and styling. Now let's add the ability for users to edit and delete their own posts and comments. Right now, any logged-in user could modify anyone's content if we added those features—we need to add authorization checks to prevent this.
Overview
To handle permissions properly, we'll use AdonisJS's Bouncer package. Bouncer lets you organize authorization logic into policies—classes where each method represents a permission check. For example, a PostPolicy can have an edit method that checks if a user can edit a specific post.
Instead of scattering permission checks throughout your controllers, you define the rules once in a policy and use them everywhere. In this chapter, we'll install Bouncer, create policies for posts and comments, and implement edit and delete features with proper authorization.
Installing Bouncer
Let's install the Bouncer package:
npm install @adonisjs/bouncer
Now configure it:
node ace configure @adonisjs/bouncer
This configuration command does a few things for you:
- Creates an
app/abilities/main.tsfile where you can define authorization abilities (we won't need this file for now, so don't worry about it) - Registers a middleware that initializes Bouncer for every HTTP request
- Makes the
bouncerobject available on theHttpContext, so you can use it in your controllers
You're all set! Now let's create our first policy.
Creating the PostPolicy
Policies are classes where each method represents a permission check. Let's create a policy for posts:
node ace make:policy post
Open the generated file and add permission checks for editing and deleting posts.
import User from '#models/user'
import Post from '#models/post'
import { BasePolicy } from '@adonisjs/bouncer'
export default class PostPolicy extends BasePolicy {
/**
* Only the post owner can edit their post
*/
edit(user: User, post: Post) {
return user.id === post.userId
}
/**
* Only the post owner can delete their post
*/
delete(user: User, post: Post) {
return user.id === post.userId
}
}
Each policy method receives the currently logged-in user as the first parameter, followed by the resource being checked (in this case, the post). The method returns true if the user is allowed to perform the action, or false if they're not. Here, we're simply checking if the user's ID matches the post's userId.
You might notice that edit and delete have identical logic right now. Even though they're the same, keeping them separate gives you flexibility. Later, you might decide that posts can't be edited after 24 hours, or that admins can delete any post but can't edit them. Having separate methods makes these kinds of changes easier.
Creating the CommentPolicy
Now create a policy for comments:
node ace make:policy comment
Add the delete permission check.
import User from '#models/user'
import Comment from '#models/comment'
import { BasePolicy } from '@adonisjs/bouncer'
export default class CommentPolicy extends BasePolicy {
/**
* Only the comment owner can delete their comment
*/
delete(user: User, comment: Comment) {
return user.id === comment.userId
}
}
Perfect! Now let's put these policies to work.
Adding edit functionality
-
Create the update validator
We'll add a validator for updating posts. Since we already have a
validators/post.tsfile for creating posts, we'll add the update validator there too. A single validator file can export multiple validators—this keeps related validation logic organized together.Open your existing post validator file and add the update validator.
app/validators/post.tsimport vine from '@vinejs/vine' export const createPostValidator = vine.compile( vine.object({ title: vine.string().minLength(3).maxLength(255), url: vine.string().url(), summary: vine.string().minLength(80).maxLength(500), }) ) /** * Same validation rules as creating a post */ export const updatePostValidator = vine.compile( vine.object({ title: vine.string().minLength(3).maxLength(255), url: vine.string().url(), summary: vine.string().minLength(80).maxLength(500), }) )We're using the same validation rules for both creating and updating posts. In many applications, you might want different rules—for example, allowing updates without certain required fields—but for DevShow, the requirements are the same.
-
Add controller methods
We'll add two controller methods:
editto show the edit form, andupdateto handle the form submission. Both methods will use Bouncer to check if the current user is allowed to modify the post before performing any action.app/controllers/posts_controller.tsimport type { HttpContext } from '@adonisjs/core/http' import Post from '#models/post' import { createPostValidator, updatePostValidator } from '#validators/post' import PostPolicy from '#policies/post_policy' export default class PostsController { // ... existing methods (index, create, store, show) /** * Show the edit form */ async edit({ bouncer, params, view }: HttpContext) { const post = await Post.findOrFail(params.id) // Check if the current user can edit this post await bouncer.with(PostPolicy).authorize('edit', post) return view.render('posts/edit', { post }) } /** * Update the post */ async update({ bouncer, params, request, response, session }: HttpContext) { const post = await Post.findOrFail(params.id) // Check authorization again—someone could send a PUT request directly await bouncer.with(PostPolicy).authorize('edit', post) // Validate and update the post const data = await request.validateUsing(updatePostValidator) await post.merge(data).save() session.flash('notification', 'Post updated successfully') return response.redirect().toRoute('posts.show', { id: post.id }) } }The key part here is
bouncer.with(PostPolicy).authorize('edit', post). This line:- Calls the
editmethod in ourPostPolicy - Passes the the post to the policy method
- If the policy returns
false, Bouncer automatically throws a 403 Forbidden error - If the policy returns
true, the code continues executing
Notice we check authorization in both methods. Even though
editchecks permissions, someone could bypass the form and send a PUT request directly to theupdateroute. Always verify permissions before performing sensitive actions. - Calls the
-
Register the routes
Now let's register the routes for editing posts. Open your routes file.
start/routes.tsimport router from '@adonisjs/core/services/router' import { middleware } from '#start/kernel' import { controllers } from '#generated/controllers' router.get('/posts', [controllers.Posts, 'index']) router.get('/posts/create', [controllers.Posts, 'create']).use(middleware.auth()) router.post('/posts', [controllers.Posts, 'store']).use(middleware.auth()) router.get('/posts/:id', [controllers.Posts, 'show']) router.get('/posts/:id/edit', [controllers.Posts, 'edit']).use(middleware.auth()) router.put('/posts/:id', [controllers.Posts, 'update']).use(middleware.auth()) router.post('/posts/:id/comments', [controllers.Comments, 'store']).use(middleware.auth())We need two routes—one to show the edit form and another to handle the form submission. Both require authentication.
-
Create the edit form template
Create the edit form template:
node ace make:view posts/editNow add the form markup.
resources/views/posts/edit.edge@layout() <div class="form-container"> @!link({ route: 'posts.show', routeParams: post, text: '‹ Back to post' }) <h1>Edit Post</h1> @form({ route: 'posts.update', routeParams: post, method: 'PUT' }) <div> @field.root({ name: 'title' }) @!field.label({ text: 'Post title' }) @!input.control({ value: post.title }) @!field.error() @end </div> <div> @field.root({ name: 'url' }) @!field.label({ text: 'URL' }) @!input.control({ type: 'url', value: post.url }) @!field.error() @end </div> <div> @field.root({ name: 'summary' }) @!field.label({ text: 'Short summary' }) @!textarea.control({ rows: 4, value: post.summary }) @!field.error() @end </div> <div> @!button({ text: 'Update Post', type: 'submit' }) </div> @end </div> @endThis form is similar to the create form, with a few key differences:
- HTTP method: Uses
method: 'PUT'to indicate this is an update request, not a POST for creating new data - Pre-filled values: Each field shows the current post data (
value: post.title,value: post.url,text: post.summary) so users can see what they're editing - Route: Submits to the
posts.updateroute with the post ID included in the URL
- HTTP method: Uses
-
Add edit button to post detail page
Now add an Edit button to the post detail page. Open your
posts/show.edgetemplate.resources/views/posts/show.edge@layout() <div class="container"> <div class="post-header"> @!link({ route: 'posts.index', text: '‹ Go back to posts listing' }) {{-- Show edit button only to the post owner --}} @can('PostPolicy.edit', post) <span>.</span> @!link({ route: 'posts.edit', routeParams: post, text: 'Edit', }) @end </div> <h1>{{ post.title }}</h1> <div class="post"> {{-- ... existing post info ... --}} </div> </div> @endThe
@cantag checks the policy method in your template, similar to howbouncer.authorize()works in controllers:- First parameter (
'PostPolicy.edit') - Specifies which policy and method to use - Second parameter (
post) - The resource being checked, passed to the policy method - When check fails - Everything between
@canand@endis hidden from the HTML output
Non-owners won't even see the Edit button in the page source. If someone tries to visit the edit URL directly, they still get a 403 error from the controller's authorization check.
- First parameter (
Visit a post you created and you'll see the Edit button. Click it and try updating your post!
Adding delete functionality
-
Add controller method
Deleting a post is simpler than editing because there's no form to show—just a button that submits a DELETE request. Let's add the controller method to handle deletions.
app/controllers/posts_controller.tsimport type { HttpContext } from '@adonisjs/core/http' import Post from '#models/post' import { createPostValidator, updatePostValidator } from '#validators/post' import PostPolicy from '#policies/post_policy' export default class PostsController { // ... existing methods /** * Delete a post */ async destroy({ bouncer, params, response, session }: HttpContext) { const post = await Post.findOrFail(params.id) // Check if the user can delete this post await bouncer.with(PostPolicy).authorize('delete', post) await post.delete() session.flash('notification', 'Post deleted successfully') return response.redirect().toRoute('posts.index') } }The pattern is familiar by now: find the post, authorize the action using the policy, perform the deletion, flash a success message, and redirect. After deleting a post, we redirect to the posts index page since the post detail page no longer exists.
-
Register the route
Now register the delete route.
start/routes.tsimport router from '@adonisjs/core/services/router' import { middleware } from '#start/kernel' import { controllers } from '#generated/controllers' router.put('/posts/:id', [controllers.Posts, 'update']).use(middleware.auth()) router.delete('/posts/:id', [controllers.Posts, 'destroy']).use(middleware.auth()) router.post('/posts/:id/comments', [controllers.Comments, 'store']).use(middleware.auth()) -
Add delete button to post detail page
Add a delete button next to the edit button in your post detail template.
resources/views/posts/show.edge@layout() <div class="container"> <div class="post-header"> @!link({ route: 'posts.index', text: '‹ Go back to posts listing' }) {{-- Show edit button only to the post owner --}} @can('PostPolicy.edit', post) <span>.</span> @!link({ route: 'posts.edit', routeParams: post, text: 'Edit', }) @end {{-- Show delete button only to the post owner --}} @can('PostPolicy.delete', post) <span>.</span> @form({ route: 'posts.destroy', routeParams: post, method: 'DELETE' }) @!button({ text: 'Delete', class: 'destructive' }) @end @end </div> {{-- ... rest of the template ... --}} </div> @endA few important things about this delete button:
- Form wrapper - We need a form with
method: 'DELETE'because HTML forms only support GET and POST natively - DELETE simulation - AdonisJS uses a POST request with a special field to simulate the DELETE method
- Authorization check - The
@can('PostPolicy.delete', post)tag ensures only the post owner sees the button
Try it out! Visit a post you created and you'll see both Edit and Delete buttons. Visit a post created by someone else—no buttons appear.
- Form wrapper - We need a form with
Adding comment deletion
-
Add controller method
Let's add the controller method for deleting comments.
app/controllers/comments_controller.tsimport type { HttpContext } from '@adonisjs/core/http' import Comment from '#models/comment' import { createCommentValidator } from '#validators/comment' import CommentPolicy from '#policies/comment_policy' export default class CommentsController { // ... existing store method /** * Delete a comment */ async destroy({ bouncer, params, response, session }: HttpContext) { const comment = await Comment.findOrFail(params.id) // Load the post so we can redirect back to it await comment.load('post') // Check if the user can delete this comment await bouncer.with(CommentPolicy).authorize('delete', comment) await comment.delete() session.flash('notification', 'Comment deleted successfully') return response.redirect().toRoute('posts.show', { id: comment.post.id }) } }Here's what this method does:
- Finds the comment using the ID from the route parameter
- Loads the post relationship so we have access to
comment.post.idfor redirecting after deletion - Checks authorization with
CommentPolicy—if the user doesn't own the comment, they get a 403 error - Deletes the comment from the database
- Redirects back to the post detail page where the comment was displayed
-
Register the route
Now register the delete route for comments.
start/routes.tsimport router from '@adonisjs/core/services/router' import { middleware } from '#start/kernel' import { controllers } from '#generated/controllers' // ... existing routes router .delete('/comments/:id', [controllers.Comments, 'destroy']) .use(middleware.auth()) -
Add delete buttons to comments
Finally, add delete buttons to the comments list in your post detail template.
resources/views/posts/show.edge@layout() <div class="container"> {{-- ... post content ... --}} <div class="posts-comments"> <h2>Comments</h2> <div class="post-comment-form"> {{-- ... comment form ... --}} </div> @each(comment in post.comments) <div class="comment-item"> <div class="comment-header"> <div class="comment-content"> <p>{{ comment.content }}</p> <p class="comment-meta"> By {{ comment.user.fullName }} on {{ comment.createdAt.toFormat('MMM dd, yyyy') }} </p> </div> {{-- Show delete button only to the comment owner --}} @can('CommentPolicy.delete', comment) @form({ route: 'comments.destroy', routeParams: [comment.id], method: 'DELETE' }) @!button({ type: 'submit', text: 'Delete', size: 'sm' }) @end @end </div> </div> @else <p>No comments yet.</p> @end </div> </div> @endHow the comment delete button works:
- Policy check - The
@cantag checksCommentPolicyto determine if the current user can delete each comment - Visibility - Only the comment's author will see the delete button
- Action - The button submits a DELETE request to the
comments.destroyroute
Visit a post with comments you created and you'll see delete buttons next to your comments. Try viewing comments from other users—no delete buttons will appear.
- Policy check - The
What you built
You've successfully added authorization to DevShow using Bouncer's policy system. Here's what you accomplished:
- Created
PostPolicyandCommentPolicyto centralize all permission logic in one place - Used
bouncer.with(Policy).authorize()in controllers to enforce permissions before allowing actions - Implemented the complete edit post feature with form, validation, and authorization
- Added delete functionality for both posts and comments with proper permission checks
- Used the
@cantag in templates to conditionally show action buttons only to authorized users
The key benefit of this approach is that your authorization logic is reusable and maintainable. When you need to change a permission rule, you update it in one place (the policy), and it automatically applies everywhere you use that policy—in controllers, templates, and anywhere else in your application.