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,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeclarativePolicy
4
+ module Cache
5
+ class << self
6
+ def user_key(user)
7
+ return '<anonymous>' if user.nil?
8
+
9
+ id_for(user)
10
+ end
11
+
12
+ def policy_key(user, subject)
13
+ u = user_key(user)
14
+ s = subject_key(subject)
15
+ "/dp/policy/#{u}/#{s}"
16
+ end
17
+
18
+ def subject_key(subject)
19
+ return '<nil>' if subject.nil?
20
+ return subject.inspect if subject.is_a?(Symbol)
21
+
22
+ "#{subject.class.name}:#{id_for(subject)}"
23
+ end
24
+
25
+ private
26
+
27
+ def id_for(obj)
28
+ id =
29
+ begin
30
+ obj.id
31
+ rescue NoMethodError
32
+ nil
33
+ end
34
+
35
+ id || "##{obj.object_id}"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeclarativePolicy
4
+ # A Condition is the data structure that is created by the
5
+ # `condition` declaration on DeclarativePolicy::Base. It is
6
+ # more or less just a struct of the data passed to that
7
+ # declaration. It holds on to the block to be instance_eval'd
8
+ # on a context (instance of Base) later, via #compute.
9
+ class Condition
10
+ attr_reader :name, :description, :scope, :manual_score, :context_key
11
+
12
+ def initialize(name, opts = {}, &compute)
13
+ @name = name
14
+ @compute = compute
15
+ @scope = opts.fetch(:scope, :normal)
16
+ @description = opts.delete(:description)
17
+ @context_key = opts[:context_key]
18
+ @manual_score = opts.fetch(:score, nil)
19
+ end
20
+
21
+ def compute(context)
22
+ !!context.instance_eval(&@compute)
23
+ end
24
+
25
+ def key
26
+ "#{@context_key}/#{@name}"
27
+ end
28
+ end
29
+
30
+ # In contrast to a Condition, a ManifestCondition contains
31
+ # a Condition and a context object, and is capable of calculating
32
+ # a result itself. This is the return value of Base#condition.
33
+ class ManifestCondition
34
+ def initialize(condition, context)
35
+ @condition = condition
36
+ @context = context
37
+ end
38
+
39
+ # The main entry point - does this condition pass? We reach into
40
+ # the context's cache here so that we can share in the global
41
+ # cache (often RequestStore or similar).
42
+ def pass?
43
+ @context.cache(cache_key) { @condition.compute(@context) }
44
+ end
45
+
46
+ # Whether we've already computed this condition.
47
+ def cached?
48
+ @context.cached?(cache_key)
49
+ end
50
+
51
+ # This is used to score Rule::Condition. See Rule::Condition#score
52
+ # and Runner#steps_by_score for how scores are used.
53
+ #
54
+ # The number here is intended to represent, abstractly, how
55
+ # expensive it would be to calculate this condition.
56
+ #
57
+ # See #cache_key for info about @condition.scope.
58
+ def score
59
+ # If we've been cached, no computation is necessary.
60
+ return 0 if cached?
61
+
62
+ # Use the override from condition(score: ...) if present
63
+ return @condition.manual_score if @condition.manual_score
64
+
65
+ # Global scope rules are cheap due to max cache sharing
66
+ return 2 if @condition.scope == :global
67
+
68
+ # "Normal" rules can't share caches with any other policies
69
+ return 16 if @condition.scope == :normal
70
+
71
+ # otherwise, we're :user or :subject scope, so it's 4 if
72
+ # the caller has declared a preference
73
+ return 4 if @condition.scope == DeclarativePolicy.preferred_scope
74
+
75
+ # and 8 for all other :user or :subject scope conditions.
76
+ 8
77
+ end
78
+
79
+ private
80
+
81
+ # This method controls the caching for the condition. This is where
82
+ # the condition(scope: ...) option comes into play. Notice that
83
+ # depending on the scope, we may cache only by the user or only by
84
+ # the subject, resulting in sharing across different policy objects.
85
+ def cache_key
86
+ @cache_key ||=
87
+ case @condition.scope
88
+ when :normal then "/dp/condition/#{@condition.key}/#{user_key},#{subject_key}"
89
+ when :user then "/dp/condition/#{@condition.key}/#{user_key}"
90
+ when :subject then "/dp/condition/#{@condition.key}/#{subject_key}"
91
+ when :global then "/dp/condition/#{@condition.key}"
92
+ else raise 'invalid scope'
93
+ end
94
+ end
95
+
96
+ def user_key
97
+ Cache.user_key(@context.user)
98
+ end
99
+
100
+ def subject_key
101
+ Cache.subject_key(@context.subject)
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeclarativePolicy
4
+ class Configuration
5
+ ConfigurationError = Class.new(StandardError)
6
+
7
+ def initialize
8
+ @named_policies = {}
9
+ @name_transformation = ->(name) { "#{name}Policy" }
10
+ end
11
+
12
+ def named_policy(name, policy = nil)
13
+ @named_policies[name] = policy if policy
14
+
15
+ @named_policies[name] || raise(ConfigurationError, "No #{name} policy configured")
16
+ end
17
+
18
+ def nil_policy(policy = nil)
19
+ @nil_policy = policy if policy
20
+
21
+ @nil_policy || ::DeclarativePolicy::NilPolicy
22
+ end
23
+
24
+ def name_transformation(&block)
25
+ @name_transformation = block
26
+ nil
27
+ end
28
+
29
+ def policy_class(domain_class_name)
30
+ return unless domain_class_name
31
+
32
+ @name_transformation.call(domain_class_name).constantize
33
+ rescue NameError
34
+ nil
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeclarativePolicy
4
+ # Used when the name of a delegate is mentioned in
5
+ # the rule DSL.
6
+ class DelegateDsl
7
+ def initialize(rule_dsl, delegate_name)
8
+ @rule_dsl = rule_dsl
9
+ @delegate_name = delegate_name
10
+ end
11
+
12
+ def method_missing(msg, *args)
13
+ return super unless args.empty? && !block_given?
14
+
15
+ @rule_dsl.delegate(@delegate_name, msg)
16
+ end
17
+
18
+ def respond_to_missing?(msg, include_all)
19
+ true
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Default policy definition for nil values
4
+ module DeclarativePolicy
5
+ class NilPolicy < DeclarativePolicy::Base
6
+ rule { default }.prevent_all
7
+ end
8
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeclarativePolicy
4
+ # The return value of a rule { ... } declaration.
5
+ # Can call back to register rules with the containing
6
+ # Policy class (context_class here). See Base.rule
7
+ #
8
+ # Note that the #policy method just performs an #instance_eval,
9
+ # which is useful for multiple #enable or #prevent calls.
10
+ #
11
+ # Also provides a #method_missing proxy to the context
12
+ # class's class methods, so that helper methods can be
13
+ # defined and used in a #policy { ... } block.
14
+ class PolicyDsl
15
+ def initialize(context_class, rule)
16
+ @context_class = context_class
17
+ @rule = rule
18
+ end
19
+
20
+ def policy(&block)
21
+ instance_eval(&block)
22
+ end
23
+
24
+ def enable(*abilities)
25
+ @context_class.enable_when(abilities, @rule)
26
+ end
27
+
28
+ def prevent(*abilities)
29
+ @context_class.prevent_when(abilities, @rule)
30
+ end
31
+
32
+ def prevent_all
33
+ @context_class.prevent_all_when(@rule)
34
+ end
35
+
36
+ def method_missing(msg, *args, &block)
37
+ return super unless @context_class.respond_to?(msg)
38
+
39
+ @context_class.__send__(msg, *args, &block) # rubocop: disable GitlabSecurity/PublicSend
40
+ end
41
+
42
+ def respond_to_missing?(msg)
43
+ @context_class.respond_to?(msg) || super
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeclarativePolicy
4
+ module PreferredScope
5
+ PREFERRED_SCOPE_KEY = :"DeclarativePolicy.preferred_scope"
6
+
7
+ def with_preferred_scope(scope)
8
+ old_scope = Thread.current[PREFERRED_SCOPE_KEY]
9
+ Thread.current[PREFERRED_SCOPE_KEY] = scope
10
+ yield
11
+ ensure
12
+ Thread.current[PREFERRED_SCOPE_KEY] = old_scope
13
+ end
14
+
15
+ def preferred_scope
16
+ Thread.current[PREFERRED_SCOPE_KEY]
17
+ end
18
+
19
+ def user_scope(&block)
20
+ with_preferred_scope(:user, &block)
21
+ end
22
+
23
+ def subject_scope(&block)
24
+ with_preferred_scope(:subject, &block)
25
+ end
26
+
27
+ def preferred_scope=(scope)
28
+ Thread.current[PREFERRED_SCOPE_KEY] = scope
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeclarativePolicy
4
+ module Rule
5
+ # A Rule is the object that results from the `rule` declaration,
6
+ # usually built using the DSL in `RuleDsl`. It is a basic logical
7
+ # combination of building blocks, and is capable of deciding,
8
+ # given a context (instance of DeclarativePolicy::Base) whether it
9
+ # passes or not. Note that this decision doesn't by itself know
10
+ # how that affects the actual ability decision - for that, a
11
+ # `Step` is used.
12
+ class Base
13
+ def self.make(*args)
14
+ new(*args).simplify
15
+ end
16
+
17
+ # true or false whether this rule passes.
18
+ # `context` is a policy - an instance of
19
+ # DeclarativePolicy::Base.
20
+ def pass?(_context)
21
+ raise 'abstract'
22
+ end
23
+
24
+ # same as #pass? except refuses to do any I/O,
25
+ # returning nil if the result is not yet cached.
26
+ # used for accurately scoring And/Or
27
+ def cached_pass?(_context)
28
+ raise 'abstract'
29
+ end
30
+
31
+ # abstractly, how long would it take to compute
32
+ # this rule? lower-scored rules are tried first.
33
+ def score(_context)
34
+ raise 'abstract'
35
+ end
36
+
37
+ # unwrap double negatives and nested and/or
38
+ def simplify
39
+ self
40
+ end
41
+
42
+ # convenience combination methods
43
+ def or(other)
44
+ Or.make([self, other])
45
+ end
46
+
47
+ def and(other)
48
+ And.make([self, other])
49
+ end
50
+
51
+ def negate
52
+ Not.make(self)
53
+ end
54
+
55
+ alias_method :|, :or
56
+ alias_method :&, :and
57
+ alias_method :~, :negate
58
+
59
+ def inspect
60
+ "#<Rule #{repr}>"
61
+ end
62
+ end
63
+
64
+ # A rule that checks a condition. This is the
65
+ # type of rule that results from a basic bareword
66
+ # in the rule dsl (see RuleDsl#method_missing).
67
+ class Condition < Base
68
+ def initialize(name)
69
+ @name = name
70
+ end
71
+
72
+ # we delegate scoring to the condition. See
73
+ # ManifestCondition#score.
74
+ def score(context)
75
+ context.condition(@name).score
76
+ end
77
+
78
+ # Let the ManifestCondition from the context
79
+ # decide whether we pass.
80
+ def pass?(context)
81
+ context.condition(@name).pass?
82
+ end
83
+
84
+ # returns nil unless it's already cached
85
+ def cached_pass?(context)
86
+ condition = context.condition(@name)
87
+ return unless condition.cached?
88
+
89
+ condition.pass?
90
+ end
91
+
92
+ def description(context)
93
+ context.class.conditions[@name].description
94
+ end
95
+
96
+ def repr
97
+ @name.to_s
98
+ end
99
+ end
100
+
101
+ # A rule constructed from DelegateDsl - using a condition from a
102
+ # delegated policy.
103
+ class DelegatedCondition < Base
104
+ # Internal use only - this is rescued each time it's raised.
105
+ MissingDelegate = Class.new(StandardError)
106
+
107
+ def initialize(delegate_name, name)
108
+ @delegate_name = delegate_name
109
+ @name = name
110
+ end
111
+
112
+ def delegated_context(context)
113
+ policy = context.delegated_policies[@delegate_name]
114
+ raise MissingDelegate if policy.nil?
115
+
116
+ policy
117
+ end
118
+
119
+ def score(context)
120
+ delegated_context(context).condition(@name).score
121
+ rescue MissingDelegate
122
+ 0
123
+ end
124
+
125
+ def cached_pass?(context)
126
+ condition = delegated_context(context).condition(@name)
127
+ return unless condition.cached?
128
+
129
+ condition.pass?
130
+ rescue MissingDelegate
131
+ false
132
+ end
133
+
134
+ def pass?(context)
135
+ delegated_context(context).condition(@name).pass?
136
+ rescue MissingDelegate
137
+ false
138
+ end
139
+
140
+ def repr
141
+ "#{@delegate_name}.#{@name}"
142
+ end
143
+ end
144
+
145
+ # A rule constructed from RuleDsl#can?. Computes a different ability
146
+ # on the same subject.
147
+ class Ability < Base
148
+ attr_reader :ability
149
+
150
+ def initialize(ability)
151
+ @ability = ability
152
+ end
153
+
154
+ # We ask the ability's runner for a score
155
+ def score(context)
156
+ context.runner(@ability).score
157
+ end
158
+
159
+ def pass?(context)
160
+ context.allowed?(@ability)
161
+ end
162
+
163
+ def cached_pass?(context)
164
+ runner = context.runner(@ability)
165
+ return unless runner.cached?
166
+
167
+ runner.pass?
168
+ end
169
+
170
+ def description(_context)
171
+ "User can #{@ability.inspect}"
172
+ end
173
+
174
+ def repr
175
+ "can?(#{@ability.inspect})"
176
+ end
177
+ end
178
+
179
+ # Logical `and`, containing a list of rules. Only passes
180
+ # if all of them do.
181
+ class And < Base
182
+ attr_reader :rules
183
+
184
+ def initialize(rules)
185
+ @rules = rules
186
+ end
187
+
188
+ def simplify
189
+ simplified_rules = @rules.flat_map do |rule|
190
+ simplified = rule.simplify
191
+ case simplified
192
+ when And then simplified.rules
193
+ else [simplified]
194
+ end
195
+ end
196
+
197
+ And.new(simplified_rules)
198
+ end
199
+
200
+ def score(context)
201
+ return 0 unless cached_pass?(context).nil?
202
+
203
+ # note that cached rules will have score 0 anyways.
204
+ @rules.sum { |r| r.score(context) }
205
+ end
206
+
207
+ def pass?(context)
208
+ # try to find a cached answer before
209
+ # checking in order
210
+ cached = cached_pass?(context)
211
+ return cached unless cached.nil?
212
+
213
+ @rules.all? { |r| r.pass?(context) }
214
+ end
215
+
216
+ def cached_pass?(context)
217
+ @rules.each do |rule|
218
+ pass = rule.cached_pass?(context)
219
+
220
+ return pass if pass.nil? || pass == false
221
+ end
222
+
223
+ true
224
+ end
225
+
226
+ def repr
227
+ "all?(#{rules.map(&:repr).join(', ')})"
228
+ end
229
+ end
230
+
231
+ # Logical `or`. Mirrors And.
232
+ class Or < Base
233
+ attr_reader :rules
234
+
235
+ def initialize(rules)
236
+ @rules = rules
237
+ end
238
+
239
+ def pass?(context)
240
+ cached = cached_pass?(context)
241
+ return cached unless cached.nil?
242
+
243
+ @rules.any? { |r| r.pass?(context) }
244
+ end
245
+
246
+ def simplify
247
+ simplified_rules = @rules.flat_map do |rule|
248
+ simplified = rule.simplify
249
+ case simplified
250
+ when Or then simplified.rules
251
+ else [simplified]
252
+ end
253
+ end
254
+
255
+ Or.new(simplified_rules)
256
+ end
257
+
258
+ def cached_pass?(context)
259
+ @rules.each do |rule|
260
+ pass = rule.cached_pass?(context)
261
+
262
+ return pass if pass.nil? || pass == true
263
+ end
264
+
265
+ false
266
+ end
267
+
268
+ def score(context)
269
+ return 0 unless cached_pass?(context).nil?
270
+
271
+ @rules.sum { |r| r.score(context) }
272
+ end
273
+
274
+ def repr
275
+ "any?(#{@rules.map(&:repr).join(', ')})"
276
+ end
277
+ end
278
+
279
+ class Not < Base
280
+ attr_reader :rule
281
+
282
+ def initialize(rule)
283
+ @rule = rule
284
+ end
285
+
286
+ def simplify
287
+ case @rule
288
+ when And then Or.new(@rule.rules.map(&:negate)).simplify
289
+ when Or then And.new(@rule.rules.map(&:negate)).simplify
290
+ when Not then @rule.rule.simplify
291
+ else Not.new(@rule.simplify)
292
+ end
293
+ end
294
+
295
+ def pass?(context)
296
+ !@rule.pass?(context)
297
+ end
298
+
299
+ def cached_pass?(context)
300
+ case @rule.cached_pass?(context)
301
+ when nil then nil
302
+ when true then false
303
+ when false then true
304
+ end
305
+ end
306
+
307
+ def score(context)
308
+ @rule.score(context)
309
+ end
310
+
311
+ def repr
312
+ "~#{@rule.repr}"
313
+ end
314
+ end
315
+ end
316
+ end