BigW Consortium Gitlab

runner.rb 5.22 KB
Newer Older
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
module DeclarativePolicy
  class Runner
    class State
      def initialize
        @enabled = false
        @prevented = false
      end

      def enable!
        @enabled = true
      end

      def enabled?
        @enabled
      end

      def prevent!
        @prevented = true
      end

      def prevented?
        @prevented
      end

      def pass?
        !prevented? && enabled?
      end
    end

    # a Runner contains a list of Steps to be run.
    attr_reader :steps
    def initialize(steps)
      @steps = steps
    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

    # used by Rule::Ability. See #steps_by_score
    def score
      return 0 if cached?
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
      steps.map(&:score).inject(0, :+)
    end

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

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

      @state.pass?
    end

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

    private

    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)
      @state = State.new

      steps_by_score do |step, score|
80 81
        return if !debug && @state.prevented?

82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98
        passed = nil
        case step.action
        when :enable then
          # 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

          debug << inspect_step(step, score, passed) if debug
        when :prevent then
          # we only check :prevent actions if the state hasn't already
          # been prevented.
          unless @state.prevented?
            passed = step.pass?
99
            @state.prevent! if passed
100 101 102 103 104 105 106 107 108 109 110
          end

          debug << inspect_step(step, score, passed) if debug
        else raise "invalid action #{step.action.inspect}"
        end
      end

      @state
    end

    # This is the core spot where all those `#score` methods matter.
111
    # It is critical for performance to run steps in the correct order,
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
    # 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(&b)
      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

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

      loop do
147 148 149 150 151 152 153 154 155
        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
        else
          # if the permission hasn't yet been enabled and we only have
          # prevent steps left, we short-circuit the state here
          @state.prevent! if remaining_enablers.empty?
        end
156

157
        return if remaining_steps.empty?
158 159 160 161

        lowest_score = Float::INFINITY
        next_step = nil

162
        remaining_steps.each do |step|
163
          score = step.score
164

165 166 167 168 169
          if score < lowest_score
            next_step = step
            lowest_score = score
          end

170 171
          break if lowest_score.zero?
        end
172

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

177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193
        yield next_step, lowest_score
      end
    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