class Authorization::ObligationScope

The ObligationScope class parses any number of obligations into joins and conditions.

In ObligationScope parlance, “association paths” are one-dimensional arrays in which each element represents an attribute or association (or “step”), and “leads” to the next step in the association path.

Suppose we have this path defined in the context of model Foo: +{ :bar => { :baz => { :foo => { :attr => is { user } } } } }+

To parse this path, ObligationScope evaluates each step in the context of the preceding step. The first step is evaluated in the context of the parent scope, the second step is evaluated in the context of the first, and so forth. Every time we encounter a step representing an association, we make note of the fact by storing the path (up to that point), assigning it a table alias intended to match the one that will eventually be chosen by ActiveRecord when executing the find method on the scope.

+@table_aliases = {

[] => 'foos',
[:bar] => 'bars',
[:bar, :baz] => 'bazzes',
[:bar, :baz, :foo] => 'foos_bazzes' # Alias avoids collisions with 'foos' (already used)

}+

At the “end” of each path, we expect to find a comparison operation of some kind, generally comparing an attribute of the most recent association with some other value (such as an ID, constant, or array of values). When we encounter a step representing a comparison, we make note of the fact by storing the path (up to that point) and the comparison operation together. (Note that individual obligations' conditions are kept separate, to allow their conditions to be OR'ed together in the generated scope options.)

+@proxy_options = { :bar => { :baz => :foo } } @proxy_options = [ 'foos_bazzes.attr = :foos_bazzes__id_0', { :foos_bazzes__id_0 => 1 } ]+

Public Class Methods

new(model, options) click to toggle source
Calls superclass method
# File lib/declarative_authorization/obligation_scope.rb, line 46
def initialize (model, options)
  @finder_options = {}
  if Rails.version < "3"
    super(model, options)
  else
    super(model, model.table_name)
  end
end

Public Instance Methods

parse!( obligation ) click to toggle source

Consumes the given obligation, converting it into scope join and condition options.

# File lib/declarative_authorization/obligation_scope.rb, line 65
def parse!( obligation )
  @current_obligation = obligation
  @join_table_joins = Set.new
  obligation_conditions[@current_obligation] ||= {}
  follow_path( obligation )

  rebuild_condition_options!
  rebuild_join_options!
end
scope() click to toggle source
# File lib/declarative_authorization/obligation_scope.rb, line 55
def scope
  if Rails.version < "3"
    self
  else
    # for Rails < 3: scope, after setting proxy_options
    self.klass.scoped(@finder_options)
  end
end

Protected Instance Methods

add_obligation_condition_for( path, expression ) click to toggle source

Adds the given expression to the current obligation's indicated path's conditions.

Condition expressions must follow the format +[ <attribute>, <operator>, <value> ]+.

# File lib/declarative_authorization/obligation_scope.rb, line 128
def add_obligation_condition_for( path, expression )
  raise "invalid expression #{expression.inspect}" unless expression.is_a?( Array ) && expression.length == 3
  add_obligation_join_for( path )
  obligation_conditions[@current_obligation] ||= {}
  ( obligation_conditions[@current_obligation][path] ||= Set.new ) << expression
end
add_obligation_join_for( path ) click to toggle source

Adds the given path to the list of obligation joins, if we haven't seen it before.

# File lib/declarative_authorization/obligation_scope.rb, line 136
def add_obligation_join_for( path )
  map_reflection_for( path ) if reflections[path].nil?
end
attribute_value(value) click to toggle source
# File lib/declarative_authorization/obligation_scope.rb, line 294
def attribute_value (value)
  value.class.respond_to?(:descends_from_active_record?) && value.class.descends_from_active_record? && value.id ||
    value.is_a?(Array) && value[0].class.respond_to?(:descends_from_active_record?) && value[0].class.descends_from_active_record? && value.map( &:id ) ||
    value
end
finder_options() click to toggle source
# File lib/declarative_authorization/obligation_scope.rb, line 109
def finder_options
  Rails.version < "3" ? @proxy_options : @finder_options
end
follow_comparison( steps, past_steps, attribute ) click to toggle source

At the end of every association path, we expect to see a comparison of some kind; for example, +:attr => [ :is, :value ]+.

This method parses the comparison and creates an obligation condition from it.

# File lib/declarative_authorization/obligation_scope.rb, line 117
def follow_comparison( steps, past_steps, attribute )
  operator = steps[0]
  value = steps[1..-1]
  value = value[0] if value.length == 1

  add_obligation_condition_for( past_steps, [attribute, operator, value] )
end
follow_path( steps, past_steps = [] ) click to toggle source

Parses the next step in the association path. If it's an association, we advance down the path. Otherwise, it's an attribute, and we need to evaluate it as a comparison operation.

# File lib/declarative_authorization/obligation_scope.rb, line 79
def follow_path( steps, past_steps = [] )
  if steps.is_a?( Hash )
    steps.each do |step, next_steps|
      path_to_this_point = [past_steps, step].flatten
      reflection = reflection_for( path_to_this_point ) rescue nil
      if reflection
        follow_path( next_steps, path_to_this_point )
      else
        follow_comparison( next_steps, past_steps, step )
      end
    end
  elsif steps.is_a?( Array ) && steps.length == 2
    if reflection_for( past_steps )
      follow_comparison( steps, past_steps, :id )
    else
      follow_comparison( steps, past_steps[0..-2], past_steps[-1] )
    end
  else
    raise "invalid obligation path #{[past_steps, steps].inspect}"
  end
end
join_to_path(join) click to toggle source
# File lib/declarative_authorization/obligation_scope.rb, line 348
def join_to_path (join)
  case join
  when Symbol
    [join]
  when Hash
    [join.keys.first] + join_to_path(join[join.keys.first])
  end
end
map_reflection_for( path ) click to toggle source

Attempts to map a reflection for the given path. Raises if already defined.

# File lib/declarative_authorization/obligation_scope.rb, line 169
def map_reflection_for( path )
  raise "reflection for #{path.inspect} already exists" unless reflections[path].nil?

  reflection = path.empty? ? top_level_model : begin
    parent = reflection_for( path[0..-2] )
    if !Authorization.is_a_association_proxy?(parent) and parent.respond_to?(:klass)
      parent.klass.reflect_on_association( path.last )
    else
      parent.reflect_on_association( path.last )
    end
  rescue
    parent.reflect_on_association( path.last )
  end
  raise "invalid path #{path.inspect}" if reflection.nil?

  reflections[path] = reflection
  map_table_alias_for( path )  # Claim a table alias for the path.

  # Claim alias for join table
  # TODO change how this is checked
  if !Authorization.is_a_association_proxy?(reflection) and !reflection.respond_to?(:proxy_scope) and reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
    join_table_path = path[0..-2] + [reflection.options[:through]]
    reflection_for(join_table_path, true)
  end
  
  reflection
end
map_table_alias_for( path ) click to toggle source

Attempts to map a table alias for the given path. Raises if already defined.

# File lib/declarative_authorization/obligation_scope.rb, line 198
def map_table_alias_for( path )
  return "table alias for #{path.inspect} already exists" unless table_aliases[path].nil?
  
  reflection = reflection_for( path )
  table_alias = reflection.table_name
  if table_aliases.values.include?( table_alias )
    max_length = reflection.active_record.connection.table_alias_length
    # Rails seems to pluralize reflection names
    table_alias = "#{reflection.name.to_s.pluralize}_#{reflection.active_record.table_name}".to(max_length-1)
  end            
  while table_aliases.values.include?( table_alias )
    if table_alias =~ /\w(_\d+?)$/
      table_index = $1.succ
      table_alias = "#{table_alias[0..-(table_index.length+1)]}_#{table_index}"
    else
      table_alias = "#{table_alias[0..(max_length-3)]}_2" 
    end
  end
  table_aliases[path] = table_alias
end
model_for(path) click to toggle source

Returns the model associated with the given path.

# File lib/declarative_authorization/obligation_scope.rb, line 141
def model_for (path)
  reflection = reflection_for(path)

  if Authorization.is_a_association_proxy?(reflection)
    if Rails.version < "3.2"
      reflection.proxy_reflection.klass
    else
      reflection.proxy_association.reflection.klass
    end
  elsif reflection.respond_to?(:klass)
    reflection.klass
  else
    reflection
  end
end
obligation_conditions() click to toggle source

Returns a hash mapping obligations to zero or more condition path sets.

# File lib/declarative_authorization/obligation_scope.rb, line 220
def obligation_conditions
  @obligation_conditions ||= {}
end
path_to_join(path) click to toggle source
# File lib/declarative_authorization/obligation_scope.rb, line 335
def path_to_join (path)
  case path.length
  when 0 then nil
  when 1 then path[0]
  else
    hash = { path[-2] => path[-1] }
    path[0..-3].reverse.each do |elem|
      hash = { elem => hash }
    end
    hash
  end
end
rebuild_condition_options!() click to toggle source

Parses all of the defined obligation conditions and defines the scope's :conditions option.

# File lib/declarative_authorization/obligation_scope.rb, line 236
def rebuild_condition_options!
  conds = []
  binds = {}
  used_paths = Set.new
  delete_paths = Set.new
  obligation_conditions.each_with_index do |array, obligation_index|
    obligation, conditions = array
    obligation_conds = []
    conditions.each do |path, expressions|
      model = model_for( path )
      table_alias = table_alias_for(path)
      parent_model = (path.length > 1 ? model_for(path[0..-2]) : top_level_model)
      expressions.each do |expression|
        attribute, operator, value = expression
        # prevent unnecessary joins:
        if attribute == :id and operator == :is and parent_model.columns_hash["#{path.last}_id"]
          attribute_name = :"#{path.last}_id"
          attribute_table_alias = table_alias_for(path[0..-2])
          used_paths << path[0..-2]
          delete_paths << path
        else
          attribute_name = model.columns_hash["#{attribute}_id"] && :"#{attribute}_id" ||
                           model.columns_hash[attribute.to_s]    && attribute ||
                           model.primary_key
          attribute_table_alias = table_alias
          used_paths << path
        end
        bindvar = "#{attribute_table_alias}__#{attribute_name}_#{obligation_index}".to_sym

        sql_attribute = "#{parent_model.connection.quote_table_name(attribute_table_alias)}." +
            "#{parent_model.connection.quote_table_name(attribute_name)}"
        if value.nil? and [:is, :is_not].include?(operator)
          obligation_conds << "#{sql_attribute} IS #{[:contains, :is].include?(operator) ? '' : 'NOT '}NULL"
        else
          attribute_operator = case operator
                               when :contains, :is             then "= :#{bindvar}"
                               when :does_not_contain, :is_not then "<> :#{bindvar}"
                               when :is_in, :intersects_with   then "IN (:#{bindvar})"
                               when :is_not_in                 then "NOT IN (:#{bindvar})"
                               when :lt                        then "< :#{bindvar}"
                               when :lte                       then "<= :#{bindvar}"
                               when :gt                        then "> :#{bindvar}"
                               when :gte                       then ">= :#{bindvar}"
                               else raise AuthorizationUsageError, "Unknown operator: #{operator}"
                               end
          obligation_conds << "#{sql_attribute} #{attribute_operator}"
          binds[bindvar] = attribute_value(value)
        end
      end
    end
    obligation_conds << "1=1" if obligation_conds.empty?
    conds << "(#{obligation_conds.join(' AND ')})"
  end
  (delete_paths - used_paths).each {|path| reflections.delete(path)}

  finder_options[:conditions] = [ conds.join( " OR " ), binds ]
end
rebuild_join_options!() click to toggle source

Parses all of the defined obligation joins and defines the scope's :joins or :includes option. TODO: Support non-linear association paths. Right now, we just break down the longest path parsed.

# File lib/declarative_authorization/obligation_scope.rb, line 302
def rebuild_join_options!
  joins = (finder_options[:joins] || []) + (finder_options[:includes] || [])

  reflections.keys.each do |path|
    next if path.empty? or @join_table_joins.include?(path)

    existing_join = joins.find do |join|
      existing_path = join_to_path(join)
      min_length = [existing_path.length, path.length].min
      existing_path.first(min_length) == path.first(min_length)
    end

    if existing_join
      if join_to_path(existing_join).length < path.length
        joins[joins.index(existing_join)] = path_to_join(path)
      end
    else
      joins << path_to_join(path)
    end
  end

  case obligation_conditions.length
  when 0 then
    # No obligation conditions means we don't have to mess with joins or includes at all.
  when 1 then
    finder_options[:joins] = joins
    finder_options.delete( :include )
  else
    finder_options.delete( :joins )
    finder_options[:include] = joins
  end
end
reflection_for(path, for_join_table_only = false) click to toggle source

Returns the reflection corresponding to the given path.

# File lib/declarative_authorization/obligation_scope.rb, line 158
def reflection_for(path, for_join_table_only = false)
  @join_table_joins << path if for_join_table_only and !reflections[path]
  reflections[path] ||= map_reflection_for( path )
end
reflections() click to toggle source

Returns a hash mapping paths to reflections.

# File lib/declarative_authorization/obligation_scope.rb, line 225
def reflections
  # lets try to get the order of joins right
  @reflections ||= ActiveSupport::OrderedHash.new
end
table_alias_for( path ) click to toggle source

Returns a proper table alias for the given path. This alias may be used in SQL statements.

# File lib/declarative_authorization/obligation_scope.rb, line 164
def table_alias_for( path )
  table_aliases[path] ||= map_table_alias_for( path )
end
table_aliases() click to toggle source

Returns a hash mapping paths to proper table aliases to use in SQL statements.

# File lib/declarative_authorization/obligation_scope.rb, line 231
def table_aliases
  @table_aliases ||= {}
end
top_level_model() click to toggle source
# File lib/declarative_authorization/obligation_scope.rb, line 101
def top_level_model
  if Rails.version < "3"
    @proxy_scope
  else
    self.klass
  end
end