timcharper-declarative_authorization 0.4.1.2

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 (42) hide show
  1. data/CHANGELOG +135 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.rdoc +503 -0
  4. data/Rakefile +43 -0
  5. data/app/controllers/authorization_rules_controller.rb +259 -0
  6. data/app/controllers/authorization_usages_controller.rb +23 -0
  7. data/app/helpers/authorization_rules_helper.rb +218 -0
  8. data/app/views/authorization_rules/_change.erb +58 -0
  9. data/app/views/authorization_rules/_show_graph.erb +37 -0
  10. data/app/views/authorization_rules/_suggestions.erb +48 -0
  11. data/app/views/authorization_rules/change.html.erb +169 -0
  12. data/app/views/authorization_rules/graph.dot.erb +68 -0
  13. data/app/views/authorization_rules/graph.html.erb +40 -0
  14. data/app/views/authorization_rules/index.html.erb +17 -0
  15. data/app/views/authorization_usages/index.html.erb +36 -0
  16. data/authorization_rules.dist.rb +20 -0
  17. data/config/routes.rb +7 -0
  18. data/garlic_example.rb +20 -0
  19. data/init.rb +5 -0
  20. data/lib/declarative_authorization.rb +15 -0
  21. data/lib/declarative_authorization/authorization.rb +683 -0
  22. data/lib/declarative_authorization/development_support/analyzer.rb +252 -0
  23. data/lib/declarative_authorization/development_support/change_analyzer.rb +253 -0
  24. data/lib/declarative_authorization/development_support/change_supporter.rb +620 -0
  25. data/lib/declarative_authorization/development_support/development_support.rb +243 -0
  26. data/lib/declarative_authorization/helper.rb +60 -0
  27. data/lib/declarative_authorization/in_controller.rb +623 -0
  28. data/lib/declarative_authorization/in_model.rb +162 -0
  29. data/lib/declarative_authorization/maintenance.rb +198 -0
  30. data/lib/declarative_authorization/obligation_scope.rb +345 -0
  31. data/lib/declarative_authorization/rails_legacy.rb +14 -0
  32. data/lib/declarative_authorization/reader.rb +472 -0
  33. data/test/authorization_test.rb +971 -0
  34. data/test/controller_filter_resource_access_test.rb +511 -0
  35. data/test/controller_test.rb +465 -0
  36. data/test/dsl_reader_test.rb +157 -0
  37. data/test/helper_test.rb +171 -0
  38. data/test/maintenance_test.rb +46 -0
  39. data/test/model_test.rb +1694 -0
  40. data/test/schema.sql +54 -0
  41. data/test/test_helper.rb +134 -0
  42. metadata +119 -0
@@ -0,0 +1,162 @@
1
+ # Authorization::AuthorizationInModel
2
+ require File.dirname(__FILE__) + '/authorization.rb'
3
+ require File.dirname(__FILE__) + '/obligation_scope.rb'
4
+
5
+ module Authorization
6
+
7
+ module AuthorizationInModel
8
+
9
+ # If the user meets the given privilege, permitted_to? returns true
10
+ # and yields to the optional block.
11
+ def permitted_to? (privilege, options = {}, &block)
12
+ options = {
13
+ :user => Authorization.current_user,
14
+ :object => self
15
+ }.merge(options)
16
+ Authorization::Engine.instance.permit?(privilege,
17
+ {:user => options[:user],
18
+ :object => options[:object]},
19
+ &block)
20
+ end
21
+
22
+ # Works similar to the permitted_to? method, but doesn't accept a block
23
+ # and throws the authorization exceptions, just like Engine#permit!
24
+ def permitted_to! (privilege, options = {} )
25
+ options = {
26
+ :user => Authorization.current_user,
27
+ :object => self
28
+ }.merge(options)
29
+ Authorization::Engine.instance.permit!(privilege,
30
+ {:user => options[:user],
31
+ :object => options[:object]})
32
+ end
33
+
34
+ def self.included(base) # :nodoc:
35
+ #base.extend(ClassMethods)
36
+ base.module_eval do
37
+ scopes[:with_permissions_to] = lambda do |parent_scope, *args|
38
+ options = args.last.is_a?(Hash) ? args.pop : {}
39
+ privilege = (args[0] || :read).to_sym
40
+ privileges = [privilege]
41
+ context =
42
+ if options[:context]
43
+ options[:context]
44
+ elsif parent_scope.respond_to?(:proxy_reflection)
45
+ parent_scope.proxy_reflection.klass.name.tableize.to_sym
46
+ elsif parent_scope.respond_to?(:decl_auth_context)
47
+ parent_scope.decl_auth_context
48
+ else
49
+ parent_scope.name.tableize.to_sym
50
+ end
51
+
52
+ user = options[:user] || Authorization.current_user
53
+
54
+ engine = options[:engine] || Authorization::Engine.instance
55
+ engine.permit!(privileges, :user => user, :skip_attribute_test => true,
56
+ :context => context)
57
+
58
+ obligation_scope_for( privileges, :user => user,
59
+ :context => context, :engine => engine, :model => parent_scope)
60
+ end
61
+
62
+ # Builds and returns a scope with joins and conditions satisfying all obligations.
63
+ def self.obligation_scope_for( privileges, options = {} )
64
+ options = {
65
+ :user => Authorization.current_user,
66
+ :context => nil,
67
+ :model => self,
68
+ :engine => nil,
69
+ }.merge(options)
70
+ engine = options[:engine] || Authorization::Engine.instance
71
+
72
+ obligation_scope = ObligationScope.new( options[:model], {} )
73
+ engine.obligations( privileges, :user => options[:user], :context => options[:context] ).each do |obligation|
74
+ obligation_scope.parse!( obligation )
75
+ end
76
+
77
+ obligation_scope.scope
78
+ end
79
+
80
+ # Named scope for limiting query results according to the authorization
81
+ # of the current user. If no privilege is given, :+read+ is assumed.
82
+ #
83
+ # User.with_permissions_to
84
+ # User.with_permissions_to(:update)
85
+ # User.with_permissions_to(:update, :context => :users)
86
+ #
87
+ # As in the case of other named scopes, this one may be chained:
88
+ # User.with_permission_to.find(:all, :conditions...)
89
+ #
90
+ # Options
91
+ # [:+context+]
92
+ # Context for the privilege to be evaluated in; defaults to the
93
+ # model's table name.
94
+ # [:+user+]
95
+ # User to be used for gathering obligations; defaults to the
96
+ # current user.
97
+ #
98
+ def self.with_permissions_to (*args)
99
+ scopes[:with_permissions_to].call(self, *args)
100
+ end
101
+
102
+ # Activates model security for the current model. Then, CRUD operations
103
+ # are checked against the authorization of the current user. The
104
+ # privileges are :+create+, :+read+, :+update+ and :+delete+ in the
105
+ # context of the model. By default, :+read+ is not checked because of
106
+ # performance impacts, especially with large result sets.
107
+ #
108
+ # class User < ActiveRecord::Base
109
+ # using_access_control
110
+ # end
111
+ #
112
+ # If an operation is not permitted, a Authorization::AuthorizationError
113
+ # is raised.
114
+ #
115
+ # To activate model security on all models, call using_access_control
116
+ # on ActiveRecord::Base
117
+ # ActiveRecord::Base.using_access_control
118
+ #
119
+ # Available options
120
+ # [:+context+] Specify context different from the models table name.
121
+ # [:+include_read+] Also check for :+read+ privilege after find.
122
+ #
123
+ def self.using_access_control (options = {})
124
+ options = {
125
+ :context => nil,
126
+ :include_read => false
127
+ }.merge(options)
128
+
129
+ class_eval do
130
+ [:create, :update, [:destroy, :delete]].each do |action, privilege|
131
+ send(:"before_#{action}") do |object|
132
+ Authorization::Engine.instance.permit!(privilege || action,
133
+ :object => object, :context => options[:context])
134
+ end
135
+ end
136
+
137
+ if options[:include_read]
138
+ # after_find is only called if after_find is implemented
139
+ after_find do |object|
140
+ Authorization::Engine.instance.permit!(:read, :object => object,
141
+ :context => options[:context])
142
+ end
143
+
144
+ if Rails.version < "3"
145
+ def after_find; end
146
+ end
147
+ end
148
+
149
+ def self.using_access_control?
150
+ true
151
+ end
152
+ end
153
+ end
154
+
155
+ # Returns true if the model is using model security.
156
+ def self.using_access_control?
157
+ false
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,198 @@
1
+ # Authorization::Maintenance
2
+ require File.dirname(__FILE__) + '/authorization.rb'
3
+
4
+ module Authorization
5
+ # Provides a few maintenance methods for modifying data without enforcing
6
+ # authorization.
7
+ module Maintenance
8
+ # Disables access control for the given block. Appropriate for
9
+ # maintenance operation at the Rails console or in test case setup.
10
+ #
11
+ # For use in the Rails console:
12
+ # require "vendor/plugins/declarative_authorization/lib/maintenance"
13
+ # include Authorization::Maintenance
14
+ #
15
+ # without_access_control do
16
+ # SomeModel.find(:first).save
17
+ # end
18
+ def without_access_control (&block)
19
+ Authorization::Maintenance.without_access_control(&block)
20
+ end
21
+
22
+ # A class method variant of without_access_control. Thus, one can call
23
+ # Authorization::Maintenance::without_access_control do
24
+ # ...
25
+ # end
26
+ def self.without_access_control
27
+ previous_state = Authorization.ignore_access_control
28
+ begin
29
+ Authorization.ignore_access_control(true)
30
+ yield
31
+ ensure
32
+ Authorization.ignore_access_control(previous_state)
33
+ end
34
+ end
35
+
36
+ # Sets the current user for the declarative authorization plugin to the
37
+ # given one for the execution of the supplied block. Suitable for tests
38
+ # on certain users.
39
+ def with_user (user, &block)
40
+ Authorization::Maintenance.with_user(user, &block)
41
+ end
42
+
43
+ def self.with_user (user)
44
+ prev_user = Authorization.current_user
45
+ Authorization.current_user = user
46
+ yield
47
+ ensure
48
+ Authorization.current_user = prev_user
49
+ end
50
+
51
+ # Module for grouping usage-related helper methods
52
+ module Usage
53
+ # Delivers a hash of {ControllerClass => usage_info_hash},
54
+ # where usage_info_hash has the form of
55
+ def self.usages_by_controller
56
+ # load each application controller
57
+ begin
58
+ Dir.foreach(File.join(RAILS_ROOT, %w{app controllers})) do |entry|
59
+ if entry =~ /^\w+_controller\.rb$/
60
+ require File.join(RAILS_ROOT, %w{app controllers}, entry)
61
+ end
62
+ end
63
+ rescue Errno::ENOENT
64
+ end
65
+ controllers = []
66
+ ObjectSpace.each_object(Class) do |obj|
67
+ controllers << obj if obj.ancestors.include?(ActionController::Base) and
68
+ !%w{ActionController::Base ApplicationController}.include?(obj.name)
69
+ end
70
+
71
+ controllers.inject({}) do |memo, controller|
72
+ catchall_permissions = []
73
+ permission_by_action = {}
74
+ controller.all_filter_access_permissions.each do |controller_permissions|
75
+ catchall_permissions << controller_permissions if controller_permissions.actions.include?(:all)
76
+ controller_permissions.actions.reject {|action| action == :all}.each do |action|
77
+ permission_by_action[action] = controller_permissions
78
+ end
79
+ end
80
+
81
+ actions = controller.public_instance_methods(false) - controller.hidden_actions.to_a
82
+ memo[controller] = actions.inject({}) do |actions_memo, action|
83
+ action_sym = action.to_sym
84
+ actions_memo[action_sym] =
85
+ if permission_by_action[action_sym]
86
+ {
87
+ :privilege => permission_by_action[action_sym].privilege,
88
+ :context => permission_by_action[action_sym].context,
89
+ :controller_permissions => [permission_by_action[action_sym]]
90
+ }
91
+ elsif !catchall_permissions.empty?
92
+ {
93
+ :privilege => catchall_permissions[0].privilege,
94
+ :context => catchall_permissions[0].context,
95
+ :controller_permissions => catchall_permissions
96
+ }
97
+ else
98
+ {}
99
+ end
100
+ actions_memo
101
+ end
102
+ memo
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ # TestHelper provides assert methods and controller request methods which
109
+ # take authorization into account and set the current user to a specific
110
+ # one.
111
+ #
112
+ # Defines get_with, post_with, get_by_xhr_with etc. for methods
113
+ # get, post, put, delete each with the signature
114
+ # get_with(user, action, params = {}, session = {}, flash = {})
115
+ #
116
+ # Use it by including it in your TestHelper:
117
+ # require File.expand_path(File.dirname(__FILE__) +
118
+ # "/../vendor/plugins/declarative_authorization/lib/maintenance")
119
+ # class Test::Unit::TestCase
120
+ # include Authorization::TestHelper
121
+ # ...
122
+ #
123
+ # def admin
124
+ # # create admin user
125
+ # end
126
+ # end
127
+ #
128
+ # class SomeControllerTest < ActionController::TestCase
129
+ # def test_should_get_index
130
+ # ...
131
+ # get_with admin, :index, :param_1 => "param value"
132
+ # ...
133
+ # end
134
+ # end
135
+ #
136
+ # Note: get_with etc. do two things to set the user for the request:
137
+ # Authorization.current_user is set and session[:user], session[:user_id]
138
+ # are set appropriately. If you determine the current user in a different
139
+ # way, these methods might not work for you.
140
+ module TestHelper
141
+ include Authorization::Maintenance
142
+
143
+ # Analogue to the Ruby's assert_raise method, only executing the block
144
+ # in the context of the given user.
145
+ def assert_raise_with_user (user, *args, &block)
146
+ assert_raise(*args) do
147
+ with_user(user, &block)
148
+ end
149
+ end
150
+
151
+ # Test helper to test authorization rules. E.g.
152
+ # with_user a_normal_user do
153
+ # should_not_be_allowed_to :update, :conferences
154
+ # should_not_be_allowed_to :read, an_unpublished_conference
155
+ # should_be_allowed_to :read, a_published_conference
156
+ # end
157
+ def should_be_allowed_to (privilege, object_or_context)
158
+ options = {}
159
+ options[object_or_context.is_a?(Symbol) ? :context : :object] = object_or_context
160
+ assert_nothing_raised do
161
+ Authorization::Engine.instance.permit!(privilege, options)
162
+ end
163
+ end
164
+
165
+ # See should_be_allowed_to
166
+ def should_not_be_allowed_to (privilege, object_or_context)
167
+ options = {}
168
+ options[object_or_context.is_a?(Symbol) ? :context : :object] = object_or_context
169
+ assert !Authorization::Engine.instance.permit?(privilege, options)
170
+ end
171
+
172
+ def request_with (user, method, xhr, action, params = {},
173
+ session = {}, flash = {})
174
+ session = session.merge({:user => user, :user_id => user.id})
175
+ with_user(user) do
176
+ if xhr
177
+ xhr method, action, params, session, flash
178
+ else
179
+ send method, action, params, session, flash
180
+ end
181
+ end
182
+ end
183
+
184
+ def self.included (base)
185
+ [:get, :post, :put, :delete].each do |method|
186
+ base.class_eval <<-EOV, __FILE__, __LINE__
187
+ def #{method}_with (user, *args)
188
+ request_with(user, #{method.inspect}, false, *args)
189
+ end
190
+
191
+ def #{method}_by_xhr_with (user, *args)
192
+ request_with(user, #{method.inspect}, true, *args)
193
+ end
194
+ EOV
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +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::NamedScope::Scope
46
+ def initialize (model, options)
47
+ @finder_options = {}
48
+ super(model, options)
49
+ end
50
+
51
+ def scope
52
+ if Rails.version < "3"
53
+ self
54
+ else
55
+ # for Rails < 3: scope, after setting proxy_options
56
+ self.klass.scoped(@finder_options)
57
+ end
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
+ if Rails.version < "3"
99
+ @proxy_scope
100
+ else
101
+ self.klass
102
+ end
103
+ end
104
+
105
+ def finder_options
106
+ Rails.version < "3" ? @proxy_options : @finder_options
107
+ end
108
+
109
+ # At the end of every association path, we expect to see a comparison of some kind; for
110
+ # example, +:attr => [ :is, :value ]+.
111
+ #
112
+ # This method parses the comparison and creates an obligation condition from it.
113
+ def follow_comparison( steps, past_steps, attribute )
114
+ operator = steps[0]
115
+ value = steps[1..-1]
116
+ value = value[0] if value.length == 1
117
+
118
+ add_obligation_condition_for( past_steps, [attribute, operator, value] )
119
+ end
120
+
121
+ # Adds the given expression to the current obligation's indicated path's conditions.
122
+ #
123
+ # Condition expressions must follow the format +[ <attribute>, <operator>, <value> ]+.
124
+ def add_obligation_condition_for( path, expression )
125
+ raise "invalid expression #{expression.inspect}" unless expression.is_a?( Array ) && expression.length == 3
126
+ add_obligation_join_for( path )
127
+ obligation_conditions[@current_obligation] ||= {}
128
+ ( obligation_conditions[@current_obligation][path] ||= Set.new ) << expression
129
+ end
130
+
131
+ # Adds the given path to the list of obligation joins, if we haven't seen it before.
132
+ def add_obligation_join_for( path )
133
+ map_reflection_for( path ) if reflections[path].nil?
134
+ end
135
+
136
+ # Returns the model associated with the given path.
137
+ def model_for (path)
138
+ reflection = reflection_for(path)
139
+
140
+ if reflection.respond_to?(:proxy_reflection)
141
+ reflection.proxy_reflection.klass
142
+ elsif reflection.respond_to?(:klass)
143
+ reflection.klass
144
+ else
145
+ reflection
146
+ end
147
+ end
148
+
149
+ # Returns the reflection corresponding to the given path.
150
+ def reflection_for(path, for_join_table_only = false)
151
+ @join_table_joins << path if for_join_table_only and !reflections[path]
152
+ reflections[path] ||= map_reflection_for( path )
153
+ end
154
+
155
+ # Returns a proper table alias for the given path. This alias may be used in SQL statements.
156
+ def table_alias_for( path )
157
+ table_aliases[path] ||= map_table_alias_for( path )
158
+ end
159
+
160
+ # Attempts to map a reflection for the given path. Raises if already defined.
161
+ def map_reflection_for( path )
162
+ raise "reflection for #{path.inspect} already exists" unless reflections[path].nil?
163
+
164
+ reflection = path.empty? ? top_level_model : begin
165
+ parent = reflection_for( path[0..-2] )
166
+ if !parent.respond_to?(:proxy_reflection) and parent.respond_to?(:klass)
167
+ parent.klass.reflect_on_association( path.last )
168
+ else
169
+ parent.reflect_on_association( path.last )
170
+ end
171
+ rescue
172
+ parent.reflect_on_association( path.last )
173
+ end
174
+ raise "invalid path #{path.inspect}" if reflection.nil?
175
+
176
+ reflections[path] = reflection
177
+ map_table_alias_for( path ) # Claim a table alias for the path.
178
+
179
+ # Claim alias for join table
180
+ # TODO change how this is checked
181
+ if !reflection.respond_to?(:proxy_scope) and reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
182
+ join_table_path = path[0..-2] + [reflection.options[:through]]
183
+ reflection_for(join_table_path, true)
184
+ end
185
+
186
+ reflection
187
+ end
188
+
189
+ # Attempts to map a table alias for the given path. Raises if already defined.
190
+ def map_table_alias_for( path )
191
+ return "table alias for #{path.inspect} already exists" unless table_aliases[path].nil?
192
+
193
+ reflection = reflection_for( path )
194
+ table_alias = reflection.table_name
195
+ if table_aliases.values.include?( table_alias )
196
+ max_length = reflection.active_record.connection.table_alias_length
197
+ # Rails seems to pluralize reflection names
198
+ table_alias = "#{reflection.name.to_s.pluralize}_#{reflection.active_record.table_name}".to(max_length-1)
199
+ end
200
+ while table_aliases.values.include?( table_alias )
201
+ if table_alias =~ /\w(_\d+?)$/
202
+ table_index = $1.succ
203
+ table_alias = "#{table_alias[0..-(table_index.length+1)]}_#{table_index}"
204
+ else
205
+ table_alias = "#{table_alias[0..(max_length-3)]}_2"
206
+ end
207
+ end
208
+ table_aliases[path] = table_alias
209
+ end
210
+
211
+ # Returns a hash mapping obligations to zero or more condition path sets.
212
+ def obligation_conditions
213
+ @obligation_conditions ||= {}
214
+ end
215
+
216
+ # Returns a hash mapping paths to reflections.
217
+ def reflections
218
+ # lets try to get the order of joins right
219
+ @reflections ||= ActiveSupport::OrderedHash.new
220
+ end
221
+
222
+ # Returns a hash mapping paths to proper table aliases to use in SQL statements.
223
+ def table_aliases
224
+ @table_aliases ||= {}
225
+ end
226
+
227
+ # Parses all of the defined obligation conditions and defines the scope's :conditions option.
228
+ def rebuild_condition_options!
229
+ conds = []
230
+ binds = {}
231
+ used_paths = Set.new
232
+ delete_paths = Set.new
233
+ obligation_conditions.each_with_index do |array, obligation_index|
234
+ obligation, conditions = array
235
+ obligation_conds = []
236
+ conditions.each do |path, expressions|
237
+ model = model_for( path )
238
+ table_alias = table_alias_for(path)
239
+ parent_model = (path.length > 1 ? model_for(path[0..-2]) : top_level_model)
240
+ expressions.each do |expression|
241
+ attribute, operator, value = expression
242
+ # prevent unnecessary joins:
243
+ if attribute == :id and operator == :is and parent_model.columns_hash["#{path.last}_id"]
244
+ attribute_name = :"#{path.last}_id"
245
+ attribute_table_alias = table_alias_for(path[0..-2])
246
+ used_paths << path[0..-2]
247
+ delete_paths << path
248
+ else
249
+ attribute_name = model.columns_hash["#{attribute}_id"] && :"#{attribute}_id" ||
250
+ model.columns_hash[attribute.to_s] && attribute ||
251
+ :id
252
+ attribute_table_alias = table_alias
253
+ used_paths << path
254
+ end
255
+ bindvar = "#{attribute_table_alias}__#{attribute_name}_#{obligation_index}".to_sym
256
+
257
+ sql_attribute = "#{parent_model.connection.quote_table_name(attribute_table_alias)}." +
258
+ "#{parent_model.connection.quote_table_name(attribute_name)}"
259
+ if value.nil? and [:is, :is_not].include?(operator)
260
+ obligation_conds << "#{sql_attribute} IS #{[:contains, :is].include?(operator) ? '' : 'NOT '}NULL"
261
+ else
262
+ attribute_operator = case operator
263
+ when :contains, :is then "= :#{bindvar}"
264
+ when :does_not_contain, :is_not then "<> :#{bindvar}"
265
+ when :is_in, :intersects_with then "IN (:#{bindvar})"
266
+ when :is_not_in then "NOT IN (:#{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