action_policy 0.5.0 → 0.5.5

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