Announcing GateKeeper

I'm pleased to announce the official release of my latest Rails plugin "GateKeeper". GateKeeper started life as a library in one of my big personal Rails projects where I've been experimenting with multiple methods of setting, managing and enforcing access and security permissions for specific models. This project has lots of users with different security access levels to other user's data based on their roles and relationships to each other. I think this latest experiment was a huge success, so I immediately decided to give it away to you ('cause you're all that and more).

Why GateKeeper?

In most simple and straight forward Rails projects, scoping your finds through has_many associations is probably sufficient for ensuring that users can't get at data they aren't supposed to. For example, a show action might call @current_user.notes.find(params[:id]) to ensure that the current user owns the note with the passed id, or it won't even find it.

However, things quickly get more difficult when users need limited access to other user's objects based on different roles (such as Admin, Moderator, Premium Subscriber, Employee of the Month, etc), or based on their relationship with the object's owner (such as Supervisor, Teacher, Parent, etc). You might want to allow variety of people to read certain objects based on a variety of different roles and relationships, and only allow a subset of those people to update or destroy the same objects. Trying to manage all of this can be daunting, and before long your code starts to look like a mess and it becomes easier and easier to accidentally leave a hole open that allows the wrong people to see or even modify data they're not supposed to. Adding new features that will require a new layer of permissions has now become the thing nightmares are made of and you start to wonder when in the hell Rails stopped being fun.

GateKeeper to the rescue!
  • GateKeeper allows fine grained control of CRUD permissions on individual instances of any ActiveRecord Model.
  • GateKeeper lets you define these permissions in plain English in one central location right in each model.
  • GateKeeper lets you give permissions based on the current users roles (RBAC), and/or based on the current user's relationship with the model instance in question via ActiveRecord associations.
  • GateKeeper allows permissions based on associations to be easily inherited from other classes through multiple chained associations.
  • GateKeeper strictly enforces every CRUD action that occurs on every instance of every ActiveRecord model, making sure no users ever do anything that permission hasn't been explicitly granted for.
  • GateKeeper can optionally scope all finds to only return records that the current user has 'read' permission for.
That's a lot of reading! How about some samples instead?

Sure, how about this?

class User < ActiveRecord::Base
  has_many :articles
  has_and_belongs_to_many :roles
  
  ## GateKeeper Permissions ##
  # Grouped together for easy reference later
  crudable_by_admin
  creatable_by_guest :unless => lambda {|new_user| new_user.username == 'guest'}
  updatable_by_self
  ############################
  
  #etc...
end

class Article < ActiveRecord::Base
  belongs_to :author, :class_name => 'User'
  
  ## GateKeeper Permissions ##
  crudable_by_admin
  creatable_by_my_author
  readable_by_my_author
  updatable_by_my_author :unless => :published?
  readable_by_premium_member :if => :published?
  ############################
end
Ok, I'm sold! What do I need to do to get started.

Well, first, install the plugin. script/plugin install svn://rubyforge.org/var/svn/gatekeeper/trunk

Next, by default GateKeeper assumes that you have a class named 'User' that represents individual users on your site. If not, you'll need to add the following line to your environment.rb to tell GateKeeper the name of the class you use for users. GateKeeper.user_class_name = 'Person' Through the rest of this post, mentally replace any mention of "User" or "User class" with whatever class name you set this to.

Lastly, GateKeeper requires that your User class provide a 'current' class method (eg. User.current)that returns an instance of User representing the user currently logged into the site. How you implement this is based entirely on your method of keeping track of who is logged into your site. (If you ask nice enough, I can probably be talked into doing a future post to outline how I do it.)

How can I do Role Based Access Control (RBAC) with GateKeeper?

By default GateKeeper makes a bunch of assumptions about your User class. If any of this won't work for your application, you can easily override it (details at the end of the section).

If you declare a permission such as updatable_by_admin, then whenever a user tries to update an instance of the class, GateKeeper performs a couple of checks.

First, GateKeeper checks to see if instances of your User class respond to is_admin?, and if so, calls that method on User.current. If that method returns true, the update is allowed to proceed.

If the above method returns false (or isn't defined), then GateKeeper checks to see if instances of your user class respond to roles (provided by an association like has_and_belongs_to_many :roles), and if so calls User.current.roles.find_by_name('admin') to see if your user has a role with the name defined in the permission declaration. If it does, the the update is allowed to proceed.

If both of the above two checks fail, then the update is halted and a permission error is raised.

If for any reason that won't work for you (i.e. you use some other method to check users roles), then you can simply define an instance method named has_gate_keeper_role? that takes one string argument which will be the name of the roles GateKeeper wants to know about, and will return true or false. Precisely what you do inside has_gate_keeper_role? is entirely your business.

How do permissions based on associations work?

When you declare a permission such as updatable_by_my_owner, GateKeeper treats the 'my_' prefix on the role as a flag to indicate it needs to check associations on the object in question (instead of a user's role as described above). Whenever a user tries to update an instance of the class with this permission, GateKeeper expects to find a owner method (presumably provided by an association, but not necessarily) that returns an instance of User. GateKeeper then simply compares the object returned by 'owner' with User.current and if they math, the action is allowed to proceed.

Can a model "inherit" some or all of it's permissions from some other model?

Absolutely, thanks to associations and the '_as_' preposition. Let's say you have an instance of Chair, which belongs_to :table. If you declare updatable_as_my_table in your Chair class, then anyone who has update permission of the specific table that your instance of chair belongs to can also update the chair. You may find that the permission declaration that you use most often is simply crudable_as_my_<association>, which will cause instances of the class to inherit all of their permissions from the associated object.

What if the association isn't a user?

Often times associations refer to some other class with it's own associations, and to get from your current object to an instance of User you need to travel through several associations (eg. @chair.table.room.house.owner ). GateKeeper lets you dictate how to get from the current object to an instance of User in either of two styles.

First, you can simply chain multiple 'crudable_as_*' permissions across multiple associations. In the example above we had an instance of Chair inheriting it's update permissions from the instance of Table it belonged to. If in turn Table declared updatable_as_my_room then Chair and Table would both get their update permissions from Room.

Alternatively, you can declare the entire chain directly in Chair with the '_of_' separator, and you can chain as many of them toegther as you like, so you might do something like updatable_by_owner_of_house_of_room_of_my_table as long as each class has the correct associations setup so that @chair.table.room.house.owner returns an instance of User to compare to User.current.

Additional Conditionals!

In addition to all of the above, permission declarations may also be given an ':if' or ':unless' argument that takes a string, symbol, or proc that will return a boolean, allowing you limitless flexibility in dictating when certain actions are allowed to occur.

What's this "permission scoping" thing you mentioned earlier?

By default, if any of the ActiveRecord finder methods find an object that User.current doesn't have permission to read, GateKeeper will throw a permission error. Even with permission scoping turned on, this is still true any time the finder method is expected to return an exact number of objects (eg find(1,3,5,9)).

However, any ActiveRecord finder methods that typically return arrays of arbitrary length (eg. find(:all)), GateKeeper allows you to tell it to just return those that User.current has permission to read, instead of throwing an error for those they can't. This can be enabled in one of two ways.

First, you can turn permission scoping on globally for your application by setting GateKeeper.permission_scoping_enabled = true in your environment.rb. Or, if you prefer to leave permission scoping off most of the time, you can simply pass a block to GateKeeper.with_permission_scoping and that block will be executed with permission scoping enabled just for it.

Note however, that it is still strongly advisable to limit your find in some way. Since GateKeeper still needs to iterate through each and every item returned by the finder to to check if it's readable, running GateKeeper.with_permission_scoping { Article.find(:all) } is likely going to be a very long running command if you have thousands, or even just a couple hundred, articles in your database. If this happens to you, work on making your block return fewer records in the first place, such as with extra conditions, limits, or paginating.

More information?

More information, including installation instructions, can be found in the online documentation at rubyforge. Enjoy, and please let me know how it goes. You can post questions or comments either here, or on the Ruby on Rails mailing list (be sure to include the word "GateKeeper" in the body to catch my attention.)

Comments: [add comment]