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.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.gitlab-ci.yml +48 -0
- data/.rspec +4 -0
- data/.rubocop.yml +10 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Dangerfile +16 -0
- data/Gemfile +24 -0
- data/Gemfile.lock +197 -0
- data/LICENSE.txt +21 -0
- data/README.md +144 -0
- data/Rakefile +8 -0
- data/danger/plugins/project_helper.rb +58 -0
- data/danger/roulette/Dangerfile +97 -0
- data/declarative_policy.gemspec +36 -0
- data/doc/caching.md +4 -0
- data/doc/configuration.md +78 -0
- data/doc/defining-policies.md +185 -0
- data/lib/declarative_policy.rb +128 -0
- data/lib/declarative_policy/base.rb +351 -0
- data/lib/declarative_policy/cache.rb +39 -0
- data/lib/declarative_policy/condition.rb +104 -0
- data/lib/declarative_policy/configuration.rb +37 -0
- data/lib/declarative_policy/delegate_dsl.rb +22 -0
- data/lib/declarative_policy/nil_policy.rb +8 -0
- data/lib/declarative_policy/policy_dsl.rb +46 -0
- data/lib/declarative_policy/preferred_scope.rb +31 -0
- data/lib/declarative_policy/rule.rb +316 -0
- data/lib/declarative_policy/rule_dsl.rb +51 -0
- data/lib/declarative_policy/runner.rb +203 -0
- data/lib/declarative_policy/step.rb +89 -0
- data/lib/declarative_policy/version.rb +5 -0
- metadata +84 -0
@@ -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
|