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
@@ -1,5 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ unless "".respond_to?(:then)
4
+ require "action_policy/ext/yield_self_then"
5
+ using ActionPolicy::Ext::YieldSelfThen
6
+ end
7
+
8
+ unless {}.respond_to?(:transform_keys)
9
+ require "action_policy/ext/hash_transform_keys"
10
+ using ActionPolicy::Ext::HashTransformKeys
11
+ end
12
+
3
13
  module ActionPolicy
4
14
  module Policy
5
15
  # Failures reasons store
@@ -10,10 +20,15 @@ module ActionPolicy
10
20
  @reasons = {}
11
21
  end
12
22
 
13
- def add(policy_or_class, rule)
23
+ def add(policy_or_class, rule, details = nil)
14
24
  policy_class = policy_or_class.is_a?(Module) ? policy_or_class : policy_or_class.class
15
25
  reasons[policy_class] ||= []
16
- reasons[policy_class] << rule
26
+
27
+ if details.nil?
28
+ add_non_detailed_reason reasons[policy_class], rule
29
+ else
30
+ add_detailed_reason reasons[policy_class], with_details(rule, details)
31
+ end
17
32
  end
18
33
 
19
34
  # Return Hash of the form:
@@ -29,6 +44,30 @@ module ActionPolicy
29
44
  def present?
30
45
  !empty?
31
46
  end
47
+
48
+ private
49
+
50
+ def add_non_detailed_reason(store, rule)
51
+ index =
52
+ if store.last.is_a?(::Hash)
53
+ store.size - 1
54
+ else
55
+ store.size
56
+ end
57
+
58
+ store.insert(index, rule)
59
+ end
60
+
61
+ def add_detailed_reason(store, detailed_rule)
62
+ store.last.is_a?(::Hash) || store << {}
63
+ store.last.merge!(detailed_rule)
64
+ end
65
+
66
+ def with_details(rule, details)
67
+ return rule if details.nil?
68
+
69
+ {rule => details}
70
+ end
32
71
  end
33
72
 
34
73
  # Extend ExecutionResult with `reasons` method
@@ -36,6 +75,20 @@ module ActionPolicy
36
75
  def reasons
37
76
  @reasons ||= FailureReasons.new
38
77
  end
78
+
79
+ attr_accessor :details
80
+
81
+ def clear_details
82
+ @details = nil
83
+ end
84
+
85
+ # Add reasons to inspect
86
+ def inspect
87
+ super.then do |str|
88
+ next str if reasons.empty?
89
+ str.sub(/>$/, " (reasons: #{reasons.details})")
90
+ end
91
+ end
39
92
  end
40
93
 
41
94
  # Provides failure reasons tracking functionality.
@@ -58,7 +111,7 @@ module ActionPolicy
58
111
  # rescue_from ActionPolicy::Unauthorized do |ex|
59
112
  # ex.policy #=> ApplicantPolicy
60
113
  # ex.rule #=> :show?
61
- # ex.result.reasons.details #=> { stage: [:show?] }
114
+ # ex.result.reasons.details #=> {stage: [:show?]}
62
115
  # end
63
116
  #
64
117
  # NOTE: the reason key (`stage`) is a policy identifier (underscored class name by default).
@@ -68,7 +121,7 @@ module ActionPolicy
68
121
  # # ..
69
122
  # end
70
123
  #
71
- # reasons.details #=> { :"admin/user" => [:show?] }
124
+ # reasons.details #=> {:"admin/user" => [:show?]}
72
125
  #
73
126
  #
74
127
  # You can also wrap _local_ rules into `allowed_to?` to populate reasons:
@@ -83,18 +136,49 @@ module ActionPolicy
83
136
  # user.has_permission?(:view_applicants)
84
137
  # end
85
138
  # end
139
+ #
140
+ # NOTE: there is `check?` alias for `allowed_to?`.
141
+ #
142
+ # You can provide additional details to your failure reasons by using
143
+ # a `details: { ... }` option:
144
+ #
145
+ # class ApplicantPolicy < ApplicationPolicy
146
+ # def show?
147
+ # allowed_to?(:show?, object.stage)
148
+ # end
149
+ # end
150
+ #
151
+ # class StagePolicy < ApplicationPolicy
152
+ # def show?
153
+ # # Add stage title to the failure reason (if any)
154
+ # # (could be used by client to show more descriptive message)
155
+ # details[:title] = record.title
156
+ #
157
+ # # then perform the checks
158
+ # user.stages.where(id: record.id).exists?
159
+ # end
160
+ # end
161
+ #
162
+ # # when accessing the reasons
163
+ # p ex.result.reasons.details #=> { stage: [{show?: {title: "Onboarding"}] }
164
+ #
165
+ # NOTE: when using detailed reasons, the `details` array contains as the last element
166
+ # a hash with ALL details reasons for the policy (in a form of <rule> => <details>).
167
+ #
86
168
  module Reasons
87
169
  class << self
88
170
  def included(base)
89
- base.result_class.include(ResultFailureReasons)
171
+ base.result_class.prepend(ResultFailureReasons)
90
172
  end
91
173
  end
92
174
 
93
- # rubocop: disable Metrics/MethodLength
94
- def allowed_to?(rule, record = :__undef__, **options)
95
- policy = nil
175
+ # Add additional details to the failure reason
176
+ def details
177
+ result.details ||= {}
178
+ end
96
179
 
97
- succeed =
180
+ def allowed_to?(rule, record = :__undef__, **options)
181
+ res =
98
182
  if record == :__undef__
99
183
  policy = self
100
184
  with_clean_result { apply(rule) }
@@ -102,12 +186,15 @@ module ActionPolicy
102
186
  policy = policy_for(record: record, **options)
103
187
 
104
188
  policy.apply(rule)
189
+ policy.result
105
190
  end
106
191
 
107
- result.reasons.add(policy, rule) unless succeed
108
- succeed
192
+ result.reasons.add(policy, rule, res.details) if res.fail?
193
+
194
+ res.clear_details
195
+
196
+ res.success?
109
197
  end
110
- # rubocop: enable Metrics/MethodLength
111
198
  end
112
199
  end
113
200
  end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_policy/behaviours/scoping"
4
+
5
+ require "action_policy/utils/suggest_message"
6
+
7
+ require "action_policy/ext/proc_case_eq"
8
+
9
+ using ActionPolicy::Ext::ProcCaseEq
10
+
11
+ module ActionPolicy
12
+ class UnknownScopeType < Error # :nodoc:
13
+ include ActionPolicy::SuggestMessage
14
+
15
+ MESSAGE_TEMPLATE = "Unknown policy scope type :%s for %s%s"
16
+
17
+ attr_reader :message
18
+
19
+ def initialize(policy_class, type)
20
+ @message = format(
21
+ MESSAGE_TEMPLATE,
22
+ type, policy_class,
23
+ suggest(type, policy_class.scoping_handlers.keys)
24
+ )
25
+ end
26
+ end
27
+
28
+ class UnknownNamedScope < Error # :nodoc:
29
+ include ActionPolicy::SuggestMessage
30
+
31
+ MESSAGE_TEMPLATE = "Unknown named scope :%s for type :%s for %s%s"
32
+
33
+ attr_reader :message
34
+
35
+ def initialize(policy_class, type, name)
36
+ @message = format(
37
+ MESSAGE_TEMPLATE, name, type, policy_class,
38
+ suggest(name, policy_class.scoping_handlers[type].keys)
39
+ )
40
+ end
41
+ end
42
+
43
+ class UnrecognizedScopeTarget < Error # :nodoc:
44
+ MESSAGE_TEMPLATE = "Couldn't infer scope type for %s instance"
45
+
46
+ attr_reader :message
47
+
48
+ def initialize(target)
49
+ target_class = target.is_a?(Module) ? target : target.class
50
+
51
+ @message = format(
52
+ MESSAGE_TEMPLATE, target_class
53
+ )
54
+ end
55
+ end
56
+
57
+ module Policy
58
+ # Scoping is used to modify the _object under authorization_.
59
+ #
60
+ # The most common situation is when you want to _scope_ the collection depending
61
+ # on the current user permissions.
62
+ #
63
+ # For example:
64
+ #
65
+ # class ApplicationPolicy < ActionPolicy::Base
66
+ # # Scoping only makes sense when you have the authorization context
67
+ # authorize :user
68
+ #
69
+ # # :relation here is a scoping type
70
+ # scope_for :relation do |relation|
71
+ # # authorization context is available within a scope
72
+ # if user.admin?
73
+ # relation
74
+ # else
75
+ # relation.publicly_visible
76
+ # end
77
+ # end
78
+ # end
79
+ #
80
+ # base_scope = User.all
81
+ # authorized_scope = ApplicantPolicy.new(user: user)
82
+ # .apply_scope(base_scope, type: :relation)
83
+ module Scoping
84
+ class << self
85
+ def included(base)
86
+ base.extend ClassMethods
87
+ end
88
+ end
89
+
90
+ include ActionPolicy::Behaviours::Scoping
91
+
92
+ # Pass target to the scope handler of the specified type and name.
93
+ # If `name` is not specified then `:default` name is used.
94
+ # If `type` is not specified then we try to infer the type from the
95
+ # target class.
96
+ def apply_scope(target, type:, name: :default, scope_options: nil)
97
+ raise ActionPolicy::UnknownScopeType.new(self.class, type) unless
98
+ self.class.scoping_handlers.key?(type)
99
+
100
+ raise ActionPolicy::UnknownNamedScope.new(self.class, type, name) unless
101
+ self.class.scoping_handlers[type].key?(name)
102
+
103
+ mid = :"__scoping__#{type}__#{name}"
104
+ scope_options ? send(mid, target, **scope_options) : send(mid, target)
105
+ end
106
+
107
+ def resolve_scope_type(target)
108
+ lookup_type_from_target(target) ||
109
+ raise(ActionPolicy::UnrecognizedScopeTarget, target)
110
+ end
111
+
112
+ def lookup_type_from_target(target)
113
+ self.class.scope_matchers.detect do |(_type, matcher)|
114
+ matcher === target
115
+ end&.first
116
+ end
117
+
118
+ module ClassMethods # :nodoc:
119
+ # Register a new scoping method for the `type`
120
+ def scope_for(type, name = :default, &block)
121
+ mid = :"__scoping__#{type}__#{name}"
122
+
123
+ define_method(mid, &block)
124
+
125
+ scoping_handlers[type][name] = mid
126
+ end
127
+
128
+ def scoping_handlers
129
+ return @scoping_handlers if instance_variable_defined?(:@scoping_handlers)
130
+
131
+ @scoping_handlers = Hash.new { |h, k| h[k] = {} }
132
+
133
+ if superclass.respond_to?(:scoping_handlers)
134
+ superclass.scoping_handlers.each do |k, v|
135
+ @scoping_handlers[k] = v.dup
136
+ end
137
+ end
138
+
139
+ @scoping_handlers
140
+ end
141
+
142
+ # Define scope type matcher.
143
+ #
144
+ # Scope matcher is an object that implements `#===` (_case equality_) or a Proc.
145
+ #
146
+ # When no type is provided when applying a scope we try to infer a type
147
+ # from the target object by calling matchers one by one until we find a matching
148
+ # type (i.e. there is a matcher which returns `true` when applying it to the target).
149
+ def scope_matcher(type, class_or_proc)
150
+ scope_matchers << [type, class_or_proc]
151
+ end
152
+
153
+ def scope_matchers
154
+ return @scope_matchers if instance_variable_defined?(:@scope_matchers)
155
+
156
+ @scope_matchers = []
157
+
158
+ @scope_matchers = superclass.scope_matchers.dup if superclass.respond_to?(:scope_matchers)
159
+
160
+ @scope_matchers
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPolicy # :nodoc:
4
+ module Rails
5
+ # Add instrumentation for `authorize!` method
6
+ module Authorizer
7
+ EVENT_NAME = "action_policy.authorize"
8
+
9
+ def authorize(policy, rule)
10
+ event = {policy: policy.class.name, rule: rule.to_s}
11
+ ActiveSupport::Notifications.instrument(EVENT_NAME, event) do
12
+ res = super
13
+ event[:cached] = policy.result.cached?
14
+ event[:value] = policy.result.value
15
+ res
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -42,9 +42,6 @@ module ActionPolicy
42
42
  #
43
43
  # Raises `ActionPolicy::Unauthorized` if check failed.
44
44
  def authorize!(record = :__undef__, to: nil, **options)
45
- record = controller_name.classify.safe_constantize if
46
- record == :__undef__
47
-
48
45
  to ||= :"#{action_name}?"
49
46
 
50
47
  super(record, to: to, **options)
@@ -52,17 +49,10 @@ module ActionPolicy
52
49
  self.authorize_count += 1
53
50
  end
54
51
 
55
- # Checks that an activity is allowed for the current context (e.g. user).
56
- #
57
- # If record is not provided, tries to infer the resource class
58
- # from controller name (i.e. `controller_name.classify.safe_constantize`).
59
- #
60
- # Returns true of false.
61
- def allowed_to?(rule, record = :__undef__, **options)
62
- record = controller_name.classify.safe_constantize if
63
- record == :__undef__
64
-
65
- super(rule, record, **options)
52
+ # Tries to infer the resource class from controller name
53
+ # (i.e. `controller_name.classify.safe_constantize`).
54
+ def implicit_authorization_target
55
+ controller_name.classify.safe_constantize
66
56
  end
67
57
 
68
58
  def verify_authorized
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Add explicit #policy_cache_key to Relation to
4
+ # avoid calling #cache_key.
5
+ # See https://github.com/palkan/action_policy/issues/55
6
+ ActiveRecord::Relation.include(Module.new do
7
+ def policy_cache_key
8
+ object_id
9
+ end
10
+ end)
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPolicy # :nodoc:
4
+ module Policy
5
+ module Rails
6
+ # Add ActiveSupport::Notifications support.
7
+ #
8
+ # Fires `action_policy.apply_rule` event on every `#apply` call.
9
+ module Instrumentation
10
+ EVENT_NAME = "action_policy.apply_rule"
11
+
12
+ def apply(rule)
13
+ event = {policy: self.class.name, rule: rule.to_s}
14
+ ActiveSupport::Notifications.instrument(EVENT_NAME, event) do
15
+ res = super
16
+ event[:cached] = result.cached?
17
+ event[:value] = result.value
18
+ res
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPolicy
4
+ module ScopeMatchers
5
+ # Adds `params_filter` method as an alias
6
+ # for `scope_for :action_controller_params`
7
+ module ActionControllerParams
8
+ def params_filter(*args, &block)
9
+ scope_for :action_controller_params, *args, &block
10
+ end
11
+ end
12
+ end
13
+ end
14
+
15
+ # Register params scope matcher
16
+ ActionPolicy::Base.scope_matcher :action_controller_params, ActionController::Parameters
17
+
18
+ # Add alias to base policy
19
+ ActionPolicy::Base.extend ActionPolicy::ScopeMatchers::ActionControllerParams