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 +4 -4
- data/doc/caching.md +10 -3
- data/doc/optimization.md +3 -1
- data/lib/declarative_policy/base.rb +5 -1
- data/lib/declarative_policy/preferred_scope.rb +9 -2
- data/lib/declarative_policy/runner.rb +60 -6
- data/lib/declarative_policy/step.rb +38 -9
- data/lib/declarative_policy/version.rb +1 -1
- data/lib/declarative_policy.rb +17 -0
- 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
|
|
|
@@ -399,7 +399,11 @@ module DeclarativePolicy
|
|
|
399
399
|
def cache(key)
|
|
400
400
|
return @cache[key] if cached?(key)
|
|
401
401
|
|
|
402
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
@@ -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.
|
|
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
|