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.
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeclarativePolicy
4
+ # The DSL evaluation context inside rule { ... } blocks.
5
+ # Responsible for creating and combining Rule objects.
6
+ #
7
+ # See Base.rule
8
+ class RuleDsl
9
+ def initialize(context_class)
10
+ @context_class = context_class
11
+ end
12
+
13
+ def can?(ability)
14
+ Rule::Ability.new(ability)
15
+ end
16
+
17
+ def all?(*rules)
18
+ Rule::And.make(rules)
19
+ end
20
+
21
+ def any?(*rules)
22
+ Rule::Or.make(rules)
23
+ end
24
+
25
+ def none?(*rules)
26
+ ~Rule::Or.new(rules)
27
+ end
28
+
29
+ def cond(condition)
30
+ Rule::Condition.new(condition)
31
+ end
32
+
33
+ def delegate(delegate_name, condition)
34
+ Rule::DelegatedCondition.new(delegate_name, condition)
35
+ end
36
+
37
+ def method_missing(msg, *args)
38
+ return super unless args.empty? && !block_given?
39
+
40
+ if @context_class.delegations.key?(msg)
41
+ DelegateDsl.new(self, msg)
42
+ else
43
+ cond(msg.to_sym)
44
+ end
45
+ end
46
+
47
+ def respond_to_missing?(symbol, include_all)
48
+ true
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeclarativePolicy
4
+ class Runner
5
+ class State
6
+ def initialize
7
+ @enabled = false
8
+ @prevented = false
9
+ end
10
+
11
+ def enable!
12
+ @enabled = true
13
+ end
14
+
15
+ def enabled?
16
+ @enabled
17
+ end
18
+
19
+ def prevent!
20
+ @prevented = true
21
+ end
22
+
23
+ def prevented?
24
+ @prevented
25
+ end
26
+
27
+ def pass?
28
+ !prevented? && enabled?
29
+ end
30
+ end
31
+
32
+ # a Runner contains a list of Steps to be run.
33
+ attr_reader :steps
34
+
35
+ def initialize(steps)
36
+ @steps = steps
37
+ @state = nil
38
+ end
39
+
40
+ # We make sure only to run any given Runner once,
41
+ # and just continue to use the resulting @state
42
+ # that's left behind.
43
+ def cached?
44
+ !!@state
45
+ end
46
+
47
+ # used by Rule::Ability. See #steps_by_score
48
+ def score
49
+ return 0 if cached?
50
+
51
+ steps.sum(&:score)
52
+ end
53
+
54
+ def merge_runner(other)
55
+ Runner.new(@steps + other.steps)
56
+ end
57
+
58
+ # The main entry point, called for making an ability decision.
59
+ # See #run and DeclarativePolicy::Base#can?
60
+ def pass?
61
+ run unless cached?
62
+
63
+ @state.pass?
64
+ end
65
+
66
+ # see DeclarativePolicy::Base#debug
67
+ def debug(out = $stderr)
68
+ run(out)
69
+ end
70
+
71
+ private
72
+
73
+ def flatten_steps!
74
+ @steps = @steps.flat_map { |s| s.flattened(@steps) }
75
+ end
76
+
77
+ # This method implements the semantic of "one enable and no prevents".
78
+ # It relies on #steps_by_score for the main loop, and updates @state
79
+ # with the result of the step.
80
+ def run(debug = nil)
81
+ @state = State.new
82
+
83
+ steps_by_score do |step, score|
84
+ break if !debug && @state.prevented?
85
+
86
+ passed = nil
87
+ case step.action
88
+ when :enable
89
+ # we only check :enable actions if they have a chance of
90
+ # changing the outcome - if no other rule has enabled or
91
+ # prevented.
92
+ unless @state.enabled? || @state.prevented?
93
+ passed = step.pass?
94
+ @state.enable! if passed
95
+ end
96
+
97
+ debug << inspect_step(step, score, passed) if debug
98
+ when :prevent
99
+ # we only check :prevent actions if the state hasn't already
100
+ # been prevented.
101
+ unless @state.prevented?
102
+ passed = step.pass?
103
+ @state.prevent! if passed
104
+ end
105
+
106
+ debug << inspect_step(step, score, passed) if debug
107
+ else raise "invalid action #{step.action.inspect}"
108
+ end
109
+ end
110
+
111
+ @state
112
+ end
113
+
114
+ # This is the core spot where all those `#score` methods matter.
115
+ # It is critical for performance to run steps in the correct order,
116
+ # so that we don't compute expensive conditions (potentially n times
117
+ # if we're called on, say, a large list of users).
118
+ #
119
+ # In order to determine the cheapest step to run next, we rely on
120
+ # Step#score, which returns a numerical rating of how expensive
121
+ # it would be to calculate - the lower the better. It would be
122
+ # easy enough to statically sort by these scores, but we can do
123
+ # a little better - the scores are cache-aware (conditions that
124
+ # are already in the cache have score 0), which means that running
125
+ # a step can actually change the scores of other steps.
126
+ #
127
+ # So! The way we sort here involves re-scoring at every step. This
128
+ # is by necessity quadratic, but most of the time the number of steps
129
+ # will be low. But just in case, if the number of steps exceeds 50,
130
+ # we print a warning and fall back to a static sort.
131
+ #
132
+ # For each step, we yield the step object along with the computed score
133
+ # for debugging purposes.
134
+ def steps_by_score
135
+ flatten_steps!
136
+
137
+ if @steps.size > 50
138
+ warn "DeclarativePolicy: large number of steps (#{steps.size}), falling back to static sort"
139
+
140
+ @steps.map { |s| [s.score, s] }.sort_by { |(score, _)| score }.each do |(score, step)|
141
+ yield step, score
142
+ end
143
+
144
+ return
145
+ end
146
+
147
+ remaining_steps = Set.new(@steps)
148
+ remaining_enablers, remaining_preventers = remaining_steps.partition(&:enable?).map { |s| Set.new(s) }
149
+
150
+ loop do
151
+ if @state.enabled?
152
+ # Once we set this, we never need to unset it, because a single
153
+ # prevent will stop this from being enabled
154
+ remaining_steps = remaining_preventers
155
+ elsif remaining_enablers.empty?
156
+ # if the permission hasn't yet been enabled and we only have
157
+ # prevent steps left, we short-circuit the state here
158
+ @state.prevent!
159
+ end
160
+
161
+ return if remaining_steps.empty?
162
+
163
+ next_step, lowest_score = next_step_and_score(remaining_steps)
164
+
165
+ [remaining_steps, remaining_enablers, remaining_preventers].each do |set|
166
+ set.delete(next_step)
167
+ end
168
+
169
+ yield next_step, lowest_score
170
+ end
171
+ end
172
+
173
+ def next_step_and_score(remaining_steps)
174
+ lowest_score = Float::INFINITY
175
+ next_step = nil
176
+
177
+ remaining_steps.each do |step|
178
+ score = step.score
179
+
180
+ if score < lowest_score
181
+ next_step = step
182
+ lowest_score = score
183
+ end
184
+
185
+ break if lowest_score.zero?
186
+ end
187
+
188
+ [next_step, score]
189
+ end
190
+
191
+ # Formatter for debugging output.
192
+ def inspect_step(step, original_score, passed)
193
+ symbol =
194
+ case passed
195
+ when true then '+'
196
+ when false then '-'
197
+ when nil then ' '
198
+ end
199
+
200
+ "#{symbol} [#{original_score.to_i}] #{step.repr}\n"
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeclarativePolicy
4
+ # This object represents one step in the runtime decision of whether
5
+ # an ability is allowed. It contains a Rule and a context (instance
6
+ # of DeclarativePolicy::Base), which contains the user, the subject,
7
+ # and the cache. It also contains an "action", which is the symbol
8
+ # :prevent or :enable.
9
+ class Step
10
+ attr_reader :context, :rule, :action
11
+
12
+ def initialize(context, rule, action)
13
+ @context = context
14
+ @rule = rule
15
+ @action = action
16
+ end
17
+
18
+ # In the flattening process, duplicate steps may be generated in the
19
+ # same rule. This allows us to eliminate those (see Runner#steps_by_score
20
+ # and note its use of a Set)
21
+ def ==(other)
22
+ @context == other.context && @rule == other.rule && @action == other.action
23
+ end
24
+
25
+ # In the runner, steps are sorted dynamically by score, so that
26
+ # we are sure to compute them in close to the optimal order.
27
+ #
28
+ # See also Rule#score, ManifestCondition#score, and Runner#steps_by_score.
29
+ def score
30
+ # we slightly prefer the preventative actions
31
+ # since they are more likely to short-circuit
32
+ case @action
33
+ when :prevent
34
+ @rule.score(@context) * (7.0 / 8)
35
+ when :enable
36
+ @rule.score(@context)
37
+ end
38
+ end
39
+
40
+ def with_action(action)
41
+ Step.new(@context, @rule, action)
42
+ end
43
+
44
+ def enable?
45
+ @action == :enable
46
+ end
47
+
48
+ def prevent?
49
+ @action == :prevent
50
+ end
51
+
52
+ # This rather complex method allows us to split rules into parts so that
53
+ # they can be sorted independently for better optimization
54
+ def flattened(roots)
55
+ case @rule
56
+ when Rule::Or
57
+ # A single `Or` step is the same as each of its elements as separate steps
58
+ @rule.rules.flat_map { |r| Step.new(@context, r, @action).flattened(roots) }
59
+ when Rule::Ability
60
+ # This looks like a weird micro-optimization but it buys us quite a lot
61
+ # in some cases. If we depend on an Ability (i.e. a `can?(...)` rule),
62
+ # and that ability *only* has :enable actions (modulo some actions that
63
+ # we already have taken care of), then its rules can be safely inlined.
64
+ steps = @context.runner(@rule.ability).steps.reject { |s| roots.include?(s) }
65
+
66
+ if steps.all?(&:enable?)
67
+ # in the case that we are a :prevent step, each inlined step becomes
68
+ # an independent :prevent, even though it was an :enable in its initial
69
+ # context.
70
+ steps.map! { |s| s.with_action(:prevent) } if prevent?
71
+
72
+ steps.flat_map { |s| s.flattened(roots) }
73
+ else
74
+ [self]
75
+ end
76
+ else
77
+ [self]
78
+ end
79
+ end
80
+
81
+ def pass?
82
+ @rule.pass?(@context)
83
+ end
84
+
85
+ def repr
86
+ "#{@action} when #{@rule.repr} (#{@context.repr})"
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeclarativePolicy
4
+ VERSION = '1.0.0'
5
+ end
metadata ADDED
@@ -0,0 +1,84 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: declarative_policy
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Jeanine Adkisson
8
+ - Alexis Kalderimis
9
+ autorequire:
10
+ bindir: exe
11
+ cert_chain: []
12
+ date: 2021-04-12 00:00:00.000000000 Z
13
+ dependencies: []
14
+ description: |
15
+ This library provides an authorization framework with a declarative DSL
16
+
17
+ With this library, you can write permission policies that are separate
18
+ from business logic.
19
+
20
+ This library is in production use at GitLab.com
21
+ email:
22
+ - akalderimis@gitlab.com
23
+ executables: []
24
+ extensions: []
25
+ extra_rdoc_files: []
26
+ files:
27
+ - ".gitignore"
28
+ - ".gitlab-ci.yml"
29
+ - ".rspec"
30
+ - ".rubocop.yml"
31
+ - CODE_OF_CONDUCT.md
32
+ - Dangerfile
33
+ - Gemfile
34
+ - Gemfile.lock
35
+ - LICENSE.txt
36
+ - README.md
37
+ - Rakefile
38
+ - danger/plugins/project_helper.rb
39
+ - danger/roulette/Dangerfile
40
+ - declarative_policy.gemspec
41
+ - doc/caching.md
42
+ - doc/configuration.md
43
+ - doc/defining-policies.md
44
+ - lib/declarative_policy.rb
45
+ - lib/declarative_policy/base.rb
46
+ - lib/declarative_policy/cache.rb
47
+ - lib/declarative_policy/condition.rb
48
+ - lib/declarative_policy/configuration.rb
49
+ - lib/declarative_policy/delegate_dsl.rb
50
+ - lib/declarative_policy/nil_policy.rb
51
+ - lib/declarative_policy/policy_dsl.rb
52
+ - lib/declarative_policy/preferred_scope.rb
53
+ - lib/declarative_policy/rule.rb
54
+ - lib/declarative_policy/rule_dsl.rb
55
+ - lib/declarative_policy/runner.rb
56
+ - lib/declarative_policy/step.rb
57
+ - lib/declarative_policy/version.rb
58
+ homepage: https://gitlab.com/gitlab-org/declarative-policy
59
+ licenses:
60
+ - MIT
61
+ metadata:
62
+ homepage_uri: https://gitlab.com/gitlab-org/declarative-policy
63
+ source_code_uri: https://gitlab.com/gitlab-org/declarative-policy
64
+ changelog_uri: https://gitlab.com/gitlab-org/declarative-policy/-/blobs/master/CHANGELOG.md
65
+ post_install_message:
66
+ rdoc_options: []
67
+ require_paths:
68
+ - lib
69
+ required_ruby_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: 2.6.0
74
+ required_rubygems_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '0'
79
+ requirements: []
80
+ rubygems_version: 3.1.4
81
+ signing_key:
82
+ specification_version: 4
83
+ summary: An authorization library with a focus on declarative policy definitions.
84
+ test_files: []