stffn-declarative_authorization 0.2.1 → 0.2.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,174 @@
1
+ # Authorization::Maintenance
2
+ require File.dirname(__FILE__) + '/authorization.rb'
3
+
4
+ module Authorization
5
+
6
+ def self.ignore_access_control (state = nil) # :nodoc:
7
+ Thread.current["ignore_access_control"] = state unless state.nil?
8
+ Thread.current["ignore_access_control"] || false
9
+ end
10
+
11
+ # Provides a few maintenance methods for modifying data without enforcing
12
+ # authorization.
13
+ module Maintenance
14
+ # Disables access control for the given block. Appropriate for
15
+ # maintenance operation at the Rails console or in test case setup.
16
+ #
17
+ # For use in the Rails console:
18
+ # require "vendor/plugins/declarative_authorization/lib/maintenance"
19
+ # include Authorization::Maintenance
20
+ #
21
+ # without_access_control do
22
+ # SomeModel.find(:first).save
23
+ # end
24
+ def without_access_control
25
+ Authorization.ignore_access_control(true)
26
+ yield
27
+ ensure
28
+ Authorization.ignore_access_control(false)
29
+ end
30
+
31
+ # A class method variant of without_access_control. Thus, one can call
32
+ # Authorization::Maintenance::without_access_control do
33
+ # ...
34
+ # end
35
+ def self.without_access_control
36
+ Authorization.ignore_access_control(true)
37
+ yield
38
+ ensure
39
+ Authorization.ignore_access_control(false)
40
+ end
41
+
42
+ # Sets the current user for the declarative authorization plugin to the
43
+ # given one for the execution of the supplied block. Suitable for tests
44
+ # on certain users.
45
+ def with_user (user)
46
+ prev_user = Authorization.current_user
47
+ Authorization.current_user = user
48
+ yield
49
+ ensure
50
+ Authorization.current_user = prev_user
51
+ end
52
+
53
+ # Module for grouping usage-related helper methods
54
+ module Usage
55
+ # Delivers a hash of {ControllerClass => usage_info_hash},
56
+ # where usage_info_hash has the form of
57
+ def self.usages_by_controller
58
+ # load each application controller
59
+ begin
60
+ Dir.foreach(File.join(RAILS_ROOT, %w{app controllers})) do |entry|
61
+ if entry =~ /^\w+_controller\.rb$/
62
+ require File.join(RAILS_ROOT, %w{app controllers}, entry)
63
+ end
64
+ end
65
+ rescue Errno::ENOENT
66
+ end
67
+ controllers = []
68
+ ObjectSpace.each_object(Class) do |obj|
69
+ controllers << obj if obj.ancestors.include?(ActionController::Base) and
70
+ !%w{ActionController::Base ApplicationController}.include?(obj.name)
71
+ end
72
+
73
+ controllers.inject({}) do |memo, controller|
74
+ catchall_permissions = []
75
+ permission_by_action = {}
76
+ controller.all_filter_access_permissions.each do |controller_permissions|
77
+ catchall_permissions << controller_permissions if controller_permissions.actions.include?(:all)
78
+ controller_permissions.actions.reject {|action| action == :all}.each do |action|
79
+ permission_by_action[action] = controller_permissions
80
+ end
81
+ end
82
+
83
+ actions = controller.public_instance_methods(false) - controller.hidden_actions
84
+ memo[controller] = actions.inject({}) do |actions_memo, action|
85
+ action_sym = action.to_sym
86
+ actions_memo[action_sym] =
87
+ if permission_by_action[action_sym]
88
+ {
89
+ :privilege => permission_by_action[action_sym].privilege,
90
+ :context => permission_by_action[action_sym].context,
91
+ :controller_permissions => [permission_by_action[action_sym]]
92
+ }
93
+ elsif !catchall_permissions.empty?
94
+ {
95
+ :privilege => catchall_permissions[0].privilege,
96
+ :context => catchall_permissions[0].context,
97
+ :controller_permissions => catchall_permissions
98
+ }
99
+ else
100
+ {}
101
+ end
102
+ actions_memo
103
+ end
104
+ memo
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ # TestHelper provides assert methods and controller request methods which
111
+ # take authorization into account and set the current user to a specific
112
+ # one.
113
+ #
114
+ # Defines get_with, post_with, get_by_xhr_with etc. for methods
115
+ # get, post, put, delete each with the signature
116
+ # get_with(user, action, params = {}, session = {}, flash = {})
117
+ #
118
+ # Use it by including it in your TestHelper:
119
+ # require File.expand_path(File.dirname(__FILE__) +
120
+ # "/../vendor/plugins/declarative_authorization/lib/maintenance")
121
+ # class Test::Unit::TestCase
122
+ # include Authorization::TestHelper
123
+ # ...
124
+ #
125
+ # def admin
126
+ # # create admin user
127
+ # end
128
+ # end
129
+ #
130
+ # class SomeControllerTest < ActionController::TestCase
131
+ # def test_should_get_index
132
+ # ...
133
+ # get_with admin, :index, :param_1 => "param value"
134
+ # ...
135
+ # end
136
+ # end
137
+ module TestHelper
138
+ include Authorization::Maintenance
139
+
140
+ # Analogue to the Ruby's assert_raise method, only executing the block
141
+ # in the context of the given user.
142
+ def assert_raise_with_user (user, *args, &block)
143
+ assert_raise(*args) do
144
+ with_user(user, &block)
145
+ end
146
+ end
147
+
148
+ def request_with (user, method, xhr, action, params = {},
149
+ session = {}, flash = {})
150
+ session = session.merge({:user => user, :user_id => user.id})
151
+ with_user(user) do
152
+ if xhr
153
+ xhr method, action, params, session, flash
154
+ else
155
+ send method, action, params, session, flash
156
+ end
157
+ end
158
+ end
159
+
160
+ def self.included (base)
161
+ [:get, :post, :put, :delete].each do |method|
162
+ base.class_eval <<-EOV, __FILE__, __LINE__
163
+ def #{method}_with (user, *args)
164
+ request_with(user, #{method.inspect}, false, *args)
165
+ end
166
+
167
+ def #{method}_by_xhr_with (user, *args)
168
+ request_with(user, #{method.inspect}, true, *args)
169
+ end
170
+ EOV
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,281 @@
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
+ # After successfully parsing an obligation, all of the stored paths and conditions are converted
37
+ # into scope options (stored in +proxy_options+ as +:joins+ and +:conditions+). The resulting
38
+ # scope may then be used to find all scoped objects for which at least one of the parsed
39
+ # obligations is fully met.
40
+ #
41
+ # +@proxy_options[:joins] = { :bar => { :baz => :foo } }
42
+ # @proxy_options[:conditions] = [ 'foos_bazzes.attr = :foos_bazzes__id_0', { :foos_bazzes__id_0 => 1 } ]+
43
+ #
44
+ class ObligationScope < ActiveRecord::NamedScope::Scope
45
+
46
+ # Consumes the given obligation, converting it into scope join and condition options.
47
+ def parse!( obligation )
48
+ @current_obligation = obligation
49
+ obligation_conditions[@current_obligation] ||= {}
50
+ follow_path( obligation )
51
+
52
+ rebuild_condition_options!
53
+ rebuild_join_options!
54
+ end
55
+
56
+ protected
57
+
58
+ # Parses the next step in the association path. If it's an association, we advance down the
59
+ # path. Otherwise, it's an attribute, and we need to evaluate it as a comparison operation.
60
+ def follow_path( steps, past_steps = [] )
61
+ if steps.is_a?( Hash )
62
+ steps.each do |step, next_steps|
63
+ path_to_this_point = [past_steps, step].flatten
64
+ reflection = reflection_for( path_to_this_point ) rescue nil
65
+ if reflection
66
+ follow_path( next_steps, path_to_this_point )
67
+ else
68
+ follow_comparison( next_steps, past_steps, step )
69
+ end
70
+ end
71
+ elsif steps.is_a?( Array ) && steps.length == 2
72
+ if reflection_for( past_steps )
73
+ follow_comparison( steps, past_steps, :id )
74
+ else
75
+ follow_comparison( steps, past_steps[0..-2], past_steps[-1] )
76
+ end
77
+ else
78
+ raise "invalid obligation path #{[past_steps, steps].flatten}"
79
+ end
80
+ end
81
+
82
+ # At the end of every association path, we expect to see a comparison of some kind; for
83
+ # example, +:attr => [ :is, :value ]+.
84
+ #
85
+ # This method parses the comparison and creates an obligation condition from it.
86
+ def follow_comparison( steps, past_steps, attribute )
87
+ operator = steps[0]
88
+ value = steps[1..-1]
89
+ value = value[0] if value.length == 1
90
+
91
+ add_obligation_condition_for( past_steps, [attribute, operator, value] )
92
+ end
93
+
94
+ # Adds the given expression to the current obligation's indicated path's conditions.
95
+ #
96
+ # Condition expressions must follow the format +[ <attribute>, <operator>, <value> ]+.
97
+ def add_obligation_condition_for( path, expression )
98
+ raise "invalid expression #{expression.inspect}" unless expression.is_a?( Array ) && expression.length == 3
99
+ add_obligation_join_for( path )
100
+ obligation_conditions[@current_obligation] ||= {}
101
+ ( obligation_conditions[@current_obligation][path] ||= Set.new ) << expression
102
+ end
103
+
104
+ # Adds the given path to the list of obligation joins, if we haven't seen it before.
105
+ def add_obligation_join_for( path )
106
+ map_reflection_for( path ) if reflections[path].nil?
107
+ end
108
+
109
+ # Returns the model associated with the given path.
110
+ def model_for( path )
111
+ reflection = reflection_for( path )
112
+ reflection.respond_to?( :klass ) ? reflection.klass : reflection
113
+ end
114
+
115
+ # Returns the reflection corresponding to the given path.
116
+ def reflection_for( path )
117
+ reflections[path] ||= map_reflection_for( path )
118
+ end
119
+
120
+ # Returns a proper table alias for the given path. This alias may be used in SQL statements.
121
+ def table_alias_for( path )
122
+ table_aliases[path] ||= map_table_alias_for( path )
123
+ end
124
+
125
+ # Attempts to map a reflection for the given path. Raises if already defined.
126
+ def map_reflection_for( path )
127
+ raise "reflection for #{path.inspect} already exists" unless reflections[path].nil?
128
+
129
+ reflection = path.empty? ? @proxy_scope : begin
130
+ parent = reflection_for( path[0..-2] )
131
+ parent.klass.reflect_on_association( path.last )
132
+ rescue
133
+ parent.reflect_on_association( path.last )
134
+ end
135
+ raise "invalid path #{path.inspect}" if reflection.nil?
136
+
137
+ reflections[path] = reflection
138
+ map_table_alias_for( path ) # Claim a table alias for the path.
139
+
140
+ reflection
141
+ end
142
+
143
+ # Attempts to map a table alias for the given path. Raises if already defined.
144
+ def map_table_alias_for( path )
145
+ return "table alias for #{path.inspect} already exists" unless table_aliases[path].nil?
146
+
147
+ reflection = reflection_for( path )
148
+ table_alias = reflection.table_name
149
+ if table_aliases.values.include?( table_alias )
150
+ max_length = reflection.active_record.connection.table_alias_length
151
+ # Rails seems to pluralize reflection names
152
+ table_alias = "#{reflection.name.to_s.pluralize}_#{reflection.active_record.table_name}".to(max_length-1)
153
+ end
154
+ while table_aliases.values.include?( table_alias )
155
+ if table_alias =~ /\w(_\d+?)$/
156
+ table_index = $1.succ
157
+ table_alias = "#{table_alias[0..-(table_index.length+1)]}_#{table_index}"
158
+ else
159
+ table_alias = "#{table_alias[0..(max_length-3)]}_2"
160
+ end
161
+ end
162
+ table_aliases[path] = table_alias
163
+ end
164
+
165
+ # Returns a hash mapping obligations to zero or more condition path sets.
166
+ def obligation_conditions
167
+ @obligation_conditions ||= {}
168
+ end
169
+
170
+ # Returns a hash mapping paths to reflections.
171
+ def reflections
172
+ # lets try to get the order of joins right
173
+ @reflections ||= ActiveSupport::OrderedHash.new
174
+ end
175
+
176
+ # Returns a hash mapping paths to proper table aliases to use in SQL statements.
177
+ def table_aliases
178
+ @table_aliases ||= {}
179
+ end
180
+
181
+ # Parses all of the defined obligation conditions and defines the scope's :conditions option.
182
+ def rebuild_condition_options!
183
+ conds = []
184
+ binds = {}
185
+ used_paths = Set.new
186
+ delete_paths = Set.new
187
+ obligation_conditions.each_with_index do |array, obligation_index|
188
+ obligation, conditions = array
189
+ obligation_conds = []
190
+ conditions.each do |path, expressions|
191
+ model = model_for( path )
192
+ table_alias = table_alias_for(path)
193
+ parent_model = (path.length > 1 ? model_for(path[0..-2]) : @proxy_scope)
194
+ expressions.each do |expression|
195
+ attribute, operator, value = expression
196
+ # prevent unnecessary joins:
197
+ if attribute == :id and operator == :is and parent_model.columns_hash["#{path.last}_id"]
198
+ attribute_name = :"#{path.last}_id"
199
+ attribute_table_alias = table_alias_for(path[0..-2])
200
+ used_paths << path[0..-2]
201
+ delete_paths << path
202
+ else
203
+ attribute_name = model.columns_hash["#{attribute}_id"] && :"#{attribute}_id" ||
204
+ model.columns_hash[attribute.to_s] && attribute ||
205
+ :id
206
+ attribute_table_alias = table_alias
207
+ used_paths << path
208
+ end
209
+ bindvar = "#{attribute_table_alias}__#{attribute_name}_#{obligation_index}".to_sym
210
+
211
+ attribute_value = value.respond_to?( :descends_from_active_record? ) && value.descends_from_active_record? && value.id ||
212
+ value.is_a?( Array ) && value[0].respond_to?( :descends_from_active_record? ) && value[0].descends_from_active_record? && value.map( &:id ) ||
213
+ value
214
+ attribute_operator = case operator
215
+ when :contains, :is then "= :#{bindvar}"
216
+ when :does_not_contain, :is_not then "<> :#{bindvar}"
217
+ when :is_in then "IN (:#{bindvar})"
218
+ when :is_not_in then "NOT IN (:#{bindvar})"
219
+ end
220
+ obligation_conds << "#{connection.quote_table_name(attribute_table_alias)}.#{connection.quote_table_name(attribute_name)} #{attribute_operator}"
221
+ binds[bindvar] = attribute_value
222
+ end
223
+ end
224
+ obligation_conds << "1=1" if obligation_conds.empty?
225
+ conds << "(#{obligation_conds.join(' AND ')})"
226
+ end
227
+ (delete_paths - used_paths).each {|path| reflections.delete(path)}
228
+ @proxy_options[:conditions] = [ conds.join( " OR " ), binds ]
229
+ end
230
+
231
+ # Parses all of the defined obligation joins and defines the scope's :joins or :includes option.
232
+ # TODO: Support non-linear association paths. Right now, we just break down the longest path parsed.
233
+ def rebuild_join_options!
234
+ joins = @proxy_options[:joins] || []
235
+
236
+ reflections.keys.reverse.each do |path|
237
+ next if path.empty?
238
+
239
+ existing_join = joins.find do |join|
240
+ join.is_a?(Symbol) ? (join == path.first) : join.key?(path.first)
241
+ end
242
+ path_join = path_to_join(path)
243
+
244
+ case [existing_join.class, path_join.class]
245
+ when [Symbol, Hash]
246
+ joins.delete(existing_join)
247
+ joins << path_join
248
+ when [Hash, Hash]
249
+ joins.delete(existing_join)
250
+ joins << path_join.deep_merge(existing_join)
251
+ when [NilClass, Hash], [NilClass, Symbol]
252
+ joins << path_join
253
+ end
254
+ end
255
+
256
+ case obligation_conditions.length
257
+ when 0:
258
+ # No obligation conditions means we don't have to mess with joins or includes at all.
259
+ when 1:
260
+ @proxy_options[:joins] = joins
261
+ @proxy_options.delete( :include )
262
+ else
263
+ @proxy_options.delete( :joins )
264
+ @proxy_options[:include] = joins
265
+ end
266
+ end
267
+
268
+ def path_to_join (path)
269
+ case path.length
270
+ when 0 then nil
271
+ when 1 then path[0]
272
+ else
273
+ hash = { path[-2] => path[-1] }
274
+ path[0..-3].reverse.each do |elem|
275
+ hash = { elem => hash }
276
+ end
277
+ hash
278
+ end
279
+ end
280
+ end
281
+ end