action_policy 0.4.1 → 0.5.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (125) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +230 -172
  3. data/LICENSE.txt +1 -1
  4. data/README.md +7 -11
  5. data/lib/.rbnext/2.7/action_policy/behaviours/policy_for.rb +62 -0
  6. data/lib/.rbnext/2.7/action_policy/i18n.rb +56 -0
  7. data/lib/.rbnext/2.7/action_policy/policy/cache.rb +101 -0
  8. data/lib/.rbnext/2.7/action_policy/policy/pre_check.rb +162 -0
  9. data/lib/.rbnext/2.7/action_policy/rspec/be_authorized_to.rb +89 -0
  10. data/lib/.rbnext/2.7/action_policy/rspec/have_authorized_scope.rb +124 -0
  11. data/lib/.rbnext/2.7/action_policy/utils/pretty_print.rb +159 -0
  12. data/lib/.rbnext/3.0/action_policy/behaviour.rb +115 -0
  13. data/lib/.rbnext/3.0/action_policy/behaviours/policy_for.rb +62 -0
  14. data/lib/.rbnext/3.0/action_policy/behaviours/scoping.rb +35 -0
  15. data/lib/.rbnext/3.0/action_policy/behaviours/thread_memoized.rb +59 -0
  16. data/lib/.rbnext/3.0/action_policy/ext/policy_cache_key.rb +72 -0
  17. data/lib/.rbnext/3.0/action_policy/policy/aliases.rb +69 -0
  18. data/lib/.rbnext/3.0/action_policy/policy/authorization.rb +87 -0
  19. data/lib/.rbnext/3.0/action_policy/policy/cache.rb +101 -0
  20. data/lib/.rbnext/3.0/action_policy/policy/core.rb +161 -0
  21. data/lib/.rbnext/3.0/action_policy/policy/defaults.rb +31 -0
  22. data/lib/.rbnext/3.0/action_policy/policy/execution_result.rb +37 -0
  23. data/lib/.rbnext/3.0/action_policy/policy/pre_check.rb +162 -0
  24. data/lib/.rbnext/3.0/action_policy/policy/reasons.rb +210 -0
  25. data/lib/.rbnext/3.0/action_policy/policy/scoping.rb +160 -0
  26. data/lib/.rbnext/3.0/action_policy/rspec/be_authorized_to.rb +89 -0
  27. data/lib/.rbnext/3.0/action_policy/rspec/have_authorized_scope.rb +124 -0
  28. data/lib/.rbnext/3.0/action_policy/utils/pretty_print.rb +159 -0
  29. data/lib/.rbnext/3.0/action_policy/utils/suggest_message.rb +19 -0
  30. data/lib/action_policy.rb +7 -1
  31. data/lib/action_policy/behaviour.rb +22 -16
  32. data/lib/action_policy/behaviours/policy_for.rb +10 -3
  33. data/lib/action_policy/behaviours/scoping.rb +2 -1
  34. data/lib/action_policy/behaviours/thread_memoized.rb +1 -3
  35. data/lib/action_policy/ext/module_namespace.rb +1 -6
  36. data/lib/action_policy/ext/policy_cache_key.rb +15 -33
  37. data/lib/action_policy/ext/{symbol_classify.rb → symbol_camelize.rb} +6 -6
  38. data/lib/action_policy/i18n.rb +1 -1
  39. data/lib/action_policy/lookup_chain.rb +41 -21
  40. data/lib/action_policy/policy/aliases.rb +7 -12
  41. data/lib/action_policy/policy/authorization.rb +14 -17
  42. data/lib/action_policy/policy/cache.rb +34 -18
  43. data/lib/action_policy/policy/core.rb +25 -12
  44. data/lib/action_policy/policy/defaults.rb +3 -9
  45. data/lib/action_policy/policy/execution_result.rb +3 -9
  46. data/lib/action_policy/policy/pre_check.rb +19 -58
  47. data/lib/action_policy/policy/reasons.rb +30 -20
  48. data/lib/action_policy/policy/scoping.rb +5 -6
  49. data/lib/action_policy/rails/controller.rb +6 -1
  50. data/lib/action_policy/rails/ext/active_record.rb +7 -0
  51. data/lib/action_policy/rails/policy/instrumentation.rb +1 -1
  52. data/lib/action_policy/rspec/be_authorized_to.rb +5 -9
  53. data/lib/action_policy/rspec/dsl.rb +3 -3
  54. data/lib/action_policy/rspec/have_authorized_scope.rb +5 -7
  55. data/lib/action_policy/testing.rb +1 -12
  56. data/lib/action_policy/utils/pretty_print.rb +21 -24
  57. data/lib/action_policy/utils/suggest_message.rb +1 -3
  58. data/lib/action_policy/version.rb +1 -1
  59. data/lib/generators/action_policy/install/templates/{application_policy.rb → application_policy.rb.tt} +1 -1
  60. data/lib/generators/action_policy/policy/policy_generator.rb +4 -1
  61. data/lib/generators/action_policy/policy/templates/{policy.rb → policy.rb.tt} +0 -0
  62. data/lib/generators/rspec/templates/{policy_spec.rb → policy_spec.rb.tt} +0 -0
  63. data/lib/generators/test_unit/templates/{policy_test.rb → policy_test.rb.tt} +0 -0
  64. metadata +55 -119
  65. data/.gitattributes +0 -2
  66. data/.github/FUNDING.yml +0 -1
  67. data/.github/ISSUE_TEMPLATE.md +0 -18
  68. data/.github/PULL_REQUEST_TEMPLATE.md +0 -29
  69. data/.gitignore +0 -15
  70. data/.rubocop.yml +0 -54
  71. data/.tidelift.yml +0 -6
  72. data/.travis.yml +0 -31
  73. data/Gemfile +0 -22
  74. data/Rakefile +0 -27
  75. data/action_policy.gemspec +0 -44
  76. data/benchmarks/namespaced_lookup_cache.rb +0 -71
  77. data/bin/console +0 -14
  78. data/bin/setup +0 -8
  79. data/docs/.nojekyll +0 -0
  80. data/docs/CNAME +0 -1
  81. data/docs/README.md +0 -77
  82. data/docs/_sidebar.md +0 -27
  83. data/docs/aliases.md +0 -122
  84. data/docs/assets/docsify-search.js +0 -364
  85. data/docs/assets/docsify.min.js +0 -3
  86. data/docs/assets/fonts/FiraCode-Medium.woff +0 -0
  87. data/docs/assets/fonts/FiraCode-Regular.woff +0 -0
  88. data/docs/assets/images/banner.png +0 -0
  89. data/docs/assets/images/cache.png +0 -0
  90. data/docs/assets/images/cache.svg +0 -70
  91. data/docs/assets/images/layer.png +0 -0
  92. data/docs/assets/images/layer.svg +0 -35
  93. data/docs/assets/prism-ruby.min.js +0 -1
  94. data/docs/assets/styles.css +0 -347
  95. data/docs/assets/vue.min.css +0 -1
  96. data/docs/authorization_context.md +0 -92
  97. data/docs/behaviour.md +0 -113
  98. data/docs/caching.md +0 -273
  99. data/docs/controller_action_aliases.md +0 -109
  100. data/docs/custom_lookup_chain.md +0 -48
  101. data/docs/custom_policy.md +0 -53
  102. data/docs/debugging.md +0 -55
  103. data/docs/decorators.md +0 -27
  104. data/docs/favicon.ico +0 -0
  105. data/docs/graphql.md +0 -302
  106. data/docs/i18n.md +0 -44
  107. data/docs/index.html +0 -43
  108. data/docs/instrumentation.md +0 -84
  109. data/docs/lookup_chain.md +0 -17
  110. data/docs/namespaces.md +0 -77
  111. data/docs/non_rails.md +0 -28
  112. data/docs/pre_checks.md +0 -57
  113. data/docs/pundit_migration.md +0 -80
  114. data/docs/quick_start.md +0 -118
  115. data/docs/rails.md +0 -120
  116. data/docs/reasons.md +0 -120
  117. data/docs/scoping.md +0 -255
  118. data/docs/testing.md +0 -333
  119. data/docs/writing_policies.md +0 -107
  120. data/gemfiles/jruby.gemfile +0 -8
  121. data/gemfiles/rails42.gemfile +0 -8
  122. data/gemfiles/rails6.gemfile +0 -8
  123. data/gemfiles/railsmaster.gemfile +0 -6
  124. data/lib/action_policy/ext/string_match.rb +0 -14
  125. data/lib/action_policy/ext/yield_self_then.rb +0 -25
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_policy/testing"
4
+
5
+ module ActionPolicy
6
+ module RSpec
7
+ # Authorization matcher `be_authorized_to`.
8
+ #
9
+ # Verifies that a block of code has been authorized using specific policy.
10
+ #
11
+ # Example:
12
+ #
13
+ # # in controller/request specs
14
+ # subject { patch :update, id: product.id }
15
+ #
16
+ # it "is authorized" do
17
+ # expect { subject }
18
+ # .to be_authorized_to(:manage?, product)
19
+ # .with(ProductPolicy)
20
+ # end
21
+ #
22
+ class BeAuthorizedTo < ::RSpec::Matchers::BuiltIn::BaseMatcher
23
+ attr_reader :rule, :target, :policy, :actual_calls
24
+
25
+ def initialize(rule, target)
26
+ @rule = rule
27
+ @target = target
28
+ end
29
+
30
+ def with(policy)
31
+ @policy = policy
32
+ self
33
+ end
34
+
35
+ def match(_expected, actual)
36
+ raise "This matcher only supports block expectations" unless actual.is_a?(Proc)
37
+
38
+ @policy ||= ::ActionPolicy.lookup(target)
39
+
40
+ begin
41
+ ActionPolicy::Testing::AuthorizeTracker.tracking { actual.call }
42
+ rescue ActionPolicy::Unauthorized
43
+ # we don't want to care about authorization result
44
+ end
45
+
46
+ @actual_calls = ActionPolicy::Testing::AuthorizeTracker.calls
47
+
48
+ actual_calls.any? { _1.matches?(policy, rule, target) }
49
+ end
50
+
51
+ def does_not_match?(*)
52
+ raise "This matcher doesn't support negation"
53
+ end
54
+
55
+ def supports_block_expectations?() ; true; end
56
+
57
+ def failure_message
58
+ "expected #{formatted_record} " \
59
+ "to be authorized with #{policy}##{rule}, " \
60
+ "but #{actual_calls_message}"
61
+ end
62
+
63
+ def actual_calls_message
64
+ if actual_calls.empty?
65
+ "no authorization calls have been made"
66
+ else
67
+ "the following calls were encountered:\n" \
68
+ "#{formatted_calls}"
69
+ end
70
+ end
71
+
72
+ def formatted_calls
73
+ actual_calls.map do
74
+ " - #{_1.inspect}"
75
+ end.join("\n")
76
+ end
77
+
78
+ def formatted_record(record = target) ; ::RSpec::Support::ObjectFormatter.format(record); end
79
+ end
80
+ end
81
+ end
82
+
83
+ RSpec.configure do |config|
84
+ config.include(Module.new do
85
+ def be_authorized_to(rule, target)
86
+ ActionPolicy::RSpec::BeAuthorizedTo.new(rule, target)
87
+ end
88
+ end)
89
+ end
@@ -0,0 +1,124 @@
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(&block)
48
+ @target_expectations = block
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 { _1.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?() ; true; end
79
+
80
+ def failure_message
81
+ "expected a scoping named :#{name} for type :#{type} " \
82
+ "#{scope_options_message} " \
83
+ "from #{policy} to have been applied, " \
84
+ "but #{actual_scopes_message}"
85
+ end
86
+
87
+ def scope_options_message
88
+ if scope_options
89
+ if defined?(::RSpec::Matchers::Composable) &&
90
+ scope_options.is_a?(::RSpec::Matchers::Composable)
91
+ "with scope options #{scope_options.description}"
92
+ else
93
+ "with scope options #{scope_options}"
94
+ end
95
+ else
96
+ "without scope options"
97
+ end
98
+ end
99
+
100
+ def actual_scopes_message
101
+ if actual_scopes.empty?
102
+ "no scopings have been made"
103
+ else
104
+ "the following scopings were encountered:\n" \
105
+ "#{formatted_scopings}"
106
+ end
107
+ end
108
+
109
+ def formatted_scopings
110
+ actual_scopes.map do
111
+ " - #{_1.inspect}"
112
+ end.join("\n")
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ RSpec.configure do |config|
119
+ config.include(Module.new do
120
+ def have_authorized_scope(type)
121
+ ActionPolicy::RSpec::HaveAuthorizedScope.new(type)
122
+ end
123
+ end)
124
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ old_verbose = $VERBOSE
4
+
5
+ begin
6
+ require "method_source"
7
+ # Ignore parse warnings when patch
8
+ # Ruby version mismatches
9
+ $VERBOSE = nil
10
+ require "parser/current"
11
+ require "unparser"
12
+ rescue LoadError
13
+ # do nothing
14
+ ensure
15
+ $VERBOSE = old_verbose
16
+ end
17
+
18
+ module ActionPolicy
19
+ using RubyNext
20
+
21
+ # Takes the object and a method name,
22
+ # and returns the "annotated" source code for the method:
23
+ # code is split into parts by logical operators and each
24
+ # part is evaluated separately.
25
+ #
26
+ # Example:
27
+ #
28
+ # class MyClass
29
+ # def access?
30
+ # admin? && access_feed?
31
+ # end
32
+ # end
33
+ #
34
+ # puts PrettyPrint.format_method(MyClass.new, :access?)
35
+ #
36
+ # #=> MyClass#access?
37
+ # #=> ↳ admin? #=> false
38
+ # #=> AND
39
+ # #=> access_feed? #=> true
40
+ module PrettyPrint
41
+ TRUE = "\e[32mtrue\e[0m"
42
+ FALSE = "\e[31mfalse\e[0m"
43
+
44
+ class Visitor
45
+ attr_reader :lines, :object
46
+ attr_accessor :indent
47
+
48
+ def initialize(object)
49
+ @object = object
50
+ end
51
+
52
+ def collect(ast)
53
+ @lines = []
54
+ @indent = 0
55
+
56
+ visit_node(ast)
57
+
58
+ lines.join("\n")
59
+ end
60
+
61
+ def visit_node(ast)
62
+ if respond_to?("visit_#{ast.type}")
63
+ send("visit_#{ast.type}", ast)
64
+ else
65
+ visit_missing ast
66
+ end
67
+ end
68
+
69
+ def expression_with_result(sexp)
70
+ expression = Unparser.unparse(sexp)
71
+ "#{expression} #=> #{PrettyPrint.colorize(eval_exp(expression))}"
72
+ end
73
+
74
+ def eval_exp(exp)
75
+ return "<skipped>" if ignore_exp?(exp)
76
+ object.instance_eval(exp)
77
+ rescue => e
78
+ "Failed: #{e.message}"
79
+ end
80
+
81
+ def visit_and(ast)
82
+ visit_node(ast.children[0])
83
+ lines << indented("AND")
84
+ visit_node(ast.children[1])
85
+ end
86
+
87
+ def visit_or(ast)
88
+ visit_node(ast.children[0])
89
+ lines << indented("OR")
90
+ visit_node(ast.children[1])
91
+ end
92
+
93
+ def visit_begin(ast)
94
+ # Parens
95
+ if ast.children.size == 1
96
+ lines << indented("(")
97
+ self.indent += 2
98
+ visit_node(ast.children[0])
99
+ self.indent -= 2
100
+ lines << indented(")")
101
+ else
102
+ # Multiple expressions
103
+ ast.children.each do |node|
104
+ visit_node(node)
105
+ # restore indent after each expression
106
+ self.indent -= 2
107
+ end
108
+ end
109
+ end
110
+
111
+ def visit_missing(ast)
112
+ lines << indented(expression_with_result(ast))
113
+ end
114
+
115
+ def indented(str)
116
+ "#{indent.zero? ? "↳ " : ""}#{" " * indent}#{str}".tap do
117
+ # increase indent after the first expression
118
+ self.indent += 2 if indent.zero?
119
+ end
120
+ end
121
+
122
+ # Some lines should not be evaled
123
+ def ignore_exp?(exp)
124
+ PrettyPrint.ignore_expressions.any? { exp.match?(_1) }
125
+ end
126
+ end
127
+
128
+ class << self
129
+ attr_accessor :ignore_expressions
130
+
131
+ if defined?(::Unparser) && defined?(::MethodSource)
132
+ def available?() ; true; end
133
+
134
+ def print_method(object, method_name)
135
+ ast = object.method(method_name).source.then(&Unparser.method(:parse))
136
+ # outer node is a method definition itself
137
+ body = ast.children[2]
138
+
139
+ Visitor.new(object).collect(body)
140
+ end
141
+ else
142
+ def available?() ; false; end
143
+
144
+ def print_method(_, _) ; ""; end
145
+ end
146
+
147
+ def colorize(val)
148
+ return val unless $stdout.isatty
149
+ return TRUE if val.eql?(true)
150
+ return FALSE if val.eql?(false)
151
+ val
152
+ end
153
+ end
154
+
155
+ self.ignore_expressions = [
156
+ /^\s*binding\.(pry|irb)\s*$/s
157
+ ]
158
+ end
159
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPolicy
4
+ # Adds `suggest` method which uses did_you_mean
5
+ # to generate a suggestion message
6
+ module SuggestMessage
7
+ if defined?(::DidYouMean::SpellChecker)
8
+ def suggest(needle, heystack)
9
+ suggestion = ::DidYouMean::SpellChecker.new(
10
+ dictionary: heystack
11
+ ).correct(needle).first
12
+
13
+ suggestion ? "\nDid you mean? #{suggestion}" : ""
14
+ end
15
+ else
16
+ def suggest(*) ; ""; end
17
+ end
18
+ end
19
+ end
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ruby-next"
4
+
5
+ require "ruby-next/language/setup"
6
+ RubyNext::Language.setup_gem_load_path(transpile: true)
7
+
3
8
  # ActionPolicy is an authorization framework for Ruby/Rails applications.
4
9
  #
5
10
  # It provides a way to write access policies and helpers to check these policies
@@ -30,8 +35,9 @@ module ActionPolicy
30
35
  attr_accessor :cache_store
31
36
 
32
37
  # Find a policy class for a target
33
- def lookup(target, allow_nil: false, **options)
38
+ def lookup(target, allow_nil: false, default: nil, **options)
34
39
  LookupChain.call(target, **options) ||
40
+ default ||
35
41
  (allow_nil ? nil : raise(NotFound, target))
36
42
  end
37
43
  end
@@ -35,12 +35,7 @@ module ActionPolicy
35
35
  #
36
36
  # Raises `ActionPolicy::Unauthorized` if check failed.
37
37
  def authorize!(record = :__undef__, to:, **options)
38
- record = implicit_authorization_target! if record == :__undef__
39
- raise ArgumentError, "Record must be specified" if record.nil?
40
-
41
- options[:context] && (options[:context] = authorization_context.merge(options[:context]))
42
-
43
- policy = policy_for(record: record, **options)
38
+ policy = lookup_authorization_policy(record, **options)
44
39
 
45
40
  Authorizer.call(policy, authorization_rule_for(policy, to))
46
41
  end
@@ -49,14 +44,17 @@ module ActionPolicy
49
44
  #
50
45
  # Returns true of false.
51
46
  def allowed_to?(rule, record = :__undef__, **options)
52
- record = implicit_authorization_target! if record == :__undef__
53
- raise ArgumentError, "Record must be specified" if record.nil?
47
+ policy = lookup_authorization_policy(record, **options)
54
48
 
55
- options[:context] && (options[:context] = authorization_context.merge(options[:context]))
49
+ policy.apply(authorization_rule_for(policy, rule))
50
+ end
56
51
 
57
- policy = policy_for(record: record, **options)
52
+ # Returns the authorization result object after applying a specified rule to a record.
53
+ def allowance_to(rule, record = :__undef__, **options)
54
+ policy = lookup_authorization_policy(record, **options)
58
55
 
59
56
  policy.apply(authorization_rule_for(policy, rule))
57
+ policy.result
60
58
  end
61
59
 
62
60
  def authorization_context
@@ -75,6 +73,15 @@ module ActionPolicy
75
73
  policy.resolve_rule(rule)
76
74
  end
77
75
 
76
+ def lookup_authorization_policy(record, **options) # :nodoc:
77
+ record = implicit_authorization_target! if record == :__undef__
78
+ raise ArgumentError, "Record must be specified" if record.nil?
79
+
80
+ options[:context] && (options[:context] = authorization_context.merge(options[:context]))
81
+
82
+ policy_for(record: record, **options)
83
+ end
84
+
78
85
  module ClassMethods # :nodoc:
79
86
  # Configure authorization context.
80
87
  #
@@ -97,12 +104,11 @@ module ActionPolicy
97
104
  def authorization_targets
98
105
  return @authorization_targets if instance_variable_defined?(:@authorization_targets)
99
106
 
100
- @authorization_targets =
101
- if superclass.respond_to?(:authorization_targets)
102
- superclass.authorization_targets.dup
103
- else
104
- {}
105
- end
107
+ if superclass.respond_to?(:authorization_targets)
108
+ superclass.authorization_targets.dup
109
+ else
110
+ {}
111
+ end => @authorization_targets
106
112
  end
107
113
  end
108
114
  end