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
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 704af6c0500c00a0e6c6797dd5b496c098db86f33ccc1ef2496be37e43b33e03
|
4
|
+
data.tar.gz: 2236adb02dbee28b6565fc72541fe2fbee5a087ef15cc2fe1f0d060ce1eece13
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c3841495e1922ae72f704524e40ca080da4838cfe075d401350212f5b31f116f6003aa3e371d2a0084a11986b4745bb82946ad84b61247a19b59470a26e24755
|
7
|
+
data.tar.gz: f3f0d97ba2b9e56079d5307c135ec2b15c73759661f32e6cda2db44ab0fa21002598725cf0c762ca85c0580ae1c5b7c397115f185ec94c9da600cfaf19f6e590
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
2.0.0:
|
2
|
+
|
3
|
+
- Drop explicit support for Ruby 2.6 and 2.7 by removing those versions from
|
4
|
+
the CI matrix. These Ruby versions are now past EOL.
|
5
|
+
- Rename default condition scope name to `:user_and_subject`
|
6
|
+
- Clarify the use of `User` and `Subject` in README and documentation
|
7
|
+
- Update `ruby-git` in Gemfile.lock
|
8
|
+
|
9
|
+
1.1.1:
|
10
|
+
|
11
|
+
- Define development dependencies
|
12
|
+
|
13
|
+
1.1.0:
|
14
|
+
|
15
|
+
- Add cache invalidation API: `DeclarativePolicy.invalidate(cache, keys)`
|
16
|
+
- Include actor class name in cache key
|
17
|
+
|
18
|
+
1.0.1:
|
19
|
+
|
20
|
+
- Added unit level tests for `lib/declarative_policy/rule.rb`
|
data/CONTRIBUTING.md
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
## Developer Certificate of Origin and License
|
2
|
+
|
3
|
+
By contributing to GitLab B.V., you accept and agree to the following terms and
|
4
|
+
conditions for your present and future contributions submitted to GitLab B.V.
|
5
|
+
Except for the license granted herein to GitLab B.V. and recipients of software
|
6
|
+
distributed by GitLab B.V., you reserve all right, title, and interest in and to
|
7
|
+
your Contributions.
|
8
|
+
|
9
|
+
All contributions are subject to the Developer Certificate of Origin and license set out at [docs.gitlab.com/ce/legal/developer_certificate_of_origin](https://docs.gitlab.com/ce/legal/developer_certificate_of_origin).
|
10
|
+
|
11
|
+
_This notice should stay as the first item in the CONTRIBUTING.md file._
|
12
|
+
|
13
|
+
## Code of conduct
|
14
|
+
|
15
|
+
As contributors and maintainers of this project, we pledge to respect all people
|
16
|
+
who contribute through reporting issues, posting feature requests, updating
|
17
|
+
documentation, submitting pull requests or patches, and other activities.
|
18
|
+
|
19
|
+
We are committed to making participation in this project a harassment-free
|
20
|
+
experience for everyone, regardless of level of experience, gender, gender
|
21
|
+
identity and expression, sexual orientation, disability, personal appearance,
|
22
|
+
body size, race, ethnicity, age, or religion.
|
23
|
+
|
24
|
+
Examples of unacceptable behavior by participants include the use of sexual
|
25
|
+
language or imagery, derogatory comments or personal attacks, trolling, public
|
26
|
+
or private harassment, insults, or other unprofessional conduct.
|
27
|
+
|
28
|
+
Project maintainers have the right and responsibility to remove, edit, or reject
|
29
|
+
comments, commits, code, wiki edits, issues, and other contributions that are
|
30
|
+
not aligned to this Code of Conduct. Project maintainers who do not follow the
|
31
|
+
Code of Conduct may be removed from the project team.
|
32
|
+
|
33
|
+
This code of conduct applies both within project spaces and in public spaces
|
34
|
+
when an individual is representing the project or its community.
|
35
|
+
|
36
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior can be
|
37
|
+
reported by emailing contact@gitlab.com.
|
38
|
+
|
39
|
+
This Code of Conduct is adapted from the [Contributor Covenant](https://contributor-covenant.org), version 1.1.0,
|
40
|
+
available at [https://contributor-covenant.org/version/1/1/0/](https://contributor-covenant.org/version/1/1/0/).
|
41
|
+
|
data/LICENSE.txt
CHANGED
@@ -1,6 +1,9 @@
|
|
1
1
|
The MIT License (MIT)
|
2
2
|
|
3
|
-
Copyright (c) 2021
|
3
|
+
Copyright (c) 2021 GitLab
|
4
|
+
|
5
|
+
The original author of this library is [Jeanine Adkisson](http://jneen.net),
|
6
|
+
and copyright is held by GitLab.
|
4
7
|
|
5
8
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
9
|
of this software and associated documentation files (the "Software"), to deal
|
data/README.md
CHANGED
@@ -1,5 +1,7 @@
|
|
1
1
|
# `DeclarativePolicy`: A Declarative Authorization Library
|
2
2
|
|
3
|
+
[](https://badge.fury.io/rb/declarative_policy)
|
4
|
+
|
3
5
|
This library provides a DSL for writing authorization policies.
|
4
6
|
|
5
7
|
It can be used to separate logic from permissions, and has been
|
@@ -18,14 +20,72 @@ gem 'declarative_policy'
|
|
18
20
|
|
19
21
|
And then execute:
|
20
22
|
|
21
|
-
|
23
|
+
```plain
|
24
|
+
$ bundle install
|
25
|
+
```
|
22
26
|
|
23
27
|
Or install it yourself as:
|
24
28
|
|
25
|
-
|
29
|
+
```plain
|
30
|
+
$ gem install declarative_policy
|
31
|
+
```
|
26
32
|
|
27
|
-
##
|
33
|
+
## Example
|
28
34
|
|
35
|
+
```ruby
|
36
|
+
require 'declarative_policy'
|
37
|
+
|
38
|
+
class User
|
39
|
+
attr_reader :name
|
40
|
+
|
41
|
+
def initialize(name:)
|
42
|
+
@name = name
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
class Vehicle
|
47
|
+
def initialize(owner:, trusted: [])
|
48
|
+
@owner = owner
|
49
|
+
@trusted = trusted
|
50
|
+
end
|
51
|
+
|
52
|
+
def owner?(user)
|
53
|
+
@owner.name == user.name
|
54
|
+
end
|
55
|
+
|
56
|
+
def trusted?(user)
|
57
|
+
@owner.name == user.name || @trusted.detect { |t| t.name == user.name }
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
class VehiclePolicy < DeclarativePolicy::Base
|
62
|
+
condition(:owns) { @subject.owner?(@user) }
|
63
|
+
condition(:trusted) { @subject.trusted?(@user) }
|
64
|
+
|
65
|
+
rule { owns }.enable :sell_vehicle
|
66
|
+
rule { trusted }.enable :drive_vehicle
|
67
|
+
end
|
68
|
+
|
69
|
+
jack = User.new(name: 'jack')
|
70
|
+
jill = User.new(name: 'jill')
|
71
|
+
jacks_vehicle = Vehicle.new(owner: jack, trusted: [jill])
|
72
|
+
jills_vehicle = Vehicle.new(owner: jill, trusted: [jack])
|
73
|
+
|
74
|
+
puts "Jack can drive Jack's vehicle? -> #{DeclarativePolicy.policy_for(jack, jacks_vehicle).can?(:drive_vehicle)}"
|
75
|
+
puts "Jack can drive Jill's vehicle? -> #{DeclarativePolicy.policy_for(jack, jills_vehicle).can?(:drive_vehicle)}"
|
76
|
+
puts "Jack can sell Jack's vehicle? -> #{DeclarativePolicy.policy_for(jack, jacks_vehicle).can?(:sell_vehicle)}"
|
77
|
+
puts "Jack can sell Jill's vehicle? -> #{DeclarativePolicy.policy_for(jack, jills_vehicle).can?(:sell_vehicle)}"
|
78
|
+
```
|
79
|
+
|
80
|
+
```plain
|
81
|
+
$ ruby example.rb
|
82
|
+
Jack can drive Jack's vehicle? -> true
|
83
|
+
Jack can drive Jill's vehicle? -> true
|
84
|
+
Jack can sell Jack's vehicle? -> true
|
85
|
+
Jack can sell Jill's vehicle? -> false
|
86
|
+
```
|
87
|
+
|
88
|
+
## Usage
|
29
89
|
|
30
90
|
The core abstraction of this library is a `Policy`. Policies combine:
|
31
91
|
|
@@ -34,9 +94,12 @@ The core abstraction of this library is a `Policy`. Policies combine:
|
|
34
94
|
|
35
95
|
This library exists to determine the truth value of statements of the form:
|
36
96
|
|
97
|
+
```plain
|
98
|
+
User Predicate [Subject]
|
37
99
|
```
|
38
|
-
|
39
|
-
|
100
|
+
|
101
|
+
Renaming `User` to `Actor` and `Subject` to `Resource` is discussed in
|
102
|
+
[this issue](https://gitlab.com/gitlab-org/ruby/gems/declarative-policy/-/issues/6).
|
40
103
|
|
41
104
|
For example:
|
42
105
|
|
@@ -61,15 +124,15 @@ class VehiclePolicy < DeclarativePolicy::Base
|
|
61
124
|
# expensive rules can have 'score'. Higher scores are 'more expensive' to calculate
|
62
125
|
condition(:owns, score: 0) { @subject.owner == @user }
|
63
126
|
condition(:has_access_to, score: 3) { @subject.owner.trusts?(@user) }
|
64
|
-
condition(:intoxicated, score: 5) { @user.blood_alcohol
|
65
|
-
|
127
|
+
condition(:intoxicated, score: 5) { @user.blood_alcohol > laws.max_blood_alcohol }
|
128
|
+
|
66
129
|
# conclusions we can draw:
|
67
130
|
rule { owns }.enable :drive_vehicle
|
68
131
|
rule { has_access_to }.enable :drive_vehicle
|
69
132
|
rule { ~old_enough_to_drive }.prevent :drive_vehicle
|
70
133
|
rule { intoxicated }.prevent :drive_vehicle
|
71
134
|
rule { ~has_driving_license }.prevent :drive_vehicle
|
72
|
-
|
135
|
+
|
73
136
|
# we can use methods to abstract common logic
|
74
137
|
def laws
|
75
138
|
@subject.registration.country.driving_laws
|
@@ -116,20 +179,26 @@ policy = DeclarativePolicy.policy_for(user, car, cache: cache)
|
|
116
179
|
policy.can?(:drive_vehicle)
|
117
180
|
```
|
118
181
|
|
119
|
-
For more usage details, see the [documentation](
|
182
|
+
For more usage details, see the [documentation](doc).
|
120
183
|
|
121
184
|
## Development
|
122
185
|
|
123
|
-
After checking out the repository, run `
|
186
|
+
After checking out the repository, run `bundle install` to install dependencies.
|
124
187
|
Then, run `rake spec` to run the tests. You can also run `bin/console` for an
|
125
188
|
interactive prompt that will allow you to experiment.
|
126
189
|
|
127
190
|
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
128
191
|
|
192
|
+
## Additional Reading Material
|
193
|
+
|
194
|
+
More details on policies and custom roles can be found in the following pages:
|
195
|
+
- [Development Process for the DeclarativePolicy framework](https://docs.gitlab.com/ee/development/policies.html)
|
196
|
+
- [Custom Roles docs](https://docs.gitlab.com/ee/development/permissions/custom_roles.html)
|
197
|
+
|
129
198
|
## Contributing
|
130
199
|
|
131
|
-
Bug reports and
|
132
|
-
https://gitlab.com/gitlab-org/declarative-policy. This project is intended to be
|
200
|
+
Bug reports and merge requests are welcome on GitLab at
|
201
|
+
https://gitlab.com/gitlab-org/ruby/gems/declarative-policy. This project is intended to be
|
133
202
|
a safe, welcoming space for collaboration, and contributors are expected to
|
134
203
|
adhere to the [GitLab code of conduct](https://about.gitlab.com/community/contribute/code-of-conduct/).
|
135
204
|
|
@@ -5,8 +5,8 @@ require_relative 'lib/declarative_policy/version'
|
|
5
5
|
Gem::Specification.new do |spec|
|
6
6
|
spec.name = 'declarative_policy'
|
7
7
|
spec.version = DeclarativePolicy::VERSION
|
8
|
-
spec.authors = ['
|
9
|
-
spec.email = ['
|
8
|
+
spec.authors = ['group::authorization']
|
9
|
+
spec.email = ['engineering@gitlab.com']
|
10
10
|
|
11
11
|
spec.summary = 'An authorization library with a focus on declarative policy definitions.'
|
12
12
|
spec.description = <<~DESC
|
@@ -17,20 +17,35 @@ Gem::Specification.new do |spec|
|
|
17
17
|
|
18
18
|
This library is in production use at GitLab.com
|
19
19
|
DESC
|
20
|
-
spec.homepage = 'https://gitlab.com/gitlab-org/declarative-policy'
|
20
|
+
spec.homepage = 'https://gitlab.com/gitlab-org/ruby/gems/declarative-policy'
|
21
21
|
spec.license = 'MIT'
|
22
|
-
spec.required_ruby_version = Gem::Requirement.new('>=
|
22
|
+
spec.required_ruby_version = Gem::Requirement.new('>= 3.0.0')
|
23
23
|
|
24
24
|
spec.metadata['homepage_uri'] = spec.homepage
|
25
|
-
spec.metadata['source_code_uri'] = 'https://gitlab.com/gitlab-org/declarative-policy'
|
26
|
-
spec.metadata['changelog_uri'] = 'https://gitlab.com/gitlab-org/declarative-policy/-/
|
25
|
+
spec.metadata['source_code_uri'] = 'https://gitlab.com/gitlab-org/ruby/gems/declarative-policy'
|
26
|
+
spec.metadata['changelog_uri'] = 'https://gitlab.com/gitlab-org/ruby/gems/declarative-policy/-/blob/main/CHANGELOG.md'
|
27
|
+
|
28
|
+
spec.metadata['rubygems_mfa_required'] = 'false'
|
27
29
|
|
28
30
|
# Specify which files should be added to the gem when it is released.
|
29
|
-
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
30
31
|
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
31
|
-
|
32
|
+
%w[
|
33
|
+
*.gemspec
|
34
|
+
lib/**/*.rb
|
35
|
+
*.{md,txt}
|
36
|
+
doc/**/*
|
37
|
+
].flat_map { |pattern| Dir.glob(pattern) }
|
32
38
|
end
|
33
39
|
spec.bindir = 'exe'
|
34
40
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
35
41
|
spec.require_paths = ['lib']
|
42
|
+
|
43
|
+
# Development dependencies:
|
44
|
+
spec.add_development_dependency 'benchmark-ips', '~> 2.12'
|
45
|
+
spec.add_development_dependency 'gitlab-dangerfiles', '~> 3.8'
|
46
|
+
spec.add_development_dependency 'gitlab-styles', '~> 12.0'
|
47
|
+
spec.add_development_dependency 'pry-byebug'
|
48
|
+
spec.add_development_dependency 'rake', '~> 12.0'
|
49
|
+
spec.add_development_dependency 'rspec', '~> 3.10'
|
50
|
+
spec.add_development_dependency 'rspec-parameterized', '~> 1.0'
|
36
51
|
end
|
data/doc/caching.md
CHANGED
@@ -1,4 +1,302 @@
|
|
1
1
|
# Caching
|
2
2
|
|
3
|
-
|
3
|
+
This library deals with making observations about the state of
|
4
|
+
a system (usually performing I/O, such as making a database query),
|
5
|
+
and combining these facts into logical propositions.
|
4
6
|
|
7
|
+
In order to make this performant, the library transparently caches repeated
|
8
|
+
observations of conditions. Understanding how caching works is useful for
|
9
|
+
designing good policies, using them effectively.
|
10
|
+
|
11
|
+
## What is cached?
|
12
|
+
|
13
|
+
If a policy is instantiated with a cache, then the following things will be
|
14
|
+
stored in it:
|
15
|
+
|
16
|
+
- Policy instances (there will only ever be one policy per `user/subject` pair
|
17
|
+
for the lifetime of the cache).
|
18
|
+
- Condition results
|
19
|
+
|
20
|
+
The correctness of these cached values depends on the correctness of the
|
21
|
+
cache-keys. We assume the objects in your domain have a `#id` method that
|
22
|
+
fully captures the notion of object identity. See [Cache keys](#cache-keys) for
|
23
|
+
details. All cache keys begin with `"/dp/"`.
|
24
|
+
|
25
|
+
Policies themselves cache the results of the abilities they compute.
|
26
|
+
|
27
|
+
Policies distinguish between facts based on the type of the fact:
|
28
|
+
|
29
|
+
- Boolean facts: implemented with `condition`.
|
30
|
+
- Abilities: implemented with `rule` blocks.
|
31
|
+
- Non-boolean facts: implemented by policy instance methods.
|
32
|
+
|
33
|
+
For example, consider a policy for countries:
|
34
|
+
|
35
|
+
```ruby
|
36
|
+
class CountryPolicy < DeclarativePolicy::Base
|
37
|
+
condition(:citizen) { @user.citizen_of?(country.country_code) }
|
38
|
+
condition(:eu_citizen, scope: :user) { @user.citizen_of?(*Unions::EU) }
|
39
|
+
condition(:eu_member, scope: :subject) { Unions::EU.include?(country.country_code) }
|
40
|
+
|
41
|
+
condition(:has_visa_waiver) { country.visa_waivers.any? { |c| @user.citizen_of?(c) } }
|
42
|
+
condition(:permanent_resident) { visa_category == :permanent }
|
43
|
+
condition(:has_work_visa) { visa_category == :work }
|
44
|
+
condition(:has_current_visa) { has_visa_waiver? || current_visa.present? }
|
45
|
+
condition(:has_business_visa) { has_visa_waiver? || has_work_visa? || visa_category == :business }
|
46
|
+
|
47
|
+
condition(:full_rights, score: 20) { citizen? || permanent_resident? }
|
48
|
+
condition(:banned) { country.banned_list.include?(@user) }
|
49
|
+
|
50
|
+
rule { eu_member & eu_citizen }.enable :freedom_of_movement
|
51
|
+
rule { full_rights | can?(:freedom_of_movement) }.enable :settle
|
52
|
+
rule { can?(:settle) | has_current_visa }.enable :enter_country
|
53
|
+
rule { can?(:settle) | has_business_visa }.enable :attend_meetings
|
54
|
+
rule { can?(:settle) | has_work_visa }.enable :work
|
55
|
+
rule { citizen }.enable :vote
|
56
|
+
rule { ~citizen & ~permanent_resident }.enable :apply_for_visa
|
57
|
+
rule { banned }.prevent :enter_country, :apply_for_visa
|
58
|
+
|
59
|
+
def current_visa
|
60
|
+
return @current_visa if defined?(@current_visa)
|
61
|
+
|
62
|
+
@current_visa = country.active_visas.find_by(applicant: @user)
|
63
|
+
end
|
64
|
+
|
65
|
+
def visa_category
|
66
|
+
current_visa&.category
|
67
|
+
end
|
68
|
+
|
69
|
+
def country
|
70
|
+
@subject
|
71
|
+
end
|
72
|
+
end
|
73
|
+
```
|
74
|
+
|
75
|
+
This is a reasonably realistic policy - there are a few pieces of state (the
|
76
|
+
country, the list of visa waiver agreements, the list of citizenships the user
|
77
|
+
holds, the kind of visa the user has, if they have one, the current list of
|
78
|
+
banned users), and these are combined to determine a range of abilities (whether
|
79
|
+
one can visit or live in or vote in a certain country). Importantly, these
|
80
|
+
pieces of information are re-used between abilities - the citizenship status is
|
81
|
+
relevant to all abilities, whereas the banned list is only considered on entry
|
82
|
+
and when applying for a new visa).
|
83
|
+
|
84
|
+
If we imagine that some of these operations are reasonably expensive (fetching
|
85
|
+
the current visa status, or checking the banned list, for example), then it
|
86
|
+
follows that we really care about avoiding re-computation of these facts. In the
|
87
|
+
policy above we can see a few strategies that are taken to avoid this:
|
88
|
+
|
89
|
+
- Conditions are re-used liberally.
|
90
|
+
- Non-boolean facts are cached at the policy level.
|
91
|
+
|
92
|
+
## Re-using conditions
|
93
|
+
|
94
|
+
Rules can and should re-use conditions as much as possible. Condition
|
95
|
+
observations are cached automatically, so referring to the same condition in
|
96
|
+
multiple rules is encouraged. Conditions can also refer to other conditions by
|
97
|
+
using the predicate methods that are created for them (see `full_rights`, which
|
98
|
+
refers to the `:citizen` condition as `citizen?`).
|
99
|
+
|
100
|
+
Note that referring to conditions inside other conditions can be DRY, but it
|
101
|
+
limits the ability of the library to optimize the steps (see
|
102
|
+
[optimization](./optimization.md)). For example in the `:has_current_visa`
|
103
|
+
condition, the sub-conditions will always be tested in the order
|
104
|
+
`has_visa_waiver` then `current_visa.present?`. It is recommended not to rely
|
105
|
+
heavily on this kind of abstraction.
|
106
|
+
|
107
|
+
## Re-using rules
|
108
|
+
|
109
|
+
Entire rule-sets can be re-used with `can?`. This is a form of logical
|
110
|
+
implication where a previous conclusion can be used in a further rule. Examples
|
111
|
+
of this here are `can?(:settle)` and `can?(:freedom_of_movement)`. This can
|
112
|
+
prevent having to repeat long groups of conditions in rule definitions. This
|
113
|
+
abstraction is transparent to the optimizer.
|
114
|
+
|
115
|
+
## Non-boolean values must be managed manually
|
116
|
+
|
117
|
+
The condition `has_current_visa` and the more specific
|
118
|
+
`has_{work,business}_visa` all refer to the same piece of state - the
|
119
|
+
`#current_visa`. Since this is not a boolean (but is here a database record with
|
120
|
+
a `#category` attribute), this cannot be a condition, but must be managed by the
|
121
|
+
policy itself.
|
122
|
+
|
123
|
+
The best approach here is to use normal Ruby methods and instance variables for
|
124
|
+
such values. The policy instances themselves are cached, so that any two
|
125
|
+
invocations of `DeclarativePolicy.policy_for(user, subject)` with identical
|
126
|
+
`user` and `subject` arguments will always return the same policy object. This
|
127
|
+
means instance variables stored on the policy will be available for the lifetime
|
128
|
+
of the cache.
|
129
|
+
|
130
|
+
Methods can be used for the usual reasons of clarity (such as referring to the
|
131
|
+
`@subject` as `country`) and brevity (such as `visa_category`).
|
132
|
+
|
133
|
+
## Cache lifetime
|
134
|
+
|
135
|
+
The cache is provided by the user of the library, passing it to the
|
136
|
+
`.policy_for` method. For example:
|
137
|
+
|
138
|
+
```ruby
|
139
|
+
DeclarativePolicy.policy_for(user, country, cache: some_cache_value)
|
140
|
+
```
|
141
|
+
|
142
|
+
The object only needs to implement the following methods:
|
143
|
+
|
144
|
+
- `cache[key: String] -> Boolean?`: Fetch the cached value
|
145
|
+
- `cache.key?(key: String) -> Boolean`: Test if the key is cached
|
146
|
+
- `cache[key: String] = Boolean`: Cache a value
|
147
|
+
|
148
|
+
Obviously, a `HashMap` will work just fine, but so will a wrapper around a
|
149
|
+
[`Concurrent::Map`](https://ruby-concurrency.github.io/concurrent-ruby/1.1.4/Concurrent/Map.html),
|
150
|
+
or even a map that delegates to Redis with a TTL for each key, so long as the
|
151
|
+
object supports these methods. Keys are never deleted by the library, and values
|
152
|
+
are only computed if the key is not cached, so it is up to the application code
|
153
|
+
to determine the life-time of each key.
|
154
|
+
|
155
|
+
Clearly, cache-invalidation is a hard problem. At GitLab we share a single cache
|
156
|
+
object for each request - so any single request can freely request a permission
|
157
|
+
check multiple times (or even compute related abilities, such as
|
158
|
+
`:enter_country` and `:settle`) and know that no work is duplicated. This
|
159
|
+
allows developers to reason declaratively, and add permission checks where
|
160
|
+
needed, without worrying about performance.
|
161
|
+
|
162
|
+
## Cache sharing: scopes
|
163
|
+
|
164
|
+
Not all conditions are equally specific. The condition `citizen` refers to
|
165
|
+
both the user and the country, and so can only be used when checking both the
|
166
|
+
user and the country. We say that this is the `normal` scope.
|
167
|
+
|
168
|
+
This is not always true however. Sometimes a condition refers only to the user.
|
169
|
+
For example, above we have two conditions: `eu_citizen` and `eu_member`:
|
170
|
+
|
171
|
+
```ruby
|
172
|
+
condition(:eu_citizen, scope: :user) { @user.citizen_of?(*Unions::EU) }
|
173
|
+
condition(:eu_member, scope: :subject) { Unions::EU.include?(country.country_code) }
|
174
|
+
```
|
175
|
+
|
176
|
+
`eu_citizen` refers only to the user, and `eu_member` refers only to the
|
177
|
+
country.
|
178
|
+
|
179
|
+
If we have a user that wants to enter multiple countries on a grand European
|
180
|
+
tour, we could check this with:
|
181
|
+
|
182
|
+
```ruby
|
183
|
+
itinerary.countries.all? { |c| DeclarativePolicy.policy_for(user, c).allowed?(:enter_country) }
|
184
|
+
```
|
185
|
+
|
186
|
+
If `eu_citizen` were declared with the `normal` scope, then this would have a lot of cache
|
187
|
+
misses. By using the `:user` scope on `eu_citizen`, we only check EU citizenship
|
188
|
+
once.
|
189
|
+
|
190
|
+
Similarly for `eu_member`, if a team of football players want to visit a
|
191
|
+
country, then we could check this with:
|
192
|
+
|
193
|
+
```ruby
|
194
|
+
team.players.all? { |user| DeclarativePolicy.policy_for(user, country).allowed?(:enter_country) }
|
195
|
+
```
|
196
|
+
|
197
|
+
Again, by declaring `eu_member` as having the `:subject` scope, this ensures we
|
198
|
+
only check EU membership once, not once for each football player.
|
199
|
+
|
200
|
+
The last scope is `:global`, used when the condition is universally true:
|
201
|
+
|
202
|
+
```ruby
|
203
|
+
condition(:earth_destroyed_by_meteor, scope: global) { !Planet::Earth.exists? }
|
204
|
+
|
205
|
+
rule { earth_destroyed_by_meteor }.prevent_all
|
206
|
+
```
|
207
|
+
|
208
|
+
In this case, it doesn't matter who the user is or even where they are going:
|
209
|
+
the condition will be computed once (per cache lifetime) for all combinations.
|
210
|
+
|
211
|
+
Because of the implications for sharing, the scope determines the
|
212
|
+
[`#score`](https://gitlab.com/gitlab-org/ruby/gems/declarative-policy/blob/2ab9dbdf44fb37beb8d0f7c131742d47ae9ef5d0/lib/declarative_policy/condition.rb#L58-77) of
|
213
|
+
the condition (if not provided explicitly). The intention is to prefer values we
|
214
|
+
are more likely (all other things being equal) to re-use:
|
215
|
+
|
216
|
+
- Conditions we have already cached get a score of `0`.
|
217
|
+
- Conditions that are in the `:global` scope get a score of `2`.
|
218
|
+
- Conditions that are in the `:user` or `:subject` scopes get a score of `8`.
|
219
|
+
- Conditions that are in the `:user_and_subject` scope get a score of `16`.
|
220
|
+
|
221
|
+
Bear helper-methods in mind when defining scopes. While the instance level cache
|
222
|
+
for non-boolean values would not be shared, as long as the derived condition is
|
223
|
+
shared (for example by being in the `:user` scope, rather than the `:user_and_subject`
|
224
|
+
scope), helper-methods will also benefit from improved cache hits.
|
225
|
+
|
226
|
+
### Preferred scope
|
227
|
+
|
228
|
+
In the example situations above (a single user visiting many countries, or a
|
229
|
+
football team visiting one country), we know which is more likely to be useful,
|
230
|
+
the `:subject` or the `:user` scope. We can inform the optimizer of this
|
231
|
+
by setting `DeclarativePolicy.preferred_scope`.
|
232
|
+
|
233
|
+
To do this, check the abilities within a block bounded
|
234
|
+
by [`DeclarativePolicy.with_preferred_scope`](https://gitlab.com/gitlab-org/ruby/gems/declarative-policy/blob/481c322a74f76c325d3ccab7f2f3cc2773e8168b/lib/declarative_policy/preferred_scope.rb#L7-13).
|
235
|
+
For example:
|
236
|
+
|
237
|
+
```ruby
|
238
|
+
cache = {}
|
239
|
+
|
240
|
+
# preferring to run user-scoped conditions
|
241
|
+
DeclarativePolicy.with_preferred_scope(:user) do
|
242
|
+
itinerary.countries.all? do |c|
|
243
|
+
DeclarativePolicy.policy_for(user, c, cache: cache).allowed?(:enter_country)
|
244
|
+
end
|
245
|
+
end
|
246
|
+
|
247
|
+
# preferring to run subject-scoped conditions
|
248
|
+
DeclarativePolicy.with_preferred_scope(:subject) do
|
249
|
+
team.players.all? do |player|
|
250
|
+
DeclarativePolicy.policy_for(player, c, cache: cache).allowed?(:enter_country)
|
251
|
+
end
|
252
|
+
end
|
253
|
+
|
254
|
+
```
|
255
|
+
|
256
|
+
When we set `preferred_scope`, this reduces the default score for conditions in
|
257
|
+
that scope, so that they are more likely to be executed first. Instead of `8`,
|
258
|
+
they are given a default score of `4`.
|
259
|
+
|
260
|
+
## Cache keys
|
261
|
+
|
262
|
+
In order for an object to be cached, it should be able to identify itself
|
263
|
+
with a suitable cache key. A good cache key will identify an object, without
|
264
|
+
containing irrelevant information - a database `#id` is perfect, and this
|
265
|
+
library defaults to calling an `#id` method on objects, falling back to
|
266
|
+
`object_id`.
|
267
|
+
|
268
|
+
Relying on `object_id` is not recommended since otherwise equivalent objects
|
269
|
+
have different `object_id` values, and using `object_id` will not get optimal caching. All
|
270
|
+
policy subjects should implement `#id` for this reason. `ActiveRecord` models
|
271
|
+
with an `id` primary ID attribute do not need any extra configuration.
|
272
|
+
|
273
|
+
Please see: [`DeclarativePolicy::Cache`](https://gitlab.com/gitlab-org/ruby/gems/declarative-policy/blob/main/lib/declarative_policy/cache.rb).
|
274
|
+
|
275
|
+
## Cache invalidation
|
276
|
+
|
277
|
+
Generally, cache invalidation is best avoided. It is very hard to get right, and
|
278
|
+
relying on it opens you up to subtle but pernicious bugs that are hard to
|
279
|
+
reproduce and debug.
|
280
|
+
|
281
|
+
The best strategy is to run all permission checks upfront, before mutating any
|
282
|
+
state that might change a permission computation. For instance, if you want to
|
283
|
+
make a user an administrator, then check for permission **before** assigning
|
284
|
+
administrator privileges.
|
285
|
+
|
286
|
+
However, it isn't always possible to avoid needing to mark certain parts of the
|
287
|
+
cached state as dirty (in need of re-computation). If this is needed, then you
|
288
|
+
can call the `DeclarativePolicy.invalidate(cache, keys)` method. This takes an
|
289
|
+
enumerable of dirty keys, and:
|
290
|
+
|
291
|
+
- removes the cached condition results from the cache
|
292
|
+
- marks the abilities that depend on those conditions as dirty, and in need of
|
293
|
+
re-computation.
|
294
|
+
|
295
|
+
The responsibility for determining which cache-keys are dirty falls on the
|
296
|
+
client. You could, for example, do this by observing which keys are added to the
|
297
|
+
cache (knowing that condition keys all start with `"/dp/condition/"`), or by
|
298
|
+
scanning the cache for keys that match a heuristic.
|
299
|
+
|
300
|
+
This method is the only place where the `#delete` method is called on the cache.
|
301
|
+
If you do not call `.invalidate`, there is no need for the cache to implement
|
302
|
+
`#delete`.
|