action_policy 0.5.0 → 0.5.1

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