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,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