inossidabile/heimdallr

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

Summary

Maintainability
B
5 hrs
Test Coverage
module Heimdallr
  # A security-aware proxy for +ActiveRecord+ scopes. This class validates all the
  # method calls and either forwards them to the encapsulated scope or raises
  # an exception.
  #
  # There are two kinds of collection proxies, explicit and implicit, which instantiate
  # the corresponding types of record proxies. See also {Proxy::Record}.
  class Proxy::Collection
    include Enumerable

    # Create a collection proxy.
    #
    # The +scope+ is expected to be already restricted with +:fetch+ scope.
    #
    # @param context  security context
    # @param scope    proxified scope
    # @option options [Boolean] implicit proxy type
    def initialize(context, scope, options={})
      @context, @scope, @options = context, scope, options

      @restrictions = @scope.restrictions(context)
      @options[:eager_loaded] ||= {}
    end

    # Collections 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

    # @private
    # @macro [attach] delegate_as_constructor
    #   A proxy for +$1+ method which adds fixtures to the attribute list and
    #   returns a restricted record.
    def self.delegate_as_constructor(name, method)
      class_eval(<<-EOM, __FILE__, __LINE__)
      def #{name}(attributes={})
        record = @restrictions.request_scope(:fetch).new.restrict(@context, options_with_escape)
        record.#{method}(attributes.merge(@restrictions.fixtures[:create]))
        record
      end
      EOM
    end

    # @private
    # @macro [attach] delegate_as_scope
    #   A proxy for +$1+ method which returns a restricted scope.
    def self.delegate_as_scope(name, conversion=false)
      conversion = conversion ? "set = set.send(:#{conversion})" : ''

      class_eval(<<-EOM, __FILE__, __LINE__)
      def #{name}(*args)
        set = @scope.#{name}(*args); #{conversion}
        Proxy::Collection.new(@context, set, options_with_escape)
      end
      EOM
    end

    # @private
    # @macro [attach] delegate_as_destroyer
    #   A proxy for +$1+ method which works on a +:delete+ scope.
    def self.delegate_as_destroyer(name)
      class_eval(<<-EOM, __FILE__, __LINE__)
      def #{name}(*args)
        @restrictions.request_scope(:delete, @scope).#{name}(*args)
      end
      EOM
    end

    # @private
    # @macro [attach] delegate_as_record
    #   A proxy for +$1+ method which returns a restricted record.
    def self.delegate_as_record(name)
      class_eval(<<-EOM, __FILE__, __LINE__)
      def #{name}(*args)
        @scope.#{name}(*args).restrict(@context, options_with_eager_load)
      end
      EOM
    end

    # @private
    # @macro [attach] delegate_as_records
    #   A proxy for +$1+ method which returns an array of restricted records.
    def self.delegate_as_records(name, conversion=false)
      conversion = conversion ? "set = set.send(:#{conversion})" : ''

      class_eval(<<-EOM, __FILE__, __LINE__)
      def #{name}(*args)
        set = @scope.#{name}(*args); #{conversion}

        set.map do |element|
          element.restrict(@context, options_with_eager_load)
        end
      end
      EOM
    end

    # @private
    # @macro [attach] delegate_as_value
    #   A proxy for +$1+ method which returns a raw value.
    def self.delegate_as_value(name)
      class_eval(<<-EOM, __FILE__, __LINE__)
      def #{name}(*args)
        @scope.#{name}(*args)
      end
      EOM
    end

    delegate_as_constructor :build,   :assign_attributes
    delegate_as_constructor :new,     :assign_attributes
    delegate_as_constructor :create,  :update_attributes
    delegate_as_constructor :create!, :update_attributes!

    delegate_as_scope :scoped
    delegate_as_scope :uniq
    delegate_as_scope :where
    delegate_as_scope :joins
    delegate_as_scope :lock
    delegate_as_scope :limit
    delegate_as_scope :offset
    delegate_as_scope :order
    delegate_as_scope :reorder
    delegate_as_scope :reverse_order
    delegate_as_scope :extending

    delegate_as_value :klass
    delegate_as_value :model_name
    delegate_as_value :empty?
    delegate_as_value :any?
    delegate_as_value :many?
    delegate_as_value :include?
    delegate_as_value :exists?
    delegate_as_value :size
    delegate_as_value :length

    delegate_as_value :calculate
    delegate_as_value :count
    delegate_as_value :average
    delegate_as_value :sum
    delegate_as_value :maximum
    delegate_as_value :minimum
    delegate_as_value :pluck

    delegate_as_destroyer :delete
    delegate_as_destroyer :delete_all
    delegate_as_destroyer :destroy
    delegate_as_destroyer :destroy_all

    delegate_as_record  :first
    delegate_as_record  :first!
    delegate_as_record  :last
    delegate_as_record  :last!

    delegate_as_records :all
    delegate_as_records :to_a
    delegate_as_records :to_ary

    # A proxy for +includes+ which adds Heimdallr conditions for eager loaded
    # associations.
    def includes(*associations)
      # Normalize association list to strict nested hash.
      normalize = ->(list) {
        if list.is_a? Array
          list.map(&normalize).reduce(:merge)
        elsif list.is_a? Symbol
          { list => {} }
        elsif list.is_a? Hash
          hash = {}
          list.each do |key, value|
            hash[key] = normalize.(value)
          end
          hash
        end
      }
      associations = normalize.(associations)

      current_scope = @scope.includes(associations)

      add_conditions = ->(associations, scope) {
        associations.each do |association, nested|
          reflection = scope.reflect_on_association(association)
          if reflection && !reflection.options[:polymorphic]
            associated_klass = reflection.klass

            if associated_klass.respond_to? :restrict
              nested_scope = associated_klass.restrictions(@context).request_scope(:fetch)

              where_values = nested_scope.where_values
              if where_values.any?
                current_scope = current_scope.where(*where_values)
              end

              add_conditions.(nested, associated_klass)
            end
          end
        end
      }

      unless Heimdallr.skip_eager_condition_injection
        add_conditions.(associations, current_scope)
      end

      options = @options.merge(eager_loaded:
        @options[:eager_loaded].merge(associations))

      Proxy::Collection.new(@context, current_scope, options)
    end

    # A proxy for +find+ which restricts the returned record or records.
    #
    # @return [Proxy::Record, Array<Proxy::Record>]
    def find(*args)
      result = @scope.find(*args)

      if result.is_a? Enumerable
        result.map do |element|
          element.restrict(@context, options_with_eager_load)
        end
      else
        result.restrict(@context, options_with_eager_load)
      end
    end

    # A proxy for +each+ which restricts the yielded records.
    #
    # @yield [record]
    # @yieldparam [Proxy::Record] record
    def each
      @scope.each do |record|
        yield record.restrict(@context, options_with_eager_load)
      end
    end

    # Wraps a scope or a record in a corresponding proxy.
    def method_missing(method, *args)
      if method =~ /^find_all_by/
        @scope.send(method, *args).map do |element|
          element.restrict(@context, options_with_escape)
        end
      elsif method =~ /^find_by/
        @scope.send(method, *args).restrict(@context, options_with_escape)
      elsif @scope.heimdallr_scopes && @scope.heimdallr_scopes.include?(method)
        Proxy::Collection.new(@context, @scope.send(method, *args), options_with_escape)
      elsif @scope.respond_to? method
        raise InsecureOperationError,
            "Potentially insecure method #{method} was called"
      else
        super
      end
    end

    # Return the underlying scope.
    #
    # @return ActiveRecord scope
    def insecure
      @scope
    end

    # Insecurely taps method saving restricted context for the result
    # Method (or block) is supposed to return proper relation
    #
    # @return [Proxy::Collection]
    def insecurely(*args, &block)
      if block_given?
        result = yield @scope
      else
        method = args.shift
        result = @scope.send method, *args
      end

      Proxy::Collection.new(@context, result, options_with_escape)
    end

    # Describes the proxy and proxified scope.
    #
    # @return [String]
    def inspect
      "#<Heimdallr::Proxy::Collection: #{@scope.to_sql}>"
    end

    # Return the associated security metadata. The returned hash will contain keys
    # +:context+, +:scope+ and +: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:        @scope,
        context:      @context,
        scope:        @scope,
        options:      @options,
        restrictions: @restrictions,
      }.merge(@restrictions.reflection)
    end

    def creatable?
      @restrictions.can? :create
    end

    private

    # Return options hash to pass to children proxies.
    # Currently this checks only eagerly loaded collections, which
    # shouldn't be passed around blindly.
    def options_with_escape
      @options.reject { |k,v| k == :eager_loaded }
    end

    def options_with_eager_load
      @options
    end
  end
end