declarative_policy 1.0.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.
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/dependencies'
4
+ require 'active_support/core_ext'
5
+
6
+ require_relative 'declarative_policy/cache'
7
+ require_relative 'declarative_policy/condition'
8
+ require_relative 'declarative_policy/delegate_dsl'
9
+ require_relative 'declarative_policy/policy_dsl'
10
+ require_relative 'declarative_policy/rule_dsl'
11
+ require_relative 'declarative_policy/preferred_scope'
12
+ require_relative 'declarative_policy/rule'
13
+ require_relative 'declarative_policy/runner'
14
+ require_relative 'declarative_policy/step'
15
+ require_relative 'declarative_policy/base'
16
+ require_relative 'declarative_policy/nil_policy'
17
+ require_relative 'declarative_policy/configuration'
18
+
19
+ # DeclarativePolicy: A DSL based authorization framework
20
+ module DeclarativePolicy
21
+ extend PreferredScope
22
+
23
+ CLASS_CACHE_MUTEX = Mutex.new
24
+ CLASS_CACHE_IVAR = :@__DeclarativePolicy_CLASS_CACHE
25
+
26
+ class << self
27
+ def policy_for(user, subject, opts = {})
28
+ cache = opts[:cache] || {}
29
+ key = Cache.policy_key(user, subject)
30
+
31
+ cache[key] ||=
32
+ # to avoid deadlocks in multi-threaded environment when
33
+ # autoloading is enabled, we allow concurrent loads,
34
+ # https://gitlab.com/gitlab-org/gitlab-foss/issues/48263
35
+ ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
36
+ class_for(subject).new(user, subject, opts)
37
+ end
38
+ end
39
+
40
+ def class_for(subject)
41
+ return configuration.nil_policy if subject.nil?
42
+ return configuration.named_policy(subject) if subject.is_a?(Symbol)
43
+
44
+ subject = find_delegate(subject)
45
+
46
+ policy_class = class_for_class(subject.class)
47
+ raise "no policy for #{subject.class.name}" if policy_class.nil?
48
+
49
+ policy_class
50
+ end
51
+
52
+ def configure(&block)
53
+ configuration.instance_eval(&block)
54
+
55
+ nil
56
+ end
57
+
58
+ # Reset configuration
59
+ def configure!(&block)
60
+ @configuration = DeclarativePolicy::Configuration.new
61
+ configure(&block) if block
62
+ end
63
+
64
+ def policy?(subject)
65
+ !class_for_class(subject.class).nil?
66
+ end
67
+ alias_method :has_policy?, :policy?
68
+
69
+ private
70
+
71
+ def configuration
72
+ @configuration ||= DeclarativePolicy::Configuration.new
73
+ end
74
+
75
+ # This method is heavily cached because there are a lot of anonymous
76
+ # modules in play in a typical rails app, and #name performs quite
77
+ # slowly for anonymous classes and modules.
78
+ #
79
+ # See https://bugs.ruby-lang.org/issues/11119
80
+ #
81
+ # if the above bug is resolved, this caching could likely be removed.
82
+ def class_for_class(subject_class)
83
+ unless subject_class.instance_variable_defined?(CLASS_CACHE_IVAR)
84
+ CLASS_CACHE_MUTEX.synchronize do
85
+ # re-check in case of a race
86
+ break if subject_class.instance_variable_defined?(CLASS_CACHE_IVAR)
87
+
88
+ policy_class = compute_class_for_class(subject_class)
89
+ subject_class.instance_variable_set(CLASS_CACHE_IVAR, policy_class)
90
+ end
91
+ end
92
+
93
+ subject_class.instance_variable_get(CLASS_CACHE_IVAR)
94
+ end
95
+
96
+ def compute_class_for_class(subject_class)
97
+ return subject_class.declarative_policy_class.constantize if subject_class.respond_to?(:declarative_policy_class)
98
+
99
+ subject_class.ancestors.each do |klass|
100
+ name = klass.name
101
+ klass = policy_class(name)
102
+
103
+ return klass if klass
104
+ end
105
+
106
+ nil
107
+ end
108
+
109
+ def policy_class(name)
110
+ clazz = configuration.policy_class(name)
111
+
112
+ clazz if clazz && clazz < Base
113
+ end
114
+
115
+ def find_delegate(subject)
116
+ seen = Set.new
117
+
118
+ while subject.respond_to?(:declarative_policy_delegate)
119
+ raise ArgumentError, 'circular delegations' if seen.include?(subject.object_id)
120
+
121
+ seen << subject.object_id
122
+ subject = subject.declarative_policy_delegate
123
+ end
124
+
125
+ subject
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,351 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeclarativePolicy
4
+ class Base
5
+ # A map of ability => list of rules together with :enable
6
+ # or :prevent actions. Used to look up which rules apply to
7
+ # a given ability. See Base.ability_map
8
+ class AbilityMap
9
+ attr_reader :map
10
+
11
+ def initialize(map = {})
12
+ @map = map
13
+ end
14
+
15
+ # This merge behavior is different than regular hashes - if both
16
+ # share a key, the values at that key are concatenated, rather than
17
+ # overridden.
18
+ def merge(other)
19
+ conflict_proc = proc { |_key, my_val, other_val| my_val + other_val }
20
+ AbilityMap.new(@map.merge(other.map, &conflict_proc))
21
+ end
22
+
23
+ def actions(key)
24
+ @map[key] ||= []
25
+ end
26
+
27
+ def enable(key, rule)
28
+ actions(key) << [:enable, rule]
29
+ end
30
+
31
+ def prevent(key, rule)
32
+ actions(key) << [:prevent, rule]
33
+ end
34
+ end
35
+
36
+ class << self
37
+ # The `own_ability_map` vs `ability_map` distinction is used so that
38
+ # the data structure is properly inherited - with subclasses recursively
39
+ # merging their parent class.
40
+ #
41
+ # This pattern is also used for conditions, global_actions, and delegations.
42
+ def ability_map
43
+ if self == Base
44
+ own_ability_map
45
+ else
46
+ superclass.ability_map.merge(own_ability_map)
47
+ end
48
+ end
49
+
50
+ def own_ability_map
51
+ @own_ability_map ||= AbilityMap.new
52
+ end
53
+
54
+ # an inheritable map of conditions, by name
55
+ def conditions
56
+ if self == Base
57
+ own_conditions
58
+ else
59
+ superclass.conditions.merge(own_conditions)
60
+ end
61
+ end
62
+
63
+ def own_conditions
64
+ @own_conditions ||= {}
65
+ end
66
+
67
+ # a list of global actions, generated by `prevent_all`. these aren't
68
+ # stored in `ability_map` because they aren't indexed by a particular
69
+ # ability.
70
+ def global_actions
71
+ if self == Base
72
+ own_global_actions
73
+ else
74
+ superclass.global_actions + own_global_actions
75
+ end
76
+ end
77
+
78
+ def own_global_actions
79
+ @own_global_actions ||= []
80
+ end
81
+
82
+ # an inheritable map of delegations, indexed by name (which may be
83
+ # autogenerated)
84
+ def delegations
85
+ if self == Base
86
+ own_delegations
87
+ else
88
+ superclass.delegations.merge(own_delegations)
89
+ end
90
+ end
91
+
92
+ def own_delegations
93
+ @own_delegations ||= {}
94
+ end
95
+
96
+ # all the [rule, action] pairs that apply to a particular ability.
97
+ # we combine the specific ones looked up in ability_map with the global
98
+ # ones.
99
+ def configuration_for(ability)
100
+ ability_map.actions(ability) + global_actions
101
+ end
102
+
103
+ ### declaration methods ###
104
+
105
+ def delegate(name = nil, &delegation_block)
106
+ if name.nil?
107
+ @delegate_name_counter ||= 0
108
+ @delegate_name_counter += 1
109
+ name = :"anonymous_#{@delegate_name_counter}"
110
+ end
111
+
112
+ name = name.to_sym
113
+
114
+ # rubocop: disable GitlabSecurity/PublicSend
115
+ delegation_block = proc { @subject.__send__(name) } if delegation_block.nil?
116
+ # rubocop: enable GitlabSecurity/PublicSend
117
+
118
+ own_delegations[name] = delegation_block
119
+ end
120
+
121
+ # Declare that the given abilities should not be read from delegates.
122
+ #
123
+ # This is useful if you have an ability that you want to define
124
+ # differently in a policy than in a delegated policy, but still want to
125
+ # delegate all other abilities.
126
+ #
127
+ # example:
128
+ #
129
+ # delegate { @subect.parent }
130
+ #
131
+ # overrides :drive_car, :watch_tv
132
+ #
133
+ def overrides(*names)
134
+ @overrides ||= [].to_set
135
+ @overrides.merge(names)
136
+ end
137
+
138
+ # Declares a rule, constructed using RuleDsl, and returns
139
+ # a PolicyDsl which is used for registering the rule with
140
+ # this class. PolicyDsl will call back into Base.enable_when,
141
+ # Base.prevent_when, and Base.prevent_all_when.
142
+ def rule(&block)
143
+ rule = RuleDsl.new(self).instance_eval(&block)
144
+ PolicyDsl.new(self, rule)
145
+ end
146
+
147
+ # A hash in which to store calls to `desc` and `with_scope`, etc.
148
+ def last_options
149
+ @last_options ||= {}.with_indifferent_access
150
+ end
151
+
152
+ # retrieve and zero out the previously set options (used in .condition)
153
+ def last_options!
154
+ last_options.tap { @last_options = nil }
155
+ end
156
+
157
+ # Declare a description for the following condition. Currently unused,
158
+ # but opens the potential for explaining to users why they were or were
159
+ # not able to do something.
160
+ def desc(description)
161
+ last_options[:description] = description
162
+ end
163
+
164
+ def with_options(opts = {})
165
+ last_options.merge!(opts)
166
+ end
167
+
168
+ def with_scope(scope)
169
+ with_options scope: scope
170
+ end
171
+
172
+ def with_score(score)
173
+ with_options score: score
174
+ end
175
+
176
+ # Declares a condition. It gets stored in `own_conditions`, and generates
177
+ # a query method based on the condition's name.
178
+ def condition(name, opts = {}, &value)
179
+ name = name.to_sym
180
+
181
+ opts = last_options!.merge(opts)
182
+ opts[:context_key] ||= self.name
183
+
184
+ condition = Condition.new(name, opts, &value)
185
+
186
+ own_conditions[name] = condition
187
+
188
+ define_method(:"#{name}?") { condition(name).pass? }
189
+ end
190
+
191
+ # These next three methods are mainly called from PolicyDsl,
192
+ # and are responsible for "inverting" the relationship between
193
+ # an ability and a rule. We store in `ability_map` a map of
194
+ # abilities to rules that affect them, together with a
195
+ # symbol indicating :prevent or :enable.
196
+ def enable_when(abilities, rule)
197
+ abilities.each { |a| own_ability_map.enable(a, rule) }
198
+ end
199
+
200
+ def prevent_when(abilities, rule)
201
+ abilities.each { |a| own_ability_map.prevent(a, rule) }
202
+ end
203
+
204
+ # we store global prevents (from `prevent_all`) separately,
205
+ # so that they can be combined into every decision made.
206
+ def prevent_all_when(rule)
207
+ own_global_actions << [:prevent, rule]
208
+ end
209
+ end
210
+
211
+ # A policy object contains a specific user and subject on which
212
+ # to compute abilities. For this reason it's sometimes called
213
+ # "context" within the framework.
214
+ #
215
+ # It also stores a reference to the cache, so it can be used
216
+ # to cache computations by e.g. ManifestCondition.
217
+ attr_reader :user, :subject
218
+
219
+ def initialize(user, subject, opts = {})
220
+ @user = user
221
+ @subject = subject
222
+ @cache = opts[:cache] || {}
223
+ end
224
+
225
+ # helper for checking abilities on this and other subjects
226
+ # for the current user.
227
+ def can?(ability, new_subject = :_self)
228
+ return allowed?(ability) if new_subject == :_self
229
+
230
+ policy_for(new_subject).allowed?(ability)
231
+ end
232
+
233
+ # This is the main entry point for permission checks. It constructs
234
+ # or looks up a Runner for the given ability and asks it if it passes.
235
+ def allowed?(*abilities)
236
+ abilities.all? { |a| runner(a).pass? }
237
+ end
238
+
239
+ # The inverse of #allowed?, used mainly in specs.
240
+ def disallowed?(*abilities)
241
+ abilities.all? { |a| !runner(a).pass? }
242
+ end
243
+
244
+ # computes the given ability and prints a helpful debugging output
245
+ # showing which
246
+ def debug(ability, *args)
247
+ runner(ability).debug(*args)
248
+ end
249
+
250
+ desc 'Unknown user'
251
+ condition(:anonymous, scope: :user, score: 0) { @user.nil? }
252
+
253
+ desc 'By default'
254
+ condition(:default, scope: :global, score: 0) { true }
255
+
256
+ def repr
257
+ subject_repr =
258
+ if @subject.respond_to?(:id)
259
+ "#{@subject.class.name}/#{@subject.id}"
260
+ else
261
+ @subject.inspect
262
+ end
263
+
264
+ user_repr = @user.try(:to_reference) || '<anonymous>'
265
+
266
+ "(#{user_repr} : #{subject_repr})"
267
+ end
268
+
269
+ def inspect
270
+ "#<#{self.class.name} #{repr}>"
271
+ end
272
+
273
+ # returns a Runner for the given ability, capable of computing whether
274
+ # the ability is allowed. Runners are cached on the policy (which itself
275
+ # is cached on @cache), and caches its result. This is how we perform caching
276
+ # at the ability level.
277
+ def runner(ability)
278
+ ability = ability.to_sym
279
+ @runners ||= {}
280
+ @runners[ability] ||=
281
+ begin
282
+ own_runner = Runner.new(own_steps(ability))
283
+ if self.class.overrides.include?(ability)
284
+ own_runner
285
+ else
286
+ delegated_runners = delegated_policies.values.compact.map { |p| p.runner(ability) }
287
+ delegated_runners.inject(own_runner, &:merge_runner)
288
+ end
289
+ end
290
+ end
291
+
292
+ # Helpers for caching. Used by ManifestCondition in performing condition
293
+ # computation.
294
+ #
295
+ # NOTE we can't use ||= here because the value might be the
296
+ # boolean `false`
297
+ def cache(key)
298
+ return @cache[key] if cached?(key)
299
+
300
+ @cache[key] = yield
301
+ end
302
+
303
+ def cached?(key)
304
+ !@cache[key].nil?
305
+ end
306
+
307
+ # returns a ManifestCondition capable of computing itself. The computation
308
+ # will use our own @cache.
309
+ def condition(name)
310
+ name = name.to_sym
311
+ @_conditions ||= {}
312
+ @_conditions[name] ||=
313
+ begin
314
+ raise "invalid condition #{name}" unless self.class.conditions.key?(name)
315
+
316
+ ManifestCondition.new(self.class.conditions[name], self)
317
+ end
318
+ end
319
+
320
+ # used in specs - returns true if there is no possible way for any action
321
+ # to be allowed, determined only by the global :prevent_all rules.
322
+ def banned?
323
+ global_steps = self.class.global_actions.map { |(action, rule)| Step.new(self, rule, action) }
324
+ !Runner.new(global_steps).pass?
325
+ end
326
+
327
+ # A list of other policies that we've delegated to (see `Base.delegate`)
328
+ def delegated_policies
329
+ @delegated_policies ||= self.class.delegations.transform_values do |block|
330
+ new_subject = instance_eval(&block)
331
+
332
+ # never delegate to nil, as that would immediately prevent_all
333
+ next if new_subject.nil?
334
+
335
+ policy_for(new_subject)
336
+ end
337
+ end
338
+
339
+ def policy_for(other_subject)
340
+ DeclarativePolicy.policy_for(@user, other_subject, cache: @cache)
341
+ end
342
+
343
+ protected
344
+
345
+ # constructs steps that come from this policy and not from any delegations
346
+ def own_steps(ability)
347
+ rules = self.class.configuration_for(ability)
348
+ rules.map { |(action, rule)| Step.new(self, rule, action) }
349
+ end
350
+ end
351
+ end