Project

indulgence

0.0
No commit activity in last 3 years
No release in over 3 years
Packages permission functionality into a set of permission objects.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies

Development

Runtime

 Project Readme

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