action_policy 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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