Aegis: Role-based Permissions for your Ruby on Rails application

by on July 5, 2009 · 20 comments

This is a guest post by our friends over at makandra, a cool Ruby on Rails development shop. Today they announce a great new Ruby gem for dealing with role-based permissions.
You know the game!
Each time you start a new application the same procedure starts over again: You set up your tools like git, trac & co. and prepare to get going. Then you look into your backlog and plan your first iteration:

  • “As a moderator I should be able to edit and delete all posts in case somebody …”
  • “As a superuser I want to create, edit and delete moderators and users.”
  • “As a department director I should be able to allow or deny requests for leave for employees in my department.”

In almost every project we at makandra were involved with during the past year, some kind of role-based permission-system was required.

Over and over and over again…

After we had implemented a custom role system for the third time, it was enough – and we have learned some things about how complex those kinds of permissions can get. We extracted everything necessary and turned it into a gem. Today we proudly present Aegis – role-based permissions for your user models.

We put it all on github so you can use, modify and fork our little gem: http://github.com/makandra/aegis

To install Aegis into your project, add the following to your Initializer.run block in your environment.rb:

config.gem 'makandra-aegis', :lib => 'aegis', :source => 'http://gems.github.com'

Afterwards run sudo rake gems:install to fetch all gems including Aegis.

Defining your permissions

Create a Permissions-model in app/models, make it inherit from Aegis::Permissions and define the roles and permissions you need:

# app/models/permissions.rb
class Permissions < Aegis::Permissions
 
  role :guest
  role :registered_user
  role :moderator
  role :administrator, :default_permission => :allow
 
  permission :edit_post do |user, post|
    allow :registered_user do
      post.creator == user # registered users may only edit their own posts
    end
    allow :moderator # moderators may edit any post
  end
 
  permission :read_post do |user, post|
    allow :everyone
    deny :guest do
      post.private? # guests may not read private posts
    end
  end
 
end

See how nicely all permission related logic is gathered in one central place?
No more need to scatter a million ifs all over your application.

To tell Aegis which models are equipped with roles, you add a string column role_name to the users table. Then open the User model and add has_role to it:

# app/models/user.rb
class User < ActiveRecord::Base
  has_role
end

Checking and asserting permissions

In your views and controllers you can now call may_read_post? and may_read_post! on instances of the user model. The soft may_read_post? simply returns true or false while the less forgiving may_read_post! throws an exception when the user is missing the required permission.

In views you will often use the soft check to decide whether to show or hide a GUI element:

# app/views/posts/index.html.erb
@posts.each do |post|
  <% if current_user.may_read_post? post %>
    <%= render post %>
    <% if current_user.may_edit_post? post %>
      <%= link_to 'Edit', edit_post_path(post) %>
    <% end %>
  <% end %>
<% end %>

(current_user is a helper method we’re using to point to the currently signed in user. If you are using Clearance for authentication you already have it.)

You rarely want those soft checks in controllers.
What you want is to simply assert that the user has sufficient permissions at a given
point in your code, and raise an error otherwise:

# app/controllers/posts_controller.rb
class PostsController
 
  def update
    @post = Post.find(params[:id])
    current_user.may_edit_post! @post # raises an Aegis::PermissionError for unauthorized access
    # ...
  end
 
end

Presenting permission errors to the world

We often use an around filter to convert Aegis::PermissionError to a 403 forbidden status code to be
a good citizen of HTTP and make Webrat see failures in our integration tests:

around_filter :convert_permission_error
 
def convert_permission_error
  yield
rescue Aegis::PermissionError => e
  render :text => e.message, :status => :forbidden
end

The same around filter can be used to show a nicer “access denied” message
to your users.

Feedback?

If you find Aegis useful, have comments or need help with your Ruby on Rails projects,
do not hesitate to drop us a line at
[email protected].

Did you enjoy this article? Get new articles for free by email:

Comments

  1. Charlie says

    Good stuff! Wondering if this depends on ActiveRecord or if it can work with other model-managing libraries like DataMapper or some of the more experimental ones?

  2. says

    Hi Charlie,

    thanks for your comment – at the moment Aegis indeed depends on AR.
    In case you couple Aegis with DataMapper, drop us a line, we’re also interested too see it!

    Greetings from Cercedilla
    Thomas

  3. Shan says

    I really like the approach you guys have chosen. I am a beginner with Ruby on Rails, and haven taken a look at all the http://ruby-toolbox.com/categories/rails_authorization.html.

    Instead of Clearance, I’m using Authlogic for authentication. Do you know any problems with this paring?

    And another noob question that is a bit off topic. Do you know a good way to assign users to a specific role at registration? Thank you!

  4. Chris says

    Thanks for this gem! I’m working on a site which will be mostly accessible to non-registered users as well as registered users. I’m using current_user.may_edit_page? checks in my views, but I keep having to check current_user.nil? first so I don’t get a NoMethod error. Is there a more efficient way to use permissions such that when nobody is logged in, permissions are set to :everyone? I’m thinking about using a wrapper method around current_user, which will return a new user if current_user.nil?

  5. says

    @Chris: The best way is to always have a current_user. When no particular user is signed in, use an unsaved User instance with a role name like “guest” or “anonymous”. Here is a typical before_filter in our ApplicationControllers:

      def find_current_user
        @current_user = User.find_by_id(session["user_id"]) || User.new(:role_name => "guest")
      end
  6. says

    @Shan: Aegis should work just fine with Clearance or Authlogic.

    A simple way to have a default role name for new user’s is to use fnando’s excellent has_defaults plugin and then change your User model like this:

    class User
      has_defaults :role_name => "registered_user"
    end
  7. Yuriy says

    Looks nice. Simple and powerful, exactly what I was looking for.

    But it doesn’t seem to be working when I define roles like “role :guest”. I changed the role names to strings (role ‘guest’) instead of the symbols and it helped.

  8. says

    @Yuriy: You should be able to use symbols to refer to roles.

    Make sure that the role_name attribute in your user model receives the role name as a string though. ActiveRecord can only store strings since the database has no concept of Ruby symbols.

  9. Brad says

    Has anyone used this with devise? I’ve started looking at devise and I like the idea of a rack based auth solution. It has roles *kindof* in that you can use authentication with any model you like (admin, user etc) but that’s not ideal for users with multiple roles. I’d be interested to see how this stacks up in devise vs, say, clearance

  10. says

    @Brad: Using devise’s multiple-model-authentication to manage access rules would be rather inconvenient. I don’t think it was meant for fine-grained permission management.

  11. Brad says

    I agree, I think one User model with access rules is much better than a model per role. So do you know how this would work with one Devise User model? I’m assuming it’s fine?

  12. says

    @Brad: Just use the “has_role” directive in the same user model where you set up Devise. Like Clearance and Authlogic, Devise provides a “current_user” getter for your controller on which you can invoke aegis methods like “current_user.may_create_orders?”.

    Aegis really doesn’t care which authentication plugin you’re using.

  13. Enduras says

    Hello,
    i am completly new to ruby on rails and just experimenting with it at the moment to check if i wanna go future with it, so i am trying out some basics every application need kinda so installed me clearance which is working fine and also found your gem which seems to fit my needs fine, other are either too simple or too complex.
    But i just dont get this to work i kinda made the stuff as in the sample but i keep getting all the time
    undefined local variable or method `has_role’ for #
    I guess its something really simple not mentioned in the guide which isnt a problem for a experienced rails user but for newbs like me.

  14. says

    @Henning Koch,
    can you elaborate a bit on the code you gave to Chris about setting up a guest user:

    def find_current_user @current_user = User.find_by_id(session["user_id"]) || User.new(:role_name => “guest”) end

    I am using Authlogic but having trouble finding where I should create the temporary guest user

    Thanks!

  15. says

    @Dave: When you implement authentication in Rails, it is useful to define a helper method in your ApplicationController that returns the user currently signed in. This method is often called current_user.

    My suggestion to Chris was to enhance his current_user method, so it returns a new, unsaved guest user in case no user is signed in for this session.

  16. S. says

    Interesting.

    But what if the user wants to configure the permissions on each role. With this hard coded into a .rb file… how can it be modified by anyone other than a developer doing a redployment?

  17. says

    @S.: You can grant a user permission under the condition that a piece of Ruby code yields true. You do this by calling `allow` with a block. Inside that block you can look at anything you like, including configuration settings that live in the database and can be configured in the UI.