declarative_policy 2.1.0 → 2.3.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 +4 -4
- data/doc/caching.md +10 -3
- data/doc/optimization.md +3 -1
- data/lib/declarative_policy/base.rb +105 -32
- data/lib/declarative_policy/condition.rb +5 -8
- data/lib/declarative_policy/preferred_scope.rb +9 -2
- data/lib/declarative_policy/runner.rb +109 -29
- data/lib/declarative_policy/step.rb +38 -9
- data/lib/declarative_policy/version.rb +1 -1
- data/lib/declarative_policy.rb +18 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bf0b45880663f66032b9313af36819d078739f7430b53c9f182a4a8904acb443
|
|
4
|
+
data.tar.gz: c4e162b87f85d1008d5fa956f72f4745c4bb208b59796f95b472f4606504f295
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 41f679b05618e46bfc952406e211ddbe8512a25b86c254f6c0793d1293a67689414149765ca2e12e1368462669d06685c4af603307661a674d1999475fa2e1e9
|
|
7
|
+
data.tar.gz: ab068e42451bf95c939eb5952fbb8319741927d123faedd1d2f9713323cee26e019632d053cef1eddc4fc88d937501c062f80ba982e3d183d5ff9bac60cdae81
|
data/doc/caching.md
CHANGED
|
@@ -148,9 +148,16 @@ The object only needs to implement the following methods:
|
|
|
148
148
|
Obviously, a `HashMap` will work just fine, but so will a wrapper around a
|
|
149
149
|
[`Concurrent::Map`](https://ruby-concurrency.github.io/concurrent-ruby/1.1.4/Concurrent/Map.html),
|
|
150
150
|
or even a map that delegates to Redis with a TTL for each key, so long as the
|
|
151
|
-
object supports these methods.
|
|
152
|
-
|
|
153
|
-
|
|
151
|
+
object supports these methods. Values are only computed if the key is not
|
|
152
|
+
cached, so it is up to the application code to determine the life-time of each
|
|
153
|
+
key.
|
|
154
|
+
|
|
155
|
+
To remove keys, use `DeclarativePolicy.invalidate(cache, keys)`. It also makes
|
|
156
|
+
the abilities that depended on those keys check again, and tells the library
|
|
157
|
+
that scores used to order checks may have changed. Keys removed in any other
|
|
158
|
+
way, such as by a TTL expiring, don't change a decision a policy has already
|
|
159
|
+
made, and may make checks run in a less efficient order until the library
|
|
160
|
+
next sees a change.
|
|
154
161
|
|
|
155
162
|
Clearly, cache-invalidation is a hard problem. At GitLab we share a single cache
|
|
156
163
|
object for each request - so any single request can freely request a permission
|
data/doc/optimization.md
CHANGED
|
@@ -124,7 +124,9 @@ their components.
|
|
|
124
124
|
|
|
125
125
|
The evaluation of one rule results in updating the cache, so other rules might
|
|
126
126
|
become cheaper, during policy evaluation. To take this into account, we re-score
|
|
127
|
-
the set of rules on each iteration of the main loop.
|
|
127
|
+
the set of rules on each iteration of the main loop. Each rule keeps its score
|
|
128
|
+
until something it depends on changes, such as a condition being cached, so
|
|
129
|
+
re-scoring is cheap.
|
|
128
130
|
|
|
129
131
|
## Consequences for the policy-writer
|
|
130
132
|
|
|
@@ -6,6 +6,8 @@ module DeclarativePolicy
|
|
|
6
6
|
# or :prevent actions. Used to look up which rules apply to
|
|
7
7
|
# a given ability. See Base.ability_map
|
|
8
8
|
class AbilityMap
|
|
9
|
+
EMPTY_ACTIONS = [].freeze
|
|
10
|
+
|
|
9
11
|
attr_reader :map
|
|
10
12
|
|
|
11
13
|
def initialize(map = {})
|
|
@@ -20,16 +22,31 @@ module DeclarativePolicy
|
|
|
20
22
|
AbilityMap.new(@map.merge(other.map, &conflict_proc))
|
|
21
23
|
end
|
|
22
24
|
|
|
25
|
+
# Does not add the ability if it is missing. An application may check
|
|
26
|
+
# abilities named by user input, and storing those would grow the map
|
|
27
|
+
# without limit.
|
|
23
28
|
def actions(key)
|
|
24
|
-
@map[key]
|
|
29
|
+
@map[key] || EMPTY_ACTIONS
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def key?(key)
|
|
33
|
+
@map.key?(key)
|
|
25
34
|
end
|
|
26
35
|
|
|
27
36
|
def enable(key, rule)
|
|
28
|
-
|
|
37
|
+
rules_for(key) << [:enable, rule]
|
|
29
38
|
end
|
|
30
39
|
|
|
31
40
|
def prevent(key, rule)
|
|
32
|
-
|
|
41
|
+
rules_for(key) << [:prevent, rule]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# The writer's counterpart to #actions: this one does create the list,
|
|
47
|
+
# because a rule has to be appended to something.
|
|
48
|
+
def rules_for(key)
|
|
49
|
+
@map[key] ||= []
|
|
33
50
|
end
|
|
34
51
|
end
|
|
35
52
|
|
|
@@ -51,18 +68,25 @@ module DeclarativePolicy
|
|
|
51
68
|
end
|
|
52
69
|
end
|
|
53
70
|
|
|
71
|
+
# Counts declarations across all policies, so the caches below can tell
|
|
72
|
+
# that something was declared since they were built. Without it a policy
|
|
73
|
+
# loaded or reopened later would be seen with an out of date set of rules.
|
|
74
|
+
@definition_generation = 0
|
|
75
|
+
|
|
54
76
|
class << self
|
|
55
77
|
# The `own_ability_map` vs `ability_map` distinction is used so that
|
|
56
78
|
# the data structure is properly inherited - with subclasses recursively
|
|
57
79
|
# merging their parent class.
|
|
58
80
|
#
|
|
59
81
|
# This pattern is also used for conditions, global_actions, and delegations.
|
|
82
|
+
#
|
|
83
|
+
# The combined result is cached because rebuilding it is expensive, and
|
|
84
|
+
# every policy object would otherwise redo it, once per subject when
|
|
85
|
+
# authorizing a batch.
|
|
60
86
|
def ability_map
|
|
61
|
-
if self == Base
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
superclass.ability_map.merge(own_ability_map)
|
|
65
|
-
end
|
|
87
|
+
return own_ability_map if self == Base
|
|
88
|
+
|
|
89
|
+
memoized_definition(:ability_map) { superclass.ability_map.merge(own_ability_map) }
|
|
66
90
|
end
|
|
67
91
|
|
|
68
92
|
def own_ability_map
|
|
@@ -71,11 +95,9 @@ module DeclarativePolicy
|
|
|
71
95
|
|
|
72
96
|
# an inheritable map of conditions, by name
|
|
73
97
|
def conditions
|
|
74
|
-
if self == Base
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
superclass.conditions.merge(own_conditions)
|
|
78
|
-
end
|
|
98
|
+
return own_conditions if self == Base
|
|
99
|
+
|
|
100
|
+
memoized_definition(:conditions) { superclass.conditions.merge(own_conditions) }
|
|
79
101
|
end
|
|
80
102
|
|
|
81
103
|
def own_conditions
|
|
@@ -86,11 +108,9 @@ module DeclarativePolicy
|
|
|
86
108
|
# stored in `ability_map` because they aren't indexed by a particular
|
|
87
109
|
# ability.
|
|
88
110
|
def global_actions
|
|
89
|
-
if self == Base
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
superclass.global_actions + own_global_actions
|
|
93
|
-
end
|
|
111
|
+
return own_global_actions if self == Base
|
|
112
|
+
|
|
113
|
+
memoized_definition(:global_actions) { superclass.global_actions + own_global_actions }
|
|
94
114
|
end
|
|
95
115
|
|
|
96
116
|
def own_global_actions
|
|
@@ -100,28 +120,37 @@ module DeclarativePolicy
|
|
|
100
120
|
# an inheritable map of delegations, indexed by name (which may be
|
|
101
121
|
# autogenerated)
|
|
102
122
|
def delegations
|
|
103
|
-
if self == Base
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
superclass.delegations.merge(own_delegations)
|
|
107
|
-
end
|
|
123
|
+
return own_delegations if self == Base
|
|
124
|
+
|
|
125
|
+
memoized_definition(:delegations) { superclass.delegations.merge(own_delegations) }
|
|
108
126
|
end
|
|
109
127
|
|
|
110
128
|
def own_delegations
|
|
111
129
|
@own_delegations ||= {}
|
|
112
130
|
end
|
|
113
131
|
|
|
132
|
+
# How many declarations (conditions, rules, delegations) have been made
|
|
133
|
+
# across all policies so far. Save this value and compare it later to
|
|
134
|
+
# tell whether any policy has been defined or reopened since.
|
|
135
|
+
def definition_generation
|
|
136
|
+
Base.instance_variable_get(:@definition_generation)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Clears the cached results for every policy class. Declaration methods
|
|
140
|
+
# like condition and enable_when call this for you; call it yourself
|
|
141
|
+
# only if you write to own_conditions and friends directly.
|
|
142
|
+
def invalidate_definitions!
|
|
143
|
+
Base.instance_variable_set(:@definition_generation, definition_generation + 1)
|
|
144
|
+
end
|
|
145
|
+
|
|
114
146
|
# all the [rule, action] pairs that apply to a particular ability.
|
|
115
147
|
# we combine the specific ones looked up in ability_map with the global
|
|
116
148
|
# ones, filtering out any global actions that have excepted this ability.
|
|
117
149
|
def configuration_for(ability)
|
|
118
|
-
|
|
119
|
-
next if exceptions&.include?(ability)
|
|
120
|
-
|
|
121
|
-
[action, rule]
|
|
122
|
-
end
|
|
150
|
+
return build_configuration_for(ability) unless ability_map.key?(ability)
|
|
123
151
|
|
|
124
|
-
|
|
152
|
+
configurations = memoized_definition(:configurations) { {} }
|
|
153
|
+
configurations[ability] ||= build_configuration_for(ability)
|
|
125
154
|
end
|
|
126
155
|
|
|
127
156
|
### declaration methods ###
|
|
@@ -140,6 +169,7 @@ module DeclarativePolicy
|
|
|
140
169
|
# rubocop: enable GitlabSecurity/PublicSend
|
|
141
170
|
|
|
142
171
|
own_delegations[name] = delegation_block
|
|
172
|
+
invalidate_definitions!
|
|
143
173
|
end
|
|
144
174
|
|
|
145
175
|
# Declare that the given abilities should not be read from delegates.
|
|
@@ -202,6 +232,7 @@ module DeclarativePolicy
|
|
|
202
232
|
condition = Condition.new(condition_name, condition_options(opts), &value)
|
|
203
233
|
|
|
204
234
|
own_conditions[condition_name] = condition
|
|
235
|
+
invalidate_definitions!
|
|
205
236
|
|
|
206
237
|
define_method(:"#{condition_name}?") { condition(condition_name).pass? }
|
|
207
238
|
end
|
|
@@ -213,10 +244,12 @@ module DeclarativePolicy
|
|
|
213
244
|
# symbol indicating :prevent or :enable.
|
|
214
245
|
def enable_when(abilities, rule)
|
|
215
246
|
abilities.each { |a| own_ability_map.enable(a, rule) }
|
|
247
|
+
invalidate_definitions!
|
|
216
248
|
end
|
|
217
249
|
|
|
218
250
|
def prevent_when(abilities, rule)
|
|
219
251
|
abilities.each { |a| own_ability_map.prevent(a, rule) }
|
|
252
|
+
invalidate_definitions!
|
|
220
253
|
end
|
|
221
254
|
|
|
222
255
|
# we store global prevents (from `prevent_all`) separately,
|
|
@@ -225,10 +258,37 @@ module DeclarativePolicy
|
|
|
225
258
|
# that should be excluded from the blanket prevent.
|
|
226
259
|
def prevent_all_when(rule, except: nil)
|
|
227
260
|
own_global_actions << [:prevent, rule, except]
|
|
261
|
+
invalidate_definitions!
|
|
228
262
|
end
|
|
229
263
|
|
|
230
264
|
private
|
|
231
265
|
|
|
266
|
+
def build_configuration_for(ability)
|
|
267
|
+
applicable_globals = global_actions.filter_map do |(action, rule, exceptions)|
|
|
268
|
+
next if exceptions&.include?(ability)
|
|
269
|
+
|
|
270
|
+
[action, rule]
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
(ability_map.actions(ability) + applicable_globals).freeze
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Caches this class's combined result (its own declarations plus its
|
|
277
|
+
# superclass's) until any policy anywhere declares something new.
|
|
278
|
+
def memoized_definition(name)
|
|
279
|
+
generation = Base.definition_generation
|
|
280
|
+
|
|
281
|
+
unless @memoized_definitions_at == generation
|
|
282
|
+
@definitions = {}
|
|
283
|
+
@memoized_definitions_at = generation
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
defs = @definitions
|
|
287
|
+
return defs[name] if defs.key?(name)
|
|
288
|
+
|
|
289
|
+
defs.store(name, yield)
|
|
290
|
+
end
|
|
291
|
+
|
|
232
292
|
# retrieve and zero out the previously set options (used in .condition)
|
|
233
293
|
def condition_options(opts)
|
|
234
294
|
# The context_key distinguishes two conditions of the same name.
|
|
@@ -339,7 +399,11 @@ module DeclarativePolicy
|
|
|
339
399
|
def cache(key)
|
|
340
400
|
return @cache[key] if cached?(key)
|
|
341
401
|
|
|
342
|
-
|
|
402
|
+
# Advance the score epoch only once the value is stored: checks run by
|
|
403
|
+
# the block would otherwise keep scores that see the key as missing.
|
|
404
|
+
value = @cache[key] = yield
|
|
405
|
+
DeclarativePolicy.advance_score_epoch!
|
|
406
|
+
value
|
|
343
407
|
end
|
|
344
408
|
|
|
345
409
|
def cached?(key)
|
|
@@ -353,12 +417,21 @@ module DeclarativePolicy
|
|
|
353
417
|
@_conditions ||= {}
|
|
354
418
|
@_conditions[name] ||=
|
|
355
419
|
begin
|
|
356
|
-
|
|
420
|
+
definition = self.class.conditions[name]
|
|
421
|
+
raise "invalid condition #{name}" unless definition
|
|
357
422
|
|
|
358
|
-
ManifestCondition.new(
|
|
423
|
+
ManifestCondition.new(definition, self)
|
|
359
424
|
end
|
|
360
425
|
end
|
|
361
426
|
|
|
427
|
+
def user_cache_key
|
|
428
|
+
@user_cache_key ||= Cache.user_key(@user)
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def subject_cache_key
|
|
432
|
+
@subject_cache_key ||= Cache.subject_key(@subject)
|
|
433
|
+
end
|
|
434
|
+
|
|
362
435
|
# used in specs - returns true if there is no possible way for any action
|
|
363
436
|
# to be allowed, determined only by the global :prevent_all rules.
|
|
364
437
|
# Only considers global actions with no exceptions (a prevent_all with
|
|
@@ -7,7 +7,7 @@ module DeclarativePolicy
|
|
|
7
7
|
# declaration. It holds on to the block to be instance_eval'd
|
|
8
8
|
# on a context (instance of Base) later, via #compute.
|
|
9
9
|
class Condition
|
|
10
|
-
attr_reader :name, :description, :scope, :manual_score, :context_key
|
|
10
|
+
attr_reader :name, :description, :scope, :manual_score, :context_key, :key
|
|
11
11
|
|
|
12
12
|
VALID_SCOPES = %i[user_and_subject user subject global].freeze
|
|
13
13
|
ALLOWED_SCOPES = VALID_SCOPES + %i[normal]
|
|
@@ -20,16 +20,13 @@ module DeclarativePolicy
|
|
|
20
20
|
@description = opts.delete(:description)
|
|
21
21
|
@context_key = opts[:context_key]
|
|
22
22
|
@manual_score = opts.fetch(:score, nil)
|
|
23
|
+
@key = "#{@context_key}/#{@name}".freeze
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
def compute(context)
|
|
26
27
|
!!context.instance_eval(&@compute)
|
|
27
28
|
end
|
|
28
29
|
|
|
29
|
-
def key
|
|
30
|
-
"#{@context_key}/#{@name}"
|
|
31
|
-
end
|
|
32
|
-
|
|
33
30
|
private
|
|
34
31
|
|
|
35
32
|
def fetch_scope(options)
|
|
@@ -107,17 +104,17 @@ module DeclarativePolicy
|
|
|
107
104
|
when :user then "/dp/condition/#{@condition.key}/#{user_key}"
|
|
108
105
|
when :subject then "/dp/condition/#{@condition.key}/#{subject_key}"
|
|
109
106
|
else "/dp/condition/#{@condition.key}/#{user_key},#{subject_key}"
|
|
110
|
-
end
|
|
107
|
+
end.freeze
|
|
111
108
|
end
|
|
112
109
|
|
|
113
110
|
private
|
|
114
111
|
|
|
115
112
|
def user_key
|
|
116
|
-
|
|
113
|
+
@context.user_cache_key
|
|
117
114
|
end
|
|
118
115
|
|
|
119
116
|
def subject_key
|
|
120
|
-
|
|
117
|
+
@context.subject_cache_key
|
|
121
118
|
end
|
|
122
119
|
end
|
|
123
120
|
end
|
|
@@ -4,12 +4,16 @@ module DeclarativePolicy
|
|
|
4
4
|
module PreferredScope
|
|
5
5
|
PREFERRED_SCOPE_KEY = :'DeclarativePolicy.preferred_scope'
|
|
6
6
|
|
|
7
|
+
# Scores depend on the preferred scope (see ManifestCondition#score), so
|
|
8
|
+
# changing it advances the score epoch. The scope is per fiber but the
|
|
9
|
+
# epoch is not, so fibers sharing a cache with different scopes may see
|
|
10
|
+
# each other's scores. That can only change the order of checks.
|
|
7
11
|
def with_preferred_scope(scope)
|
|
8
12
|
old_scope = Thread.current[PREFERRED_SCOPE_KEY]
|
|
9
|
-
|
|
13
|
+
self.preferred_scope = scope
|
|
10
14
|
yield
|
|
11
15
|
ensure
|
|
12
|
-
|
|
16
|
+
self.preferred_scope = old_scope
|
|
13
17
|
end
|
|
14
18
|
|
|
15
19
|
def preferred_scope
|
|
@@ -25,7 +29,10 @@ module DeclarativePolicy
|
|
|
25
29
|
end
|
|
26
30
|
|
|
27
31
|
def preferred_scope=(scope)
|
|
32
|
+
return if Thread.current[PREFERRED_SCOPE_KEY] == scope
|
|
33
|
+
|
|
28
34
|
Thread.current[PREFERRED_SCOPE_KEY] = scope
|
|
35
|
+
DeclarativePolicy.advance_score_epoch!
|
|
29
36
|
end
|
|
30
37
|
end
|
|
31
38
|
end
|
|
@@ -5,12 +5,10 @@ require 'set'
|
|
|
5
5
|
module DeclarativePolicy
|
|
6
6
|
class Runner
|
|
7
7
|
class State
|
|
8
|
-
attr_reader :called_conditions
|
|
9
|
-
|
|
10
8
|
def initialize
|
|
11
9
|
@enabled = false
|
|
12
10
|
@prevented = false
|
|
13
|
-
@
|
|
11
|
+
@called = nil
|
|
14
12
|
end
|
|
15
13
|
|
|
16
14
|
def enable!
|
|
@@ -34,7 +32,31 @@ module DeclarativePolicy
|
|
|
34
32
|
end
|
|
35
33
|
|
|
36
34
|
def register(manifest_condition)
|
|
37
|
-
|
|
35
|
+
called[manifest_condition.cache_key] = true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def called_conditions
|
|
39
|
+
return Set.new unless @called
|
|
40
|
+
|
|
41
|
+
Set.new(@called.keys)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def absorb(other)
|
|
45
|
+
return if other.equal?(self) || other.called_any.nil?
|
|
46
|
+
|
|
47
|
+
called.update(other.called_any)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
protected
|
|
51
|
+
|
|
52
|
+
def called_any
|
|
53
|
+
@called
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def called
|
|
59
|
+
@called ||= {}
|
|
38
60
|
end
|
|
39
61
|
end
|
|
40
62
|
|
|
@@ -44,6 +66,8 @@ module DeclarativePolicy
|
|
|
44
66
|
def initialize(steps)
|
|
45
67
|
@steps = steps
|
|
46
68
|
@state = nil
|
|
69
|
+
@score = nil
|
|
70
|
+
@score_epoch = nil
|
|
47
71
|
end
|
|
48
72
|
|
|
49
73
|
# We make sure only to run any given Runner once,
|
|
@@ -56,13 +80,20 @@ module DeclarativePolicy
|
|
|
56
80
|
# Delete the cached state - allowing this runner to be re-used if the facts have changed.
|
|
57
81
|
def uncache!
|
|
58
82
|
@state = nil
|
|
83
|
+
DeclarativePolicy.advance_score_epoch!
|
|
59
84
|
end
|
|
60
85
|
|
|
61
|
-
# used by Rule::Ability. See #steps_by_score
|
|
86
|
+
# used by Rule::Ability. See #steps_by_score, and Step#score for how
|
|
87
|
+
# scores are kept.
|
|
62
88
|
def score
|
|
63
89
|
return 0 if cached?
|
|
64
90
|
|
|
65
|
-
|
|
91
|
+
epoch = DeclarativePolicy.score_epoch
|
|
92
|
+
return @score if @score_epoch == epoch
|
|
93
|
+
|
|
94
|
+
@score = compute_score
|
|
95
|
+
@score_epoch = epoch
|
|
96
|
+
@score
|
|
66
97
|
end
|
|
67
98
|
|
|
68
99
|
def merge_runner(other)
|
|
@@ -81,7 +112,7 @@ module DeclarativePolicy
|
|
|
81
112
|
run unless cached?
|
|
82
113
|
|
|
83
114
|
parent_state = Thread.current[:declarative_policy_current_runner_state]
|
|
84
|
-
parent_state&.
|
|
115
|
+
parent_state&.absorb(@state)
|
|
85
116
|
|
|
86
117
|
@state.pass?
|
|
87
118
|
end
|
|
@@ -93,19 +124,66 @@ module DeclarativePolicy
|
|
|
93
124
|
|
|
94
125
|
private
|
|
95
126
|
|
|
127
|
+
# A run that raises part way through leaves no state behind, so the next
|
|
128
|
+
# check runs again rather than reading a half-finished decision.
|
|
129
|
+
#
|
|
130
|
+
# A runner counts as cached from the start of its run, which changes the
|
|
131
|
+
# score of every step that depends on it, so the score epoch advances
|
|
132
|
+
# when the run starts, and again when a failed run is discarded.
|
|
96
133
|
def with_state
|
|
97
134
|
@state = State.new
|
|
135
|
+
DeclarativePolicy.advance_score_epoch!
|
|
98
136
|
old_runner_state = Thread.current[:declarative_policy_current_runner_state]
|
|
99
137
|
Thread.current[:declarative_policy_current_runner_state] = @state
|
|
138
|
+
finished = false
|
|
100
139
|
|
|
101
140
|
yield
|
|
141
|
+
finished = true
|
|
102
142
|
ensure
|
|
103
143
|
Thread.current[:declarative_policy_current_runner_state] = old_runner_state
|
|
144
|
+
uncache! unless finished
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def compute_score
|
|
148
|
+
steps.sum(&:score)
|
|
104
149
|
end
|
|
105
150
|
|
|
106
151
|
def flatten_steps!
|
|
107
|
-
|
|
152
|
+
roots = @steps.size > RootSteps::MAX_SCANNED_STEPS ? RootSteps.new(@steps) : @steps
|
|
153
|
+
@steps = @steps.flat_map { |s| s.flattened(roots) }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The steps being flattened, for Step#flattened to tell whether a step is
|
|
157
|
+
# among them. A faster stand-in for Array#include?, which compares every
|
|
158
|
+
# step with Step#==. Rules and policies don't define ==, so comparing
|
|
159
|
+
# them by identity gives the same answer. The index is built on first
|
|
160
|
+
# use, since only `can?` rules need it.
|
|
161
|
+
class RootSteps
|
|
162
|
+
# Up to this many steps, scanning the array is cheaper than building
|
|
163
|
+
# the index.
|
|
164
|
+
MAX_SCANNED_STEPS = 8
|
|
165
|
+
|
|
166
|
+
def initialize(steps)
|
|
167
|
+
@steps = steps
|
|
168
|
+
@index = nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def include?(step)
|
|
172
|
+
actions = index[step.rule]&.[](step.context)
|
|
173
|
+
|
|
174
|
+
actions ? actions.include?(step.action) : false
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
def index
|
|
180
|
+
@index ||= @steps.each_with_object({}.compare_by_identity) do |step, by_rule|
|
|
181
|
+
by_context = (by_rule[step.rule] ||= {}.compare_by_identity)
|
|
182
|
+
(by_context[step.context] ||= []) << step.action
|
|
183
|
+
end
|
|
184
|
+
end
|
|
108
185
|
end
|
|
186
|
+
private_constant :RootSteps
|
|
109
187
|
|
|
110
188
|
# This method implements the semantic of "one enable and no prevents".
|
|
111
189
|
# It relies on #steps_by_score for the main loop, and updates @state
|
|
@@ -158,7 +236,7 @@ module DeclarativePolicy
|
|
|
158
236
|
# So! The way we sort here involves re-scoring at every step. This
|
|
159
237
|
# is by necessity quadratic, but most of the time the number of steps
|
|
160
238
|
# will be low. But just in case, if the number of steps exceeds 50,
|
|
161
|
-
# we
|
|
239
|
+
# we fall back to a static sort.
|
|
162
240
|
#
|
|
163
241
|
# For each step, we yield the step object along with the computed score
|
|
164
242
|
# for debugging purposes.
|
|
@@ -166,8 +244,6 @@ module DeclarativePolicy
|
|
|
166
244
|
flatten_steps!
|
|
167
245
|
|
|
168
246
|
if @steps.size > 50
|
|
169
|
-
warn "DeclarativePolicy: large number of steps (#{steps.size}), falling back to static sort"
|
|
170
|
-
|
|
171
247
|
@steps.map { |s| [s.score, s] }.sort_by { |(score, _)| score }.each do |(score, step)|
|
|
172
248
|
yield step, score
|
|
173
249
|
end
|
|
@@ -175,49 +251,53 @@ module DeclarativePolicy
|
|
|
175
251
|
return
|
|
176
252
|
end
|
|
177
253
|
|
|
178
|
-
|
|
179
|
-
|
|
254
|
+
remaining = @steps.uniq
|
|
255
|
+
enablers_left = remaining.count(&:enable?)
|
|
256
|
+
preventers_only = false
|
|
180
257
|
|
|
181
258
|
loop do
|
|
182
259
|
if @state.enabled?
|
|
183
260
|
# Once we set this, we never need to unset it, because a single
|
|
184
261
|
# prevent will stop this from being enabled
|
|
185
|
-
|
|
186
|
-
|
|
262
|
+
unless preventers_only
|
|
263
|
+
remaining.reject!(&:enable?)
|
|
264
|
+
preventers_only = true
|
|
265
|
+
end
|
|
266
|
+
elsif enablers_left.zero?
|
|
187
267
|
# if the permission hasn't yet been enabled and we only have
|
|
188
268
|
# prevent steps left, we short-circuit the state here
|
|
189
269
|
@state.prevent!
|
|
190
270
|
return unless debugging
|
|
191
271
|
end
|
|
192
272
|
|
|
193
|
-
return if
|
|
273
|
+
return if remaining.empty?
|
|
194
274
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
set.delete(next_step)
|
|
199
|
-
end
|
|
275
|
+
lowest_index, lowest_score = cheapest_step(remaining)
|
|
276
|
+
step = remaining.delete_at(lowest_index)
|
|
277
|
+
enablers_left -= 1 if step.enable?
|
|
200
278
|
|
|
201
|
-
yield
|
|
279
|
+
yield step, lowest_score
|
|
202
280
|
end
|
|
203
281
|
end
|
|
204
282
|
|
|
205
|
-
def
|
|
283
|
+
def cheapest_step(remaining)
|
|
206
284
|
lowest_score = Float::INFINITY
|
|
207
|
-
|
|
285
|
+
lowest_index = 0
|
|
286
|
+
index = 0
|
|
208
287
|
|
|
209
|
-
|
|
210
|
-
score =
|
|
288
|
+
while index < remaining.size
|
|
289
|
+
score = remaining[index].score
|
|
211
290
|
|
|
212
291
|
if score < lowest_score
|
|
213
|
-
next_step = step
|
|
214
292
|
lowest_score = score
|
|
293
|
+
lowest_index = index
|
|
294
|
+
break if score.zero?
|
|
215
295
|
end
|
|
216
296
|
|
|
217
|
-
|
|
297
|
+
index += 1
|
|
218
298
|
end
|
|
219
299
|
|
|
220
|
-
[
|
|
300
|
+
[lowest_index, lowest_score]
|
|
221
301
|
end
|
|
222
302
|
|
|
223
303
|
# Formatter for debugging output.
|
|
@@ -13,6 +13,8 @@ module DeclarativePolicy
|
|
|
13
13
|
@context = context
|
|
14
14
|
@rule = rule
|
|
15
15
|
@action = action
|
|
16
|
+
@score = nil
|
|
17
|
+
@score_epoch = nil
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
# In the flattening process, duplicate steps may be generated in the
|
|
@@ -26,15 +28,17 @@ module DeclarativePolicy
|
|
|
26
28
|
# we are sure to compute them in close to the optimal order.
|
|
27
29
|
#
|
|
28
30
|
# See also Rule#score, ManifestCondition#score, and Runner#steps_by_score.
|
|
31
|
+
#
|
|
32
|
+
# A score only changes when the score epoch does, so it is kept until
|
|
33
|
+
# then. The epoch is read before scoring, since scoring can run code that
|
|
34
|
+
# advances it.
|
|
29
35
|
def score
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
@rule.score(@context)
|
|
37
|
-
end
|
|
36
|
+
epoch = DeclarativePolicy.score_epoch
|
|
37
|
+
return @score if @score_epoch == epoch
|
|
38
|
+
|
|
39
|
+
@score = compute_score
|
|
40
|
+
@score_epoch = epoch
|
|
41
|
+
@score
|
|
38
42
|
end
|
|
39
43
|
|
|
40
44
|
def with_action(action)
|
|
@@ -61,7 +65,19 @@ module DeclarativePolicy
|
|
|
61
65
|
# in some cases. If we depend on an Ability (i.e. a `can?(...)` rule),
|
|
62
66
|
# and that ability *only* has :enable actions (modulo some actions that
|
|
63
67
|
# we already have taken care of), then its rules can be safely inlined.
|
|
64
|
-
|
|
68
|
+
#
|
|
69
|
+
# Steps that are already in `roots` are skipped, since they run anyway.
|
|
70
|
+
# A :prevent step can only skip :prevent steps, because each :enable
|
|
71
|
+
# step it inlines would turn into a :prevent. For example, with
|
|
72
|
+
#
|
|
73
|
+
# rule { member }.enable :read, :write
|
|
74
|
+
# rule { can?(:read) }.prevent :write
|
|
75
|
+
#
|
|
76
|
+
# `enable when member` is already a step of :write, but inlining needs
|
|
77
|
+
# `prevent when member`, so it must not be skipped.
|
|
78
|
+
steps = @context.runner(@rule.ability).steps.reject do |s|
|
|
79
|
+
roots.include?(s) && !(prevent? && s.enable?)
|
|
80
|
+
end
|
|
65
81
|
|
|
66
82
|
if steps.all?(&:enable?)
|
|
67
83
|
# in the case that we are a :prevent step, each inlined step becomes
|
|
@@ -85,5 +101,18 @@ module DeclarativePolicy
|
|
|
85
101
|
def repr
|
|
86
102
|
"#{@action} when #{@rule.repr} (#{@context.repr})"
|
|
87
103
|
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def compute_score
|
|
108
|
+
# we slightly prefer the preventative actions
|
|
109
|
+
# since they are more likely to short-circuit
|
|
110
|
+
case @action
|
|
111
|
+
when :prevent
|
|
112
|
+
@rule.score(@context) * (7.0 / 8)
|
|
113
|
+
when :enable
|
|
114
|
+
@rule.score(@context)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
88
117
|
end
|
|
89
118
|
end
|
data/lib/declarative_policy.rb
CHANGED
|
@@ -19,7 +19,23 @@ require_relative 'declarative_policy/configuration'
|
|
|
19
19
|
module DeclarativePolicy
|
|
20
20
|
extend PreferredScope
|
|
21
21
|
|
|
22
|
+
# Counts changes to anything a step's score depends on: a condition being
|
|
23
|
+
# cached, a runner starting or being uncached, and the preferred scope.
|
|
24
|
+
# Steps and runners keep their last score until this changes (see
|
|
25
|
+
# Step#score). It is shared by every thread. A change made elsewhere, or an
|
|
26
|
+
# update lost on a Ruby without a global lock, can only change the order of
|
|
27
|
+
# checks, not their result.
|
|
28
|
+
@score_epoch = 0
|
|
29
|
+
|
|
22
30
|
class << self
|
|
31
|
+
# @api private
|
|
32
|
+
attr_reader :score_epoch
|
|
33
|
+
|
|
34
|
+
# @api private
|
|
35
|
+
def advance_score_epoch!
|
|
36
|
+
@score_epoch += 1
|
|
37
|
+
end
|
|
38
|
+
|
|
23
39
|
def policy_for(user, subject, opts = {})
|
|
24
40
|
cache = opts[:cache] || {}
|
|
25
41
|
key = Cache.policy_key(user, subject)
|
|
@@ -37,12 +53,13 @@ module DeclarativePolicy
|
|
|
37
53
|
policies = cache.select { |k, _| k.is_a?(String) && k.start_with?('/dp/policy/') }
|
|
38
54
|
|
|
39
55
|
policies.each_value do |policy|
|
|
40
|
-
policy.runners.
|
|
56
|
+
policy.runners.each_value do |runner|
|
|
41
57
|
runner.uncache! if keys.intersect?(runner.dependencies)
|
|
42
58
|
end
|
|
43
59
|
end
|
|
44
60
|
|
|
45
61
|
invalidated_keys.each { |k| cache.delete(k) }
|
|
62
|
+
advance_score_epoch!
|
|
46
63
|
|
|
47
64
|
nil
|
|
48
65
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: declarative_policy
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- group::authorization
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: benchmark-ips
|