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
@@ -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
|