184 lines
4.8 KiB
Ruby
184 lines
4.8 KiB
Ruby
|
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?
|
||
|
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|
|
||
|
return 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 critcal 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(&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
|
||
|
|
||
|
steps = Set.new(@steps)
|
||
|
remaining_enablers = steps.count { |s| s.enable? }
|
||
|
|
||
|
loop do
|
||
|
return if steps.empty?
|
||
|
|
||
|
# if the permission hasn't yet been enabled and we only have
|
||
|
# prevent steps left, we short-circuit the state here
|
||
|
@state.prevent! if !@state.enabled? && remaining_enablers == 0
|
||
|
|
||
|
lowest_score = Float::INFINITY
|
||
|
next_step = nil
|
||
|
|
||
|
steps.each do |step|
|
||
|
score = step.score
|
||
|
if score < lowest_score
|
||
|
next_step = step
|
||
|
lowest_score = score
|
||
|
end
|
||
|
end
|
||
|
|
||
|
steps.delete(next_step)
|
||
|
|
||
|
remaining_enablers -= 1 if next_step.enable?
|
||
|
|
||
|
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
|