action_policy 0.5.0 → 0.5.1
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/lib/.rbnext/2.7/action_policy/behaviours/policy_for.rb +62 -0
- data/lib/.rbnext/2.7/action_policy/i18n.rb +56 -0
- data/lib/.rbnext/2.7/action_policy/policy/cache.rb +101 -0
- data/lib/.rbnext/2.7/action_policy/policy/pre_check.rb +162 -0
- data/lib/.rbnext/2.7/action_policy/rspec/be_authorized_to.rb +89 -0
- data/lib/.rbnext/2.7/action_policy/rspec/have_authorized_scope.rb +124 -0
- data/lib/.rbnext/2.7/action_policy/utils/pretty_print.rb +159 -0
- data/lib/.rbnext/3.0/action_policy/behaviour.rb +115 -0
- data/lib/.rbnext/3.0/action_policy/behaviours/policy_for.rb +62 -0
- data/lib/.rbnext/3.0/action_policy/behaviours/scoping.rb +35 -0
- data/lib/.rbnext/3.0/action_policy/behaviours/thread_memoized.rb +59 -0
- data/lib/.rbnext/3.0/action_policy/ext/policy_cache_key.rb +72 -0
- data/lib/.rbnext/3.0/action_policy/policy/aliases.rb +69 -0
- data/lib/.rbnext/3.0/action_policy/policy/authorization.rb +87 -0
- data/lib/.rbnext/3.0/action_policy/policy/cache.rb +101 -0
- data/lib/.rbnext/3.0/action_policy/policy/core.rb +161 -0
- data/lib/.rbnext/3.0/action_policy/policy/defaults.rb +31 -0
- data/lib/.rbnext/3.0/action_policy/policy/execution_result.rb +37 -0
- data/lib/.rbnext/3.0/action_policy/policy/pre_check.rb +162 -0
- data/lib/.rbnext/3.0/action_policy/policy/reasons.rb +210 -0
- data/lib/.rbnext/3.0/action_policy/policy/scoping.rb +160 -0
- data/lib/.rbnext/3.0/action_policy/rspec/be_authorized_to.rb +89 -0
- data/lib/.rbnext/3.0/action_policy/rspec/have_authorized_scope.rb +124 -0
- data/lib/.rbnext/3.0/action_policy/utils/pretty_print.rb +159 -0
- data/lib/.rbnext/3.0/action_policy/utils/suggest_message.rb +19 -0
- data/lib/action_policy/version.rb +1 -1
- metadata +27 -2
@@ -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| _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 |_1|
|
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? { |_1| 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,115 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "action_policy/behaviours/policy_for"
|
4
|
+
require "action_policy/behaviours/scoping"
|
5
|
+
require "action_policy/behaviours/memoized"
|
6
|
+
require "action_policy/behaviours/thread_memoized"
|
7
|
+
require "action_policy/behaviours/namespaced"
|
8
|
+
|
9
|
+
require "action_policy/authorizer"
|
10
|
+
|
11
|
+
module ActionPolicy
|
12
|
+
# Provides `authorize!` and `allowed_to?` methods and
|
13
|
+
# `authorize` class method to define authorization context.
|
14
|
+
#
|
15
|
+
# Could be included anywhere to perform authorization.
|
16
|
+
module Behaviour
|
17
|
+
include ActionPolicy::Behaviours::PolicyFor
|
18
|
+
include ActionPolicy::Behaviours::Scoping
|
19
|
+
|
20
|
+
def self.included(base)
|
21
|
+
# Handle ActiveSupport::Concern differently
|
22
|
+
if base.respond_to?(:class_methods)
|
23
|
+
base.class_methods do
|
24
|
+
include ClassMethods
|
25
|
+
end
|
26
|
+
else
|
27
|
+
base.extend ClassMethods
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Authorize action against a policy.
|
32
|
+
#
|
33
|
+
# Policy is inferred from record
|
34
|
+
# (unless explicitly specified through `with` option).
|
35
|
+
#
|
36
|
+
# Raises `ActionPolicy::Unauthorized` if check failed.
|
37
|
+
def authorize!(record = :__undef__, to:, **options)
|
38
|
+
policy = lookup_authorization_policy(record, **options)
|
39
|
+
|
40
|
+
Authorizer.call(policy, authorization_rule_for(policy, to))
|
41
|
+
end
|
42
|
+
|
43
|
+
# Checks that an activity is allowed for the current context (e.g. user).
|
44
|
+
#
|
45
|
+
# Returns true of false.
|
46
|
+
def allowed_to?(rule, record = :__undef__, **options)
|
47
|
+
policy = lookup_authorization_policy(record, **options)
|
48
|
+
|
49
|
+
policy.apply(authorization_rule_for(policy, rule))
|
50
|
+
end
|
51
|
+
|
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)
|
55
|
+
|
56
|
+
policy.apply(authorization_rule_for(policy, rule))
|
57
|
+
policy.result
|
58
|
+
end
|
59
|
+
|
60
|
+
def authorization_context
|
61
|
+
return @__authorization_context if
|
62
|
+
instance_variable_defined?(:@__authorization_context)
|
63
|
+
|
64
|
+
@__authorization_context = self.class.authorization_targets
|
65
|
+
.each_with_object({}) do |(key, meth), obj|
|
66
|
+
obj[key] = send(meth)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# Check that rule is defined for policy,
|
71
|
+
# otherwise fallback to :manage? rule.
|
72
|
+
def authorization_rule_for(policy, rule)
|
73
|
+
policy.resolve_rule(rule)
|
74
|
+
end
|
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
|
+
|
85
|
+
module ClassMethods # :nodoc:
|
86
|
+
# Configure authorization context.
|
87
|
+
#
|
88
|
+
# For example:
|
89
|
+
#
|
90
|
+
# class ApplicationController < ActionController::Base
|
91
|
+
# # Pass the value of `current_user` to authorization as `user`
|
92
|
+
# authorize :user, through: :current_user
|
93
|
+
# end
|
94
|
+
#
|
95
|
+
# # Assuming that in your ApplicationPolicy
|
96
|
+
# class ApplicationPolicy < ActionPolicy::Base
|
97
|
+
# authorize :user
|
98
|
+
# end
|
99
|
+
def authorize(key, through: nil)
|
100
|
+
meth = through || key
|
101
|
+
authorization_targets[key] = meth
|
102
|
+
end
|
103
|
+
|
104
|
+
def authorization_targets
|
105
|
+
return @authorization_targets if instance_variable_defined?(:@authorization_targets)
|
106
|
+
|
107
|
+
@authorization_targets = if superclass.respond_to?(:authorization_targets)
|
108
|
+
superclass.authorization_targets.dup
|
109
|
+
else
|
110
|
+
{}
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionPolicy
|
4
|
+
module Behaviours
|
5
|
+
# Adds `policy_for` method
|
6
|
+
module PolicyFor
|
7
|
+
require "action_policy/ext/policy_cache_key"
|
8
|
+
using ActionPolicy::Ext::PolicyCacheKey
|
9
|
+
|
10
|
+
# Returns policy instance for the record.
|
11
|
+
def policy_for(record:, with: nil, namespace: authorization_namespace, context: authorization_context, allow_nil: false, default: default_authorization_policy_class)
|
12
|
+
policy_class = with || ::ActionPolicy.lookup(
|
13
|
+
record,
|
14
|
+
**{namespace: namespace, context: context, allow_nil: allow_nil, default: default}
|
15
|
+
)
|
16
|
+
policy_class&.new(record, **context)
|
17
|
+
end
|
18
|
+
|
19
|
+
def authorization_context
|
20
|
+
raise NotImplementedError, "Please, define `authorization_context` method!"
|
21
|
+
end
|
22
|
+
|
23
|
+
def authorization_namespace
|
24
|
+
# override to provide specific authorization namespace
|
25
|
+
end
|
26
|
+
|
27
|
+
def default_authorization_policy_class
|
28
|
+
# override to provide a policy class use when no policy found
|
29
|
+
end
|
30
|
+
|
31
|
+
# Override this method to provide implicit authorization target
|
32
|
+
# that would be used in case `record` is not specified in
|
33
|
+
# `authorize!` and `allowed_to?` call.
|
34
|
+
#
|
35
|
+
# It is also used to infer a policy for scoping (in `authorized_scope` method).
|
36
|
+
def implicit_authorization_target
|
37
|
+
# no-op
|
38
|
+
end
|
39
|
+
|
40
|
+
# Return implicit authorization target or raises an exception if it's nil
|
41
|
+
def implicit_authorization_target!
|
42
|
+
implicit_authorization_target || raise(
|
43
|
+
NotFound,
|
44
|
+
[
|
45
|
+
self,
|
46
|
+
"Couldn't find implicit authorization target " \
|
47
|
+
"for #{self.class}. " \
|
48
|
+
"Please, provide policy class explicitly using `with` option or " \
|
49
|
+
"define the `implicit_authorization_target` method."
|
50
|
+
]
|
51
|
+
)
|
52
|
+
end
|
53
|
+
|
54
|
+
def policy_for_cache_key(record:, with: nil, namespace: nil, context: authorization_context, **)
|
55
|
+
record_key = record._policy_cache_key(use_object_id: true)
|
56
|
+
context_key = context.values.map { _1._policy_cache_key(use_object_id: true) }.join(".")
|
57
|
+
|
58
|
+
"#{namespace}/#{with}/#{context_key}/#{record_key}"
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|