action_policy 0.2.4 → 0.3.0.beta1
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.
- checksums.yaml +4 -4
- data/.rubocop.yml +26 -64
- data/.travis.yml +13 -10
- data/CHANGELOG.md +216 -1
- data/Gemfile +7 -0
- data/LICENSE.txt +1 -1
- data/Rakefile +10 -0
- data/action_policy.gemspec +5 -3
- data/benchmarks/namespaced_lookup_cache.rb +18 -22
- data/docs/README.md +3 -3
- data/docs/_sidebar.md +4 -0
- data/docs/aliases.md +9 -5
- data/docs/authorization_context.md +59 -1
- data/docs/behaviour.md +113 -0
- data/docs/caching.md +6 -4
- data/docs/custom_policy.md +1 -2
- data/docs/debugging.md +55 -0
- data/docs/decorators.md +27 -0
- data/docs/i18n.md +41 -2
- data/docs/instrumentation.md +70 -2
- data/docs/lookup_chain.md +5 -4
- data/docs/namespaces.md +1 -1
- data/docs/non_rails.md +2 -3
- data/docs/pundit_migration.md +77 -2
- data/docs/quick_start.md +5 -5
- data/docs/rails.md +5 -2
- data/docs/reasons.md +50 -3
- data/docs/scoping.md +262 -0
- data/docs/testing.md +232 -21
- data/docs/writing_policies.md +1 -1
- data/gemfiles/jruby.gemfile +3 -0
- data/gemfiles/rails42.gemfile +3 -0
- data/gemfiles/rails6.gemfile +8 -0
- data/gemfiles/railsmaster.gemfile +1 -1
- data/lib/action_policy.rb +3 -3
- data/lib/action_policy/authorizer.rb +12 -4
- data/lib/action_policy/base.rb +2 -0
- data/lib/action_policy/behaviour.rb +14 -3
- data/lib/action_policy/behaviours/memoized.rb +1 -1
- data/lib/action_policy/behaviours/policy_for.rb +12 -3
- data/lib/action_policy/behaviours/scoping.rb +32 -0
- data/lib/action_policy/behaviours/thread_memoized.rb +1 -1
- data/lib/action_policy/ext/hash_transform_keys.rb +19 -0
- data/lib/action_policy/ext/module_namespace.rb +1 -1
- data/lib/action_policy/ext/policy_cache_key.rb +2 -1
- data/lib/action_policy/ext/proc_case_eq.rb +14 -0
- data/lib/action_policy/ext/string_constantize.rb +1 -0
- data/lib/action_policy/ext/symbol_classify.rb +22 -0
- data/lib/action_policy/i18n.rb +56 -0
- data/lib/action_policy/lookup_chain.rb +21 -3
- data/lib/action_policy/policy/cache.rb +10 -6
- data/lib/action_policy/policy/core.rb +31 -19
- data/lib/action_policy/policy/execution_result.rb +12 -0
- data/lib/action_policy/policy/pre_check.rb +2 -6
- data/lib/action_policy/policy/reasons.rb +99 -12
- data/lib/action_policy/policy/scoping.rb +165 -0
- data/lib/action_policy/rails/authorizer.rb +20 -0
- data/lib/action_policy/rails/controller.rb +4 -14
- data/lib/action_policy/rails/ext/active_record.rb +10 -0
- data/lib/action_policy/rails/policy/instrumentation.rb +24 -0
- data/lib/action_policy/rails/scope_matchers/action_controller_params.rb +19 -0
- data/lib/action_policy/rails/scope_matchers/active_record.rb +29 -0
- data/lib/action_policy/railtie.rb +29 -7
- data/lib/action_policy/rspec.rb +1 -0
- data/lib/action_policy/rspec/be_authorized_to.rb +1 -1
- data/lib/action_policy/rspec/dsl.rb +103 -0
- data/lib/action_policy/rspec/have_authorized_scope.rb +126 -0
- data/lib/action_policy/rspec/pundit_syntax.rb +1 -1
- data/lib/action_policy/test_helper.rb +69 -4
- data/lib/action_policy/testing.rb +54 -0
- data/lib/action_policy/utils/pretty_print.rb +137 -0
- data/lib/action_policy/utils/suggest_message.rb +21 -0
- data/lib/action_policy/version.rb +1 -1
- 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
|
-
|
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 #=> {
|
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 #=> {
|
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.
|
171
|
+
base.result_class.prepend(ResultFailureReasons)
|
90
172
|
end
|
91
173
|
end
|
92
174
|
|
93
|
-
#
|
94
|
-
def
|
95
|
-
|
175
|
+
# Add additional details to the failure reason
|
176
|
+
def details
|
177
|
+
result.details ||= {}
|
178
|
+
end
|
96
179
|
|
97
|
-
|
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)
|
108
|
-
|
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
|
-
#
|
56
|
-
#
|
57
|
-
|
58
|
-
|
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
|