gitlab-org--gitlab-foss/lib/declarative_policy/rule.rb
Sean McGivern e9476eb97a Speed up cached_pass? for composite rules
Both `Or` and `And` would evaluate whether each rule passed, then calculate a
value based on the results of all of those. We can actually return early in many
cases, without running the rule at all.
2017-10-05 10:12:58 +01:00

305 lines
7.1 KiB
Ruby

module DeclarativePolicy
module Rule
# A Rule is the object that results from the `rule` declaration,
# usually built using the DSL in `RuleDsl`. It is a basic logical
# combination of building blocks, and is capable of deciding,
# given a context (instance of DeclarativePolicy::Base) whether it
# passes or not. Note that this decision doesn't by itself know
# how that affects the actual ability decision - for that, a
# `Step` is used.
class Base
def self.make(*a)
new(*a).simplify
end
# true or false whether this rule passes.
# `context` is a policy - an instance of
# DeclarativePolicy::Base.
def pass?(context)
raise 'abstract'
end
# same as #pass? except refuses to do any I/O,
# returning nil if the result is not yet cached.
# used for accurately scoring And/Or
def cached_pass?(context)
raise 'abstract'
end
# abstractly, how long would it take to compute
# this rule? lower-scored rules are tried first.
def score(context)
raise 'abstract'
end
# unwrap double negatives and nested and/or
def simplify
self
end
# convenience combination methods
def or(other)
Or.make([self, other])
end
def and(other)
And.make([self, other])
end
def negate
Not.make(self)
end
alias_method :|, :or
alias_method :&, :and
alias_method :~@, :negate
def inspect
"#<Rule #{repr}>"
end
end
# A rule that checks a condition. This is the
# type of rule that results from a basic bareword
# in the rule dsl (see RuleDsl#method_missing).
class Condition < Base
def initialize(name)
@name = name
end
# we delegate scoring to the condition. See
# ManifestCondition#score.
def score(context)
context.condition(@name).score
end
# Let the ManifestCondition from the context
# decide whether we pass.
def pass?(context)
context.condition(@name).pass?
end
# returns nil unless it's already cached
def cached_pass?(context)
condition = context.condition(@name)
return nil unless condition.cached?
condition.pass?
end
def description(context)
context.class.conditions[@name].description
end
def repr
@name.to_s
end
end
# A rule constructed from DelegateDsl - using a condition from a
# delegated policy.
class DelegatedCondition < Base
# Internal use only - this is rescued each time it's raised.
MissingDelegate = Class.new(StandardError)
def initialize(delegate_name, name)
@delegate_name = delegate_name
@name = name
end
def delegated_context(context)
policy = context.delegated_policies[@delegate_name]
raise MissingDelegate if policy.nil?
policy
end
def score(context)
delegated_context(context).condition(@name).score
rescue MissingDelegate
0
end
def cached_pass?(context)
condition = delegated_context(context).condition(@name)
return nil unless condition.cached?
condition.pass?
rescue MissingDelegate
false
end
def pass?(context)
delegated_context(context).condition(@name).pass?
rescue MissingDelegate
false
end
def repr
"#{@delegate_name}.#{@name}"
end
end
# A rule constructed from RuleDsl#can?. Computes a different ability
# on the same subject.
class Ability < Base
attr_reader :ability
def initialize(ability)
@ability = ability
end
# We ask the ability's runner for a score
def score(context)
context.runner(@ability).score
end
def pass?(context)
context.allowed?(@ability)
end
def cached_pass?(context)
runner = context.runner(@ability)
return nil unless runner.cached?
runner.pass?
end
def description(context)
"User can #{@ability.inspect}"
end
def repr
"can?(#{@ability.inspect})"
end
end
# Logical `and`, containing a list of rules. Only passes
# if all of them do.
class And < Base
attr_reader :rules
def initialize(rules)
@rules = rules
end
def simplify
simplified_rules = @rules.flat_map do |rule|
simplified = rule.simplify
case simplified
when And then simplified.rules
else [simplified]
end
end
And.new(simplified_rules)
end
def score(context)
return 0 unless cached_pass?(context).nil?
# note that cached rules will have score 0 anyways.
@rules.map { |r| r.score(context) }.inject(0, :+)
end
def pass?(context)
# try to find a cached answer before
# checking in order
cached = cached_pass?(context)
return cached unless cached.nil?
@rules.all? { |r| r.pass?(context) }
end
def cached_pass?(context)
@rules.each do |rule|
pass = rule.cached_pass?(context)
return pass if pass.nil? || pass == false
end
true
end
def repr
"all?(#{rules.map(&:repr).join(', ')})"
end
end
# Logical `or`. Mirrors And.
class Or < Base
attr_reader :rules
def initialize(rules)
@rules = rules
end
def pass?(context)
cached = cached_pass?(context)
return cached unless cached.nil?
@rules.any? { |r| r.pass?(context) }
end
def simplify
simplified_rules = @rules.flat_map do |rule|
simplified = rule.simplify
case simplified
when Or then simplified.rules
else [simplified]
end
end
Or.new(simplified_rules)
end
def cached_pass?(context)
@rules.each do |rule|
pass = rule.cached_pass?(context)
return pass if pass.nil? || pass == true
end
false
end
def score(context)
return 0 unless cached_pass?(context).nil?
@rules.map { |r| r.score(context) }.inject(0, :+)
end
def repr
"any?(#{@rules.map(&:repr).join(', ')})"
end
end
class Not < Base
attr_reader :rule
def initialize(rule)
@rule = rule
end
def simplify
case @rule
when And then Or.new(@rule.rules.map(&:negate)).simplify
when Or then And.new(@rule.rules.map(&:negate)).simplify
when Not then @rule.rule.simplify
else Not.new(@rule.simplify)
end
end
def pass?(context)
!@rule.pass?(context)
end
def cached_pass?(context)
case @rule.cached_pass?(context)
when nil then nil
when true then false
when false then true
end
end
def score(context)
@rule.score(context)
end
def repr
"~#{@rule.repr}"
end
end
end
end