-
Notifications
You must be signed in to change notification settings - Fork 53
/
Copy pathencrypted_model.rb
410 lines (348 loc) · 14.4 KB
/
encrypted_model.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: MPL-2.0
require "active_support/concern"
module Vault
module EncryptedModel
extend ActiveSupport::Concern
module ClassMethods
# Creates an attribute that is read and written using Vault.
#
# @example
#
# class Person < ActiveRecord::Base
# include Vault::EncryptedModel
# vault_attribute :ssn
# end
#
# person = Person.new
# person.ssn = "123-45-6789"
# person.save
# person.encrypted_ssn #=> "vault:v0:6hdPkhvyL6..."
#
# @param [Symbol] column
# the column that is encrypted
# @param [Hash] options
#
# @option options [Symbol] :encrypted_column
# the name of the encrypted column (default: +#{column}_encrypted+)
# @option options [String] :path
# the path to the transit backend (default: +transit+)
# @option options [String] :key
# the name of the encryption key (default: +#{app}_#{table}_#{column}+)
# @option options [String, Symbol, Proc] :context
# either a string context, or a symbol or proc used to generate a
# context for key generation
# @option options [Object] :default
# a default value for this attribute to be set to if the underlying
# value is nil
# @option options [Symbol, Class] :serializer
# the name of the serializer to use (or a class)
# @option options [Proc] :encode
# a proc to encode the value with
# @option options [Proc] :decode
# a proc to decode the value with
# @option options [Hash] :transform_secret
# a hash providing details about the transformation to use,
# this includes the name, and the role to use
def vault_attribute(attribute, options = {})
# Sanity check options!
_vault_validate_options!(options)
parsed_opts = if options[:transform_secret]
parse_transform_secret_attributes(attribute, options)
else
parse_transit_attributes(attribute, options)
end
parsed_opts[:encrypted_column] = options[:encrypted_column] || "#{attribute}_encrypted"
# Make a note of this attribute so we can use it in the future (maybe).
__vault_attributes[attribute.to_sym] = parsed_opts
self.attribute attribute.to_s, ActiveRecord::Type::Value.new,
default: nil
# Getter
define_method("#{attribute}") do
self.__vault_load_attributes!(attribute) unless @__vault_loaded
super()
end
# Setter
define_method("#{attribute}=") do |value|
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
# that if you call attr=, you want it sent back regardless.
attribute_will_change!("#{attribute}")
instance_variable_set("@#{attribute}", value)
super(value)
# Return the value to be consistent with other AR methods.
value
end
# Checker
define_method("#{attribute}?") do
self.__vault_load_attributes!(attribute) unless @__vault_loaded
instance_variable_get("@#{attribute}").present?
end
self
end
# The list of Vault attributes.
#
# @return [Hash]
def __vault_attributes
@vault_attributes ||= {}
end
# Validate that Vault options are all a-okay! This method will raise
# exceptions if something does not make sense.
def _vault_validate_options!(options)
if options[:serializer]
if options[:encode] || options[:decode]
raise Vault::Rails::ValidationFailedError, "Cannot use a " \
"custom encoder/decoder if a `:serializer' is specified!"
end
if options[:transform_secret]
raise Vault::Rails::ValidationFailedError, "Cannot use the " \
"transform secrets engine with a specified `:serializer'!"
end
end
if options[:encode] && !options[:decode]
raise Vault::Rails::ValidationFailedError, "Cannot specify " \
"`:encode' without specifying `:decode' as well!"
end
if options[:decode] && !options[:encode]
raise Vault::Rails::ValidationFailedError, "Cannot specify " \
"`:decode' without specifying `:encode' as well!"
end
if context = options[:context]
if context.is_a?(Proc) && context.arity != 1
raise Vault::Rails::ValidationFailedError, "Proc passed to " \
"`:context' must take 1 argument!"
end
end
if transform_opts = options[:transform_secret]
if !transform_opts[:transformation]
raise Vault::Rails::VaildationFailedError, "Transform Secrets " \
"requires a transformation name!"
end
end
end
def vault_lazy_decrypt
@vault_lazy_decrypt ||= false
end
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
private
def parse_transform_secret_attributes(attribute, options)
opts = {}
opts[:transform_secret] = true
serializer = Class.new
serializer.define_singleton_method(:encode) do |raw|
return if raw.nil?
resp = Vault::Rails.transform_encode(raw, options[:transform_secret])
resp.dig(:data, :encoded_value)
end
serializer.define_singleton_method(:decode) do |raw|
return if raw.nil?
resp = Vault::Rails.transform_decode(raw, options[:transform_secret])
resp.dig(:data, :decoded_value)
end
opts[:serializer] = serializer
opts
end
def parse_transit_attributes(attribute, options)
opts = {}
opts[:path] = options[:path] || "transit"
opts[:key] = options[:key] || "#{Vault::Rails.application}_#{table_name}_#{attribute}"
opts[:context] = options[:context]
opts[:default] = options[:default]
# Get the serializer if one was given.
serializer = options[:serialize]
# Unless a class or module was given, construct our serializer. (Slass
# is a subset of Module).
if serializer && !serializer.is_a?(Module)
serializer = Vault::Rails.serializer_for(serializer)
end
# See if custom encoding or decoding options were given.
if options[:encode] && options[:decode]
serializer = Class.new
serializer.define_singleton_method(:encode, &options[:encode])
serializer.define_singleton_method(:decode, &options[:decode])
end
opts[:serializer] = serializer
opts
end
end
included do
# After a resource has been initialized, immediately communicate with
# Vault and decrypt any attributes unless vault_lazy_decrypt is set.
after_initialize :__vault_initialize_attributes!
# After we save the record, persist all the values to Vault and reload
# them attributes from Vault to ensure we have the proper attributes set.
# The reason we use `after_save` here is because a `before_save` could
# run too early in the callback process. If a user is changing Vault
# attributes in a callback, it is possible that our callback will run
# before theirs, resulting in attributes that are not persisted.
after_save :__vault_persist_attributes!
# Before destroying a record, ensure that all vault attributes have been
# decrypted. Otherwise, attempting to read a lazily decrypted attribute
# after a destroy will result in a "Can't modify frozen Hash" error when
# vault-rails attempts to set the plain-text attribute.
before_destroy :__vault_load_attributes!, unless: -> { @__vault_loaded }
# Decrypt all the attributes from Vault.
# @return [true]
def __vault_initialize_attributes!
if self.class.vault_lazy_decrypt
@__vault_loaded = false
return
end
__vault_load_attributes!
end
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 = self.class.__vault_attributes.all? { |attribute, __| instance_variable_defined?("@#{attribute}") }
return true
end
# Decrypt and load a single attribute from Vault.
def __vault_load_attribute!(attribute, options)
key = options[:key]
path = options[:path]
serializer = options[:serializer]
column = options[:encrypted_column]
context = options[:context]
default = options[:default]
transform = options[:transform_secret]
# Load the ciphertext
ciphertext = read_attribute(column)
# If the user provided a value for the attribute, do not try to load
# it from Vault
if attributes[attribute.to_s]
return
end
# Generate context if needed
generated_context = __vault_generate_context(context)
if transform
# If this is a secret encrypted with FPE, we do not need to decrypt with vault
# This prevents a double encryption via standard vault encryption and FPE.
# FPE is decrypted later as part of the serializer
plaintext = ciphertext
else
# Load the plaintext value
plaintext = Vault::Rails.decrypt(
path, key, ciphertext,
context: generated_context
)
end
# Deserialize the plaintext value, if a serializer exists
if serializer
plaintext = serializer.decode(plaintext)
end
# Set to default if needed
if default && plaintext == nil
plaintext = default
end
# Write the virtual attribute with the plaintext value
instance_variable_set("@#{attribute}", plaintext)
@attributes.write_from_database attribute.to_s, plaintext
end
# Encrypt all the attributes using Vault and set the encrypted values back
# on this model.
# @return [true]
def __vault_persist_attributes!
changes = {}
self.class.__vault_attributes.each do |attribute, options|
if c = self.__vault_persist_attribute!(attribute, options)
changes.merge!(c)
end
end
# If there are any changes to the model, update them all at once,
# skipping any callbacks and validation. This is okay, because we are
# already in a transaction due to the callback.
if !changes.empty?
self.update_columns(changes)
end
return true
end
# 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]
# Only persist changed attributes to minimize requests - this helps
# minimize the number of requests to Vault.
if ActiveRecord.gem_version >= Gem::Version.new("6.0")
return unless previous_changes.include?(attribute)
elsif ActiveRecord.gem_version >= Gem::Version.new("5.2")
return unless previous_changes_include?(attribute)
elsif ActiveRecord.gem_version >= Gem::Version.new("5.1")
return unless saved_change_to_attribute?(attribute.to_s)
else
return unless attribute_changed?(attribute)
end
# Get the current value of the plaintext attribute
plaintext = attributes[attribute.to_s]
# Apply the serialize to the plaintext value, if one exists
if serializer
plaintext = serializer.encode(plaintext)
end
# Generate context if needed
generated_context = __vault_generate_context(context)
if transform
# If this is a secret encrypted with FPE, we should not encrypt it in vault
# This prevents a double encryption via standard vault encryption and FPE.
# FPE was performed earlier as part of the serialization process.
ciphertext = plaintext
else
# Generate the ciphertext and store it back as an attribute
ciphertext = Vault::Rails.encrypt(
path, key, plaintext,
context: generated_context
)
end
# Write the attribute back, so that we don't have to reload the record
# to get the ciphertext
write_attribute(column, ciphertext)
# Return the updated column so we can save
{ column => ciphertext }
end
# Generates an Vault Transit encryption context for use on derived keys.
def __vault_generate_context(context)
case context
when String
context
when Symbol
send(context)
when Proc
context.call(self)
else
nil
end
end
# Override the reload method to reload the Vault attributes. This will
# ensure that we always have the most recent data from Vault when we
# reload a record from the database.
def reload(*)
super.tap do
# Unset all the instance variables to force the new data to be pulled
# from Vault
self.class.__vault_attributes.each do |attribute, _|
self.instance_variable_set("@#{attribute}", nil)
@attributes.write_from_database attribute.to_s, nil
end
self.__vault_initialize_attributes!
end
end
end
end
end