zeiv-declarative_authorization 1.0.0.pre

Sign up to get free protection for your applications and to get access to all the features.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG +189 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.rdoc +632 -0
  5. data/Rakefile +53 -0
  6. data/app/controllers/authorization_rules_controller.rb +258 -0
  7. data/app/controllers/authorization_usages_controller.rb +22 -0
  8. data/app/helpers/authorization_rules_helper.rb +218 -0
  9. data/app/views/authorization_rules/_change.erb +58 -0
  10. data/app/views/authorization_rules/_show_graph.erb +44 -0
  11. data/app/views/authorization_rules/_suggestions.erb +48 -0
  12. data/app/views/authorization_rules/change.html.erb +169 -0
  13. data/app/views/authorization_rules/graph.dot.erb +68 -0
  14. data/app/views/authorization_rules/graph.html.erb +47 -0
  15. data/app/views/authorization_rules/index.html.erb +17 -0
  16. data/app/views/authorization_usages/index.html.erb +36 -0
  17. data/authorization_rules.dist.rb +20 -0
  18. data/config/routes.rb +20 -0
  19. data/garlic_example.rb +20 -0
  20. data/init.rb +5 -0
  21. data/lib/declarative_authorization.rb +19 -0
  22. data/lib/declarative_authorization/adapters/active_record.rb +13 -0
  23. data/lib/declarative_authorization/adapters/active_record/base_extensions.rb +0 -0
  24. data/lib/declarative_authorization/adapters/active_record/obligation_scope_builder.rb +0 -0
  25. data/lib/declarative_authorization/authorization.rb +798 -0
  26. data/lib/declarative_authorization/development_support/analyzer.rb +261 -0
  27. data/lib/declarative_authorization/development_support/change_analyzer.rb +253 -0
  28. data/lib/declarative_authorization/development_support/change_supporter.rb +620 -0
  29. data/lib/declarative_authorization/development_support/development_support.rb +243 -0
  30. data/lib/declarative_authorization/helper.rb +68 -0
  31. data/lib/declarative_authorization/in_controller.rb +703 -0
  32. data/lib/declarative_authorization/in_model.rb +188 -0
  33. data/lib/declarative_authorization/maintenance.rb +210 -0
  34. data/lib/declarative_authorization/obligation_scope.rb +361 -0
  35. data/lib/declarative_authorization/rails_legacy.rb +22 -0
  36. data/lib/declarative_authorization/railsengine.rb +6 -0
  37. data/lib/declarative_authorization/reader.rb +546 -0
  38. data/lib/generators/authorization/install/install_generator.rb +77 -0
  39. data/lib/generators/authorization/rules/rules_generator.rb +14 -0
  40. data/lib/generators/authorization/rules/templates/authorization_rules.rb +27 -0
  41. data/lib/tasks/authorization_tasks.rake +89 -0
  42. data/test/authorization_test.rb +1124 -0
  43. data/test/controller_filter_resource_access_test.rb +575 -0
  44. data/test/controller_test.rb +480 -0
  45. data/test/database.yml +3 -0
  46. data/test/dsl_reader_test.rb +178 -0
  47. data/test/helper_test.rb +247 -0
  48. data/test/maintenance_test.rb +46 -0
  49. data/test/model_test.rb +2008 -0
  50. data/test/schema.sql +56 -0
  51. data/test/test_helper.rb +255 -0
  52. metadata +95 -0
@@ -0,0 +1,188 @@
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
+ if Rails.version < "3.1"
38
+ scopes[:with_permissions_to] = lambda do |parent_scope, *args|
39
+ options = args.last.is_a?(Hash) ? args.pop : {}
40
+ privilege = (args[0] || :read).to_sym
41
+ privileges = [privilege]
42
+ context =
43
+ if options[:context]
44
+ options[:context]
45
+ elsif parent_scope.respond_to?(:proxy_reflection)
46
+ parent_scope.proxy_reflection.klass.name.tableize.to_sym
47
+ elsif parent_scope.respond_to?(:decl_auth_context)
48
+ parent_scope.decl_auth_context
49
+ else
50
+ parent_scope.name.tableize.to_sym
51
+ end
52
+
53
+ user = options[:user] || Authorization.current_user
54
+
55
+ engine = options[:engine] || Authorization::Engine.instance
56
+ engine.permit!(privileges, :user => user, :skip_attribute_test => true,
57
+ :context => context)
58
+
59
+ obligation_scope_for( privileges, :user => user,
60
+ :context => context, :engine => engine, :model => parent_scope)
61
+ end
62
+ end
63
+
64
+ # Builds and returns a scope with joins and conditions satisfying all obligations.
65
+ def self.obligation_scope_for( privileges, options = {} )
66
+ options = {
67
+ :user => Authorization.current_user,
68
+ :context => nil,
69
+ :model => self,
70
+ :engine => nil,
71
+ }.merge(options)
72
+ engine = options[:engine] || Authorization::Engine.instance
73
+
74
+ obligation_scope = ObligationScope.new( options[:model], {} )
75
+ engine.obligations( privileges, :user => options[:user], :context => options[:context] ).each do |obligation|
76
+ obligation_scope.parse!( obligation )
77
+ end
78
+
79
+ obligation_scope.scope
80
+ end
81
+
82
+ # Named scope for limiting query results according to the authorization
83
+ # of the current user. If no privilege is given, :+read+ is assumed.
84
+ #
85
+ # User.with_permissions_to
86
+ # User.with_permissions_to(:update)
87
+ # User.with_permissions_to(:update, :context => :users)
88
+ #
89
+ # As in the case of other named scopes, this one may be chained:
90
+ # User.with_permission_to.find(:all, :conditions...)
91
+ #
92
+ # Options
93
+ # [:+context+]
94
+ # Context for the privilege to be evaluated in; defaults to the
95
+ # model's table name.
96
+ # [:+user+]
97
+ # User to be used for gathering obligations; defaults to the
98
+ # current user.
99
+ #
100
+ def self.with_permissions_to (*args)
101
+ if Rails.version < "3.1"
102
+ scopes[:with_permissions_to].call(self, *args)
103
+ else
104
+ options = args.last.is_a?(Hash) ? args.pop : {}
105
+ privilege = (args[0] || :read).to_sym
106
+ privileges = [privilege]
107
+ parent_scope = where(nil)
108
+ context =
109
+ if options[:context]
110
+ options[:context]
111
+ elsif parent_scope.klass.respond_to?(:decl_auth_context)
112
+ parent_scope.klass.decl_auth_context
113
+ else
114
+ parent_scope.klass.name.tableize.to_sym
115
+ end
116
+
117
+ user = options[:user] || Authorization.current_user
118
+
119
+ engine = options[:engine] || Authorization::Engine.instance
120
+ engine.permit!(privileges, :user => user, :skip_attribute_test => true,
121
+ :context => context)
122
+
123
+ obligation_scope_for( privileges, :user => user,
124
+ :context => context, :engine => engine, :model => parent_scope.klass)
125
+ end
126
+ end
127
+
128
+ # Activates model security for the current model. Then, CRUD operations
129
+ # are checked against the authorization of the current user. The
130
+ # privileges are :+create+, :+read+, :+update+ and :+delete+ in the
131
+ # context of the model. By default, :+read+ is not checked because of
132
+ # performance impacts, especially with large result sets.
133
+ #
134
+ # class User < ActiveRecord::Base
135
+ # using_access_control
136
+ # end
137
+ #
138
+ # If an operation is not permitted, a Authorization::AuthorizationError
139
+ # is raised.
140
+ #
141
+ # To activate model security on all models, call using_access_control
142
+ # on ActiveRecord::Base
143
+ # ActiveRecord::Base.using_access_control
144
+ #
145
+ # Available options
146
+ # [:+context+] Specify context different from the models table name.
147
+ # [:+include_read+] Also check for :+read+ privilege after find.
148
+ #
149
+ def self.using_access_control (options = {})
150
+ options = {
151
+ :context => nil,
152
+ :include_read => false
153
+ }.merge(options)
154
+
155
+ class_eval do
156
+ [:create, :update, [:destroy, :delete]].each do |action, privilege|
157
+ send(:"before_#{action}") do |object|
158
+ Authorization::Engine.instance.permit!(privilege || action,
159
+ :object => object, :context => options[:context])
160
+ end
161
+ end
162
+
163
+ if options[:include_read]
164
+ # after_find is only called if after_find is implemented
165
+ after_find do |object|
166
+ Authorization::Engine.instance.permit!(:read, :object => object,
167
+ :context => options[:context])
168
+ end
169
+
170
+ if Rails.version < "3"
171
+ def after_find; end
172
+ end
173
+ end
174
+
175
+ def self.using_access_control?
176
+ true
177
+ end
178
+ end
179
+ end
180
+
181
+ # Returns true if the model is using model security.
182
+ def self.using_access_control?
183
+ false
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,210 @@
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.glob(File.join(::Rails.root, 'app', 'controllers', '**', '*_controller\.rb')) do |entry|
59
+ require entry
60
+ end
61
+ rescue Errno::ENOENT
62
+ end
63
+ controllers = ObjectSpace.each_object(Class).select do |obj|
64
+ obj.ancestors.include?(ActionController::Base) &&
65
+ obj != ActionController::Base &&
66
+ (!obj.name || obj.name.demodulize != 'ApplicationController')
67
+ end
68
+
69
+ controllers.inject({}) do |memo, controller|
70
+ catchall_permissions = []
71
+ permission_by_action = {}
72
+ controller.all_filter_access_permissions.each do |controller_permissions|
73
+ catchall_permissions << controller_permissions if controller_permissions.actions.include?(:all)
74
+ controller_permissions.actions.reject {|action| action == :all}.each do |action|
75
+ permission_by_action[action] = controller_permissions
76
+ end
77
+ end
78
+
79
+ actions = controller.public_instance_methods(false) - controller.hidden_actions.to_a
80
+ memo[controller] = actions.inject({}) do |actions_memo, action|
81
+ action_sym = action.to_sym
82
+ actions_memo[action_sym] =
83
+ if permission_by_action[action_sym]
84
+ {
85
+ :privilege => permission_by_action[action_sym].privilege,
86
+ :context => permission_by_action[action_sym].context,
87
+ :controller_permissions => [permission_by_action[action_sym]]
88
+ }
89
+ elsif !catchall_permissions.empty?
90
+ {
91
+ :privilege => catchall_permissions[0].privilege,
92
+ :context => catchall_permissions[0].context,
93
+ :controller_permissions => catchall_permissions
94
+ }
95
+ else
96
+ {}
97
+ end
98
+ actions_memo
99
+ end
100
+ memo
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ # TestHelper provides assert methods and controller request methods which
107
+ # take authorization into account and set the current user to a specific
108
+ # one.
109
+ #
110
+ # Defines get_with, post_with, get_by_xhr_with etc. for methods
111
+ # get, post, put, delete each with the signature
112
+ # get_with(user, action, params = {}, session = {}, flash = {})
113
+ #
114
+ # Use it by including it in your TestHelper:
115
+ # require File.expand_path(File.dirname(__FILE__) +
116
+ # "/../vendor/plugins/declarative_authorization/lib/maintenance")
117
+ # class Test::Unit::TestCase
118
+ # include Authorization::TestHelper
119
+ # ...
120
+ #
121
+ # def admin
122
+ # # create admin user
123
+ # end
124
+ # end
125
+ #
126
+ # class SomeControllerTest < ActionController::TestCase
127
+ # def test_should_get_index
128
+ # ...
129
+ # get_with admin, :index, :param_1 => "param value"
130
+ # ...
131
+ # end
132
+ # end
133
+ #
134
+ # Note: get_with etc. do two things to set the user for the request:
135
+ # Authorization.current_user is set and session[:user], session[:user_id]
136
+ # are set appropriately. If you determine the current user in a different
137
+ # way, these methods might not work for you.
138
+ module TestHelper
139
+ include Authorization::Maintenance
140
+
141
+ # Analogue to the Ruby's assert_raise method, only executing the block
142
+ # in the context of the given user.
143
+ def assert_raise_with_user (user, *args, &block)
144
+ assert_raise(*args) do
145
+ with_user(user, &block)
146
+ end
147
+ end
148
+
149
+ # Test helper to test authorization rules.
150
+ # with_user a_normal_user do
151
+ # should_not_be_allowed_to :update, :conferences
152
+ # should_not_be_allowed_to :read, an_unpublished_conference
153
+ # should_be_allowed_to :read, a_published_conference
154
+ # end
155
+ #
156
+ # If the objects class name does not match the controller name, you can set the object and context manually
157
+ # should_be_allowed_to :create, :object => car, :context => :vehicles
158
+ #
159
+ # If you use specify the object and context manually, you can also specify the user manually, skipping the with_user block:
160
+ # should_be_allowed_to :create, :object => car, :context => :vehicles, :user => a_normal_user
161
+ def should_be_allowed_to (privilege, *args)
162
+ options = {}
163
+ if(args.first.class == Hash)
164
+ options = args.extract_options!
165
+ else
166
+ options[args[0].is_a?(Symbol) ? :context : :object] = args[0]
167
+ end
168
+ assert_nothing_raised do
169
+ Authorization::Engine.instance.permit!(privilege, options)
170
+ end
171
+ end
172
+
173
+ # See should_be_allowed_to
174
+ def should_not_be_allowed_to (privilege, *args)
175
+ options = {}
176
+ if(args.first.class == Hash)
177
+ options = args.extract_options!
178
+ else
179
+ options[args[0].is_a?(Symbol) ? :context : :object] = args[0]
180
+ end
181
+ assert !Authorization::Engine.instance.permit?(privilege, options)
182
+ end
183
+
184
+ def request_with (user, method, xhr, action, params = {},
185
+ session = {}, flash = {})
186
+ session = session.merge({:user => user, :user_id => user && user.id})
187
+ with_user(user) do
188
+ if xhr
189
+ xhr method, action, params, session, flash
190
+ else
191
+ send method, action, params, session, flash
192
+ end
193
+ end
194
+ end
195
+
196
+ def self.included (base)
197
+ [:get, :post, :put, :delete].each do |method|
198
+ base.class_eval <<-EOV, __FILE__, __LINE__
199
+ def #{method}_with (user, *args)
200
+ request_with(user, #{method.inspect}, false, *args)
201
+ end
202
+
203
+ def #{method}_by_xhr_with (user, *args)
204
+ request_with(user, #{method.inspect}, true, *args)
205
+ end
206
+ EOV
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,361 @@
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 < (Rails.version < "3" ? ActiveRecord::NamedScope::Scope : ActiveRecord::Relation)
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
54
+
55
+ def scope
56
+ if Rails.version < "3"
57
+ self
58
+ elsif Rails.version < "4"
59
+ # for Rails < 4: use scoped method
60
+ self.klass.scoped(@finder_options)
61
+ else
62
+ # TODO Refactor this. There is certainly a better way.
63
+ self.klass.joins(@finder_options[:joins]).includes(@finder_options[:include]).where(@finder_options[:conditions])
64
+ end
65
+ end
66
+
67
+ # Consumes the given obligation, converting it into scope join and condition options.
68
+ def parse!( obligation )
69
+ @current_obligation = obligation
70
+ @join_table_joins = Set.new
71
+ obligation_conditions[@current_obligation] ||= {}
72
+ follow_path( obligation )
73
+
74
+ rebuild_condition_options!
75
+ rebuild_join_options!
76
+ end
77
+
78
+ protected
79
+
80
+ # Parses the next step in the association path. If it's an association, we advance down the
81
+ # path. Otherwise, it's an attribute, and we need to evaluate it as a comparison operation.
82
+ def follow_path( steps, past_steps = [] )
83
+ if steps.is_a?( Hash )
84
+ steps.each do |step, next_steps|
85
+ path_to_this_point = [past_steps, step].flatten
86
+ reflection = reflection_for( path_to_this_point ) rescue nil
87
+ if reflection
88
+ follow_path( next_steps, path_to_this_point )
89
+ else
90
+ follow_comparison( next_steps, past_steps, step )
91
+ end
92
+ end
93
+ elsif steps.is_a?( Array ) && steps.length == 2
94
+ if reflection_for( past_steps )
95
+ follow_comparison( steps, past_steps, :id )
96
+ else
97
+ follow_comparison( steps, past_steps[0..-2], past_steps[-1] )
98
+ end
99
+ else
100
+ raise "invalid obligation path #{[past_steps, steps].inspect}"
101
+ end
102
+ end
103
+
104
+ def top_level_model
105
+ if Rails.version < "3"
106
+ @proxy_scope
107
+ else
108
+ self.klass
109
+ end
110
+ end
111
+
112
+ def finder_options
113
+ Rails.version < "3" ? @proxy_options : @finder_options
114
+ end
115
+
116
+ # At the end of every association path, we expect to see a comparison of some kind; for
117
+ # example, +:attr => [ :is, :value ]+.
118
+ #
119
+ # This method parses the comparison and creates an obligation condition from it.
120
+ def follow_comparison( steps, past_steps, attribute )
121
+ operator = steps[0]
122
+ value = steps[1..-1]
123
+ value = value[0] if value.length == 1
124
+
125
+ add_obligation_condition_for( past_steps, [attribute, operator, value] )
126
+ end
127
+
128
+ # Adds the given expression to the current obligation's indicated path's conditions.
129
+ #
130
+ # Condition expressions must follow the format +[ <attribute>, <operator>, <value> ]+.
131
+ def add_obligation_condition_for( path, expression )
132
+ raise "invalid expression #{expression.inspect}" unless expression.is_a?( Array ) && expression.length == 3
133
+ add_obligation_join_for( path )
134
+ obligation_conditions[@current_obligation] ||= {}
135
+ ( obligation_conditions[@current_obligation][path] ||= Set.new ) << expression
136
+ end
137
+
138
+ # Adds the given path to the list of obligation joins, if we haven't seen it before.
139
+ def add_obligation_join_for( path )
140
+ map_reflection_for( path ) if reflections[path].nil?
141
+ end
142
+
143
+ # Returns the model associated with the given path.
144
+ def model_for (path)
145
+ reflection = reflection_for(path)
146
+
147
+ if Authorization.is_a_association_proxy?(reflection)
148
+ if Rails.version < "3.2"
149
+ reflection.proxy_reflection.klass
150
+ else
151
+ reflection.proxy_association.reflection.klass
152
+ end
153
+ elsif reflection.respond_to?(:klass)
154
+ reflection.klass
155
+ else
156
+ reflection
157
+ end
158
+ end
159
+
160
+ # Returns the reflection corresponding to the given path.
161
+ def reflection_for(path, for_join_table_only = false)
162
+ @join_table_joins << path if for_join_table_only and !reflections[path]
163
+ reflections[path] ||= map_reflection_for( path )
164
+ end
165
+
166
+ # Returns a proper table alias for the given path. This alias may be used in SQL statements.
167
+ def table_alias_for( path )
168
+ table_aliases[path] ||= map_table_alias_for( path )
169
+ end
170
+
171
+ # Attempts to map a reflection for the given path. Raises if already defined.
172
+ def map_reflection_for( path )
173
+ raise "reflection for #{path.inspect} already exists" unless reflections[path].nil?
174
+
175
+ reflection = path.empty? ? top_level_model : begin
176
+ parent = reflection_for( path[0..-2] )
177
+ if !Authorization.is_a_association_proxy?(parent) and parent.respond_to?(:klass)
178
+ parent.klass.reflect_on_association( path.last )
179
+ else
180
+ parent.reflect_on_association( path.last )
181
+ end
182
+ rescue
183
+ parent.reflect_on_association( path.last )
184
+ end
185
+ raise "invalid path #{path.inspect}" if reflection.nil?
186
+
187
+ reflections[path] = reflection
188
+ map_table_alias_for( path ) # Claim a table alias for the path.
189
+
190
+ # Claim alias for join table
191
+ # TODO change how this is checked
192
+ if !Authorization.is_a_association_proxy?(reflection) and !reflection.respond_to?(:proxy_scope) and reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
193
+ join_table_path = path[0..-2] + [reflection.options[:through]]
194
+ reflection_for(join_table_path, true)
195
+ end
196
+
197
+ reflection
198
+ end
199
+
200
+ # Attempts to map a table alias for the given path. Raises if already defined.
201
+ def map_table_alias_for( path )
202
+ return "table alias for #{path.inspect} already exists" unless table_aliases[path].nil?
203
+
204
+ reflection = reflection_for( path )
205
+ table_alias = reflection.table_name
206
+ if table_aliases.values.include?( table_alias )
207
+ max_length = reflection.active_record.connection.table_alias_length
208
+ # Rails seems to pluralize reflection names
209
+ table_alias = "#{reflection.name.to_s.pluralize}_#{reflection.active_record.table_name}".to(max_length-1)
210
+ end
211
+ while table_aliases.values.include?( table_alias )
212
+ if table_alias =~ /\w(_\d+?)$/
213
+ table_index = $1.succ
214
+ table_alias = "#{table_alias[0..-(table_index.length+1)]}_#{table_index}"
215
+ else
216
+ table_alias = "#{table_alias[0..(max_length-3)]}_2"
217
+ end
218
+ end
219
+ table_aliases[path] = table_alias
220
+ end
221
+
222
+ # Returns a hash mapping obligations to zero or more condition path sets.
223
+ def obligation_conditions
224
+ @obligation_conditions ||= {}
225
+ end
226
+
227
+ # Returns a hash mapping paths to reflections.
228
+ def reflections
229
+ # lets try to get the order of joins right
230
+ @reflections ||= ActiveSupport::OrderedHash.new
231
+ end
232
+
233
+ # Returns a hash mapping paths to proper table aliases to use in SQL statements.
234
+ def table_aliases
235
+ @table_aliases ||= {}
236
+ end
237
+
238
+ # Parses all of the defined obligation conditions and defines the scope's :conditions option.
239
+ def rebuild_condition_options!
240
+ conds = []
241
+ binds = {}
242
+ used_paths = Set.new
243
+ delete_paths = Set.new
244
+ obligation_conditions.each_with_index do |array, obligation_index|
245
+ obligation, conditions = array
246
+ obligation_conds = []
247
+ conditions.each do |path, expressions|
248
+ model = model_for( path )
249
+ table_alias = table_alias_for(path)
250
+ parent_model = (path.length > 1 ? model_for(path[0..-2]) : top_level_model)
251
+ expressions.each do |expression|
252
+ attribute, operator, value = expression
253
+ # prevent unnecessary joins:
254
+ if attribute == :id and operator == :is and parent_model.columns_hash["#{path.last}_id"]
255
+ attribute_name = :"#{path.last}_id"
256
+ attribute_table_alias = table_alias_for(path[0..-2])
257
+ used_paths << path[0..-2]
258
+ delete_paths << path
259
+ else
260
+ attribute_name = model.columns_hash["#{attribute}_id"] && :"#{attribute}_id" ||
261
+ model.columns_hash[attribute.to_s] && attribute ||
262
+ model.primary_key
263
+ attribute_table_alias = table_alias
264
+ used_paths << path
265
+ end
266
+ bindvar = "#{attribute_table_alias}__#{attribute_name}_#{obligation_index}".to_sym
267
+
268
+ sql_attribute = "#{parent_model.connection.quote_table_name(attribute_table_alias)}." +
269
+ "#{parent_model.connection.quote_table_name(attribute_name)}"
270
+ if value.nil? and [:is, :is_not].include?(operator)
271
+ obligation_conds << "#{sql_attribute} IS #{[:contains, :is].include?(operator) ? '' : 'NOT '}NULL"
272
+ else
273
+ attribute_operator = case operator
274
+ when :contains, :is then "= :#{bindvar}"
275
+ when :does_not_contain, :is_not then "<> :#{bindvar}"
276
+ when :is_in, :intersects_with then "IN (:#{bindvar})"
277
+ when :is_not_in then "NOT IN (:#{bindvar})"
278
+ when :lt then "< :#{bindvar}"
279
+ when :lte then "<= :#{bindvar}"
280
+ when :gt then "> :#{bindvar}"
281
+ when :gte then ">= :#{bindvar}"
282
+ else raise AuthorizationUsageError, "Unknown operator: #{operator}"
283
+ end
284
+ obligation_conds << "#{sql_attribute} #{attribute_operator}"
285
+ binds[bindvar] = attribute_value(value)
286
+ end
287
+ end
288
+ end
289
+ obligation_conds << "1=1" if obligation_conds.empty?
290
+ conds << "(#{obligation_conds.join(' AND ')})"
291
+ end
292
+ (delete_paths - used_paths).each {|path| reflections.delete(path)}
293
+
294
+ finder_options[:conditions] = [ conds.join( " OR " ), binds ]
295
+ end
296
+
297
+ def attribute_value (value)
298
+ value.class.respond_to?(:descends_from_active_record?) && value.class.descends_from_active_record? && value.id ||
299
+ value.is_a?(Array) && value[0].class.respond_to?(:descends_from_active_record?) && value[0].class.descends_from_active_record? && value.map( &:id ) ||
300
+ value
301
+ end
302
+
303
+ # Parses all of the defined obligation joins and defines the scope's :joins or :includes option.
304
+ # TODO: Support non-linear association paths. Right now, we just break down the longest path parsed.
305
+ def rebuild_join_options!
306
+ joins = (finder_options[:joins] || []) + (finder_options[:includes] || [])
307
+
308
+ reflections.keys.each do |path|
309
+ next if path.empty? or @join_table_joins.include?(path)
310
+
311
+ existing_join = joins.find do |join|
312
+ existing_path = join_to_path(join)
313
+ min_length = [existing_path.length, path.length].min
314
+ existing_path.first(min_length) == path.first(min_length)
315
+ end
316
+
317
+ if existing_join
318
+ if join_to_path(existing_join).length < path.length
319
+ joins[joins.index(existing_join)] = path_to_join(path)
320
+ end
321
+ else
322
+ joins << path_to_join(path)
323
+ end
324
+ end
325
+
326
+ case obligation_conditions.length
327
+ when 0 then
328
+ # No obligation conditions means we don't have to mess with joins or includes at all.
329
+ when 1 then
330
+ finder_options[:joins] = joins
331
+ finder_options.delete( :include )
332
+ else
333
+ finder_options.delete( :joins )
334
+ finder_options[:include] = joins
335
+ end
336
+ end
337
+
338
+ def path_to_join (path)
339
+ case path.length
340
+ when 0 then nil
341
+ when 1 then path[0]
342
+ else
343
+ hash = { path[-2] => path[-1] }
344
+ path[0..-3].reverse.each do |elem|
345
+ hash = { elem => hash }
346
+ end
347
+ hash
348
+ end
349
+ end
350
+
351
+ def join_to_path (join)
352
+ case join
353
+ when Symbol
354
+ [join]
355
+ when Hash
356
+ [join.keys.first] + join_to_path(join[join.keys.first])
357
+ end
358
+ end
359
+ end
360
+ end
361
+