Authorization determines which actions a user can perform on an application
There are a lot of alternatives which can help us solve this functionality issue, now I will show you the Pundit way (maybe the Pundit-Enums way).
The Pundit's paradigm
For Pundit, everything revolves around policies; as we already know, a policy is:
A statement of intent, and it is implemented as a procedure or protocol.
So, If I create a policy for a model inside an application, I will define its behavior (depending on the user’s permissions).
Let's see an example of this process....
We need defined rules
Our scenario is the following; a student community wants to create their own blogpost system and they need some roles to have better control of it. Luckily, they have a well-defined hierarchy, and they created only two roles: Writers and Moderators.
- A Writer is able to:
- Create, update and delete only their own posts.
- Read all published posts and own posts.
- A Moderator is able to:
- Access published posts
- They are not able to create, update or delete posts.
- Create, update and delete new users
In this example, we are going to create only the Post Policy. You can apply these principles in order to create the other Policy.
Now, let's code.
rails new blog
Let's suppose that we are at this point of the project
So, we have already created:
- User's table
- Post table
- Devise as user's authentication.
Note: If you want to see how I get at this point. Feel free to visit my repo
Add role to users
Note: If you already have a user-role association in your project, skip this part. I will just show you a fast alternative.
For this, we can use an enum to represent the user’s role. To do this we have to:
- Create a migration and add this column to the user's table
rails g migration AddRoleToUsers role:integer
- Don't forget run your migration
- Define the enum in the user's model
class User < ApplicationRecord
has_many :posts
enum role: %w[writer moderator]
# ...
end
Now we are able to use some ‘fancy’ methods, such as user.writer?
or user.moderator?
, and get a boolean value. This makes everything way easier.
Set up Pundit.
1. Installation
# Gemfile
#...
gem 'pundit'
#...
Run this in order to create your parent class. This will contain all the denied permissions.
rails g pundit:install
Now, If you do not define an action in a new policy, this will be denied. This scenario is better than allowing all the actions which have not been defined yet.
2. Create Post Policy
To create the Post Policy, we can use the generator that Pundit provides us with.
rails g pundit:policy post
Then we can define the Post
permissions:
class PostPolicy < ApplicationPolicy
def show?
record.published? || record.user == user
end
def create?
user.writer?
end
def update?
user.writer? && record.user == user
end
def destroy?
user.writer? && record.user == user
end
end
user
and record
?
In Pundit, our object model is called record
, and our current_user
is called user
. But if you want to change these names you will need to rewrite them in your constructor method. I do not recommend doing this. I prefer to keep this abstraction (it works for me). However, I will leave you an example, in case you want to try this out:
class PostPolicy < ApplicationPolicy
def initialize(user, record)
@current_user = user
@post = record
end
# ...
end
3. Apply Post restrictions in Controller
We have already defined the permissions, and now we have to apply this to the controller. We do it like this:
class PostsController < ApplicationController
# ...
def show
authorize @post
end
#...
end
We have to add this method everywhere we want to apply the PostPolicy.
Why aren't we using before_action
?
The authorize method automatically infers Post
since we will have a matching PostPolicy
class, and this instantiates this class, handling the current user and the given record. Then it infers from the action name, and in this case, it should call show?
at this point of the policy.
4. Remove some actions from views
We already have correctly defined our permissions by applying the Pundit authorization from the controller, but we might want to remove some buttons or links from our views
; maybe just to improve our interface or the user experience:
<% if policy(@post).create? %>
<%= link_to "New post", new_post_path %>
<% end %>
5. Create subsets of data (Scopes)
Remember that we want to limit the data that a Writer can see in their Post index view.
- A Writer can see all their posts as well as all previously published.
- A Moderator can see all the posts.
Fine, so we have two different queries: Post.where(published: true).or(user.posts)
and Post.all
. We can put this logic into the index method, but I guess it would be better to delegate it to our Post Policy. Because if we do this, we will be able to use this logic anywhere else on the app.
To accomplish this task, we only have to define the scope in our Post Policy:
class PostPolicy < ApplicationPolicy
# ...
class Scope < Scope
def resolve
return scope.where(published: true).or(user.posts) if user.writer?
return scope.all if user.moderator?
end
end
#...
end
class PostsController < ApplicationController
# ...
def index
@posts = policy_scope(Post.all)
end
#...
end
And that's all!!!
Add a useful Test Matcher
I'm fan of TDD , and If you visit this example you will see which matcher I used. To get fancy single line tests with RSpec, like this:
# ...
context 'when user has writer role' do
let(:role) { :writer }
it { should authorize(:create) }
context 'when the current user is the post owner' do
it { should authorize(:edit) }
it { should authorize(:destroy) }
end
context 'when the current user is not the post owner' do
let(:post) { create(:post, user: other_user) }
it { should_not authorize(:edit) }
it { should_not authorize(:destroy) }
end
end
context 'when user has moderator role' do
let(:role) { :moderator }
it { should_not authorize(:create) }
it { should_not authorize(:edit) }
it { should_not authorize(:destroy) }
end
# ...
Well, Why should we test this logic?
Because we can ensure that our code actually works, and we can easily get the documentation about our project like this:
If you enjoyed reading this blog, you may want to follow us on Twitter, so you have access to more content like this @weAreMagmalabs.
Thanks for reading my blogpost, and let me know your comments (if any).