Monday, April 4, 2011

Role Based Authorization in Rails

Previously, I created an OpenID based login system. Now I want to create a role based authorization system. What do I mean by role based authorization? Well, in many systems you have actions that you want to protect so that only admins can perform them. Obviously, we could implement this by adding an "admin" flag to our User model. However, sometimes, we have more than just two classes of users. For example, if you have a website with comments, you might want to have a class of users who aren't full admins, but do have authority to filter comments for spam.

A simple way to accomplish all of this is to have Role model and then let each user have 0 or more roles associated with them. Here is how you can do this, assuming that you've already got an authentication system as described in the previous post.

Data Model
First, create database tables, which will just consist of a Role table with a name column, and then a mapping table to map roles to users.

rails generate model Role name:string
rails generate model RolesUser role_id:integer user_id:integer
rake db:migrate

Then tell the User model about the roles, by adding the following line to the User class (app/models/user.rb):
has_and_belongs_to_many :roles, uniq=>true
Role Views
Now that we have a Role model, we need a way to add and delete roles. Since this is functionality that we'll want to reserve only to top level admins, let's put this functionality in an admin directory.

rails generate controller Admin::Roles index

This generated the controller and view, but not the right routes. So delete the

get "roles/index"

line that was added to routes.rb file and replace it with:
namespace :admin do
  resources :roles, :only=>[:index, :create, :destroy]
end
This allows a showing of roles and the RESTful creation and destruction of roles. Since roles are just a name, we don't really need the separate show, edit, and update paths.

The controller (at app/controllers/admin/roles_controller.rb) looks like:
class Admin::RolesController < ApplicationController

  def index
    @roles = Role.all
    @new_role = Role.new
  end

  def create
    Role.create params[:role]
    redisplay_roles
  end

  def destroy
    Role.find(params[:id]).destroy
    redisplay_roles
  end

  private

  def redisplay_roles
    redirect_to admin_roles_path
  end
end
The method redisplay_roles is a placeholder for now. A little bit further down we'll modify this to support adding of roles via Ajax calls.

So now we have to create the HTML, app/views/admin/roles/index.html.haml
%h1 Roles
= render 'role_list'
%br
= form_for [:admin, @new_role] do |f|
  = f.label :name, 'Name of new role:'
  = f.text_field :name
  = f.submit
There are two things of note here. On line 4, the array [:admin, @new_role] results in the URL for creating a Role object in the admin namespace, which is what we want. On line 2, we render the partial 'role_list' (app/views/admin/roles/_role_list.html.haml), which is shown below. The reason it is a partial is to make it easier to Ajaxify everything.
#RoleList
  %h2 There #{is_pluralize(@roles.length, 'role')}
  %ul
    - @roles.each do |role|
      %li
        %b= role.name
        [#{link_to 'x', [:admin, role], :method=>:delete, :confirm=>'You sure?'}]
is_pluralize on line 2 is a helper function I wrote (in application_helper.rb) that uses the appropriate form of to be along with the pluralized noun. It looks like:
def is_pluralize(count, noun)
  verb = (count == 1) ? "is" : "are"
  "#{verb} #{pluralize(count, noun)}"
end
The rest of the partial is just looping through the roles and showing them. On line 7, a link to the destroy action is created, by specifying the method as 'delete'.

AJAX
The above is all you need to be able to add and delete roles. Since we are redirecting back to the index page after each add or delete, I thought it would be nice to make those Ajax actions, and just stay on the page. Turns out this is pretty easy.

As a first step, if you haven't already, install jQuery. (yes you could use prototype or many other javascript libraries, but jQuery is what I am using, so it's what I'll describe.) To do this, put the line

gem 'jquery-rails'

in your Gemfile and then run the commands:

bundle install
rails g jquery:install

Now that jQuery is installed, modify the add/remove calls to be AJAX calls by adding the parameter ":remote=>true" to the link_to call on line 7 of _role_list.html.haml and to the form_for call on line 4 of index.html.haml.

Now we modify redisplay_roles to look like
def redisplay_roles
  respond_to do |format|
    format.html { redirect_to admin_roles_path }
    format.js {
      @roles = Role.all
      render :redisplay_roles
    }
  end
end
Line 3 handles the case if the method is called in a non-Ajax way. Line 5 ensures that @roles variable is set. Line 6 is where the magic happens. Rather than returning html like normal, we will return javascript, so we create a view file app/views/admin/roles/redisplay_roles.js.haml. This javascript HAML file just has one line:
$('#RoleList').replaceWith("#{escape_javascript(render :partial=>'role_list')}")
$('#RoleList') is the jQuery selector to choose the div we are modifying and replaceWith is a jQuery function which will replace the contents of that div with the argument that is passed in. To create that argument, we just render the partial 'role_list' again.

With just those simple changes, now the adding and removing of roles is done via Ajax calls.

User Views
Now that we can create roles, we need to be able to add roles to users. This will be very similar to what we did with Roles above. The command:

rails generate controller Admin::Users index show

generates our controllers and views. Again, we will delete what got added to routes.rb and replace it with:
namespace :admin do
  resources :users do
    member do
      post :add_role
      delete :delete_role
    end
  end
  resources :roles, :only=>[:index, :create, :destroy]
end
This includes the route for Roles from above.  While we aren't using the edit and update routes, we will leave them in for future enhancements. We are also adding the routes add_role and delete_role which will do what you'd expect. The users_controller looks like:
class Admin::UsersController < ApplicationController
  before_filter :load_user, :except=>[:index]

  def index
    @users = User.all
  end

  def show
  end

  def add_role
    role = Role.find_by_name params[:role]
    @user.roles.push role if role
    redisplay_roles
  end

  def delete_role
    @user.roles.delete(Role.find params[:role])
    redisplay_roles
  end

  private

  def load_user
    @user = User.find params[:id]
  end

  def redisplay_roles
    respond_to do |format|
      format.html { redirect_to [:admin, @user] }
      format.js { render :redisplay_roles }
    end
  end
end
The index.html.haml just shows a list of all the users with links to show them. 
%h1 Users
%h2 There #{is_pluralize(@users.length, 'user')}
%ul
  - @users.each do |user|
    %li= link_to user.email, [:admin, user]
show.html.haml looks like:
%h1 User
%b Id:
= @user.identifier_url
%br
%b Name:
#{@user.last_name}, #{@user.first_name}
%br
%b Email:
= @user.email
%hr
%h2 Roles
= render :partial=>'role_list'
#addRole
  = form_tag [:add_role, :admin, @user], :remote=>true do
    = label_tag :role, 'Add Role:'
    = text_field_tag :role
    = submit_tag 'Add'
%hr
= link_to 'Return to User List', [:admin, User]
Just like above, we put the roles listing in a partial. _role_list.html.haml looks very similar to the one in the roles view:
#RoleList
  This user has #{pluralize @user.roles.length, 'role'}
  %ul
  - @user.roles.each do |role|
    %li
      = role.name
      [#{link_to 'x', delete_role_admin_user_path(@user, :role=>role.id), :method=>:delete, :confirm=>'Are you sure?', :remote=>true}]
which means that redisplay_roles.js.haml is identical to the Roles version of this. I suppose I could pull this into the shared directory and let it be shared, but I am not convinced that it will always remain identical, so I'll leave the duplication for now.

Authorization
Now that we can create roles, and add users to roles we need a way to use this information. First we'll add a method to User to determine if a user has any of a given role or roles. I would like this to work if the passed in roles are Role objects or strings or symbols defining the role name. To do this I overrode to_s in Role
def to_s
  self.name
end
and added the following methods to User
def has_role?(*role_names)
    self.roles.index {|role| includes_role role_names, role}
  end

  def includes_role(role_list, role)
    role_list.index {|r| r.to_s == role.name}
  end
As you can see, has_role? takes a variable number of roles and returns true if the user has any of those roles. Now that we can make this determination, we will create a method called ensure_role. We could place it in the controller, but since we will be using it from multiple controllers we will be placing it in the authentication_helper.rb that was created in the previous post. It looks like:
def ensure_role(*role_names)
    if signed_in?
      role_names.push 'admin'
      unless current_user.has_role? *role_names
        flash[:error] = 'You do not have permission to view this page'
        redirect_to actions_index_path
      end
    else
      ensure_signed_in
    end
  end
On line 3, we push the role 'admin' onto the list of roles. We do this, because we want the 'admin' role to have all permissions and this way we don't have to explicitly list it every time we are ensuring a role. We add the line
before_filter { ensure_role 'admin' }
to our controllers in the admin directory to ensure that only users with the admin role can access these pages. Technically we could leave off the 'admin' argument, since it is implicitly added, but I think it makes the controller much more readable to have it explicitly there in the case where 'admin' is the only role allowed.

Rake Task
Here's a brief quiz. Do you see the problem that we've created?

We now have a chicken and egg problem. You can't create an admin role or assign it to yourself unless you already have it. The way we'll solve this is by creating rake tasks that you can run at the command line to create roles and assign them to users. All the details of rake are beyond the scope of this blog post which has gone on for way to long already, so I'll just tell you what I did. I created a file lib/tasks/roles.rake which looks like:
namespace :roles do
  desc 'Creates a role'
  task :create, [:role_name] => :environment do |cmd, args|
    Role.create :name=>args[:role_name]
    puts "Created role #{args[:role_name]}"
  end

  desc 'Add User to Role'
  task :add_user, [:email, :role_name] => :environment do |cmd, args|
    user = User.find_by_email args[:email]
    role = Role.find_by_name args[:role_name]
    unless user
      puts "No such user #{args[:email]}"
      return
    end
    unless role
      puts "No such role #{args[:role_name]}"
      return
    end
    user.roles.push role
    puts "added #{role.name} to #{user.last_name}, #{user.first_name}"
  end
end
Now you can run the commands:

rake roles:create[admin]
rake roles:create[<your email address>,admin]

to create an admin role and assign your user to it.

Conclusion
Wow, this post went on a lot longer than I meant it to. I guess there was a lot more ground to cover than I realized. I'll try to keep my tutorial based posts shorter in the future...

2 comments:

Anonymous said...

Thanks for the great post, this is exactly what I need for my (first) RoR site.

Volte said...

Shouldn't it be

rake roles:create[admin]
rake roles:add_user[,admin]

?