Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 20 additions & 14 deletions app/models/document_capture_session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
64 changes: 6 additions & 58 deletions app/services/document_capture_session_result.rb
Original file line number Diff line number Diff line change
@@ -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
75 changes: 75 additions & 0 deletions app/services/encrypted_redis_struct_storage.rb
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest to don't ask if the type respond to redis_key_prefix. IMO it would be programmatic error[1] by failing to follow the contract, and let it fall with something similar to this:

NoMethodError (undefined method `redis_key_prefix' for DocumentCaptureSessionResult:Class)

Is not as nice as the descriptive raise on line 53, but close enough to see where it's failing.

[1] Compared to user error.

[type.redis_key_prefix, id].join(':')
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method would create an array + string on every single call.

Suggested change
[type.redis_key_prefix, id].join(':')
type.redis_key_prefix + ":" id
# or
# "#{type.redis_key_prefix}:#{id}"

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
60 changes: 4 additions & 56 deletions app/services/proofing_document_capture_session_result.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion spec/models/document_capture_session_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
34 changes: 5 additions & 29 deletions spec/services/document_capture_session_result_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading