declarative_policy 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Danger
4
+ # Project specific configuration
5
+ class ProjectHelper < ::Danger::Plugin
6
+ LOCAL_RULES ||= %w[
7
+ changelog
8
+ documentation
9
+ ].freeze
10
+
11
+ CI_ONLY_RULES ||= %w[
12
+ roulette
13
+ ].freeze
14
+
15
+ MESSAGE_PREFIX = '==>'
16
+
17
+ # First-match win, so be sure to put more specific regex at the top...
18
+ # rubocop: disable Style/RegexpLiteral
19
+ CATEGORIES = {
20
+ %r{\A(\.gitlab-ci\.yml\z|\.gitlab/ci)} => :engineering_productivity,
21
+ %r{\Alefthook.yml\z} => :engineering_productivity,
22
+ %r{\A\.editorconfig\z} => :engineering_productivity,
23
+ %r{Dangerfile\z} => :engineering_productivity,
24
+ %r{\A(danger/|tooling/danger/)} => :engineering_productivity,
25
+ %r{\A?scripts/} => :engineering_productivity,
26
+ %r{\Atooling/} => :engineering_productivity,
27
+ %r{(CODEOWNERS)} => :engineering_productivity,
28
+ %r{\A(Gemfile|Gemfile.lock|Rakefile)\z} => :backend,
29
+ %r{\A\.rubocop((_manual)?_todo)?\.yml\z} => :backend,
30
+ %r{\.rb\z} => :backend,
31
+ %r{(
32
+ \.(md|txt)\z |
33
+ \.markdownlint\.json
34
+ )}x => :docs
35
+ }.freeze
36
+ # rubocop: enable Style/RegexpLiteral
37
+
38
+ def changes_by_category
39
+ helper.changes_by_category(CATEGORIES)
40
+ end
41
+
42
+ def changes
43
+ helper.changes(CATEGORIES)
44
+ end
45
+
46
+ def rule_names
47
+ helper.ci? ? LOCAL_RULES | CI_ONLY_RULES : LOCAL_RULES
48
+ end
49
+
50
+ def project_name
51
+ # 'declarative-policy'
52
+ # TODO: roulette uses the project name to find reviewers, but the gitlab team
53
+ # directory currently does not have any team members assigned to the declarative-policy
54
+ # project. We thus are piggybacking on 'gitlab' for now.
55
+ 'gitlab'
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest/md5'
4
+
5
+ MESSAGE = <<MARKDOWN
6
+ ## Reviewer roulette
7
+
8
+ Changes that require review have been detected! A merge request is normally
9
+ reviewed by both a reviewer and a maintainer.
10
+ MARKDOWN
11
+
12
+ CATEGORY_TABLE_HEADER = <<MARKDOWN
13
+
14
+ To spread load more evenly across eligible reviewers, Danger has picked a
15
+ candidate for each review slot, based on their timezone. Feel free to
16
+ [override these selections](https://about.gitlab.com/handbook/engineering/projects/#gitlab) if
17
+ you think someone else would be better-suited or use the
18
+ [GitLab Review Workload Dashboard](https://gitlab-org.gitlab.io/gitlab-roulette/) to find other
19
+ available reviewers.
20
+
21
+ To read more on how to use the reviewer roulette, please take a look at the
22
+ [Engineering workflow](https://about.gitlab.com/handbook/engineering/workflow/#basics) and
23
+ [code review guidelines](https://docs.gitlab.com/ee/development/code_review.html). You may
24
+ consider assigning a reviewer or maintainer who is
25
+ a [domain expert](https://about.gitlab.com/handbook/engineering/projects/#gitlab) for
26
+ the `DeclarativePolicy` library.
27
+
28
+ Once you've decided who will review this merge request, assign them as a reviewer!
29
+ Danger does not automatically notify them for you.
30
+
31
+ | Category | Reviewer | Maintainer |
32
+ | -------- | -------- | ---------- |
33
+ MARKDOWN
34
+
35
+ UNKNOWN_FILES_MESSAGE = <<MARKDOWN
36
+
37
+ These files couldn't be categorised, so Danger was unable to suggest a reviewer.
38
+ Please consider creating a merge request
39
+ to [add support](https://gitlab.com/gitlab-org/gitlab/blob/master/tooling/danger/project_helper.rb)
40
+ for them.
41
+ MARKDOWN
42
+
43
+ OPTIONAL_REVIEW_TEMPLATE = '%{role} review is optional for %{category}'
44
+ NOT_AVAILABLE_TEMPLATE = 'No %{role} available'
45
+
46
+ def note_for_spins_role(spins, role)
47
+ spins.each do |spin|
48
+ note = note_for_spin_role(spin, role)
49
+
50
+ return note if note
51
+ end
52
+
53
+ format(NOT_AVAILABLE_TEMPLATE, role: role)
54
+ end
55
+
56
+ def note_for_spin_role(spin, role)
57
+ if spin.optional_role == role
58
+ return format(OPTIONAL_REVIEW_TEMPLATE, role: role.capitalize, category: helper.label_for_category(spin.category))
59
+ end
60
+
61
+ spin.public_send(role)&.markdown_name(author: roulette.team_mr_author) # rubocop:disable GitlabSecurity/PublicSend
62
+ end
63
+
64
+ def markdown_row_for_spins(category, spins_array)
65
+ reviewer_note = note_for_spins_role(spins_array, :reviewer)
66
+ maintainer_note = note_for_spins_role(spins_array, :maintainer)
67
+
68
+ "| #{helper.label_for_category(category)} | #{reviewer_note} | #{maintainer_note} |"
69
+ end
70
+
71
+ changes = project_helper.changes_by_category
72
+
73
+ # Ignore any files that are known but uncategorized. Prompt for any unknown files
74
+ changes.delete(:none)
75
+ # To reinstate roulette for documentation, remove this line.
76
+ changes.delete(:docs)
77
+ # No special review for changelog needed and changelog was never a label.
78
+ changes.delete(:changelog)
79
+ # No special review for feature flags needed.
80
+ changes.delete(:feature_flag)
81
+ categories = changes.keys.to_set.delete(:unknown)
82
+
83
+ if changes.any?
84
+ project = project_helper.project_name
85
+
86
+ random_roulette_spins = roulette.spin(project, categories, timezone_experiment: false)
87
+
88
+ rows = random_roulette_spins.map do |spin|
89
+ markdown_row_for_spins(spin.category, [spin])
90
+ end
91
+
92
+ markdown(MESSAGE)
93
+ markdown(CATEGORY_TABLE_HEADER + rows.join("\n")) unless rows.empty?
94
+
95
+ unknown = changes.fetch(:unknown, [])
96
+ markdown(UNKNOWN_FILES_MESSAGE + helper.markdown_list(unknown)) unless unknown.empty?
97
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/declarative_policy/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'declarative_policy'
7
+ spec.version = DeclarativePolicy::VERSION
8
+ spec.authors = ['Jeanine Adkisson', 'Alexis Kalderimis']
9
+ spec.email = ['akalderimis@gitlab.com']
10
+
11
+ spec.summary = 'An authorization library with a focus on declarative policy definitions.'
12
+ spec.description = <<~DESC
13
+ This library provides an authorization framework with a declarative DSL
14
+
15
+ With this library, you can write permission policies that are separate
16
+ from business logic.
17
+
18
+ This library is in production use at GitLab.com
19
+ DESC
20
+ spec.homepage = 'https://gitlab.com/gitlab-org/declarative-policy'
21
+ spec.license = 'MIT'
22
+ spec.required_ruby_version = Gem::Requirement.new('>= 2.6.0')
23
+
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/-/blobs/master/CHANGELOG.md'
27
+
28
+ # 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
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
31
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
32
+ end
33
+ spec.bindir = 'exe'
34
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
35
+ spec.require_paths = ['lib']
36
+ end
data/doc/caching.md ADDED
@@ -0,0 +1,4 @@
1
+ # Caching
2
+
3
+ **TODO**: see https://gitlab.com/gitlab-org/declarative-policy/-/issues/11
4
+
@@ -0,0 +1,78 @@
1
+ # Configuration
2
+
3
+ This library is generally configured by writing policies that match
4
+ the look-up rules for domain objects (see: [defining policies](./defining-policies.md)).
5
+
6
+ ## Configuration blocks
7
+
8
+ This library can be configured using `DeclarativePolicy.configure` and
9
+ `DeclarativePolicy.configure!`. Both methods take a block, and they differ only
10
+ in that `.configure!` ensures that the configuration is pristine, and
11
+ discards any previous configuration, and `configure` can be called multiple
12
+ times.
13
+
14
+ ## Handling `nil` values
15
+
16
+ By default, all permission checks on `nil` values are denied. This is
17
+ controlled by `DeclarativePolicy::NilPolicy`, which is implemented as:
18
+
19
+ ```ruby
20
+ module DeclarativePolicy
21
+ class NilPolicy < DeclarativePolicy::Base
22
+ rule { default }.prevent_all
23
+ end
24
+ end
25
+ ```
26
+
27
+ If you want to handle `nil` values differently, then you can define your
28
+ own `nil` policy, and configure it to be used in a configuration block:
29
+
30
+ ```ruby
31
+ DeclarativePolicy.configure do
32
+ nil_policy MyNilPolicy
33
+ end
34
+ ```
35
+
36
+ ## Named policies
37
+
38
+ Normally policies are determined by looking up matching policy definitions
39
+ based on the class of the value. `Symbol` values are treated specially, and
40
+ these define **named policies**.
41
+
42
+ To define a named policy, use a configuration block:
43
+
44
+ ```ruby
45
+ DeclarativePolicy.configure do
46
+ named_policy :global, MyGlobalPolicy
47
+ end
48
+ ```
49
+
50
+ Then it can be used by passing the `:global` symbol as the value in a permission
51
+ check:
52
+
53
+ ```
54
+ policy = DeclarativePolicy.policy_for(the_user, :global)
55
+ policy.allowed?(:some_ability)
56
+ ```
57
+
58
+ This can be useful where there is no object of the permission check (that is,
59
+ the predicate is **intransitive**). An example might be `:can_log_in`, where
60
+ there is no suitable object, and the identity of the user is fully sufficient to
61
+ determine the permission check.
62
+
63
+ Using `:global` is a convention, but any policy name can be used.
64
+
65
+ ## Name transformation
66
+
67
+ By default, policy classes are expected to be named for the domain classes, with
68
+ a `Policy` suffix. So a domain class of `Foo` would resolve to a `FooPolicy`.
69
+
70
+ This logic can be customized by specifying the `name_transformation` rule. To
71
+ instead have all policies be placed in a `Policies` namespace, so that `Foo`
72
+ would have its policy at `Policies::Foo`, we can configure that with:
73
+
74
+ ```ruby
75
+ DeclarativePolicy.configure do
76
+ name_transformation { |name| "Policies::#{name}" }
77
+ end
78
+ ```
@@ -0,0 +1,185 @@
1
+ # Defining policies
2
+
3
+ A policy is a set of conditions and rules for domain objects. They are defined
4
+ using a DSL, and mapped to domain objects by class name.
5
+
6
+ ## Class name determines policy choice
7
+
8
+ If there is a domain class `Foo`, then we can link it to a policy by defining a
9
+ class `FooPolicy`. This class can be placed anywhere, as long as it is loaded
10
+ before the call to `DeclarativePolicy.policy_for`.
11
+
12
+ Our recommendation for large applications, such as Rails apps, is to add a new
13
+ top-level application directory: `app/policies`, and place all policy
14
+ definitions in there. If you have an `Invoice` model at `app/models/invoice.rb`,
15
+ then you would create an `InvoicePolicy` at `app/policies/invoice_policy.rb`.
16
+
17
+ ## Invocation
18
+
19
+ We evaluate policies by instantiating them with `DeclarativePolicy::policy_for`,
20
+ and then evaluating them with `DeclarativePolicy::Base#allowed?`.
21
+
22
+ You may wish to define a method to abstract policy evaluation. Something like:
23
+
24
+ ```ruby
25
+ def allowed?(user, ability, object)
26
+ opts = { cache: Cache.current_cache } # re-using a cache between checks eliminates duplication of work
27
+ policy = DeclarativePolicy.policy_for(user, object, opts)
28
+ policy.allowed?(ability)
29
+ end
30
+ ```
31
+
32
+ We will assume the presence of such a method below.
33
+
34
+ ## Defining rules in the DSL
35
+
36
+ The DSL has two primary parts: defining **conditions** and **rules**.
37
+
38
+ For example, imagine we have a data model containing vehicles and users, and we
39
+ want to know if a user can drive a vehicle. We need a `VehiclePolicy`:
40
+
41
+ ```ruby
42
+ class VehiclePolicy < DeclarativePolicy::Base
43
+ # conditions go here by convention
44
+
45
+ # rules go here by convention
46
+
47
+ # helper methods go last
48
+ end
49
+ ```
50
+
51
+ ### Conditions
52
+
53
+ Conditions are facts about the state of the system.
54
+
55
+ They have access to two elements of the proposition:
56
+
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
59
+ that the current user is anonymous (for example this may reflect an
60
+ unauthenticated request in your system).
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)`.
63
+ `@subject` is never `nil`. See [handling `nil` values](./configuration.md#handling-nil-values)
64
+ for details of how to apply policies to `nil` values.
65
+
66
+
67
+ They are defined as `condition(name, **options, &block)`, where the block is
68
+ evaluated in the context of an instance of the policy.
69
+
70
+ For example:
71
+
72
+ ```ruby
73
+ condition(:owns) { @subject.owner == @user }
74
+ condition(:has_access_to) { @subject.owner.trusts?(@user) }
75
+ condition(:old_enough_to_drive) { @user.age >= laws.minimum_age }
76
+ condition(:has_driving_license) { @user.driving_license&.valid? }
77
+ condition(:intoxicated, score: 5) { @user.blood_alcohol < laws.max_blood_alcohol }
78
+ condition(:has_access_to, score: 3) { @subject.owner.trusts?(@user) }
79
+ ```
80
+
81
+ These can be defined in any order, but we consider it best practice to define
82
+ conditions at the top of the file.
83
+
84
+ Conditions may call methods of the policy class, which can be helpful for
85
+ memoizing some intermediate state:
86
+
87
+ ```ruby
88
+ condition(:full_license) { license.full? }
89
+ condition(:learner_license) { license.learner? }
90
+ condition(:hgv_license) { license.heavy_goods? }
91
+
92
+ def license
93
+ @license ||= Licenses.by_country(@user.country_of_residence).for_user(@user)
94
+ end
95
+ ```
96
+
97
+ Conditions are evaluated at most once, and their values are automatically
98
+ memoized and cached (see [caching](./caching.md) for more detail).
99
+
100
+ If you want to perform I/O (such as database access) or expensive computations,
101
+ place this access in a condition.
102
+
103
+ ### Rules
104
+
105
+ Rules are conclusions we can draw based on the facts:
106
+
107
+ ```ruby
108
+ rule { owns }.enable :drive_vehicle
109
+ rule { has_access_to }.enable :drive_vehicle
110
+ rule { ~old_enough_to_drive }.prevent :drive_vehicle
111
+ rule { intoxicated }.prevent :drive_vehicle
112
+ rule { ~has_driving_license }.prevent :drive_vehicle
113
+ ```
114
+
115
+ Rules are combined such that each ability must be enabled at least once, and not
116
+ prevented in order to be permitted. So `enable` calls are implicitly combined
117
+ with `ANY`, and `prevent` calls are implicitly combined with `ALL`.
118
+
119
+ A set of conclusions can be defined for a single condition:
120
+
121
+ ```ruby
122
+ rule { old_enough_to_drive }.policy do
123
+ enable :drive_vehicle
124
+ enable :vote
125
+ end
126
+ ```
127
+
128
+ Rule blocks do not have access to the internal state of the policy, and cannot
129
+ access the `@user` or `@subject`, or any methods on the policy instance. You
130
+ should not perform I/O in a rule. They exist solely to define the logical rules
131
+ of implication and combination between conditions.
132
+
133
+ ### Complex conditions
134
+
135
+ Conditions may be combined in the rule blocks:
136
+
137
+ ```ruby
138
+ # A or B
139
+ rule { owns | has_access_to }.enable :drive_vehicle
140
+ # A and B
141
+ rule { has_driving_license & old_enough_to_drive }.enable :drive_vehicle
142
+ # Not A
143
+ rule { ~has_driving_license }.prevent :drive_vehicle
144
+ ```
145
+
146
+ And conditions can be implied from abilities:
147
+
148
+ ```ruby
149
+ rule { can?(:drive_vehicle) }.enable :drive_taxi
150
+ ```
151
+
152
+ ### Delegation
153
+
154
+ Policies may delegate to other policies. For example we could have a
155
+ `DrivingLicense` class, and a `DrivingLicensePolicy`, which might contain rules
156
+ like:
157
+
158
+ ```ruby
159
+ class DrivingLicensePolicy < DeclarativePolicy::Base
160
+ condition(:expired) { @subject.expires_at <= Time.current }
161
+
162
+ rule { expired }.prevent :drive_vehicle
163
+ end
164
+ ```
165
+
166
+ And a registration policy:
167
+
168
+ ```ruby
169
+ class RegistrationPolicy < DeclarativePolicy::Base
170
+ condition(:valid) { @subject.valid_for?(@user.current_location) }
171
+
172
+ rule { ~valid }.prevent :drive_vehicle
173
+ end
174
+ ```
175
+
176
+ Then in our `VehiclePolicy` we can delegate the license and registration
177
+ checking to these two policies:
178
+
179
+ ```ruby
180
+ delegate { @user.driving_license }
181
+ delegate { @subject.registration }
182
+ ```
183
+
184
+ This is a powerful mechanism for inferring rules based on relationships between
185
+ objects.