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