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
@@ -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