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