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
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionPolicy
|
4
|
+
module ScopeMatchers
|
5
|
+
# Adds `relation_scope` method as an alias
|
6
|
+
# for `scope_for :active_record_relation`
|
7
|
+
module ActiveRecord
|
8
|
+
def relation_scope(*args, &block)
|
9
|
+
scope_for :active_record_relation, *args, &block
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
# Register relation scope matcher
|
16
|
+
ActionPolicy::Base.scope_matcher :active_record_relation, ActiveRecord::Relation
|
17
|
+
|
18
|
+
# Add alias to base policy
|
19
|
+
ActionPolicy::Base.extend ActionPolicy::ScopeMatchers::ActiveRecord
|
20
|
+
|
21
|
+
ActiveRecord::Relation.include(Module.new do
|
22
|
+
def policy_name
|
23
|
+
if model.respond_to?(:policy_name)
|
24
|
+
model.policy_name.to_s
|
25
|
+
else
|
26
|
+
"#{model}Policy"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end)
|
@@ -30,6 +30,10 @@ module ActionPolicy # :nodoc:
|
|
30
30
|
# Enabled only in production by default.
|
31
31
|
attr_accessor :namespace_cache_enabled
|
32
32
|
|
33
|
+
# Define whether to include instrumentation functionality.
|
34
|
+
# Enabled by default.
|
35
|
+
attr_accessor :instrumentation_enabled
|
36
|
+
|
33
37
|
def cache_store=(store)
|
34
38
|
# Handle both:
|
35
39
|
# store = :memory
|
@@ -46,13 +50,14 @@ module ActionPolicy # :nodoc:
|
|
46
50
|
self.controller_authorize_current_user = true
|
47
51
|
self.auto_inject_into_channel = true
|
48
52
|
self.channel_authorize_current_user = true
|
49
|
-
self.namespace_cache_enabled = Rails.env.production?
|
53
|
+
self.namespace_cache_enabled = ::Rails.env.production?
|
54
|
+
self.instrumentation_enabled = true
|
50
55
|
end
|
51
56
|
|
52
57
|
config.action_policy = Config
|
53
58
|
|
54
59
|
initializer "action_policy.clear_per_thread_cache" do |app|
|
55
|
-
if Rails::VERSION::MAJOR >= 5
|
60
|
+
if ::Rails::VERSION::MAJOR >= 5
|
56
61
|
app.executor.to_run { ActionPolicy::PerThreadCache.clear_all }
|
57
62
|
app.executor.to_complete { ActionPolicy::PerThreadCache.clear_all }
|
58
63
|
else
|
@@ -61,29 +66,46 @@ module ActionPolicy # :nodoc:
|
|
61
66
|
end
|
62
67
|
end
|
63
68
|
|
69
|
+
config.after_initialize do
|
70
|
+
next unless ::Rails.application.config.action_policy.instrumentation_enabled
|
71
|
+
|
72
|
+
require "action_policy/rails/policy/instrumentation"
|
73
|
+
require "action_policy/rails/authorizer"
|
74
|
+
|
75
|
+
ActionPolicy::Base.prepend ActionPolicy::Policy::Rails::Instrumentation
|
76
|
+
ActionPolicy::Authorizer.singleton_class.prepend ActionPolicy::Rails::Authorizer
|
77
|
+
end
|
78
|
+
|
64
79
|
config.to_prepare do |_app|
|
65
80
|
ActionPolicy::LookupChain.namespace_cache_enabled =
|
66
|
-
Rails.application.config.action_policy.namespace_cache_enabled
|
81
|
+
::Rails.application.config.action_policy.namespace_cache_enabled
|
67
82
|
|
68
83
|
ActiveSupport.on_load(:action_controller) do
|
69
|
-
|
84
|
+
require "action_policy/rails/scope_matchers/action_controller_params"
|
85
|
+
|
86
|
+
next unless ::Rails.application.config.action_policy.auto_inject_into_controller
|
70
87
|
|
71
88
|
ActionController::Base.include ActionPolicy::Controller
|
72
89
|
|
73
|
-
next unless Rails.application.config.action_policy.controller_authorize_current_user
|
90
|
+
next unless ::Rails.application.config.action_policy.controller_authorize_current_user
|
74
91
|
|
75
92
|
ActionController::Base.authorize :user, through: :current_user
|
76
93
|
end
|
77
94
|
|
78
95
|
ActiveSupport.on_load(:action_cable) do
|
79
|
-
next unless Rails.application.config.action_policy.auto_inject_into_channel
|
96
|
+
next unless ::Rails.application.config.action_policy.auto_inject_into_channel
|
80
97
|
|
81
98
|
ActionCable::Channel::Base.include ActionPolicy::Channel
|
82
99
|
|
83
|
-
next unless Rails.application.config.action_policy.channel_authorize_current_user
|
100
|
+
next unless ::Rails.application.config.action_policy.channel_authorize_current_user
|
84
101
|
|
85
102
|
ActionCable::Channel::Base.authorize :user, through: :current_user
|
86
103
|
end
|
104
|
+
|
105
|
+
ActiveSupport.on_load(:active_record) do
|
106
|
+
require "action_policy/rails/ext/active_record"
|
107
|
+
require "action_policy/rails/scope_matchers/active_record"
|
108
|
+
end
|
87
109
|
end
|
88
110
|
end
|
89
111
|
end
|
data/lib/action_policy/rspec.rb
CHANGED
@@ -39,7 +39,7 @@ module ActionPolicy
|
|
39
39
|
|
40
40
|
begin
|
41
41
|
ActionPolicy::Testing::AuthorizeTracker.tracking { actual.call }
|
42
|
-
rescue ActionPolicy::Unauthorized
|
42
|
+
rescue ActionPolicy::Unauthorized
|
43
43
|
# we don't want to care about authorization result
|
44
44
|
end
|
45
45
|
|
@@ -0,0 +1,103 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionPolicy
|
4
|
+
module RSpec # :nodoc: all
|
5
|
+
module DSL
|
6
|
+
%w[describe fdescribe xdescribe].each do |meth|
|
7
|
+
class_eval <<~CODE, __FILE__, __LINE__ + 1
|
8
|
+
def #{meth}_rule(rule, *args, &block)
|
9
|
+
find_and_eval_shared("context", "action_policy:policy_rule_context", caller.first, rule, *args, method: :#{meth}, block: block)
|
10
|
+
end
|
11
|
+
CODE
|
12
|
+
end
|
13
|
+
|
14
|
+
["", "f", "x"].each do |prefix|
|
15
|
+
class_eval <<~CODE, __FILE__, __LINE__ + 1
|
16
|
+
def #{prefix}succeed(msg = "succeeds", *args, **kwargs)
|
17
|
+
the_caller = caller
|
18
|
+
#{prefix}context(msg, *args, **kwargs) do
|
19
|
+
instance_eval(&Proc.new) if block_given?
|
20
|
+
find_and_eval_shared("examples", "action_policy:policy_rule_example", the_caller.first, true, the_caller)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def #{prefix}failed(msg = "fails", *args, **kwargs)
|
25
|
+
the_caller = caller
|
26
|
+
#{prefix}context(msg, *args, **kwargs) do
|
27
|
+
instance_eval(&Proc.new) if block_given?
|
28
|
+
find_and_eval_shared("examples", "action_policy:policy_rule_example", the_caller.first, false, the_caller)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
CODE
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
module PolicyExampleGroup
|
36
|
+
def self.included(base)
|
37
|
+
base.metadata[:type] = :policy
|
38
|
+
base.extend ActionPolicy::RSpec::DSL
|
39
|
+
super
|
40
|
+
end
|
41
|
+
|
42
|
+
def formatted_policy(policy)
|
43
|
+
"#{policy.result.inspect}\n#{policy.inspect_rule(policy.result.rule)}"
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
if defined?(::RSpec)
|
50
|
+
::RSpec.shared_context "action_policy:policy_context" do
|
51
|
+
let(:record) { nil }
|
52
|
+
let(:context) { {} }
|
53
|
+
let(:policy) { described_class.new(record, context) }
|
54
|
+
end
|
55
|
+
|
56
|
+
::RSpec.shared_context "action_policy:policy_rule_context" do |policy_rule, *args, method: "describe", block: nil|
|
57
|
+
public_send(method, policy_rule.to_s, *args) do
|
58
|
+
let(:rule) { policy_rule }
|
59
|
+
|
60
|
+
let(:subject) do
|
61
|
+
policy.apply(rule)
|
62
|
+
policy.result
|
63
|
+
end
|
64
|
+
|
65
|
+
instance_eval(&block) if block
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
::RSpec.shared_examples_for "action_policy:policy_rule_example" do |success, the_caller|
|
70
|
+
if success
|
71
|
+
specify do
|
72
|
+
next if subject.success?
|
73
|
+
raise(
|
74
|
+
RSpec::Expectations::ExpectationNotMetError,
|
75
|
+
"Expected to succeed but failed:\n#{formatted_policy(policy)}",
|
76
|
+
the_caller
|
77
|
+
)
|
78
|
+
end
|
79
|
+
else
|
80
|
+
specify do
|
81
|
+
next if subject.fail?
|
82
|
+
raise(
|
83
|
+
RSpec::Expectations::ExpectationNotMetError,
|
84
|
+
"Expected to fail but succeed:\n#{formatted_policy(policy)}",
|
85
|
+
the_caller
|
86
|
+
)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
::RSpec.configure do |config|
|
92
|
+
config.include(
|
93
|
+
ActionPolicy::RSpec::PolicyExampleGroup,
|
94
|
+
type: :policy,
|
95
|
+
file_path: %r{spec/policies}
|
96
|
+
)
|
97
|
+
config.include_context(
|
98
|
+
"action_policy:policy_context",
|
99
|
+
type: :policy,
|
100
|
+
file_path: %r{spec/policies}
|
101
|
+
)
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,126 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "action_policy/testing"
|
4
|
+
|
5
|
+
module ActionPolicy
|
6
|
+
module RSpec
|
7
|
+
# Implements `have_authorized_scope` matcher.
|
8
|
+
#
|
9
|
+
# Verifies that a block of code applies authorization scoping using specific policy.
|
10
|
+
#
|
11
|
+
# Example:
|
12
|
+
#
|
13
|
+
# # in controller/request specs
|
14
|
+
# subject { get :index }
|
15
|
+
#
|
16
|
+
# it "has authorized scope" do
|
17
|
+
# expect { subject }
|
18
|
+
# .to have_authorized_scope(:active_record_relation)
|
19
|
+
# .with(ProductPolicy)
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
class HaveAuthorizedScope < ::RSpec::Matchers::BuiltIn::BaseMatcher
|
23
|
+
attr_reader :type, :name, :policy, :scope_options, :actual_scopes,
|
24
|
+
:target_expectations
|
25
|
+
|
26
|
+
def initialize(type)
|
27
|
+
@type = type
|
28
|
+
@name = :default
|
29
|
+
@scope_options = nil
|
30
|
+
end
|
31
|
+
|
32
|
+
def with(policy)
|
33
|
+
@policy = policy
|
34
|
+
self
|
35
|
+
end
|
36
|
+
|
37
|
+
def as(name)
|
38
|
+
@name = name
|
39
|
+
self
|
40
|
+
end
|
41
|
+
|
42
|
+
def with_scope_options(scope_options)
|
43
|
+
@scope_options = scope_options
|
44
|
+
self
|
45
|
+
end
|
46
|
+
|
47
|
+
def with_target
|
48
|
+
@target_expectations = Proc.new
|
49
|
+
self
|
50
|
+
end
|
51
|
+
|
52
|
+
def match(_expected, actual)
|
53
|
+
raise "This matcher only supports block expectations" unless actual.is_a?(Proc)
|
54
|
+
|
55
|
+
ActionPolicy::Testing::AuthorizeTracker.tracking { actual.call }
|
56
|
+
|
57
|
+
@actual_scopes = ActionPolicy::Testing::AuthorizeTracker.scopings
|
58
|
+
|
59
|
+
matching_scopes = actual_scopes.select { |scope| scope.matches?(policy, type, name, scope_options) }
|
60
|
+
|
61
|
+
return false if matching_scopes.empty?
|
62
|
+
|
63
|
+
return true unless target_expectations
|
64
|
+
|
65
|
+
if matching_scopes.size > 1
|
66
|
+
raise "Too many matching scopings (#{matching_scopes.size}), " \
|
67
|
+
"you can run `.with_target` only when there is the only one match"
|
68
|
+
end
|
69
|
+
|
70
|
+
target_expectations.call(matching_scopes.first.target)
|
71
|
+
true
|
72
|
+
end
|
73
|
+
|
74
|
+
def does_not_match?(*)
|
75
|
+
raise "This matcher doesn't support negation"
|
76
|
+
end
|
77
|
+
|
78
|
+
def supports_block_expectations?
|
79
|
+
true
|
80
|
+
end
|
81
|
+
|
82
|
+
def failure_message
|
83
|
+
"expected a scoping named :#{name} for type :#{type} " \
|
84
|
+
"#{scope_options_message} " \
|
85
|
+
"from #{policy} to have been applied, " \
|
86
|
+
"but #{actual_scopes_message}"
|
87
|
+
end
|
88
|
+
|
89
|
+
def scope_options_message
|
90
|
+
if scope_options
|
91
|
+
if defined?(::RSpec::Matchers::Composable) &&
|
92
|
+
scope_options.is_a?(::RSpec::Matchers::Composable)
|
93
|
+
"with scope options #{scope_options.description}"
|
94
|
+
else
|
95
|
+
"with scope options #{scope_options}"
|
96
|
+
end
|
97
|
+
else
|
98
|
+
"without scope options"
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def actual_scopes_message
|
103
|
+
if actual_scopes.empty?
|
104
|
+
"no scopings have been made"
|
105
|
+
else
|
106
|
+
"the following scopings were encountered:\n" \
|
107
|
+
"#{formatted_scopings}"
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def formatted_scopings
|
112
|
+
actual_scopes.map do |ascope|
|
113
|
+
" - #{ascope.inspect}"
|
114
|
+
end.join("\n")
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
RSpec.configure do |config|
|
121
|
+
config.include(Module.new do
|
122
|
+
def have_authorized_scope(type)
|
123
|
+
ActionPolicy::RSpec::HaveAuthorizedScope.new(type)
|
124
|
+
end
|
125
|
+
end)
|
126
|
+
end
|
@@ -5,6 +5,22 @@ require "action_policy/testing"
|
|
5
5
|
module ActionPolicy
|
6
6
|
# Provides assertions for policies usage
|
7
7
|
module TestHelper
|
8
|
+
class WithScopeTarget
|
9
|
+
attr_reader :scopes
|
10
|
+
|
11
|
+
def initialize(scopes)
|
12
|
+
@scopes = scopes
|
13
|
+
end
|
14
|
+
|
15
|
+
def with_target
|
16
|
+
if scopes.size > 1
|
17
|
+
raise "Too many matching scopings (#{scopes.size}), " \
|
18
|
+
"you can run `.with_target` only when there is the only one match"
|
19
|
+
end
|
20
|
+
|
21
|
+
yield scopes.first.target
|
22
|
+
end
|
23
|
+
end
|
8
24
|
# Asserts that the given policy was used to authorize the given target.
|
9
25
|
#
|
10
26
|
# def test_authorize
|
@@ -19,7 +35,6 @@ module ActionPolicy
|
|
19
35
|
# get :show, id: user.id
|
20
36
|
# end
|
21
37
|
#
|
22
|
-
# rubocop: disable Metrics/MethodLength
|
23
38
|
def assert_authorized_to(rule, target, with: nil)
|
24
39
|
raise ArgumentError, "Block is required" unless block_given?
|
25
40
|
|
@@ -27,7 +42,7 @@ module ActionPolicy
|
|
27
42
|
|
28
43
|
begin
|
29
44
|
ActionPolicy::Testing::AuthorizeTracker.tracking { yield }
|
30
|
-
rescue ActionPolicy::Unauthorized
|
45
|
+
rescue ActionPolicy::Unauthorized
|
31
46
|
# we don't want to care about authorization result
|
32
47
|
end
|
33
48
|
|
@@ -38,9 +53,59 @@ module ActionPolicy
|
|
38
53
|
"Expected #{target.inspect} to be authorized with #{policy}##{rule}, " \
|
39
54
|
"but no such authorization has been made.\n" \
|
40
55
|
"Registered authorizations: " \
|
41
|
-
"#{actual_calls.empty? ?
|
56
|
+
"#{actual_calls.empty? ? "none" : actual_calls.map(&:inspect).join(",")}"
|
57
|
+
)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Asserts that the given policy was used for scoping.
|
61
|
+
#
|
62
|
+
# def test_authorize
|
63
|
+
# assert_have_authorized_scope(type: :active_record_relation, with: UserPolicy) do
|
64
|
+
# get :index
|
65
|
+
# end
|
66
|
+
# end
|
67
|
+
#
|
68
|
+
# You can also specify `as` option.
|
69
|
+
#
|
70
|
+
# NOTE: `type` and `with` must be specified.
|
71
|
+
#
|
72
|
+
# You can run additional assertions for the matching target (the object passed
|
73
|
+
# to the `authorized_scope` method) by calling `with_target`:
|
74
|
+
#
|
75
|
+
# def test_authorize
|
76
|
+
# assert_have_authorized_scope(type: :active_record_relation, with: UserPolicy) do
|
77
|
+
# get :index
|
78
|
+
# end.with_target do |target|
|
79
|
+
# assert_equal User.all, target
|
80
|
+
# end
|
81
|
+
# end
|
82
|
+
#
|
83
|
+
def assert_have_authorized_scope(type:, with:, as: :default, scope_options: nil)
|
84
|
+
raise ArgumentError, "Block is required" unless block_given?
|
85
|
+
|
86
|
+
policy = with
|
87
|
+
|
88
|
+
ActionPolicy::Testing::AuthorizeTracker.tracking { yield }
|
89
|
+
|
90
|
+
actual_scopes = ActionPolicy::Testing::AuthorizeTracker.scopings
|
91
|
+
|
92
|
+
scope_options_message = if scope_options
|
93
|
+
"with scope options #{scope_options}"
|
94
|
+
else
|
95
|
+
"without scope options"
|
96
|
+
end
|
97
|
+
|
98
|
+
assert(
|
99
|
+
actual_scopes.any? { |scope| scope.matches?(policy, type, as, scope_options) },
|
100
|
+
"Expected a scoping named :#{as} for :#{type} type " \
|
101
|
+
"#{scope_options_message} " \
|
102
|
+
"from #{policy} to have been applied, " \
|
103
|
+
"but no such scoping has been made.\n" \
|
104
|
+
"Registered scopings: " \
|
105
|
+
"#{actual_scopes.empty? ? "none" : actual_scopes.map(&:inspect).join(",")}"
|
42
106
|
)
|
107
|
+
|
108
|
+
WithScopeTarget.new(actual_scopes)
|
43
109
|
end
|
44
|
-
# rubocop: enable Metrics/MethodLength
|
45
110
|
end
|
46
111
|
end
|