zeiv-declarative_authorization 1.0.0.pre

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 (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
+