Migrate from attr_encrypted to ActiveRecordEncryption

Migrate from attr_encrypted to ActiveRecordEncryption

Rails 7 introduced in-built encryption called Active Record Encryption and attr_encrypted has reached its expiry. And now many projects are on the verge of moving from attr_encrypted to Active Record Encryption. This blog will explain how this migration can be made possible with very few steps.

Data in attr_encrypted and Active Record Encryption:

attr_encrypted uses two columns in the DB to save the data, the encrypted_attribute and encrypted_attribute_iv. Active Record Encryption uses a single column same as the name of the attribute and saves the data as a hash. For example, to encrypt address in the User model, with attr_encrypted, there are two columns encrypted_address and encrypted_address_iv in DB. With active record encryption, there is a single column in DB, address where the data is saved as a hash as below

{"p":"Hashed Value","h":{"iv":"Hashed Value","at":"Hashed Value"}}

As an example, the article will migrate the address attribute from the User model. The migration includes the following steps,

  1. Add Active Record Encryption to the Rails 7 app.

  2. Add address and address_tmp columns to DB

  3. Create migration to fill data into columns

  4. Remove the attr_encrypted gem and drop columns

Step 1: Add Active Record Encryption to the Rails 7 app

The new encryption from Rails 7 uses 3 keys, primary_key, deterministic_key, and key_derivation_salt. Generate them using the following command

bin/rails db:encryption:init

Now save them in your application.rb

config.active_record.encryption.primary_key = "Primary Key"
config.active_record.encryption.deterministic_key = "Deterministic Key"
config.active_record.encryption.key_derivation_salt = "Key Derivation Salt"

Step 2: Add address and address_tmp columns to DB

As mentioned in the introduction, attr_encrypted uses different columns in DB and uses methods to return the value of the address attribute. So the next step is to introduce the column with the same name as the attribute in the DB. But before that, ignore the column in the model.

Class User < ApplicationRecord
  self.ignored_columns = %w[address]
end

Now create a migration to add the column in the User Table. The data can’t be moved directly to the address column since it's still used by attr_encrypted, so create two columns in DB. address and address_tmp. address_tmp will be the temporary column to save the encrypted value. The data is first saved in address_tmp and then the address column is updated using the update_all command. The update_all command does not instantiate the User model or trigger Active Record callbacks and validations, which works perfectly in the current scenario.

class AddAddressAndTmpColumnToUser < ActiveRecord::Migration[7.0]
  def change
    add_column :users, :address, :text
    add_column :users, :address_tmp, :text
  end
end

Now add the encrypts command on the User model from Active Record Encryption which does all the magic. The encrypts point to the address_tmp and not address, because the attr_encrypted gem still uses the address method.

encrypts :address_tmp

Step 3: Create migration to fill data into columns

Create a migration and fill in values into the address_tmp column and then to the address column using update_all. The code that goes into the migration,

User.find_in_batches do |users_batch|
  users_batch.map do |user|
    user.address_tmp = user.address
    user.save
  end
end
User.update_all("address=address_tmp")

Also, add a temporary code in the User model which will save the value in the address and address_tmp columns during updates made by clients using your application.

after_commit :write_address, if: -> { previous_changes[:address] }

def write_address
  self.address_tmp = address
  save!
  User.where(id: id).update_all("address=address_tmp")
end

Now the data is migrated to the address and address_tmp columns. Any updates made by clients are saved as well in the new columns.

Step 4: Remove the attr_encrypted gem and drop columns

Remove the attr_encrypted reference from the code before removing the gem completely. The existing code might look something like the one below

attr_encrypted :address, key: "key"

While removing the above code, also point to the new column address with the encrypted data. In the User model add the following code,

encrypts :address

Remove the references that were temporarily created in the User model,

# to be removed from User model
self.ignored_columns = %w[address]
after_commit :write_address, if: -> { previous_changes[:address] }
encrypts :address_tmp

Also if you are using encrypted_address or encrypted_address_iv directly in your code anywhere, make sure to replace them with the new values from the address attribute. Once all the references are removed, it's safe to remove the gem attr_encrypted from the code.

The final step is to remove the following columns encrypted_address, encrypted_address_iv, and address_tmp from User model.

To know more details on Active Record Encryption, here is the official link https://guides.rubyonrails.org/active_record_encryption.html