Skip to content

Commit cb7efac

Browse files
authored
Encrypt attribute values manually without needing save callback (#150)
* 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==" * Update ruby/setup-ruby * concurrent-ruby v1.3.5 removed the dependency on logger * revise tested versions of ruby/vault * Update README.md * clarifying comments and documentation
1 parent 0ddf4c5 commit cb7efac

File tree

5 files changed

+88
-12
lines changed

5 files changed

+88
-12
lines changed

.github/workflows/run-tests.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ jobs:
1010
fail-fast: false
1111
matrix:
1212
# https://endoflife.date/ruby
13-
ruby: ["2.7", "3.0", "3.1", "3.2"]
14-
vault: ["1.11.9", "1.12.5", "1.13.1"]
13+
ruby: ["2.7", "3.0", "3.1", "3.3"]
14+
vault: ["1.11.9", "1.19.5", "1.20.4", "1.21.0"]
1515
runs-on: ubuntu-latest
1616
timeout-minutes: 5
1717
steps:
1818
- uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4.0.0
19-
- uses: ruby/setup-ruby@250fcd6a742febb1123a77a841497ccaa8b9e939 # v1.152.0
19+
- uses: ruby/setup-ruby@ab177d40ee5483edb974554986f56b33477e21d0 # v1.265.0
2020
with:
2121
ruby-version: ${{ matrix.ruby }}
2222
bundler-cache: true # runs 'bundle install' and caches installed gems automatically

Gemfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ source "https://rubygems.org"
66
RAILS_VERSION = ENV.fetch("RAILS_VERSION", "6.0.0")
77

88
gem "rails", "~> #{RAILS_VERSION}"
9+
10+
# Rails versions before 7.1 have a dependency on concurrent-ruby but
11+
# we need to pin to 1.3.4 because later versions removed a dependency on Logger
12+
# that we need to start tests.
13+
gem "concurrent-ruby", "1.3.4"
14+
915
if RAILS_VERSION.start_with?("6")
1016
gem "sqlite3", "~> 1.4"
1117
end

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,21 @@ So for the example above, the key would be:
332332
333333
my_app_people_ssn
334334
335+
### Encrypting without Saving
336+
Normally, vault-rails will wait until the after_save callback to encrypt changed
337+
values before updating them. If you'd like to encrypt changed attributes without
338+
saving, call `vault_encrypt_attributes!`
339+
340+
```ruby
341+
p = Person.new(ssn: "123-45-6789")
342+
p.ssn_encrypted
343+
=> nil
344+
p.vault_encrypt_attributes!
345+
p.ssn_encrypted
346+
=> "vault:dev:flu/yp9oeYYFgjcZH2hVBA=="
347+
p.persisted?
348+
=> false
349+
```
335350

336351
### Searching Encrypted Attributes
337352
Because each column is uniquely encrypted, it is not possible to search for a
@@ -345,7 +360,6 @@ Person.where(ssn: "123-45-6789")
345360
This is because the database is unaware of the plain-text data (which is part of
346361
the security model).
347362

348-
349363
Development
350364
-----------
351365
↥ [back to top](#table-of-contents)

lib/vault/encrypted_model.rb

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -325,12 +325,7 @@ def __vault_persist_attributes!
325325
# Encrypt a single attribute using Vault and persist back onto the
326326
# encrypted attribute value.
327327
def __vault_persist_attribute!(attribute, options)
328-
key = options[:key]
329-
path = options[:path]
330-
serializer = options[:serializer]
331-
column = options[:encrypted_column]
332-
context = options[:context]
333-
transform = options[:transform_secret]
328+
column = options[:encrypted_column]
334329

335330
# Only persist changed attributes to minimize requests - this helps
336331
# minimize the number of requests to Vault.
@@ -346,6 +341,19 @@ def __vault_persist_attribute!(attribute, options)
346341

347342
# Get the current value of the plaintext attribute
348343
plaintext = attributes[attribute.to_s]
344+
ciphertext = __vault_write_encrypted_attribute!(plaintext, options)
345+
346+
# Return the updated column so we can save
347+
{ column => ciphertext }
348+
end
349+
350+
def __vault_write_encrypted_attribute!(plaintext, options)
351+
column = options[:encrypted_column]
352+
key = options[:key]
353+
path = options[:path]
354+
serializer = options[:serializer]
355+
context = options[:context]
356+
transform = options[:transform_secret]
349357

350358
# Apply the serialize to the plaintext value, if one exists
351359
if serializer
@@ -372,8 +380,7 @@ def __vault_persist_attribute!(attribute, options)
372380
# to get the ciphertext
373381
write_attribute(column, ciphertext)
374382

375-
# Return the updated column so we can save
376-
{ column => ciphertext }
383+
ciphertext
377384
end
378385

379386
# Generates an Vault Transit encryption context for use on derived keys.
@@ -405,6 +412,18 @@ def reload(*)
405412
self.__vault_initialize_attributes!
406413
end
407414
end
415+
416+
def vault_encrypt_attributes!
417+
self.class.__vault_attributes.each do |attribute, options|
418+
next if !attribute_changed?(attribute) && options[:default].nil?
419+
420+
# Get the current value of the plaintext attribute
421+
plaintext = attributes[attribute.to_s]
422+
423+
__vault_write_encrypted_attribute!(plaintext, options)
424+
end
425+
self
426+
end
408427
end
409428
end
410429
end

spec/integration/rails_spec.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,4 +746,41 @@
746746
expect(person.credit_card).to eq("1234567890111213")
747747
end
748748
end
749+
750+
context "manual encryption" do
751+
describe "#vault_encrypt_attributes!" do
752+
it "encrypts vault attributes without saving" do
753+
person = Person.new(ssn: "123-45-6789", favorite_color: "green")
754+
expect {
755+
person.vault_encrypt_attributes!
756+
}.to change {
757+
person.ssn_encrypted
758+
}.from(nil).to(be_a(String))
759+
760+
expect(person.favorite_color).to eq("green")
761+
expect(person.favorite_color_encrypted).to be_present
762+
end
763+
764+
it "returns self" do
765+
person = Person.new
766+
result = person.vault_encrypt_attributes!
767+
expect(result).to be(person)
768+
end
769+
770+
it "encrypts attributes with a default option" do
771+
person = Person.new
772+
expect(person.default).to eq("abc123")
773+
expect(person.default_with_serializer).to eq({})
774+
775+
expect {
776+
person.vault_encrypt_attributes!
777+
}.to change {
778+
person.default_encrypted
779+
}.from(nil).to(be_a(String))
780+
.and change {
781+
person.default_with_serializer_encrypted
782+
}.from(nil).to(be_a(String))
783+
end
784+
end
785+
end
749786
end

0 commit comments

Comments
 (0)