culturecode-cancan 2.0.0.alpha

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 (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.rdoc +381 -0
  3. data/Gemfile +3 -0
  4. data/LICENSE +20 -0
  5. data/README.rdoc +108 -0
  6. data/Rakefile +18 -0
  7. data/init.rb +1 -0
  8. data/lib/cancan.rb +13 -0
  9. data/lib/cancan/ability.rb +348 -0
  10. data/lib/cancan/controller_additions.rb +392 -0
  11. data/lib/cancan/controller_resource.rb +266 -0
  12. data/lib/cancan/exceptions.rb +53 -0
  13. data/lib/cancan/inherited_resource.rb +20 -0
  14. data/lib/cancan/matchers.rb +14 -0
  15. data/lib/cancan/model_adapters/abstract_adapter.rb +56 -0
  16. data/lib/cancan/model_adapters/active_record_adapter.rb +172 -0
  17. data/lib/cancan/model_adapters/data_mapper_adapter.rb +34 -0
  18. data/lib/cancan/model_adapters/default_adapter.rb +7 -0
  19. data/lib/cancan/model_adapters/mongoid_adapter.rb +54 -0
  20. data/lib/cancan/model_additions.rb +29 -0
  21. data/lib/cancan/rule.rb +178 -0
  22. data/lib/generators/cancan/ability/USAGE +5 -0
  23. data/lib/generators/cancan/ability/ability_generator.rb +16 -0
  24. data/lib/generators/cancan/ability/templates/ability.rb +24 -0
  25. data/lib/generators/cancan/ability/templates/ability_spec.rb +16 -0
  26. data/lib/generators/cancan/ability/templates/ability_test.rb +10 -0
  27. data/spec/README.rdoc +28 -0
  28. data/spec/cancan/ability_spec.rb +541 -0
  29. data/spec/cancan/controller_additions_spec.rb +118 -0
  30. data/spec/cancan/controller_resource_spec.rb +551 -0
  31. data/spec/cancan/exceptions_spec.rb +58 -0
  32. data/spec/cancan/inherited_resource_spec.rb +58 -0
  33. data/spec/cancan/matchers_spec.rb +33 -0
  34. data/spec/cancan/model_adapters/active_record_adapter_spec.rb +278 -0
  35. data/spec/cancan/model_adapters/data_mapper_adapter_spec.rb +120 -0
  36. data/spec/cancan/model_adapters/default_adapter_spec.rb +7 -0
  37. data/spec/cancan/model_adapters/mongoid_adapter_spec.rb +226 -0
  38. data/spec/cancan/rule_spec.rb +55 -0
  39. data/spec/matchers.rb +13 -0
  40. data/spec/spec_helper.rb +49 -0
  41. metadata +194 -0
@@ -0,0 +1,266 @@
1
+ module CanCan
2
+ # Handle the load and authorization controller logic so we don't clutter up all controllers with non-interface methods.
3
+ # This class is used internally, so you do not need to call methods directly on it.
4
+ class ControllerResource # :nodoc:
5
+ def self.add_before_filter(controller_class, behavior, *args)
6
+ options = args.extract_options!.merge(behavior)
7
+ resource_name = args.first
8
+ before_filter_method = options.delete(:prepend) ? :prepend_before_filter : :before_filter
9
+ controller_class.send(before_filter_method, options.slice(:only, :except, :if, :unless)) do |controller|
10
+ controller.class.cancan_resource_class.new(controller, resource_name, options.except(:only, :except, :if, :unless)).process
11
+ end
12
+ end
13
+
14
+ def initialize(controller, *args)
15
+ @controller = controller
16
+ @params = controller.params
17
+ @options = args.extract_options!
18
+ @name = args.first
19
+ end
20
+
21
+ def process
22
+ if @options[:load]
23
+ if load_instance?
24
+ self.resource_instance ||= load_resource_instance
25
+ elsif load_collection?
26
+ self.collection_instance ||= load_collection
27
+ current_ability.fully_authorized! @params[:action], @params[:controller]
28
+ end
29
+ end
30
+ if @options[:authorize]
31
+ if resource_instance
32
+ if resource_params && (authorization_action == :create || authorization_action == :update)
33
+ resource_params.each do |key, value|
34
+ @controller.authorize!(authorization_action, resource_instance, key.to_sym)
35
+ end
36
+ else
37
+ @controller.authorize!(authorization_action, resource_instance)
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ def parent?
44
+ @options.has_key?(:parent) ? @options[:parent] : @name && @name != name_from_controller.to_sym
45
+ end
46
+
47
+ # def skip?(behavior) # This could probably use some refactoring
48
+ # options = @controller.class.cancan_skipper[behavior][@name]
49
+ # if options.nil?
50
+ # false
51
+ # elsif options == {}
52
+ # true
53
+ # elsif options[:except] && ![options[:except]].flatten.include?(@params[:action].to_sym)
54
+ # true
55
+ # elsif [options[:only]].flatten.include?(@params[:action].to_sym)
56
+ # true
57
+ # end
58
+ # end
59
+
60
+ protected
61
+
62
+ def load_resource_instance
63
+ if !parent? && new_actions.include?(@params[:action].to_sym)
64
+ build_resource
65
+ elsif id_param || @options[:singleton]
66
+ find_and_update_resource
67
+ end
68
+ end
69
+
70
+ def load_instance?
71
+ parent? || member_action?
72
+ end
73
+
74
+ def load_collection?
75
+ resource_base.respond_to?(:accessible_by) && !current_ability.has_block?(authorization_action, subject_name)
76
+ end
77
+
78
+ def load_collection
79
+ resource_base.accessible_by(current_ability, authorization_action)
80
+ end
81
+
82
+ def build_resource
83
+ resource = resource_base.new(resource_params || {})
84
+ assign_attributes(resource)
85
+ end
86
+
87
+ def assign_attributes(resource)
88
+ initial_attributes.each do |attr_name, value|
89
+ resource.send("#{attr_name}=", value)
90
+ end
91
+
92
+ resource.send("#{parent_name}=", parent_resource) if @options[:singleton] && parent_resource
93
+ resource
94
+ end
95
+
96
+ def initial_attributes
97
+ current_ability.attributes_for(@params[:action].to_sym, subject_name).delete_if do |key, value|
98
+ resource_params && resource_params.include?(key)
99
+ end
100
+ end
101
+
102
+ def find_and_update_resource
103
+ resource = find_resource
104
+ if resource_params
105
+ @controller.authorize!(authorization_action, resource) if @options[:authorize]
106
+ resource.attributes = resource_params
107
+ end
108
+ resource
109
+ end
110
+
111
+ def find_resource
112
+ if @options[:singleton] && parent_resource.respond_to?(name)
113
+ parent_resource.send(name)
114
+ else
115
+ if @options[:find_by]
116
+ if resource_base.respond_to? "find_by_#{@options[:find_by]}!"
117
+ resource_base.send("find_by_#{@options[:find_by]}!", id_param)
118
+ else
119
+ resource_base.send(@options[:find_by], id_param)
120
+ end
121
+ else
122
+ adapter.find(resource_base, id_param)
123
+ end
124
+ end
125
+ end
126
+
127
+ def adapter
128
+ ModelAdapters::AbstractAdapter.adapter_class(resource_class)
129
+ end
130
+
131
+ def authorization_action
132
+ parent? ? :show : @params[:action].to_sym
133
+ end
134
+
135
+ def id_param
136
+ if @options[:id_param]
137
+ @params[@options[:id_param]]
138
+ else
139
+ @params[parent? ? :"#{name}_id" : :id]
140
+ end
141
+ end
142
+
143
+ def member_action?
144
+ new_actions.include?(@params[:action].to_sym) || @options[:singleton] || ( (@params[:id] || @params[@options[:id_param]]) && !collection_actions.include?(@params[:action].to_sym))
145
+ end
146
+
147
+ # Returns the class used for this resource. This can be overriden by the :class option.
148
+ # If +false+ is passed in it will use the resource name as a symbol in which case it should
149
+ # only be used for authorization, not loading since there's no class to load through.
150
+ def resource_class
151
+ case @options[:class]
152
+ when false then name.to_sym
153
+ when nil then namespaced_name.to_s.camelize.constantize
154
+ when String then @options[:class].constantize
155
+ else @options[:class]
156
+ end
157
+ end
158
+
159
+ def subject_name
160
+ resource_class.to_s.underscore.pluralize.to_sym
161
+ end
162
+
163
+ def subject_name_with_parent
164
+ parent_resource ? {parent_resource => subject_name} : subject_name
165
+ end
166
+
167
+ def resource_instance=(instance)
168
+ @controller.instance_variable_set("@#{instance_name}", instance)
169
+ end
170
+
171
+ def resource_instance
172
+ if load_instance?
173
+ if @controller.instance_variable_defined? "@#{instance_name}"
174
+ @controller.instance_variable_get("@#{instance_name}")
175
+ elsif @controller.respond_to?(instance_name, true)
176
+ @controller.send(instance_name)
177
+ end
178
+ end
179
+ end
180
+
181
+ def collection_instance=(instance)
182
+ @controller.instance_variable_set("@#{instance_name.to_s.pluralize}", instance)
183
+ end
184
+
185
+ def collection_instance
186
+ @controller.instance_variable_get("@#{instance_name.to_s.pluralize}")
187
+ end
188
+
189
+ # The object that methods (such as "find", "new" or "build") are called on.
190
+ # If the :through option is passed it will go through an association on that instance.
191
+ # If the :shallow option is passed it will use the resource_class if there's no parent
192
+ # If the :singleton option is passed it won't use the association because it needs to be handled later.
193
+ def resource_base
194
+ if @options[:through]
195
+ if parent_resource
196
+ @options[:singleton] ? resource_class : parent_resource.send(@options[:through_association] || name.to_s.pluralize)
197
+ elsif @options[:shallow]
198
+ resource_class
199
+ else
200
+ raise Unauthorized.new(nil, authorization_action, @params[:controller].to_sym) # maybe this should be a record not found error instead?
201
+ end
202
+ else
203
+ resource_class
204
+ end
205
+ end
206
+
207
+ def parent_name
208
+ @options[:through] && [@options[:through]].flatten.detect { |i| fetch_parent(i) }
209
+ end
210
+
211
+ # The object to load this resource through.
212
+ def parent_resource
213
+ parent_name && fetch_parent(parent_name)
214
+ end
215
+
216
+ def fetch_parent(name)
217
+ if @controller.instance_variable_defined? "@#{name}"
218
+ @controller.instance_variable_get("@#{name}")
219
+ elsif @controller.respond_to?(name, true)
220
+ @controller.send(name)
221
+ end
222
+ end
223
+
224
+ def current_ability
225
+ @controller.send(:current_ability)
226
+ end
227
+
228
+ def name
229
+ @name || name_from_controller
230
+ end
231
+
232
+ def resource_params
233
+ if @options[:class]
234
+ @params[@options[:class].to_s.underscore.gsub('/', '_')]
235
+ else
236
+ @params[namespaced_name.to_s.underscore.gsub("/", "_")]
237
+ end
238
+ end
239
+
240
+ def namespace
241
+ @params[:controller].split(/::|\//)[0..-2]
242
+ end
243
+
244
+ def namespaced_name
245
+ [namespace, name.camelize].join('::').singularize.camelize.constantize
246
+ rescue NameError
247
+ name
248
+ end
249
+
250
+ def name_from_controller
251
+ @params[:controller].sub("Controller", "").underscore.split('/').last.singularize
252
+ end
253
+
254
+ def instance_name
255
+ @options[:instance_name] || name
256
+ end
257
+
258
+ def collection_actions
259
+ [:index] + [@options[:collection]].flatten
260
+ end
261
+
262
+ def new_actions
263
+ [:new, :create] + [@options[:new]].flatten
264
+ end
265
+ end
266
+ end
@@ -0,0 +1,53 @@
1
+ module CanCan
2
+ # A general CanCan exception
3
+ class Error < StandardError; end
4
+
5
+ # Raised when behavior is not implemented, usually used in an abstract class.
6
+ class NotImplemented < Error; end
7
+
8
+ # Raised when removed code is called, an alternative solution is provided in message.
9
+ class ImplementationRemoved < Error; end
10
+
11
+ # Raised when using check_authorization without calling authorized!
12
+ class AuthorizationNotPerformed < Error; end
13
+
14
+ # Raised when enable_authorization is used and not fully authorized by the end of the action
15
+ class InsufficientAuthorizationCheck < Error; end
16
+
17
+ # This error is raised when a user isn't allowed to access a given controller action.
18
+ # This usually happens within a call to ControllerAdditions#authorize! but can be
19
+ # raised manually.
20
+ #
21
+ # raise CanCan::Unauthorized.new("Not authorized!", :read, Article)
22
+ #
23
+ # The passed message, action, and subject are optional and can later be retrieved when
24
+ # rescuing from the exception.
25
+ #
26
+ # exception.message # => "Not authorized!"
27
+ # exception.action # => :read
28
+ # exception.subject # => Article
29
+ #
30
+ # If the message is not specified (or is nil) it will default to "You are not authorized
31
+ # to access this page." This default can be overridden by setting default_message.
32
+ #
33
+ # exception.default_message = "Default error message"
34
+ # exception.message # => "Default error message"
35
+ #
36
+ # See ControllerAdditions#authorize! for more information on rescuing from this exception
37
+ # and customizing the message using I18n.
38
+ class Unauthorized < Error
39
+ attr_reader :action, :subject
40
+ attr_writer :default_message
41
+
42
+ def initialize(message = nil, action = nil, subject = nil)
43
+ @message = message
44
+ @action = action
45
+ @subject = subject
46
+ @default_message = I18n.t(:"unauthorized.default", :default => "You are not authorized to access this page.")
47
+ end
48
+
49
+ def to_s
50
+ @message || @default_message
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,20 @@
1
+ module CanCan
2
+ # For use with Inherited Resources
3
+ class InheritedResource < ControllerResource # :nodoc:
4
+ def load_resource_instance
5
+ if parent?
6
+ @controller.send :association_chain
7
+ @controller.instance_variable_get("@#{instance_name}")
8
+ elsif new_actions.include? @params[:action].to_sym
9
+ resource = @controller.send :build_resource
10
+ assign_attributes(resource)
11
+ else
12
+ @controller.send :resource
13
+ end
14
+ end
15
+
16
+ def resource_base
17
+ @controller.send :end_of_association_chain
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,14 @@
1
+ rspec_module = defined?(RSpec::Core) ? 'RSpec' : 'Spec' # for RSpec 1 compatability
2
+ Kernel.const_get(rspec_module)::Matchers.define :be_able_to do |*args|
3
+ match do |ability|
4
+ ability.can?(*args)
5
+ end
6
+
7
+ failure_message_for_should do |ability|
8
+ "expected to be able to #{args.map(&:inspect).join(" ")}"
9
+ end
10
+
11
+ failure_message_for_should_not do |ability|
12
+ "expected not to be able to #{args.map(&:inspect).join(" ")}"
13
+ end
14
+ end
@@ -0,0 +1,56 @@
1
+ module CanCan
2
+ module ModelAdapters
3
+ class AbstractAdapter
4
+ def self.inherited(subclass)
5
+ @subclasses ||= []
6
+ @subclasses << subclass
7
+ end
8
+
9
+ def self.adapter_class(model_class)
10
+ @subclasses.detect { |subclass| subclass.for_class?(model_class) } || DefaultAdapter
11
+ end
12
+
13
+ # Used to determine if the given adapter should be used for the passed in class.
14
+ def self.for_class?(member_class)
15
+ false # override in subclass
16
+ end
17
+
18
+ # Override if you need custom find behavior
19
+ def self.find(model_class, id)
20
+ model_class.find(id)
21
+ end
22
+
23
+ # Used to determine if this model adapter will override the matching behavior for a hash of conditions.
24
+ # If this returns true then matches_conditions_hash? will be called. See Rule#matches_conditions_hash
25
+ def self.override_conditions_hash_matching?(subject, conditions)
26
+ false
27
+ end
28
+
29
+ # Override if override_conditions_hash_matching? returns true
30
+ def self.matches_conditions_hash?(subject, conditions)
31
+ raise NotImplemented, "This model adapter does not support matching on a conditions hash."
32
+ end
33
+
34
+ # Used to determine if this model adapter will override the matching behavior for a specific condition.
35
+ # If this returns true then matches_condition? will be called. See Rule#matches_conditions_hash
36
+ def self.override_condition_matching?(subject, name, value)
37
+ false
38
+ end
39
+
40
+ # Override if override_condition_matching? returns true
41
+ def self.matches_condition?(subject, name, value)
42
+ raise NotImplemented, "This model adapter does not support matching on a specific condition."
43
+ end
44
+
45
+ def initialize(model_class, rules)
46
+ @model_class = model_class
47
+ @rules = rules
48
+ end
49
+
50
+ def database_records
51
+ # This should be overridden in a subclass to return records which match @rules
52
+ raise NotImplemented, "This model adapter does not support fetching records from the database."
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,172 @@
1
+ module CanCan
2
+ module ModelAdapters
3
+ class ActiveRecordAdapter < AbstractAdapter
4
+ def self.for_class?(model_class)
5
+ model_class <= ActiveRecord::Base
6
+ end
7
+
8
+ def self.override_condition_matching?(subject, name, value)
9
+ name.kind_of?(MetaWhere::Column) if defined? MetaWhere
10
+ end
11
+
12
+ def self.matches_condition?(subject, name, value)
13
+ subject_value = subject.send(name.column)
14
+ if name.method.to_s.ends_with? "_any"
15
+ value.any? { |v| meta_where_match? subject_value, name.method.to_s.sub("_any", ""), v }
16
+ elsif name.method.to_s.ends_with? "_all"
17
+ value.all? { |v| meta_where_match? subject_value, name.method.to_s.sub("_all", ""), v }
18
+ else
19
+ meta_where_match? subject_value, name.method, value
20
+ end
21
+ end
22
+
23
+ def self.meta_where_match?(subject_value, method, value)
24
+ case method.to_sym
25
+ when :eq then subject_value == value
26
+ when :not_eq then subject_value != value
27
+ when :in then value.include?(subject_value)
28
+ when :not_in then !value.include?(subject_value)
29
+ when :lt then subject_value < value
30
+ when :lteq then subject_value <= value
31
+ when :gt then subject_value > value
32
+ when :gteq then subject_value >= value
33
+ when :matches then subject_value =~ Regexp.new("^" + Regexp.escape(value).gsub("%", ".*") + "$", true)
34
+ when :does_not_match then !meta_where_match?(subject_value, :matches, value)
35
+ else raise NotImplemented, "The #{method} MetaWhere condition is not supported."
36
+ end
37
+ end
38
+
39
+ # Returns conditions intended to be used inside a database query. Normally you will not call this
40
+ # method directly, but instead go through ModelAdditions#accessible_by.
41
+ #
42
+ # If there is only one "can" definition, a hash of conditions will be returned matching the one defined.
43
+ #
44
+ # can :manage, User, :id => 1
45
+ # query(:manage, User).conditions # => { :id => 1 }
46
+ #
47
+ # If there are multiple "can" definitions, a SQL string will be returned to handle complex cases.
48
+ #
49
+ # can :manage, User, :id => 1
50
+ # can :manage, User, :manager_id => 1
51
+ # cannot :manage, User, :self_managed => true
52
+ # query(:manage, User).conditions # => "not (self_managed = 't') AND ((manager_id = 1) OR (id = 1))"
53
+ #
54
+ def conditions
55
+ if @rules.size == 1 && @rules.first.base_behavior
56
+ # Return the conditions directly if there's just one definition
57
+ tableized_conditions(@rules.first.conditions).dup
58
+ else
59
+ @rules.reverse.inject(false_sql) do |sql, rule|
60
+ merge_conditions(sql, tableized_conditions(rule.conditions).dup, rule.base_behavior)
61
+ end
62
+ end
63
+ end
64
+
65
+ def tableized_conditions(conditions, model_class = @model_class)
66
+ return conditions unless conditions.kind_of? Hash
67
+ conditions.inject({}) do |result_hash, (name, value)|
68
+ if value.kind_of? Hash
69
+ association_class = model_class.reflect_on_association(name).class_name.constantize
70
+ name = model_class.reflect_on_association(name).table_name.to_sym
71
+ value = tableized_conditions(value, association_class)
72
+ end
73
+ result_hash[name] = value
74
+ result_hash
75
+ end
76
+ end
77
+
78
+ # Returns the associations used in conditions for the :joins option of a search.
79
+ # See ModelAdditions#accessible_by
80
+ def joins
81
+ joins_hash = {}
82
+ @rules.each do |rule|
83
+ merge_joins(joins_hash, rule.associations_hash)
84
+ end
85
+ clean_joins(joins_hash) unless joins_hash.empty?
86
+ end
87
+
88
+ def database_records
89
+ if override_scope
90
+ @model_class.scoped.merge(override_scope)
91
+ elsif @model_class.respond_to?(:where) && @model_class.respond_to?(:joins)
92
+ mergeable_conditions = @rules.select {|rule| rule.unmergeable? }.blank?
93
+ if mergeable_conditions
94
+ @model_class.where(conditions).joins(joins)
95
+ else
96
+ @model_class.where(*(@rules.map(&:conditions))).joins(joins)
97
+ end
98
+ else
99
+ @model_class.scoped(:conditions => conditions, :joins => joins)
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ def override_scope
106
+ conditions = @rules.map(&:conditions).compact
107
+ if defined?(ActiveRecord::Relation) && conditions.any? { |c| c.kind_of?(ActiveRecord::Relation) }
108
+ if conditions.size == 1
109
+ conditions.first
110
+ else
111
+ rule = @rules.detect { |rule| rule.conditions.kind_of?(ActiveRecord::Relation) }
112
+ raise Error, "Unable to merge an Active Record scope with other conditions. Instead use a hash or SQL for #{rule.actions.first} #{rule.subjects.first} ability."
113
+ end
114
+ end
115
+ end
116
+
117
+ def merge_conditions(sql, conditions_hash, behavior)
118
+ if conditions_hash.blank?
119
+ behavior ? true_sql : false_sql
120
+ else
121
+ conditions = sanitize_sql(conditions_hash)
122
+ case sql
123
+ when true_sql
124
+ behavior ? true_sql : "not (#{conditions})"
125
+ when false_sql
126
+ behavior ? conditions : false_sql
127
+ else
128
+ behavior ? "(#{conditions}) OR (#{sql})" : "not (#{conditions}) AND (#{sql})"
129
+ end
130
+ end
131
+ end
132
+
133
+ def false_sql
134
+ sanitize_sql(['?=?', true, false])
135
+ end
136
+
137
+ def true_sql
138
+ sanitize_sql(['?=?', true, true])
139
+ end
140
+
141
+ def sanitize_sql(conditions)
142
+ @model_class.send(:sanitize_sql, conditions)
143
+ end
144
+
145
+ # Takes two hashes and does a deep merge.
146
+ def merge_joins(base, add)
147
+ add.each do |name, nested|
148
+ if base[name].is_a?(Hash) && !nested.empty?
149
+ merge_joins(base[name], nested)
150
+ else
151
+ base[name] = nested
152
+ end
153
+ end
154
+ end
155
+
156
+ # Removes empty hashes and moves everything into arrays.
157
+ def clean_joins(joins_hash)
158
+ joins = []
159
+ joins_hash.each do |name, nested|
160
+ joins << (nested.empty? ? name : {name => clean_joins(nested)})
161
+ end
162
+ joins
163
+ end
164
+ end
165
+ end
166
+ end
167
+
168
+ ActiveSupport.on_load(:active_record) do
169
+ ActiveRecord::Base.class_eval do
170
+ include CanCan::ModelAdditions
171
+ end
172
+ end