Yet another permissions gem.
In creating Indulgence I wanted a role based permissions tool that did two main things:
-
Determine what permission a user had to do something to an object
-
Filtered a search of objects based on the those permissions
It was apparent to me that if ‘something’ was one of the CRUD actions, it would cover most of the use cases I could think of. So permissions were sub-divided into the ‘actions’: create, read, update, and delete.
The other requirement was that the permission for an object could be defined succinctly within a single file.
Indulgence can be added to a class via acts_as_indulgent:
class Thing < ActiveRecord::Base acts_as_indulgent end
Used in this way, permissions need to be defined in an Indulgence::Permission object called ThingPermission, with an instance method :default
class ThingPermission < Indulgence::Permission def default { create: none, read: all, update: none, delete: none } end end
This needs to be available to the Thing class. For example, in a rails app, by placing it in app/permissions/thing_permission.rb
Indulgence assumes that permissions will be tested against an entity object (e.g. User). The default behaviour assumes that the entity object has a :role method that returns the role object, and that the role object has a :name method.
So typically, these objects could look like this:
class User < ActiveRecord::Base belongs_to :role end class Role < ActiveRecord::Base has_many :users validates :name, :uniqueness => true end pleb = Role.create(:name => 'pleb') user = User.create( :first_name => 'Joe', :last_name => 'Blogs', :role => pleb )
Simple true/false permission can be determined using the :indulge? method:
thing = Thing.first thing.indulge?(user, :create) == false thing.indulge?(user, :read) == true thing.indulge?(user, :update) == false thing.indulge?(user, :delete) == false
The :indulgence method is used as a where filter:
Thing.indulgence(user, :create) --> raises ActiveRecord::RecordNotFound Thing.indulgence(user, :read) == Thing.all Thing.indulgence(user, :update) --> raises ActiveRecord::RecordNotFound Thing.indulgence(user, :delete) --> raises ActiveRecord::RecordNotFound
So to find all the blue things that the user has permission to read:
Thing.indulgence(user, :read).where(:colour => 'blue')
Up until now, all users get the same permissions irrespective of role. Let’s give Emperors the right to see and do anything by first creating an emperor
emperor = Role.create(:name => 'emperor') caesar = User.create( :first_name => 'Julius', :last_name => 'Ceasar', :role => emperor )
And then defining what they can do by adding these two methods to ThingPermission:
def abilities { emperor: default.merge(emperor) } end def emperor { create: all, update: all, delete: all } end
This uses a merger of the default abilities so that only the variant abilities need to be defined in the emperor method. That is, read is inherited from default rather than being defined in emperor, as it is already set to all.
abilities is a hash of hashes. The lowest level, associates action names with ability objects. The top level associates role names to the lower level ability object hashes. In this simple case, construction is perhaps clearer if the abilities method above was written like this:
def abilities { emperor: { create: all, read: default[:read], update: all, delete: all } } end
With this done:
thing.indulge?(caesar, :create) == true thing.indulge?(caesar, :read) == true thing.indulge?(caesar, :update) == true thing.indulge?(caesar, :delete) == true Thing.indulgence(caesar, :create) == Thing.all Thing.indulgence(caesar, :read) == Thing.all Thing.indulgence(caesar, :update) == Thing.all Thing.indulgence(caesar, :delete) == Thing.all
Indulgence has two built in abilities. These are all and none. These two have provided all the functionality described above, but in most real cases some more fine tuned ability setting will be needed.
Let’s create an author role, and give authors the ability to create and update their own things.
author = Role.create(:name => :author)
Next we need to give author’s ownership of things. So we add an :author_id attribute to Thing, and a matching :author method:
class Thing < ActiveRecord::Base acts_as_indulgent belongs_to :author, :class_name => 'User' end
Then we need to create an Ability that uses this relationship to determine permissions. This can be done by adding this method to ThingPermission:
def things_they_wrote define_ability( :name => :things_they_wrote, :compare_single => lambda {|thing, user| thing.author_id == user.id}, :filter_many => lambda {|things, user| things.where(:author_id => user.id)} ) end
This will create an Ability object with the following methods:
- name
-
Allows abilities of the same kind to be matched and cached
- compare_single
-
Used by :indulge?
- filter_many
-
Used by :indulgence
Alternatively you can define the ability like this:
def things_they_wrote define_ability(:author) end
This will use :author to define attributes of an ability object. :author could be an association or an attribute that returns either the entity or the entity.id.
So this also works:
def things_they_wrote define_ability(:author_id) end
Once things_they_wrote has been defined, we can use it to define a new set of abilities:
def abilities { emperor: default.merge(emperor), author: default.merge(author) } end def author { create: things_they_wrote, update: things_they_wrote } end
With that done:
cicero = User.create( :first_name => 'Marcus', :last_name => 'Cicero', :role => author ) thing.update_attribute :author, cicero thing.indulge?(cicero, :create) == true thing.indulge?(cicero, :read) == true thing.indulge?(cicero, :update) == true thing.indulge?(cicero, :delete) == false Thing.indulgence(cicero, :create) == [thing] Thing.indulgence(cicero, :read) == Thing.all Thing.indulgence(cicero, :update) == [thing] Thing.indulgence(cicero, :delete) --> raises ActiveRecord::RecordNotFound
The default actions on which indulgence is based are the CRUD operations: create, read, update and delete. You can add your own actions, or define a completely different action set if you prefer.
So for example when showing information about a thing, we could display a warning that only emperors should see.
First update ThingPermissions like this:
def default { create: none, read: all, update: none, delete: none, prophecy: none, } end def emperor { create: all, update: all, delete: all, prophecy: all } end
And then in views/things/show.html.erb add:
<%= "Beware the Ides of March" if @thing.indulge?(current_user, :prophecy) %>
As stated above, the default behaviour is for a Thing class to expect its permissions to be defined in ThingPermission. However, you can define an alternative class name:
acts_as_indulgent :using => PermissionsForThing
Consider this example:
class User < ActiveRecord::Base belongs_to :position end class Position < ActiveRecord::Base has_many :users validates :title, :uniqueness => true end
There are two ways of dealing with this.
If only ThingPermission is affected, the attributes that stores the role_method and role_name_method could be overwritten:
class ThingPermission < Indulgence::Permission def self.role_method :position end def self.role_name_method :title end ..... end
Alternatively if all permissions were to be altered the super class Permission could be updated (for example in a rails initializer):
Indulgence::Permission.role_method = :position Indulgence::Permission.role_name_method = :title
The method names indulgence and indulge? may not suit your application. If you wish to use alternative names, they can be aliased like this:
acts_as_indulgent( :compare_single_method => :permit?, :filter_many_method => :permitted )
With this used to define indulgence in Thing, we can do this:
thing.permit?(cicero, :update) == true thing.permit?(cicero, :delete) == false Thing.permitted(cicero, :create) == [thing] Thing.permitted(cicero, :read) == Thing.all
For some examples, have a look at the tests. In particular, look at the object definitions in test/lib.
This app has a set of tests associated with it. The test suite includes the configuration for a sqlite3 test database, with migrations and fixtures. If you wish to play with, and/or modify Indulgence: fork the project and clone a local copy. Run bundler to install the necessary gems. Then use this command to create the test database:
rake db:migrate RAILS_ENV=test
If this raises no errors, you should then be able to run the tests with:
rake test