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

3

7

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

Sperling answered 12/6, 2017 at 22:4 Comment(5)
What version of Rails is this?Pacorro
According to the post its Rails 5.0.2Sensitize
Take a look at attr_encrypted. Although it basically does the same thing, it more "battle-tested". No need to roll your own solution here.Indolence
That requires a separate IV field for each table field, requires renaming each field to encrypted_field, and it is just like the answer below, except it switches to use encrypted_name as the real field and a virtual name method instead of decrypted_name.Sperling
@Sensitize that was added after my question.Pacorro
P
6

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.

Pacorro answered 15/6, 2017 at 23:16 Comment(9)
Ooh that looks really nice! I'll try it! Yes, they are Base64 encoded.Sperling
Is there a way to list multiple attributes at once, like attribute :name, :phone, :address1, :encrypted? What does cast value do?Sperling
This updates the database every time you call User.save because it generates a new value with a new IV, whereas the getter/setter/getter_before_type_cast doesn't. > u.save (3.0ms) BEGIN User Exists (2.0ms) ... SQL (2.0ms) UPDATE "users" SET "name" = $1, ... #serialize consists of only value.encrypt unless value.nil?.Sperling
> u=User.first User Load (15.8ms) ... > u.changed? => true Yet nothing changed.Sperling
@Sperling cast is part of the Type interface, its purpose is taking a raw value from the user or DB and coercing it to the correct model value.Pacorro
@Sperling you left out any details about the implementation of your encryption, so I wasn't able to address that at all. If you want to open a new Question, I will take a look. Generally speaking, how you generate the IV is a tradeoff. If you want greater security, you should randomly generate it for each value. That means you will be changing the data when it's not strictly necessary; using a static IV is less secure though.Pacorro
Yes it generates a random IV for each value. But for some reason, this implementation calls serialize to generate a new encrypted value immediately after it loads (or calling changed?). So just loading the model will require a save of dozens of fields with new values, even if nothing changed. That could slow down the DB.Sperling
@Sperling try overloading changed_in_place? to account for your encryptionPacorro
@Sperling you don't want to be using class variables in that way, as they have some nasty side-effects when combined with inheritance.Pacorro
P
1

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.

Paisano answered 15/6, 2017 at 11:56 Comment(2)
How is this fundamentally different from what he has already? Why not use name, transparently from view's perspective?Indolence
Then I'll have to change all the attributes to decrypted_[attribute] everywhere in the code, and there are 1-2 dozen attributes I would like to encrypt, and I would like it to work transparently.Sperling
S
1

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

Sperling answered 15/6, 2017 at 21:32 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.