Role Based Authorization in Ruby on Rails

Posted by Berin Loritsch Fri, 28 Apr 2006 16:40:00 GMT

I'm rereleasing all the articles on D-Haven so that when I upgrade the site they have a home.
...

Ruby on Rails is a great web framework, and it does simplify many aspects of writing a dynamic web application. While rails does have a login generator, it does not provide support for role based authorization. This article details how to extend the login to include role based authorization.

Role based authorization provides protection in the sense of enabling users who are trusted at different levels access to what they need at their trust level. For example, you may have an article submission system with all users able to add comments, some users able to submit and edit articles, and administrators able to delete articles. This level of access control enables the system to protect itself from accidental abuse.

Step 1: Enable logins

You will need to install the "login" generator for rails. Just follow the instructions on the LoginGenerator page. At that point, you just need to generate the login with a command similar to this:

$ script/generate login User

Next, set up your database with a table for the logins. The table name needs to be "users", with the set of columns listed below.

  1. id, integer, auto-increment, and primary key
  2. login, varchar(20+)
  3. password, char(40)

Note, you can add additional columns if you want to track more information about the user. I like to have a "Name" and an "Email" column in adition to the required columns. That way I can have a better login message than "Welcome bloritsch" and I can also add a process to reset passwords if the user forgot it. That's a subject for another time.

Another additional thing you should do at this time is to change the "salt" value. The login generator allows an application to salt the passwords so that it is not as trivial as finding the SHA1 hash of the password itself. You'll see a line with @@salt = 'change-me'. All you have to do is to replace the contents of the string with your new salt value.

Step 2: User specific authorization

In my article submission scheme, I wanted to make it that the user who submitted an article is the only one who can edit it. Call it an experiment in ownership. I very well may relax that requirement and make it a role based authorization. However, this is the first step in showing how authorization could work.

To enable user specific authorization, you have to associate a user with a controlled item. We will assume you have a model named "Article" which maps to the database table named "Articles". To make the association, you need to add in the reference from Articles to User. You do that by adding the "user_id" column to the "Articles" table. Next, modify your Article model which should be in app/models/Article.rb. Add the line "belongs_to :user" to the top of the class. You should also add to the "validates_presence_of" list the parameter ":user". The articles class will look something like this:

class Article < ActiveRecord::Base
  validates_presence_of :title, :user
  belongs_to :user
end

Since it is very inconvenient for your user to have to explicitly add themselves to an article we need to alter the controller that manages the articles to automatically associate the logged in user to the article. If you generated the scaffolding already for your articles, this is fairly simple to do. We need to ensure the user is logged in, and then we need to alter the "create" method to make the association.

  1. Add the line before_filter :login_required, :except => [:index, :list, :show] to the top of the class. This will ensure the user is logged in for any action that would alter the data in the articles without forcing them to be logged in to see the articles.
  2. Add the line @article.user = @session[:user] directly after the line @article = Article.new(params[:article]) that was generated from the scaffolding code.

The resulting changes to the Article's Controller should look similar to the following code snippet:

class ArticlesController < ApplicationController
  before_filter :login_required, :except => [:index, :list, :show]

  # ... skip other generated code ...

  def create
    @article = Article.new(params[:article])

    @article.user = @session[:user]
    if @article.save
      flash[:notice] = 'Article was successfully created.'
      redirect_to :action => 'list'
    else
      render :action => 'new'
    end
  end

  # ... skip other generated code ...
end

So far, we have a user automatically associated with new articles. So far so good, but we aren't really protecting against just anyone altering my article. To do that we need to alter the "edit" method to validate the logged in user is the same as what is already associated with an article. To do that we need to add conditional to the code that is already there. For brevity's sake let's take a look at what it should look like:

  def edit
    @article = Article.find(params[:id])
    if @article.user != @session[:user]
      flash[:notice] = 'You cannot edit an article you didn\'t upload.'
      redirect_to :action => 'show', :id => @article
    end
  end

What we did here was add an if statement that checks to see if the logged in user matches the article's author. If not, the controller flashes the notice "You cannot edit an article you didn't upload." and redirects the user back to showing the article. If you don't like the flash notice approach (it can get lost at the top of a long article) you can adapt the code to your purposes. So far we are performing a certain level of control over the process. What we are trying to do, of course, is to provide a way to protect against spammers or people with nothing better to do than to destroy your hard work.

Step 3: Adding Roles to the Mix

Now that we have authorization happening and some level of user control, we want to have more granular support so that you have to be an "Admin" role to delete an article. Just to make things interesting we will allow the "Admin" role to alter any article just in case it is largely OK, but the original author had some objectionable language in there.

We need to generate a model for "Role" and two tables in the database. We need two tables to provide for a many to many relationship between users and roles. Any role can belong to many users, and any user can have many roles. The database tables should look like this:

Table: Roles
  1. id, int, auto-increment, primary key
  2. role, varchar(20), not null
Table: Roles_Users
  1. user_id, int, primary key
  2. role_id, int, primary key

To allow rails to understand the way they relate to each other in the database, we need to add the directives to associate them. We do this with the line has_and_belongs_to_many and then we cross reference the users with roles and roles with users. It should look something like this:

Model: Role
class Role < ActiveRecord::Base
  has_and_belongs_to_many :users

  #add validation for good measure  
  validates_presence_of :role
  validates_uniqueness_of :role
end
Model: User
class User < ActiveRecord::Base
  has_and_belongs_to_many :roles

  # ... skip remaining code ...
end

That's all well and good, but as it stands right now we have to do some fancy checking to see if your user has a particlur role. After all we need to look up the Role object we need, and then see if the array of roles for the User object includes that role. That's alot to ask for the consumer of the system. So lets make it a little easier.

First, let's make it easier to find the Role object that we need. We will assume that we look up a role by the name of the role instead of needing to know what the id is. It makes the code more understandable. To do that we will add a new method to the Role's model. The easiest way to do that is to create a static method overriding the bracket operator like this:

  def self.[] (role)
    find_first(["role = ?", role])
  end

That code allows us to find any role with a very simple call: Role["Admin"]. But wouldn't it be nicer to be a little more Rubyesque in our API and us a symbol for the role? It's really easy to modify the above method to use symbols so that the call looks like Role[:Admin]. Just change the find_first call like this:

    find_first(["role = ?", role.id2name])

Now we just want to find out if our user has a particular role. To do that we need a new method in our User model to check that fact. To keep things easy to use we will call it "is_in_role?" The method should look like this:

  def is_in_role?(role)
    if role.nil?
      return false;
    end

    return roles.include?(Role[role])
  end

That way we guard against an empty role and we can validate if the role exists. Instead of asking the developer to constantly find the proper role himself, we include that logic in the method. That way we can validate if the user is in a particular role like this: @user.is_in_role? (:Admin).

Using the Role Checking in Code

Now that we have effectivley added the role checking code we can start using it. Let's start by extending the user specific checking for editing an article to allow anyone with the role "Admin" to edit the article. This is a relatively simple thing to do. First, we need a role with the "Admin" role defined. Next we need a user with the "Admin" privilege associated. At this point you'll have to add those directly to the database.

Blocking actions in the controller

Now, let's take a second look at the "edit" method in the articles controller. All we need to do is add an additional clause to the if statement to check to see if the role is not in "Admin". The altered if statement should look like this:

    if @article.user != @session[:user] and not @session[:user].is_in_role? (:Admin)
Hiding parts of the view based on role

Let's say that we want to suppress the "Edit" link in the show.rhtml view for the article if the user does not have the "Edit" role. To do that we need to perform our check in the show.rhtml file. If you recall, anyone can view an article without being logged in, so we do need to verify if the user is logged in before we check their role. I did the check like this:

<% if not @user.nil? and @user.is_in_role? (:Edit) %>
  <%= link_to 'Edit', :action => 'edit', :id => @article %> | 
<% end  %>
<%= link_to 'Back', :action => 'list' %>

To enable @user to work, you'll have to add the line @user = session[:user] to the "show" method in the articles generator. It does make it a little easier to handle.

Protecting known URLs

The last thing we need to do is protect against just anyone deleting any article. If they recognize your app as a Rails based app, they will know that the "destroy" method will delete the article. Its best to protect any action before we take it. To do that we need to alter the articles controller again, this time with the "destroy" method. Something that the scaffold generator doesn't do is provide any flash notices whether a delete was successful or not. We are going to fix this oversight at the same time. The method should look like this when it is done:

  def destroy
    if session[:user].is_in_role? (:Admin)
      Article.find(params[:id]).destroy
      flash[:notice] = 'Article was successfully deleted.'
    else
      flash[:notice] = 'Only an administrator can delete the article.'
    end

    redirect_to :action => 'list'
  end

What we just did was to validate if the user is in the "Admin" role, and if they were we destroyed the article and flashed the success message. If they were not an Admin, they are notified that they can't delete the article.

We're Done

We have a thing of beauty. We can provide fine grained control, and we have a very flexible role based system we can extend. What we didn't do was user administration or adding a way to manage who gets assigned what roles. That's handled manually in the database. Its up to you to write the rest of that. The good news is that you have the ability to make your administration maintained by only authorized users.

Comments

Leave a response

Comments