/authorize

A mature Rails Plugin for role-based authorization (polymorphic on subject and trustee, DSL for auth expressions, model- and controller-level expressions)

Primary LanguageRubyMIT LicenseMIT

Authorize
=========
Authorize is a Ruby on Rails plugin providing a sophisticated Role-Based Access Control (RBAC) system.  Current functionality highlights include:

 * Polymorphic association of ActiveRecord models as authorizable resources.
 * Three-level (global, class, instance) authorizations over resources.
 * "Acts" to support a single ActiveRecord model being both an authorizable subject and trustee.
 * Hierarchical role tree supporting rich modelling of role assignment. 
 * High-performance resolution of effective roles using Redis key-value database as a graph database.

For more information on the theory of RBAC, see http://en.wikipedia.org/wiki/Role-based_access_control

----------------
ActionPack
The Authorize plugin extends ActionController and ActionView with the ability to check permissions and react accordingly.  There are two approaches:
a simple boolean check (permit?) and a more sophisticated predicated block (permit) with a configurable callback.  In both cases, the method accepts
a permissions description hash.  For example, using the boolean version:

	permit?(:update => widget)

More complex expressions typically involve checking for permissions to multiple model instances.  The following predicate, for example,
is true if the current roles include the :all permission over foo OR the :read permission over bar:

	permit?({:all => foo, :read => bar})  

Clean hooks are available for identifying the appropriate roles for the current request.  No "User" class is assumed, only a
ApplicationController#roles method that returns an enumeration of the roles for the current request.  A simple implementation might
work something like this:

	class ApplicationController << ActionController::Base
	  def roles
	    User.current.role.roles
	  end
	end

----------------
ActiveRecord
The Authorize plugin extends ActiveRecord with two methods: authorizable_resource and authorizable_trustee.  A given model may invoke
either or both, depending on requirements.  Models are thus extended with additional capabilities as follows:

Trustee
A #role association is defined that links a trustee to a "primary" or "identity" role (Authorize::Role).  This role serves as the entry
point for traversing the role hierarchy and determining the effective set of roles (identity role plus its children) for a given trustee.

Resource
A #permissions association is defined that links a resource to the set of permissions (Authorize::Permission) that define the available
access modes to the resource.  A #permitted parameterized scope is also provided:

Resource.permitted(roles, options = {})
Returns the resources to which the given roles have any permissions.  Optionally, the qualifying permissions can be restricted with the
remaining arguments, or the :mode or :modes options:
Examples:
  Widget.permitted([Authorize::Role::PUB])
  Widget.permitted([Authorize::Role::PUB, my_role], :mode => :update)
  Widget.permitted([Authorize::Role::PUB, my_role], :modes => [:delete, :update])
  Widget.permitted([Authorize::Role::PUB, my_role], :delete, :update)

In addition to the macro methods described above, two models (ActiveRecord::Base subclasses) are defined:

Authorize::Permission
Permissions link roles to resources along with a defined access mode.  Access modes are limited to the classics (read, update, delete, etc.),
but interpretation of them is application-specific (but see the note below about the list mode).  Permissions apply to resources at one of
three levels:

	Instance (e.g. update permission for the Widget instance with id 6324)
	Class (e.g. list permission for all instances of Widget)
	Global (e.g. read permission for all instances of every model class)

NOTE: To maximize performance, the list access mode is assumed to be included in EVERY instance of Authorize::Permission.  This allows
efficient SQL joins with model tables (widgets, for example) and the permissions table (authorize_permissions).  The permissions table
can grow quite large, and this implied mode obviates the need to index the mask field and add complex conditions to permissions queries.  
	
Authorize::Role
Roles allow flexible modelling of application-specific functions.  The Role class is rather thin and mainly serves to identify a role
instance and polymorphically associate it, where appropriate, to a trustee.  Critically, it has the sole interface (#roles) into the 
role graph (a DAG) stored in the Redis database. 
----------------

A Note on Performance

Performance of the Resource.permitted named scope is critical to effective use of this plugin.  However, it is difficult to optimize across multiple
database systems for multiple use cases.  Empirically, it seems to be best to use a UNION of the three cases that can yield a permission: global, class-
based and instance based.  Alternatives using moderately complex nested or expanded OR clauses fail to optimize correctly on MySQL 5.0 and degrade terribly 
with substantial authorization and subject volume.  Not surprisingly, COALESCE also fails to optimize nicely.  A JOIN-based solution was considered, but the
semantics of a JOIN are such that duplicate subject records are returned.  The duplicates could be eliminated with :group and :having options, but at the cost
of transparency of the #permitted named scope.

Indexing of the authorize_permissions table is very important.  See the test application's schema for an reasonable set of indices.

Code examples of alternatives:

          # Baseline with nested booleans
          c1 = Authorize::Permission.sanitize_sql_hash_for_conditions(:subject_type => nil)
          c2 = Authorize::Permission.sanitize_sql_hash_for_conditions(:subject_type => base_class.name)
          c3l = "%s.%s" % [reflection.quoted_table_name, connection.quote_column_name(reflection.primary_key_name)]
          c3r = "%s.%s" % [connection.quote_table_name(table_name), connection.quote_column_name(primary_key)]
          c4 = Authorize::Permission.sanitize_sql_hash_for_conditions(:subject_id => nil)
          subject_condition_clause = "#{c1} OR (#{c2} AND (#{c3l} = #{c3r} OR #{c4}))"
          named_scope :a0, lambda {|tokens, roles|
            scope = Authorize::Permission.scoped(:conditions => subject_condition_clause).with(tokens).as(roles)
            c = scope.construct_finder_sql({:select => 1, :from => "#{reflection.quoted_table_name} a"}).gsub(/#{reflection.quoted_table_name}\./, 'a.')
            {:conditions => "EXISTS (%s)" % c}
          }

          # Baseline with booleans expanded into three ORs
          c1 = Authorize::Permission.sanitize_sql_hash_for_conditions(:subject_type => nil)
          c2 = Authorize::Permission.sanitize_sql_hash_for_conditions(:subject_type => base_class.name)
          c3l = "%s.%s" % [reflection.quoted_table_name, connection.quote_column_name(reflection.primary_key_name)]
          c3r = "%s.%s" % [connection.quote_table_name(table_name), connection.quote_column_name(primary_key)]
          c4 = Authorize::Permission.sanitize_sql_hash_for_conditions(:subject_id => nil)
          subject_condition_clause = "#{c1} OR (#{c2} AND #{c3l} = #{c3r}) OR (#{c1} AND #{c4})"
          named_scope :a1, lambda {|tokens, roles|
            scope = Authorize::Permission.scoped(:conditions => subject_condition_clause).with(tokens).as(roles)
            c = scope.construct_finder_sql({:select => 1, :from => "#{reflection.quoted_table_name} a"}).gsub(/#{reflection.quoted_table_name}\./, 'a.')
            {:conditions => "EXISTS (%s)" % c}
          }

          # COALESCE replacing OR (and a subtle but harmless semantic shift)
          auth_fk = "#{reflection.quoted_table_name}.#{connection.quote_column_name(reflection.primary_key_name)}"
          subject_pk = "#{connection.quote_table_name(table_name)}.#{connection.quote_column_name(primary_key)}"
          auth_fk_type = "#{reflection.quoted_table_name}.#{connection.quote_column_name(Authorize::Permission.reflections[:subject].options[:foreign_type])}"
          subject_condition_clause = "%s = COALESCE(#{auth_fk_type}, %s) AND #{subject_pk} = COALESCE(#{auth_fk}, #{subject_pk})" % ([connection.quote(base_class.name)] * 2)
          named_scope :a2, lambda {|tokens, roles|
            scope = Authorize::Permission.scoped(:conditions => subject_condition_clause).with(tokens).as(roles)
            c = scope.construct_finder_sql({:select => 1, :from => "#{reflection.quoted_table_name} a"}).gsub(/#{reflection.quoted_table_name}\./, 'a.')
            {:conditions => "EXISTS (%s)" % c}
          }

          # Correlated subquery with COALESCE
          auth_fk = "#{reflection.quoted_table_name}.#{connection.quote_column_name(reflection.primary_key_name)}"
          subject_pk = "#{connection.quote_table_name(table_name)}.#{connection.quote_column_name(primary_key)}"
          auth_fk_type = "#{reflection.quoted_table_name}.#{connection.quote_column_name(Authorize::Permission.reflections[:subject].options[:foreign_type])}"
          subject_condition_clause = "%s = COALESCE(#{auth_fk_type}, %s)" % ([connection.quote(base_class.name)] * 2)
          select_clause = "COALESCE(#{auth_fk}, #{subject_pk})"
          named_scope :a3, lambda {|tokens, roles|
            scope = Authorize::Permission.scoped(:conditions => subject_condition_clause).with(tokens).as(roles)
            c = scope.construct_finder_sql({:select => select_clause})
            {:conditions => "#{subject_pk} IN (#{c})"}
          }

          # Three-way union - nice performance but UGLY query
          auth_fk = "#{reflection.quoted_table_name}.#{connection.quote_column_name(reflection.primary_key_name)}"
          subject_pk = "#{connection.quote_table_name(table_name)}.#{connection.quote_column_name(primary_key)}"
          named_scope :a4, lambda {|tokens, roles|
            scope = Authorize::Permission.with(tokens).as(roles)
            sq0 = scope.construct_finder_sql({:select => true, :conditions => {:subject_id => nil, :subject_type => nil}})
            sq1 = scope.construct_finder_sql({:select => true, :conditions => {:subject_type => base_class.name, :subject_id => nil}})
            sq2 = scope.scoped(:conditions => "#{auth_fk} = #{subject_pk}").construct_finder_sql({:select => true, :conditions => {:subject_type => base_class.name}})
            {:conditions => "EXISTS (#{sq0} UNION #{sq1} UNION #{sq2})"}
          }

          # Join - possible to get nice performance, but semantics collapse
          auth_fk = "#{reflection.quoted_table_name}.#{connection.quote_column_name(reflection.primary_key_name)}"
          subject_pk = "#{connection.quote_table_name(table_name)}.#{connection.quote_column_name(primary_key)}"
          auth_fk_type = "#{reflection.quoted_table_name}.#{connection.quote_column_name(Authorize::Permission.reflections[:subject].options[:foreign_type])}"
          subject_condition_clause = "%s = COALESCE(#{auth_fk_type}, %s) AND #{subject_pk} = COALESCE(#{auth_fk}, #{subject_pk})" % ([connection.quote(base_class.name)] * 2)
          named_scope :a9, lambda {|tokens, roles|
            ascope = Authorize::Permission.with(tokens).as(roles).current_scoped_methods[:find][:conditions]
            {:joins => "JOIN authorizations ON #{subject_condition_clause}", :conditions => {:authorizations => ascope}}
          }

TODO:
 * Flexible configuration of permission bits