File: runner.rb

package info (click to toggle)
ruby-declarative-policy 1.1.0-1
  • links: PTS, VCS
  • area: main
  • in suites: bookworm, forky, sid, trixie
  • size: 264 kB
  • sloc: ruby: 1,020; makefile: 4
file content (235 lines) | stat: -rw-r--r-- 6,450 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
# frozen_string_literal: true

require 'set'

module DeclarativePolicy
  class Runner
    class State
      attr_reader :called_conditions

      def initialize
        @enabled = false
        @prevented = false
        @called_conditions = Set.new
      end

      def enable!
        @enabled = true
      end

      def enabled?
        @enabled
      end

      def prevent!
        @prevented = true
      end

      def prevented?
        @prevented
      end

      def pass?
        !prevented? && enabled?
      end

      def register(manifest_condition)
        @called_conditions << manifest_condition.cache_key
      end
    end

    # a Runner contains a list of Steps to be run.
    attr_reader :steps

    def initialize(steps)
      @steps = steps
      @state = nil
    end

    # We make sure only to run any given Runner once,
    # and just continue to use the resulting @state
    # that's left behind.
    def cached?
      !!@state
    end

    # Delete the cached state - allowing this runner to be re-used if the facts have changed.
    def uncache!
      @state = nil
    end

    # used by Rule::Ability. See #steps_by_score
    def score
      return 0 if cached?

      steps.sum(&:score)
    end

    def merge_runner(other)
      Runner.new(@steps + other.steps)
    end

    def dependencies
      return Set.new unless @state

      @state.called_conditions
    end

    # The main entry point, called for making an ability decision.
    # See #run and DeclarativePolicy::Base#can?
    def pass?
      run unless cached?

      parent_state = Thread.current[:declarative_policy_current_runner_state]
      parent_state&.called_conditions&.merge(@state.called_conditions)

      @state.pass?
    end

    # see DeclarativePolicy::Base#debug
    def debug(out = $stderr)
      run(out)
    end

    private

    def with_state(&block)
      @state = State.new
      old_runner_state = Thread.current[:declarative_policy_current_runner_state]
      Thread.current[:declarative_policy_current_runner_state] = @state

      yield
    ensure
      Thread.current[:declarative_policy_current_runner_state] = old_runner_state
    end

    def flatten_steps!
      @steps = @steps.flat_map { |s| s.flattened(@steps) }
    end

    # This method implements the semantic of "one enable and no prevents".
    # It relies on #steps_by_score for the main loop, and updates @state
    # with the result of the step.
    def run(debug = nil)
      with_state do
        steps_by_score(!!debug) do |step, score|
          break if !debug && @state.prevented?

          passed = nil
          case step.action
          when :enable
            # we only check :enable actions if they have a chance of
            # changing the outcome - if no other rule has enabled or
            # prevented.
            unless @state.enabled? || @state.prevented?
              passed = step.pass?
              @state.enable! if passed
            end
          when :prevent
            # we only check :prevent actions if the state hasn't already
            # been prevented.
            unless @state.prevented?
              passed = step.pass?
              @state.prevent! if passed
            end
          else raise "invalid action #{step.action.inspect}"
          end

          debug << inspect_step(step, score, passed) if debug
        end
      end

      @state
    end

    # This is the core spot where all those `#score` methods matter.
    # It is critical for performance to run steps in the correct order,
    # so that we don't compute expensive conditions (potentially n times
    # if we're called on, say, a large list of users).
    #
    # In order to determine the cheapest step to run next, we rely on
    # Step#score, which returns a numerical rating of how expensive
    # it would be to calculate - the lower the better. It would be
    # easy enough to statically sort by these scores, but we can do
    # a little better - the scores are cache-aware (conditions that
    # are already in the cache have score 0), which means that running
    # a step can actually change the scores of other steps.
    #
    # So! The way we sort here involves re-scoring at every step. This
    # is by necessity quadratic, but most of the time the number of steps
    # will be low. But just in case, if the number of steps exceeds 50,
    # we print a warning and fall back to a static sort.
    #
    # For each step, we yield the step object along with the computed score
    # for debugging purposes.
    def steps_by_score(debugging)
      flatten_steps!

      if @steps.size > 50
        warn "DeclarativePolicy: large number of steps (#{steps.size}), falling back to static sort"

        @steps.map { |s| [s.score, s] }.sort_by { |(score, _)| score }.each do |(score, step)|
          yield step, score
        end

        return
      end

      remaining_steps = Set.new(@steps)
      remaining_enablers, remaining_preventers = remaining_steps.partition(&:enable?).map { |s| Set.new(s) }

      loop do
        if @state.enabled?
          # Once we set this, we never need to unset it, because a single
          # prevent will stop this from being enabled
          remaining_steps = remaining_preventers
        elsif remaining_enablers.empty?
          # if the permission hasn't yet been enabled and we only have
          # prevent steps left, we short-circuit the state here
          @state.prevent!
          return unless debugging
        end

        return if remaining_steps.empty?

        next_step, lowest_score = next_step_and_score(remaining_steps)

        [remaining_steps, remaining_enablers, remaining_preventers].each do |set|
          set.delete(next_step)
        end

        yield next_step, lowest_score
      end
    end

    def next_step_and_score(remaining_steps)
      lowest_score = Float::INFINITY
      next_step = nil

      remaining_steps.each do |step|
        score = step.score

        if score < lowest_score
          next_step = step
          lowest_score = score
        end

        break if lowest_score.zero?
      end

      [next_step, lowest_score]
    end

    # Formatter for debugging output.
    def inspect_step(step, original_score, passed)
      symbol =
        case passed
        when true then '+'
        when false then '-'
        when nil then ' '
        end

      "#{symbol} [#{original_score.to_i}] #{step.repr}\n"
    end
  end
end