action_policy 0.0.1 → 0.1.0

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 (73) hide show
  1. checksums.yaml +5 -5
  2. data/.rubocop.yml +85 -0
  3. data/.travis.yml +25 -2
  4. data/CHANGELOG.md +7 -0
  5. data/Gemfile +12 -3
  6. data/README.md +71 -12
  7. data/Rakefile +9 -1
  8. data/action_policy.gemspec +11 -5
  9. data/docs/.nojekyll +0 -0
  10. data/docs/CNAME +1 -0
  11. data/docs/README.md +46 -0
  12. data/docs/_sidebar.md +19 -0
  13. data/docs/aliases.md +54 -0
  14. data/docs/assets/docsify.min.js +1 -0
  15. data/docs/assets/fonts/FiraCode-Medium.woff +0 -0
  16. data/docs/assets/fonts/FiraCode-Regular.woff +0 -0
  17. data/docs/assets/images/cache.png +0 -0
  18. data/docs/assets/images/cache.svg +70 -0
  19. data/docs/assets/images/layer.png +0 -0
  20. data/docs/assets/images/layer.svg +92 -0
  21. data/docs/assets/prism-ruby.min.js +1 -0
  22. data/docs/assets/styles.css +317 -0
  23. data/docs/assets/vue.min.css +1 -0
  24. data/docs/authorization_context.md +33 -0
  25. data/docs/caching.md +262 -0
  26. data/docs/custom_lookup_chain.md +48 -0
  27. data/docs/custom_policy.md +51 -0
  28. data/docs/favicon.ico +0 -0
  29. data/docs/i18n.md +3 -0
  30. data/docs/index.html +25 -0
  31. data/docs/instrumentation.md +3 -0
  32. data/docs/lookup_chain.md +16 -0
  33. data/docs/namespaces.md +69 -0
  34. data/docs/non_rails.md +29 -0
  35. data/docs/pre_checks.md +57 -0
  36. data/docs/quick_start.md +102 -0
  37. data/docs/rails.md +110 -0
  38. data/docs/reasons.md +67 -0
  39. data/docs/testing.md +116 -0
  40. data/docs/writing_policies.md +55 -0
  41. data/gemfiles/jruby.gemfile +5 -0
  42. data/gemfiles/rails42.gemfile +5 -0
  43. data/gemfiles/railsmaster.gemfile +6 -0
  44. data/lib/action_policy.rb +34 -2
  45. data/lib/action_policy/authorizer.rb +28 -0
  46. data/lib/action_policy/base.rb +24 -0
  47. data/lib/action_policy/behaviour.rb +94 -0
  48. data/lib/action_policy/behaviours/memoized.rb +56 -0
  49. data/lib/action_policy/behaviours/namespaced.rb +80 -0
  50. data/lib/action_policy/behaviours/policy_for.rb +23 -0
  51. data/lib/action_policy/behaviours/thread_memoized.rb +54 -0
  52. data/lib/action_policy/ext/module_namespace.rb +21 -0
  53. data/lib/action_policy/ext/policy_cache_key.rb +67 -0
  54. data/lib/action_policy/ext/string_constantize.rb +23 -0
  55. data/lib/action_policy/lookup_chain.rb +84 -0
  56. data/lib/action_policy/policy/aliases.rb +69 -0
  57. data/lib/action_policy/policy/authorization.rb +91 -0
  58. data/lib/action_policy/policy/cache.rb +74 -0
  59. data/lib/action_policy/policy/cached_apply.rb +28 -0
  60. data/lib/action_policy/policy/core.rb +64 -0
  61. data/lib/action_policy/policy/defaults.rb +37 -0
  62. data/lib/action_policy/policy/pre_check.rb +210 -0
  63. data/lib/action_policy/policy/reasons.rb +109 -0
  64. data/lib/action_policy/rails/channel.rb +15 -0
  65. data/lib/action_policy/rails/controller.rb +90 -0
  66. data/lib/action_policy/railtie.rb +74 -0
  67. data/lib/action_policy/rspec.rb +3 -0
  68. data/lib/action_policy/rspec/be_authorized_to.rb +93 -0
  69. data/lib/action_policy/rspec/pundit_syntax.rb +48 -0
  70. data/lib/action_policy/test_helper.rb +46 -0
  71. data/lib/action_policy/testing.rb +64 -0
  72. data/lib/action_policy/version.rb +3 -1
  73. metadata +115 -9
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_policy/behaviours/policy_for"
4
+
5
+ module ActionPolicy
6
+ # Raised when `resolve_rule` failed to find an approriate
7
+ # policy rule method for the activity
8
+ class UnknownRule < Error
9
+ attr_reader :policy, :rule, :message
10
+
11
+ def initialize(policy, rule)
12
+ @policy = policy.class
13
+ @rule = rule
14
+ @message = "Couldn't find rule '#{@rule}' for #{@policy}"
15
+ end
16
+ end
17
+
18
+ module Policy
19
+ # Core policy API
20
+ module Core
21
+ include ActionPolicy::Behaviours::PolicyFor
22
+
23
+ attr_reader :record
24
+
25
+ def initialize(record = nil)
26
+ @record = record
27
+ end
28
+
29
+ # Returns a result of applying the specified rule.
30
+ # Unlike simply calling a predicate rule (`policy.manage?`),
31
+ # `apply` also calls pre-checks.
32
+ def apply(rule)
33
+ public_send(rule)
34
+ end
35
+
36
+ # Returns a result of applying the specified rule to the specified record.
37
+ # Under the hood a policy class for record is resolved
38
+ # (unless it's explicitly set through `with` option).
39
+ #
40
+ # If record is `nil` then we uses the current policy.
41
+ def allowed_to?(rule, record = :__undef__, **options)
42
+ policy =
43
+ if record == :__undef__
44
+ self
45
+ else
46
+ policy_for(record: record, **options)
47
+ end
48
+
49
+ policy.apply(rule)
50
+ end
51
+
52
+ # Returns a rule name (policy method name) for activity.
53
+ #
54
+ # By default, rule name is equal to activity name.
55
+ #
56
+ # Raises ActionPolicy::UknownRule when rule is not found in policy.
57
+ def resolve_rule(activity)
58
+ raise UnknownRule.new(self, activity) unless
59
+ respond_to?(activity)
60
+ activity
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPolicy
4
+ module Policy
5
+ # Create default rules and aliases:
6
+ # - `index?` (=`false`)
7
+ # - `create?` (=`false`)
8
+ # - `new?` as an alias for `create?`
9
+ # - `manage?` as a fallback for all unspecified rules (default rule)
10
+ module Defaults
11
+ def self.included(base)
12
+ raise "Aliases support is required for defaults" unless
13
+ base.ancestors.include?(Aliases)
14
+
15
+ base.default_rule :manage?
16
+ base.alias_rule :new?, to: :create?
17
+
18
+ raise "Verification context support is required for defaults" unless
19
+ base.ancestors.include?(Aliases)
20
+
21
+ base.authorize :user
22
+ end
23
+
24
+ def index?
25
+ false
26
+ end
27
+
28
+ def create?
29
+ false
30
+ end
31
+
32
+ def manage?
33
+ false
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPolicy
4
+ module Policy
5
+ # Adds callback-style checks to policies to
6
+ # extract common checks from rules.
7
+ #
8
+ # class ApplicationPolicy < ActionPolicy::Base
9
+ # authorize :user
10
+ # pre_check :allow_admins
11
+ #
12
+ # private
13
+ # # Allow every action for admins
14
+ # def allow_admins
15
+ # allow! if user.admin?
16
+ # end
17
+ # end
18
+ #
19
+ # You can specify conditional pre-checks (through `except` / `only`) options
20
+ # and skip already defined pre-checks if necessary.
21
+ #
22
+ # class UserPolicy < ApplicationPolicy
23
+ # skip_pre_check :allow_admins, only: :destroy?
24
+ #
25
+ # def destroy?
26
+ # user.admin? && !record.admin?
27
+ # end
28
+ # end
29
+ module PreCheck
30
+ # Single pre-check instance.
31
+ #
32
+ # Implements filtering logic.
33
+ class Check
34
+ # Wrapper over check result
35
+ class Result
36
+ DENY = :__deny__
37
+ ALLOW = :__allow__
38
+
39
+ KINDS = {
40
+ DENY => false,
41
+ ALLOW => true
42
+ }.freeze
43
+
44
+ attr_reader :fulfilled
45
+
46
+ def initialize(val)
47
+ @fulfilled = KINDS.keys.include?(val)
48
+ @value = val
49
+ end
50
+
51
+ alias fulfilled? fulfilled
52
+
53
+ def denied?
54
+ @value == DENY
55
+ end
56
+
57
+ def value
58
+ KINDS.fetch(@value)
59
+ end
60
+ end
61
+
62
+ attr_reader :name, :policy_class
63
+
64
+ def initialize(policy, name, except: nil, only: nil)
65
+ if !except.nil? && !only.nil?
66
+ raise ArgumentError,
67
+ "Only one of `except` and `only` may be specified for pre-check"
68
+ end
69
+
70
+ @policy_class = policy
71
+ @name = name
72
+ @blacklist = Array(except) unless except.nil?
73
+ @whitelist = Array(only) unless only.nil?
74
+
75
+ rebuild_filter
76
+ end
77
+
78
+ def applicable?(rule)
79
+ return true if filter.nil?
80
+ filter.call(rule)
81
+ end
82
+
83
+ def call(policy)
84
+ Result.new(policy.send(name)).tap do |res|
85
+ # add denial reason if Reasons included
86
+ policy.reasons.add(policy_class, name) if
87
+ res.denied? && policy.respond_to?(:reasons)
88
+ end
89
+ end
90
+
91
+ # rubocop: disable Metrics/AbcSize, Metrics/CyclomaticComplexity
92
+ # rubocop: disable Metrics/PerceivedComplexity, Metrics/MethodLength
93
+ def skip!(except: nil, only: nil)
94
+ if !except.nil? && !only.nil?
95
+ raise ArgumentError,
96
+ "Only one of `except` and `only` may be specified when skipping pre-check"
97
+ end
98
+
99
+ if except.nil? && only.nil?
100
+ raise ArgumentError,
101
+ "At least one of `except` and `only` must be specified when skipping pre-check"
102
+ end
103
+
104
+ if except
105
+ @whitelist = Array(except)
106
+ @whitelist -= blacklist if blacklist
107
+ @blacklist = nil
108
+ else
109
+ # only
110
+ @blacklist += Array(only) if blacklist
111
+ @whitelist -= Array(only) if whitelist
112
+ @blacklist = Array(only) if filter.nil?
113
+ end
114
+
115
+ rebuild_filter
116
+ end
117
+ # rubocop: enable Metrics/AbcSize, Metrics/CyclomaticComplexity
118
+ # rubocop: enable Metrics/PerceivedComplexity, Metrics/MethodLength
119
+
120
+ def dup
121
+ self.class.new(policy_class, name, except: blacklist&.dup, only: whitelist&.dup)
122
+ end
123
+
124
+ private
125
+
126
+ attr_reader :whitelist, :blacklist, :filter
127
+
128
+ def rebuild_filter
129
+ @filter =
130
+ if whitelist
131
+ proc { |rule| whitelist.include?(rule) }
132
+ elsif blacklist
133
+ proc { |rule| !blacklist.include?(rule) }
134
+ end
135
+ end
136
+ end
137
+
138
+ class << self
139
+ def prepended(base)
140
+ base.extend ClassMethods
141
+ base.prepend InstanceMethods
142
+ end
143
+
144
+ alias included prepended
145
+ end
146
+
147
+ def run_pre_checks(rule)
148
+ self.class.pre_checks.each do |check|
149
+ next unless check.applicable?(rule)
150
+ res = check.call(self)
151
+ return res.value if res.fulfilled?
152
+ end
153
+
154
+ yield if block_given?
155
+ end
156
+
157
+ def deny!
158
+ Check::Result::DENY
159
+ end
160
+
161
+ def allow!
162
+ Check::Result::ALLOW
163
+ end
164
+
165
+ module InstanceMethods # :nodoc:
166
+ def apply(rule)
167
+ run_pre_checks(rule) { super }
168
+ end
169
+ end
170
+
171
+ module ClassMethods # :nodoc:
172
+ def pre_check(*names, **options)
173
+ names.each do |name|
174
+ # do not allow pre-check override
175
+ check = pre_checks.find { |c| c.name == name }
176
+ raise "Pre-check already defined: #{name}" unless check.nil?
177
+
178
+ pre_checks << Check.new(self, name, **options)
179
+ end
180
+ end
181
+
182
+ # rubocop: disable Metrics/AbcSize
183
+ def skip_pre_check(*names, **options)
184
+ names.each do |name|
185
+ check = pre_checks.find { |c| c.name == name }
186
+ raise "Pre-check not found: #{name}" if check.nil?
187
+
188
+ # when no options provided we remove this check completely
189
+ next pre_checks.delete(check) if options.empty?
190
+
191
+ # otherwise duplicate and apply skip options
192
+ pre_checks[pre_checks.index(check)] = check.dup.tap { |c| c.skip! options }
193
+ end
194
+ end
195
+ # rubocop: enable Metrics/AbcSize
196
+
197
+ def pre_checks
198
+ return @pre_checks if instance_variable_defined?(:@pre_checks)
199
+
200
+ @pre_checks =
201
+ if superclass.respond_to?(:pre_checks)
202
+ superclass.pre_checks.dup
203
+ else
204
+ []
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPolicy
4
+ module Policy
5
+ class FailureReason # :nodoc:
6
+ attr_reader :policy, :rule
7
+
8
+ def initialize(policy_or_class, rule)
9
+ @policy = policy_or_class.is_a?(Class) ? policy_or_class : policy_or_class.class
10
+ @rule = rule
11
+ end
12
+ end
13
+
14
+ # Failures reasons store
15
+ class FailureReasons
16
+ include Enumerable
17
+ extend Forwardable
18
+
19
+ def_delegators :@reasons, :size, :empty?, :last, :each
20
+
21
+ def initialize
22
+ @reasons = []
23
+ end
24
+
25
+ def add(policy, rule)
26
+ @reasons << FailureReason.new(policy, rule)
27
+ end
28
+ end
29
+
30
+ # Provides failure reasons tracking functionality.
31
+ # That allows you to distinguish between the reasons why authorization was rejected.
32
+ #
33
+ # It's helpful when you compose policies (i.e. use one policy within another).
34
+ #
35
+ # For example:
36
+ #
37
+ # class ApplicantPolicy < ApplicationPolicy
38
+ # def show?
39
+ # user.has_permission?(:view_applicants) &&
40
+ # allowed_to?(:show?, object.stage)
41
+ # end
42
+ # end
43
+ #
44
+ # Now when you receive an exception, you have a reasons object, which contains additional
45
+ # information about the failure:
46
+ #
47
+ # rescue_from ActionPolicy::Unauthorized do |ex|
48
+ # ex.reasons.messages #=> { stage: [:show] }
49
+ # end
50
+ #
51
+ # You can also wrap _local_ rules into `allowed_to?` to populate reasons:
52
+ #
53
+ # class ApplicantPolicy < ApplicationPolicy
54
+ # def show?
55
+ # allowed_to?(:view_applicants?) &&
56
+ # allowed_to?(:show?, object.stage)
57
+ # end
58
+ #
59
+ # def view_applicants?
60
+ # user.has_permission?(:view_applicants)
61
+ # end
62
+ # end
63
+ module Reasons
64
+ class << self
65
+ def prepended(base)
66
+ base.prepend InstanceMethods
67
+ end
68
+
69
+ alias included prepended
70
+ end
71
+
72
+ attr_reader :reasons
73
+
74
+ def with_clean_reasons # :nodoc:
75
+ old_reasons = reasons
76
+ @reasons = nil
77
+ res = yield
78
+ @reasons = old_reasons
79
+ res
80
+ end
81
+
82
+ module InstanceMethods # :nodoc:
83
+ def apply(rule)
84
+ @reasons = FailureReasons.new
85
+ super
86
+ end
87
+
88
+ # rubocop: disable Metrics/MethodLength
89
+ def allowed_to?(rule, record = :__undef__, **options)
90
+ policy = nil
91
+
92
+ succeed =
93
+ if record == :__undef__
94
+ policy = self
95
+ with_clean_reasons { apply(rule) }
96
+ else
97
+ policy = policy_for(record: record, **options)
98
+
99
+ policy.apply(rule)
100
+ end
101
+
102
+ reasons.add(policy, rule) if reasons && !succeed
103
+ succeed
104
+ end
105
+ # rubocop: enable Metrics/MethodLength
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "action_policy/behaviour"
5
+
6
+ module ActionPolicy
7
+ # Channel concern.
8
+ # Add `authorize!` and `allowed_to?` methods.
9
+ module Channel
10
+ extend ActiveSupport::Concern
11
+
12
+ include ActionPolicy::Behaviour
13
+ include ActionPolicy::Behaviours::Namespaced
14
+ end
15
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "action_policy/behaviour"
5
+
6
+ module ActionPolicy
7
+ # Raised when `authorize!` hasn't been called for action
8
+ class UnauthorizedAction < Error
9
+ def initialize(controller, action)
10
+ super("Action '#{controller}##{action}' hasn't been authorized")
11
+ end
12
+ end
13
+
14
+ # Controller concern.
15
+ # Add `authorize!` and `allowed_to?` methods,
16
+ # provide `verify_authorized` hook.
17
+ module Controller
18
+ extend ActiveSupport::Concern
19
+
20
+ include ActionPolicy::Behaviour
21
+ include ActionPolicy::Behaviours::ThreadMemoized
22
+ include ActionPolicy::Behaviours::Memoized
23
+ include ActionPolicy::Behaviours::Namespaced
24
+
25
+ included do
26
+ helper_method :allowed_to?
27
+
28
+ attr_writer :authorize_count
29
+
30
+ protected :authorize_count=, :authorize_count
31
+ end
32
+
33
+ # Authorize action against a policy.
34
+ #
35
+ # Policy is inferred from record
36
+ # (unless explicitly specified through `with` option).
37
+ #
38
+ # If action is not provided, it's inferred from `action_name`.
39
+ #
40
+ # If record is not provided, tries to infer the resource class
41
+ # from controller name (i.e. `controller_name.classify.safe_constantize`).
42
+ #
43
+ # Raises `ActionPolicy::Unauthorized` if check failed.
44
+ def authorize!(record = :__undef__, to: nil, **options)
45
+ record = controller_name.classify.safe_constantize if
46
+ record == :__undef__
47
+
48
+ to ||= :"#{action_name}?"
49
+
50
+ super(record, to: to, **options)
51
+
52
+ self.authorize_count += 1
53
+ end
54
+
55
+ # Checks that an activity is allowed for the current context (e.g. user).
56
+ #
57
+ # If record is not provided, tries to infer the resource class
58
+ # from controller name (i.e. `controller_name.classify.safe_constantize`).
59
+ #
60
+ # Returns true of false.
61
+ def allowed_to?(rule, record = :__undef__, **options)
62
+ record = controller_name.classify.safe_constantize if
63
+ record == :__undef__
64
+
65
+ super(rule, record, **options)
66
+ end
67
+
68
+ def verify_authorized
69
+ raise UnauthorizedAction.new(controller_path, action_name) if
70
+ authorize_count.zero?
71
+ end
72
+
73
+ def authorize_count
74
+ @authorize_count ||= 0
75
+ end
76
+
77
+ class_methods do
78
+ # Adds after_action callback to check that
79
+ # authorize! method has been called.
80
+ def verify_authorized(**options)
81
+ after_action :verify_authorized, **options
82
+ end
83
+
84
+ # Skips verify_authorized after_action callback.
85
+ def skip_verify_authorized(**options)
86
+ skip_after_action :verify_authorized, **options\
87
+ end
88
+ end
89
+ end
90
+ end