stffn-declarative_authorization 0.2.1 → 0.2.3

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.
@@ -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