User authorization without cancan(can)
Update 10 April 2018: I changed the code on application_controller
to accept a controller name as well as an action so you can check if a user is authorized to perform an action from any view.
Update 17 April 2018: At the bottom of this post, you will find modifications that will let you use this with namespaced classes.
I was recently working on a project that needed user authorization. I knew people who had used cancan before with some success so I started by looking through the repo and trying a small scale test. There were a few problems that made the gem not a perfect fit which I'll run through below as well as the solution I came up with.
Situation:
- Every resource behind the login page has a differing level of authorization
- There are at least 5 groups who have different permissions for different resources
- Users can belong to more than one group
- I only want to maintain one user model that can easily move members from one group to another
Options with cancan
It is possible to do one user model with multiple roles in cancan. The wiki lists a walk through on how to set this up, but this seemed a little involved and not exactly in keeping with the main use case from the Readme. Someone has extracted this model into a gem but it hasn't been updated in a few years and using a gem on top of a gem seemed to validate my gut feeling that this was more complicated than it needed to be.
Implementing it myself
Goals
- One user model, multiple roles
- A permissions hash that is easy to scan and modify
- A simple
before_action
on the controllers to check authorization - A view helper that can conditionally display items based on the current users level of permission
Goal 1: Assigning roles
I was already using Devise for user authentication so I added a user role column to the model for roles.
db/migrations/...
class AddRoleToUsers < ActiveRecord::Migration[5.2]
def change
add_column :users, :role, :string, array: true, default: []
end
end
In the future I thought we might want to have the option to initialize users with a different class so I set up a before_save
hook on the User
model instead of defaulting the role in the migration.
app/models/user.rb
class User < ApplicationRecord
before_save :assign_user_role
private
def assign_user_role
self.role.push('user') unless self.role.include?('user')
end
Now we can create users with roles and know that they will always be at least assigned the role of 'user' and we don't duplicate that role if it's passed in create.
Goal 2: Permissions hash
I wanted the permissions hash to be easy to scan at a glance and see what groups were authorized to do what. Moreover, I wanted it to be easy to call using the current_user
helper that comes built in with Devise.
To do this, I created a module that can be mixed in to User
.
lib/modules/policy.rb
module Policy
# All members are users therefore any action for which a user is authorized
# can be performed by all members
def authorize(role, controller, action)
policies = {
wiki: {
show: %w[user],
index: %w[user],
edit: %w[admin moderator],
update: %w[admin moderator],
new: %w[admin moderator],
create: %w[admin moderator],
destroy: %w[admin moderator],
dynamic: %w[user]
}
if !((policies[controller.to_sym][action.to_sym] & role).empty?)
return true
else
return false
end
end
end
The hash is pretty easy to scan. You can see at a glance what the controller is and what the actions are. In this case, we have 7 RESTful actions as well as one custom action that gathers up wiki topics dynamically.
To authorize the user, it looks up the controller and action in policies, then uses &
to check for set intersection, call .empty
and negate the result using !
. If the set intersection finds any roles in common, .empty
will return false which means the user is authorized in our case, so we use the bang to negate and it reads a bit more naturally since authorize should return true if the user role is contained in the authorized roles.
Then, include the module on User
app/models/user.rb
class User < ApplicationRecord
include Policy
before_save :assign_user_role
...
If you like to save your modules under /lib
remember to include it in the auto-load path
config/application.rb
...
config.autoload_paths += %W(#{config.root}/lib/modules)
...
Goal 3: A simple before_action
Before the before_action
can be called it first needs to be defined. In this case, every action in the site requires authorization so I defined the action on ApplicationController
app/controllers/application_controller.rb
def authorized?(action="#{action_name}", controller="#{controller_name.singularize}")
current_user.authorize(current_user.role, controller, action)
end
def authorize
redirect_to unauthorized_path unless authorized?
end
This takes advantage of string interpolation to take the values being passed around from the controller name and controller action.
Jumping ahead just a bit, you can see that I set a default value for action and controller so it can be called on the controller without any arguments, but still have the option to pass a specific action and controller in the view. redirect_to
could have been called directly on the authorized?
method if you don't need this, but by separating them out you will be able to use authorized? :edit
as a view helper later or authorized? :edit, :controller_name
if you want to check against another controller.
Now on the controller a simple before_action
is needed:
app/controllers/your_controller.rb
class WikisController < ApplicationController
before_action :authorize
...
Goal 4: A view helper
All the above works great to lock down your controller actions, but you don't want users to constantly be being sent to an unauthorized page every time they click on a link they don't have the permissions to do anything with. To clean this up using the authorized?
method, it is first necessary add authorized?
as a helper method:
app/controllers/application_controller.rb
...
helper_method :authorized?
...
Now, let's say in the view you have some users who are allowed to edit entries, some that are allowed to delete them and some that can do neither, this has you covered.
app/views/controller_name/show.html.erb
<%= @item.title %>
<% if authorized? :edit %>
<%= link_to edit_item_path(@item) %>
<% end %>
<% if authorized? :delete %>
<%= button_to "Delete", { action: "destroy", id: @item.id }, method: :delete %>
<% end %>
As written, this will default to the controller that is behind that view, but I find it useful on pages that are collecting different links to be able to authorize against a different controller. For example, if your show or index page is pulling in any associated data, you can drop an edit or delete button for that association and save the user a page load.
<% if authorized? :edit, :controller_name %>
<%= link_to edit_item_path(@item) %>
<% end %>
Update: One things that I ran into while using this is is does not work with namespaced classes. The reason it doesn't work with namespaced classes is that the method controller_name
that is used on the original example actually strips outs modulization. PS: learning that Rails has a demodulize
method was a surprise for me; it seems like there are many weird and wonderful things in ActiveSupport.
Not the most elegant way, but this will make namespaced classes work:
def authorized?(action="#{action_name}", controller="#{self.class.to_s.gsub('::', '_').gsub('Controller', '').underscore.singularize.downcase}")
current_user.authorize(current_user.role, controller, action)
end