cancancan 2.3.0 → 3.3.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/cancancan.gemspec +6 -5
  3. data/init.rb +2 -0
  4. data/lib/cancan/ability/actions.rb +2 -0
  5. data/lib/cancan/ability/rules.rb +19 -8
  6. data/lib/cancan/ability/strong_parameter_support.rb +41 -0
  7. data/lib/cancan/ability.rb +54 -24
  8. data/lib/cancan/class_matcher.rb +26 -0
  9. data/lib/cancan/conditions_matcher.rb +25 -12
  10. data/lib/cancan/config.rb +74 -0
  11. data/lib/cancan/controller_additions.rb +4 -1
  12. data/lib/cancan/controller_resource.rb +6 -0
  13. data/lib/cancan/controller_resource_builder.rb +2 -0
  14. data/lib/cancan/controller_resource_finder.rb +2 -0
  15. data/lib/cancan/controller_resource_loader.rb +4 -0
  16. data/lib/cancan/controller_resource_name_finder.rb +2 -0
  17. data/lib/cancan/controller_resource_sanitizer.rb +2 -0
  18. data/lib/cancan/exceptions.rb +18 -2
  19. data/lib/cancan/matchers.rb +3 -0
  20. data/lib/cancan/model_adapters/abstract_adapter.rb +3 -1
  21. data/lib/cancan/model_adapters/active_record_4_adapter.rb +26 -25
  22. data/lib/cancan/model_adapters/active_record_5_adapter.rb +21 -26
  23. data/lib/cancan/model_adapters/active_record_adapter.rb +56 -14
  24. data/lib/cancan/model_adapters/conditions_extractor.rb +3 -3
  25. data/lib/cancan/model_adapters/conditions_normalizer.rb +49 -0
  26. data/lib/cancan/model_adapters/default_adapter.rb +2 -0
  27. data/lib/cancan/model_adapters/sti_normalizer.rb +39 -0
  28. data/lib/cancan/model_additions.rb +6 -2
  29. data/lib/cancan/parameter_validators.rb +9 -0
  30. data/lib/cancan/relevant.rb +29 -0
  31. data/lib/cancan/rule.rb +67 -23
  32. data/lib/cancan/rules_compressor.rb +3 -0
  33. data/lib/cancan/unauthorized_message_resolver.rb +24 -0
  34. data/lib/cancan/version.rb +3 -1
  35. data/lib/cancan.rb +6 -0
  36. data/lib/cancancan.rb +2 -0
  37. data/lib/generators/cancan/ability/ability_generator.rb +3 -1
  38. data/lib/generators/cancan/ability/templates/ability.rb +2 -0
  39. metadata +37 -30
  40. data/lib/cancan/model_adapters/can_can/model_adapters/active_record_adapter/joins.rb +0 -39
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CanCan
2
4
  # A general CanCan exception
3
5
  class Error < StandardError; end
@@ -8,9 +10,15 @@ module CanCan
8
10
  # Raised when removed code is called, an alternative solution is provided in message.
9
11
  class ImplementationRemoved < Error; end
10
12
 
11
- # Raised when using check_authorization without calling authorized!
13
+ # Raised when using check_authorization without calling authorize!
12
14
  class AuthorizationNotPerformed < Error; end
13
15
 
16
+ # Raised when a rule is created with both a block and a hash of conditions
17
+ class BlockAndConditionsError < Error; end
18
+
19
+ # Raised when an unexpected argument is passed as an attribute
20
+ class AttributeArgumentError < Error; end
21
+
14
22
  # Raised when using a wrong association name
15
23
  class WrongAssociationName < Error; end
16
24
 
@@ -33,7 +41,7 @@ module CanCan
33
41
  # exception.default_message = "Default error message"
34
42
  # exception.message # => "Default error message"
35
43
  #
36
- # See ControllerAdditions#authorized! for more information on rescuing from this exception
44
+ # See ControllerAdditions#authorize! for more information on rescuing from this exception
37
45
  # and customizing the message using I18n.
38
46
  class AccessDenied < Error
39
47
  attr_reader :action, :subject, :conditions
@@ -50,5 +58,13 @@ module CanCan
50
58
  def to_s
51
59
  @message || @default_message
52
60
  end
61
+
62
+ def inspect
63
+ details = %i[action subject conditions message].map do |attribute|
64
+ value = instance_variable_get "@#{attribute}"
65
+ "#{attribute}: #{value.inspect}" if value.present?
66
+ end.compact.join(', ')
67
+ "#<#{self.class.name} #{details}>"
68
+ end
53
69
  end
54
70
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  rspec_module = defined?(RSpec::Core) ? 'RSpec' : 'Spec' # RSpec 1 compatability
2
4
 
3
5
  if rspec_module == 'RSpec'
@@ -12,6 +14,7 @@ Kernel.const_get(rspec_module)::Matchers.define :be_able_to do |*args|
12
14
  actions = args.first
13
15
  if actions.is_a? Array
14
16
  break false if actions.empty?
17
+
15
18
  actions.all? { |action| ability.can?(action, *args[1..-1]) }
16
19
  else
17
20
  ability.can?(*args)
@@ -1,9 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CanCan
2
4
  module ModelAdapters
3
5
  class AbstractAdapter
4
6
  def self.inherited(subclass)
5
7
  @subclasses ||= []
6
- @subclasses << subclass
8
+ @subclasses.insert(0, subclass)
7
9
  end
8
10
 
9
11
  def self.adapter_class(model_class)
@@ -1,27 +1,30 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CanCan
2
4
  module ModelAdapters
3
- class ActiveRecord4Adapter < AbstractAdapter
4
- include ActiveRecordAdapter
5
- def self.for_class?(model_class)
6
- ActiveRecord::VERSION::MAJOR == 4 && model_class <= ActiveRecord::Base
7
- end
5
+ class ActiveRecord4Adapter < ActiveRecordAdapter
6
+ AbstractAdapter.inherited(self)
8
7
 
9
- # TODO: this should be private
10
- def self.override_condition_matching?(subject, name, _value)
11
- subject.class.defined_enums.include?(name.to_s)
12
- end
8
+ class << self
9
+ def for_class?(model_class)
10
+ version_lower?('5.0.0') && model_class <= ActiveRecord::Base
11
+ end
13
12
 
14
- # TODO: this should be private
15
- def self.matches_condition?(subject, name, value)
16
- # Get the mapping from enum strings to values.
17
- enum = subject.class.send(name.to_s.pluralize)
18
- # Get the value of the attribute as an integer.
19
- attribute = enum[subject.send(name)]
20
- # Check to see if the value matches the condition.
21
- if value.is_a?(Enumerable)
22
- value.include? attribute
23
- else
24
- attribute == value
13
+ def override_condition_matching?(subject, name, _value)
14
+ subject.class.defined_enums.include?(name.to_s)
15
+ end
16
+
17
+ def matches_condition?(subject, name, value)
18
+ # Get the mapping from enum strings to values.
19
+ enum = subject.class.send(name.to_s.pluralize)
20
+ # Get the value of the attribute as an integer.
21
+ attribute = enum[subject.send(name)]
22
+ # Check to see if the value matches the condition.
23
+ if value.is_a?(Enumerable)
24
+ value.include? attribute
25
+ else
26
+ attribute == value
27
+ end
25
28
  end
26
29
  end
27
30
 
@@ -31,15 +34,13 @@ module CanCan
31
34
  # look inside the where clause to decide to outer join tables
32
35
  # you're using in the where. Instead, `references()` is required
33
36
  # in addition to `includes()` to force the outer join.
34
- def build_relation(*where_conditions)
35
- relation = @model_class.where(*where_conditions)
36
- relation = relation.includes(joins).references(joins) if joins.present?
37
- relation
37
+ def build_joins_relation(relation, *_where_conditions)
38
+ relation.includes(joins).references(joins)
38
39
  end
39
40
 
40
41
  # Rails 4.2 deprecates `sanitize_sql_hash_for_conditions`
41
42
  def sanitize_sql(conditions)
42
- if ActiveRecord::VERSION::MINOR >= 2 && conditions.is_a?(Hash)
43
+ if self.class.version_greater_or_equal?('4.2.0') && conditions.is_a?(Hash)
43
44
  sanitize_sql_activerecord4(conditions)
44
45
  else
45
46
  @model_class.send(:sanitize_sql, conditions)
@@ -1,10 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CanCan
2
4
  module ModelAdapters
3
5
  class ActiveRecord5Adapter < ActiveRecord4Adapter
4
6
  AbstractAdapter.inherited(self)
5
7
 
6
8
  def self.for_class?(model_class)
7
- ActiveRecord::VERSION::MAJOR == 5 && model_class <= ActiveRecord::Base
9
+ version_greater_or_equal?('5.0.0') && model_class <= ActiveRecord::Base
8
10
  end
9
11
 
10
12
  # rails 5 is capable of using strings in enum
@@ -13,26 +15,25 @@ module CanCan
13
15
  return super if Array.wrap(value).all? { |x| x.is_a? Integer }
14
16
 
15
17
  attribute = subject.send(name)
16
- if value.is_a?(Enumerable)
17
- value.map(&:to_s).include? attribute
18
- else
19
- attribute == value.to_s
20
- end
18
+ raw_attribute = subject.class.send(name.to_s.pluralize)[attribute]
19
+ !(Array(value).map(&:to_s) & [attribute, raw_attribute]).empty?
21
20
  end
22
21
 
23
22
  private
24
23
 
25
- # As of rails 4, `includes()` no longer causes active record to
26
- # look inside the where clause to decide to outer join tables
27
- # you're using in the where. Instead, `references()` is required
28
- # in addition to `includes()` to force the outer join.
29
- def build_relation(*where_conditions)
30
- relation = @model_class.where(*where_conditions)
31
- relation = relation.includes(joins).references(joins) if joins.present?
32
- relation
24
+ def build_joins_relation(relation, *where_conditions)
25
+ case CanCan.accessible_by_strategy
26
+ when :subquery
27
+ inner = @model_class.unscoped do
28
+ @model_class.left_joins(joins).where(*where_conditions)
29
+ end
30
+ @model_class.where(@model_class.primary_key => inner)
31
+
32
+ when :left_join
33
+ relation.left_joins(joins).distinct
34
+ end
33
35
  end
34
36
 
35
- # Rails 4.2 deprecates `sanitize_sql_hash_for_conditions`
36
37
  def sanitize_sql(conditions)
37
38
  if conditions.is_a?(Hash)
38
39
  sanitize_sql_activerecord5(conditions)
@@ -46,23 +47,17 @@ module CanCan
46
47
  table_metadata = ActiveRecord::TableMetadata.new(@model_class, table)
47
48
  predicate_builder = ActiveRecord::PredicateBuilder.new(table_metadata)
48
49
 
49
- conditions = predicate_builder.resolve_column_aliases(conditions)
50
-
51
- conditions.stringify_keys!
52
-
53
- predicate_builder.build_from_hash(conditions).map do |b|
54
- visit_nodes(b)
55
- end.join(' AND ')
50
+ predicate_builder.build_from_hash(conditions.stringify_keys).map { |b| visit_nodes(b) }.join(' AND ')
56
51
  end
57
52
 
58
- def visit_nodes(b)
53
+ def visit_nodes(node)
59
54
  # Rails 5.2 adds a BindParam node that prevents the visitor method from properly compiling the SQL query
60
- if ActiveRecord::VERSION::MINOR >= 2
55
+ if self.class.version_greater_or_equal?('5.2.0')
61
56
  connection = @model_class.send(:connection)
62
57
  collector = Arel::Collectors::SubstituteBinds.new(connection, Arel::Collectors::SQLString.new)
63
- connection.visitor.accept(b, collector).value
58
+ connection.visitor.accept(node, collector).value
64
59
  else
65
- @model_class.send(:connection).visitor.compile(b)
60
+ @model_class.send(:connection).visitor.compile(node)
66
61
  end
67
62
  end
68
63
  end
@@ -1,10 +1,22 @@
1
- require_relative 'can_can/model_adapters/active_record_adapter/joins.rb'
2
- require_relative 'conditions_extractor.rb'
3
- require 'cancan/rules_compressor'
1
+ # frozen_string_literal: true
2
+
4
3
  module CanCan
5
4
  module ModelAdapters
6
- module ActiveRecordAdapter
7
- include CanCan::ModelAdapters::ActiveRecordAdapter::Joins
5
+ class ActiveRecordAdapter < AbstractAdapter
6
+ def self.version_greater_or_equal?(version)
7
+ Gem::Version.new(ActiveRecord.version).release >= Gem::Version.new(version)
8
+ end
9
+
10
+ def self.version_lower?(version)
11
+ Gem::Version.new(ActiveRecord.version).release < Gem::Version.new(version)
12
+ end
13
+
14
+ def initialize(model_class, rules)
15
+ super
16
+ @compressed_rules = RulesCompressor.new(@rules.reverse).rules_collapsed.reverse
17
+ StiNormalizer.normalize(@compressed_rules)
18
+ ConditionsNormalizer.normalize(model_class, @compressed_rules)
19
+ end
8
20
 
9
21
  # Returns conditions intended to be used inside a database query. Normally you will not call this
10
22
  # method directly, but instead go through ModelAdditions#accessible_by.
@@ -22,13 +34,12 @@ module CanCan
22
34
  # query(:manage, User).conditions # => "not (self_managed = 't') AND ((manager_id = 1) OR (id = 1))"
23
35
  #
24
36
  def conditions
25
- compressed_rules = RulesCompressor.new(@rules.reverse).rules_collapsed.reverse
26
37
  conditions_extractor = ConditionsExtractor.new(@model_class)
27
- if compressed_rules.size == 1 && compressed_rules.first.base_behavior
38
+ if @compressed_rules.size == 1 && @compressed_rules.first.base_behavior
28
39
  # Return the conditions directly if there's just one definition
29
- conditions_extractor.tableize_conditions(compressed_rules.first.conditions).dup
40
+ conditions_extractor.tableize_conditions(@compressed_rules.first.conditions).dup
30
41
  else
31
- extract_multiple_conditions(conditions_extractor, compressed_rules)
42
+ extract_multiple_conditions(conditions_extractor, @compressed_rules)
32
43
  end
33
44
  end
34
45
 
@@ -42,27 +53,58 @@ module CanCan
42
53
  if override_scope
43
54
  @model_class.where(nil).merge(override_scope)
44
55
  elsif @model_class.respond_to?(:where) && @model_class.respond_to?(:joins)
45
- mergeable_conditions? ? build_relation(conditions) : build_relation(*@rules.map(&:conditions))
56
+ build_relation(conditions)
46
57
  else
47
58
  @model_class.all(conditions: conditions, joins: joins)
48
59
  end
49
60
  end
50
61
 
62
+ def build_relation(*where_conditions)
63
+ relation = @model_class.where(*where_conditions)
64
+ return relation unless joins.present?
65
+
66
+ # subclasses must implement `build_joins_relation`
67
+ build_joins_relation(relation, *where_conditions)
68
+ end
69
+
70
+ # Returns the associations used in conditions for the :joins option of a search.
71
+ # See ModelAdditions#accessible_by
72
+ def joins
73
+ joins_hash = {}
74
+ @compressed_rules.reverse_each do |rule|
75
+ deep_merge(joins_hash, rule.associations_hash)
76
+ end
77
+ deep_clean(joins_hash) unless joins_hash.empty?
78
+ end
79
+
51
80
  private
52
81
 
53
- def mergeable_conditions?
54
- @rules.find(&:unmergeable?).blank?
82
+ # Removes empty hashes and moves everything into arrays.
83
+ def deep_clean(joins_hash)
84
+ joins_hash.map { |name, nested| nested.empty? ? name : { name => deep_clean(nested) } }
85
+ end
86
+
87
+ # Takes two hashes and does a deep merge.
88
+ def deep_merge(base_hash, added_hash)
89
+ added_hash.each do |key, value|
90
+ if base_hash[key].is_a?(Hash)
91
+ deep_merge(base_hash[key], value) unless value.empty?
92
+ else
93
+ base_hash[key] = value
94
+ end
95
+ end
55
96
  end
56
97
 
57
98
  def override_scope
58
- conditions = @rules.map(&:conditions).compact
99
+ conditions = @compressed_rules.map(&:conditions).compact
59
100
  return unless conditions.any? { |c| c.is_a?(ActiveRecord::Relation) }
60
101
  return conditions.first if conditions.size == 1
102
+
61
103
  raise_override_scope_error
62
104
  end
63
105
 
64
106
  def raise_override_scope_error
65
- rule_found = @rules.detect { |rule| rule.conditions.is_a?(ActiveRecord::Relation) }
107
+ rule_found = @compressed_rules.detect { |rule| rule.conditions.is_a?(ActiveRecord::Relation) }
66
108
  raise Error,
67
109
  'Unable to merge an Active Record scope with other conditions. '\
68
110
  "Instead use a hash or SQL for #{rule_found.actions.first} #{rule_found.subjects.first} ability."
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # this class is responsible of converting the hash of conditions
2
4
  # in "where conditions" to generate the sql query
3
5
  # it consists of a names_cache that helps calculating the next name given to the association
@@ -12,6 +14,7 @@ module CanCan
12
14
 
13
15
  def tableize_conditions(conditions, model_class = @root_model_class, path_to_key = 0)
14
16
  return conditions unless conditions.is_a? Hash
17
+
15
18
  conditions.each_with_object({}) do |(key, value), result_hash|
16
19
  if value.is_a? Hash
17
20
  result_hash.merge!(calculate_result_hash(key, model_class, path_to_key, result_hash, value))
@@ -26,9 +29,6 @@ module CanCan
26
29
 
27
30
  def calculate_result_hash(key, model_class, path_to_key, result_hash, value)
28
31
  reflection = model_class.reflect_on_association(key)
29
- unless reflection
30
- raise WrongAssociationName, "association #{key} not defined in model #{model_class.name}"
31
- end
32
32
  nested_resulted = calculate_nested(model_class, result_hash, key, value.dup, path_to_key)
33
33
  association_class = reflection.klass.name.constantize
34
34
  tableize_conditions(nested_resulted, association_class, "#{path_to_key}_#{key}")
@@ -0,0 +1,49 @@
1
+ # this class is responsible of normalizing the hash of conditions
2
+ # by exploding has_many through associations
3
+ # when a condition is defined with an has_many thorugh association this is exploded in all its parts
4
+ # TODO: it could identify STI and normalize it
5
+ module CanCan
6
+ module ModelAdapters
7
+ class ConditionsNormalizer
8
+ class << self
9
+ def normalize(model_class, rules)
10
+ rules.each { |rule| rule.conditions = normalize_conditions(model_class, rule.conditions) }
11
+ end
12
+
13
+ def normalize_conditions(model_class, conditions)
14
+ return conditions unless conditions.is_a? Hash
15
+
16
+ conditions.each_with_object({}) do |(key, value), result_hash|
17
+ if value.is_a? Hash
18
+ result_hash.merge!(calculate_result_hash(model_class, key, value))
19
+ else
20
+ result_hash[key] = value
21
+ end
22
+ result_hash
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def calculate_result_hash(model_class, key, value)
29
+ reflection = model_class.reflect_on_association(key)
30
+ unless reflection
31
+ raise WrongAssociationName, "Association '#{key}' not defined in model '#{model_class.name}'"
32
+ end
33
+
34
+ if normalizable_association? reflection
35
+ key = reflection.options[:through]
36
+ value = { reflection.source_reflection_name => value }
37
+ reflection = model_class.reflect_on_association(key)
38
+ end
39
+
40
+ { key => normalize_conditions(reflection.klass.name.constantize, value) }
41
+ end
42
+
43
+ def normalizable_association?(reflection)
44
+ reflection.options[:through].present? && !reflection.options[:source_type].present?
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CanCan
2
4
  module ModelAdapters
3
5
  class DefaultAdapter < AbstractAdapter
@@ -0,0 +1,39 @@
1
+ # this class is responsible for detecting sti classes and creating new rules for the
2
+ # relevant subclasses, using the inheritance_column as a merger
3
+ module CanCan
4
+ module ModelAdapters
5
+ class StiNormalizer
6
+ class << self
7
+ def normalize(rules)
8
+ rules_cache = []
9
+ return unless defined?(ActiveRecord::Base)
10
+
11
+ rules.delete_if do |rule|
12
+ subjects = rule.subjects.select do |subject|
13
+ update_rule(subject, rule, rules_cache)
14
+ end
15
+ subjects.length == rule.subjects.length
16
+ end
17
+ rules_cache.each { |rule| rules.push(rule) }
18
+ end
19
+
20
+ private
21
+
22
+ def update_rule(subject, rule, rules_cache)
23
+ return false unless subject.respond_to?(:descends_from_active_record?)
24
+ return false if subject == :all || subject.descends_from_active_record?
25
+ return false unless subject < ActiveRecord::Base
26
+
27
+ rules_cache.push(build_rule_for_subclass(rule, subject))
28
+ true
29
+ end
30
+
31
+ # create a new rule for the subclasses that links on the inheritance_column
32
+ def build_rule_for_subclass(rule, subject)
33
+ CanCan::Rule.new(rule.base_behavior, rule.actions, subject.superclass,
34
+ rule.conditions.merge(subject.inheritance_column => subject.name), rule.block)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CanCan
2
4
  # This module adds the accessible_by class method to a model. It is included in the model adapters.
3
5
  module ModelAdditions
@@ -18,8 +20,10 @@ module CanCan
18
20
  # @articles = Article.accessible_by(current_ability, :update)
19
21
  #
20
22
  # Here only the articles which the user can update are returned.
21
- def accessible_by(ability, action = :index)
22
- ability.model_adapter(self, action).database_records
23
+ def accessible_by(ability, action = :index, strategy: CanCan.accessible_by_strategy)
24
+ CanCan.with_accessible_by_strategy(strategy) do
25
+ ability.model_adapter(self, action).database_records
26
+ end
23
27
  end
24
28
  end
25
29
 
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CanCan
4
+ module ParameterValidators
5
+ def valid_attribute_param?(attribute)
6
+ attribute.nil? || attribute.is_a?(Symbol) || (attribute.is_a?(Array) && attribute.all? { |a| a.is_a?(Symbol) })
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CanCan
4
+ module Relevant
5
+ # Matches both the action, subject, and attribute, not necessarily the conditions
6
+ def relevant?(action, subject)
7
+ subject = subject.values.first if subject.class == Hash
8
+ @match_all || (matches_action?(action) && matches_subject?(subject))
9
+ end
10
+
11
+ private
12
+
13
+ def matches_action?(action)
14
+ @expanded_actions.include?(:manage) || @expanded_actions.include?(action)
15
+ end
16
+
17
+ def matches_subject?(subject)
18
+ @subjects.include?(:all) || @subjects.include?(subject) || matches_subject_class?(subject)
19
+ end
20
+
21
+ def matches_subject_class?(subject)
22
+ @subjects.any? do |sub|
23
+ sub.is_a?(Module) && (subject.is_a?(sub) ||
24
+ subject.class.to_s == sub.to_s ||
25
+ (subject.is_a?(Module) && subject.ancestors.include?(sub)))
26
+ end
27
+ end
28
+ end
29
+ end
data/lib/cancan/rule.rb CHANGED
@@ -1,29 +1,53 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'conditions_matcher.rb'
4
+ require_relative 'class_matcher.rb'
5
+ require_relative 'relevant.rb'
6
+
2
7
  module CanCan
3
8
  # This class is used internally and should only be called through Ability.
4
9
  # it holds the information about a "can" call made on Ability and provides
5
10
  # helpful methods to determine permission checking and conditions hash generation.
6
11
  class Rule # :nodoc:
7
12
  include ConditionsMatcher
8
- attr_reader :base_behavior, :subjects, :actions, :conditions
9
- attr_writer :expanded_actions
13
+ include Relevant
14
+ include ParameterValidators
15
+ attr_reader :base_behavior, :subjects, :actions, :conditions, :attributes, :block
16
+ attr_writer :expanded_actions, :conditions
10
17
 
11
18
  # The first argument when initializing is the base_behavior which is a true/false
12
19
  # value. True for "can" and false for "cannot". The next two arguments are the action
13
20
  # and subject respectively (such as :read, @project). The third argument is a hash
14
21
  # of conditions and the last one is the block passed to the "can" call.
15
- def initialize(base_behavior, action, subject, conditions, block)
16
- both_block_and_hash_error = 'You are not able to supply a block with a hash of conditions in '\
17
- "#{action} #{subject} ability. Use either one."
18
- raise Error, both_block_and_hash_error if conditions.is_a?(Hash) && block
22
+ def initialize(base_behavior, action, subject, *extra_args, &block)
23
+ # for backwards compatibility, attributes are an optional parameter. Check if
24
+ # attributes were passed or are actually conditions
25
+ attributes, extra_args = parse_attributes_from_extra_args(extra_args)
26
+ condition_and_block_check(extra_args, block, action, subject)
19
27
  @match_all = action.nil? && subject.nil?
28
+ raise Error, "Subject is required for #{action}" if action && subject.nil?
29
+
20
30
  @base_behavior = base_behavior
21
- @actions = Array(action)
22
- @subjects = Array(subject)
23
- @conditions = conditions || {}
31
+ @actions = wrap(action)
32
+ @subjects = wrap(subject)
33
+ @attributes = wrap(attributes)
34
+ @conditions = extra_args || {}
24
35
  @block = block
25
36
  end
26
37
 
38
+ def inspect
39
+ repr = "#<#{self.class.name}"
40
+ repr += "#{@base_behavior ? 'can' : 'cannot'} #{@actions.inspect}, #{@subjects.inspect}, #{@attributes.inspect}"
41
+
42
+ if with_scope?
43
+ repr += ", #{@conditions.where_values_hash}"
44
+ elsif [Hash, String].include?(@conditions.class)
45
+ repr += ", #{@conditions.inspect}"
46
+ end
47
+
48
+ repr + '>'
49
+ end
50
+
27
51
  def can_rule?
28
52
  base_behavior
29
53
  end
@@ -33,13 +57,8 @@ module CanCan
33
57
  end
34
58
 
35
59
  def catch_all?
36
- [nil, false, [], {}, '', ' '].include? @conditions
37
- end
38
-
39
- # Matches both the subject and action, not necessarily the conditions
40
- def relevant?(action, subject)
41
- subject = subject.values.first if subject.class == Hash
42
- @match_all || (matches_action?(action) && matches_subject?(subject))
60
+ (with_scope? && @conditions.where_values_hash.empty?) ||
61
+ (!with_scope? && [nil, false, [], {}, '', ' '].include?(@conditions))
43
62
  end
44
63
 
45
64
  def only_block?
@@ -50,9 +69,8 @@ module CanCan
50
69
  @block.nil? && !conditions_empty? && !@conditions.is_a?(Hash)
51
70
  end
52
71
 
53
- def unmergeable?
54
- @conditions.respond_to?(:keys) && @conditions.present? &&
55
- (!@conditions.keys.first.is_a? Symbol)
72
+ def with_scope?
73
+ @conditions.is_a?(ActiveRecord::Relation)
56
74
  end
57
75
 
58
76
  def associations_hash(conditions = @conditions)
@@ -75,6 +93,13 @@ module CanCan
75
93
  attributes
76
94
  end
77
95
 
96
+ def matches_attributes?(attribute)
97
+ return true if @attributes.empty?
98
+ return @base_behavior if attribute.nil?
99
+
100
+ @attributes.include?(attribute.to_sym)
101
+ end
102
+
78
103
  private
79
104
 
80
105
  def matches_action?(action)
@@ -86,10 +111,29 @@ module CanCan
86
111
  end
87
112
 
88
113
  def matches_subject_class?(subject)
89
- @subjects.any? do |sub|
90
- sub.is_a?(Module) && (subject.is_a?(sub) ||
91
- subject.class.to_s == sub.to_s ||
92
- (subject.is_a?(Module) && subject.ancestors.include?(sub)))
114
+ SubjectClassMatcher.matches_subject_class?(@subjects, subject)
115
+ end
116
+
117
+ def parse_attributes_from_extra_args(args)
118
+ attributes = args.shift if valid_attribute_param?(args.first)
119
+ extra_args = args.shift
120
+ [attributes, extra_args]
121
+ end
122
+
123
+ def condition_and_block_check(conditions, block, action, subject)
124
+ return unless conditions.is_a?(Hash) && block
125
+
126
+ raise BlockAndConditionsError, 'A hash of conditions is mutually exclusive with a block. '\
127
+ "Check \":#{action} #{subject}\" ability."
128
+ end
129
+
130
+ def wrap(object)
131
+ if object.nil?
132
+ []
133
+ elsif object.respond_to?(:to_ary)
134
+ object.to_ary || [object]
135
+ else
136
+ [object]
93
137
  end
94
138
  end
95
139
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'conditions_matcher.rb'
2
4
  module CanCan
3
5
  class RulesCompressor
@@ -11,6 +13,7 @@ module CanCan
11
13
  def compress(array)
12
14
  idx = array.rindex(&:catch_all?)
13
15
  return array unless idx
16
+
14
17
  value = array[idx]
15
18
  array[idx..-1]
16
19
  .drop_while { |n| n.base_behavior == value.base_behavior }