action_policy 0.5.0 → 0.5.5

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -2
  3. data/config/rubocop-rspec.yml +17 -0
  4. data/lib/.rbnext/1995.next/action_policy/behaviours/policy_for.rb +62 -0
  5. data/lib/.rbnext/1995.next/action_policy/behaviours/scoping.rb +35 -0
  6. data/lib/.rbnext/1995.next/action_policy/policy/authorization.rb +87 -0
  7. data/lib/.rbnext/1995.next/action_policy/utils/pretty_print.rb +159 -0
  8. data/lib/.rbnext/2.7/action_policy/behaviours/policy_for.rb +62 -0
  9. data/lib/.rbnext/2.7/action_policy/i18n.rb +56 -0
  10. data/lib/.rbnext/2.7/action_policy/policy/cache.rb +101 -0
  11. data/lib/.rbnext/2.7/action_policy/policy/pre_check.rb +162 -0
  12. data/lib/.rbnext/2.7/action_policy/rspec/be_authorized_to.rb +89 -0
  13. data/lib/.rbnext/2.7/action_policy/rspec/have_authorized_scope.rb +124 -0
  14. data/lib/.rbnext/2.7/action_policy/utils/pretty_print.rb +159 -0
  15. data/lib/.rbnext/3.0/action_policy/behaviours/thread_memoized.rb +59 -0
  16. data/lib/.rbnext/3.0/action_policy/ext/policy_cache_key.rb +72 -0
  17. data/lib/.rbnext/3.0/action_policy/policy/aliases.rb +69 -0
  18. data/lib/.rbnext/3.0/action_policy/policy/cache.rb +101 -0
  19. data/lib/.rbnext/3.0/action_policy/policy/core.rb +161 -0
  20. data/lib/.rbnext/3.0/action_policy/policy/defaults.rb +31 -0
  21. data/lib/.rbnext/3.0/action_policy/policy/execution_result.rb +37 -0
  22. data/lib/.rbnext/3.0/action_policy/policy/pre_check.rb +162 -0
  23. data/lib/.rbnext/3.0/action_policy/policy/reasons.rb +212 -0
  24. data/lib/.rbnext/3.0/action_policy/rspec/be_authorized_to.rb +89 -0
  25. data/lib/.rbnext/3.0/action_policy/rspec/have_authorized_scope.rb +124 -0
  26. data/lib/.rbnext/3.0/action_policy/utils/pretty_print.rb +159 -0
  27. data/lib/.rbnext/3.0/action_policy/utils/suggest_message.rb +19 -0
  28. data/lib/action_policy/behaviour.rb +2 -2
  29. data/lib/action_policy/behaviours/memoized.rb +1 -1
  30. data/lib/action_policy/behaviours/namespaced.rb +1 -1
  31. data/lib/action_policy/behaviours/policy_for.rb +1 -1
  32. data/lib/action_policy/behaviours/scoping.rb +2 -2
  33. data/lib/action_policy/behaviours/thread_memoized.rb +1 -1
  34. data/lib/action_policy/lookup_chain.rb +11 -27
  35. data/lib/action_policy/policy/aliases.rb +2 -2
  36. data/lib/action_policy/policy/authorization.rb +2 -2
  37. data/lib/action_policy/policy/cache.rb +2 -2
  38. data/lib/action_policy/policy/pre_check.rb +2 -2
  39. data/lib/action_policy/policy/reasons.rb +4 -2
  40. data/lib/action_policy/policy/scoping.rb +2 -2
  41. data/lib/action_policy/test_helper.rb +1 -0
  42. data/lib/action_policy/version.rb +1 -1
  43. metadata +29 -4
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_policy/behaviours/policy_for"
4
+ require "action_policy/policy/execution_result"
5
+ require "action_policy/utils/suggest_message"
6
+ require "action_policy/utils/pretty_print"
7
+
8
+ unless "".respond_to?(:underscore)
9
+ require "action_policy/ext/string_underscore"
10
+ using ActionPolicy::Ext::StringUnderscore
11
+ end
12
+
13
+ module ActionPolicy
14
+ using RubyNext
15
+
16
+ # Raised when `resolve_rule` failed to find an approriate
17
+ # policy rule method for the activity
18
+ class UnknownRule < Error
19
+ include ActionPolicy::SuggestMessage
20
+
21
+ attr_reader :policy, :rule, :message
22
+
23
+ def initialize(policy, rule)
24
+ @policy = policy.class
25
+ @rule = rule
26
+ @message =
27
+ "Couldn't find rule '#{@rule}' for #{@policy}" \
28
+ "#{suggest(@rule, @policy.instance_methods - Object.instance_methods)}"
29
+ end
30
+ end
31
+
32
+ module Policy
33
+ # Core policy API
34
+ module Core
35
+ class << self
36
+ def included(base)
37
+ base.extend ClassMethods
38
+
39
+ # Generate a new class for each _policy chain_
40
+ # in order to extend it independently
41
+ base.module_eval do
42
+ @result_class = Class.new(ExecutionResult)
43
+
44
+ # we need to make this class _named_,
45
+ # 'cause anonymous classes couldn't be marshalled
46
+ base.const_set(:APR, @result_class)
47
+ end
48
+ end
49
+ end
50
+
51
+ module ClassMethods # :nodoc:
52
+ attr_writer :identifier
53
+
54
+ def result_class
55
+ return @result_class if instance_variable_defined?(:@result_class)
56
+ @result_class = superclass.result_class
57
+ end
58
+
59
+ def identifier
60
+ return @identifier if instance_variable_defined?(:@identifier)
61
+
62
+ @identifier = name.sub(/Policy$/, "").underscore.to_sym
63
+ end
64
+ end
65
+
66
+ include ActionPolicy::Behaviours::PolicyFor
67
+
68
+ attr_reader :record, :result
69
+
70
+ # NEXT_RELEASE: deprecate `record` arg, migrate to `record: nil`
71
+ def initialize(record = nil, *)
72
+ @record = record
73
+ end
74
+
75
+ # Returns a result of applying the specified rule (true of false).
76
+ # Unlike simply calling a predicate rule (`policy.manage?`),
77
+ # `apply` also calls pre-checks.
78
+ def apply(rule)
79
+ @result = self.class.result_class.new(self.class, rule)
80
+
81
+ catch :policy_fulfilled do
82
+ result.load __apply__(rule)
83
+ end
84
+
85
+ result.value
86
+ end
87
+
88
+ def deny!
89
+ result&.load false
90
+ throw :policy_fulfilled
91
+ end
92
+
93
+ def allow!
94
+ result&.load true
95
+ throw :policy_fulfilled
96
+ end
97
+
98
+ # This method performs the rule call.
99
+ # Override or extend it to provide custom functionality
100
+ # (such as caching, pre checks, etc.)
101
+ def __apply__(rule) ; public_send(rule); end
102
+
103
+ # Wrap code that could modify result
104
+ # to prevent the current result modification
105
+ def with_clean_result # :nodoc:
106
+ was_result = @result
107
+ yield
108
+ @result
109
+ ensure
110
+ @result = was_result
111
+ end
112
+
113
+ # Returns a result of applying the specified rule to the specified record.
114
+ # Under the hood a policy class for record is resolved
115
+ # (unless it's explicitly set through `with` option).
116
+ #
117
+ # If record is `nil` then we uses the current policy.
118
+ def allowed_to?(rule, record = :__undef__, **options)
119
+ if (record == :__undef__ || record == self.record) && options.empty?
120
+ __apply__(resolve_rule(rule))
121
+ else
122
+ policy_for(record: record, **options).then do |policy|
123
+ policy.apply(policy.resolve_rule(rule))
124
+ end
125
+ end
126
+ end
127
+
128
+ # An alias for readability purposes
129
+ def check?(*args) ; allowed_to?(*args); end
130
+
131
+ # Returns a rule name (policy method name) for activity.
132
+ #
133
+ # By default, rule name is equal to activity name.
134
+ #
135
+ # Raises ActionPolicy::UknownRule when rule is not found in policy.
136
+ def resolve_rule(activity)
137
+ raise UnknownRule.new(self, activity) unless
138
+ respond_to?(activity)
139
+ activity
140
+ end
141
+
142
+ # Return annotated source code for the rule
143
+ # NOTE: require "method_source" and "unparser" gems to be installed.
144
+ # Otherwise returns empty string.
145
+ def inspect_rule(rule) ; PrettyPrint.print_method(self, rule); end
146
+
147
+ # Helper for printing the annotated rule source.
148
+ # Useful for debugging: type `pp :show?` within the context of the policy
149
+ # to preview the rule.
150
+ def pp(rule)
151
+ with_clean_result do
152
+ # We need result to exist for `allowed_to?` to work correctly
153
+ @result = self.class.result_class.new(self.class, rule)
154
+ header = "#{self.class.name}##{rule}"
155
+ source = inspect_rule(rule)
156
+ $stdout.puts "#{header}\n#{source}"
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPolicy
4
+ module Policy
5
+ # Create default rules and aliases:
6
+ # - `index?` (=`false`)
7
+ # - `create?` (=`false`)
8
+ # - `new?` as an alias for `create?`
9
+ # - `manage?` as a fallback for all unspecified rules (default rule)
10
+ module Defaults
11
+ def self.included(base)
12
+ raise "Aliases support is required for defaults" unless
13
+ base.ancestors.include?(Aliases)
14
+
15
+ base.default_rule :manage?
16
+ base.alias_rule :new?, to: :create?
17
+
18
+ raise "Verification context support is required for defaults" unless
19
+ base.ancestors.include?(Aliases)
20
+
21
+ base.authorize :user
22
+ end
23
+
24
+ def index?() ; false; end
25
+
26
+ def create?() ; false; end
27
+
28
+ def manage?() ; false; end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPolicy
4
+ module Policy
5
+ # Result of applying a policy rule
6
+ #
7
+ # This class could be extended by some modules to provide
8
+ # additional functionality
9
+ class ExecutionResult
10
+ attr_reader :value, :policy, :rule
11
+
12
+ def initialize(policy, rule)
13
+ @policy = policy
14
+ @rule = rule
15
+ end
16
+
17
+ # Populate the final value
18
+ def load(value)
19
+ @value = value
20
+ end
21
+
22
+ def success?() ; @value == true; end
23
+
24
+ def fail?() ; @value == false; end
25
+
26
+ def cached!
27
+ @cached = true
28
+ end
29
+
30
+ def cached?() ; @cached == true; end
31
+
32
+ def inspect
33
+ "<#{policy}##{rule}: #{@value}>"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPolicy
4
+ module Policy
5
+ # Adds callback-style checks to policies to
6
+ # extract common checks from rules.
7
+ #
8
+ # class ApplicationPolicy < ActionPolicy::Base
9
+ # authorize :user
10
+ # pre_check :allow_admins
11
+ #
12
+ # private
13
+ # # Allow every action for admins
14
+ # def allow_admins
15
+ # allow! if user.admin?
16
+ # end
17
+ # end
18
+ #
19
+ # You can specify conditional pre-checks (through `except` / `only`) options
20
+ # and skip already defined pre-checks if necessary.
21
+ #
22
+ # class UserPolicy < ApplicationPolicy
23
+ # skip_pre_check :allow_admins, only: :destroy?
24
+ #
25
+ # def destroy?
26
+ # user.admin? && !record.admin?
27
+ # end
28
+ # end
29
+ module PreCheck
30
+ # Single pre-check instance.
31
+ #
32
+ # Implements filtering logic.
33
+ class Check
34
+ attr_reader :name, :policy_class
35
+
36
+ def initialize(policy, name, except: nil, only: nil)
37
+ if !except.nil? && !only.nil?
38
+ raise ArgumentError,
39
+ "Only one of `except` and `only` may be specified for pre-check"
40
+ end
41
+
42
+ @policy_class = policy
43
+ @name = name
44
+ @blacklist = Array(except) unless except.nil?
45
+ @whitelist = Array(only) unless only.nil?
46
+
47
+ rebuild_filter
48
+ end
49
+
50
+ def applicable?(rule)
51
+ return true if filter.nil?
52
+ filter.call(rule)
53
+ end
54
+
55
+ def call(policy) ; policy.send(name); end
56
+
57
+ def skip!(except: nil, only: nil)
58
+ if !except.nil? && !only.nil?
59
+ raise ArgumentError,
60
+ "Only one of `except` and `only` may be specified when skipping pre-check"
61
+ end
62
+
63
+ if except.nil? && only.nil?
64
+ raise ArgumentError,
65
+ "At least one of `except` and `only` must be specified when skipping pre-check"
66
+ end
67
+
68
+ if except
69
+ @whitelist = Array(except)
70
+ @whitelist -= blacklist if blacklist
71
+ @blacklist = nil
72
+ else
73
+ # only
74
+ @blacklist += Array(only) if blacklist
75
+ @whitelist -= Array(only) if whitelist
76
+ @blacklist = Array(only) if filter.nil?
77
+ end
78
+
79
+ rebuild_filter
80
+ end
81
+ # rubocop: enable
82
+ # rubocop: enable
83
+
84
+ def dup
85
+ self.class.new(
86
+ policy_class,
87
+ name,
88
+ except: blacklist&.dup,
89
+ only: whitelist&.dup
90
+ )
91
+ end
92
+
93
+ private
94
+
95
+ attr_reader :whitelist, :blacklist, :filter
96
+
97
+ def rebuild_filter
98
+ @filter =
99
+ if whitelist
100
+ proc { |rule| whitelist.include?(rule) }
101
+ elsif blacklist
102
+ proc { |rule| !blacklist.include?(rule) }
103
+ end
104
+ end
105
+ end
106
+
107
+ class << self
108
+ def included(base)
109
+ base.extend ClassMethods
110
+ end
111
+ end
112
+
113
+ def run_pre_checks(rule)
114
+ self.class.pre_checks.each do |check|
115
+ next unless check.applicable?(rule)
116
+ check.call(self)
117
+ end
118
+
119
+ yield if block_given?
120
+ end
121
+
122
+ def __apply__(rule)
123
+ run_pre_checks(rule) { super }
124
+ end
125
+
126
+ module ClassMethods # :nodoc:
127
+ def pre_check(*names, **options)
128
+ names.each do |name|
129
+ # do not allow pre-check override
130
+ check = pre_checks.find { _1.name == name }
131
+ raise "Pre-check already defined: #{name}" unless check.nil?
132
+
133
+ pre_checks << Check.new(self, name, **options)
134
+ end
135
+ end
136
+
137
+ def skip_pre_check(*names, **options)
138
+ names.each do |name|
139
+ check = pre_checks.find { _1.name == name }
140
+ raise "Pre-check not found: #{name}" if check.nil?
141
+
142
+ # when no options provided we remove this check completely
143
+ next pre_checks.delete(check) if options.empty?
144
+
145
+ # otherwise duplicate and apply skip options
146
+ pre_checks[pre_checks.index(check)] = check.dup.tap { _1.skip!(**options) }
147
+ end
148
+ end
149
+
150
+ def pre_checks
151
+ return @pre_checks if instance_variable_defined?(:@pre_checks)
152
+
153
+ @pre_checks = if superclass.respond_to?(:pre_checks)
154
+ superclass.pre_checks.dup
155
+ else
156
+ []
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPolicy
4
+ using RubyNext
5
+
6
+ module Policy
7
+ # Failures reasons store
8
+ class FailureReasons
9
+ attr_reader :reasons
10
+
11
+ def initialize
12
+ @reasons = {}
13
+ end
14
+
15
+ def add(policy_or_class, rule, details = nil)
16
+ policy_class = policy_or_class.is_a?(Module) ? policy_or_class : policy_or_class.class
17
+ reasons[policy_class] ||= []
18
+
19
+ if details.nil?
20
+ add_non_detailed_reason reasons[policy_class], rule
21
+ else
22
+ add_detailed_reason reasons[policy_class], with_details(rule, details)
23
+ end
24
+ end
25
+
26
+ # Return Hash of the form:
27
+ # { policy_identifier => [rules, ...] }
28
+ def details() ; reasons.transform_keys(&:identifier); end
29
+
30
+ def empty?() ; reasons.empty?; end
31
+
32
+ def present?() ; !empty?; end
33
+
34
+ private
35
+
36
+ def add_non_detailed_reason(store, rule)
37
+ index =
38
+ if store.last.is_a?(::Hash)
39
+ store.size - 1
40
+ else
41
+ store.size
42
+ end
43
+
44
+ store.insert(index, rule)
45
+ end
46
+
47
+ def add_detailed_reason(store, detailed_rule)
48
+ store.last.is_a?(::Hash) || store << {}
49
+ store.last.merge!(detailed_rule)
50
+ end
51
+
52
+ def with_details(rule, details)
53
+ return rule if details.nil?
54
+
55
+ {rule => details}
56
+ end
57
+ end
58
+
59
+ # Extend ExecutionResult with `reasons` method
60
+ module ResultFailureReasons
61
+ def reasons
62
+ @reasons ||= FailureReasons.new
63
+ end
64
+
65
+ attr_accessor :details
66
+
67
+ def clear_details
68
+ @details = nil
69
+ end
70
+
71
+ # Returns all the details merged together
72
+ def all_details
73
+ return @all_details if defined?(@all_details)
74
+
75
+ @all_details = {}.tap do |all|
76
+ next unless defined?(@reasons)
77
+
78
+ reasons.reasons.each_value do |rules|
79
+ detailed_reasons = rules.last
80
+
81
+ next unless detailed_reasons.is_a?(Hash)
82
+
83
+ detailed_reasons.each_value do |details|
84
+ all.merge!(details)
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ # Add reasons to inspect
91
+ def inspect
92
+ super.then do |str|
93
+ next str if reasons.empty?
94
+ str.sub(/>$/, " (reasons: #{reasons.details})")
95
+ end
96
+ end
97
+ end
98
+
99
+ # Provides failure reasons tracking functionality.
100
+ # That allows you to distinguish between the reasons why authorization was rejected.
101
+ #
102
+ # It's helpful when you compose policies (i.e. use one policy within another).
103
+ #
104
+ # For example:
105
+ #
106
+ # class ApplicantPolicy < ApplicationPolicy
107
+ # def show?
108
+ # user.has_permission?(:view_applicants) &&
109
+ # allowed_to?(:show?, object.stage)
110
+ # end
111
+ # end
112
+ #
113
+ # Now when you receive an exception, you have a reasons object, which contains additional
114
+ # information about the failure:
115
+ #
116
+ # rescue_from ActionPolicy::Unauthorized do |ex|
117
+ # ex.policy #=> ApplicantPolicy
118
+ # ex.rule #=> :show?
119
+ # ex.result.reasons.details #=> {stage: [:show?]}
120
+ # end
121
+ #
122
+ # NOTE: the reason key (`stage`) is a policy identifier (underscored class name by default).
123
+ # For namespaced policies it has a form of:
124
+ #
125
+ # class Admin::UserPolicy < ApplicationPolicy
126
+ # # ..
127
+ # end
128
+ #
129
+ # reasons.details #=> {:"admin/user" => [:show?]}
130
+ #
131
+ #
132
+ # You can also wrap _local_ rules into `allowed_to?` to populate reasons:
133
+ #
134
+ # class ApplicantPolicy < ApplicationPolicy
135
+ # def show?
136
+ # allowed_to?(:view_applicants?) &&
137
+ # allowed_to?(:show?, object.stage)
138
+ # end
139
+ #
140
+ # def view_applicants?
141
+ # user.has_permission?(:view_applicants)
142
+ # end
143
+ # end
144
+ #
145
+ # NOTE: there is `check?` alias for `allowed_to?`.
146
+ #
147
+ # You can provide additional details to your failure reasons by using
148
+ # a `details: { ... }` option:
149
+ #
150
+ # class ApplicantPolicy < ApplicationPolicy
151
+ # def show?
152
+ # allowed_to?(:show?, object.stage)
153
+ # end
154
+ # end
155
+ #
156
+ # class StagePolicy < ApplicationPolicy
157
+ # def show?
158
+ # # Add stage title to the failure reason (if any)
159
+ # # (could be used by client to show more descriptive message)
160
+ # details[:title] = record.title
161
+ #
162
+ # # then perform the checks
163
+ # user.stages.where(id: record.id).exists?
164
+ # end
165
+ # end
166
+ #
167
+ # # when accessing the reasons
168
+ # p ex.result.reasons.details #=> { stage: [{show?: {title: "Onboarding"}] }
169
+ #
170
+ # NOTE: when using detailed reasons, the `details` array contains as the last element
171
+ # a hash with ALL details reasons for the policy (in a form of <rule> => <details>).
172
+ #
173
+ module Reasons
174
+ class << self
175
+ def included(base)
176
+ base.result_class.prepend(ResultFailureReasons)
177
+ end
178
+ end
179
+
180
+ # Add additional details to the failure reason
181
+ def details
182
+ result.details ||= {}
183
+ end
184
+
185
+ def allowed_to?(rule, record = :__undef__, **options)
186
+ res =
187
+ if (record == :__undef__ || record == self.record) && options.empty?
188
+ rule = resolve_rule(rule)
189
+ policy = self
190
+ with_clean_result { apply(rule) }
191
+ else
192
+ policy = policy_for(record: record, **options)
193
+ rule = policy.resolve_rule(rule)
194
+
195
+ policy.apply(rule)
196
+ policy.result
197
+ end
198
+
199
+ result&.reasons&.add(policy, rule, res.details) if res.fail?
200
+
201
+ res.clear_details
202
+
203
+ res.success?
204
+ end
205
+
206
+ def deny!(reason = nil)
207
+ result&.reasons&.add(self, reason) if reason
208
+ super()
209
+ end
210
+ end
211
+ end
212
+ end