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.
- 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
data/docs/testing.md
CHANGED
@@ -3,8 +3,158 @@
|
|
3
3
|
Authorization is one of the crucial parts of your application. Hence, it should be thoroughly tested (that is the place where 100% coverage makes sense).
|
4
4
|
|
5
5
|
When you use policies for authorization, it is possible to split testing into two parts:
|
6
|
-
- Test
|
7
|
-
- Test the
|
6
|
+
- Test the policy class itself
|
7
|
+
- Test that **the required authorization is performed** within your authorization layer (controller, channel, etc.)
|
8
|
+
- Test that **the required scoping has been applied**.
|
9
|
+
|
10
|
+
## Testing policies
|
11
|
+
|
12
|
+
You can test policies as plain-old Ruby classes, no special tooling is required.
|
13
|
+
|
14
|
+
Consider an RSpec example:
|
15
|
+
|
16
|
+
```ruby
|
17
|
+
describe PostPolicy do
|
18
|
+
let(:user) { build_stubbed(:user) }
|
19
|
+
let(:post) { build_stubbed(:post) }
|
20
|
+
|
21
|
+
let(:policy) { described_class.new(post, user: user) }
|
22
|
+
|
23
|
+
describe "#update?" do
|
24
|
+
subject { policy.apply(:update?) }
|
25
|
+
|
26
|
+
it "returns false when the user is not admin nor author" do
|
27
|
+
is_expected.to eq false
|
28
|
+
end
|
29
|
+
|
30
|
+
context "when the user is admin" do
|
31
|
+
let(:user) { build_stubbed(:user, :admin) }
|
32
|
+
|
33
|
+
it { is_expected.to eq true }
|
34
|
+
end
|
35
|
+
|
36
|
+
context "when the user is an author" do
|
37
|
+
let(:post) { build_stubbed(:post, user: user) }
|
38
|
+
|
39
|
+
it { is_expected.to eq true }
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
```
|
44
|
+
|
45
|
+
### RSpec DSL
|
46
|
+
|
47
|
+
We also provide a simple RSpec DSL which aims to reduce the boilerplate when writing
|
48
|
+
policies specs.
|
49
|
+
|
50
|
+
Example:
|
51
|
+
|
52
|
+
```ruby
|
53
|
+
# Add this to your spec_helper.rb / rails_helper.rb
|
54
|
+
require "action_policy/rspec/dsl"
|
55
|
+
|
56
|
+
describe PostPolicy do
|
57
|
+
let(:user) { build_stubbed :user }
|
58
|
+
# `record` must be defined – it is the authorization target
|
59
|
+
let(:record) { build_stubbed :post, draft: false }
|
60
|
+
|
61
|
+
# `context` is the authorization context
|
62
|
+
let(:context) { {user: user} }
|
63
|
+
|
64
|
+
# `describe_rule` is a combination of
|
65
|
+
# `describe` and `subject { ... }` (returns the result of
|
66
|
+
# applying the rule to the record)
|
67
|
+
describe_rule :show? do
|
68
|
+
# `succeed` is `context` + `specify`, which checks
|
69
|
+
# that the result of application is successful
|
70
|
+
succeed "when post is published"
|
71
|
+
|
72
|
+
# `succeed` is `context` + `specify`, which checks
|
73
|
+
# that the result of application wasn't successful
|
74
|
+
failed "when post is draft" do
|
75
|
+
before { post.draft = false }
|
76
|
+
|
77
|
+
succeed "when user is a manager" do
|
78
|
+
before { user.role = "manager" }
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
```
|
84
|
+
|
85
|
+
If test failed the exception message includes the result and [failure reasons](reasons) (if any):
|
86
|
+
|
87
|
+
```
|
88
|
+
1) PostPolucy#show? when post is draft
|
89
|
+
Failure/Error: ...
|
90
|
+
|
91
|
+
Expected to fail but succeed:
|
92
|
+
<PostPolicy#show?: true (reasons: ...)>
|
93
|
+
```
|
94
|
+
|
95
|
+
If you have [debugging utils](debugging) installed the message also includes the _annotated_
|
96
|
+
source code of the policy rule:
|
97
|
+
|
98
|
+
```
|
99
|
+
1) UserPolucy#manage? when post is draft
|
100
|
+
Failure/Error: ...
|
101
|
+
|
102
|
+
Expected to fail but succeed:
|
103
|
+
<PostPolicy#show?: true (reasons: ...)>
|
104
|
+
↳ user.admin? #=> true
|
105
|
+
OR
|
106
|
+
!record.draft? #=> false
|
107
|
+
```
|
108
|
+
|
109
|
+
**NOTE:** DSL for focusing or skipping examples and groups is also available (e.g. `xdescribe_rule`, `fsucceed`, etc.).
|
110
|
+
|
111
|
+
**NOTE:** the DSL is included only to example with the tag `type: :policy` or in the `spec/policies` folder. If you want to add this DSL to other examples, add `include ActionPolicy::RSpec::PolicyExampleGroup`.
|
112
|
+
|
113
|
+
### Testing scopes
|
114
|
+
|
115
|
+
There is no single rule on how to test scopes, 'cause it dependes on the _nature_ of the scope.
|
116
|
+
|
117
|
+
Here's an example of RSpec tests for Active Record scoping rules:
|
118
|
+
|
119
|
+
```ruby
|
120
|
+
describe PostPolicy do
|
121
|
+
describe "relation scope" do
|
122
|
+
let(:user) { build_stubbed :user }
|
123
|
+
let(:context) { {user: user} }
|
124
|
+
|
125
|
+
# Feel free to replace with `before_all` from `test-prof`:
|
126
|
+
# https://test-prof.evilmartians.io/#/before_all
|
127
|
+
before do
|
128
|
+
create(:post, name: "A")
|
129
|
+
create(:post, name: "B", draft: true)
|
130
|
+
end
|
131
|
+
|
132
|
+
let(:target) do
|
133
|
+
# We want to make sure that only the records created
|
134
|
+
# for this test are affected, and they have a deterministic order
|
135
|
+
Post.where(name: %w[A B]).order(name: :asc)
|
136
|
+
end
|
137
|
+
|
138
|
+
subject { policy.apply_scope(target, type: :active_record_relation).pluck(:name) }
|
139
|
+
|
140
|
+
context "as user" do
|
141
|
+
it { is_expected.to eq(%w[A]) }
|
142
|
+
end
|
143
|
+
|
144
|
+
context "as manager" do
|
145
|
+
before { user.update!(role: :manager) }
|
146
|
+
|
147
|
+
it { is_expected.to eq(%w[A B]) }
|
148
|
+
end
|
149
|
+
|
150
|
+
context "as banned user" do
|
151
|
+
before { user.update!(banned: true) }
|
152
|
+
|
153
|
+
it { is_expected.to be_empty }
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
```
|
8
158
|
|
9
159
|
## Testing authorization
|
10
160
|
|
@@ -80,37 +230,98 @@ end
|
|
80
230
|
|
81
231
|
If you omit `.with(PostPolicy)` then the inferred policy for the target (`post`) would be used.
|
82
232
|
|
83
|
-
## Testing
|
233
|
+
## Testing scoping
|
84
234
|
|
85
|
-
|
235
|
+
Action Policy provides a way to test that a correct scoping has been applied during the code execution.
|
86
236
|
|
87
|
-
|
237
|
+
For example, you can test that in your `#index` action the correct scoping is used:
|
88
238
|
|
89
239
|
```ruby
|
90
|
-
|
91
|
-
|
92
|
-
|
240
|
+
class UsersController < ApplicationController
|
241
|
+
def index
|
242
|
+
@user = authorized(User.all)
|
243
|
+
end
|
244
|
+
end
|
245
|
+
```
|
93
246
|
|
94
|
-
|
247
|
+
### Minitest
|
95
248
|
|
96
|
-
|
97
|
-
|
249
|
+
Include `ActionPolicy::TestHelper` to your test class and you'll be able to use
|
250
|
+
`assert_have_authorized_scope` assertion:
|
98
251
|
|
99
|
-
|
100
|
-
|
101
|
-
|
252
|
+
```ruby
|
253
|
+
# in your test
|
254
|
+
require "action_policy/test_helper"
|
102
255
|
|
103
|
-
|
104
|
-
|
256
|
+
class UsersControllerTest < ActionDispatch::IntegrationTest
|
257
|
+
include ActionPolicy::TestHelper
|
105
258
|
|
106
|
-
|
259
|
+
test "index has authorized scope" do
|
260
|
+
sign_in users(:john)
|
261
|
+
|
262
|
+
assert_have_authorized_scope(type: :active_record_relation, with: UserPolicy) do
|
263
|
+
get :index
|
107
264
|
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
```
|
108
268
|
|
109
|
-
|
110
|
-
let(:post) { build_stubbed(:post, user: user) }
|
269
|
+
You can also specify `as` and `scope_options` options.
|
111
270
|
|
112
|
-
|
113
|
-
|
271
|
+
**NOTE:** both `type` and `with` params are required.
|
272
|
+
|
273
|
+
It's not possible to test that a scoped has been applied to a particular _target_ but we provide
|
274
|
+
a way to perform additional assertions against the matching target (if the assertion didn't fail):
|
275
|
+
|
276
|
+
```ruby
|
277
|
+
test "index has authorized scope" do
|
278
|
+
sign_in users(:john)
|
279
|
+
|
280
|
+
assert_have_authorized_scope(type: :active_record_relation, with: UserPolicy) do
|
281
|
+
get :index
|
282
|
+
end.with_target do |target|
|
283
|
+
# target is a object passed to `authorized` call
|
284
|
+
assert_equal User.all, target
|
285
|
+
end
|
286
|
+
end
|
287
|
+
```
|
288
|
+
|
289
|
+
### RSpec
|
290
|
+
|
291
|
+
Add the following to your `rails_helper.rb` (or `spec_helper.rb`):
|
292
|
+
|
293
|
+
```ruby
|
294
|
+
require "action_policy/rspec"
|
295
|
+
```
|
296
|
+
|
297
|
+
Now you can use `have_authorized_scope` matcher:
|
298
|
+
|
299
|
+
```ruby
|
300
|
+
describe UsersController do
|
301
|
+
subject { get :index }
|
302
|
+
|
303
|
+
it "has authorized scope" do
|
304
|
+
expect { subject }.to have_authorized_scope(:active_record_relation)
|
305
|
+
.with(PostPolicy)
|
114
306
|
end
|
115
307
|
end
|
116
308
|
```
|
309
|
+
|
310
|
+
You can also add `.as(:named_scope)` and `with_scope_options(options_hash)` options.
|
311
|
+
|
312
|
+
RSpec composed matchers are available as scope options:
|
313
|
+
|
314
|
+
```ruby
|
315
|
+
expect { subject }.to have_authorized_scope(:scope)
|
316
|
+
.with_scope_options(matching(with_deleted: a_falsey_value))
|
317
|
+
```
|
318
|
+
|
319
|
+
You can use the `with_target` modifier to run additional expectations against the matching target (if the matcher didn't fail):
|
320
|
+
|
321
|
+
```ruby
|
322
|
+
expect { subject }.to have_authorized_scope(:scope)
|
323
|
+
.with_scope_options(matching(with_deleted: a_falsey_value))
|
324
|
+
.with_target { |target|
|
325
|
+
expect(target).to eq(User.all)
|
326
|
+
}
|
327
|
+
```
|
data/docs/writing_policies.md
CHANGED
@@ -20,7 +20,7 @@ end
|
|
20
20
|
|
21
21
|
## Initializing policies
|
22
22
|
|
23
|
-
**NOTE**: it is not recommended to manually initialize policy objects and use them directly (one exclusion–[tests](testing.md)). Use `authorize!` / `allowed_to?` methods instead.
|
23
|
+
**NOTE**: it is not recommended to manually initialize policy objects and use them directly (one exclusion–[tests](testing.md)). Use [`authorize!` / `allowed_to?` methods](./behaviour.md#authorize) instead.
|
24
24
|
|
25
25
|
To initialize policy object, you should specify target record and context:
|
26
26
|
|
data/gemfiles/jruby.gemfile
CHANGED
data/gemfiles/rails42.gemfile
CHANGED
data/lib/action_policy.rb
CHANGED
@@ -20,16 +20,16 @@ module ActionPolicy
|
|
20
20
|
require "action_policy/version"
|
21
21
|
require "action_policy/base"
|
22
22
|
require "action_policy/lookup_chain"
|
23
|
-
require "action_policy/authorizer"
|
24
23
|
require "action_policy/behaviour"
|
24
|
+
require "action_policy/i18n" if defined?(::I18n)
|
25
25
|
|
26
26
|
class << self
|
27
27
|
attr_accessor :cache_store
|
28
28
|
|
29
29
|
# Find a policy class for a target
|
30
|
-
def lookup(target, **options)
|
30
|
+
def lookup(target, allow_nil: false, **options)
|
31
31
|
LookupChain.call(target, options) ||
|
32
|
-
raise(NotFound, target)
|
32
|
+
(allow_nil ? nil : raise(NotFound, target))
|
33
33
|
end
|
34
34
|
end
|
35
35
|
|
@@ -14,16 +14,24 @@ module ActionPolicy
|
|
14
14
|
end
|
15
15
|
end
|
16
16
|
|
17
|
-
#
|
18
|
-
#
|
19
|
-
# The main purpose of this module is to extact authorize action
|
17
|
+
# The main purpose of this module is to extact authorize actions
|
20
18
|
# from everything else to make it easily testable.
|
21
19
|
module Authorizer
|
22
20
|
class << self
|
21
|
+
# Performs authorization, raises an exception when check failed.
|
23
22
|
def call(policy, rule)
|
24
|
-
policy
|
23
|
+
authorize(policy, rule) ||
|
25
24
|
raise(::ActionPolicy::Unauthorized.new(policy, rule))
|
26
25
|
end
|
26
|
+
|
27
|
+
def authorize(policy, rule)
|
28
|
+
policy.apply(rule)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Applies scope to the target
|
32
|
+
def scopify(target, policy, **options)
|
33
|
+
policy.apply_scope(target, **options)
|
34
|
+
end
|
27
35
|
end
|
28
36
|
end
|
29
37
|
end
|
data/lib/action_policy/base.rb
CHANGED
@@ -9,6 +9,7 @@ module ActionPolicy
|
|
9
9
|
require "action_policy/policy/reasons"
|
10
10
|
require "action_policy/policy/pre_check"
|
11
11
|
require "action_policy/policy/aliases"
|
12
|
+
require "action_policy/policy/scoping"
|
12
13
|
require "action_policy/policy/cache"
|
13
14
|
require "action_policy/policy/cached_apply"
|
14
15
|
|
@@ -17,6 +18,7 @@ module ActionPolicy
|
|
17
18
|
include ActionPolicy::Policy::PreCheck
|
18
19
|
include ActionPolicy::Policy::Reasons
|
19
20
|
include ActionPolicy::Policy::Aliases
|
21
|
+
include ActionPolicy::Policy::Scoping
|
20
22
|
include ActionPolicy::Policy::Cache
|
21
23
|
include ActionPolicy::Policy::CachedApply
|
22
24
|
include ActionPolicy::Policy::Defaults
|
@@ -1,10 +1,13 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "action_policy/behaviours/policy_for"
|
4
|
+
require "action_policy/behaviours/scoping"
|
4
5
|
require "action_policy/behaviours/memoized"
|
5
6
|
require "action_policy/behaviours/thread_memoized"
|
6
7
|
require "action_policy/behaviours/namespaced"
|
7
8
|
|
9
|
+
require "action_policy/authorizer"
|
10
|
+
|
8
11
|
module ActionPolicy
|
9
12
|
# Provides `authorize!` and `allowed_to?` methods and
|
10
13
|
# `authorize` class method to define authorization context.
|
@@ -12,6 +15,7 @@ module ActionPolicy
|
|
12
15
|
# Could be included anywhere to perform authorization.
|
13
16
|
module Behaviour
|
14
17
|
include ActionPolicy::Behaviours::PolicyFor
|
18
|
+
include ActionPolicy::Behaviours::Scoping
|
15
19
|
|
16
20
|
def self.included(base)
|
17
21
|
# Handle ActiveSupport::Concern differently
|
@@ -30,7 +34,10 @@ module ActionPolicy
|
|
30
34
|
# (unless explicitly specified through `with` option).
|
31
35
|
#
|
32
36
|
# Raises `ActionPolicy::Unauthorized` if check failed.
|
33
|
-
def authorize!(record, to:, **options)
|
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
|
+
|
34
41
|
policy = policy_for(record: record, **options)
|
35
42
|
|
36
43
|
Authorizer.call(policy, authorization_rule_for(policy, to))
|
@@ -39,8 +46,12 @@ module ActionPolicy
|
|
39
46
|
# Checks that an activity is allowed for the current context (e.g. user).
|
40
47
|
#
|
41
48
|
# Returns true of false.
|
42
|
-
def allowed_to?(rule, record, **options)
|
49
|
+
def allowed_to?(rule, record = :__undef__, **options)
|
50
|
+
record = implicit_authorization_target if record == :__undef__
|
51
|
+
raise ArgumentError, "Record must be specified" if record.nil?
|
52
|
+
|
43
53
|
policy = policy_for(record: record, **options)
|
54
|
+
|
44
55
|
policy.apply(authorization_rule_for(policy, rule))
|
45
56
|
end
|
46
57
|
|
@@ -49,7 +60,7 @@ module ActionPolicy
|
|
49
60
|
instance_variable_defined?(:@__authorization_context)
|
50
61
|
|
51
62
|
@__authorization_context = self.class.authorization_targets
|
52
|
-
|
63
|
+
.each_with_object({}) do |(key, meth), obj|
|
53
64
|
obj[key] = send(meth)
|
54
65
|
end
|
55
66
|
end
|
@@ -36,7 +36,7 @@ module ActionPolicy
|
|
36
36
|
end
|
37
37
|
end
|
38
38
|
|
39
|
-
def __policy_memoize__(record, with: nil, namespace: nil)
|
39
|
+
def __policy_memoize__(record, with: nil, namespace: nil, **_opts)
|
40
40
|
record_key = record._policy_cache_key(use_object_id: true)
|
41
41
|
cache_key = "#{namespace}/#{with}/#{record_key}"
|
42
42
|
|