diff --git a/app/models/document_capture_session.rb b/app/models/document_capture_session.rb index a27f25ae1a7..080d0246e74 100644 --- a/app/models/document_capture_session.rb +++ b/app/models/document_capture_session.rb @@ -4,36 +4,42 @@ class DocumentCaptureSession < ApplicationRecord belongs_to :user def load_result - DocumentCaptureSessionResult.load(result_id) + EncryptedRedisStructStorage.load(result_id, type: DocumentCaptureSessionResult) end def load_proofing_result - ProofingDocumentCaptureSessionResult.load(result_id) + EncryptedRedisStructStorage.load(result_id, type: ProofingDocumentCaptureSessionResult) end def store_result_from_response(doc_auth_response) - DocumentCaptureSessionResult.store( - id: generate_result_id, - success: doc_auth_response.success?, - pii: doc_auth_response.pii_from_doc, + EncryptedRedisStructStorage.store( + DocumentCaptureSessionResult.new( + id: generate_result_id, + success: doc_auth_response.success?, + pii: doc_auth_response.pii_from_doc, + ), ) save! end def store_proofing_pii_from_doc(pii_from_doc) - ProofingDocumentCaptureSessionResult.store( - id: generate_result_id, - pii: pii_from_doc, - result: nil, + EncryptedRedisStructStorage.store( + ProofingDocumentCaptureSessionResult.new( + id: generate_result_id, + pii: pii_from_doc, + result: nil, + ), ) save! end def store_proofing_result(pii_from_doc, result) - ProofingDocumentCaptureSessionResult.store( - id: result_id, - pii: pii_from_doc, - result: result, + EncryptedRedisStructStorage.store( + ProofingDocumentCaptureSessionResult.new( + id: result_id, + pii: pii_from_doc, + result: result, + ), ) end diff --git a/app/services/document_capture_session_result.rb b/app/services/document_capture_session_result.rb index 6686a074c3e..fc903f1bae8 100644 --- a/app/services/document_capture_session_result.rb +++ b/app/services/document_capture_session_result.rb @@ -1,62 +1,10 @@ -class DocumentCaptureSessionResult - REDIS_KEY_PREFIX = 'dcs:result'.freeze +# frozen_string_literal: true - attr_reader :id, :success, :pii - - alias success? success - alias pii_from_doc pii - - class << self - def load(id) - ciphertext = REDIS_POOL.with { |client| client.read(key(id)) } - return nil if ciphertext.blank? - decrypt_and_deserialize(id, ciphertext) - end - - def store(id:, success:, pii:) - result = new(id: id, success: success, pii: pii) - REDIS_POOL.with do |client| - client.write(key(id), result.serialize_and_encrypt, expires_in: 60) - end - end - - def key(id) - [REDIS_KEY_PREFIX, id].join(':') - end - - private - - def decrypt_and_deserialize(id, ciphertext) - deserialize( - id, - Encryption::Encryptors::SessionEncryptor.new.decrypt(ciphertext), - ) - end - - def deserialize(id, json) - data = JSON.parse(json) - new( - id: id, - success: data['success'], - pii: data['pii'], - ) - end - end - - def initialize(id:, success:, pii:) - @id = id - @success = success - @pii = pii +DocumentCaptureSessionResult = Struct.new(:id, :success, :pii, keyword_init: true) do + def self.redis_key_prefix + 'dcs:result' end - def serialize - { - success: success, - pii: pii, - }.to_json - end - - def serialize_and_encrypt - Encryption::Encryptors::SessionEncryptor.new.encrypt(serialize) - end + alias_method :success?, :success + alias_method :pii_from_doc, :pii end diff --git a/app/services/encrypted_redis_struct_storage.rb b/app/services/encrypted_redis_struct_storage.rb new file mode 100644 index 00000000000..ac8d50fbe42 --- /dev/null +++ b/app/services/encrypted_redis_struct_storage.rb @@ -0,0 +1,75 @@ +# Use this class to store a plain Struct in redis. It will be stored +# encrypted and by default will expire, the struct must have a +redis_key_prefix+ +# class method +# +# @example +# MyStruct = Struct.new(:id, :a, :b) do +# def self.redis_key_prefix +# 'mystruct' +# end +# end +# +# struct = MyStruct.new('id123', 'a', 'b') +# +# EncryptedRedisStructStorage.store(struct) +# s = EncryptedRedisStructStorage.load('id123', type: MyStruct) +module EncryptedRedisStructStorage + module_function + + def load(id, type:) + check_for_id_property!(type) + + ciphertext = REDIS_POOL.with { |client| client.read(key(id, type: type)) } + return nil if ciphertext.blank? + + json = Encryption::Encryptors::SessionEncryptor.new.decrypt(ciphertext) + data = JSON.parse(json) + type.new.tap do |struct| + struct.id = id + init_fields(struct: struct, data: data) + end + end + + def store(struct, expires_in: 60) + check_for_id_property!(struct.class) + check_for_empty_id!(struct.id) + + payload = struct.as_json + payload.delete('id') + + REDIS_POOL.with do |client| + client.write( + key(struct.id, type: struct.class), + Encryption::Encryptors::SessionEncryptor.new.encrypt(payload.to_json), + expires_in: expires_in, + ) + end + end + + def key(id, type:) + if type.respond_to?(:redis_key_prefix) + [type.redis_key_prefix, id].join(':') + else + raise "#{self} expected #{type.name} to have defined class method redis_key_prefix" + end + end + + # Assigns member fields from a hash. That way, it doesn't matter + # if a Struct was created with keyword_init or not (and we can't currently + # reflect on that field) + # @param [Hash] data + def init_fields(struct:, data:) + data.each do |key, value| + struct[key] = value + end + end + + def check_for_id_property!(type) + return if type.members.include?(:id) + raise "#{self} expected #{type.name} to have an id property" + end + + def check_for_empty_id!(id) + raise ArgumentError, 'id cannot be empty' if id.blank? + end +end diff --git a/app/services/proofing_document_capture_session_result.rb b/app/services/proofing_document_capture_session_result.rb index d5cd829bb32..7fb1dc87cce 100644 --- a/app/services/proofing_document_capture_session_result.rb +++ b/app/services/proofing_document_capture_session_result.rb @@ -1,59 +1,7 @@ -class ProofingDocumentCaptureSessionResult - REDIS_KEY_PREFIX = 'dcs-proofing:result'.freeze +# frozen_string_literal: true - attr_reader :id, :pii, :result - - class << self - def load(id) - ciphertext = REDIS_POOL.with { |client| client.read(key(id)) } - return nil if ciphertext.blank? - decrypt_and_deserialize(id, ciphertext) - end - - def store(id:, pii:, result:) - result = new(id: id, pii: pii, result: result) - REDIS_POOL.with do |client| - client.write(key(id), result.serialize_and_encrypt, expires_in: 60) - end - end - - def key(id) - [REDIS_KEY_PREFIX, id].join(':') - end - - private - - def decrypt_and_deserialize(id, ciphertext) - deserialize( - id, - Encryption::Encryptors::SessionEncryptor.new.decrypt(ciphertext), - ) - end - - def deserialize(id, json) - data = JSON.parse(json) - new( - id: id, - pii: data['pii'], - result: data['result'], - ) - end - end - - def initialize(id:, pii:, result:) - @id = id - @pii = pii - @result = result - end - - def serialize - { - pii: pii, - result: result, - }.to_json - end - - def serialize_and_encrypt - Encryption::Encryptors::SessionEncryptor.new.encrypt(serialize) +ProofingDocumentCaptureSessionResult = Struct.new(:id, :pii, :result, keyword_init: true) do + def self.redis_key_prefix + 'dcs-proofing:result' end end diff --git a/spec/models/document_capture_session_spec.rb b/spec/models/document_capture_session_spec.rb index 1a7f5bddb00..3763afa15ca 100644 --- a/spec/models/document_capture_session_spec.rb +++ b/spec/models/document_capture_session_spec.rb @@ -18,7 +18,8 @@ record.store_result_from_response(doc_auth_response) result_id = record.result_id - data = REDIS_POOL.with { |client| client.read(DocumentCaptureSessionResult.key(result_id)) } + key = EncryptedRedisStructStorage.key(result_id, type: DocumentCaptureSessionResult) + data = REDIS_POOL.with { |client| client.read(key) } expect(data).to be_a(String) expect(data).to_not include('Testy') expect(data).to_not include('Testerson') diff --git a/spec/services/document_capture_session_result_spec.rb b/spec/services/document_capture_session_result_spec.rb index 25cf8e260e5..5a19dd8f338 100644 --- a/spec/services/document_capture_session_result_spec.rb +++ b/spec/services/document_capture_session_result_spec.rb @@ -5,40 +5,16 @@ let(:success) { true } let(:pii) { { 'first_name' => 'Testy', 'last_name' => 'Testerson' } } - describe '.key' do - it 'generates a key' do - key = DocumentCaptureSessionResult.key(id) - expect(key).to eq('dcs:result:' + id) - end - end - - describe '.store' do - it 'writes encrypted data to redis' do - DocumentCaptureSessionResult.store(id: id, success: success, pii: pii) + context 'EncryptedRedisStructStorage' do + it 'works with EncryptedRedisStructStorage' do + result = DocumentCaptureSessionResult.new(id: id, success: success, pii: pii) - data = REDIS_POOL.with { |client| client.read(DocumentCaptureSessionResult.key(id)) } - - expect(data).to be_a(String) - expect(data).to_not include('Testy') - expect(data).to_not include('Testerson') - end - end - - describe '.load' do - it 'reads the unloaded result from the session' do - DocumentCaptureSessionResult.store(id: id, success: success, pii: pii) - - loaded_result = DocumentCaptureSessionResult.load(id) + EncryptedRedisStructStorage.store(result) + loaded_result = EncryptedRedisStructStorage.load(id, type: DocumentCaptureSessionResult) expect(loaded_result.id).to eq(id) expect(loaded_result.success?).to eq(success) expect(loaded_result.pii).to eq(pii) end - - it 'returns nil if no data exists in redis' do - loaded_result = DocumentCaptureSessionResult.load(SecureRandom.uuid) - - expect(loaded_result).to eq(nil) - end end end diff --git a/spec/services/encrypted_redis_struct_storage_spec.rb b/spec/services/encrypted_redis_struct_storage_spec.rb new file mode 100644 index 00000000000..abcf341e912 --- /dev/null +++ b/spec/services/encrypted_redis_struct_storage_spec.rb @@ -0,0 +1,147 @@ +require 'rails_helper' + +RSpec.describe EncryptedRedisStructStorage do + let(:id) { SecureRandom.uuid } + + let(:struct_class) do + Struct.new(:id, :a, :b, :c, keyword_init: true) do + def self.redis_key_prefix + 'example:prefix' + end + end + end + + describe '.key' do + subject(:key) { EncryptedRedisStructStorage.key(id, type: struct_class) } + + context 'with a struct that has a redis_key_prefix' do + it 'prefixes the id' do + expect(key).to eq("example:prefix:#{id}") + end + end + + context 'with a struct that does not have a redis_key_prefix' do + let(:struct_class) do + Struct.new(:id, :a, :b, :c) + end + + it 'raises an error with a message describing what to define' do + expect { key }.to raise_error(/to have defined class method redis_key_prefix/) + end + end + end + + describe '.load' do + subject(:load_struct) { EncryptedRedisStructStorage.load(id, type: struct_class) } + + it 'returns nil if no data exists' do + expect(load_struct).to eq(nil) + end + + context 'with an empty id' do + let(:id) { '' } + + it 'is nil' do + expect(load_struct).to eq(nil) + end + end + + context 'with a keyword init struct' do + it 'loads the value out of redis' do + EncryptedRedisStructStorage.store(struct_class.new(id: id, a: 'a', b: 'b', c: 'c')) + + loaded_result = load_struct + + expect(loaded_result.a).to eq('a') + expect(loaded_result.b).to eq('b') + expect(loaded_result.c).to eq('c') + end + end + + context 'with an ordered initializer struct' do + let(:struct_class) do + Struct.new(:id, :d, :e, :f, keyword_init: false) do + def self.redis_key_prefix + 'abcdef' + end + end + end + + it 'loads the value out of redis' do + EncryptedRedisStructStorage.store( + struct_class.new(id, 'd', 'e', 'f') + ) + + loaded_result = load_struct + + expect(loaded_result.d).to eq('d') + expect(loaded_result.e).to eq('e') + expect(loaded_result.f).to eq('f') + end + end + + context 'with a struct that does not have a redis_key_prefix' do + let(:struct_class) do + Struct.new(:id, :a, :b, :c) + end + + it 'raises an error with a message describing what to define' do + expect { load_struct }.to raise_error(/to have defined class method redis_key_prefix/) + end + end + end + + describe '.store' do + context 'with a struct that does not have an id method' do + let(:struct_class) do + Struct.new(:a, :b, :c) + end + + it 'throws an error describing the missing method' do + expect do + EncryptedRedisStructStorage.store(struct_class.new(a: 'a', b: 'b')) + end.to raise_error(/to have an id property/) + end + end + + context 'with a struct that has an id' do + context 'with an empty id' do + let(:id) { '' } + + it 'errors' do + expect { EncryptedRedisStructStorage.store(struct_class.new) }. + to raise_error(ArgumentError, 'id cannot be empty') + end + end + + it 'writes encrypted data to redis' do + EncryptedRedisStructStorage.store( + struct_class.new(id: id, a: 'value for a', b: 'value for b', c: 'value for c'), + ) + + data = REDIS_POOL.with do |client| + client.read(EncryptedRedisStructStorage.key(id, type: struct_class)) + end + + expect(data).to be_a(String) + expect(data).to_not include('value for a') + expect(data).to_not include('value for b') + expect(data).to_not include('value for c') + end + + it 'stores the value with a ttl (expiration)' do + EncryptedRedisStructStorage.store( + struct_class.new(id: id, a: 'value for a', b: 'value for b', c: 'value for c'), + ) + + ttl = REDIS_POOL.with do |client| + client.pool.with do |redis| + redis.ttl(EncryptedRedisStructStorage.key(id, type: struct_class)) + end + end + + expect(ttl).to be <= 60 + end + end + end +end diff --git a/spec/services/proofing_document_capture_session_result_spec.rb b/spec/services/proofing_document_capture_session_result_spec.rb new file mode 100644 index 00000000000..f677c0d3850 --- /dev/null +++ b/spec/services/proofing_document_capture_session_result_spec.rb @@ -0,0 +1,24 @@ +require 'rails_helper' + +RSpec.describe ProofingDocumentCaptureSessionResult do + let(:id) { SecureRandom.uuid } + let(:pii) { { 'first_name' => 'Testy', 'last_name' => 'Testerson' } } + let(:idv_result) { { errors: {}, messages: ['some message'] } } + + context 'EncryptedRedisStructStorage' do + it 'works with EncryptedRedisStructStorage' do + result = ProofingDocumentCaptureSessionResult.new(id: id, pii: pii, result: idv_result) + + EncryptedRedisStructStorage.store(result) + + loaded_result = EncryptedRedisStructStorage.load( + id, type: ProofingDocumentCaptureSessionResult + ) + + expect(loaded_result.id).to eq(id) + expect(loaded_result.pii).to eq(pii) + + expect(loaded_result.result.deep_symbolize_keys!).to eq(idv_result) + end + end +end