declarative_policy 1.0.0

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