chrisjensen/attr_redactor

View on GitHub
lib/attr_redactor.rb

Summary

Maintainability
A
3 hrs
Test Coverage
require 'hash_redactor'

# Adds attr_accessors that redact an object's attributes
module AttrRedactor
  autoload :Version, 'attr_redactor/version'

  def self.extended(base) # :nodoc:
    base.class_eval do
      include InstanceMethods
      attr_writer :attr_redactor_options
      @attr_redactor_options, @redacted_attributes = {}, {}
    end
  end

  # Generates attr_accessors that remove, digest or encrypt values in an attribute that is a hash transparently
  #
  # Options
  # Any other options you specify are passed on to hash_redactor which is used for
  # redacting, and in turn onto attr_encrypted which it uses for encryption)
  #
  #   redact:            Hash that describes the values to redact in the hash. Should be a
  #                        map of the form { key => method }, where key is the key to redact
  #                        and method is one of :remove, :digest, :encrypt
  #                        eg {
  #                                :ssn => :remove,
  #                                :email => :digest
  #                                :medical_notes => :encrypt,
  #                           }
  #
  #   attribute:        The name of the referenced encrypted attribute. For example
  #                     <tt>attr_accessor :data, attribute: :safe_data</tt> would generate 
  #                     an attribute named 'safe_data' to store the redacted data.
  #                        This is useful when defining one attribute to encrypt at a time 
  #                        or when the :prefix and :suffix options aren't enough.
  #                     Defaults to nil.
  #
  #   prefix:           A prefix used to generate the name of the referenced redacted attributes.
  #                     For example <tt>attr_accessor :data, prefix: 'safe_'</tt> would
  #                     generate attributes named 'safe_data' to store the redacted
  #                     data hash.
  #                     Defaults to 'redacted_'.
  #
  #   suffix:           A suffix used to generate the name of the referenced redacted attributes.
  #                     For example <tt>attr_accessor :data, prefix: '', suffix: '_cleaned'</tt>
  #                     would generate attributes named 'data_cleaned' to store the
  #                     cleaned up data.
  #                     Defaults to ''.
  #
  #   encryption_key:   The encryption key to use for encrypted fields.
  #                     Defaults to nil. Required if you are using encryption.
  #
  #   digest_salt:        The salt to use for digests
  #                        Defaults to ""
  #
  #
  # You can specify your own default options
  #
  #   class User
  #     attr_redactor_options.merge!(redact: { :ssn => :remove })
  #     attr_redactor :data
  #   end
  #
  #
  # Example
  #
  #   class User
  #     attr_redactor_options.merge!(encryption_key: 'some secret key')
  #     attr_redactor :data, redact: {
  #                                :ssn => :remove,
  #                                :email => :digest
  #                                :medical_notes => :encrypt,
  #                           }
  #   end
  #
  #   @user = User.new
  #   @user.redacted_data # nil
  #   @user.data? # false
  #   @user.data = { ssn: 'private', email: 'mail@email.com', medical_notes: 'private' }
  #   @user.data? # true
  #   @user.redacted_data # { email_digest: 'XXXXXX', encrypted_medical_notes: 'XXXXXX', encrypted_medical_notes_iv: 'XXXXXXX' }
  #   @user.save!
  #      @user = User.last
  #
  #   @user.data # { email_digest: 'XXXXXX', medical_notes: 'private' }
  #
  #   See README for more examples
  def attr_redactor(*attributes)
    options = attributes.last.is_a?(Hash) ? attributes.pop : {}
    options = attr_redactor_default_options.dup.merge!(attr_redactor_options).merge!(options)

    attributes.each do |attribute|
      redacted_attribute_name = (options[:attribute] ? options[:attribute] : [options[:prefix], attribute, options[:suffix]].join).to_sym

      instance_methods_as_symbols = attribute_instance_methods_as_symbols
      attr_reader redacted_attribute_name unless instance_methods_as_symbols.include?(redacted_attribute_name)
      attr_writer redacted_attribute_name unless instance_methods_as_symbols.include?(:"#{redacted_attribute_name}=")

      hash_redactor_options = options.dup

      # Don't pass the filter mode in, in case it's dynamic
      hash_redactor_options.delete :filter_mode

      # Create a redactor for the attribute
      options[:redactor] = HashRedactor::HashRedactor.new(hash_redactor_options)

      define_method(attribute) do
        instance_variable_get("@#{attribute}") || instance_variable_set("@#{attribute}", unredact(attribute, send(redacted_attribute_name)))
      end

      define_method("#{attribute}=") do |value|
        send("#{redacted_attribute_name}=", redact(attribute, value))
        instance_variable_set("@#{attribute}", value)
        # replace with redacted/unredacted value immediately
        instance_variable_set("@#{attribute}", unredact(attribute, send(redacted_attribute_name)))
      end

      define_method("#{attribute}?") do
        value = send(attribute)
        value.respond_to?(:empty?) ? !value.empty? : !!value
      end

      define_method("#{attribute}_redact_hash") do
        options = redacted_attributes[attribute]
          options[:redact]
      end

      redacted_attributes[attribute.to_sym] = options.merge(attribute: redacted_attribute_name)
    end
  end

  # Default options to use with calls to <tt>attr_redactor</tt>
  #
  # It will inherit existing options from its superclass
  def attr_redactor_options
    @attr_redactor_options ||= superclass.attr_redactor_options.dup
  end

  def attr_redactor_default_options
    {
      prefix:            'redacted_',
      suffix:            '',
      if:                true,
      unless:            false,
      marshal:           false,
      marshaler:         Marshal,
      dump_method:       'dump',
      load_method:       'load',
    }
  end

  private :attr_redactor_default_options

  # Checks if an attribute is configured with <tt>attr_redactor</tt>
  #
  # Example
  #
  #   class User
  #     attr_accessor :name
  #     attr_redactor :email
  #   end
  #
  #   User.attr_redacted?(:name)  # false
  #   User.attr_redacted?(:email) # true
  def attr_redacted?(attribute)
    redacted_attributes.has_key?(attribute.to_sym)
  end

  # Decrypts values in the attribute specified
  #
  # Example
  #
  #   class User
  #     attr_redactor :data
  #   end
  #
  #   data = User.redact(:data, SOME_REDACTED_HASH)
  def unredact(attribute, redacted_value, options = {})
    options = redacted_attributes[attribute.to_sym].merge(options)
    if options[:if] && !options[:unless] && !redacted_value.nil?
      value = options[:redactor].decrypt(redacted_value, options)
      value
    else
      redacted_value
    end
  end

  # Redacts for the attribute specified
  #
  # Example
  #
  #   class User
  #     attr_redactor :data
  #   end
  #
  #   redacted_data = User.redact(:data, { email: 'test@example.com' })
  def redact(attribute, value, options = {})
    options = redacted_attributes[attribute.to_sym].merge(options)
    if options[:if] && !options[:unless] && !value.nil?
      redacted_value = options[:redactor].redact(value, options)
    else
      value
    end
  end

  # Contains a hash of redacted attributes with virtual attribute names as keys
  # and their corresponding options as values
  #
  # Example
  #
  #   class User
  #     attr_redactor :data, key: 'my secret key'
  #   end
  #
  #   User.redacted_attributes # { data: { attribute: 'redacted_data', encryption_key: 'my secret key' } }
  def redacted_attributes
    @redacted_attributes ||= superclass.redacted_attributes.dup
  end

  # Forwards calls to :redact_#{attribute} or :unredact_#{attribute} to the corresponding redact or unredact method
  # if attribute was configured with attr_redactor
  #
  # Example
  #
  #   class User
  #     attr_redactor :data, key: 'my secret key'
  #   end
  #
  #   User.redact_data('SOME_ENCRYPTED_EMAIL_STRING')
  def method_missing(method, *arguments, &block)
    if method.to_s =~ /^(redact|unredact)_(.+)$/ && attr_redacted?($2)
      send($1, $2, *arguments)
    else
      super
    end
  end

  module InstanceMethods
    # Decrypts a value for the attribute specified using options evaluated in the current object's scope
    #
    # Example
    #
    #  class User
    #    attr_accessor :secret_key
    #    attr_redactor :data, key: :secret_key
    #
    #    def initialize(secret_key)
    #      self.secret_key = secret_key
    #    end
    #  end
    #
    #  @user = User.new('some-secret-key')
    #  @user.unredact(:data, SOME_REDACTED_HASH)
    def unredact(attribute, redacted_value)
      redacted_attributes[attribute.to_sym][:operation] = :unredacting
      self.class.unredact(attribute, redacted_value, evaluated_attr_redacted_options_for(attribute))
    end

    # Redacts a value for the attribute specified using options evaluated in the current object's scope
    #
    # Example
    #
    #  class User
    #    attr_accessor :secret_key
    #    attr_redactor :data, key: :secret_key
    #
    #    def initialize(secret_key)
    #      self.secret_key = secret_key
    #    end
    #  end
    #
    #  @user = User.new('some-secret-key')
    #  @user.redact(:data, 'test@example.com')
    def redact(attribute, value)
      redacted_attributes[attribute.to_sym][:operation] = :redacting
      self.class.redact(attribute, value, evaluated_attr_redacted_options_for(attribute))
    end

    # Copies the class level hash of redacted attributes with virtual attribute names as keys
    # and their corresponding options as values to the instance
    #
    def redacted_attributes
      @redacted_attributes ||= self.class.redacted_attributes.dup
    end

    protected

      # Returns attr_redactor options evaluated in the current object's scope for the attribute specified
      def evaluated_attr_redacted_options_for(attribute)
        evaluated_options = Hash.new
        attribute_option_value = redacted_attributes[attribute.to_sym][:attribute]
        redacted_attributes[attribute.to_sym].each do |option, value|
          evaluated_options[option] = evaluate_attr_redactor_option(value)
        end

        evaluated_options[:attribute] = attribute_option_value

        evaluated_options
      end

      # Evaluates symbol (method reference) or proc (responds to call) options
      #
      # If the option is not a symbol or proc then the original option is returned
      def evaluate_attr_redactor_option(option)
        if option.is_a?(Symbol) && respond_to?(option)
          send(option)
        elsif option.respond_to?(:call)
          option.call(self)
        else
          option
        end
      end
  end

  protected

  def attribute_instance_methods_as_symbols
    instance_methods.collect { |method| method.to_sym }
  end

end


Dir[File.join(File.dirname(__FILE__), 'attr_redactor', 'adapters', '*.rb')].each { |adapter| require adapter }