action_policy 0.2.4 → 0.3.0.beta1

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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +26 -64
  3. data/.travis.yml +13 -10
  4. data/CHANGELOG.md +216 -1
  5. data/Gemfile +7 -0
  6. data/LICENSE.txt +1 -1
  7. data/Rakefile +10 -0
  8. data/action_policy.gemspec +5 -3
  9. data/benchmarks/namespaced_lookup_cache.rb +18 -22
  10. data/docs/README.md +3 -3
  11. data/docs/_sidebar.md +4 -0
  12. data/docs/aliases.md +9 -5
  13. data/docs/authorization_context.md +59 -1
  14. data/docs/behaviour.md +113 -0
  15. data/docs/caching.md +6 -4
  16. data/docs/custom_policy.md +1 -2
  17. data/docs/debugging.md +55 -0
  18. data/docs/decorators.md +27 -0
  19. data/docs/i18n.md +41 -2
  20. data/docs/instrumentation.md +70 -2
  21. data/docs/lookup_chain.md +5 -4
  22. data/docs/namespaces.md +1 -1
  23. data/docs/non_rails.md +2 -3
  24. data/docs/pundit_migration.md +77 -2
  25. data/docs/quick_start.md +5 -5
  26. data/docs/rails.md +5 -2
  27. data/docs/reasons.md +50 -3
  28. data/docs/scoping.md +262 -0
  29. data/docs/testing.md +232 -21
  30. data/docs/writing_policies.md +1 -1
  31. data/gemfiles/jruby.gemfile +3 -0
  32. data/gemfiles/rails42.gemfile +3 -0
  33. data/gemfiles/rails6.gemfile +8 -0
  34. data/gemfiles/railsmaster.gemfile +1 -1
  35. data/lib/action_policy.rb +3 -3
  36. data/lib/action_policy/authorizer.rb +12 -4
  37. data/lib/action_policy/base.rb +2 -0
  38. data/lib/action_policy/behaviour.rb +14 -3
  39. data/lib/action_policy/behaviours/memoized.rb +1 -1
  40. data/lib/action_policy/behaviours/policy_for.rb +12 -3
  41. data/lib/action_policy/behaviours/scoping.rb +32 -0
  42. data/lib/action_policy/behaviours/thread_memoized.rb +1 -1
  43. data/lib/action_policy/ext/hash_transform_keys.rb +19 -0
  44. data/lib/action_policy/ext/module_namespace.rb +1 -1
  45. data/lib/action_policy/ext/policy_cache_key.rb +2 -1
  46. data/lib/action_policy/ext/proc_case_eq.rb +14 -0
  47. data/lib/action_policy/ext/string_constantize.rb +1 -0
  48. data/lib/action_policy/ext/symbol_classify.rb +22 -0
  49. data/lib/action_policy/i18n.rb +56 -0
  50. data/lib/action_policy/lookup_chain.rb +21 -3
  51. data/lib/action_policy/policy/cache.rb +10 -6
  52. data/lib/action_policy/policy/core.rb +31 -19
  53. data/lib/action_policy/policy/execution_result.rb +12 -0
  54. data/lib/action_policy/policy/pre_check.rb +2 -6
  55. data/lib/action_policy/policy/reasons.rb +99 -12
  56. data/lib/action_policy/policy/scoping.rb +165 -0
  57. data/lib/action_policy/rails/authorizer.rb +20 -0
  58. data/lib/action_policy/rails/controller.rb +4 -14
  59. data/lib/action_policy/rails/ext/active_record.rb +10 -0
  60. data/lib/action_policy/rails/policy/instrumentation.rb +24 -0
  61. data/lib/action_policy/rails/scope_matchers/action_controller_params.rb +19 -0
  62. data/lib/action_policy/rails/scope_matchers/active_record.rb +29 -0
  63. data/lib/action_policy/railtie.rb +29 -7
  64. data/lib/action_policy/rspec.rb +1 -0
  65. data/lib/action_policy/rspec/be_authorized_to.rb +1 -1
  66. data/lib/action_policy/rspec/dsl.rb +103 -0
  67. data/lib/action_policy/rspec/have_authorized_scope.rb +126 -0
  68. data/lib/action_policy/rspec/pundit_syntax.rb +1 -1
  69. data/lib/action_policy/test_helper.rb +69 -4
  70. data/lib/action_policy/testing.rb +54 -0
  71. data/lib/action_policy/utils/pretty_print.rb +137 -0
  72. data/lib/action_policy/utils/suggest_message.rb +21 -0
  73. data/lib/action_policy/version.rb +1 -1
  74. metadata +58 -11
@@ -5,10 +5,10 @@ module ActionPolicy
5
5
  # Adds `policy_for` method
6
6
  module PolicyFor
7
7
  # Returns policy instance for the record.
8
- def policy_for(record:, with: nil, namespace: nil)
8
+ def policy_for(record:, with: nil, namespace: nil, context: nil, **options)
9
9
  namespace ||= authorization_namespace
10
- policy_class = with || ::ActionPolicy.lookup(record, namespace: namespace)
11
- policy_class.new(record, authorization_context)
10
+ policy_class = with || ::ActionPolicy.lookup(record, namespace: namespace, **options)
11
+ policy_class&.new(record, authorization_context.tap { |ctx| ctx.merge!(context) if context })
12
12
  end
13
13
 
14
14
  def authorization_context
@@ -18,6 +18,15 @@ module ActionPolicy
18
18
  def authorization_namespace
19
19
  # override to provide specific authorization namespace
20
20
  end
21
+
22
+ # Override this method to provide implicit authorization target
23
+ # that would be used in case `record` is not specified in
24
+ # `authorize!` and `allowed_to?` call.
25
+ #
26
+ # It is also used to infer a policy for scoping (in `authorized_scope` method).
27
+ def implicit_authorization_target
28
+ # no-op
29
+ end
21
30
  end
22
31
  end
23
32
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPolicy
4
+ module Behaviours
5
+ # Adds `authorized_scop` method to behaviour
6
+ module Scoping
7
+ # Apply scope to the target of the specified type.
8
+ #
9
+ # NOTE: policy lookup consists of the following steps:
10
+ # - first, check whether `with` option is present
11
+ # - secondly, try to infer policy class from `target` (non-raising lookup)
12
+ # - use `implicit_authorization_target` if none of the above works.
13
+ def authorized_scope(target, type: nil, as: :default, scope_options: nil, **options)
14
+ policy = policy_for(record: target, allow_nil: true, **options)
15
+ policy ||= policy_for(record: implicit_authorization_target, **options)
16
+
17
+ type ||= authorization_scope_type_for(policy, target)
18
+
19
+ Authorizer.scopify(target, policy, type: type, name: as, scope_options: scope_options)
20
+ end
21
+
22
+ # For backward compatibility
23
+ alias authorized authorized_scope
24
+
25
+ # Infer scope type for target if none provided.
26
+ # Raises an exception if type couldn't be inferred.
27
+ def authorization_scope_type_for(policy, target)
28
+ policy.resolve_scope_type(target)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -54,7 +54,7 @@ module ActionPolicy
54
54
  end
55
55
  end
56
56
 
57
- def __policy_thread_memoize__(record, with: nil, namespace: nil)
57
+ def __policy_thread_memoize__(record, with: nil, namespace: nil, **_opts)
58
58
  record_key = record._policy_cache_key(use_object_id: true)
59
59
  cache_key = "#{namespace}/#{with}/#{record_key}"
60
60
 
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPolicy
4
+ module Ext
5
+ # Add transform_keys to Hash for older Rubys
6
+ module HashTransformKeys
7
+ refine Hash do
8
+ def transform_keys
9
+ return enum_for(:transform_keys) { size } unless block_given?
10
+ result = {}
11
+ each_key do |key|
12
+ result[yield(key)] = self[key]
13
+ end
14
+ result
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -16,7 +16,7 @@ module ActionPolicy
16
16
 
17
17
  module Ext
18
18
  def namespace
19
- return unless name.match?(/[^^]::/)
19
+ return unless name&.match?(/[^^]::/)
20
20
 
21
21
  name.sub(/::[^:]+$/, "").safe_constantize
22
22
  end
@@ -23,6 +23,7 @@ module ActionPolicy
23
23
 
24
24
  # JRuby doesn't support _global_ modules refinements (see https://github.com/jruby/jruby/issues/5220)
25
25
  # Fallback to monkey-patching.
26
+ # TODO: remove after 9.2.7.0 (See https://github.com/jruby/jruby/pull/5627)
26
27
  ::Object.include(ObjectExt) if RUBY_PLATFORM =~ /java/i
27
28
 
28
29
  refine Object do
@@ -59,7 +60,7 @@ module ActionPolicy
59
60
  end
60
61
  end
61
62
 
62
- if RUBY_PLATFORM =~ /java/i
63
+ if RUBY_PLATFORM.match?(/java/i)
63
64
  refine Integer do
64
65
  def _policy_cache_key(*)
65
66
  to_s
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPolicy
4
+ module Ext
5
+ # Add #=== to Proc
6
+ module ProcCaseEq
7
+ refine Proc do
8
+ def ===(other)
9
+ call(other) == true
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -14,6 +14,7 @@ module ActionPolicy
14
14
  names.shift if names.size > 1 && names.first.empty?
15
15
 
16
16
  names.inject(Object) do |constant, name|
17
+ break if constant.nil?
17
18
  constant.const_get(name, false) if constant.const_defined?(name, false)
18
19
  end
19
20
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPolicy
4
+ module Ext
5
+ # Add `classify` to Symbol
6
+ module SymbolClassify
7
+ refine Symbol do
8
+ if "".respond_to?(:classify)
9
+ def classify
10
+ to_s.classify
11
+ end
12
+ else
13
+ def classify
14
+ word = to_s.capitalize
15
+ word.gsub!(/(?:_)([a-z\d]*)/) { $1.capitalize }
16
+ word
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPolicy
4
+ module I18n # :nodoc:
5
+ DEFAULT_UNAUTHORIZED_MESSAGE = "You are not authorized to perform this action"
6
+
7
+ class << self
8
+ def full_message(policy_class, rule, details = nil)
9
+ candidates = candidates_for(policy_class, rule)
10
+
11
+ options = {scope: :action_policy}
12
+ options.merge!(details) unless details.nil?
13
+
14
+ ::I18n.t(
15
+ candidates.shift,
16
+ default: candidates,
17
+ **options
18
+ )
19
+ end
20
+
21
+ private
22
+
23
+ def candidates_for(policy_class, rule)
24
+ policy_hierarchy = policy_class.ancestors.select { |klass| klass.respond_to?(:identifier) }
25
+ [
26
+ *policy_hierarchy.map { |klass| :"policy.#{klass.identifier}.#{rule}" },
27
+ :"policy.#{rule}",
28
+ :unauthorized,
29
+ DEFAULT_UNAUTHORIZED_MESSAGE
30
+ ]
31
+ end
32
+ end
33
+
34
+ ActionPolicy::Policy::FailureReasons.prepend(Module.new do
35
+ def full_messages
36
+ reasons.flat_map do |policy_klass, rules|
37
+ rules.flat_map do |rule|
38
+ if rule.is_a?(::Hash)
39
+ rule.map do |key, details|
40
+ ActionPolicy::I18n.full_message(policy_klass, key, details)
41
+ end
42
+ else
43
+ ActionPolicy::I18n.full_message(policy_klass, rule)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end)
49
+
50
+ ActionPolicy::Policy::ExecutionResult.prepend(Module.new do
51
+ def message
52
+ ActionPolicy::I18n.full_message(policy, rule, details)
53
+ end
54
+ end)
55
+ end
56
+ end
@@ -12,6 +12,9 @@ module ActionPolicy
12
12
  using ActionPolicy::Ext::StringConstantize
13
13
  end
14
14
 
15
+ require "action_policy/ext/symbol_classify"
16
+ using ActionPolicy::Ext::SymbolClassify
17
+
15
18
  require "action_policy/ext/module_namespace"
16
19
  using ActionPolicy::Ext::ModuleNamespace
17
20
 
@@ -50,8 +53,7 @@ module ActionPolicy
50
53
 
51
54
  private
52
55
 
53
- def lookup_within_namespace(record, namespace)
54
- policy_name = policy_class_name_for(record)
56
+ def lookup_within_namespace(policy_name, namespace)
55
57
  NamespaceCache.fetch(namespace.name, policy_name) do
56
58
  mod = namespace
57
59
 
@@ -68,6 +70,8 @@ module ActionPolicy
68
70
  end
69
71
 
70
72
  def policy_class_name_for(record)
73
+ return record.policy_name.to_s if record.respond_to?(:policy_name)
74
+
71
75
  record_class = record.is_a?(Module) ? record : record.class
72
76
 
73
77
  if record_class.respond_to?(:policy_name)
@@ -97,7 +101,8 @@ module ActionPolicy
97
101
  NAMESPACE_LOOKUP = ->(record, namespace: nil, **) {
98
102
  next if namespace.nil?
99
103
 
100
- lookup_within_namespace(record, namespace)
104
+ policy_name = policy_class_name_for(record)
105
+ lookup_within_namespace(policy_name, namespace)
101
106
  }
102
107
 
103
108
  # Infer from class name
@@ -105,7 +110,20 @@ module ActionPolicy
105
110
  policy_class_name_for(record).safe_constantize
106
111
  }
107
112
 
113
+ # Infer from passed symbol
114
+ SYMBOL_LOOKUP = ->(record, namespace: nil, **) {
115
+ next unless record.is_a?(Symbol)
116
+
117
+ policy_name = "#{record.classify}Policy"
118
+ if namespace.nil?
119
+ policy_name.safe_constantize
120
+ else
121
+ lookup_within_namespace(policy_name, namespace)
122
+ end
123
+ }
124
+
108
125
  self.chain = [
126
+ SYMBOL_LOOKUP,
109
127
  INSTANCE_POLICY_CLASS,
110
128
  CLASS_POLICY_CLASS,
111
129
  NAMESPACE_LOOKUP,
@@ -4,12 +4,15 @@ require "action_policy/version"
4
4
 
5
5
  module ActionPolicy # :nodoc:
6
6
  # By default cache namespace (or prefix) contains major and minor version of the gem
7
- CACHE_NAMESPACE = "acp:#{ActionPolicy::VERSION.split('.').take(2).join('.')}"
7
+ CACHE_NAMESPACE = "acp:#{ActionPolicy::VERSION.split(".").take(2).join(".")}"
8
8
 
9
9
  require "action_policy/ext/yield_self_then"
10
10
  require "action_policy/ext/policy_cache_key"
11
11
 
12
- using ActionPolicy::Ext::YieldSelfThen
12
+ unless "".respond_to?(:then)
13
+ require "action_policy/ext/yield_self_then"
14
+ using ActionPolicy::Ext::YieldSelfThen
15
+ end
13
16
  using ActionPolicy::Ext::PolicyCacheKey
14
17
 
15
18
  module Policy
@@ -36,24 +39,25 @@ module ActionPolicy # :nodoc:
36
39
  authorization_context.map { |_k, v| v._policy_cache_key.to_s }.join("/")
37
40
  end
38
41
 
39
- # rubocop: disable Metrics/AbcSize
40
42
  def apply_with_cache(rule)
41
43
  options = self.class.cached_rules.fetch(rule)
42
44
  key = cache_key(rule)
43
45
 
44
46
  ActionPolicy.cache_store.then do |store|
45
47
  @result = store.read(key)
46
- next result.value unless result.nil?
48
+ unless result.nil?
49
+ result.cached!
50
+ next result.value
51
+ end
47
52
  yield
48
53
  store.write(key, result, options)
49
54
  result.value
50
55
  end
51
56
  end
52
- # rubocop: enable Metrics/AbcSize
53
57
 
54
58
  def apply(rule)
55
59
  return super if ActionPolicy.cache_store.nil? ||
56
- !self.class.cached_rules.key?(rule)
60
+ !self.class.cached_rules.key?(rule)
57
61
 
58
62
  apply_with_cache(rule) { super }
59
63
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "action_policy/behaviours/policy_for"
4
4
  require "action_policy/policy/execution_result"
5
+ require "action_policy/utils/suggest_message"
6
+ require "action_policy/utils/pretty_print"
5
7
 
6
8
  unless "".respond_to?(:underscore)
7
9
  require "action_policy/ext/string_underscore"
@@ -12,26 +14,16 @@ module ActionPolicy
12
14
  # Raised when `resolve_rule` failed to find an approriate
13
15
  # policy rule method for the activity
14
16
  class UnknownRule < Error
17
+ include ActionPolicy::SuggestMessage
18
+
15
19
  attr_reader :policy, :rule, :message
16
20
 
17
21
  def initialize(policy, rule)
18
22
  @policy = policy.class
19
23
  @rule = rule
20
- @message = "Couldn't find rule '#{@rule}' for #{@policy}#{suggest(policy, rule)}"
21
- end
22
-
23
- if defined?(::DidYouMean::SpellChecker)
24
- def suggest(policy, error)
25
- suggestion = ::DidYouMean::SpellChecker.new(
26
- dictionary: policy.public_methods
27
- ).correct(error).first
28
-
29
- suggestion ? "\nDid you mean? #{suggestion}" : ""
30
- end
31
- else
32
- def suggest(*)
33
- ""
34
- end
24
+ @message =
25
+ "Couldn't find rule '#{@rule}' for #{@policy}" \
26
+ "#{suggest(@rule, @policy.instance_methods - Object.instance_methods)}"
35
27
  end
36
28
  end
37
29
 
@@ -73,7 +65,7 @@ module ActionPolicy
73
65
 
74
66
  attr_reader :record, :result
75
67
 
76
- def initialize(record = nil)
68
+ def initialize(record = nil, **_opts)
77
69
  @record = record
78
70
  end
79
71
 
@@ -94,10 +86,10 @@ module ActionPolicy
94
86
 
95
87
  # Wrap code that could modify result
96
88
  # to prevent the current result modification
97
- def with_clean_result
89
+ def with_clean_result # :nodoc:
98
90
  was_result = @result
99
- res = yield
100
- @result = was_result
91
+ yield
92
+ res, @result = @result, was_result
101
93
  res
102
94
  end
103
95
 
@@ -129,6 +121,26 @@ module ActionPolicy
129
121
  respond_to?(activity)
130
122
  activity
131
123
  end
124
+
125
+ # Return annotated source code for the rule
126
+ # NOTE: require "method_source" and "unparser" gems to be installed.
127
+ # Otherwise returns empty string.
128
+ def inspect_rule(rule)
129
+ PrettyPrint.print_method(self, rule)
130
+ end
131
+
132
+ # Helper for printing the annotated rule source.
133
+ # Useful for debugging: type `pp :show?` within the context of the policy
134
+ # to preview the rule.
135
+ def pp(rule)
136
+ with_clean_result do
137
+ # We need result to exist for `allowed_to?` to work correctly
138
+ @result = self.class.result_class.new(self.class, rule)
139
+ header = "#{self.class.name}##{rule}"
140
+ source = inspect_rule(rule)
141
+ $stdout.puts "#{header}\n#{source}"
142
+ end
143
+ end
132
144
  end
133
145
  end
134
146
  end
@@ -26,6 +26,18 @@ module ActionPolicy
26
26
  def fail?
27
27
  @value == false
28
28
  end
29
+
30
+ def cached!
31
+ @cached = true
32
+ end
33
+
34
+ def cached?
35
+ @cached == true
36
+ end
37
+
38
+ def inspect
39
+ "<#{policy}##{rule}: #{@value}>"
40
+ end
29
41
  end
30
42
  end
31
43
  end
@@ -88,8 +88,6 @@ module ActionPolicy
88
88
  end
89
89
  end
90
90
 
91
- # rubocop: disable Metrics/AbcSize, Metrics/CyclomaticComplexity
92
- # rubocop: disable Metrics/PerceivedComplexity, Metrics/MethodLength
93
91
  def skip!(except: nil, only: nil)
94
92
  if !except.nil? && !only.nil?
95
93
  raise ArgumentError,
@@ -114,8 +112,8 @@ module ActionPolicy
114
112
 
115
113
  rebuild_filter
116
114
  end
117
- # rubocop: enable Metrics/AbcSize, Metrics/CyclomaticComplexity
118
- # rubocop: enable Metrics/PerceivedComplexity, Metrics/MethodLength
115
+ # rubocop: enable
116
+ # rubocop: enable
119
117
 
120
118
  def dup
121
119
  self.class.new(policy_class, name, except: blacklist&.dup, only: whitelist&.dup)
@@ -174,7 +172,6 @@ module ActionPolicy
174
172
  end
175
173
  end
176
174
 
177
- # rubocop: disable Metrics/AbcSize
178
175
  def skip_pre_check(*names, **options)
179
176
  names.each do |name|
180
177
  check = pre_checks.find { |c| c.name == name }
@@ -187,7 +184,6 @@ module ActionPolicy
187
184
  pre_checks[pre_checks.index(check)] = check.dup.tap { |c| c.skip! options }
188
185
  end
189
186
  end
190
- # rubocop: enable Metrics/AbcSize
191
187
 
192
188
  def pre_checks
193
189
  return @pre_checks if instance_variable_defined?(:@pre_checks)