diff --git a/app/jobs/resolution_proofing_job.rb b/app/jobs/resolution_proofing_job.rb index a0959d4f7bc..44401a8914e 100644 --- a/app/jobs/resolution_proofing_job.rb +++ b/app/jobs/resolution_proofing_job.rb @@ -73,6 +73,16 @@ def perform( device_profiling_success: callback_log_data&.device_profiling_success, timing: timer.results, ) + + if IdentityConfig.store.idv_socure_shadow_mode_enabled + SocureShadowModeProofingJob.perform_later( + document_capture_session_result_id: document_capture_session.result_id, + encrypted_arguments:, + service_provider_issuer:, + user_email: user_email_for_proofing(user), + user_uuid: user.uuid, + ) + end end private @@ -89,7 +99,7 @@ def make_vendor_proofing_requests( ) result = progressive_proofer.proof( applicant_pii: applicant_pii, - user_email: user.confirmed_email_addresses.first.email, + user_email: user_email_for_proofing(user), threatmetrix_session_id: threatmetrix_session_id, request_ip: request_ip, ipp_enrollment_in_progress: ipp_enrollment_in_progress, @@ -109,6 +119,10 @@ def make_vendor_proofing_requests( ) end + def user_email_for_proofing(user) + user.confirmed_email_addresses.first.email + end + def log_threatmetrix_info(threatmetrix_result, user) logger_info_hash( name: 'ThreatMetrix', diff --git a/app/jobs/socure_shadow_mode_proofing_job.rb b/app/jobs/socure_shadow_mode_proofing_job.rb new file mode 100644 index 00000000000..fc39a3280c2 --- /dev/null +++ b/app/jobs/socure_shadow_mode_proofing_job.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +class SocureShadowModeProofingJob < ApplicationJob + include JobHelpers::StaleJobHelper + + queue_as :low + + discard_on JobHelpers::StaleJobHelper::StaleJobError + + # @param [String] document_capture_session_result_id + # @param [String] encrypted_arguments + # @param [String,nil] service_provider_issuer + # @param [String] user_email + # @param [String] user_uuid + def perform( + document_capture_session_result_id:, + encrypted_arguments:, + service_provider_issuer:, + user_email:, + user_uuid: + ) + raise_stale_job! if stale_job?(enqueued_at) + + user = User.find_by(uuid: user_uuid) + raise "User not found: #{user_uuid}" if !user + + analytics = create_analytics( + user:, + service_provider_issuer:, + ) + + proofing_result = load_proofing_result(document_capture_session_result_id:) + if !proofing_result + analytics.idv_socure_shadow_mode_proofing_result_missing + return + end + + applicant = build_applicant(encrypted_arguments:, user_email:) + + socure_result = proofer.proof(applicant) + + analytics.idv_socure_shadow_mode_proofing_result( + resolution_result: format_proofing_result_for_logs(proofing_result), + socure_result: socure_result.to_h, + user_id: user.uuid, + pii_like_keypaths: [ + [:errors, :ssn], + [:resolution_result, :context, :stages, :resolution, :errors, :ssn], + [:resolution_result, :context, :stages, :residential_address, :errors, :ssn], + [:resolution_result, :context, :stages, :threatmetrix, :response_body, :first_name], + [:resolution_result, :context, :stages, :state_id, :state_id_jurisdiction], + ], + ) + end + + def create_analytics( + user:, + service_provider_issuer: + ) + Analytics.new( + user:, + request: nil, + sp: service_provider_issuer, + session: {}, + ) + end + + def format_proofing_result_for_logs(proofing_result) + proofing_result.to_h.tap do |hash| + hash.dig(:context, :stages, :threatmetrix)&.delete(:response_body) + end + end + + def load_proofing_result(document_capture_session_result_id:) + DocumentCaptureSession.new( + result_id: document_capture_session_result_id, + ).load_proofing_result&.result + end + + def build_applicant( + encrypted_arguments:, + user_email: + ) + decrypted_arguments = JSON.parse( + Encryption::Encryptors::BackgroundProofingArgEncryptor.new.decrypt(encrypted_arguments), + symbolize_names: true, + ) + + applicant_pii = decrypted_arguments[:applicant_pii] + + { + **applicant_pii.slice( + :first_name, + :last_name, + :address1, + :address2, + :city, + :state, + :zipcode, + :phone, + :dob, + :ssn, + ), + email: user_email, + } + end + + def proofer + @proofer ||= Proofing::Socure::IdPlus::Proofer.new( + Proofing::Socure::IdPlus::Config.new( + api_key: IdentityConfig.store.socure_idplus_api_key, + base_url: IdentityConfig.store.socure_idplus_base_url, + timeout: IdentityConfig.store.socure_idplus_timeout_in_seconds, + ), + ) + end +end diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index fe682f678fe..43a77b3ba50 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -4198,6 +4198,28 @@ def idv_session_error_visited( ) end + # Logs a Socure KYC result alongside a resolution result for later comparison. + # @param [Hash] socure_result Result from Socure KYC API call + # @param [Hash] resolution_result Result from resolution proofing + def idv_socure_shadow_mode_proofing_result( + socure_result:, + resolution_result:, + **extra + ) + track_event( + :idv_socure_shadow_mode_proofing_result, + resolution_result: resolution_result.to_h, + socure_result: socure_result.to_h, + **extra, + ) + end + + # Indicates that no proofing result was found when SocureShadowModeProofingJob + # attempted to look for one. + def idv_socure_shadow_mode_proofing_result_missing(**extra) + track_event(:idv_socure_shadow_mode_proofing_result_missing, **extra) + end + # @param [String] step # @param [String] location # @param [Hash,nil] proofing_components User's current proofing components diff --git a/app/services/proofing/resolution/result.rb b/app/services/proofing/resolution/result.rb index f8d07ea6398..0978ce995bb 100644 --- a/app/services/proofing/resolution/result.rb +++ b/app/services/proofing/resolution/result.rb @@ -61,6 +61,7 @@ def to_h attributes_requiring_additional_verification, vendor_name: vendor_name, vendor_workflow: vendor_workflow, + verified_attributes: verified_attributes, } end diff --git a/config/application.yml.default b/config/application.yml.default index f42688da342..f2993cb3feb 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -143,6 +143,7 @@ idv_max_attempts: 5 idv_min_age_years: 13 idv_send_link_attempt_window_in_minutes: 10 idv_send_link_max_attempts: 5 +idv_socure_shadow_mode_enabled: false idv_sp_required: false in_person_completion_survey_url: 'https://login.gov' in_person_doc_auth_button_enabled: true @@ -338,6 +339,9 @@ sign_in_user_id_per_ip_attempt_window_exponential_factor: 1.1 sign_in_user_id_per_ip_attempt_window_in_minutes: 720 sign_in_user_id_per_ip_attempt_window_max_minutes: 43_200 sign_in_user_id_per_ip_max_attempts: 50 +socure_idplus_api_key: '' +socure_idplus_base_url: '' +socure_idplus_timeout_in_seconds: 5 socure_webhook_enabled: false socure_webhook_secret_key: '' socure_webhook_secret_key_queue: '[]' @@ -432,8 +436,7 @@ development: sign_in_recaptcha_percent_tested: 100 sign_in_recaptcha_score_threshold: 0.3 skip_encryption_allowed_list: '["urn:gov:gsa:SAML:2.0.profiles:sp:sso:localhost"]' - socure_webhook_secret_key: 'secret-key' - socure_webhook_secret_key_queue: '["old-key-one", "old-key-two"]' + socure_idplus_base_url: 'https://sandbox.socure.us' state_tracking_enabled: true telephony_adapter: test use_dashboard_service_providers: true diff --git a/lib/identity_config.rb b/lib/identity_config.rb index eacbfd662c3..d4af025ba85 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -165,6 +165,7 @@ def self.store config.add(:idv_min_age_years, type: :integer) config.add(:idv_send_link_attempt_window_in_minutes, type: :integer) config.add(:idv_send_link_max_attempts, type: :integer) + config.add(:idv_socure_shadow_mode_enabled, type: :boolean) config.add(:idv_sp_required, type: :boolean) config.add(:in_person_completion_survey_url, type: :string) config.add(:in_person_doc_auth_button_enabled, type: :boolean) @@ -360,6 +361,9 @@ def self.store config.add(:s3_reports_enabled, type: :boolean) config.add(:saml_endpoint_configs, type: :json, options: { symbolize_names: true }) config.add(:saml_secret_rotation_enabled, type: :boolean) + config.add(:socure_idplus_api_key, type: :string) + config.add(:socure_idplus_base_url, type: :string) + config.add(:socure_idplus_timeout_in_seconds, type: :integer) config.add(:scrypt_cost, type: :string) config.add(:second_mfa_reminder_account_age_in_days, type: :integer) config.add(:second_mfa_reminder_sign_in_count, type: :integer) diff --git a/spec/features/idv/analytics_spec.rb b/spec/features/idv/analytics_spec.rb index 2e722d7f654..83ff4adc608 100644 --- a/spec/features/idv/analytics_spec.rb +++ b/spec/features/idv/analytics_spec.rb @@ -71,7 +71,8 @@ can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_name: 'ResolutionMock', - vendor_workflow: nil } + vendor_workflow: nil, + verified_attributes: nil } end let(:base_proofing_results) do @@ -95,7 +96,8 @@ timed_out: false, transaction_id: '', vendor_name: 'ResidentialAddressNotRequired', - vendor_workflow: nil }, + vendor_workflow: nil, + verified_attributes: nil }, state_id: state_id_resolution, threatmetrix: threatmetrix_response, }, @@ -124,7 +126,8 @@ can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_name: 'ResolutionMock', - vendor_workflow: nil }, + vendor_workflow: nil, + verified_attributes: nil }, state_id: state_id_resolution, threatmetrix: threatmetrix_response, }, diff --git a/spec/jobs/resolution_proofing_job_spec.rb b/spec/jobs/resolution_proofing_job_spec.rb index d4c2db323c2..bb5a31a8779 100644 --- a/spec/jobs/resolution_proofing_job_spec.rb +++ b/spec/jobs/resolution_proofing_job_spec.rb @@ -525,6 +525,68 @@ end end + context 'socure shadow mode' do + context 'turned on' do + before do + allow(IdentityConfig.store).to receive(:idv_socure_shadow_mode_enabled).and_return(true) + end + + it 'schedules a SocureShadowModeProofingJob' do + stub_vendor_requests + expect(SocureShadowModeProofingJob).to receive(:perform_later).with( + user_email: user.email, + user_uuid: user.uuid, + document_capture_session_result_id: document_capture_session.result_id, + encrypted_arguments: satisfy do |ciphertext| + json = JSON.parse( + Encryption::Encryptors::BackgroundProofingArgEncryptor.new.decrypt(ciphertext), + symbolize_names: true, + ) + expect(json[:applicant_pii]).to eql( + { + first_name: 'FAKEY', + middle_name: nil, + last_name: 'MCFAKERSON', + address1: '1 FAKE RD', + identity_doc_address1: '1 FAKE RD', + identity_doc_address2: nil, + identity_doc_city: 'GREAT FALLS', + identity_doc_address_state: 'MT', + identity_doc_zipcode: '59010-1234', + issuing_country_code: 'US', + address2: nil, + same_address_as_id: 'true', + city: 'GREAT FALLS', + state: 'MT', + zipcode: '59010-1234', + dob: '1938-10-06', + ssn: '900-66-1234', + state_id_jurisdiction: 'ND', + state_id_expiration: '2099-12-31', + state_id_issued: '2019-12-31', + state_id_number: '1111111111111', + state_id_type: 'drivers_license', + }, + ) + end, + service_provider_issuer: service_provider.issuer, + ) + + perform + end + end + + context 'turned off' do + it 'does not schedule a SocureShadowModeProofingJob' do + stub_vendor_requests + + expect(SocureShadowModeProofingJob).not_to receive(:perform_later) + + perform + end + end + end + it 'determines the UUID and UUID prefix and passes it to the downstream proofing vendors' do uuid_info = { uuid_prefix: service_provider.app_id, diff --git a/spec/jobs/socure_shadow_mode_proofing_job_spec.rb b/spec/jobs/socure_shadow_mode_proofing_job_spec.rb new file mode 100644 index 00000000000..3353e0e427f --- /dev/null +++ b/spec/jobs/socure_shadow_mode_proofing_job_spec.rb @@ -0,0 +1,381 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SocureShadowModeProofingJob do + let(:job) do + described_class.new + end + + let(:document_capture_session) do + DocumentCaptureSession.create(user:).tap do |dcs| + dcs.create_proofing_session + end + end + + let(:document_capture_session_result_id) do + document_capture_session.result_id + end + + let(:applicant_pii) do + Idp::Constants::MOCK_IDV_APPLICANT_WITH_PHONE + end + + let(:encrypted_arguments) do + Encryption::Encryptors::BackgroundProofingArgEncryptor.new.encrypt( + JSON.generate({ applicant_pii: }), + ) + end + + let(:user) { create(:user) } + + let(:user_uuid) { user.uuid } + + let(:user_email) { user.email } + + let(:proofing_result) do + FormResponse.new( + success: true, + errors: {}, + extra: { + exception: nil, + timed_out: false, + threatmetrix_review_status: 'pass', + context: { + device_profiling_adjudication_reason: 'device_profiling_result_pass', + resolution_adjudication_reason: 'pass_resolution_and_state_id', + should_proof_state_id: true, + stages: { + resolution: { + success: true, + errors: {}, + exception: nil, + timed_out: false, + transaction_id: 'resolution-mock-transaction-id-123', + reference: 'aaa-bbb-ccc', + can_pass_with_additional_verification: false, + attributes_requiring_additional_verification: [], + vendor_name: 'ResolutionMock', + vendor_workflow: nil, + verified_attributes: nil, + }, + residential_address: { + success: true, + errors: {}, + exception: nil, + timed_out: false, + transaction_id: '', + reference: '', + can_pass_with_additional_verification: false, + attributes_requiring_additional_verification: [], + vendor_name: 'ResidentialAddressNotRequired', + vendor_workflow: nil, + verified_attributes: nil, + }, + state_id: { + success: true, + errors: {}, + exception: nil, + mva_exception: nil, + requested_attributes: {}, + timed_out: false, + transaction_id: 'state-id-mock-transaction-id-456', + vendor_name: 'StateIdMock', + verified_attributes: [], + }, + threatmetrix: { + client: nil, + success: true, + errors: {}, + exception: nil, + timed_out: false, + transaction_id: 'ddp-mock-transaction-id-123', + review_status: 'pass', + response_body: { + "fraudpoint.score": '500', + request_id: '1234', + request_result: 'success', + review_status: 'pass', + risk_rating: 'trusted', + summary_risk_score: '-6', + tmx_risk_rating: 'neutral', + tmx_summary_reason_code: ['Identity_Negative_History'], + first_name: '[redacted]', + }, + }, + }, + }, + ssn_is_unique: true, + }, + ) + end + + let(:socure_idplus_base_url) { 'https://example.org' } + + before do + document_capture_session.store_proofing_result(proofing_result.to_h) + + allow(IdentityConfig.store).to receive(:socure_idplus_base_url). + and_return(socure_idplus_base_url) + end + + describe '#perform' do + subject(:perform) do + allow(job).to receive(:create_analytics).and_return(analytics) + + job.perform( + document_capture_session_result_id:, + encrypted_arguments:, + service_provider_issuer: nil, + user_email:, + user_uuid:, + ) + end + + let(:analytics) do + FakeAnalytics.new + end + + let(:socure_response_body) do + { + referenceId: 'a1234b56-e789-0123-4fga-56b7c890d123', + kyc: { + reasonCodes: [ + 'I919', + 'I914', + 'I905', + ], + fieldValidations: { + firstName: 0.99, + surName: 0.99, + streetAddress: 0.99, + city: 0.99, + state: 0.99, + zip: 0.99, + mobileNumber: 0.99, + dob: 0.99, + ssn: 0.99, + }, + }, + customerProfile: { + customerUserId: '129', + userId: 'u8JpWn4QsF3R7tA2', + }, + } + end + + before do + stub_request(:post, 'https://example.org/api/3.0/EmailAuthScore'). + to_return( + headers: { + 'Content-Type' => 'application/json', + }, + body: JSON.generate(socure_response_body), + ) + end + + context 'when document capture session result is present in redis' do + it 'makes a proofing call' do + expect(job.proofer).to receive(:proof).and_call_original + perform + end + + it 'does not log an idv_socure_shadow_mode_proofing_result_missing event' do + perform + expect(analytics).not_to have_logged_event(:idv_socure_shadow_mode_proofing_result_missing) + end + + it 'logs an event' do + perform + expect(analytics).to have_logged_event( + :idv_socure_shadow_mode_proofing_result, + user_id: user.uuid, + resolution_result: { + success: true, + errors: {}, + context: { + device_profiling_adjudication_reason: 'device_profiling_result_pass', + resolution_adjudication_reason: 'pass_resolution_and_state_id', + should_proof_state_id: true, + stages: { + residential_address: { + attributes_requiring_additional_verification: [], + can_pass_with_additional_verification: false, + errors: {}, + exception: nil, + reference: '', + success: true, + timed_out: false, + transaction_id: '', + vendor_name: 'ResidentialAddressNotRequired', + vendor_workflow: nil, + verified_attributes: nil, + }, + resolution: { + attributes_requiring_additional_verification: [], + can_pass_with_additional_verification: false, + errors: {}, + exception: nil, + reference: 'aaa-bbb-ccc', + success: true, + timed_out: false, + transaction_id: 'resolution-mock-transaction-id-123', + vendor_name: 'ResolutionMock', + vendor_workflow: nil, + verified_attributes: nil, + }, + state_id: { + errors: {}, + exception: nil, + mva_exception: nil, + requested_attributes: {}, + success: true, + timed_out: false, + transaction_id: 'state-id-mock-transaction-id-456', + vendor_name: 'StateIdMock', + verified_attributes: [], + }, + threatmetrix: { + client: nil, + errors: {}, + exception: nil, + review_status: 'pass', + success: true, + timed_out: false, + transaction_id: 'ddp-mock-transaction-id-123', + }, + }, + }, + exception: nil, + ssn_is_unique: true, + threatmetrix_review_status: 'pass', + timed_out: false, + }, + socure_result: { + attributes_requiring_additional_verification: [], + can_pass_with_additional_verification: false, + errors: { reason_codes: ['I905', 'I914', 'I919'] }, + exception: nil, + reference: '', + success: true, + timed_out: false, + transaction_id: 'a1234b56-e789-0123-4fga-56b7c890d123', + vendor_name: 'socure_kyc', + vendor_workflow: nil, + verified_attributes: %i[address first_name last_name phone ssn dob].to_set, + }, + ) + end + + context 'when socure proofer raises an error' do + before do + allow(job.proofer).to receive(:proof).and_raise + end + + it 'does not squash the error' do + # If the Proofer encounters an error while _making_ a request, that + # will be returned as a Result with the `exception` property set. + # Other errors will be raised as normal. + expect { perform }.to raise_error + end + end + end + + context 'when document capture session result is not present in redis' do + let(:document_capture_session_result_id) { 'some-id-that-is-not-valid' } + + it 'logs an idv_socure_shadow_mode_proofing_result_missing event' do + perform + + expect(analytics).to have_logged_event( + :idv_socure_shadow_mode_proofing_result_missing, + ) + end + end + + context 'when job is stale' do + before do + allow(job).to receive(:stale_job?).and_return(true) + end + it 'raises StaleJobError' do + expect { perform }.to raise_error(JobHelpers::StaleJobHelper::StaleJobError) + end + end + + context 'when user is not found' do + let(:user_uuid) { 'some-user-id-that-does-not-exist' } + it 'raises an error' do + expect do + perform + end.to raise_error(RuntimeError, 'User not found: some-user-id-that-does-not-exist') + end + end + + context 'when encrypted_arguments cannot be decrypted' do + let(:encrypted_arguments) { 'bG9sIHRoaXMgaXMgbm90IGV2ZW4gZW5jcnlwdGVk' } + it 'raises an error' do + expect { perform }.to raise_error(Encryption::EncryptionError) + end + end + + context 'when encrypted_arguments contains invalid JSON' do + let(:encrypted_arguments) do + Encryption::Encryptors::BackgroundProofingArgEncryptor.new.encrypt( + 'this is not valid JSON', + ) + end + it 'raises an error' do + expect { perform }.to raise_error(JSON::ParserError) + end + end + end + + describe '#build_applicant' do + subject(:build_applicant) do + job.build_applicant(encrypted_arguments:, user_email:) + end + + it 'builds an applicant structure that looks right' do + expect(build_applicant).to eql( + { + first_name: 'FAKEY', + last_name: 'MCFAKERSON', + address1: '1 FAKE RD', + address2: nil, + city: 'GREAT FALLS', + state: 'MT', + zipcode: '59010-1234', + phone: '12025551212', + dob: '1938-10-06', + ssn: '900-66-1234', + email: user.email, + }, + ) + end + end + + describe '#create_analytics' do + it 'creates an Analytics instance with user and sp configured' do + analytics = job.create_analytics( + user:, + service_provider_issuer: 'some-issuer', + ) + expect(analytics.sp).to eql('some-issuer') + expect(analytics.user).to eql(user) + end + end + + describe '#proofer' do + it 'returns a configured proofer' do + allow(IdentityConfig.store).to receive(:socure_idplus_api_key).and_return('an-api-key') + allow(IdentityConfig.store).to receive(:socure_idplus_base_url).and_return('https://example.org') + allow(IdentityConfig.store).to receive(:socure_idplus_timeout_in_seconds).and_return(6) + + expect(job.proofer.config.to_h).to eql( + api_key: 'an-api-key', + base_url: 'https://example.org', + timeout: 6, + ) + end + end +end diff --git a/spec/services/proofing/mock/resolution_mock_client_spec.rb b/spec/services/proofing/mock/resolution_mock_client_spec.rb index 5c8b2f9933f..61f23ddb71c 100644 --- a/spec/services/proofing/mock/resolution_mock_client_spec.rb +++ b/spec/services/proofing/mock/resolution_mock_client_spec.rb @@ -28,6 +28,7 @@ can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_workflow: nil, + verified_attributes: nil, ) end end @@ -51,6 +52,7 @@ can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_workflow: nil, + verified_attributes: nil, ) end end @@ -74,6 +76,7 @@ can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_workflow: nil, + verified_attributes: nil, ) end end @@ -97,6 +100,7 @@ can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_workflow: nil, + verified_attributes: nil, ) end end @@ -120,6 +124,7 @@ can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_workflow: nil, + verified_attributes: nil, ) end end @@ -143,6 +148,7 @@ can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_workflow: nil, + verified_attributes: nil, ) end end @@ -166,6 +172,7 @@ can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_workflow: nil, + verified_attributes: nil, ) end end @@ -191,6 +198,7 @@ can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_workflow: nil, + verified_attributes: nil, ) end end diff --git a/spec/support/have_logged_event_matcher.rb b/spec/support/have_logged_event_matcher.rb index 86d09a48164..52e06b2ca2b 100644 --- a/spec/support/have_logged_event_matcher.rb +++ b/spec/support/have_logged_event_matcher.rb @@ -22,6 +22,15 @@ def failure_message end end + def failure_message_when_negated + adjective = attributes_matcher_description(expected_attributes) ? 'matching ' : '' + [ + "Expected that FakeAnalytics would not have received #{adjective}event", + expected_event_name.inspect, + has_expected_count? ? count_failure_reason('it was received').strip : nil, + ].compact.join(' ') + end + def matches?(actual) @actual = actual