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