cancancan 2.3.0 → 3.2.2
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.
- checksums.yaml +4 -4
- data/cancancan.gemspec +6 -5
- data/init.rb +2 -0
- data/lib/cancan.rb +6 -0
- data/lib/cancan/ability.rb +54 -24
- data/lib/cancan/ability/actions.rb +2 -0
- data/lib/cancan/ability/rules.rb +14 -6
- data/lib/cancan/ability/strong_parameter_support.rb +41 -0
- data/lib/cancan/class_matcher.rb +26 -0
- data/lib/cancan/conditions_matcher.rb +25 -12
- data/lib/cancan/config.rb +54 -0
- data/lib/cancan/controller_additions.rb +4 -1
- data/lib/cancan/controller_resource.rb +6 -0
- data/lib/cancan/controller_resource_builder.rb +2 -0
- data/lib/cancan/controller_resource_finder.rb +2 -0
- data/lib/cancan/controller_resource_loader.rb +4 -0
- data/lib/cancan/controller_resource_name_finder.rb +2 -0
- data/lib/cancan/controller_resource_sanitizer.rb +2 -0
- data/lib/cancan/exceptions.rb +18 -2
- data/lib/cancan/matchers.rb +3 -0
- data/lib/cancan/model_adapters/abstract_adapter.rb +3 -1
- data/lib/cancan/model_adapters/active_record_4_adapter.rb +26 -25
- data/lib/cancan/model_adapters/active_record_5_adapter.rb +21 -26
- data/lib/cancan/model_adapters/active_record_adapter.rb +56 -14
- data/lib/cancan/model_adapters/conditions_extractor.rb +3 -3
- data/lib/cancan/model_adapters/conditions_normalizer.rb +49 -0
- data/lib/cancan/model_adapters/default_adapter.rb +2 -0
- data/lib/cancan/model_adapters/sti_normalizer.rb +39 -0
- data/lib/cancan/model_additions.rb +2 -0
- data/lib/cancan/parameter_validators.rb +9 -0
- data/lib/cancan/relevant.rb +29 -0
- data/lib/cancan/rule.rb +67 -23
- data/lib/cancan/rules_compressor.rb +3 -0
- data/lib/cancan/unauthorized_message_resolver.rb +24 -0
- data/lib/cancan/version.rb +3 -1
- data/lib/cancancan.rb +2 -0
- data/lib/generators/cancan/ability/ability_generator.rb +3 -1
- data/lib/generators/cancan/ability/templates/ability.rb +2 -0
- metadata +37 -30
- data/lib/cancan/model_adapters/can_can/model_adapters/active_record_adapter/joins.rb +0 -39
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CanCan
|
4
|
+
def self.valid_accessible_by_strategies
|
5
|
+
strategies = [:left_join]
|
6
|
+
strategies << :subquery unless does_not_support_subquery_strategy?
|
7
|
+
strategies
|
8
|
+
end
|
9
|
+
|
10
|
+
# Determines how CanCan should build queries when calling accessible_by,
|
11
|
+
# if the query will contain a join. The default strategy is `:subquery`.
|
12
|
+
#
|
13
|
+
# # config/initializers/cancan.rb
|
14
|
+
# CanCan.accessible_by_strategy = :subquery
|
15
|
+
#
|
16
|
+
# Valid strategies are:
|
17
|
+
# - :subquery - Creates a nested query with all joins, wrapped by a
|
18
|
+
# WHERE IN query.
|
19
|
+
# - :left_join - Calls the joins directly using `left_joins`, and
|
20
|
+
# ensures records are unique using `distinct`. Note that
|
21
|
+
# `distinct` is not reliable in some cases. See
|
22
|
+
# https://github.com/CanCanCommunity/cancancan/pull/605
|
23
|
+
def self.accessible_by_strategy
|
24
|
+
@accessible_by_strategy || default_accessible_by_strategy
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.default_accessible_by_strategy
|
28
|
+
if does_not_support_subquery_strategy?
|
29
|
+
# see https://github.com/CanCanCommunity/cancancan/pull/655 for where this was added
|
30
|
+
# the `subquery` strategy (from https://github.com/CanCanCommunity/cancancan/pull/619
|
31
|
+
# only works in Rails 5 and higher
|
32
|
+
:left_join
|
33
|
+
else
|
34
|
+
:subquery
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.accessible_by_strategy=(value)
|
39
|
+
unless valid_accessible_by_strategies.include?(value)
|
40
|
+
raise ArgumentError, "accessible_by_strategy must be one of #{valid_accessible_by_strategies.join(', ')}"
|
41
|
+
end
|
42
|
+
|
43
|
+
if value == :subquery && does_not_support_subquery_strategy?
|
44
|
+
raise ArgumentError, 'accessible_by_strategy = :subquery requires ActiveRecord 5 or newer'
|
45
|
+
end
|
46
|
+
|
47
|
+
@accessible_by_strategy = value
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.does_not_support_subquery_strategy?
|
51
|
+
!defined?(CanCan::ModelAdapters::ActiveRecordAdapter) ||
|
52
|
+
CanCan::ModelAdapters::ActiveRecordAdapter.version_lower?('5.0.0')
|
53
|
+
end
|
54
|
+
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module CanCan
|
2
4
|
# This module is automatically included into all controllers.
|
3
5
|
# It also makes the "can?" and "cannot?" methods available to all views.
|
@@ -225,7 +227,7 @@ module CanCan
|
|
225
227
|
cancan_skipper[:authorize][name] = options
|
226
228
|
end
|
227
229
|
|
228
|
-
# Add this to a controller to ensure it performs authorization through +
|
230
|
+
# Add this to a controller to ensure it performs authorization through +authorize+! or +authorize_resource+ call.
|
229
231
|
# If neither of these authorization methods are called,
|
230
232
|
# a CanCan::AuthorizationNotPerformed exception will be raised.
|
231
233
|
# This is normally added to the ApplicationController to ensure all controller actions do authorization.
|
@@ -260,6 +262,7 @@ module CanCan
|
|
260
262
|
next if controller.instance_variable_defined?(:@_authorized)
|
261
263
|
next if options[:if] && !controller.send(options[:if])
|
262
264
|
next if options[:unless] && controller.send(options[:unless])
|
265
|
+
|
263
266
|
raise AuthorizationNotPerformed,
|
264
267
|
'This action failed the check_authorization because it does not authorize_resource. '\
|
265
268
|
'Add skip_authorization_check to bypass this check.'
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require_relative 'controller_resource_loader.rb'
|
2
4
|
module CanCan
|
3
5
|
# Handle the load and authorization controller logic
|
@@ -34,6 +36,7 @@ module CanCan
|
|
34
36
|
|
35
37
|
def authorize_resource
|
36
38
|
return if skip?(:authorize)
|
39
|
+
|
37
40
|
@controller.authorize!(authorization_action, resource_instance || resource_class_with_parent)
|
38
41
|
end
|
39
42
|
|
@@ -43,6 +46,7 @@ module CanCan
|
|
43
46
|
|
44
47
|
def skip?(behavior)
|
45
48
|
return false unless (options = @controller.class.cancan_skipper[behavior][@name])
|
49
|
+
|
46
50
|
options == {} ||
|
47
51
|
options[:except] && !action_exists_in?(options[:except]) ||
|
48
52
|
action_exists_in?(options[:only])
|
@@ -90,6 +94,7 @@ module CanCan
|
|
90
94
|
|
91
95
|
def resource_instance
|
92
96
|
return unless load_instance? && @controller.instance_variable_defined?("@#{instance_name}")
|
97
|
+
|
93
98
|
@controller.instance_variable_get("@#{instance_name}")
|
94
99
|
end
|
95
100
|
|
@@ -99,6 +104,7 @@ module CanCan
|
|
99
104
|
|
100
105
|
def collection_instance
|
101
106
|
return unless @controller.instance_variable_defined?("@#{instance_name.to_s.pluralize}")
|
107
|
+
|
102
108
|
@controller.instance_variable_get("@#{instance_name.to_s.pluralize}")
|
103
109
|
end
|
104
110
|
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require_relative 'controller_resource_finder.rb'
|
2
4
|
require_relative 'controller_resource_name_finder.rb'
|
3
5
|
require_relative 'controller_resource_builder.rb'
|
@@ -11,6 +13,7 @@ module CanCan
|
|
11
13
|
|
12
14
|
def load_resource
|
13
15
|
return if skip?(:load)
|
16
|
+
|
14
17
|
if load_instance?
|
15
18
|
self.resource_instance ||= load_resource_instance
|
16
19
|
elsif load_collection?
|
@@ -26,6 +29,7 @@ module CanCan
|
|
26
29
|
|
27
30
|
def resource_params_by_key(key)
|
28
31
|
return unless @options[key] && @params.key?(extract_key(@options[key]))
|
32
|
+
|
29
33
|
@params[extract_key(@options[key])]
|
30
34
|
end
|
31
35
|
|
data/lib/cancan/exceptions.rb
CHANGED
@@ -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
|
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#
|
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
|
data/lib/cancan/matchers.rb
CHANGED
@@ -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,27 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module CanCan
|
2
4
|
module ModelAdapters
|
3
|
-
class ActiveRecord4Adapter <
|
4
|
-
|
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
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
8
|
+
class << self
|
9
|
+
def for_class?(model_class)
|
10
|
+
version_lower?('5.0.0') && model_class <= ActiveRecord::Base
|
11
|
+
end
|
13
12
|
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
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
|
35
|
-
relation
|
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
|
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
|
-
|
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
|
-
|
17
|
-
|
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
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
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
|
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(
|
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
|
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(
|
58
|
+
connection.visitor.accept(node, collector).value
|
64
59
|
else
|
65
|
-
@model_class.send(:connection).visitor.compile(
|
60
|
+
@model_class.send(:connection).visitor.compile(node)
|
66
61
|
end
|
67
62
|
end
|
68
63
|
end
|
@@ -1,10 +1,22 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
require 'cancan/rules_compressor'
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
4
3
|
module CanCan
|
5
4
|
module ModelAdapters
|
6
|
-
|
7
|
-
|
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
|
-
|
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
|
-
|
54
|
-
|
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 = @
|
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 = @
|
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."
|