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