ae_declarative_authorization 0.7.1 → 0.8.0

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