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