diff --git a/README.adoc b/README.adoc index ae10905..4ac79db 100644 --- a/README.adoc +++ b/README.adoc @@ -91,6 +91,74 @@ end migrate_up! ---- +== Zero Downtime Migration + +=== ActiveRecord + +Create new columns in database. + +[source,ruby] +---- +class Migration1 < ActiveRecord::Migration + def up + add_column :users, :encrypted_new_ssn + add_column :users, :encrypted_new_ssn_iv + end +end +---- + +Add `attr_encrypted` for new columns, `include Transcryptor::ActiveRecord::ZeroDowntime`, and `transcryptor_migrate :old_attribute_name, :new_attribute_name`. + +[source,ruby] +---- +class User < ActiveRecord::Base + include Transcryptor::ActiveRecord::ZeroDowntime + + attr_encrypted :ssn, key: '1qwe1qwe1qwe1qwe1qwe1qwe1qwe1qwe', algorithm: 'aes-256-cbc' + attr_encrypted :new_ssn, key: '2asd2asd2asd2asd2asd2asd2asd2asd', algorithm: 'aes-256-gcm' + + transcryptor_migrate :ssn, :new_ssn +end +---- + +Create `rake` task for zero downtime migration. Or any other way you prefer. + +[source,ruby] +---- +namespace :zero_downtime + desc 'migrate attr_encrypted for User' + task user: :environment do + User.find_each { |user| user.save! } + end +end +---- + +Remove & rename columns in database after finishing of `rake` task. + +[source,ruby] +---- +class Migration2 < ActiveRecord::Migration + def up + remove_column :users, :encrypted_ssn + remove_column :users, :encrypted_ssn_iv + + rename_column :users, :encrypted_new_ssn, :encrypted_ssn + rename_column :users, :encrypted_new_ssn_iv, :encrypted_ssn_iv + end +end +---- + +Move `attr_encrypted` configuration to original attribute and remove all migration code. + +[source,ruby] +---- +class User < ActiveRecord::Base + attr_encrypted :ssn, key: '2asd2asd2asd2asd2asd2asd2asd2asd', algorithm: 'aes-256-gcm' +end +---- + +Done! + == Default Options Default options for old and new configuration are absolutelly the same as it is defined in `attr_encrypted` gem. diff --git a/lib/transcryptor/active_record.rb b/lib/transcryptor/active_record.rb index 3beaf80..120d4fd 100644 --- a/lib/transcryptor/active_record.rb +++ b/lib/transcryptor/active_record.rb @@ -3,3 +3,4 @@ module Transcryptor::ActiveRecord require 'transcryptor/active_record/adapter' require 'transcryptor/active_record/re_encrypt_statement' +require 'transcryptor/active_record/zero_downtime' diff --git a/lib/transcryptor/active_record/zero_downtime.rb b/lib/transcryptor/active_record/zero_downtime.rb new file mode 100644 index 0000000..5701df4 --- /dev/null +++ b/lib/transcryptor/active_record/zero_downtime.rb @@ -0,0 +1,35 @@ +module Transcryptor::ActiveRecord::ZeroDowntime + def self.included(base) + base.extend ClassMethods + + base.class_eval do + after_initialize :transcryptor_migrate + + @transcryptor_migrate_attributes = [] + class << self + attr_reader :transcryptor_migrate_attributes + end + end + end + + def transcryptor_migrate + self.class.transcryptor_migrate_attributes.each do |attribute| + send("#{attribute[:new]}=", send(attribute[:old])) + end + end + + module ClassMethods + def transcryptor_migrate(old_attribute, new_attribute) + @transcryptor_migrate_attributes << {old: old_attribute, new: new_attribute} + + options = attr_encrypted_options.merge(encrypted_attributes[old_attribute]) + encrypted_attribute_name = "#{options[:prefix]}#{old_attribute}#{options[:suffix]}" + + define_method("#{old_attribute}=") do |value| + send("#{encrypted_attribute_name}=", encrypt(old_attribute, value)) + instance_variable_set("@#{old_attribute}", value) + send("#{new_attribute}=", value) + end + end + end +end diff --git a/spec/transcryptor/active_record/zero_downtime_spec.rb b/spec/transcryptor/active_record/zero_downtime_spec.rb new file mode 100644 index 0000000..26d58fa --- /dev/null +++ b/spec/transcryptor/active_record/zero_downtime_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' + +ActiveRecord::Base.connection.create_table(:active_record_zero_downtime_specs) do |t| + t.string :encrypted_column_1 + t.string :encrypted_column_1_iv + t.string :encrypted_new_column_1 + t.string :encrypted_new_column_1_iv +end + +class ActiveRecordZeroDowntimeSpec < ActiveRecord::Base + include Transcryptor::ActiveRecord::ZeroDowntime + + attr_encrypted :column_1, key: '1qwe1qwe1qwe1qwe1qwe1qwe1qwe1qwe' + attr_encrypted :new_column_1, key: '2asd2asd2asd2asd2asd2asd2asd2asd' + + transcryptor_migrate :column_1, :new_column_1 +end + +describe Transcryptor::ActiveRecord::ZeroDowntime do + let(:instance) { ActiveRecordZeroDowntimeSpec.create(column_1: 'test') } + + it 'assigns encrypted data for both attributes' do + expect(instance.new_column_1).to eq('test') + end + + it 'changes new attribute on change of old' do + instance.column_1 = 'another' + expect(instance.new_column_1).to eq('another') + end +end