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

  1. One user model, multiple roles
  2. A permissions hash that is easy to scan and modify
  3. A simple before_action on the controllers to check authorization
  4. 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