197 lines
		
	
	
		
			5.3 KiB
		
	
	
	
		
			Ruby
		
	
	
	
			
		
		
	
	
			197 lines
		
	
	
		
			5.3 KiB
		
	
	
	
		
			Ruby
		
	
	
	
| # frozen_string_literal: true
 | |
| 
 | |
| 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
 | |
|       @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
 | |
| 
 | |
|     # used by Rule::Ability. See #steps_by_score
 | |
|     def score
 | |
|       return 0 if cached?
 | |
| 
 | |
|       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|
 | |
|         break if !debug && @state.prevented?
 | |
| 
 | |
|         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?
 | |
|             @state.prevent! if passed
 | |
|           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.
 | |
|     # 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
 | |
|       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
 | |
|         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
 | |
| 
 | |
|         return if remaining_steps.empty?
 | |
| 
 | |
|         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 == 0
 | |
|         end
 | |
| 
 | |
|         [remaining_steps, remaining_enablers, remaining_preventers].each do |set|
 | |
|           set.delete(next_step)
 | |
|         end
 | |
| 
 | |
|         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
 |