Easy Role-Based Authorization in Rails

Comments

Once user authentication has been added to your Rails app, authorization isn't far behind. In fact, very basic authorization functionality exists the moment you implement user authentication. At that point, users who are logged in will have authorization to access areas of your application that others do not. The next common step is to add a boolean attribute to the User model to track whether a user is a "normal" user or someone who should have access to administer the application as well, yielding a convenient syntax like @user.admin?.

Adding an attribute to track a user's administrator status may well be enough for a simple application, but at some point you will want something more flexible. After all, you don't want to go adding a new column to your user table for every single possible authorization level, do you? Here's one very easy way to handle things.

A good role model

So our user might be an admin. But perhaps he is just a plain old user, or a deactivated user, or a superhero, or... You get the idea. We want to be able to add new "roles" as the need for them arises, so we will generate a Role model with a few basic attributes:

    script/generate model Role name:string description:string

Once we've added a role_id column to the user table and told Rails that User belongs_to :role, we'll now be able to do something like @user.role.name == 'admin'. Well, that's functional, but really ugly. It'd be a lot better if we could say something like @user.is_an_admin?. That's not too tricky at all.

Our old friend method_missing

Since we have no idea what kind of roles we may eventually be adding to the database, it doesn't make sense to code a special method for each and every one. Let's use method_missing instead.

app/models/user.rb:

      def method_missing(method_id, *args)
        if match = matches_dynamic_role_check?(method_id)
          tokenize_roles(match.captures.first).each do |check|
            return true if role.name.downcase == check
          end
          return false
        else
          super
        end
      end

      private

      def matches_dynamic_role_check?(method_id)
        /^is_an?_([a-zA-Z]\w*)\?$/.match(method_id.to_s)
      end

      def tokenize_roles(string_to_split)
        string_to_split.split(/_or_/)
      end

A quick regular expression check of the method called lets us capture the last part of any method starting with is_a_ or is_an_ and a quick split on "_or_" lets us do something like @user.is_an_admin_or_superhero?. Pretty slick, and very simple!

But wait, there's more!

So, that's kind of nice. But of course, as your app grows, it's likely that you'll want to expose certain functionality to users with a bunch of different roles. For instance, users who are disabled shouldn't be allowed to log in at all, but everyone else should, and you don't want to go around writing code like this:

    def login
      if @user.is_a_user_or_admin_or_superhero_or_demigod_or_chuck_norris?
        # log in
      else
        # do something else
      end
    end

"No problem," you say. "I'll just use method_missing to handle is_not_a_whatever?!" That, my friend, is a slippery slope. There is a better way.

Permission to come aboard

So we have certain roles, and they have permission to do certain things, which sometimes overlap, but not always. Why not create a Permission model, then create an association between roles and permissions? Let's do that.

    script/generate model Permission name:string description:string
    script/generate migration CreatePermissionsRoles

Then, in the migration:

      def self.up
        create_table :permissions_roles, :id => false do |t|
          t.integer :permission_id
          t.integer :role_id
        end
      end

And in the models:

    class Role < ActiveRecord::Base
      has_and_belongs_to_many :permissions
    end

    class Permission < ActiveRecord::Base
      has_and_belongs_to_many :roles
    end

So now we can get to whatever permissions we have assigned to the role of the user by doing something like @user.role.permissions and do a find_by_name, or whatever our hearts desire. I think you see where we're going from here.

First, while it's entirely accurate to say that the user's role has certain permissions, isn't it also accurate to say that the user himself has those permissions? Let's add a little bit of syntactical sugar by using delegate:

app/models/user.rb:

    class User < ActiveRecord::Base
      belongs_to :role
      delegate :permissions, :to => :role
      # ...
    end

Now we can say @user.permissions, which is a bit more readable. Now, let's modify our dynamic role check to handle permissions as well:

      def method_missing(method_id, *args)
        if match = matches_dynamic_role_check?(method_id)
          tokenize_roles(match.captures.first).each do |check|
            return true if role.name.downcase == check
          end
          return false
        elsif match = matches_dynamic_perm_check?(method_id)
          return true if permissions.find_by_name(match.captures.first)
        else
          super
        end
      end

      private

      # previous methods omitted
      def matches_dynamic_perm_check?(method_id)
        /^can_([a-zA-Z]\w*)\?$/.match(method_id.to_s)
      end

Done! Now, we can ask our user objects to tell us what they can do, such as @user.can_login?, @user.can_administer_users?, or, in the case of our superhero role, maybe we'll clean up our view a little bit:

    <%= link_to_if(@user.can_fly?, 'Fly!',
                   {:controller => 'users', :action => 'fly' }) %>
comments powered by Disqus