Easy Role-Based Authorization in Rails
CommentsOnce 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' }) %>