ae_declarative_authorization 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/Appraisals +21 -21
  3. data/CHANGELOG +189 -189
  4. data/Gemfile +7 -7
  5. data/Gemfile.lock +45 -45
  6. data/LICENSE.txt +20 -20
  7. data/README.md +620 -620
  8. data/README.rdoc +597 -597
  9. data/Rakefile +33 -33
  10. data/authorization_rules.dist.rb +20 -20
  11. data/declarative_authorization.gemspec +24 -24
  12. data/gemfiles/rails4252.gemfile +10 -10
  13. data/gemfiles/rails4271.gemfile +10 -10
  14. data/gemfiles/rails507.gemfile +11 -11
  15. data/init.rb +5 -5
  16. data/lib/declarative_authorization.rb +18 -18
  17. data/lib/declarative_authorization/authorization.rb +821 -821
  18. data/lib/declarative_authorization/helper.rb +78 -78
  19. data/lib/declarative_authorization/in_controller.rb +713 -713
  20. data/lib/declarative_authorization/in_model.rb +156 -156
  21. data/lib/declarative_authorization/maintenance.rb +215 -215
  22. data/lib/declarative_authorization/obligation_scope.rb +345 -345
  23. data/lib/declarative_authorization/railsengine.rb +5 -5
  24. data/lib/declarative_authorization/reader.rb +549 -549
  25. data/lib/declarative_authorization/test/helpers.rb +261 -261
  26. data/lib/declarative_authorization/version.rb +3 -3
  27. data/lib/generators/authorization/install/install_generator.rb +77 -77
  28. data/lib/generators/authorization/rules/rules_generator.rb +13 -13
  29. data/lib/generators/authorization/rules/templates/authorization_rules.rb +27 -27
  30. data/lib/tasks/authorization_tasks.rake +89 -89
  31. data/test/authorization_test.rb +1121 -1121
  32. data/test/controller_filter_resource_access_test.rb +573 -573
  33. data/test/controller_test.rb +478 -478
  34. data/test/database.yml +3 -3
  35. data/test/dsl_reader_test.rb +178 -178
  36. data/test/functional/filter_access_to_with_id_in_scope_test.rb +88 -88
  37. data/test/functional/no_filter_access_to_test.rb +79 -79
  38. data/test/functional/params_block_arity_test.rb +39 -39
  39. data/test/helper_test.rb +248 -248
  40. data/test/maintenance_test.rb +46 -46
  41. data/test/model_test.rb +1840 -1840
  42. data/test/schema.sql +60 -60
  43. data/test/test_helper.rb +174 -174
  44. data/test/test_support/minitest_compatibility.rb +26 -26
  45. metadata +3 -9
  46. data/gemfiles/rails4252.gemfile.lock +0 -126
  47. data/gemfiles/rails4271.gemfile.lock +0 -126
  48. data/gemfiles/rails507.gemfile.lock +0 -136
  49. data/log/test.log +0 -34715
  50. data/test/profiles/access_checking +0 -46
@@ -1,345 +1,345 @@
1
- module Authorization
2
- # The +ObligationScope+ class parses any number of obligations into joins and conditions.
3
- #
4
- # In +ObligationScope+ parlance, "association paths" are one-dimensional arrays in which each
5
- # element represents an attribute or association (or "step"), and "leads" to the next step in the
6
- # association path.
7
- #
8
- # Suppose we have this path defined in the context of model Foo:
9
- # +{ :bar => { :baz => { :foo => { :attr => is { user } } } } }+
10
- #
11
- # To parse this path, +ObligationScope+ evaluates each step in the context of the preceding step.
12
- # The first step is evaluated in the context of the parent scope, the second step is evaluated in
13
- # the context of the first, and so forth. Every time we encounter a step representing an
14
- # association, we make note of the fact by storing the path (up to that point), assigning it a
15
- # table alias intended to match the one that will eventually be chosen by ActiveRecord when
16
- # executing the +find+ method on the scope.
17
- #
18
- # +@table_aliases = {
19
- # [] => 'foos',
20
- # [:bar] => 'bars',
21
- # [:bar, :baz] => 'bazzes',
22
- # [:bar, :baz, :foo] => 'foos_bazzes' # Alias avoids collisions with 'foos' (already used)
23
- # }+
24
- #
25
- # At the "end" of each path, we expect to find a comparison operation of some kind, generally
26
- # comparing an attribute of the most recent association with some other value (such as an ID,
27
- # constant, or array of values). When we encounter a step representing a comparison, we make
28
- # note of the fact by storing the path (up to that point) and the comparison operation together.
29
- # (Note that individual obligations' conditions are kept separate, to allow their conditions to
30
- # be OR'ed together in the generated scope options.)
31
- #
32
- # +@obligation_conditions[<obligation>][[:bar, :baz, :foo]] = [
33
- # [ :attr, :is, <user.id> ]
34
- # ]+
35
- #
36
- # TODO update doc for Relations:
37
- # After successfully parsing an obligation, all of the stored paths and conditions are converted
38
- # into scope options (stored in +proxy_options+ as +:joins+ and +:conditions+). The resulting
39
- # scope may then be used to find all scoped objects for which at least one of the parsed
40
- # obligations is fully met.
41
- #
42
- # +@proxy_options[:joins] = { :bar => { :baz => :foo } }
43
- # @proxy_options[:conditions] = [ 'foos_bazzes.attr = :foos_bazzes__id_0', { :foos_bazzes__id_0 => 1 } ]+
44
- #
45
- class ObligationScope < ActiveRecord::Relation
46
- def initialize(model, options)
47
- @finder_options = {}
48
- if Rails.version >= '5'
49
- super(model, model.table_name, nil)
50
- else
51
- super(model, model.table_name)
52
- end
53
- end
54
-
55
- def scope
56
- # TODO Refactor this. There is certainly a better way.
57
- self.klass.joins(@finder_options[:joins]).includes(@finder_options[:include]).where(@finder_options[:conditions]).references(@finder_options[:include])
58
- end
59
-
60
- # Consumes the given obligation, converting it into scope join and condition options.
61
- def parse!( obligation )
62
- @current_obligation = obligation
63
- @join_table_joins = Set.new
64
- obligation_conditions[@current_obligation] ||= {}
65
- follow_path( obligation )
66
-
67
- rebuild_condition_options!
68
- rebuild_join_options!
69
- end
70
-
71
- protected
72
-
73
- # Parses the next step in the association path. If it's an association, we advance down the
74
- # path. Otherwise, it's an attribute, and we need to evaluate it as a comparison operation.
75
- def follow_path( steps, past_steps = [] )
76
- if steps.is_a?( Hash )
77
- steps.each do |step, next_steps|
78
- path_to_this_point = [past_steps, step].flatten
79
- reflection = reflection_for( path_to_this_point ) rescue nil
80
- if reflection
81
- follow_path( next_steps, path_to_this_point )
82
- else
83
- follow_comparison( next_steps, past_steps, step )
84
- end
85
- end
86
- elsif steps.is_a?( Array ) && steps.length == 2
87
- if reflection_for( past_steps )
88
- follow_comparison( steps, past_steps, :id )
89
- else
90
- follow_comparison( steps, past_steps[0..-2], past_steps[-1] )
91
- end
92
- else
93
- raise "invalid obligation path #{[past_steps, steps].inspect}"
94
- end
95
- end
96
-
97
- def top_level_model
98
- self.klass
99
- end
100
-
101
- def finder_options
102
- @finder_options
103
- end
104
-
105
- # At the end of every association path, we expect to see a comparison of some kind; for
106
- # example, +:attr => [ :is, :value ]+.
107
- #
108
- # This method parses the comparison and creates an obligation condition from it.
109
- def follow_comparison( steps, past_steps, attribute )
110
- operator = steps[0]
111
- value = steps[1..-1]
112
- value = value[0] if value.length == 1
113
-
114
- add_obligation_condition_for( past_steps, [attribute, operator, value] )
115
- end
116
-
117
- # Adds the given expression to the current obligation's indicated path's conditions.
118
- #
119
- # Condition expressions must follow the format +[ <attribute>, <operator>, <value> ]+.
120
- def add_obligation_condition_for( path, expression )
121
- raise "invalid expression #{expression.inspect}" unless expression.is_a?( Array ) && expression.length == 3
122
- add_obligation_join_for( path )
123
- obligation_conditions[@current_obligation] ||= {}
124
- ( obligation_conditions[@current_obligation][path] ||= Set.new ) << expression
125
- end
126
-
127
- # Adds the given path to the list of obligation joins, if we haven't seen it before.
128
- def add_obligation_join_for( path )
129
- map_reflection_for( path ) if reflections[path].nil?
130
- end
131
-
132
- # Returns the model associated with the given path.
133
- def model_for(path)
134
- reflection = reflection_for(path)
135
-
136
- if Authorization.is_a_association_proxy?(reflection)
137
- reflection.proxy_association.reflection.klass
138
- elsif reflection.respond_to?(:klass)
139
- reflection.klass
140
- else
141
- reflection
142
- end
143
- end
144
-
145
- # Returns the reflection corresponding to the given path.
146
- def reflection_for(path, for_join_table_only = false)
147
- @join_table_joins << path if for_join_table_only and !reflections[path]
148
- reflections[path] ||= map_reflection_for( path )
149
- end
150
-
151
- # Returns a proper table alias for the given path. This alias may be used in SQL statements.
152
- def table_alias_for( path )
153
- table_aliases[path] ||= map_table_alias_for( path )
154
- end
155
-
156
- # Attempts to map a reflection for the given path. Raises if already defined.
157
- def map_reflection_for( path )
158
- raise "reflection for #{path.inspect} already exists" unless reflections[path].nil?
159
-
160
- reflection = path.empty? ? top_level_model : begin
161
- parent = reflection_for( path[0..-2] )
162
- if !Authorization.is_a_association_proxy?(parent) and parent.respond_to?(:klass)
163
- parent.klass.reflect_on_association( path.last )
164
- else
165
- parent.reflect_on_association( path.last )
166
- end
167
- rescue
168
- parent.reflect_on_association( path.last )
169
- end
170
- raise "invalid path #{path.inspect}" if reflection.nil?
171
-
172
- reflections[path] = reflection
173
- map_table_alias_for( path ) # Claim a table alias for the path.
174
-
175
- # Claim alias for join table
176
- # TODO change how this is checked
177
- if !Authorization.is_a_association_proxy?(reflection) and !reflection.respond_to?(:proxy_scope) and reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
178
- join_table_path = path[0..-2] + [reflection.options[:through]]
179
- reflection_for(join_table_path, true)
180
- end
181
-
182
- reflection
183
- end
184
-
185
- # Attempts to map a table alias for the given path. Raises if already defined.
186
- def map_table_alias_for( path )
187
- return "table alias for #{path.inspect} already exists" unless table_aliases[path].nil?
188
-
189
- reflection = reflection_for( path )
190
- table_alias = reflection.table_name
191
- if table_aliases.values.include?( table_alias )
192
- max_length = reflection.active_record.connection.table_alias_length
193
- # Rails seems to pluralize reflection names
194
- table_alias = "#{reflection.name.to_s.pluralize}_#{reflection.active_record.table_name}".to(max_length-1)
195
- end
196
- while table_aliases.values.include?( table_alias )
197
- if table_alias =~ /\w(_\d+?)$/
198
- table_index = $1.succ
199
- table_alias = "#{table_alias[0..-(table_index.length+1)]}_#{table_index}"
200
- else
201
- table_alias = "#{table_alias[0..(max_length-3)]}_2"
202
- end
203
- end
204
- table_aliases[path] = table_alias
205
- end
206
-
207
- # Returns a hash mapping obligations to zero or more condition path sets.
208
- def obligation_conditions
209
- @obligation_conditions ||= {}
210
- end
211
-
212
- # Returns a hash mapping paths to reflections.
213
- def reflections
214
- # lets try to get the order of joins right
215
- @reflections ||= ActiveSupport::OrderedHash.new
216
- end
217
-
218
- # Returns a hash mapping paths to proper table aliases to use in SQL statements.
219
- def table_aliases
220
- @table_aliases ||= {}
221
- end
222
-
223
- # Parses all of the defined obligation conditions and defines the scope's :conditions option.
224
- def rebuild_condition_options!
225
- conds = []
226
- binds = {}
227
- used_paths = Set.new
228
- delete_paths = Set.new
229
- obligation_conditions.each_with_index do |array, obligation_index|
230
- obligation, conditions = array
231
- obligation_conds = []
232
- conditions.each do |path, expressions|
233
- model = model_for( path )
234
- table_alias = table_alias_for(path)
235
- parent_model = (path.length > 1 ? model_for(path[0..-2]) : top_level_model)
236
- expressions.each do |expression|
237
- attribute, operator, value = expression
238
- # prevent unnecessary joins:
239
- if attribute == :id and operator == :is and parent_model.columns_hash["#{path.last}_id"]
240
- attribute_name = :"#{path.last}_id"
241
- attribute_table_alias = table_alias_for(path[0..-2])
242
- used_paths << path[0..-2]
243
- delete_paths << path
244
- else
245
- attribute_name = model.columns_hash["#{attribute}_id"] && :"#{attribute}_id" ||
246
- model.columns_hash[attribute.to_s] && attribute ||
247
- model.primary_key
248
- attribute_table_alias = table_alias
249
- used_paths << path
250
- end
251
- bindvar = "#{attribute_table_alias}__#{attribute_name}_#{obligation_index}".to_sym
252
-
253
- sql_attribute = "#{parent_model.connection.quote_table_name(attribute_table_alias)}." +
254
- "#{parent_model.connection.quote_table_name(attribute_name)}"
255
- if value.nil? and [:is, :is_not].include?(operator)
256
- obligation_conds << "#{sql_attribute} IS #{[:contains, :is].include?(operator) ? '' : 'NOT '}NULL"
257
- else
258
- attribute_operator = case operator
259
- when :contains, :is then "= :#{bindvar}"
260
- when :does_not_contain, :is_not then "<> :#{bindvar}"
261
- when :is_in, :intersects_with then "IN (:#{bindvar})"
262
- when :is_not_in then "NOT IN (:#{bindvar})"
263
- when :lt then "< :#{bindvar}"
264
- when :lte then "<= :#{bindvar}"
265
- when :gt then "> :#{bindvar}"
266
- when :gte then ">= :#{bindvar}"
267
- else raise AuthorizationUsageError, "Unknown operator: #{operator}"
268
- end
269
- obligation_conds << "#{sql_attribute} #{attribute_operator}"
270
- binds[bindvar] = attribute_value(value)
271
- end
272
- end
273
- end
274
- obligation_conds << "1=1" if obligation_conds.empty?
275
- conds << "(#{obligation_conds.join(' AND ')})"
276
- end
277
- (delete_paths - used_paths).each {|path| reflections.delete(path)}
278
-
279
- finder_options[:conditions] = [ conds.join( " OR " ), binds ]
280
- end
281
-
282
- def attribute_value(value)
283
- value.class.respond_to?(:descends_from_active_record?) && value.class.descends_from_active_record? && value.id ||
284
- value.is_a?(Array) && value[0].class.respond_to?(:descends_from_active_record?) && value[0].class.descends_from_active_record? && value.map( &:id ) ||
285
- value
286
- end
287
-
288
- # Parses all of the defined obligation joins and defines the scope's :joins or :includes option.
289
- # TODO: Support non-linear association paths. Right now, we just break down the longest path parsed.
290
- def rebuild_join_options!
291
- joins = (finder_options[:joins] || []) + (finder_options[:includes] || [])
292
-
293
- reflections.keys.each do |path|
294
- next if path.empty? or @join_table_joins.include?(path)
295
-
296
- existing_join = joins.find do |join|
297
- existing_path = join_to_path(join)
298
- min_length = [existing_path.length, path.length].min
299
- existing_path.first(min_length) == path.first(min_length)
300
- end
301
-
302
- if existing_join
303
- if join_to_path(existing_join).length < path.length
304
- joins[joins.index(existing_join)] = path_to_join(path)
305
- end
306
- else
307
- joins << path_to_join(path)
308
- end
309
- end
310
-
311
- case obligation_conditions.length
312
- when 0 then
313
- # No obligation conditions means we don't have to mess with joins or includes at all.
314
- when 1 then
315
- finder_options[:joins] = joins
316
- finder_options.delete( :include )
317
- else
318
- finder_options.delete( :joins )
319
- finder_options[:include] = joins
320
- end
321
- end
322
-
323
- def path_to_join(path)
324
- case path.length
325
- when 0 then nil
326
- when 1 then path[0]
327
- else
328
- hash = { path[-2] => path[-1] }
329
- path[0..-3].reverse.each do |elem|
330
- hash = { elem => hash }
331
- end
332
- hash
333
- end
334
- end
335
-
336
- def join_to_path(join)
337
- case join
338
- when Symbol
339
- [join]
340
- when Hash
341
- [join.keys.first] + join_to_path(join[join.keys.first])
342
- end
343
- end
344
- end
345
- end
1
+ module Authorization
2
+ # The +ObligationScope+ class parses any number of obligations into joins and conditions.
3
+ #
4
+ # In +ObligationScope+ parlance, "association paths" are one-dimensional arrays in which each
5
+ # element represents an attribute or association (or "step"), and "leads" to the next step in the
6
+ # association path.
7
+ #
8
+ # Suppose we have this path defined in the context of model Foo:
9
+ # +{ :bar => { :baz => { :foo => { :attr => is { user } } } } }+
10
+ #
11
+ # To parse this path, +ObligationScope+ evaluates each step in the context of the preceding step.
12
+ # The first step is evaluated in the context of the parent scope, the second step is evaluated in
13
+ # the context of the first, and so forth. Every time we encounter a step representing an
14
+ # association, we make note of the fact by storing the path (up to that point), assigning it a
15
+ # table alias intended to match the one that will eventually be chosen by ActiveRecord when
16
+ # executing the +find+ method on the scope.
17
+ #
18
+ # +@table_aliases = {
19
+ # [] => 'foos',
20
+ # [:bar] => 'bars',
21
+ # [:bar, :baz] => 'bazzes',
22
+ # [:bar, :baz, :foo] => 'foos_bazzes' # Alias avoids collisions with 'foos' (already used)
23
+ # }+
24
+ #
25
+ # At the "end" of each path, we expect to find a comparison operation of some kind, generally
26
+ # comparing an attribute of the most recent association with some other value (such as an ID,
27
+ # constant, or array of values). When we encounter a step representing a comparison, we make
28
+ # note of the fact by storing the path (up to that point) and the comparison operation together.
29
+ # (Note that individual obligations' conditions are kept separate, to allow their conditions to
30
+ # be OR'ed together in the generated scope options.)
31
+ #
32
+ # +@obligation_conditions[<obligation>][[:bar, :baz, :foo]] = [
33
+ # [ :attr, :is, <user.id> ]
34
+ # ]+
35
+ #
36
+ # TODO update doc for Relations:
37
+ # After successfully parsing an obligation, all of the stored paths and conditions are converted
38
+ # into scope options (stored in +proxy_options+ as +:joins+ and +:conditions+). The resulting
39
+ # scope may then be used to find all scoped objects for which at least one of the parsed
40
+ # obligations is fully met.
41
+ #
42
+ # +@proxy_options[:joins] = { :bar => { :baz => :foo } }
43
+ # @proxy_options[:conditions] = [ 'foos_bazzes.attr = :foos_bazzes__id_0', { :foos_bazzes__id_0 => 1 } ]+
44
+ #
45
+ class ObligationScope < ActiveRecord::Relation
46
+ def initialize(model, options)
47
+ @finder_options = {}
48
+ if Rails.version >= '5'
49
+ super(model, model.table_name, nil)
50
+ else
51
+ super(model, model.table_name)
52
+ end
53
+ end
54
+
55
+ def scope
56
+ # TODO Refactor this. There is certainly a better way.
57
+ self.klass.joins(@finder_options[:joins]).includes(@finder_options[:include]).where(@finder_options[:conditions]).references(@finder_options[:include])
58
+ end
59
+
60
+ # Consumes the given obligation, converting it into scope join and condition options.
61
+ def parse!( obligation )
62
+ @current_obligation = obligation
63
+ @join_table_joins = Set.new
64
+ obligation_conditions[@current_obligation] ||= {}
65
+ follow_path( obligation )
66
+
67
+ rebuild_condition_options!
68
+ rebuild_join_options!
69
+ end
70
+
71
+ protected
72
+
73
+ # Parses the next step in the association path. If it's an association, we advance down the
74
+ # path. Otherwise, it's an attribute, and we need to evaluate it as a comparison operation.
75
+ def follow_path( steps, past_steps = [] )
76
+ if steps.is_a?( Hash )
77
+ steps.each do |step, next_steps|
78
+ path_to_this_point = [past_steps, step].flatten
79
+ reflection = reflection_for( path_to_this_point ) rescue nil
80
+ if reflection
81
+ follow_path( next_steps, path_to_this_point )
82
+ else
83
+ follow_comparison( next_steps, past_steps, step )
84
+ end
85
+ end
86
+ elsif steps.is_a?( Array ) && steps.length == 2
87
+ if reflection_for( past_steps )
88
+ follow_comparison( steps, past_steps, :id )
89
+ else
90
+ follow_comparison( steps, past_steps[0..-2], past_steps[-1] )
91
+ end
92
+ else
93
+ raise "invalid obligation path #{[past_steps, steps].inspect}"
94
+ end
95
+ end
96
+
97
+ def top_level_model
98
+ self.klass
99
+ end
100
+
101
+ def finder_options
102
+ @finder_options
103
+ end
104
+
105
+ # At the end of every association path, we expect to see a comparison of some kind; for
106
+ # example, +:attr => [ :is, :value ]+.
107
+ #
108
+ # This method parses the comparison and creates an obligation condition from it.
109
+ def follow_comparison( steps, past_steps, attribute )
110
+ operator = steps[0]
111
+ value = steps[1..-1]
112
+ value = value[0] if value.length == 1
113
+
114
+ add_obligation_condition_for( past_steps, [attribute, operator, value] )
115
+ end
116
+
117
+ # Adds the given expression to the current obligation's indicated path's conditions.
118
+ #
119
+ # Condition expressions must follow the format +[ <attribute>, <operator>, <value> ]+.
120
+ def add_obligation_condition_for( path, expression )
121
+ raise "invalid expression #{expression.inspect}" unless expression.is_a?( Array ) && expression.length == 3
122
+ add_obligation_join_for( path )
123
+ obligation_conditions[@current_obligation] ||= {}
124
+ ( obligation_conditions[@current_obligation][path] ||= Set.new ) << expression
125
+ end
126
+
127
+ # Adds the given path to the list of obligation joins, if we haven't seen it before.
128
+ def add_obligation_join_for( path )
129
+ map_reflection_for( path ) if reflections[path].nil?
130
+ end
131
+
132
+ # Returns the model associated with the given path.
133
+ def model_for(path)
134
+ reflection = reflection_for(path)
135
+
136
+ if Authorization.is_a_association_proxy?(reflection)
137
+ reflection.proxy_association.reflection.klass
138
+ elsif reflection.respond_to?(:klass)
139
+ reflection.klass
140
+ else
141
+ reflection
142
+ end
143
+ end
144
+
145
+ # Returns the reflection corresponding to the given path.
146
+ def reflection_for(path, for_join_table_only = false)
147
+ @join_table_joins << path if for_join_table_only and !reflections[path]
148
+ reflections[path] ||= map_reflection_for( path )
149
+ end
150
+
151
+ # Returns a proper table alias for the given path. This alias may be used in SQL statements.
152
+ def table_alias_for( path )
153
+ table_aliases[path] ||= map_table_alias_for( path )
154
+ end
155
+
156
+ # Attempts to map a reflection for the given path. Raises if already defined.
157
+ def map_reflection_for( path )
158
+ raise "reflection for #{path.inspect} already exists" unless reflections[path].nil?
159
+
160
+ reflection = path.empty? ? top_level_model : begin
161
+ parent = reflection_for( path[0..-2] )
162
+ if !Authorization.is_a_association_proxy?(parent) and parent.respond_to?(:klass)
163
+ parent.klass.reflect_on_association( path.last )
164
+ else
165
+ parent.reflect_on_association( path.last )
166
+ end
167
+ rescue
168
+ parent.reflect_on_association( path.last )
169
+ end
170
+ raise "invalid path #{path.inspect}" if reflection.nil?
171
+
172
+ reflections[path] = reflection
173
+ map_table_alias_for( path ) # Claim a table alias for the path.
174
+
175
+ # Claim alias for join table
176
+ # TODO change how this is checked
177
+ if !Authorization.is_a_association_proxy?(reflection) and !reflection.respond_to?(:proxy_scope) and reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
178
+ join_table_path = path[0..-2] + [reflection.options[:through]]
179
+ reflection_for(join_table_path, true)
180
+ end
181
+
182
+ reflection
183
+ end
184
+
185
+ # Attempts to map a table alias for the given path. Raises if already defined.
186
+ def map_table_alias_for( path )
187
+ return "table alias for #{path.inspect} already exists" unless table_aliases[path].nil?
188
+
189
+ reflection = reflection_for( path )
190
+ table_alias = reflection.table_name
191
+ if table_aliases.values.include?( table_alias )
192
+ max_length = reflection.active_record.connection.table_alias_length
193
+ # Rails seems to pluralize reflection names
194
+ table_alias = "#{reflection.name.to_s.pluralize}_#{reflection.active_record.table_name}".to(max_length-1)
195
+ end
196
+ while table_aliases.values.include?( table_alias )
197
+ if table_alias =~ /\w(_\d+?)$/
198
+ table_index = $1.succ
199
+ table_alias = "#{table_alias[0..-(table_index.length+1)]}_#{table_index}"
200
+ else
201
+ table_alias = "#{table_alias[0..(max_length-3)]}_2"
202
+ end
203
+ end
204
+ table_aliases[path] = table_alias
205
+ end
206
+
207
+ # Returns a hash mapping obligations to zero or more condition path sets.
208
+ def obligation_conditions
209
+ @obligation_conditions ||= {}
210
+ end
211
+
212
+ # Returns a hash mapping paths to reflections.
213
+ def reflections
214
+ # lets try to get the order of joins right
215
+ @reflections ||= ActiveSupport::OrderedHash.new
216
+ end
217
+
218
+ # Returns a hash mapping paths to proper table aliases to use in SQL statements.
219
+ def table_aliases
220
+ @table_aliases ||= {}
221
+ end
222
+
223
+ # Parses all of the defined obligation conditions and defines the scope's :conditions option.
224
+ def rebuild_condition_options!
225
+ conds = []
226
+ binds = {}
227
+ used_paths = Set.new
228
+ delete_paths = Set.new
229
+ obligation_conditions.each_with_index do |array, obligation_index|
230
+ obligation, conditions = array
231
+ obligation_conds = []
232
+ conditions.each do |path, expressions|
233
+ model = model_for( path )
234
+ table_alias = table_alias_for(path)
235
+ parent_model = (path.length > 1 ? model_for(path[0..-2]) : top_level_model)
236
+ expressions.each do |expression|
237
+ attribute, operator, value = expression
238
+ # prevent unnecessary joins:
239
+ if attribute == :id and operator == :is and parent_model.columns_hash["#{path.last}_id"]
240
+ attribute_name = :"#{path.last}_id"
241
+ attribute_table_alias = table_alias_for(path[0..-2])
242
+ used_paths << path[0..-2]
243
+ delete_paths << path
244
+ else
245
+ attribute_name = model.columns_hash["#{attribute}_id"] && :"#{attribute}_id" ||
246
+ model.columns_hash[attribute.to_s] && attribute ||
247
+ model.primary_key
248
+ attribute_table_alias = table_alias
249
+ used_paths << path
250
+ end
251
+ bindvar = "#{attribute_table_alias}__#{attribute_name}_#{obligation_index}".to_sym
252
+
253
+ sql_attribute = "#{parent_model.connection.quote_table_name(attribute_table_alias)}." +
254
+ "#{parent_model.connection.quote_table_name(attribute_name)}"
255
+ if value.nil? and [:is, :is_not].include?(operator)
256
+ obligation_conds << "#{sql_attribute} IS #{[:contains, :is].include?(operator) ? '' : 'NOT '}NULL"
257
+ else
258
+ attribute_operator = case operator
259
+ when :contains, :is then "= :#{bindvar}"
260
+ when :does_not_contain, :is_not then "<> :#{bindvar}"
261
+ when :is_in, :intersects_with then "IN (:#{bindvar})"
262
+ when :is_not_in then "NOT IN (:#{bindvar})"
263
+ when :lt then "< :#{bindvar}"
264
+ when :lte then "<= :#{bindvar}"
265
+ when :gt then "> :#{bindvar}"
266
+ when :gte then ">= :#{bindvar}"
267
+ else raise AuthorizationUsageError, "Unknown operator: #{operator}"
268
+ end
269
+ obligation_conds << "#{sql_attribute} #{attribute_operator}"
270
+ binds[bindvar] = attribute_value(value)
271
+ end
272
+ end
273
+ end
274
+ obligation_conds << "1=1" if obligation_conds.empty?
275
+ conds << "(#{obligation_conds.join(' AND ')})"
276
+ end
277
+ (delete_paths - used_paths).each {|path| reflections.delete(path)}
278
+
279
+ finder_options[:conditions] = [ conds.join( " OR " ), binds ]
280
+ end
281
+
282
+ def attribute_value(value)
283
+ value.class.respond_to?(:descends_from_active_record?) && value.class.descends_from_active_record? && value.id ||
284
+ value.is_a?(Array) && value[0].class.respond_to?(:descends_from_active_record?) && value[0].class.descends_from_active_record? && value.map( &:id ) ||
285
+ value
286
+ end
287
+
288
+ # Parses all of the defined obligation joins and defines the scope's :joins or :includes option.
289
+ # TODO: Support non-linear association paths. Right now, we just break down the longest path parsed.
290
+ def rebuild_join_options!
291
+ joins = (finder_options[:joins] || []) + (finder_options[:includes] || [])
292
+
293
+ reflections.keys.each do |path|
294
+ next if path.empty? or @join_table_joins.include?(path)
295
+
296
+ existing_join = joins.find do |join|
297
+ existing_path = join_to_path(join)
298
+ min_length = [existing_path.length, path.length].min
299
+ existing_path.first(min_length) == path.first(min_length)
300
+ end
301
+
302
+ if existing_join
303
+ if join_to_path(existing_join).length < path.length
304
+ joins[joins.index(existing_join)] = path_to_join(path)
305
+ end
306
+ else
307
+ joins << path_to_join(path)
308
+ end
309
+ end
310
+
311
+ case obligation_conditions.length
312
+ when 0 then
313
+ # No obligation conditions means we don't have to mess with joins or includes at all.
314
+ when 1 then
315
+ finder_options[:joins] = joins
316
+ finder_options.delete( :include )
317
+ else
318
+ finder_options.delete( :joins )
319
+ finder_options[:include] = joins
320
+ end
321
+ end
322
+
323
+ def path_to_join(path)
324
+ case path.length
325
+ when 0 then nil
326
+ when 1 then path[0]
327
+ else
328
+ hash = { path[-2] => path[-1] }
329
+ path[0..-3].reverse.each do |elem|
330
+ hash = { elem => hash }
331
+ end
332
+ hash
333
+ end
334
+ end
335
+
336
+ def join_to_path(join)
337
+ case join
338
+ when Symbol
339
+ [join]
340
+ when Hash
341
+ [join.keys.first] + join_to_path(join[join.keys.first])
342
+ end
343
+ end
344
+ end
345
+ end