From 6c5b1a3c2a6e1f9e7e7f228dee9ce6e1717ce548 Mon Sep 17 00:00:00 2001 From: Kevin Zhou Date: Wed, 8 Jan 2020 16:25:21 -0800 Subject: [PATCH] Introduce vault_single_decrypt flag to only load the attribute we care about --- README.md | 29 ++++- lib/vault/encrypted_model.rb | 23 +++- spec/dummy/app/models/lazy_single_person.rb | 18 +++ spec/integration/rails_spec.rb | 124 ++++++++++++++++++++ 4 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 spec/dummy/app/models/lazy_single_person.rb diff --git a/README.md b/README.md index 38348ef0..e7687ec4 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ vault_attribute :credit_card, - **Note** Changing this value for an existing application will make existing values no longer decryptable! #### Lazy attribute decryption -By default, `vault-rails` will decrypt a record’s encrypted attributes on that record’s initializarion. You can configure an encrypted model to decrypt attributes lazily, which will prevent communication with Vault until an encrypted attribute’s getter method is called, at which point all of the record’s encrypted attributes will be decrypted. This is useful if you do not always need access to encrypted attributes. For example: +By default, `vault-rails` will decrypt a record’s encrypted attributes on that record’s initialization. You can configure an encrypted model to decrypt attributes lazily, which will prevent communication with Vault until an encrypted attribute’s getter method is called, at which point all of the record’s encrypted attributes will be decrypted. This is useful if you do not always need access to encrypted attributes. For example: ```ruby @@ -202,6 +202,33 @@ person.ssn # Vault communication happens here # => "123-45-6789" ``` +#### Single, lazy attribute decryption +By default, `vault-rails` will decrypt all encrypted attributes on that record’s initialization on a class by class basis. You can configure an encrypted model to decrypt attributes lazily and and individually. This will prevent vault from loading all vault_attributes defined on a class the moment one attribute is requested. + + +```ruby +class Person < ActiveRecord::Base + include Vault::EncryptedModel + vault_lazy_decrypt! + vault_single_decrypt! + + vault_attribute :ssn + vault_attribute :email +end + +# Without vault_single_decrypt: +person = Person.find(id) # Vault communication happens here +person.ssn # Vault communication happens here, fetches both ssn and email +# => "123-45-6789" + +# With vault_single_decrypt: +person = Person.find(id) +person.ssn # Vault communication happens here, fetches only ssn +# => "123-45-6789" +person.email # Vault communication happens here, fetches only email +# => "foobar@baz.com" +``` + #### Serialization By default, all values are assumed to be "text" fields in the database. Sometimes it is beneficial for your application to work with a more flexible data structure (such as a Hash or Array). Vault-rails can automatically serialize and deserialize these structures for you: diff --git a/lib/vault/encrypted_model.rb b/lib/vault/encrypted_model.rb index 65e70084..239c54dd 100644 --- a/lib/vault/encrypted_model.rb +++ b/lib/vault/encrypted_model.rb @@ -72,13 +72,13 @@ def vault_attribute(attribute, options = {}) # Getter define_method("#{attribute}") do - self.__vault_load_attributes! unless @__vault_loaded + self.__vault_load_attributes!(attribute) unless @__vault_loaded super() end # Setter define_method("#{attribute}=") do |value| - self.__vault_load_attributes! unless @__vault_loaded + self.__vault_load_attributes!(attribute) unless @__vault_loaded # We always set it as changed without comparing with the current value # because we allow our held values to be mutated, so we need to assume @@ -94,7 +94,8 @@ def vault_attribute(attribute, options = {}) # Checker define_method("#{attribute}?") do - public_send(attribute).present? + self.__vault_load_attributes!(attribute) unless @__vault_loaded + instance_variable_get("@#{attribute}").present? end # Make a note of this attribute so we can use it in the future (maybe). @@ -152,6 +153,14 @@ def vault_lazy_decrypt def vault_lazy_decrypt! @vault_lazy_decrypt = true end + + def vault_single_decrypt + @vault_single_decrypt ||= false + end + + def vault_single_decrypt! + @vault_single_decrypt = true + end end included do @@ -178,12 +187,16 @@ def __vault_initialize_attributes! __vault_load_attributes! end - def __vault_load_attributes! + def __vault_load_attributes!(attribute_to_read = nil) self.class.__vault_attributes.each do |attribute, options| + # skip loading certain keys in one of two cases: + # 1- the attribute has already been loaded + # 2- the single decrypt option is set AND this is not the attribute we're requesting to decrypt + next if instance_variable_get("@#{attribute}") || (self.class.vault_single_decrypt && attribute_to_read != attribute) self.__vault_load_attribute!(attribute, options) end - @__vault_loaded = true + @__vault_loaded = self.class.__vault_attributes.all? { |attribute, __| instance_variable_get("@#{attribute}") } return true end diff --git a/spec/dummy/app/models/lazy_single_person.rb b/spec/dummy/app/models/lazy_single_person.rb new file mode 100644 index 00000000..617f2aad --- /dev/null +++ b/spec/dummy/app/models/lazy_single_person.rb @@ -0,0 +1,18 @@ + +class LazySinglePerson < ActiveRecord::Base + include Vault::EncryptedModel + + self.table_name = "people" + + vault_lazy_decrypt! + vault_single_decrypt! + + vault_attribute :ssn + + vault_attribute :credit_card, + encrypted_column: :cc_encrypted + + def encryption_context + "user_#{id}" + end +end diff --git a/spec/integration/rails_spec.rb b/spec/integration/rails_spec.rb index 54344995..33d1da54 100644 --- a/spec/integration/rails_spec.rb +++ b/spec/integration/rails_spec.rb @@ -192,6 +192,130 @@ end end + context "lazy single decrypt" do + before(:all) do + Vault::Rails.logical.write("transit/keys/dummy_people_ssn") + end + + it "encrypts attributes" do + person = LazySinglePerson.create!(ssn: "123-45-6789") + expect(person.ssn_encrypted).to be + expect(person.ssn_encrypted.encoding).to eq(Encoding::UTF_8) + end + + it "decrypts attributes" do + person = LazySinglePerson.create!(ssn: "123-45-6789") + person.reload + + expect(person.ssn).to eq("123-45-6789") + expect(person.ssn.encoding).to eq(Encoding::UTF_8) + end + + it "does not decrypt on initialization" do + person = LazySinglePerson.create!(ssn: "123-45-6789") + person.reload + + p2 = LazySinglePerson.find(person.id) + + expect(p2.instance_variable_get("@ssn")).to eq(nil) + expect(p2.ssn).to eq("123-45-6789") + end + + it "does not decrypt all attributes on single read" do + person = LazySinglePerson.create!(ssn: "123-45-6789") + person.update_attributes!(credit_card: "abcd-efgh-hijk-lmno") + expect(person.credit_card).to eq("abcd-efgh-hijk-lmno") + + person.reload + + p2 = LazySinglePerson.find(person.id) + + expect(p2.instance_variable_get("@ssn")).to eq(nil) + expect(p2.ssn).to eq("123-45-6789") + expect(p2.instance_variable_get("@credit_card")).to eq(nil) + expect(p2.credit_card).to eq("abcd-efgh-hijk-lmno") + end + + it "does not decrypt all attributes on single write" do + person = LazySinglePerson.create!(ssn: "123-45-6789") + person.update_attributes!(credit_card: "abcd-efgh-hijk-lmno") + expect(person.credit_card).to eq("abcd-efgh-hijk-lmno") + + person.reload + + p2 = LazySinglePerson.find(person.id) + + expect(p2.instance_variable_get("@ssn")).to eq(nil) + expect(p2.ssn).to eq("123-45-6789") + person.ssn = "111-11-1111" + expect(p2.instance_variable_get("@credit_card")).to eq(nil) + expect(p2.credit_card).to eq("abcd-efgh-hijk-lmno") + end + + it "tracks dirty attributes" do + person = LazySinglePerson.create!(ssn: "123-45-6789") + + expect(person.ssn_changed?).to be(false) + expect(person.ssn_change).to be(nil) + expect(person.ssn_was).to eq("123-45-6789") + + person.ssn = "111-11-1111" + + expect(person.ssn_changed?).to be(true) + expect(person.ssn_change).to eq(["123-45-6789", "111-11-1111"]) + expect(person.ssn_was).to eq("123-45-6789") + end + + it "allows attributes to be unset" do + person = LazySinglePerson.create!(ssn: "123-45-6789") + person.update_attributes!(ssn: nil) + person.reload + + expect(person.ssn).to be(nil) + end + + it "allows saving without validations" do + person = LazySinglePerson.new(ssn: "123-456-7890") + expect(person.save(validate: false)).to be(true) + expect(person.ssn_encrypted).to match("vault:") + end + + it "allows attributes to be unset after reload" do + person = LazySinglePerson.create!(ssn: "123-45-6789") + person.reload + person.update_attributes!(ssn: nil) + person.reload + + expect(person.ssn).to be(nil) + end + + it "allows attributes to be blank" do + person = LazySinglePerson.create!(ssn: "123-45-6789") + person.update_attributes!(ssn: "") + person.reload + + expect(person.ssn).to eq("") + end + + it "reloads instance variables on reload" do + person = LazySinglePerson.create!(ssn: "123-45-6789") + expect(person.instance_variable_get(:@ssn)).to eq("123-45-6789") + + person.ssn = "111-11-1111" + person.reload + + expect(person.ssn).to eq("123-45-6789") + end + + it "does not try to encrypt unchanged attributes" do + person = LazySinglePerson.create!(ssn: "123-45-6789") + + expect(Vault::Rails).to_not receive(:encrypt) + person.name = "Cinderella" + person.save! + end + end + context "with custom options" do before(:all) do Vault::Rails.sys.mount("credit-secrets", :transit)