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.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.gitlab-ci.yml +48 -0
- data/.rspec +4 -0
- data/.rubocop.yml +10 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Dangerfile +16 -0
- data/Gemfile +24 -0
- data/Gemfile.lock +197 -0
- data/LICENSE.txt +21 -0
- data/README.md +144 -0
- data/Rakefile +8 -0
- data/danger/plugins/project_helper.rb +58 -0
- data/danger/roulette/Dangerfile +97 -0
- data/declarative_policy.gemspec +36 -0
- data/doc/caching.md +4 -0
- data/doc/configuration.md +78 -0
- data/doc/defining-policies.md +185 -0
- data/lib/declarative_policy.rb +128 -0
- data/lib/declarative_policy/base.rb +351 -0
- data/lib/declarative_policy/cache.rb +39 -0
- data/lib/declarative_policy/condition.rb +104 -0
- data/lib/declarative_policy/configuration.rb +37 -0
- data/lib/declarative_policy/delegate_dsl.rb +22 -0
- data/lib/declarative_policy/nil_policy.rb +8 -0
- data/lib/declarative_policy/policy_dsl.rb +46 -0
- data/lib/declarative_policy/preferred_scope.rb +31 -0
- data/lib/declarative_policy/rule.rb +316 -0
- data/lib/declarative_policy/rule_dsl.rb +51 -0
- data/lib/declarative_policy/runner.rb +203 -0
- data/lib/declarative_policy/step.rb +89 -0
- data/lib/declarative_policy/version.rb +5 -0
- metadata +84 -0
data/Rakefile
ADDED
@@ -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,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.
|