Indulgence¶ ↑
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.
Defining indulgent permissions¶ ↑
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.
class ThingPermission < Indulgence::Permission 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
Default permissions¶ ↑
The Permission class has a default method, that matches all the CRUD actions to the ability none.
This behaviour can be overridden by explicitly defining the default method.
class ThingPermission < Indulgence::Permission def default { create: none, read: all, update: none, delete: none } end end
Users and Roles¶ ↑
Indulgence assumes that permissions will be tested against an entity object (e.g. User). The standard 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 )
Compare single item: indulge?¶ ↑
Simple true/false permission can be determined using the :indulge? method:
thing = Thing.first thing.indulge?(user, :create) == false thing.indulge?(user, :read) == true # Note default has be overridden thing.indulge?(user, :update) == false thing.indulge?(user, :delete) == false
indulge? as a class method¶ ↑
There is also a class :indulge? method. Calling this is the equivalent to calling :indulge? on a new object.
Thing.indulge?(user, :create) == Thing.new.indulge?(user, :create)
Filter many: indulgence¶ ↑
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')
Customisation¶ ↑
Adding other roles¶ ↑
Up until now, all users get the same permissions (default) 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 => 'Caesar', :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
Adding abilities¶ ↑
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
Notice how Thing.indulge? behaves:
Thing.indulge?(cicero, :create) == false
Thing.indulge? acts on a new instance of Thing where author_id will not be set. If this is not the behaviour expected, the permission may need to be checked at a stage after initialization, but before persisting:
thing = Thing.new(author: user) if thing.indulge?(user, :create) thing.save end
In this example, the thing will be saved if the user is cicero, but not if the user has the role ‘pleb’.
Defining your own actions¶ ↑
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) %>
Alternative Permission Class¶ ↑
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
Alternative Entity behaviour¶ ↑
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
Alternative method names¶ ↑
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.permit?(cicero, :update) == false Thing.permitted(cicero, :create) == [thing] Thing.permitted(cicero, :read) == Thing.all
Null entities¶ ↑
Indulgence falls back to the none ability if nil is passed as the entity.
thing.indule?(nil, :read) == false Thing.indulgence(nil, :read) --> raises ActiveRecord::RecordNotFound
Basing permissions on an object’s state rather than a user’s role¶ ↑
A work process goes through a number of stages. Instead of permissions needing to change depending on a user’s role, it may be required that permissions are dependent of the process stage. To achieve this, the association between the host object and it’s permission class would need to change. Rather than associating the work process to its permission via acts_as_indulgent, the indulge? and indulgence methods would need to be created directly:
class WorkProcess < ActiveRecord::Base def indulge?(user, ability) permission = WorkProcessPermission.new(user, ability, self.stage) permission.compare_single self end def self.indulgence(user, ability, stage_name) permission = WorkProcessPermission.new(user, ability, stage_name) permission.filter_many(self).where(:stage => stage_name) rescue Indulgence::NotFoundError, Indulgence::AbilityNotFound raise ActiveRecord::RecordNotFound.new('Unable to find the item(s) you were looking for') end end
With that in place, abilities would need to defined by each stage name rather than each user role. So:
class WorkProcessPermission < Indulgence::Permission def abilities { beginning: beginning, middle: middle, finish: finish } end def beginning { create: all, read: all, update: none, delete: all } end def middle { create: none, read: all, update: all, delete: all } end def finish { create: none, read: all, update: none, delete: none } end end
With that in place:
work_process = WorkProcess.create(stage: 'beginning') work_process.indulge?(user, :create) == true work_process.indulge?(user, :update) == false WorkProcess.indulgence(user, :create, :beginning) == [work_process]
Strict mode¶ ↑
Imagine we have a Dog class:
class Dog end
And we use a dog as an entity:
fido = Dog.new thing.indulge? fido, :edit
There are two ways that we may want indulgence to behave:
-
go bang;
-
or handle the problem as if the entity has no role (that is: return the default abilities.)
The default behaviour is to raise a method not found error. However, if Indulgence.strict = false, the response will match the default ability:
Indulgence.strict = true # default thing.indulge?(fido, :edit) ---> Raises No Method Error Indulgence.strict = false thing.indulge?(fido, :edit) == false thing.indulge?(fido, :read) == true
That is, in Strict mode, Indulgence will expect an entity to behave like a user with roles, and will go bang if this is no the case. It strict mode is turned off, Indulgence will make a best effort to guess how to behave.
Examples¶ ↑
For some examples, have a look at the tests. In particular, look at the object definitions in test/lib.
Playing with Indulgence¶ ↑
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