cancancan 1.7.0

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 (42) hide show
  1. checksums.yaml +15 -0
  2. data/CHANGELOG.rdoc +427 -0
  3. data/CONTRIBUTING.md +11 -0
  4. data/Gemfile +23 -0
  5. data/LICENSE +20 -0
  6. data/README.rdoc +161 -0
  7. data/Rakefile +18 -0
  8. data/init.rb +1 -0
  9. data/lib/cancan.rb +13 -0
  10. data/lib/cancan/ability.rb +324 -0
  11. data/lib/cancan/controller_additions.rb +397 -0
  12. data/lib/cancan/controller_resource.rb +286 -0
  13. data/lib/cancan/exceptions.rb +50 -0
  14. data/lib/cancan/inherited_resource.rb +20 -0
  15. data/lib/cancan/matchers.rb +14 -0
  16. data/lib/cancan/model_adapters/abstract_adapter.rb +56 -0
  17. data/lib/cancan/model_adapters/active_record_adapter.rb +180 -0
  18. data/lib/cancan/model_adapters/data_mapper_adapter.rb +34 -0
  19. data/lib/cancan/model_adapters/default_adapter.rb +7 -0
  20. data/lib/cancan/model_adapters/mongoid_adapter.rb +54 -0
  21. data/lib/cancan/model_additions.rb +31 -0
  22. data/lib/cancan/rule.rb +147 -0
  23. data/lib/cancancan.rb +1 -0
  24. data/lib/generators/cancan/ability/USAGE +4 -0
  25. data/lib/generators/cancan/ability/ability_generator.rb +11 -0
  26. data/lib/generators/cancan/ability/templates/ability.rb +32 -0
  27. data/spec/README.rdoc +28 -0
  28. data/spec/cancan/ability_spec.rb +455 -0
  29. data/spec/cancan/controller_additions_spec.rb +141 -0
  30. data/spec/cancan/controller_resource_spec.rb +553 -0
  31. data/spec/cancan/exceptions_spec.rb +58 -0
  32. data/spec/cancan/inherited_resource_spec.rb +60 -0
  33. data/spec/cancan/matchers_spec.rb +29 -0
  34. data/spec/cancan/model_adapters/active_record_adapter_spec.rb +358 -0
  35. data/spec/cancan/model_adapters/data_mapper_adapter_spec.rb +118 -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 +52 -0
  39. data/spec/matchers.rb +13 -0
  40. data/spec/spec.opts +2 -0
  41. data/spec/spec_helper.rb +77 -0
  42. metadata +126 -0
@@ -0,0 +1,50 @@
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
+ # This error is raised when a user isn't allowed to access a given controller action.
15
+ # This usually happens within a call to ControllerAdditions#authorize! but can be
16
+ # raised manually.
17
+ #
18
+ # raise CanCan::AccessDenied.new("Not authorized!", :read, Article)
19
+ #
20
+ # The passed message, action, and subject are optional and can later be retrieved when
21
+ # rescuing from the exception.
22
+ #
23
+ # exception.message # => "Not authorized!"
24
+ # exception.action # => :read
25
+ # exception.subject # => Article
26
+ #
27
+ # If the message is not specified (or is nil) it will default to "You are not authorized
28
+ # to access this page." This default can be overridden by setting default_message.
29
+ #
30
+ # exception.default_message = "Default error message"
31
+ # exception.message # => "Default error message"
32
+ #
33
+ # See ControllerAdditions#authorized! for more information on rescuing from this exception
34
+ # and customizing the message using I18n.
35
+ class AccessDenied < Error
36
+ attr_reader :action, :subject
37
+ attr_writer :default_message
38
+
39
+ def initialize(message = nil, action = nil, subject = nil)
40
+ @message = message
41
+ @action = action
42
+ @subject = subject
43
+ @default_message = I18n.t(:"unauthorized.default", :default => "You are not authorized to access this page.")
44
+ end
45
+
46
+ def to_s
47
+ @message || @default_message
48
+ end
49
+ end
50
+ 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,180 @@
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
+ value = value.dup
70
+ association_class = model_class.reflect_on_association(name).class_name.constantize
71
+ nested = value.inject({}) do |nested,(k,v)|
72
+ if v.kind_of? Hash
73
+ value.delete(k)
74
+ nested[k] = v
75
+ else
76
+ result_hash[model_class.reflect_on_association(name).table_name.to_sym] = value
77
+ end
78
+ nested
79
+ end
80
+ result_hash.merge!(tableized_conditions(nested,association_class))
81
+ else
82
+ result_hash[name] = value
83
+ end
84
+ result_hash
85
+ end
86
+ end
87
+
88
+ # Returns the associations used in conditions for the :joins option of a search.
89
+ # See ModelAdditions#accessible_by
90
+ def joins
91
+ joins_hash = {}
92
+ @rules.each do |rule|
93
+ merge_joins(joins_hash, rule.associations_hash)
94
+ end
95
+ clean_joins(joins_hash) unless joins_hash.empty?
96
+ end
97
+
98
+ def database_records
99
+ if override_scope
100
+ @model_class.scoped.merge(override_scope)
101
+ elsif @model_class.respond_to?(:where) && @model_class.respond_to?(:joins)
102
+ mergeable_conditions = @rules.select {|rule| rule.unmergeable? }.blank?
103
+ if mergeable_conditions
104
+ @model_class.where(conditions).includes(joins)
105
+ else
106
+ @model_class.where(*(@rules.map(&:conditions))).includes(joins)
107
+ end
108
+ else
109
+ @model_class.scoped(:conditions => conditions, :joins => joins)
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ def override_scope
116
+ conditions = @rules.map(&:conditions).compact
117
+ if defined?(ActiveRecord::Relation) && conditions.any? { |c| c.kind_of?(ActiveRecord::Relation) }
118
+ if conditions.size == 1
119
+ conditions.first
120
+ else
121
+ rule = @rules.detect { |rule| rule.conditions.kind_of?(ActiveRecord::Relation) }
122
+ 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."
123
+ end
124
+ end
125
+ end
126
+
127
+ def merge_conditions(sql, conditions_hash, behavior)
128
+ if conditions_hash.blank?
129
+ behavior ? true_sql : false_sql
130
+ else
131
+ conditions = sanitize_sql(conditions_hash)
132
+ case sql
133
+ when true_sql
134
+ behavior ? true_sql : "not (#{conditions})"
135
+ when false_sql
136
+ behavior ? conditions : false_sql
137
+ else
138
+ behavior ? "(#{conditions}) OR (#{sql})" : "not (#{conditions}) AND (#{sql})"
139
+ end
140
+ end
141
+ end
142
+
143
+ def false_sql
144
+ sanitize_sql(['?=?', true, false])
145
+ end
146
+
147
+ def true_sql
148
+ sanitize_sql(['?=?', true, true])
149
+ end
150
+
151
+ def sanitize_sql(conditions)
152
+ @model_class.send(:sanitize_sql, conditions)
153
+ end
154
+
155
+ # Takes two hashes and does a deep merge.
156
+ def merge_joins(base, add)
157
+ add.each do |name, nested|
158
+ if base[name].is_a?(Hash)
159
+ merge_joins(base[name], nested) unless nested.empty?
160
+ else
161
+ base[name] = nested
162
+ end
163
+ end
164
+ end
165
+
166
+ # Removes empty hashes and moves everything into arrays.
167
+ def clean_joins(joins_hash)
168
+ joins = []
169
+ joins_hash.each do |name, nested|
170
+ joins << (nested.empty? ? name : {name => clean_joins(nested)})
171
+ end
172
+ joins
173
+ end
174
+ end
175
+ end
176
+ end
177
+
178
+ ActiveRecord::Base.class_eval do
179
+ include CanCan::ModelAdditions
180
+ end
@@ -0,0 +1,34 @@
1
+ module CanCan
2
+ module ModelAdapters
3
+ class DataMapperAdapter < AbstractAdapter
4
+ def self.for_class?(model_class)
5
+ model_class <= DataMapper::Resource
6
+ end
7
+
8
+ def self.find(model_class, id)
9
+ model_class.get(id)
10
+ end
11
+
12
+ def self.override_conditions_hash_matching?(subject, conditions)
13
+ conditions.any? { |k,v| !k.kind_of?(Symbol) }
14
+ end
15
+
16
+ def self.matches_conditions_hash?(subject, conditions)
17
+ collection = DataMapper::Collection.new(subject.query, [ subject ])
18
+ !!collection.first(conditions)
19
+ end
20
+
21
+ def database_records
22
+ scope = @model_class.all(:conditions => ["0 = 1"])
23
+ cans, cannots = @rules.partition { |r| r.base_behavior }
24
+ return scope if cans.empty?
25
+ # apply unions first, then differences. this mean cannot overrides can
26
+ cans.each { |r| scope += @model_class.all(:conditions => r.conditions) }
27
+ cannots.each { |r| scope -= @model_class.all(:conditions => r.conditions) }
28
+ scope
29
+ end
30
+ end # class DataMapper
31
+ end # module ModelAdapters
32
+ end # module CanCan
33
+
34
+ DataMapper::Model.append_extensions(CanCan::ModelAdditions::ClassMethods)
@@ -0,0 +1,7 @@
1
+ module CanCan
2
+ module ModelAdapters
3
+ class DefaultAdapter < AbstractAdapter
4
+ # This adapter is used when no matching adapter is found
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,54 @@
1
+ module CanCan
2
+ module ModelAdapters
3
+ class MongoidAdapter < AbstractAdapter
4
+ def self.for_class?(model_class)
5
+ model_class <= Mongoid::Document
6
+ end
7
+
8
+ def self.override_conditions_hash_matching?(subject, conditions)
9
+ conditions.any? do |k,v|
10
+ key_is_not_symbol = lambda { !k.kind_of?(Symbol) }
11
+ subject_value_is_array = lambda do
12
+ subject.respond_to?(k) && subject.send(k).is_a?(Array)
13
+ end
14
+
15
+ key_is_not_symbol.call || subject_value_is_array.call
16
+ end
17
+ end
18
+
19
+ def self.matches_conditions_hash?(subject, conditions)
20
+ # To avoid hitting the db, retrieve the raw Mongo selector from
21
+ # the Mongoid Criteria and use Mongoid::Matchers#matches?
22
+ subject.matches?( subject.class.where(conditions).selector )
23
+ end
24
+
25
+ def database_records
26
+ if @rules.size == 0
27
+ @model_class.where(:_id => {'$exists' => false, '$type' => 7}) # return no records in Mongoid
28
+ elsif @rules.size == 1 && @rules[0].conditions.is_a?(Mongoid::Criteria)
29
+ @rules[0].conditions
30
+ else
31
+ # we only need to process can rules if
32
+ # there are no rules with empty conditions
33
+ rules = @rules.reject { |rule| rule.conditions.empty? && rule.base_behavior }
34
+ process_can_rules = @rules.count == rules.count
35
+
36
+ rules.inject(@model_class.all) do |records, rule|
37
+ if process_can_rules && rule.base_behavior
38
+ records.or rule.conditions
39
+ elsif !rule.base_behavior
40
+ records.excludes rule.conditions
41
+ else
42
+ records
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ # simplest way to add `accessible_by` to all Mongoid Documents
52
+ module Mongoid::Document::ClassMethods
53
+ include CanCan::ModelAdditions::ClassMethods
54
+ end
@@ -0,0 +1,31 @@
1
+ module CanCan
2
+
3
+ # This module adds the accessible_by class method to a model. It is included in the model adapters.
4
+ module ModelAdditions
5
+ module ClassMethods
6
+ # Returns a scope which fetches only the records that the passed ability
7
+ # can perform a given action on. The action defaults to :index. This
8
+ # is usually called from a controller and passed the +current_ability+.
9
+ #
10
+ # @articles = Article.accessible_by(current_ability)
11
+ #
12
+ # Here only the articles which the user is able to read will be returned.
13
+ # If the user does not have permission to read any articles then an empty
14
+ # result is returned. Since this is a scope it can be combined with any
15
+ # other scopes or pagination.
16
+ #
17
+ # An alternative action can optionally be passed as a second argument.
18
+ #
19
+ # @articles = Article.accessible_by(current_ability, :update)
20
+ #
21
+ # Here only the articles which the user can update are returned.
22
+ def accessible_by(ability, action = :index)
23
+ ability.model_adapter(self, action).database_records
24
+ end
25
+ end
26
+
27
+ def self.included(base)
28
+ base.extend ClassMethods
29
+ end
30
+ end
31
+ end