declarative_policy 1.0.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -33,6 +33,24 @@ module DeclarativePolicy
33
33
  end
34
34
  end
35
35
 
36
+ class Options
37
+ def initialize
38
+ @hash = {}
39
+ end
40
+
41
+ def []=(key, value)
42
+ @hash[key.to_sym] = value
43
+ end
44
+
45
+ def [](key)
46
+ @hash[key.to_sym]
47
+ end
48
+
49
+ def to_h
50
+ @hash
51
+ end
52
+ end
53
+
36
54
  class << self
37
55
  # The `own_ability_map` vs `ability_map` distinction is used so that
38
56
  # the data structure is properly inherited - with subclasses recursively
@@ -146,46 +164,40 @@ module DeclarativePolicy
146
164
 
147
165
  # A hash in which to store calls to `desc` and `with_scope`, etc.
148
166
  def last_options
149
- @last_options ||= {}.with_indifferent_access
167
+ @last_options ||= Options.new
150
168
  end
151
169
 
152
- # retrieve and zero out the previously set options (used in .condition)
153
- def last_options!
154
- last_options.tap { @last_options = nil }
170
+ def with_options(opts = {})
171
+ last_options.to_h.merge!(opts.to_h)
155
172
  end
156
173
 
157
174
  # Declare a description for the following condition. Currently unused,
158
175
  # but opens the potential for explaining to users why they were or were
159
176
  # not able to do something.
160
177
  def desc(description)
161
- last_options[:description] = description
162
- end
163
-
164
- def with_options(opts = {})
165
- last_options.merge!(opts)
178
+ with_options description: description
166
179
  end
167
180
 
181
+ # Declare a scope for the following condition.
168
182
  def with_scope(scope)
169
183
  with_options scope: scope
170
184
  end
171
185
 
186
+ # Declare a score for the following condition.
172
187
  def with_score(score)
173
188
  with_options score: score
174
189
  end
175
190
 
176
191
  # Declares a condition. It gets stored in `own_conditions`, and generates
177
192
  # a query method based on the condition's name.
178
- def condition(name, opts = {}, &value)
179
- name = name.to_sym
193
+ def condition(condition_name, opts = {}, &value)
194
+ condition_name = condition_name.to_sym
180
195
 
181
- opts = last_options!.merge(opts)
182
- opts[:context_key] ||= self.name
196
+ condition = Condition.new(condition_name, condition_options(opts), &value)
183
197
 
184
- condition = Condition.new(name, opts, &value)
198
+ own_conditions[condition_name] = condition
185
199
 
186
- own_conditions[name] = condition
187
-
188
- define_method(:"#{name}?") { condition(name).pass? }
200
+ define_method(:"#{condition_name}?") { condition(condition_name).pass? }
189
201
  end
190
202
 
191
203
  # These next three methods are mainly called from PolicyDsl,
@@ -206,6 +218,16 @@ module DeclarativePolicy
206
218
  def prevent_all_when(rule)
207
219
  own_global_actions << [:prevent, rule]
208
220
  end
221
+
222
+ private
223
+
224
+ # retrieve and zero out the previously set options (used in .condition)
225
+ def condition_options(opts)
226
+ # The context_key distinguishes two conditions of the same name.
227
+ # For anonymous classes, use object_id.
228
+ opts[:context_key] ||= (name || object_id)
229
+ with_options(opts).tap { @last_options = nil }
230
+ end
209
231
  end
210
232
 
211
233
  # A policy object contains a specific user and subject on which
@@ -254,16 +276,23 @@ module DeclarativePolicy
254
276
  condition(:default, scope: :global, score: 0) { true }
255
277
 
256
278
  def repr
257
- subject_repr =
258
- if @subject.respond_to?(:id)
259
- "#{@subject.class.name}/#{@subject.id}"
260
- else
261
- @subject.inspect
262
- end
279
+ "(#{identify_user} : #{identify_subject})"
280
+ end
263
281
 
264
- user_repr = @user.try(:to_reference) || '<anonymous>'
282
+ def identify_user
283
+ return '<anonymous>' unless @user
265
284
 
266
- "(#{user_repr} : #{subject_repr})"
285
+ @user.to_reference
286
+ rescue NoMethodError
287
+ "<#{@user.class}: #{@user.object_id}>"
288
+ end
289
+
290
+ def identify_subject
291
+ if @subject.respond_to?(:id)
292
+ "#{@subject.class.name}/#{@subject.id}"
293
+ else
294
+ @subject.inspect
295
+ end
267
296
  end
268
297
 
269
298
  def inspect
@@ -276,19 +305,22 @@ module DeclarativePolicy
276
305
  # at the ability level.
277
306
  def runner(ability)
278
307
  ability = ability.to_sym
279
- @runners ||= {}
280
- @runners[ability] ||=
308
+ runners[ability] ||=
281
309
  begin
282
310
  own_runner = Runner.new(own_steps(ability))
283
311
  if self.class.overrides.include?(ability)
284
312
  own_runner
285
313
  else
286
314
  delegated_runners = delegated_policies.values.compact.map { |p| p.runner(ability) }
287
- delegated_runners.inject(own_runner, &:merge_runner)
315
+ delegated_runners.reduce(own_runner, &:merge_runner)
288
316
  end
289
317
  end
290
318
  end
291
319
 
320
+ def runners
321
+ @runners ||= {}
322
+ end
323
+
292
324
  # Helpers for caching. Used by ManifestCondition in performing condition
293
325
  # computation.
294
326
  #
@@ -6,7 +6,7 @@ module DeclarativePolicy
6
6
  def user_key(user)
7
7
  return '<anonymous>' if user.nil?
8
8
 
9
- id_for(user)
9
+ "#{user.class.name}:#{id_for(user)}"
10
10
  end
11
11
 
12
12
  def policy_key(user, subject)
@@ -40,6 +40,8 @@ module DeclarativePolicy
40
40
  # the context's cache here so that we can share in the global
41
41
  # cache (often RequestStore or similar).
42
42
  def pass?
43
+ Thread.current[:declarative_policy_current_runner_state]&.register(self)
44
+
43
45
  @context.cache(cache_key) { @condition.compute(@context) }
44
46
  end
45
47
 
@@ -76,8 +78,6 @@ module DeclarativePolicy
76
78
  8
77
79
  end
78
80
 
79
- private
80
-
81
81
  # This method controls the caching for the condition. This is where
82
82
  # the condition(scope: ...) option comes into play. Notice that
83
83
  # depending on the scope, we may cache only by the user or only by
@@ -93,6 +93,8 @@ module DeclarativePolicy
93
93
  end
94
94
  end
95
95
 
96
+ private
97
+
96
98
  def user_key
97
99
  Cache.user_key(@context.user)
98
100
  end
@@ -7,6 +7,7 @@ module DeclarativePolicy
7
7
  def initialize
8
8
  @named_policies = {}
9
9
  @name_transformation = ->(name) { "#{name}Policy" }
10
+ @class_for = ->(name) { Object.const_get(name) }
10
11
  end
11
12
 
12
13
  def named_policy(name, policy = nil)
@@ -26,10 +27,15 @@ module DeclarativePolicy
26
27
  nil
27
28
  end
28
29
 
30
+ def class_for(&block)
31
+ @class_for = block
32
+ nil
33
+ end
34
+
29
35
  def policy_class(domain_class_name)
30
36
  return unless domain_class_name
31
37
 
32
- @name_transformation.call(domain_class_name).constantize
38
+ @class_for.call((@name_transformation.call(domain_class_name)))
33
39
  rescue NameError
34
40
  nil
35
41
  end
@@ -210,7 +210,7 @@ module DeclarativePolicy
210
210
  cached = cached_pass?(context)
211
211
  return cached unless cached.nil?
212
212
 
213
- @rules.all? { |r| r.pass?(context) }
213
+ @rules.sort_by { |r| r.score(context) }.all? { |r| r.pass?(context) }
214
214
  end
215
215
 
216
216
  def cached_pass?(context)
@@ -240,7 +240,7 @@ module DeclarativePolicy
240
240
  cached = cached_pass?(context)
241
241
  return cached unless cached.nil?
242
242
 
243
- @rules.any? { |r| r.pass?(context) }
243
+ @rules.sort_by { |r| r.score(context) }.any? { |r| r.pass?(context) }
244
244
  end
245
245
 
246
246
  def simplify
@@ -285,9 +285,9 @@ module DeclarativePolicy
285
285
 
286
286
  def simplify
287
287
  case @rule
288
- when And then Or.new(@rule.rules.map(&:negate)).simplify
289
- when Or then And.new(@rule.rules.map(&:negate)).simplify
290
- when Not then @rule.rule.simplify
288
+ when And then Or.new(@rule.rules.map(&:negate)).simplify # DeMorgan's laws
289
+ when Or then And.new(@rule.rules.map(&:negate)).simplify # DeMorgan's laws
290
+ when Not then @rule.rule.simplify # double negation
291
291
  else Not.new(@rule.simplify)
292
292
  end
293
293
  end
@@ -1,11 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'set'
4
+
3
5
  module DeclarativePolicy
4
6
  class Runner
5
7
  class State
8
+ attr_reader :called_conditions
9
+
6
10
  def initialize
7
11
  @enabled = false
8
12
  @prevented = false
13
+ @called_conditions = Set.new
9
14
  end
10
15
 
11
16
  def enable!
@@ -27,6 +32,10 @@ module DeclarativePolicy
27
32
  def pass?
28
33
  !prevented? && enabled?
29
34
  end
35
+
36
+ def register(manifest_condition)
37
+ @called_conditions << manifest_condition.cache_key
38
+ end
30
39
  end
31
40
 
32
41
  # a Runner contains a list of Steps to be run.
@@ -44,6 +53,11 @@ module DeclarativePolicy
44
53
  !!@state
45
54
  end
46
55
 
56
+ # Delete the cached state - allowing this runner to be re-used if the facts have changed.
57
+ def uncache!
58
+ @state = nil
59
+ end
60
+
47
61
  # used by Rule::Ability. See #steps_by_score
48
62
  def score
49
63
  return 0 if cached?
@@ -55,11 +69,20 @@ module DeclarativePolicy
55
69
  Runner.new(@steps + other.steps)
56
70
  end
57
71
 
72
+ def dependencies
73
+ return Set.new unless @state
74
+
75
+ @state.called_conditions
76
+ end
77
+
58
78
  # The main entry point, called for making an ability decision.
59
79
  # See #run and DeclarativePolicy::Base#can?
60
80
  def pass?
61
81
  run unless cached?
62
82
 
83
+ parent_state = Thread.current[:declarative_policy_current_runner_state]
84
+ parent_state&.called_conditions&.merge(@state.called_conditions)
85
+
63
86
  @state.pass?
64
87
  end
65
88
 
@@ -70,6 +93,16 @@ module DeclarativePolicy
70
93
 
71
94
  private
72
95
 
96
+ def with_state(&block)
97
+ @state = State.new
98
+ old_runner_state = Thread.current[:declarative_policy_current_runner_state]
99
+ Thread.current[:declarative_policy_current_runner_state] = @state
100
+
101
+ yield
102
+ ensure
103
+ Thread.current[:declarative_policy_current_runner_state] = old_runner_state
104
+ end
105
+
73
106
  def flatten_steps!
74
107
  @steps = @steps.flat_map { |s| s.flattened(@steps) }
75
108
  end
@@ -78,33 +111,31 @@ module DeclarativePolicy
78
111
  # It relies on #steps_by_score for the main loop, and updates @state
79
112
  # with the result of the step.
80
113
  def run(debug = nil)
81
- @state = State.new
82
-
83
- steps_by_score do |step, score|
84
- break if !debug && @state.prevented?
85
-
86
- passed = nil
87
- case step.action
88
- when :enable
89
- # we only check :enable actions if they have a chance of
90
- # changing the outcome - if no other rule has enabled or
91
- # prevented.
92
- unless @state.enabled? || @state.prevented?
93
- passed = step.pass?
94
- @state.enable! if passed
95
- end
96
-
97
- debug << inspect_step(step, score, passed) if debug
98
- when :prevent
99
- # we only check :prevent actions if the state hasn't already
100
- # been prevented.
101
- unless @state.prevented?
102
- passed = step.pass?
103
- @state.prevent! if passed
114
+ with_state do
115
+ steps_by_score(!!debug) do |step, score|
116
+ break if !debug && @state.prevented?
117
+
118
+ passed = nil
119
+ case step.action
120
+ when :enable
121
+ # we only check :enable actions if they have a chance of
122
+ # changing the outcome - if no other rule has enabled or
123
+ # prevented.
124
+ unless @state.enabled? || @state.prevented?
125
+ passed = step.pass?
126
+ @state.enable! if passed
127
+ end
128
+ when :prevent
129
+ # we only check :prevent actions if the state hasn't already
130
+ # been prevented.
131
+ unless @state.prevented?
132
+ passed = step.pass?
133
+ @state.prevent! if passed
134
+ end
135
+ else raise "invalid action #{step.action.inspect}"
104
136
  end
105
137
 
106
138
  debug << inspect_step(step, score, passed) if debug
107
- else raise "invalid action #{step.action.inspect}"
108
139
  end
109
140
  end
110
141
 
@@ -131,7 +162,7 @@ module DeclarativePolicy
131
162
  #
132
163
  # For each step, we yield the step object along with the computed score
133
164
  # for debugging purposes.
134
- def steps_by_score
165
+ def steps_by_score(debugging)
135
166
  flatten_steps!
136
167
 
137
168
  if @steps.size > 50
@@ -156,6 +187,7 @@ module DeclarativePolicy
156
187
  # if the permission hasn't yet been enabled and we only have
157
188
  # prevent steps left, we short-circuit the state here
158
189
  @state.prevent!
190
+ return unless debugging
159
191
  end
160
192
 
161
193
  return if remaining_steps.empty?
@@ -185,7 +217,7 @@ module DeclarativePolicy
185
217
  break if lowest_score.zero?
186
218
  end
187
219
 
188
- [next_step, score]
220
+ [next_step, lowest_score]
189
221
  end
190
222
 
191
223
  # Formatter for debugging output.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeclarativePolicy
4
- VERSION = '1.0.0'
4
+ VERSION = '1.1.0'
5
5
  end
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_support/dependencies'
4
- require 'active_support/core_ext'
5
-
3
+ require 'set'
6
4
  require_relative 'declarative_policy/cache'
7
5
  require_relative 'declarative_policy/condition'
8
6
  require_relative 'declarative_policy/delegate_dsl'
@@ -20,21 +18,32 @@ require_relative 'declarative_policy/configuration'
20
18
  module DeclarativePolicy
21
19
  extend PreferredScope
22
20
 
23
- CLASS_CACHE_MUTEX = Mutex.new
24
- CLASS_CACHE_IVAR = :@__DeclarativePolicy_CLASS_CACHE
25
-
26
21
  class << self
27
22
  def policy_for(user, subject, opts = {})
28
23
  cache = opts[:cache] || {}
29
24
  key = Cache.policy_key(user, subject)
30
25
 
31
- cache[key] ||=
32
- # to avoid deadlocks in multi-threaded environment when
33
- # autoloading is enabled, we allow concurrent loads,
34
- # https://gitlab.com/gitlab-org/gitlab-foss/issues/48263
35
- ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
36
- class_for(subject).new(user, subject, opts)
26
+ cache[key] ||= class_for(subject).new(user, subject, opts)
27
+ end
28
+
29
+ # Find the list of runners with now invalidated keys, and invalidate the runners
30
+ def invalidate(cache, invalidated_keys)
31
+ return unless cache&.any?
32
+ return unless invalidated_keys&.any?
33
+
34
+ keys = invalidated_keys.to_set
35
+
36
+ policies = cache.select { |k, _| k.is_a?(String) && k.start_with?('/dp/policy/') }
37
+
38
+ policies.each_value do |policy|
39
+ policy.runners.each do |runner|
40
+ runner.uncache! if keys.intersect?(runner.dependencies)
37
41
  end
42
+ end
43
+
44
+ invalidated_keys.each { |k| cache.delete(k) }
45
+
46
+ nil
38
47
  end
39
48
 
40
49
  def class_for(subject)
@@ -72,38 +81,19 @@ module DeclarativePolicy
72
81
  @configuration ||= DeclarativePolicy::Configuration.new
73
82
  end
74
83
 
75
- # This method is heavily cached because there are a lot of anonymous
76
- # modules in play in a typical rails app, and #name performs quite
77
- # slowly for anonymous classes and modules.
78
- #
79
- # See https://bugs.ruby-lang.org/issues/11119
80
- #
81
- # if the above bug is resolved, this caching could likely be removed.
82
84
  def class_for_class(subject_class)
83
- unless subject_class.instance_variable_defined?(CLASS_CACHE_IVAR)
84
- CLASS_CACHE_MUTEX.synchronize do
85
- # re-check in case of a race
86
- break if subject_class.instance_variable_defined?(CLASS_CACHE_IVAR)
87
-
88
- policy_class = compute_class_for_class(subject_class)
89
- subject_class.instance_variable_set(CLASS_CACHE_IVAR, policy_class)
85
+ if subject_class.respond_to?(:declarative_policy_class)
86
+ Object.const_get(subject_class.declarative_policy_class)
87
+ else
88
+ subject_class.ancestors.each do |klass|
89
+ name = klass.name
90
+ klass = policy_class(name)
91
+
92
+ return klass if klass
90
93
  end
91
- end
92
-
93
- subject_class.instance_variable_get(CLASS_CACHE_IVAR)
94
- end
95
94
 
96
- def compute_class_for_class(subject_class)
97
- return subject_class.declarative_policy_class.constantize if subject_class.respond_to?(:declarative_policy_class)
98
-
99
- subject_class.ancestors.each do |klass|
100
- name = klass.name
101
- klass = policy_class(name)
102
-
103
- return klass if klass
95
+ nil
104
96
  end
105
-
106
- nil
107
97
  end
108
98
 
109
99
  def policy_class(name)
metadata CHANGED
@@ -1,15 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: declarative_policy
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeanine Adkisson
8
8
  - Alexis Kalderimis
9
- autorequire:
9
+ autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2021-04-12 00:00:00.000000000 Z
12
+ date: 2021-11-05 00:00:00.000000000 Z
13
13
  dependencies: []
14
14
  description: |
15
15
  This library provides an authorization framework with a declarative DSL
@@ -28,19 +28,23 @@ files:
28
28
  - ".gitlab-ci.yml"
29
29
  - ".rspec"
30
30
  - ".rubocop.yml"
31
+ - CHANGELOG.md
31
32
  - CODE_OF_CONDUCT.md
33
+ - CONTRIBUTING.md
32
34
  - Dangerfile
33
35
  - Gemfile
34
36
  - Gemfile.lock
35
37
  - LICENSE.txt
36
38
  - README.md
37
39
  - Rakefile
40
+ - benchmarks/repeated_invocation.rb
38
41
  - danger/plugins/project_helper.rb
39
42
  - danger/roulette/Dangerfile
40
43
  - declarative_policy.gemspec
41
44
  - doc/caching.md
42
45
  - doc/configuration.md
43
46
  - doc/defining-policies.md
47
+ - doc/optimization.md
44
48
  - lib/declarative_policy.rb
45
49
  - lib/declarative_policy/base.rb
46
50
  - lib/declarative_policy/cache.rb
@@ -62,7 +66,7 @@ metadata:
62
66
  homepage_uri: https://gitlab.com/gitlab-org/declarative-policy
63
67
  source_code_uri: https://gitlab.com/gitlab-org/declarative-policy
64
68
  changelog_uri: https://gitlab.com/gitlab-org/declarative-policy/-/blobs/master/CHANGELOG.md
65
- post_install_message:
69
+ post_install_message:
66
70
  rdoc_options: []
67
71
  require_paths:
68
72
  - lib
@@ -70,15 +74,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
70
74
  requirements:
71
75
  - - ">="
72
76
  - !ruby/object:Gem::Version
73
- version: 2.6.0
77
+ version: 2.5.0
74
78
  required_rubygems_version: !ruby/object:Gem::Requirement
75
79
  requirements:
76
80
  - - ">="
77
81
  - !ruby/object:Gem::Version
78
82
  version: '0'
79
83
  requirements: []
80
- rubygems_version: 3.1.4
81
- signing_key:
84
+ rubygems_version: 3.2.15
85
+ signing_key:
82
86
  specification_version: 4
83
87
  summary: An authorization library with a focus on declarative policy definitions.
84
88
  test_files: []