inossidabile/heimdallr

View on GitHub
lib/heimdallr/proxy/record.rb

Summary

Maintainability
D
1 day
Test Coverage
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