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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +26 -64
  3. data/.travis.yml +13 -10
  4. data/CHANGELOG.md +216 -1
  5. data/Gemfile +7 -0
  6. data/LICENSE.txt +1 -1
  7. data/Rakefile +10 -0
  8. data/action_policy.gemspec +5 -3
  9. data/benchmarks/namespaced_lookup_cache.rb +18 -22
  10. data/docs/README.md +3 -3
  11. data/docs/_sidebar.md +4 -0
  12. data/docs/aliases.md +9 -5
  13. data/docs/authorization_context.md +59 -1
  14. data/docs/behaviour.md +113 -0
  15. data/docs/caching.md +6 -4
  16. data/docs/custom_policy.md +1 -2
  17. data/docs/debugging.md +55 -0
  18. data/docs/decorators.md +27 -0
  19. data/docs/i18n.md +41 -2
  20. data/docs/instrumentation.md +70 -2
  21. data/docs/lookup_chain.md +5 -4
  22. data/docs/namespaces.md +1 -1
  23. data/docs/non_rails.md +2 -3
  24. data/docs/pundit_migration.md +77 -2
  25. data/docs/quick_start.md +5 -5
  26. data/docs/rails.md +5 -2
  27. data/docs/reasons.md +50 -3
  28. data/docs/scoping.md +262 -0
  29. data/docs/testing.md +232 -21
  30. data/docs/writing_policies.md +1 -1
  31. data/gemfiles/jruby.gemfile +3 -0
  32. data/gemfiles/rails42.gemfile +3 -0
  33. data/gemfiles/rails6.gemfile +8 -0
  34. data/gemfiles/railsmaster.gemfile +1 -1
  35. data/lib/action_policy.rb +3 -3
  36. data/lib/action_policy/authorizer.rb +12 -4
  37. data/lib/action_policy/base.rb +2 -0
  38. data/lib/action_policy/behaviour.rb +14 -3
  39. data/lib/action_policy/behaviours/memoized.rb +1 -1
  40. data/lib/action_policy/behaviours/policy_for.rb +12 -3
  41. data/lib/action_policy/behaviours/scoping.rb +32 -0
  42. data/lib/action_policy/behaviours/thread_memoized.rb +1 -1
  43. data/lib/action_policy/ext/hash_transform_keys.rb +19 -0
  44. data/lib/action_policy/ext/module_namespace.rb +1 -1
  45. data/lib/action_policy/ext/policy_cache_key.rb +2 -1
  46. data/lib/action_policy/ext/proc_case_eq.rb +14 -0
  47. data/lib/action_policy/ext/string_constantize.rb +1 -0
  48. data/lib/action_policy/ext/symbol_classify.rb +22 -0
  49. data/lib/action_policy/i18n.rb +56 -0
  50. data/lib/action_policy/lookup_chain.rb +21 -3
  51. data/lib/action_policy/policy/cache.rb +10 -6
  52. data/lib/action_policy/policy/core.rb +31 -19
  53. data/lib/action_policy/policy/execution_result.rb +12 -0
  54. data/lib/action_policy/policy/pre_check.rb +2 -6
  55. data/lib/action_policy/policy/reasons.rb +99 -12
  56. data/lib/action_policy/policy/scoping.rb +165 -0
  57. data/lib/action_policy/rails/authorizer.rb +20 -0
  58. data/lib/action_policy/rails/controller.rb +4 -14
  59. data/lib/action_policy/rails/ext/active_record.rb +10 -0
  60. data/lib/action_policy/rails/policy/instrumentation.rb +24 -0
  61. data/lib/action_policy/rails/scope_matchers/action_controller_params.rb +19 -0
  62. data/lib/action_policy/rails/scope_matchers/active_record.rb +29 -0
  63. data/lib/action_policy/railtie.rb +29 -7
  64. data/lib/action_policy/rspec.rb +1 -0
  65. data/lib/action_policy/rspec/be_authorized_to.rb +1 -1
  66. data/lib/action_policy/rspec/dsl.rb +103 -0
  67. data/lib/action_policy/rspec/have_authorized_scope.rb +126 -0
  68. data/lib/action_policy/rspec/pundit_syntax.rb +1 -1
  69. data/lib/action_policy/test_helper.rb +69 -4
  70. data/lib/action_policy/testing.rb +54 -0
  71. data/lib/action_policy/utils/pretty_print.rb +137 -0
  72. data/lib/action_policy/utils/suggest_message.rb +21 -0
  73. data/lib/action_policy/version.rb +1 -1
  74. 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
- next unless Rails.application.config.action_policy.auto_inject_into_controller
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
@@ -1,3 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "action_policy/rspec/be_authorized_to"
4
+ require "action_policy/rspec/have_authorized_scope"
@@ -39,7 +39,7 @@ module ActionPolicy
39
39
 
40
40
  begin
41
41
  ActionPolicy::Testing::AuthorizeTracker.tracking { actual.call }
42
- rescue ActionPolicy::Unauthorized # rubocop: disable Lint/HandleExceptions
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
@@ -43,6 +43,6 @@ RSpec.configure do |config|
43
43
  config.include(
44
44
  ActionPolicy::RSpec::PunditSyntax::PolicyExampleGroup,
45
45
  type: :policy,
46
- example_group: { file_path: %r{spec/policies} }
46
+ example_group: {file_path: %r{spec/policies}}
47
47
  )
48
48
  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 # rubocop: disable Lint/HandleExceptions
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? ? 'none' : actual_calls.map(&:inspect).join(',')}"
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