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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/CONTRIBUTING.md +41 -0
- data/LICENSE.txt +4 -1
- data/README.md +81 -12
- data/{declarative_policy.gemspec → declarative-policy.gemspec} +23 -8
- data/doc/caching.md +299 -1
- data/doc/defining-policies.md +76 -12
- data/doc/optimization.md +277 -0
- data/lib/declarative_policy/base.rb +61 -29
- data/lib/declarative_policy/cache.rb +1 -1
- data/lib/declarative_policy/condition.rb +26 -7
- data/lib/declarative_policy/configuration.rb +7 -1
- data/lib/declarative_policy/delegate_dsl.rb +1 -1
- data/lib/declarative_policy/policy_dsl.rb +2 -2
- data/lib/declarative_policy/preferred_scope.rb +1 -1
- data/lib/declarative_policy/rule.rb +5 -5
- data/lib/declarative_policy/rule_dsl.rb +1 -1
- data/lib/declarative_policy/runner.rb +58 -26
- data/lib/declarative_policy/version.rb +1 -1
- data/lib/declarative_policy.rb +30 -40
- metadata +117 -26
- data/.gitignore +0 -10
- data/.gitlab-ci.yml +0 -48
- data/.rspec +0 -4
- data/.rubocop.yml +0 -10
- data/Dangerfile +0 -16
- data/Gemfile +0 -24
- data/Gemfile.lock +0 -197
- data/Rakefile +0 -8
- data/danger/plugins/project_helper.rb +0 -58
- data/danger/roulette/Dangerfile +0 -97
data/doc/defining-policies.md
CHANGED
@@ -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,
|
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,
|
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,
|
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. `
|
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
|
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
|
+
```
|
data/doc/optimization.md
ADDED
@@ -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 { @
|
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 ||=
|
167
|
+
@last_options ||= Options.new
|
150
168
|
end
|
151
169
|
|
152
|
-
|
153
|
-
|
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
|
-
|
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(
|
179
|
-
|
193
|
+
def condition(condition_name, opts = {}, &value)
|
194
|
+
condition_name = condition_name.to_sym
|
180
195
|
|
181
|
-
|
182
|
-
opts[:context_key] ||= self.name
|
196
|
+
condition = Condition.new(condition_name, condition_options(opts), &value)
|
183
197
|
|
184
|
-
|
198
|
+
own_conditions[condition_name] = condition
|
185
199
|
|
186
|
-
|
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
|
-
|
258
|
-
|
259
|
-
"#{@subject.class.name}/#{@subject.id}"
|
260
|
-
else
|
261
|
-
@subject.inspect
|
262
|
-
end
|
279
|
+
"(#{identify_user} : #{identify_subject})"
|
280
|
+
end
|
263
281
|
|
264
|
-
|
282
|
+
def identify_user
|
283
|
+
return '<anonymous>' unless @user
|
265
284
|
|
266
|
-
|
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
|
-
|
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.
|
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
|
#
|
@@ -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
|
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 == :
|
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 :
|
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
|
-
|
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
|