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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 05f875265a827bd025947cd0098d09804051cf0fa2c02857aa230e03d8554f02
4
- data.tar.gz: 38452f2308ad61d9cf54bb604f68cbdd73cb75d342ea24a55c265ee708f588b4
3
+ metadata.gz: bf0b45880663f66032b9313af36819d078739f7430b53c9f182a4a8904acb443
4
+ data.tar.gz: c4e162b87f85d1008d5fa956f72f4745c4bb208b59796f95b472f4606504f295
5
5
  SHA512:
6
- metadata.gz: 82ac859206da667fb45b59662aa9a35fb760f59179e0976f9e7ed9ffba92259ba271fa6732ec9b84e3e14e14961f1362fb13077deb7191c75436c83ef60ab342
7
- data.tar.gz: c9e41debc20ab2ecc2dc8da44c2e782f50e58ec7d9cba23986c5494c7c00d334de82a3f6f2c9a5b35739a402ecc097f144cb05314ab8584f07aa46dab548b6c0
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. Keys are never deleted by the library, and values
152
- are only computed if the key is not cached, so it is up to the application code
153
- to determine the life-time of each key.
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
- actions(key) << [:enable, rule]
37
+ rules_for(key) << [:enable, rule]
29
38
  end
30
39
 
31
40
  def prevent(key, rule)
32
- actions(key) << [:prevent, rule]
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
- own_ability_map
63
- else
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
- own_conditions
76
- else
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
- own_global_actions
91
- else
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
- own_delegations
105
- else
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
- applicable_globals = global_actions.filter_map do |(action, rule, exceptions)|
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
- ability_map.actions(ability) + applicable_globals
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
- @cache[key] = yield
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
- raise "invalid condition #{name}" unless self.class.conditions.key?(name)
420
+ definition = self.class.conditions[name]
421
+ raise "invalid condition #{name}" unless definition
357
422
 
358
- ManifestCondition.new(self.class.conditions[name], self)
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
- Cache.user_key(@context.user)
113
+ @context.user_cache_key
117
114
  end
118
115
 
119
116
  def subject_key
120
- Cache.subject_key(@context.subject)
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
- Thread.current[PREFERRED_SCOPE_KEY] = scope
13
+ self.preferred_scope = scope
10
14
  yield
11
15
  ensure
12
- Thread.current[PREFERRED_SCOPE_KEY] = old_scope
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
- @called_conditions = Set.new
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
- @called_conditions << manifest_condition.cache_key
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
- steps.sum(&:score)
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&.called_conditions&.merge(@state.called_conditions)
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
- @steps = @steps.flat_map { |s| s.flattened(@steps) }
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 print a warning and fall back to a static sort.
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
- remaining_steps = Set.new(@steps)
179
- remaining_enablers, remaining_preventers = remaining_steps.partition(&:enable?).map { |s| Set.new(s) }
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
- remaining_steps = remaining_preventers
186
- elsif remaining_enablers.empty?
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 remaining_steps.empty?
273
+ return if remaining.empty?
194
274
 
195
- next_step, lowest_score = next_step_and_score(remaining_steps)
196
-
197
- [remaining_steps, remaining_enablers, remaining_preventers].each do |set|
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 next_step, lowest_score
279
+ yield step, lowest_score
202
280
  end
203
281
  end
204
282
 
205
- def next_step_and_score(remaining_steps)
283
+ def cheapest_step(remaining)
206
284
  lowest_score = Float::INFINITY
207
- next_step = nil
285
+ lowest_index = 0
286
+ index = 0
208
287
 
209
- remaining_steps.each do |step|
210
- score = step.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
- break if lowest_score.zero?
297
+ index += 1
218
298
  end
219
299
 
220
- [next_step, lowest_score]
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
- # we slightly prefer the preventative actions
31
- # since they are more likely to short-circuit
32
- case @action
33
- when :prevent
34
- @rule.score(@context) * (7.0 / 8)
35
- when :enable
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
- steps = @context.runner(@rule.ability).steps.reject { |s| roots.include?(s) }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeclarativePolicy
4
- VERSION = '2.1.0'
4
+ VERSION = '2.3.0'
5
5
  end
@@ -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.each do |runner|
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.1.0
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-03-13 00:00:00.000000000 Z
11
+ date: 2026-10-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: benchmark-ips