declarative_policy 1.0.0 → 2.0.1

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.
@@ -22,9 +22,9 @@ and then evaluating them with `DeclarativePolicy::Base#allowed?`.
22
22
  You may wish to define a method to abstract policy evaluation. Something like:
23
23
 
24
24
  ```ruby
25
- def allowed?(user, ability, object)
25
+ def allowed?(user, ability, subject)
26
26
  opts = { cache: Cache.current_cache } # re-using a cache between checks eliminates duplication of work
27
- policy = DeclarativePolicy.policy_for(user, object, opts)
27
+ policy = DeclarativePolicy.policy_for(user, subject, opts)
28
28
  policy.allowed?(ability)
29
29
  end
30
30
  ```
@@ -41,9 +41,9 @@ want to know if a user can drive a vehicle. We need a `VehiclePolicy`:
41
41
  ```ruby
42
42
  class VehiclePolicy < DeclarativePolicy::Base
43
43
  # conditions go here by convention
44
-
44
+
45
45
  # rules go here by convention
46
-
46
+
47
47
  # helper methods go last
48
48
  end
49
49
  ```
@@ -55,14 +55,14 @@ Conditions are facts about the state of the system.
55
55
  They have access to two elements of the proposition:
56
56
 
57
57
  - `@user` - the representation of a user in your system: the *subject* of the proposition.
58
- `user` in `allowed?(user, ability, object)`. `@user` may be `nil`, which means
58
+ `user` in `allowed?(user, ability, subject)`. `@user` may be `nil`, which means
59
59
  that the current user is anonymous (for example this may reflect an
60
60
  unauthenticated request in your system).
61
61
  - `@subject` - any domain object that has an associated policy: the *object* of
62
- the predicate of the proposition. `object` in `allowed?(user, ability, object)`.
62
+ the predicate of the proposition. `subject` in `allowed?(user, ability, subject)`.
63
63
  `@subject` is never `nil`. See [handling `nil` values](./configuration.md#handling-nil-values)
64
64
  for details of how to apply policies to `nil` values.
65
-
65
+
66
66
 
67
67
  They are defined as `condition(name, **options, &block)`, where the block is
68
68
  evaluated in the context of an instance of the policy.
@@ -74,7 +74,7 @@ condition(:owns) { @subject.owner == @user }
74
74
  condition(:has_access_to) { @subject.owner.trusts?(@user) }
75
75
  condition(:old_enough_to_drive) { @user.age >= laws.minimum_age }
76
76
  condition(:has_driving_license) { @user.driving_license&.valid? }
77
- condition(:intoxicated, score: 5) { @user.blood_alcohol < laws.max_blood_alcohol }
77
+ condition(:intoxicated, score: 5) { @user.blood_alcohol > laws.max_blood_alcohol }
78
78
  condition(:has_access_to, score: 3) { @subject.owner.trusts?(@user) }
79
79
  ```
80
80
 
@@ -108,8 +108,7 @@ Rules are conclusions we can draw based on the facts:
108
108
  rule { owns }.enable :drive_vehicle
109
109
  rule { has_access_to }.enable :drive_vehicle
110
110
  rule { ~old_enough_to_drive }.prevent :drive_vehicle
111
- rule { intoxicated }.prevent :drive_vehicle
112
- rule { ~has_driving_license }.prevent :drive_vehicle
111
+ rule { intoxicated | ~has_driving_license }.prevent :drive_vehicle
113
112
  ```
114
113
 
115
114
  Rules are combined such that each ability must be enabled at least once, and not
@@ -130,6 +129,33 @@ access the `@user` or `@subject`, or any methods on the policy instance. You
130
129
  should not perform I/O in a rule. They exist solely to define the logical rules
131
130
  of implication and combination between conditions.
132
131
 
132
+ The available operations inside a rule block are:
133
+
134
+ - Bare words to refer to conditions in the policy, or on any delegate.
135
+ For example `owns`. This is equivalent to `cond(:owns)`, but as a matter of
136
+ general style, bare words are preferred.
137
+ - `~` to negate any rule. For example `~owns`, or `~(intoxicated | banned)`.
138
+ - `&` or `all?` to combine rules such that all must succeed. For example:
139
+ `old_enough_to_drive & has_driving_license` or `all?(old_enough_to_drive, has_driving_license)`.
140
+ - `|` or `any?` to combine rules such that one must succeed. For example:
141
+ `intoxicated | banned` or `any?(intoxicated, banned)`.
142
+ - `can?` to refer to the result of evaluating an ability. For example,
143
+ `can?(:sell_vehicle)`.
144
+ - `delegate(:delegate_name, :condition_name)` to refer to a specific
145
+ condition on a named delegate. Use of this is rare, but can be used to
146
+ handle overrides. For example if a vehicle policy defines a delegate as
147
+ `delegate :registration`, then we could refer to that
148
+ as `rule { delegate(:registration, :valid) }`.
149
+
150
+ Note: Be careful not to confuse `DeclarativePolicy::Base.condition` with
151
+ `DeclarativePolicy::RuleDSL#cond`.
152
+
153
+ - `condition` constructs a condition from a name and a block. For example:
154
+ `condition(:adult) { @subject.age >= country.age_of_majority }`.
155
+ - `cond` constructs a rule which refers to a condition by name. For example:
156
+ `rule { cond(:adult) }.enable :vote`. Use of `cond` is rare - it is nicer to
157
+ use the bare word form: `rule { adult }.enable :vote`.
158
+
133
159
  ### Complex conditions
134
160
 
135
161
  Conditions may be combined in the rule blocks:
@@ -158,7 +184,7 @@ like:
158
184
  ```ruby
159
185
  class DrivingLicensePolicy < DeclarativePolicy::Base
160
186
  condition(:expired) { @subject.expires_at <= Time.current }
161
-
187
+
162
188
  rule { expired }.prevent :drive_vehicle
163
189
  end
164
190
  ```
@@ -168,7 +194,7 @@ And a registration policy:
168
194
  ```ruby
169
195
  class RegistrationPolicy < DeclarativePolicy::Base
170
196
  condition(:valid) { @subject.valid_for?(@user.current_location) }
171
-
197
+
172
198
  rule { ~valid }.prevent :drive_vehicle
173
199
  end
174
200
  ```
@@ -183,3 +209,41 @@ delegate { @subject.registration }
183
209
 
184
210
  This is a powerful mechanism for inferring rules based on relationships between
185
211
  objects.
212
+
213
+ #### Overrides
214
+
215
+ It can be useful to declare that the given abilities should not be read
216
+ from delegates. This declaration is useful if you have an ability you want
217
+ to define differently in a policy than in a delegated policy, but you still
218
+ want to delegate all other abilities.
219
+
220
+ ```ruby
221
+ delegate { @subject.parent }
222
+
223
+ overrides :drive_car, :watch_tv
224
+ ```
225
+
226
+ NOTE:
227
+ Rules with `prevent_all` present in the delegated policy are properly
228
+ not used during overridden abilities evaluation.
229
+
230
+ #### Delegated conditions
231
+
232
+ When named delegates are defined, their [conditions](#conditions) can be
233
+ referenced in [rules](#rules) using bare words.
234
+
235
+ Given a registration policy:
236
+
237
+ ```ruby
238
+ class RegistrationPolicy < DeclarativePolicy::Base
239
+ condition(:valid) { @subject.valid_for?(@user.current_location) }
240
+ end
241
+ ```
242
+
243
+ The vehicle policy can reference the `:valid` condition as follows:
244
+
245
+ ```ruby
246
+ delegate(:registration) { @subject.registration }
247
+
248
+ rule { registration.valid }.enable :drive_vehicle
249
+ ```
@@ -0,0 +1,277 @@
1
+ # Optimization
2
+
3
+ This library cares a lot about performance, and includes features that
4
+ aim to limit the impact of permission checks on an application. In particular,
5
+ effort is made to ensure that repeated checks of the same permission are
6
+ efficient, aiming to eliminate repeated computation and unnecessary I/O.
7
+
8
+ The key observation: permission checks generally involve some facts
9
+ about the real world, and this involves (relatively expensive) I/O to compute.
10
+ These facts are then combined in some way to generate a judgment. Not all facts
11
+ are necessary to know in order to determine a judgment. The main aims of the
12
+ library:
13
+
14
+ - Avoid unnecessary work.
15
+ - If we must do work, do the least work possible.
16
+
17
+ The library enables you to define both how to compute these facts
18
+ (conditions), and how to combine them (rules), but the library is entirely
19
+ responsible for the scheduling of when to compute each fact.
20
+
21
+ ## Making truth
22
+
23
+ This library is essentially a build-system for truth - you can think of it as
24
+ similar to [`make`](https://www.gnu.org/software/make/), but:
25
+
26
+ - Instead of `targets` there are `abilities`.
27
+ - Instead of `files`, we produce `boolean` values.
28
+
29
+ We have no notion of freshness - uncached conditions are always re-computed, but
30
+ just like `make`, we try to do the least work possible in order to evaluate the
31
+ given ability.
32
+
33
+ For the interested, this corresponds to
34
+ [`memo`](https://hackage.haskell.org/package/build-1.0/docs/src/Build.System.html#memo) in
35
+ the taxonomy of build systems (although the scheduler here is somewhat smarter
36
+ about the relative order of dependencies).
37
+
38
+ ## Optimization is reducing computation of expensive I/O
39
+
40
+ In the context of this library, optimization refers to ways we can:
41
+
42
+ - Expose the smallest possible units of I/O to the scheduler.
43
+ - Never run a computation twice.
44
+ - Indicate to the scheduler which computations should be run first.
45
+
46
+ For example, if a policy defines the following rule:
47
+
48
+ ```ruby
49
+ rule { fact_a & fact_b }.enable :some_ability
50
+ ```
51
+
52
+ The core of the matter: if we know in advance that `fact_a == false`, then we do not need to compute
53
+ `fact_b`. Conversely, if we know in advance that `fact_b == false`, then we do
54
+ not need to run `fact_a`. The same goes for `fact_a | fact_a`.
55
+
56
+ In this case:
57
+
58
+ - The smallest possible units of I/O are `fact_a` and `fact_b`, and the library
59
+ is aware of them.
60
+ - The library uses the [cache](./caching.md) to avoid running a condition more
61
+ than once.
62
+ - It does not matter which order we run these conditions in - the scheduler is
63
+ free to re-order them if it thinks that `fact_b` is somehow more efficient to
64
+ compute than `fact_a`.
65
+
66
+ ## The scheduling logic
67
+
68
+ The problem each permission check seeks to solve is determining the truth value
69
+ of a proposition of the form:
70
+
71
+ ```pseudo
72
+ any? enabling-conditions && not (any? preventing-conditions)
73
+ ```
74
+
75
+ If `[a, b, c]` are enabling conditions, and `[x, y, z]` are preventing
76
+ conditions, then this could be expressed as:
77
+
78
+ ```ruby
79
+ (a | b | c) & ~x & ~y & ~z
80
+ ```
81
+
82
+ But the [scheduler](../lib/declarative_policy/runner.rb) represents this
83
+ as a flat list of rules - conditions and their outcomes:
84
+
85
+ ```pseudo
86
+ [
87
+ (a, :enable),
88
+ (b, :enable),
89
+ (c, :enable),
90
+ (x, :prevent),
91
+ (y, :prevent),
92
+ (z, :prevent)
93
+ ]
94
+ ```
95
+
96
+ They aren't necessarily run in this order, however. Instead, we try to order
97
+ the list to minimize unnecessary work.
98
+
99
+ The
100
+ [logic](https://gitlab.com/gitlab-org/ruby/gems/declarative-policy/blob/659ac0525773a76cf8712d47b3c2dadd03b758c9/lib/declarative_policy/runner.rb#L80-112)
101
+ to process this list is (in pseudo-code):
102
+
103
+ ```pseudo
104
+ while any-enable-rule-remains?(rules)
105
+ rule := pop-cheapest-remaining-rule(rules)
106
+ fact := observe-io-and-update-cache rule.condition
107
+
108
+ if fact and rule.prevents?
109
+ return prevented
110
+ else if fact and rule.enables?
111
+ skip-all-other-enabling-rules!
112
+ enabled? := true
113
+
114
+ if enabled?
115
+ return enabled
116
+ else
117
+ return prevented
118
+ ```
119
+
120
+ The process for ordering rules is that each condition has a score, and we prefer
121
+ the rules with the lowest `score`. Cached values have a score of `0`. Composite
122
+ conditions (such as `a | b | c`) have a score that the sum of the scores of
123
+ their components.
124
+
125
+ The evaluation of one rule results in updating the cache, so other rules might
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.
128
+
129
+ ## Consequences for the policy-writer
130
+
131
+ While interesting in its own right, this has some practical consequences for the
132
+ policy writer:
133
+
134
+ ### Flat is better than nested
135
+
136
+ The scheduler can do a better job of arranging work into the smallest possible
137
+ chunks if the definitions are as flat as possible, meaning this:
138
+
139
+ ```ruby
140
+ rule { condition_a }.enable :some_ability
141
+ rule { condition_b }.prevent :some_ability
142
+ ```
143
+
144
+ Is easier to optimise than:
145
+
146
+ ```ruby
147
+ rule { condition_a & ~condition_b }.enable :some_ability
148
+ ```
149
+
150
+ We do attempt to flatten and de-nest logical expressions, but it is not always
151
+ possible to raise all expressions to the top level. All things being
152
+ equal, we recommend using the declarative style.
153
+
154
+ #### An example of sub-optimal scheduling
155
+
156
+ The scheduler is only able to re-order conditions that can be flattened out to
157
+ the top level. For example, given the following definition:
158
+
159
+ ```ruby
160
+ condition(:a, score: 1) { ... }
161
+ condition(:b, score: 2) { ... }
162
+ condition(:c, score: 3) { ... }
163
+
164
+ rule { a & c }.enable :some_ability
165
+ rule { b & c }.enable :some_ability
166
+ ```
167
+
168
+ The conditions are evaluated in the following order:
169
+
170
+ - `a & c` (score = 4):
171
+ - `a` (score = 1)
172
+ - `c` (score = 3)
173
+ - `b & c` (score = 3):
174
+ - `c` (score = 0 [cached])
175
+ - `b` (score = 2)
176
+
177
+ If instead this were three top level rules:
178
+
179
+ ```ruby
180
+ rule { a }.enable :some_ability
181
+ rule { b }.enable :some_ability
182
+ rule { ~c }.prevent :some_ability
183
+ ```
184
+
185
+ Then this would be evaluated as:
186
+
187
+ - `a` (score = 1)
188
+ - `b` (score = 2)
189
+ - `c` (score = 3)
190
+
191
+ If `a` and `b` fail, then `3` is never evaluated, saving the most
192
+ expensive call.
193
+
194
+ The total evaluated costs for each arrangement are:
195
+
196
+ | Failing conditions | Nested cost | Flat cost |
197
+ |--------------------|-----------------|---------------|
198
+ | none | 4 `(a, c)` | 4 `(a, c)` |
199
+ | all | 3 `(a, b)` | 3 `(a, b)` |
200
+ | `a` | 6 `(a, b, c)` | 6 `(a, b, c)` |
201
+ | `b` | 4 `(a, c)` | 4 `(a, c)` |
202
+ | `c` | 4 `(a, c, c=0)` | 4 `(a, c)` |
203
+ | `a` and `b` | 4 `(a, c, c=0)` | 3 `(a, b)` |
204
+ | `a` and `c` | 6 `(a, b, c)` | 6 `(a, b, c)` |
205
+ | `b` and `c` | 4 `(a, c, c=0)` | 4 `(a, c)` |
206
+
207
+ While the overall costs for all arrangements are very similar,
208
+ the flat representation is strictly superior, and does not even need to
209
+ rely on the cache for this behavior.
210
+
211
+ ### Getting the scope right matters
212
+
213
+ By default, the outcome of each rule is cached against a key like
214
+ `(rule.condition.key, user.key, subject.key)`. (For more information, read
215
+ [caching](./caching.md).) This makes sense for some things like:
216
+
217
+ ```ruby
218
+ condition(:owns_vehicle) { @user == @subject.owner }
219
+ ```
220
+
221
+ In this case, the result depends on both the `@user` and the `@subject`. Not all
222
+ conditions are like that, though! The following condition only refers to the
223
+ subject:
224
+
225
+ ```ruby
226
+ condition(:roadworthy) { @subject.warrant_of_fitness.current? }
227
+ ```
228
+
229
+ If we cached this against `(user_a, car_a)` and then tested it
230
+ against `(user_b, car_a)` it would not match, and we would have to re-compute
231
+ the condition, even though the road-worthiness of a vehicle does not depend on
232
+ the driver. See [caching](./caching.md) for more discussion on scopes.
233
+
234
+ Because more general conditions are more sharable, all things being equal, it is
235
+ better to evaluate a condition that might be shared later, rather than one that
236
+ is less likely to be shared. For this reason, when we sort the rules,
237
+ we prefer ones with more general scopes to more specific ones.
238
+
239
+ ### Getting the score right matters
240
+
241
+ Each condition has a `score`, which is an abstract weight. By default this is
242
+ determined by the scope.
243
+
244
+ However, if you know that a condition is very expensive to run, then it makes sense
245
+ to give it a higher score, meaning it's only evaluated if we really need
246
+ to. On the other hand, if a condition is very likely to be determinative, then
247
+ giving it a lower score would ensure we test it first.
248
+
249
+ For example, take two conditions, one which queries the local DB, and one
250
+ which makes an external API call. If they are otherwise equivalent, calling
251
+ the database one first is likely to be more efficient, as it might save us needing
252
+ to make the external API call. Conditions that are
253
+ [pure](https://en.wikipedia.org/wiki/Pure_function) can even be given a value of
254
+ `0`, as no I/O is required to compute them.
255
+
256
+ ```ruby
257
+ condition(:local_db) { @subject.related_object.present? }
258
+ condition(:pure, score: 0) { @subject.some_attribute? }
259
+ condition(:external_api, score: API_SCORE) { ExtrnalService.get(@subject.id).ok? }
260
+
261
+ # these are run in the order: pure, local_db, external_api
262
+ rule { external_api & pure & local_db }.enable :some_ability
263
+ ```
264
+
265
+ The other consideration is the likelihood that a condition is determinative. For
266
+ example, if `condition_a` is true 80% of the time, and `condition_b` is true
267
+ 20% of the time, then we should prefer to run `condition_a` if these conditions
268
+ enable an ability (because 80% of the time we don't need to run `condition_b`).
269
+ But if they prevent an ability, then we would prefer to run `condition_b` first,
270
+ because again, 80% of the time we can skip `condition_a`. This consideration is
271
+ more subtle. It requires knowing both the distribution of the condition, and
272
+ the consequence of its outcome, but this can be used to further optimize the
273
+ order of evaluation by marking some conditions as more likely to affect the
274
+ outcome.
275
+
276
+ All things being equal, we prefer to run prevent rules, because they have this
277
+ property - they are more likely to save extra work.
@@ -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
@@ -126,7 +144,7 @@ module DeclarativePolicy
126
144
  #
127
145
  # example:
128
146
  #
129
- # delegate { @subect.parent }
147
+ # delegate { @subject.parent }
130
148
  #
131
149
  # overrides :drive_car, :watch_tv
132
150
  #
@@ -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)
@@ -9,10 +9,14 @@ module DeclarativePolicy
9
9
  class Condition
10
10
  attr_reader :name, :description, :scope, :manual_score, :context_key
11
11
 
12
+ VALID_SCOPES = %i[user_and_subject user subject global].freeze
13
+ ALLOWED_SCOPES = VALID_SCOPES + %i[normal]
14
+
12
15
  def initialize(name, opts = {}, &compute)
13
16
  @name = name
14
17
  @compute = compute
15
- @scope = opts.fetch(:scope, :normal)
18
+ @scope = fetch_scope(opts)
19
+
16
20
  @description = opts.delete(:description)
17
21
  @context_key = opts[:context_key]
18
22
  @manual_score = opts.fetch(:score, nil)
@@ -25,6 +29,20 @@ module DeclarativePolicy
25
29
  def key
26
30
  "#{@context_key}/#{@name}"
27
31
  end
32
+
33
+ private
34
+
35
+ def fetch_scope(options)
36
+ result = options.fetch(:scope, :user_and_subject)
37
+ if result == :normal
38
+ warn "[DEPRECATION] `:normal` is deprecated and will be removed in 2.0. Please use new name `:user_and_subject`"
39
+ result = :user_and_subject
40
+ end
41
+
42
+ raise "Invalid scope #{result}. Allowed values: #{VALID_SCOPES.inspect}" unless ALLOWED_SCOPES.include?(result)
43
+
44
+ result
45
+ end
28
46
  end
29
47
 
30
48
  # In contrast to a Condition, a ManifestCondition contains
@@ -40,6 +58,8 @@ module DeclarativePolicy
40
58
  # the context's cache here so that we can share in the global
41
59
  # cache (often RequestStore or similar).
42
60
  def pass?
61
+ Thread.current[:declarative_policy_current_runner_state]&.register(self)
62
+
43
63
  @context.cache(cache_key) { @condition.compute(@context) }
44
64
  end
45
65
 
@@ -66,7 +86,7 @@ module DeclarativePolicy
66
86
  return 2 if @condition.scope == :global
67
87
 
68
88
  # "Normal" rules can't share caches with any other policies
69
- return 16 if @condition.scope == :normal
89
+ return 16 if @condition.scope == :user_and_subject
70
90
 
71
91
  # otherwise, we're :user or :subject scope, so it's 4 if
72
92
  # the caller has declared a preference
@@ -76,8 +96,6 @@ module DeclarativePolicy
76
96
  8
77
97
  end
78
98
 
79
- private
80
-
81
99
  # This method controls the caching for the condition. This is where
82
100
  # the condition(scope: ...) option comes into play. Notice that
83
101
  # depending on the scope, we may cache only by the user or only by
@@ -85,14 +103,15 @@ module DeclarativePolicy
85
103
  def cache_key
86
104
  @cache_key ||=
87
105
  case @condition.scope
88
- when :normal then "/dp/condition/#{@condition.key}/#{user_key},#{subject_key}"
106
+ when :global then "/dp/condition/#{@condition.key}"
89
107
  when :user then "/dp/condition/#{@condition.key}/#{user_key}"
90
108
  when :subject then "/dp/condition/#{@condition.key}/#{subject_key}"
91
- when :global then "/dp/condition/#{@condition.key}"
92
- else raise 'invalid scope'
109
+ else "/dp/condition/#{@condition.key}/#{user_key},#{subject_key}"
93
110
  end
94
111
  end
95
112
 
113
+ private
114
+
96
115
  def user_key
97
116
  Cache.user_key(@context.user)
98
117
  end