declarative_policy 2.1.0 → 2.2.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: 735e59059f970e9e39c6fc37b1d97f7b20c030a472ca287cb95d4503a168c3a3
4
+ data.tar.gz: e4bd527d1ab32d86a77666e9b0c175a2e5c1cc8d4627b94fa3d01eb8c6dc6e10
5
5
  SHA512:
6
- metadata.gz: 82ac859206da667fb45b59662aa9a35fb760f59179e0976f9e7ed9ffba92259ba271fa6732ec9b84e3e14e14961f1362fb13077deb7191c75436c83ef60ab342
7
- data.tar.gz: c9e41debc20ab2ecc2dc8da44c2e782f50e58ec7d9cba23986c5494c7c00d334de82a3f6f2c9a5b35739a402ecc097f144cb05314ab8584f07aa46dab548b6c0
6
+ metadata.gz: 65796bc8d62ce7648433adba6feb99e78e2fff32c27902318a8394973fad38a8b7a476b8a13806178c8d4b20f8f5385e398336c2872a6c58122d213c8d864fbb
7
+ data.tar.gz: 4ab6e8f55e9efadbf2169bd4b385ec8620fdca8247cfcc1b977fc3ac5fb02f045fb1616caef9878fd0e92f37f6a98c0a58b04f458c9b2c67aec98d4b22545b99
@@ -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.
@@ -353,12 +413,21 @@ module DeclarativePolicy
353
413
  @_conditions ||= {}
354
414
  @_conditions[name] ||=
355
415
  begin
356
- raise "invalid condition #{name}" unless self.class.conditions.key?(name)
416
+ definition = self.class.conditions[name]
417
+ raise "invalid condition #{name}" unless definition
357
418
 
358
- ManifestCondition.new(self.class.conditions[name], self)
419
+ ManifestCondition.new(definition, self)
359
420
  end
360
421
  end
361
422
 
423
+ def user_cache_key
424
+ @user_cache_key ||= Cache.user_key(@user)
425
+ end
426
+
427
+ def subject_cache_key
428
+ @subject_cache_key ||= Cache.subject_key(@subject)
429
+ end
430
+
362
431
  # used in specs - returns true if there is no possible way for any action
363
432
  # to be allowed, determined only by the global :prevent_all rules.
364
433
  # 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
@@ -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
 
@@ -81,7 +103,7 @@ module DeclarativePolicy
81
103
  run unless cached?
82
104
 
83
105
  parent_state = Thread.current[:declarative_policy_current_runner_state]
84
- parent_state&.called_conditions&.merge(@state.called_conditions)
106
+ parent_state&.absorb(@state)
85
107
 
86
108
  @state.pass?
87
109
  end
@@ -175,49 +197,53 @@ module DeclarativePolicy
175
197
  return
176
198
  end
177
199
 
178
- remaining_steps = Set.new(@steps)
179
- remaining_enablers, remaining_preventers = remaining_steps.partition(&:enable?).map { |s| Set.new(s) }
200
+ remaining = @steps.uniq
201
+ enablers_left = remaining.count(&:enable?)
202
+ preventers_only = false
180
203
 
181
204
  loop do
182
205
  if @state.enabled?
183
206
  # Once we set this, we never need to unset it, because a single
184
207
  # prevent will stop this from being enabled
185
- remaining_steps = remaining_preventers
186
- elsif remaining_enablers.empty?
208
+ unless preventers_only
209
+ remaining.reject!(&:enable?)
210
+ preventers_only = true
211
+ end
212
+ elsif enablers_left.zero?
187
213
  # if the permission hasn't yet been enabled and we only have
188
214
  # prevent steps left, we short-circuit the state here
189
215
  @state.prevent!
190
216
  return unless debugging
191
217
  end
192
218
 
193
- return if remaining_steps.empty?
219
+ return if remaining.empty?
194
220
 
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
221
+ lowest_index, lowest_score = cheapest_step(remaining)
222
+ step = remaining.delete_at(lowest_index)
223
+ enablers_left -= 1 if step.enable?
200
224
 
201
- yield next_step, lowest_score
225
+ yield step, lowest_score
202
226
  end
203
227
  end
204
228
 
205
- def next_step_and_score(remaining_steps)
229
+ def cheapest_step(remaining)
206
230
  lowest_score = Float::INFINITY
207
- next_step = nil
231
+ lowest_index = 0
232
+ index = 0
208
233
 
209
- remaining_steps.each do |step|
210
- score = step.score
234
+ while index < remaining.size
235
+ score = remaining[index].score
211
236
 
212
237
  if score < lowest_score
213
- next_step = step
214
238
  lowest_score = score
239
+ lowest_index = index
240
+ break if score.zero?
215
241
  end
216
242
 
217
- break if lowest_score.zero?
243
+ index += 1
218
244
  end
219
245
 
220
- [next_step, lowest_score]
246
+ [lowest_index, lowest_score]
221
247
  end
222
248
 
223
249
  # Formatter for debugging output.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeclarativePolicy
4
- VERSION = '2.1.0'
4
+ VERSION = '2.2.0'
5
5
  end
@@ -37,7 +37,7 @@ module DeclarativePolicy
37
37
  policies = cache.select { |k, _| k.is_a?(String) && k.start_with?('/dp/policy/') }
38
38
 
39
39
  policies.each_value do |policy|
40
- policy.runners.each do |runner|
40
+ policy.runners.each_value do |runner|
41
41
  runner.uncache! if keys.intersect?(runner.dependencies)
42
42
  end
43
43
  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.2.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-09-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: benchmark-ips