action_policy 0.2.4 → 0.3.0.beta1

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