cancancan 2.3.0 → 3.3.0

Sign up to get free protection for your applications and to get access to all the features.
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 }