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.
= 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. == 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()
}
end
def
{
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)
= WorkProcessPermission.new(user, ability, self.stage)
.compare_single self
end
def self.indulgence(user, ability, stage_name)
= WorkProcessPermission.new(user, ability, stage_name)
.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