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
@@ -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 that **the required authorization is performed** within your authorization layer (controller, channel, etc.);
7
- - Test the policy class itself.
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 policies
233
+ ## Testing scoping
84
234
 
85
- You can test policies as plain-old Ruby classes, no special tooling is required.
235
+ Action Policy provides a way to test that a correct scoping has been applied during the code execution.
86
236
 
87
- Consider an RSpec example:
237
+ For example, you can test that in your `#index` action the correct scoping is used:
88
238
 
89
239
  ```ruby
90
- describe PostPolicy do
91
- let(:user) { build_stubbed(:user) }
92
- let(:post) { build_stubbed(:post) }
240
+ class UsersController < ApplicationController
241
+ def index
242
+ @user = authorized(User.all)
243
+ end
244
+ end
245
+ ```
93
246
 
94
- let(:policy) { described_class.new(post, user: user) }
247
+ ### Minitest
95
248
 
96
- describe "#update?" do
97
- subject { policy.apply(:update?) }
249
+ Include `ActionPolicy::TestHelper` to your test class and you'll be able to use
250
+ `assert_have_authorized_scope` assertion:
98
251
 
99
- it "returns false when the user is not admin nor author" do
100
- is_expected.to eq false
101
- end
252
+ ```ruby
253
+ # in your test
254
+ require "action_policy/test_helper"
102
255
 
103
- context "when the user is admin" do
104
- let(:user) { build_stubbed(:user, :admin) }
256
+ class UsersControllerTest < ActionDispatch::IntegrationTest
257
+ include ActionPolicy::TestHelper
105
258
 
106
- it { is_expected.to eq true }
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
- context "when the user is an author" do
110
- let(:post) { build_stubbed(:post, user: user) }
269
+ You can also specify `as` and `scope_options` options.
111
270
 
112
- it { is_expected.to eq true }
113
- end
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
+ ```
@@ -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
 
@@ -1,5 +1,8 @@
1
1
  source "https://rubygems.org"
2
2
 
3
+ gem "activerecord-jdbcsqlite3-adapter", "~> 52.0"
4
+ gem "jdbc-sqlite3"
5
+
3
6
  gem "rails", "~> 5.0"
4
7
 
5
8
  gemspec path: ".."
@@ -1,5 +1,8 @@
1
1
  source "https://rubygems.org"
2
2
 
3
+ gem "sqlite3", "~> 1.3.0"
3
4
  gem "rails", "~> 4.2"
5
+ gem "method_source"
6
+ gem "unparser"
4
7
 
5
8
  gemspec path: ".."
@@ -0,0 +1,8 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem "sqlite3"
4
+ gem "rails", "6.0.0.beta3"
5
+ gem "method_source"
6
+ gem "unparser"
7
+
8
+ gemspec path: ".."
@@ -1,6 +1,6 @@
1
1
  source "https://rubygems.org"
2
2
 
3
- gem "arel", github: "rails/arel"
3
+ gem "sqlite3", "~> 1.3.0"
4
4
  gem "rails", github: "rails/rails"
5
5
 
6
6
  gemspec path: ".."
@@ -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
- # Performs authorization, raises an exception when check failed.
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.apply(rule) ||
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
@@ -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
- .each_with_object({}) do |(key, meth), obj|
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