diff --git a/CHANGELOG.md b/CHANGELOG.md index 837d75c3..a431f6f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Vault Rails Changelog +## Unreleased + +IMPROVEMENTS +- Add `EncryptedModel.vault_persist_all` for encrypting and saving one attribute of multiple records with just one call to Vault (forward ported from 0.6.5) +- Add `EncryptedModel.vault_load_all` for decrypting and loading one attribute of multiple records with just one call to Vault (forward ported from 0.6.5) + ## 0.7.3 (December 10, 2018) BUG FIXES diff --git a/lib/vault/encrypted_model.rb b/lib/vault/encrypted_model.rb index bba6f566..1c25aacc 100644 --- a/lib/vault/encrypted_model.rb +++ b/lib/vault/encrypted_model.rb @@ -211,6 +211,21 @@ def vault_lazy_decrypt? def vault_lazy_decrypt! @vault_lazy_decrypt = true end + + # works only with convergent encryption + def vault_persist_all(attribute, records, plaintexts) + options = __vault_attributes[attribute] + + Vault::PerformInBatches.new(attribute, options).encrypt(records, plaintexts) + end + + # works only with convergent encryption + # relevant only if lazy decryption is enabled + def vault_load_all(attribute, records) + options = __vault_attributes[attribute] + + Vault::PerformInBatches.new(attribute, options).decrypt(records) + end end included do diff --git a/lib/vault/perform_in_batches.rb b/lib/vault/perform_in_batches.rb new file mode 100644 index 00000000..bf690d7e --- /dev/null +++ b/lib/vault/perform_in_batches.rb @@ -0,0 +1,57 @@ +module Vault + class PerformInBatches + def initialize(attribute, options) + @attribute = attribute + + @key = options[:key] + @path = options[:path] + @serializer = options[:serializer] + @column = options[:encrypted_column] + @convergent = options[:convergent] + end + + def encrypt(records, plaintexts) + raise 'Batch Operations work only with convergent attributes' unless @convergent + + raw_plaintexts = serialize(plaintexts) + + ciphertexts = Vault::Rails.batch_encrypt(path, key, raw_plaintexts, Vault.client) + + records.each_with_index do |record, index| + record.send("#{column}=", ciphertexts[index]) + record.save + end + end + + def decrypt(records) + raise 'Batch Operations work only with convergent attributes' unless @convergent + + ciphertexts = records.map { |record| record.send(column) } + + raw_plaintexts = Vault::Rails.batch_decrypt(path, key, ciphertexts, Vault.client) + plaintexts = deserialize(raw_plaintexts) + + records.each_with_index do |record, index| + record.__vault_loaded_attributes << attribute + + record.write_attribute(attribute, plaintexts[index]) + end + end + + private + + attr_reader :key, :path, :serializer, :column, :attribute + + def serialize(plaintexts) + return plaintexts unless serializer + + plaintexts.map { |plaintext| serializer.encode(plaintext) } + end + + def deserialize(plaintexts) + return plaintexts unless serializer + + plaintexts.map { |plaintext| serializer.decode(plaintext) } + end + end +end diff --git a/lib/vault/rails.rb b/lib/vault/rails.rb index b09d54d1..3956c91d 100644 --- a/lib/vault/rails.rb +++ b/lib/vault/rails.rb @@ -5,6 +5,7 @@ require_relative 'encrypted_model' require_relative 'attribute_proxy' +require_relative 'perform_in_batches' require_relative 'rails/configurable' require_relative 'rails/errors' require_relative 'rails/serializers/json_serializer' @@ -209,7 +210,7 @@ def memory_encrypt(path, key, plaintext, _client, convergent) # Perform in-memory encryption. This is useful for testing and development. def memory_batch_encrypt(path, key, plaintexts, _client) - plaintexts.map { |plaintext| memory_encrypt(path, key, ciphertext, _client, true) } + plaintexts.map { |plaintext| memory_encrypt(path, key, plaintext, _client, true) } end # Perform in-memory decryption. This is useful for testing and development. diff --git a/spec/dummy/app/models/lazy_person.rb b/spec/dummy/app/models/lazy_person.rb index 51c6f3b3..8ae1005e 100644 --- a/spec/dummy/app/models/lazy_person.rb +++ b/spec/dummy/app/models/lazy_person.rb @@ -25,4 +25,6 @@ class LazyPerson < ActiveRecord::Base decode: ->(raw) { raw && raw[3...-3] } vault_attribute :non_ascii + + vault_attribute :passport_number, convergent: true end diff --git a/spec/dummy/db/migrate/20181119142920_add_passport_number_to_people.rb b/spec/dummy/db/migrate/20181119142920_add_passport_number_to_people.rb new file mode 100644 index 00000000..a3993bd2 --- /dev/null +++ b/spec/dummy/db/migrate/20181119142920_add_passport_number_to_people.rb @@ -0,0 +1,5 @@ +class AddPassportNumberToPeople < ActiveRecord::Migration[5.0] + def change + add_column :people, :passport_number_encrypted, :string + end +end diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index 3b6e3c89..00efca22 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -10,30 +10,27 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2018_10_17_154000) do +ActiveRecord::Schema.define(version: 20181119142920) do create_table "people", force: :cascade do |t| - t.string "name" - t.string "ssn_encrypted" - t.string "cc_encrypted" - t.string "details_encrypted" - t.string "business_card_encrypted" - t.string "favorite_color_encrypted" - t.string "non_ascii_encrypted" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "email_encrypted" - t.string "address" - t.string "address_encrypted" - t.date "date_of_birth" - t.string "date_of_birth_encrypted" - t.string "integer_data_encrypted" - t.string "float_data_encrypted" - t.string "time_data_encrypted" - t.string "county" - t.string "county_encrypted" - t.string "state" - t.string "state_encrypted" + t.string "name" + t.string "ssn_encrypted" + t.string "cc_encrypted" + t.string "details_encrypted" + t.string "business_card_encrypted" + t.string "favorite_color_encrypted" + t.string "non_ascii_encrypted" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "email_encrypted" + t.string "integer_data_encrypted" + t.string "float_data_encrypted" + t.string "time_data_encrypted" + t.string "county" + t.string "county_encrypted" + t.string "state" + t.string "state_encrypted" + t.string "passport_number_encrypted" end end diff --git a/spec/integration/rails_spec.rb b/spec/integration/rails_spec.rb index d3dfcdd9..fe6724b0 100644 --- a/spec/integration/rails_spec.rb +++ b/spec/integration/rails_spec.rb @@ -499,4 +499,56 @@ end end end + + context 'batch encryption and decryption' do + before do + allow(Vault::Rails).to receive(:convergent_encryption_context).and_return('a' * 16).at_least(:once) + end + + describe '.vault_load_all' do + it 'calls Vault just once' do + first_person = LazyPerson.create!(passport_number: '12345678') + second_person = LazyPerson.create!(passport_number: '12345679') + + people = [first_person.reload, second_person.reload] + expect(Vault.logical).to receive(:write).once.and_call_original + LazyPerson.vault_load_all(:passport_number, people) + + first_person.passport_number + second_person.passport_number + end + + it 'loads the attribute of all records' do + first_person = LazyPerson.create!(passport_number: '12345678') + second_person = LazyPerson.create!(passport_number: '12345679') + + first_person.reload + second_person.reload + + LazyPerson.vault_load_all(:passport_number, [first_person, second_person]) + expect(first_person.passport_number).to eq('12345678') + expect(second_person.passport_number).to eq('12345679') + end + end + + describe '.vault_persist_all' do + it 'calls Vault just once' do + first_person = LazyPerson.new + second_person = LazyPerson.new + + expect(Vault.logical).to receive(:write).once.and_call_original + LazyPerson.vault_persist_all(:passport_number, [first_person, second_person], %w(12345678 12345679)) + end + + it 'saves the attribute of all records' do + first_person = LazyPerson.new + second_person = LazyPerson.new + + LazyPerson.vault_persist_all(:passport_number, [first_person, second_person], %w(12345678 12345679)) + + expect(first_person.reload.passport_number).to eq('12345678') + expect(second_person.reload.passport_number).to eq('12345679') + end + end + end end diff --git a/spec/unit/perform_in_batches_spec.rb b/spec/unit/perform_in_batches_spec.rb new file mode 100644 index 00000000..bafdc93e --- /dev/null +++ b/spec/unit/perform_in_batches_spec.rb @@ -0,0 +1,184 @@ +require 'spec_helper' + +describe Vault::PerformInBatches do + describe '#encrypt' do + context 'non-convergent attribute' do + let(:options) do + { + key: 'test_key', + path: 'test_path', + column: 'test_attribute_encrypted', + convergent: false + } + end + + it 'raises an exception for non-convergent attributes' do + attribute = 'test_attribute' + records = [double(:first_object, save: true), double(:second_object, save: true)] + plaintexts = %w(plaintext1 plaintext2) + + expect do + Vault::PerformInBatches.new(attribute, options).encrypt(records, plaintexts) + end.to raise_error 'Batch Operations work only with convergent attributes' + end + end + + context 'convergent attribute' do + let(:options) do + { + key: 'test_key', + path: 'test_path', + encrypted_column: 'test_attribute_encrypted', + convergent: true + } + end + + it 'encrypts one attribute for a batch of records and saves it' do + attribute = 'test_attribute' + + first_record = double(save: true) + second_record = double(save: true) + records = [first_record, second_record] + + plaintexts = %w(plaintext1 plaintext2) + + + expect(Vault::Rails).to receive(:batch_encrypt) + .with('test_path', 'test_key', %w(plaintext1 plaintext2), Vault.client) + .and_return(%w(ciphertext1 ciphertext2)) + + expect(first_record).to receive('test_attribute_encrypted=').with('ciphertext1') + expect(second_record).to receive('test_attribute_encrypted=').with('ciphertext2') + + Vault::PerformInBatches.new(attribute, options).encrypt(records, plaintexts) + end + + context 'with given serializer' do + let(:options) do + { + key: 'test_key', + path: 'test_path', + encrypted_column: 'test_attribute_encrypted', + serializer: Vault::Rails::Serializers::IntegerSerializer, + convergent: true + } + end + + it 'encrypts one attribute for a batch of records and saves it' do + attribute = 'test_attribute' + + first_record = double(save: true) + second_record = double(save: true) + records = [first_record, second_record] + + plaintexts = [100, 200] + + expect(Vault::Rails).to receive(:batch_encrypt) + .with('test_path', 'test_key', %w(100 200), Vault.client) + .and_return(%w(ciphertext1 ciphertext2)) + + expect(first_record).to receive('test_attribute_encrypted=').with('ciphertext1') + expect(second_record).to receive('test_attribute_encrypted=').with('ciphertext2') + + Vault::PerformInBatches.new(attribute, options).encrypt(records, plaintexts) + end + end + end + end + + describe '#decrypt' do + context 'non-convergent attribute' do + let(:options) do + { + key: 'test_key', + path: 'test_path', + column: 'test_attribute_encrypted', + convergent: false + } + end + + it 'raises an exception for non-convergent attributes' do + attribute = 'test_attribute' + records = [double(:first_object, save: true), double(:second_object, save: true)] + plaintexts = %w(plaintext1 plaintext2) + + expect do + Vault::PerformInBatches.new(attribute, options).encrypt(records, plaintexts) + end.to raise_error 'Batch Operations work only with convergent attributes' + end + end + + context 'convergent attribute' do + let(:options) do + { + key: 'test_key', + path: 'test_path', + encrypted_column: 'test_attribute_encrypted', + convergent: true + } + end + + it 'decrypts one attribute for a batch of records and loads it' do + attribute = 'test_attribute' + + first_record = double(test_attribute_encrypted: 'ciphertext1') + second_record = double(test_attribute_encrypted: 'ciphertext2') + records = [first_record, second_record] + + expect(Vault::Rails).to receive(:batch_decrypt) + .with('test_path', 'test_key', %w(ciphertext1 ciphertext2), Vault.client) + .and_return(%w(plaintext1 plaintext2)) + + first_record_loaded_attributes = [] + allow(first_record).to receive('__vault_loaded_attributes').and_return(first_record_loaded_attributes) + second_record_loaded_attributes = [] + allow(second_record).to receive('__vault_loaded_attributes').and_return(second_record_loaded_attributes) + + expect(first_record).to receive('write_attribute').with('test_attribute', 'plaintext1') + expect(second_record).to receive('write_attribute').with('test_attribute', 'plaintext2') + + Vault::PerformInBatches.new(attribute, options).decrypt(records) + + expect(first_record_loaded_attributes).to include(attribute) + expect(second_record_loaded_attributes).to include(attribute) + end + + context 'with given serializer' do + let(:options) do + { + key: 'test_key', + path: 'test_path', + encrypted_column: 'test_attribute_encrypted', + serializer: Vault::Rails::Serializers::IntegerSerializer, + convergent: true + } + end + + it 'decrypts one attribute for a batch of records and loads it' do + attribute = 'test_attribute' + + first_record = double(test_attribute_encrypted: 'ciphertext1') + second_record = double(test_attribute_encrypted: 'ciphertext2') + records = [first_record, second_record] + + expect(Vault::Rails).to receive(:batch_decrypt) + .with('test_path', 'test_key', %w(ciphertext1 ciphertext2), Vault.client) + .and_return(%w(100 200)) + + first_record_loaded_attributes = [] + allow(first_record).to receive('__vault_loaded_attributes').and_return(first_record_loaded_attributes) + second_record_loaded_attributes = [] + allow(second_record).to receive('__vault_loaded_attributes').and_return(second_record_loaded_attributes) + + expect(first_record).to receive('write_attribute').with('test_attribute', 100) + expect(second_record).to receive('write_attribute').with('test_attribute', 200) + + Vault::PerformInBatches.new(attribute, options).decrypt(records) + + expect(first_record_loaded_attributes).to include(attribute) + expect(second_record_loaded_attributes).to include(attribute) + end + end + end + end +end