Class Authorization::ObligationScope
In: lib/declarative_authorization/obligation_scope.rb
Parent: (Rails.version < "3" ? ActiveRecord::NamedScope::Scope : ActiveRecord::Relation)

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.)

+@obligation_conditions[<obligation>][[:bar, :baz, :foo]] = [

  [ :attr, :is, <user.id> ]

]+

TODO update doc for Relations: After successfully parsing an obligation, all of the stored paths and conditions are converted into scope options (stored in proxy_options as +:joins+ and +:conditions+). The resulting scope may then be used to find all scoped objects for which at least one of the parsed obligations is fully met.

+@proxy_options[:joins] = { :bar => { :baz => :foo } } @proxy_options[:conditions] = [ ‘foos_bazzes.attr = :foos_bazzes__id_0’, { :foos_bazzes__id_0 => 1 } ]+

Methods

Public Class methods

[Source]

    # File lib/declarative_authorization/obligation_scope.rb, line 46
46:     def initialize (model, options)
47:       @finder_options = {}
48:       if Rails.version < "3"
49:         super(model, options)
50:       else
51:         super(model, model.table_name)
52:       end
53:     end

Public Instance methods

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

[Source]

    # File lib/declarative_authorization/obligation_scope.rb, line 65
65:     def parse!( obligation )
66:       @current_obligation = obligation
67:       @join_table_joins = Set.new
68:       obligation_conditions[@current_obligation] ||= {}
69:       follow_path( obligation )
70: 
71:       rebuild_condition_options!
72:       rebuild_join_options!
73:     end

[Source]

    # File lib/declarative_authorization/obligation_scope.rb, line 55
55:     def scope
56:       if Rails.version < "3"
57:         self
58:       else
59:         # for Rails < 3: scope, after setting proxy_options
60:         self.klass.scoped(@finder_options)
61:       end
62:     end

Protected Instance methods

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

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

[Source]

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

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

[Source]

     # File lib/declarative_authorization/obligation_scope.rb, line 136
136:     def add_obligation_join_for( path )
137:       map_reflection_for( path ) if reflections[path].nil?
138:     end

[Source]

     # File lib/declarative_authorization/obligation_scope.rb, line 294
294:     def attribute_value (value)
295:       value.class.respond_to?(:descends_from_active_record?) && value.class.descends_from_active_record? && value.id ||
296:         value.is_a?(Array) && value[0].class.respond_to?(:descends_from_active_record?) && value[0].class.descends_from_active_record? && value.map( &:id ) ||
297:         value
298:     end

[Source]

     # File lib/declarative_authorization/obligation_scope.rb, line 109
109:     def finder_options
110:       Rails.version < "3" ? @proxy_options : @finder_options
111:     end

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.

[Source]

     # File lib/declarative_authorization/obligation_scope.rb, line 117
117:     def follow_comparison( steps, past_steps, attribute )
118:       operator = steps[0]
119:       value = steps[1..-1]
120:       value = value[0] if value.length == 1
121: 
122:       add_obligation_condition_for( past_steps, [attribute, operator, value] )
123:     end

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.

[Source]

    # File lib/declarative_authorization/obligation_scope.rb, line 79
79:     def follow_path( steps, past_steps = [] )
80:       if steps.is_a?( Hash )
81:         steps.each do |step, next_steps|
82:           path_to_this_point = [past_steps, step].flatten
83:           reflection = reflection_for( path_to_this_point ) rescue nil
84:           if reflection
85:             follow_path( next_steps, path_to_this_point )
86:           else
87:             follow_comparison( next_steps, past_steps, step )
88:           end
89:         end
90:       elsif steps.is_a?( Array ) && steps.length == 2
91:         if reflection_for( past_steps )
92:           follow_comparison( steps, past_steps, :id )
93:         else
94:           follow_comparison( steps, past_steps[0..-2], past_steps[-1] )
95:         end
96:       else
97:         raise "invalid obligation path #{[past_steps, steps].inspect}"
98:       end
99:     end

[Source]

     # File lib/declarative_authorization/obligation_scope.rb, line 348
348:     def join_to_path (join)
349:       case join
350:       when Symbol
351:         [join]
352:       when Hash
353:         [join.keys.first] + join_to_path(join[join.keys.first])
354:       end
355:     end

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

[Source]

     # File lib/declarative_authorization/obligation_scope.rb, line 169
169:     def map_reflection_for( path )
170:       raise "reflection for #{path.inspect} already exists" unless reflections[path].nil?
171: 
172:       reflection = path.empty? ? top_level_model : begin
173:         parent = reflection_for( path[0..-2] )
174:         if !Authorization.is_a_association_proxy?(parent) and parent.respond_to?(:klass)
175:           parent.klass.reflect_on_association( path.last )
176:         else
177:           parent.reflect_on_association( path.last )
178:         end
179:       rescue
180:         parent.reflect_on_association( path.last )
181:       end
182:       raise "invalid path #{path.inspect}" if reflection.nil?
183: 
184:       reflections[path] = reflection
185:       map_table_alias_for( path )  # Claim a table alias for the path.
186: 
187:       # Claim alias for join table
188:       # TODO change how this is checked
189:       if !Authorization.is_a_association_proxy?(reflection) and !reflection.respond_to?(:proxy_scope) and reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
190:         join_table_path = path[0..-2] + [reflection.options[:through]]
191:         reflection_for(join_table_path, true)
192:       end
193:       
194:       reflection
195:     end

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

[Source]

     # File lib/declarative_authorization/obligation_scope.rb, line 198
198:     def map_table_alias_for( path )
199:       return "table alias for #{path.inspect} already exists" unless table_aliases[path].nil?
200:       
201:       reflection = reflection_for( path )
202:       table_alias = reflection.table_name
203:       if table_aliases.values.include?( table_alias )
204:         max_length = reflection.active_record.connection.table_alias_length
205:         # Rails seems to pluralize reflection names
206:         table_alias = "#{reflection.name.to_s.pluralize}_#{reflection.active_record.table_name}".to(max_length-1)
207:       end            
208:       while table_aliases.values.include?( table_alias )
209:         if table_alias =~ /\w(_\d+?)$/
210:           table_index = $1.succ
211:           table_alias = "#{table_alias[0..-(table_index.length+1)]}_#{table_index}"
212:         else
213:           table_alias = "#{table_alias[0..(max_length-3)]}_2" 
214:         end
215:       end
216:       table_aliases[path] = table_alias
217:     end

Returns the model associated with the given path.

[Source]

     # File lib/declarative_authorization/obligation_scope.rb, line 141
141:     def model_for (path)
142:       reflection = reflection_for(path)
143: 
144:       if Authorization.is_a_association_proxy?(reflection)
145:         if Rails.version < "3.2"
146:           reflection.proxy_reflection.klass
147:         else
148:           reflection.proxy_association.reflection.klass
149:         end
150:       elsif reflection.respond_to?(:klass)
151:         reflection.klass
152:       else
153:         reflection
154:       end
155:     end

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

[Source]

     # File lib/declarative_authorization/obligation_scope.rb, line 220
220:     def obligation_conditions
221:       @obligation_conditions ||= {}
222:     end

[Source]

     # File lib/declarative_authorization/obligation_scope.rb, line 335
335:     def path_to_join (path)
336:       case path.length
337:       when 0 then nil
338:       when 1 then path[0]
339:       else
340:         hash = { path[-2] => path[-1] }
341:         path[0..-3].reverse.each do |elem|
342:           hash = { elem => hash }
343:         end
344:         hash
345:       end
346:     end

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

[Source]

     # File lib/declarative_authorization/obligation_scope.rb, line 236
236:     def rebuild_condition_options!
237:       conds = []
238:       binds = {}
239:       used_paths = Set.new
240:       delete_paths = Set.new
241:       obligation_conditions.each_with_index do |array, obligation_index|
242:         obligation, conditions = array
243:         obligation_conds = []
244:         conditions.each do |path, expressions|
245:           model = model_for( path )
246:           table_alias = table_alias_for(path)
247:           parent_model = (path.length > 1 ? model_for(path[0..-2]) : top_level_model)
248:           expressions.each do |expression|
249:             attribute, operator, value = expression
250:             # prevent unnecessary joins:
251:             if attribute == :id and operator == :is and parent_model.columns_hash["#{path.last}_id"]
252:               attribute_name = "#{path.last}_id""#{path.last}_id"
253:               attribute_table_alias = table_alias_for(path[0..-2])
254:               used_paths << path[0..-2]
255:               delete_paths << path
256:             else
257:               attribute_name = model.columns_hash["#{attribute}_id"] && "#{attribute}_id""#{attribute}_id" ||
258:                                model.columns_hash[attribute.to_s]    && attribute ||
259:                                :id
260:               attribute_table_alias = table_alias
261:               used_paths << path
262:             end
263:             bindvar = "#{attribute_table_alias}__#{attribute_name}_#{obligation_index}".to_sym
264: 
265:             sql_attribute = "#{parent_model.connection.quote_table_name(attribute_table_alias)}." +
266:                 "#{parent_model.connection.quote_table_name(attribute_name)}"
267:             if value.nil? and [:is, :is_not].include?(operator)
268:               obligation_conds << "#{sql_attribute} IS #{[:contains, :is].include?(operator) ? '' : 'NOT '}NULL"
269:             else
270:               attribute_operator = case operator
271:                                    when :contains, :is             then "= :#{bindvar}"
272:                                    when :does_not_contain, :is_not then "<> :#{bindvar}"
273:                                    when :is_in, :intersects_with   then "IN (:#{bindvar})"
274:                                    when :is_not_in                 then "NOT IN (:#{bindvar})"
275:                                    when :lt                        then "< :#{bindvar}"
276:                                    when :lte                       then "<= :#{bindvar}"
277:                                    when :gt                        then "> :#{bindvar}"
278:                                    when :gte                       then ">= :#{bindvar}"
279:                                    else raise AuthorizationUsageError, "Unknown operator: #{operator}"
280:                                    end
281:               obligation_conds << "#{sql_attribute} #{attribute_operator}"
282:               binds[bindvar] = attribute_value(value)
283:             end
284:           end
285:         end
286:         obligation_conds << "1=1" if obligation_conds.empty?
287:         conds << "(#{obligation_conds.join(' AND ')})"
288:       end
289:       (delete_paths - used_paths).each {|path| reflections.delete(path)}
290: 
291:       finder_options[:conditions] = [ conds.join( " OR " ), binds ]
292:     end

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.

[Source]

     # File lib/declarative_authorization/obligation_scope.rb, line 302
302:     def rebuild_join_options!
303:       joins = (finder_options[:joins] || []) + (finder_options[:includes] || [])
304: 
305:       reflections.keys.each do |path|
306:         next if path.empty? or @join_table_joins.include?(path)
307: 
308:         existing_join = joins.find do |join|
309:           existing_path = join_to_path(join)
310:           min_length = [existing_path.length, path.length].min
311:           existing_path.first(min_length) == path.first(min_length)
312:         end
313: 
314:         if existing_join
315:           if join_to_path(existing_join).length < path.length
316:             joins[joins.index(existing_join)] = path_to_join(path)
317:           end
318:         else
319:           joins << path_to_join(path)
320:         end
321:       end
322: 
323:       case obligation_conditions.length
324:       when 0 then
325:         # No obligation conditions means we don't have to mess with joins or includes at all.
326:       when 1 then
327:         finder_options[:joins] = joins
328:         finder_options.delete( :include )
329:       else
330:         finder_options.delete( :joins )
331:         finder_options[:include] = joins
332:       end
333:     end

Returns the reflection corresponding to the given path.

[Source]

     # File lib/declarative_authorization/obligation_scope.rb, line 158
158:     def reflection_for(path, for_join_table_only = false)
159:       @join_table_joins << path if for_join_table_only and !reflections[path]
160:       reflections[path] ||= map_reflection_for( path )
161:     end

Returns a hash mapping paths to reflections.

[Source]

     # File lib/declarative_authorization/obligation_scope.rb, line 225
225:     def reflections
226:       # lets try to get the order of joins right
227:       @reflections ||= ActiveSupport::OrderedHash.new
228:     end

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

[Source]

     # File lib/declarative_authorization/obligation_scope.rb, line 164
164:     def table_alias_for( path )
165:       table_aliases[path] ||= map_table_alias_for( path )
166:     end

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

[Source]

     # File lib/declarative_authorization/obligation_scope.rb, line 231
231:     def table_aliases
232:       @table_aliases ||= {}
233:     end

[Source]

     # File lib/declarative_authorization/obligation_scope.rb, line 101
101:     def top_level_model
102:       if Rails.version < "3"
103:         @proxy_scope
104:       else
105:         self.klass
106:       end
107:     end

[Validate]