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 DSL consists of three functions: {#scope}, {#can} and {#cannot}.
class Evaluator
attr_reader :allowed_fields, :fixtures, :validators
# Create a new Evaluator for the ActiveModel-descending 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 = {}
@validations = {}
@fixtures = {}
end
# @group DSL
# Define a scope. A special +:fetch+ scope is applied to any other scope
# automatically.
#
# @overload scope(name, block)
# This form accepts an explicit lambda.
#
# @example
# scope :fetch, -> { where(:protected => false) }
#
# @overload scope(name)
# This form accepts an implicit lambda.
#
# @example
# scope :fetch do
# if user.manager?
# scoped
# else
# where(:invisible => false)
# end
# end
def scope(name, explicit_block, &implicit_block)
@scopes[name] = explicit_block || implicit_block
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.attribute_names)
Array(actions).each do |action|
case fields
when Hash # a list of validations
@allowed_fields[action] += fields.keys
@validations[action] += create_validators(fields)
@fixtures[action].merge extract_fixtures(fields)
else # an array or a field name
@allowed_fields[action] += Array(fields)
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).each do |action|
@allowed_fields[action] -= fields
@fixtures.delete_at *fields
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
def request_scope(name=:fetch, basic_scope=request_scope(:fetch))
if name == :fetch || !@scopes.has_key?(name)
fetch_scope = @model_class.instance_exec(&@scopes[:fetch])
else
basic_scope.instance_exec(&@scopes[name])
end
end
# Compute the restrictions for a given +context+. Invokes a +block+ passed to the
# +initialize+ once.
def evaluate(context)
if context != @last_context
@scopes = {}
@allowed_fields = Hash.new { [] }
@validators = Hash.new { [] }
@fixtures = Hash.new { [] }
instance_exec context, &block
[@scopes, @allowed_fields, @validators, @fixtures].
map(&:freeze)
@last_context = context
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[attribute] = 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] = 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