From 25278860ad4016d829cf5df81613ef6fe72852a5 Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Fri, 24 Oct 2025 17:23:16 -0600 Subject: [PATCH 1/6] encrypt manually w/ vault_encrypt_attributes! Splits __vault_persist_attribute!, which is normally called on each attribute after_save, into two methods. The new method, __vault_write_encrypted_attribute!, is also called on each vault attribute when `vault_encrypt_attributes!` is called. > p = Person.new(ssn: "123-45-6789").vault_encrypt_attributes! > p.ssn_encrypted "vault:dev:flu/yp9oeYYFgjcZH2hVBA==" --- lib/vault/encrypted_model.rb | 35 ++++++++++++++++++++++++-------- spec/integration/rails_spec.rb | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/lib/vault/encrypted_model.rb b/lib/vault/encrypted_model.rb index 64a8d88..55082b1 100644 --- a/lib/vault/encrypted_model.rb +++ b/lib/vault/encrypted_model.rb @@ -325,12 +325,7 @@ def __vault_persist_attributes! # Encrypt a single attribute using Vault and persist back onto the # encrypted attribute value. def __vault_persist_attribute!(attribute, options) - key = options[:key] - path = options[:path] - serializer = options[:serializer] - column = options[:encrypted_column] - context = options[:context] - transform = options[:transform_secret] + column = options[:encrypted_column] # Only persist changed attributes to minimize requests - this helps # minimize the number of requests to Vault. @@ -346,6 +341,19 @@ def __vault_persist_attribute!(attribute, options) # Get the current value of the plaintext attribute plaintext = attributes[attribute.to_s] + ciphertext = __vault_write_encrypted_attribute!(plaintext, options) + + # Return the updated column so we can save + { column => ciphertext } + end + + def __vault_write_encrypted_attribute!(plaintext, options) + column = options[:encrypted_column] + key = options[:key] + path = options[:path] + serializer = options[:serializer] + context = options[:context] + transform = options[:transform_secret] # Apply the serialize to the plaintext value, if one exists if serializer @@ -372,8 +380,7 @@ def __vault_persist_attribute!(attribute, options) # to get the ciphertext write_attribute(column, ciphertext) - # Return the updated column so we can save - { column => ciphertext } + ciphertext end # Generates an Vault Transit encryption context for use on derived keys. @@ -405,6 +412,18 @@ def reload(*) self.__vault_initialize_attributes! end end + + def vault_encrypt_attributes! + self.class.__vault_attributes.each do |attribute, options| + next if !attribute_changed?(attribute) && options[:default].nil? + + # Get the current value of the plaintext attribute + plaintext = attributes[attribute.to_s] + + __vault_write_encrypted_attribute!(plaintext, options) + end + self + end end end end diff --git a/spec/integration/rails_spec.rb b/spec/integration/rails_spec.rb index 6e2fdf0..d0a93c4 100644 --- a/spec/integration/rails_spec.rb +++ b/spec/integration/rails_spec.rb @@ -746,4 +746,41 @@ expect(person.credit_card).to eq("1234567890111213") end end + + context "manual encryption" do + describe "#vault_encrypt_attributes!" do + it "encrypts vault attributes without saving" do + person = Person.new(ssn: "123-45-6789", favorite_color: "green") + expect { + person.vault_encrypt_attributes! + }.to change { + person.ssn_encrypted + }.from(nil).to(be_a(String)) + + expect(person.favorite_color).to eq("green") + expect(person.favorite_color_encrypted).to be_present + end + + it "returns self" do + person = Person.new + result = person.vault_encrypt_attributes! + expect(result).to be(person) + end + + it "encrypts attributes with a default option" do + person = Person.new + expect(person.default).to eq("abc123") + expect(person.default_with_serializer).to eq({}) + + expect { + person.vault_encrypt_attributes! + }.to change { + person.default_encrypted + }.from(nil).to(be_a(String)) + .and change { + person.default_with_serializer_encrypted + }.from(nil).to(be_a(String)) + end + end + end end From c0965059e21235d88bdae659016bc0e412b0994d Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Fri, 24 Oct 2025 17:29:10 -0600 Subject: [PATCH 2/6] Update ruby/setup-ruby --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index a8a173f..2aebea4 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -16,7 +16,7 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4.0.0 - - uses: ruby/setup-ruby@250fcd6a742febb1123a77a841497ccaa8b9e939 # v1.152.0 + - uses: ruby/setup-ruby@ab177d40ee5483edb974554986f56b33477e21d0 # v1.265.0 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true # runs 'bundle install' and caches installed gems automatically From 58515ad533397fdcf09b2c4df3de10b99835550e Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Fri, 24 Oct 2025 17:32:29 -0600 Subject: [PATCH 3/6] concurrent-ruby v1.3.5 removed the dependency on logger --- Gemfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Gemfile b/Gemfile index 9920bb1..c016c75 100644 --- a/Gemfile +++ b/Gemfile @@ -6,6 +6,7 @@ source "https://rubygems.org" RAILS_VERSION = ENV.fetch("RAILS_VERSION", "6.0.0") gem "rails", "~> #{RAILS_VERSION}" +gem "concurrent-ruby", "1.3.4" if RAILS_VERSION.start_with?("6") gem "sqlite3", "~> 1.4" end From 82d8b421ad090b7eea8084bc801dff5b9ac38826 Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Thu, 30 Oct 2025 07:00:21 -0600 Subject: [PATCH 4/6] revise tested versions of ruby/vault --- .github/workflows/run-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 2aebea4..90b16f1 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -10,8 +10,8 @@ jobs: fail-fast: false matrix: # https://endoflife.date/ruby - ruby: ["2.7", "3.0", "3.1", "3.2"] - vault: ["1.11.9", "1.12.5", "1.13.1"] + ruby: ["2.7", "3.0", "3.1", "3.3"] + vault: ["1.11.9", "1.19.5", "1.20.4", "1.21.0"] runs-on: ubuntu-latest timeout-minutes: 5 steps: From c06c8ae4e58693ebd7d863d94a4b0469a0659501 Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Thu, 30 Oct 2025 07:22:23 -0600 Subject: [PATCH 5/6] Update README.md --- README.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5553fda..2034b05 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,19 @@ So for the example above, the key would be: my_app_people_ssn +### Encrypting without Saving +Normally, vault-rails will wait until the after_save callback to encrypt changed +values before updating them. If you'd like to encrypt changed attributes without +saving, call `vault_encrypt_attributes!` + +```ruby +p = Person.new(ssn: "123-45-6789") +p.ssn_encrypted +> nil +p.vault_encrypt_attributes! +p.ssn_encrypted +> "vault:dev:flu/yp9oeYYFgjcZH2hVBA==" +``` ### Searching Encrypted Attributes Because each column is uniquely encrypted, it is not possible to search for a @@ -345,7 +358,6 @@ Person.where(ssn: "123-45-6789") This is because the database is unaware of the plain-text data (which is part of the security model). - Development ----------- ↥ [back to top](#table-of-contents) From 5dcb8c56f447efd1bff23c07a49f98cf5abb9ab7 Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Tue, 4 Nov 2025 13:27:39 -0700 Subject: [PATCH 6/6] clarifying comments and documentation --- Gemfile | 5 +++++ README.md | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index c016c75..1f45a4c 100644 --- a/Gemfile +++ b/Gemfile @@ -6,7 +6,12 @@ source "https://rubygems.org" RAILS_VERSION = ENV.fetch("RAILS_VERSION", "6.0.0") gem "rails", "~> #{RAILS_VERSION}" + +# Rails versions before 7.1 have a dependency on concurrent-ruby but +# we need to pin to 1.3.4 because later versions removed a dependency on Logger +# that we need to start tests. gem "concurrent-ruby", "1.3.4" + if RAILS_VERSION.start_with?("6") gem "sqlite3", "~> 1.4" end diff --git a/README.md b/README.md index 2034b05..aec54e4 100644 --- a/README.md +++ b/README.md @@ -340,10 +340,12 @@ saving, call `vault_encrypt_attributes!` ```ruby p = Person.new(ssn: "123-45-6789") p.ssn_encrypted -> nil +=> nil p.vault_encrypt_attributes! p.ssn_encrypted -> "vault:dev:flu/yp9oeYYFgjcZH2hVBA==" +=> "vault:dev:flu/yp9oeYYFgjcZH2hVBA==" +p.persisted? +=> false ``` ### Searching Encrypted Attributes