module Heimdallr
# Evaluator is a DSL for managing permissions on records with the field granularity.
# It works by evaluating a block of code within a given security context.
#
# The default resolution is to forbid everything--that is, Heimdallr security policy
# is whitelisting safe actions, not blacklisting unsafe ones. This is by design
# and is not going to change.
#
# The field +#id+ is whitelisted by default.
#
# The DSL consists of three functions: {#scope}, {#can} and {#cannot}.
class Evaluator
attr_reader :allowed_fields, :fixtures, :validators
# Create a new Evaluator for the +ActiveRecord+-derived class +model_class+,
# and use +block+ to infer restrictions for any security context passed.
def initialize(model_class, block)
@model_class, @block = model_class, block
@scopes = {}
@allowed_fields = {}
@validators = {}
@fixtures = {}
end
# @group DSL
# Define a scope. A special +:fetch+ scope is applied to any other scope
# automatically.
#
# @overload scope(name, block=nil)
# This form accepts an explicit lambda. If omitted, a scope will be
# defined to include all possible records.
#
# @example
# scope :fetch, -> { where(:protected => false) }
#
# @overload scope(name)
# This form accepts an implicit lambda.
#
# @example
# scope :fetch do |user|
# if user.manager?
# scoped
# else
# where(:invisible => false)
# end
# end
def scope(name, explicit_block=nil, &implicit_block)
unless [:fetch, :delete].include?(name)
raise "There is no such scope as #{name}"
end
@scopes[name] = explicit_block || implicit_block || -> { scoped }
end
# Define allowed operations for action(s).
#
# The +fields+ parameter accepts both Arrays and Hashes.
# * If an +Array+ is passed, then all fields present in the array are whitelised.
# * If a +Hash+ is passed, then all fields present as hash keys are whitelisted, and:
# 1. If a corresponding value is a +Hash+, it will be processed as a security
# validator. Security validators make records invalid when they are saved through
# a {Proxy::Record}.
# 2. If the corresponding value is any other object, it will be added as a security
# fixture. Fixtures are merged when objects are created through restricted scopes,
# and cause exceptions to be raised when a record is saved, even through the +#save+
# method.
#
# @example Array of fields
# can :view, [:title, :content]
#
# @example Fixtures
# can :create, { owner: current_user }
#
# @example Validations
# can [:create, :update], { priority: { inclusion: 1..10 } }
#
# @param [Symbol, Array] actions one or more action names
# @param [Hash] fields field restrictions
def can(actions, fields=@model_class.to_adapter.column_names)
Array(actions).map(&:to_sym).each do |action|
case fields
when Hash # a list of validations
@allowed_fields[action] += fields.keys.map(&:to_sym)
@validators[action] += create_validators(fields)
fixtures = extract_fixtures(fields)
@fixtures[action] = @fixtures[action].merge fixtures
@allowed_fields[action] -= fixtures.keys
else # an array or a field name
@allowed_fields[action] += Array(fields).map(&:to_sym)
end
end
end
# Revoke a permission on fields.
#
# @todo Revoke validating restrictions.
# @param [Symbol, Array] actions one or more action names
# @param [Array] fields field list
def cannot(actions, fields)
Array(actions).map(&:to_sym).each do |action|
@allowed_fields[action] -= fields.map(&:to_sym)
fields.each { |field| @fixtures.delete field }
end
end
# @endgroup
# Request a scope.
#
# @param scope name of the scope
# @param basic_scope the scope to which scope +name+ will be applied. Defaults to +:fetch+.
#
# @return +ActiveRecord+ scope.
#
# @raise [RuntimeError] if the scope is not defined
def request_scope(name=:fetch, basic_scope=nil)
unless @scopes.has_key?(name)
raise RuntimeError, "The #{name.inspect} scope does not exist"
end
if name == :fetch && basic_scope.nil?
@model_class.instance_exec(&@scopes[:fetch])
else
(basic_scope || request_scope(:fetch)).instance_exec(&@scopes[name])
end
end
# Check if any explicit restrictions were defined for +action+.
# +can :create, []+ _is_ an explicit restriction for action +:create+.
#
# @return Boolean
def can?(action)
@allowed_fields.include? action
end
# Return a Hash to be mixed in in +reflect_on_security+ methods of {Proxy::Collection}
# and {Proxy::Record}.
def reflection
{
operations: [ :view, :create, :update ].select { |op| can? op }
}
end
# Compute the restrictions for a given +context+ and possibly a specific +record+.
# Invokes a +block+ passed to the +initialize+ once.
#
# @raise [RuntimeError] if the evaluated block did not define a set of valid restrictions
def evaluate(context, record=nil)
if [context, record] != @last_context
@scopes = {}
@allowed_fields = Hash.new { [] }
@validators = Hash.new { [] }
@fixtures = Hash.new { {} }
@allowed_fields[:view] += [ :id ]
instance_exec context, record, &@block
unless @scopes[:fetch]
raise RuntimeError, "A :fetch scope must be defined"
end
@allowed_fields.each do |action, fields|
fields.uniq!
end
[@scopes, @allowed_fields, @validators, @fixtures].
map(&:freeze)
@last_context = [context, record]
end
self
end
protected
# Create validators for +fields+ in +ActiveModel::Validations+-like way.
#
# @return [Array]
def create_validators(fields)
validators = []
fields.each do |attribute, validations|
next unless validations.is_a? Hash
validations.each do |key, options|
key = "#{key.to_s.camelize}Validator"
begin
validator = key.include?('::') ? key.constantize : ActiveModel::Validations.const_get(key)
rescue NameError
raise ArgumentError, "Unknown validator: '#{key}'"
end
validators << validator.new(_parse_validates_options(options).merge(:attributes => [ attribute ]))
end
end
validators
end
# Collects fixtures from the +fields+ definition.
def extract_fixtures(fields)
fixtures = {}
fields.each do |attribute, options|
next if options.is_a? Hash
fixtures[attribute.to_sym] = options
end
fixtures
end
private
# Monkey-copied from ActiveRecord.
def _parse_validates_options(options)
case options
when TrueClass
{}
when Hash
options
when Range, Array
{ :in => options }
else
{ :with => options }
end
end
end
end