action_policy 0.5.0 → 0.5.1

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 (28) hide show
  1. checksums.yaml +4 -4
  2. data/lib/.rbnext/2.7/action_policy/behaviours/policy_for.rb +62 -0
  3. data/lib/.rbnext/2.7/action_policy/i18n.rb +56 -0
  4. data/lib/.rbnext/2.7/action_policy/policy/cache.rb +101 -0
  5. data/lib/.rbnext/2.7/action_policy/policy/pre_check.rb +162 -0
  6. data/lib/.rbnext/2.7/action_policy/rspec/be_authorized_to.rb +89 -0
  7. data/lib/.rbnext/2.7/action_policy/rspec/have_authorized_scope.rb +124 -0
  8. data/lib/.rbnext/2.7/action_policy/utils/pretty_print.rb +159 -0
  9. data/lib/.rbnext/3.0/action_policy/behaviour.rb +115 -0
  10. data/lib/.rbnext/3.0/action_policy/behaviours/policy_for.rb +62 -0
  11. data/lib/.rbnext/3.0/action_policy/behaviours/scoping.rb +35 -0
  12. data/lib/.rbnext/3.0/action_policy/behaviours/thread_memoized.rb +59 -0
  13. data/lib/.rbnext/3.0/action_policy/ext/policy_cache_key.rb +72 -0
  14. data/lib/.rbnext/3.0/action_policy/policy/aliases.rb +69 -0
  15. data/lib/.rbnext/3.0/action_policy/policy/authorization.rb +87 -0
  16. data/lib/.rbnext/3.0/action_policy/policy/cache.rb +101 -0
  17. data/lib/.rbnext/3.0/action_policy/policy/core.rb +161 -0
  18. data/lib/.rbnext/3.0/action_policy/policy/defaults.rb +31 -0
  19. data/lib/.rbnext/3.0/action_policy/policy/execution_result.rb +37 -0
  20. data/lib/.rbnext/3.0/action_policy/policy/pre_check.rb +162 -0
  21. data/lib/.rbnext/3.0/action_policy/policy/reasons.rb +210 -0
  22. data/lib/.rbnext/3.0/action_policy/policy/scoping.rb +160 -0
  23. data/lib/.rbnext/3.0/action_policy/rspec/be_authorized_to.rb +89 -0
  24. data/lib/.rbnext/3.0/action_policy/rspec/have_authorized_scope.rb +124 -0
  25. data/lib/.rbnext/3.0/action_policy/utils/pretty_print.rb +159 -0
  26. data/lib/.rbnext/3.0/action_policy/utils/suggest_message.rb +19 -0
  27. data/lib/action_policy/version.rb +1 -1
  28. metadata +27 -2
@@ -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,210 @@
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
+ policy = self
189
+ with_clean_result { apply(rule) }
190
+ else
191
+ policy = policy_for(record: record, **options)
192
+
193
+ policy.apply(rule)
194
+ policy.result
195
+ end
196
+
197
+ result&.reasons&.add(policy, rule, res.details) if res.fail?
198
+
199
+ res.clear_details
200
+
201
+ res.success?
202
+ end
203
+
204
+ def deny!(reason = nil)
205
+ result&.reasons&.add(self, reason) if reason
206
+ super()
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_policy/behaviours/scoping"
4
+
5
+ require "action_policy/utils/suggest_message"
6
+
7
+ module ActionPolicy
8
+ class UnknownScopeType < Error # :nodoc:
9
+ include ActionPolicy::SuggestMessage
10
+
11
+ MESSAGE_TEMPLATE = "Unknown policy scope type :%s for %s%s"
12
+
13
+ attr_reader :message
14
+
15
+ def initialize(policy_class, type)
16
+ @message = format(
17
+ MESSAGE_TEMPLATE,
18
+ type, policy_class,
19
+ suggest(type, policy_class.scoping_handlers.keys)
20
+ )
21
+ end
22
+ end
23
+
24
+ class UnknownNamedScope < Error # :nodoc:
25
+ include ActionPolicy::SuggestMessage
26
+
27
+ MESSAGE_TEMPLATE = "Unknown named scope :%s for type :%s for %s%s"
28
+
29
+ attr_reader :message
30
+
31
+ def initialize(policy_class, type, name)
32
+ @message = format(
33
+ MESSAGE_TEMPLATE, name, type, policy_class,
34
+ suggest(name, policy_class.scoping_handlers[type].keys)
35
+ )
36
+ end
37
+ end
38
+
39
+ class UnrecognizedScopeTarget < Error # :nodoc:
40
+ MESSAGE_TEMPLATE = "Couldn't infer scope type for %s instance"
41
+
42
+ attr_reader :message
43
+
44
+ def initialize(target)
45
+ target_class = target.is_a?(Module) ? target : target.class
46
+
47
+ @message = format(
48
+ MESSAGE_TEMPLATE, target_class
49
+ )
50
+ end
51
+ end
52
+
53
+ module Policy
54
+ # Scoping is used to modify the _object under authorization_.
55
+ #
56
+ # The most common situation is when you want to _scope_ the collection depending
57
+ # on the current user permissions.
58
+ #
59
+ # For example:
60
+ #
61
+ # class ApplicationPolicy < ActionPolicy::Base
62
+ # # Scoping only makes sense when you have the authorization context
63
+ # authorize :user
64
+ #
65
+ # # :relation here is a scoping type
66
+ # scope_for :relation do |relation|
67
+ # # authorization context is available within a scope
68
+ # if user.admin?
69
+ # relation
70
+ # else
71
+ # relation.publicly_visible
72
+ # end
73
+ # end
74
+ # end
75
+ #
76
+ # base_scope = User.all
77
+ # authorized_scope = ApplicantPolicy.new(user: user)
78
+ # .apply_scope(base_scope, type: :relation)
79
+ module Scoping
80
+ class << self
81
+ def included(base)
82
+ base.extend ClassMethods
83
+ end
84
+ end
85
+
86
+ include ActionPolicy::Behaviours::Scoping
87
+
88
+ # Pass target to the scope handler of the specified type and name.
89
+ # If `name` is not specified then `:default` name is used.
90
+ # If `type` is not specified then we try to infer the type from the
91
+ # target class.
92
+ def apply_scope(target, type:, name: :default, scope_options: nil)
93
+ raise ActionPolicy::UnknownScopeType.new(self.class, type) unless
94
+ self.class.scoping_handlers.key?(type)
95
+
96
+ raise ActionPolicy::UnknownNamedScope.new(self.class, type, name) unless
97
+ self.class.scoping_handlers[type].key?(name)
98
+
99
+ mid = :"__scoping__#{type}__#{name}"
100
+ scope_options ? send(mid, target, **scope_options) : send(mid, target)
101
+ end
102
+
103
+ def resolve_scope_type(target)
104
+ lookup_type_from_target(target) ||
105
+ raise(ActionPolicy::UnrecognizedScopeTarget, target)
106
+ end
107
+
108
+ def lookup_type_from_target(target)
109
+ self.class.scope_matchers.detect do |(_type, matcher)|
110
+ matcher === target
111
+ end&.first
112
+ end
113
+
114
+ module ClassMethods # :nodoc:
115
+ # Register a new scoping method for the `type`
116
+ def scope_for(type, name = :default, &block)
117
+ mid = :"__scoping__#{type}__#{name}"
118
+
119
+ define_method(mid, &block)
120
+
121
+ scoping_handlers[type][name] = mid
122
+ end
123
+
124
+ def scoping_handlers
125
+ return @scoping_handlers if instance_variable_defined?(:@scoping_handlers)
126
+
127
+ @scoping_handlers =
128
+ Hash.new { |h, k| h[k] = {} }.tap do |handlers|
129
+ if superclass.respond_to?(:scoping_handlers)
130
+ superclass.scoping_handlers.each do |k, v|
131
+ handlers[k] = v.dup
132
+ end
133
+ end
134
+ end
135
+ end
136
+
137
+ # Define scope type matcher.
138
+ #
139
+ # Scope matcher is an object that implements `#===` (_case equality_) or a Proc.
140
+ #
141
+ # When no type is provided when applying a scope we try to infer a type
142
+ # from the target object by calling matchers one by one until we find a matching
143
+ # type (i.e. there is a matcher which returns `true` when applying it to the target).
144
+ def scope_matcher(type, class_or_proc)
145
+ scope_matchers << [type, class_or_proc]
146
+ end
147
+
148
+ def scope_matchers
149
+ return @scope_matchers if instance_variable_defined?(:@scope_matchers)
150
+
151
+ @scope_matchers = if superclass.respond_to?(:scope_matchers)
152
+ superclass.scope_matchers.dup
153
+ else
154
+ []
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end