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