declarative_policy 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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.