declarative_policy 2.2.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: 735e59059f970e9e39c6fc37b1d97f7b20c030a472ca287cb95d4503a168c3a3
4
- data.tar.gz: e4bd527d1ab32d86a77666e9b0c175a2e5c1cc8d4627b94fa3d01eb8c6dc6e10
3
+ metadata.gz: bf0b45880663f66032b9313af36819d078739f7430b53c9f182a4a8904acb443
4
+ data.tar.gz: c4e162b87f85d1008d5fa956f72f4745c4bb208b59796f95b472f4606504f295
5
5
  SHA512:
6
- metadata.gz: 65796bc8d62ce7648433adba6feb99e78e2fff32c27902318a8394973fad38a8b7a476b8a13806178c8d4b20f8f5385e398336c2872a6c58122d213c8d864fbb
7
- data.tar.gz: 4ab6e8f55e9efadbf2169bd4b385ec8620fdca8247cfcc1b977fc3ac5fb02f045fb1616caef9878fd0e92f37f6a98c0a58b04f458c9b2c67aec98d4b22545b99
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
 
@@ -399,7 +399,11 @@ module DeclarativePolicy
399
399
  def cache(key)
400
400
  return @cache[key] if cached?(key)
401
401
 
402
- @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
403
407
  end
404
408
 
405
409
  def cached?(key)
@@ -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
@@ -66,6 +66,8 @@ module DeclarativePolicy
66
66
  def initialize(steps)
67
67
  @steps = steps
68
68
  @state = nil
69
+ @score = nil
70
+ @score_epoch = nil
69
71
  end
70
72
 
71
73
  # We make sure only to run any given Runner once,
@@ -78,13 +80,20 @@ module DeclarativePolicy
78
80
  # Delete the cached state - allowing this runner to be re-used if the facts have changed.
79
81
  def uncache!
80
82
  @state = nil
83
+ DeclarativePolicy.advance_score_epoch!
81
84
  end
82
85
 
83
- # 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.
84
88
  def score
85
89
  return 0 if cached?
86
90
 
87
- 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
88
97
  end
89
98
 
90
99
  def merge_runner(other)
@@ -115,19 +124,66 @@ module DeclarativePolicy
115
124
 
116
125
  private
117
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.
118
133
  def with_state
119
134
  @state = State.new
135
+ DeclarativePolicy.advance_score_epoch!
120
136
  old_runner_state = Thread.current[:declarative_policy_current_runner_state]
121
137
  Thread.current[:declarative_policy_current_runner_state] = @state
138
+ finished = false
122
139
 
123
140
  yield
141
+ finished = true
124
142
  ensure
125
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)
126
149
  end
127
150
 
128
151
  def flatten_steps!
129
- @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
130
185
  end
186
+ private_constant :RootSteps
131
187
 
132
188
  # This method implements the semantic of "one enable and no prevents".
133
189
  # It relies on #steps_by_score for the main loop, and updates @state
@@ -180,7 +236,7 @@ module DeclarativePolicy
180
236
  # So! The way we sort here involves re-scoring at every step. This
181
237
  # is by necessity quadratic, but most of the time the number of steps
182
238
  # will be low. But just in case, if the number of steps exceeds 50,
183
- # we print a warning and fall back to a static sort.
239
+ # we fall back to a static sort.
184
240
  #
185
241
  # For each step, we yield the step object along with the computed score
186
242
  # for debugging purposes.
@@ -188,8 +244,6 @@ module DeclarativePolicy
188
244
  flatten_steps!
189
245
 
190
246
  if @steps.size > 50
191
- warn "DeclarativePolicy: large number of steps (#{steps.size}), falling back to static sort"
192
-
193
247
  @steps.map { |s| [s.score, s] }.sort_by { |(score, _)| score }.each do |(score, step)|
194
248
  yield step, score
195
249
  end
@@ -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.2.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)
@@ -43,6 +59,7 @@ module DeclarativePolicy
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.2.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-09-26 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