lib/heimdallr/proxy/record.rb
module Heimdallr
# A security-aware proxy for individual records. This class validates all the
# method calls and either forwards them to the encapsulated object or raises
# an exception.
#
# The #touch method call isn't considered a security threat and as such, it is
# forwarded to the underlying object directly.
#
# Record proxies can be of two types, implicit and explicit. Implicit proxies
# return +nil+ on access to methods forbidden by the current security context;
# explicit proxies raise an {Heimdallr::PermissionError} instead.
class Proxy::Record
# Create a record proxy.
#
# @param context security context
# @param object proxified record
# @option options [Boolean] implicit proxy type
def initialize(context, record, options={})
@context, @record, @options = context, record, options.dup
@restrictions = @record.class.restrictions(context, record)
@eager_loaded = @options.delete(:eager_loaded) || {}
end
# @method decrement(field, by=1)
# @macro [new] delegate
# Delegates to the corresponding method of underlying object.
delegate :decrement, :to => :@record
# @method increment(field, by=1)
# @macro delegate
delegate :increment, :to => :@record
# @method toggle(field)
# @macro delegate
delegate :toggle, :to => :@record
# @method touch(field)
# @macro delegate
# This method does not modify any fields except for the timestamp itself
# and thus is not considered as a potential security threat.
delegate :touch, :to => :@record
# @method to_key
# @macro delegate
delegate :to_key, :to => :@record
# @method to_param
# @macro delegate
delegate :to_param, :to => :@record
# @method to_partial_path
# @macro delegate
delegate :to_partial_path, :to => :@record
# @method persisted?
# @macro delegate
delegate :persisted?, :to => :@record
# A proxy for +attributes+ method which removes all attributes
# without +:view+ permission.
def attributes
@record.attributes.tap do |attributes|
attributes.keys.each do |key|
unless @restrictions.allowed_fields[:view].include? key.to_sym
attributes[key] = nil
end
end
end
end
# A proxy for +update_attributes+ method.
# See also {#save}.
#
# @raise [Heimdallr::PermissionError]
def update_attributes(attributes, options={})
try_transaction do
@record.assign_attributes(attributes, options)
save
end
end
# A proxy for +update_attributes!+ method.
# See also {#save!}.
#
# @raise [Heimdallr::PermissionError]
def update_attributes!(attributes, options={})
try_transaction do
@record.assign_attributes(attributes, options)
save!
end
end
# A proxy for +save+ method which verifies all of the dirty attributes to
# be valid for current security context.
#
# @raise [Heimdallr::PermissionError]
def save(options={})
check_save_options options
check_attributes do
@record.save(options)
end
end
# A proxy for +save+ method which verifies all of the dirty attributes to
# be valid for current security context and mandates the current record
# to be valid.
#
# @raise [Heimdallr::PermissionError]
# @raise [ActiveRecord::RecordInvalid]
# @raise [ActiveRecord::RecordNotSaved]
def save!(options={})
check_save_options options
check_attributes do
@record.save!(options)
end
end
[:delete, :destroy].each do |method|
class_eval(<<-EOM, __FILE__, __LINE__)
def #{method}
scope = @restrictions.request_scope(:delete)
if record_in_scope? scope
@record.#{method}
else
raise PermissionError, "Deletion is forbidden"
end
end
EOM
end
# @method valid?
# @macro delegate
delegate :valid?, :to => :@record
# @method invalid?
# @macro delegate
delegate :invalid?, :to => :@record
# @method errors
# @macro delegate
delegate :errors, :to => :@record
# @method assign_attributes
# @macro delegate
delegate :assign_attributes, :to => :@record
# @method attributes=
# @macro delegate
delegate :attributes=, :to => :@record
# Class name of the underlying model.
# @return [String]
def class_name
@record.class.name
end
# Records cannot be restricted with different context or options.
#
# @return self
# @raise [RuntimeError]
def restrict(context, options=nil)
if @context == context && options.nil?
self
else
raise RuntimeError, "Heimdallr proxies cannot be restricted with nonmatching context or options"
end
end
# A whitelisting dispatcher for attribute-related method calls.
# Every unknown method is first normalized (that is, stripped of its +?+ or +=+
# suffix). Then, if the normalized form is whitelisted, it is passed to the
# underlying object as-is. Otherwise, an exception is raised.
#
# If the underlying object is an instance of ActiveRecord, then all association
# accesses are resolved and proxified automatically.
#
# Note that only the attribute and collection getters and setters are
# dispatched through this method. Every other model method should be defined
# as an instance method of this class in order to work.
#
# @raise [Heimdallr::PermissionError] when a non-whitelisted method is accessed
# @raise [Heimdallr::InsecureOperationError] when an insecure association is about
# to be fetched
def method_missing(method, *args, &block)
suffix = method.to_s[-1]
if %w(? = !).include? suffix
normalized_method = method[0..-2].to_sym
else
normalized_method = method
suffix = nil
end
builder_method = method.to_s.gsub(/\Abuild_/, '').to_sym
association = if @record.class.respond_to?(:reflect_on_association)
@record.class.reflect_on_association(method) ||
@record.class.reflect_on_association(builder_method)
end
pass = unless association
@record.class.heimdallr_relations.respond_to?(:include?) &&
@record.class.heimdallr_relations.include?(normalized_method)
end
if association || pass
referenced = @record.send(method, *args)
if referenced.nil?
nil
elsif referenced.respond_to? :restrict
if @eager_loaded.include?(method)
options = @options.merge(eager_loaded: @eager_loaded[method])
else
options = @options
end
if association && association.collection? && @eager_loaded.include?(method)
# Don't re-restrict eagerly loaded collections to not
# discard preloaded data.
Proxy::Collection.new(@context, referenced, options)
else
referenced.restrict(@context, @options)
end
elsif Heimdallr.allow_insecure_associations
referenced
else
raise Heimdallr::InsecureOperationError,
"Attempt to fetch insecure association #{method}. Try #insecure"
end
elsif @record.respond_to? method
if [nil, '?'].include?(suffix)
if @restrictions.allowed_fields[:view].include?(normalized_method)
result = @record.send method, *args, &block
if result.respond_to? :restrict
result.restrict(@context, @options)
else
result
end
elsif @options[:implicit]
nil
else
raise Heimdallr::PermissionError, "Attempt to fetch non-whitelisted attribute #{method}"
end
elsif suffix == '='
@record.send method, *args
else
raise Heimdallr::PermissionError,
"Non-whitelisted method #{method} is called for #{@record.inspect} "
end
else
super
end
end
# Return the underlying object.
#
# @return [ActiveRecord::Base]
def insecure
@record
end
# Return an implicit variant of this proxy.
#
# @return [Heimdallr::Proxy::Record]
def implicit
Proxy::Record.new(@context, @record, @options.merge(implicit: true))
end
# Return an explicit variant of this proxy.
#
# @return [Heimdallr::Proxy::Record]
def explicit
Proxy::Record.new(@context, @record, @options.merge(implicit: false))
end
# Describes the proxy and proxified object.
#
# @return [String]
def inspect
"#<Heimdallr::Proxy::Record: #{@record.inspect}>"
end
# Return the associated security metadata. The returned hash will contain keys
# +:context+, +:record+, +:options+, corresponding to the parameters in
# {#initialize}, +:model+ and +:restrictions+, representing the model class.
#
# Such a name was deliberately selected for this method in order to reduce namespace
# pollution.
#
# @return [Hash]
def reflect_on_security
{
model: @record.class,
context: @context,
record: @record,
options: @options,
restrictions: @restrictions,
}.merge(@restrictions.reflection)
end
def visible?
scope = @restrictions.request_scope(:fetch)
record_in_scope? scope
end
def creatable?
@restrictions.can? :create
end
def modifiable?
@restrictions.can? :update
end
def destroyable?
scope = @restrictions.request_scope(:delete)
record_in_scope? scope
end
protected
# Raises an exception if any of the changed attributes are not valid
# for the current security context.
#
# @raise [Heimdallr::PermissionError]
def check_attributes
@record.errors.clear
if @record.new_record?
action = :create
else
action = :update
end
allowed_fields = @restrictions.allowed_fields[action]
fixtures = @restrictions.fixtures[action]
validators = @restrictions.validators[action]
@record.changed.map(&:to_sym).each do |attribute|
value = @record.send attribute
if action == :create and attribute == :_id and @record.is_a?(Mongoid::Document)
# Everything is ok, continue (Mongoid sets _id before saving as opposed to ActiveRecord)
elsif fixtures.has_key? attribute
if fixtures[attribute] != value
raise Heimdallr::PermissionError,
"Attribute #{attribute} value (#{value}) is not equal to a fixture (#{fixtures[attribute]})"
end
elsif !allowed_fields.include? attribute
raise Heimdallr::PermissionError,
"Attribute #{attribute} is not allowed to change"
end
end
@record.heimdallr_validators = validators
yield
ensure
@record.heimdallr_validators = nil
end
# Raises an exception if any of the +options+ intended for use in +save+
# methods are potentially unsafe.
def check_save_options(options)
if options[:validate] == false
raise Heimdallr::InsecureOperationError,
"Saving while omitting validation would omit security validations too"
end
if @record.new_record?
unless @restrictions.can? :create
raise Heimdallr::InsecureOperationError,
"Creating was not explicitly allowed"
end
else
unless @restrictions.can? :update
raise Heimdallr::InsecureOperationError,
"Updating was not explicitly allowed"
end
end
end
def record_in_scope?(scope)
scope.where(primary_key => wrap_key(@record.to_key)).any?
end
def primary_key
@record.class.respond_to?(:primary_key) && @record.class.primary_key || :id
end
def wrap_key(key)
key.is_a?(Enumerable) ? key.first : key
end
def try_transaction
if @record.respond_to?(:with_transaction_returning_status)
@record.with_transaction_returning_status do
yield
end
else
yield
end
end
end
end