Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

`form_for` is bypassing model accessors. How to make it stop? (Or: How to make a custom attribute serializer?)

I set these methods to automatically encrypt values.

class User < ApplicationRecord
  def name=(val)
    super val.encrypt
  end
  def name
    (super() || '').decrypt
  end

When I try to submit the form and there is an error (missing phone), then the name attribute shows up garbled.

<input class="form-control" type="text" value="Mg8IS1LB2A1efAeZJxIDJMSroKcq6WueyY4ZiUX+hfI=" name="user[name]" id="user_name">

It works when the validations succeeds. It also works in the console when I go line-by-line through my controller #update.

irb(main):015:0> u = User.find 1
irb(main):016:0> u.name
=> "Sue D. Nym"
irb(main):017:0> u.phone
=> "212-555-1234"
irb(main):018:0> u.update name: 'Sue D. Nym', phone: ''
   (10.0ms)  BEGIN
   (1.0ms)  ROLLBACK
=> false
irb(main):020:0> u.save
=> false
irb(main):029:0> u.errors.full_messages.join ','
=> "Phone can't be blank"
irb(main):031:0> u.build_image unless u.image
=> nil
irb(main):033:0> u.name
=> "Sue D. Nym"
users_controller.rb
  def update
    @user = User.find current_user.id
    @user.update user_params
    if @user.save
      flash.notice = "Profile Saved"
      redirect_to :dashboard
    else
      flash.now.alert = @user.errors.full_messages.join ', '
      @user.build_image unless @user.image
      render :edit
    end
  end

The view is somehow getting the encrypted value without going through #name, and only after a validation failure.


I reduced the controller to the absolute minimum and it fails immediately after #update. However, it's working on the console!

  def update
    @user = User.find current_user.id
    @user.update user_params
    render :edit
    return

I reduced my view to the absolute minimum and it shows the name, but only outside of form_for. I don't know why yet.

edit.haml
[email protected]
=form_for @user, html: { multipart: true } do |f|
  =f.text_field :name
HTML source
<span>Sue D. Nym</span>
<form class="edit_user" id="edit_user_1" enctype="multipart/form-data" action="/users/1" accept-charset="UTF-8" method="post">
  <input name="utf8" type="hidden" value="✓"><input type="hidden" name="_method" value="patch"><input type="hidden" name="authenticity_token" value="C/ScTxfENNxCKgzG0qAlPElOKI7nOYxZimQ7BsB64wIWQ9El4+vOAfxX3qHL08rbr0sxRiJnzQti13e4DAgkfQ==">  
  <input type="text" value="sER9cjwa6Ov5weXjEQN2KJYoTOXtVBytpX/cI/aPrFs=" name="user[name]" id="user_name">
</form>

I noticed attributes still returned encrypted values so I tried adding this but form_for still manages to obtain the encrypted value and put it in the form!

  def attributes
    attr_hash = super()
    attr_hash["name"] = name
    attr_hash
  end

Rails 5.0.2

like image 573
Chloe Avatar asked Jun 12 '17 22:06

Chloe


3 Answers

While you can work around this by overloading name_before_type_case, I think this is actually the wrong place to be doing this kind of transformation.

Based on your example, the requirements here appear to be:

  1. plaintext while in memory
  2. encrypted at rest

So if we move the encrytion/decryption transformation to the Ruby-DB boundary, this logic becomes much cleaner & reusable.

Rails 5 introduced a helpful Attributes API for dealing with this exact scenario. Since you have provided no details about how your encryption routine is implemented, I'm going to use Base64 in my example code to demonstrate a text transformation.

app/types/encrypted_type.rb
class EncryptedType < ActiveRecord::Type::Text
  # this is called when saving to the DB
  def serialize(value)
    Base64.encode64(value) unless value.nil?
  end

  # called when loading from DB
  def deserialize(value)
    Base64.decode64(value) unless value.nil?
  end

  # add this if the field is not idempotent
  def changed_in_place?(raw_old_value, new_value)
    deserialize(raw_old_value) != new_value
  end
end
config/initalizers/types.rb
ActiveRecord::Type.register(:encrypted, EncryptedType)

Now, you can specify this attribute as encrypted in the model:

class User < ApplicationRecord
  attribute :name, :encrypted

  # If you have a lot of fields, you can use metaprogramming:
  %i[name phone address1 address2 ssn].each do |field_name|
    attribute field_name, :encrypted
  end
end

The name attribute will be transparently encrypted & decrypted during roundtrips to the DB. This also means that you can apply the same transform to as many attributes as you like without rewriting the same code.

like image 121
Adam Lassek Avatar answered Nov 14 '22 22:11

Adam Lassek


Why are you exposing it as name at all ?

class User < ApplicationRecord
    def decrypted_name=(val)
       name = val.encrypt
    end

    def decrypted_name
       name.decrypt
    end
end

Then you use @model.decrypted_name instead of @model.name as name is encrypted, and such saved in DB.

edit.haml
[email protected]_name
=form_for @user, html: { multipart: true } do |f|
  =f.text_field :decrypted_name

And name if it is encrypted should not be handled directly but with this decrypted_name accessor.

like image 22
Nermin Avatar answered Nov 14 '22 22:11

Nermin


I found this similar question: How do input field methods (text_area, text_field, etc.) get attribute values from a record within a form_for block?

I added

  def name_before_type_cast
    (super() || '').decrypt
  end

And now it works!

Here is the full solution:

  @@encrypted_fields = [:name, :phone, :address1, :address2, :ssn, ...]
  @@encrypted_fields.each do |m|
    setter = (m.to_s+'=').to_sym
    getter = m
    getter_btc = (m.to_s+'_before_type_cast').to_sym
    define_method(setter) do |v|
      super v.encrypt
    end
    define_method(getter) do
      (super() || '').decrypt
    end
    define_method(getter_btc) do
      (super() || '').decrypt
    end
  end

Some docs: http://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/BeforeTypeCast.html

like image 44
Chloe Avatar answered Nov 14 '22 20:11

Chloe