diff --git a/app/controllers/concerns/idv/verify_info_concern.rb b/app/controllers/concerns/idv/verify_info_concern.rb index 4f3dd6402a7..3b9336e36cd 100644 --- a/app/controllers/concerns/idv/verify_info_concern.rb +++ b/app/controllers/concerns/idv/verify_info_concern.rb @@ -221,7 +221,7 @@ def add_proofing_costs(results) elsif stage == :threatmetrix # transaction_id comes from request_id tmx_id = hash[:transaction_id] - log_irs_tmx_fraud_check_event(hash) if tmx_id + log_irs_tmx_fraud_check_event(hash, current_user) if tmx_id add_cost(:threatmetrix, transaction_id: tmx_id) if tmx_id end end diff --git a/app/models/fraud_review_request.rb b/app/models/fraud_review_request.rb new file mode 100644 index 00000000000..d4f8a39325a --- /dev/null +++ b/app/models/fraud_review_request.rb @@ -0,0 +1,5 @@ +class FraudReviewRequest < ApplicationRecord + include NonNullUuid + + belongs_to :user +end diff --git a/app/models/phone_number_opt_out.rb b/app/models/phone_number_opt_out.rb index 5f85cd2bff8..868e26c5781 100644 --- a/app/models/phone_number_opt_out.rb +++ b/app/models/phone_number_opt_out.rb @@ -1,4 +1,4 @@ -# Represents a record of a phone number that has beed opted out of SMS in AWS Pinpoint +# Represents a record of a phone number that has been opted out of SMS in AWS Pinpoint # AWS maintains separate opt-out lists per region, so this helps us keep track across regions class PhoneNumberOptOut < ApplicationRecord include NonNullUuid diff --git a/app/models/profile.rb b/app/models/profile.rb index 09c5b62a085..66ac064e4ad 100644 --- a/app/models/profile.rb +++ b/app/models/profile.rb @@ -49,6 +49,7 @@ def activate def activate_after_passing_review update!(fraud_review_pending: false, fraud_rejection: false) + track_fraud_review_adjudication(decision: 'pass') activate end @@ -62,6 +63,9 @@ def deactivate_for_fraud_review def reject_for_fraud(notify_user:) update!(active: false, fraud_review_pending: false, fraud_rejection: true) + track_fraud_review_adjudication( + decision: notify_user ? 'manual_reject' : 'automatic_reject', + ) UserAlerts::AlertUserAboutAccountRejected.call(user) if notify_user end @@ -120,8 +124,38 @@ def has_proofed_before? Profile.where(user_id: user_id).where.not(activated_at: nil).where.not(id: self.id).exists? end + def irs_attempts_api_tracker + @irs_attempts_api_tracker ||= IrsAttemptsApi::Tracker.new( + session_id: nil, + request: nil, + user: user, + sp: initiating_service_provider, + cookie_device_uuid: nil, + sp_request_uri: nil, + enabled_for_session: initiating_service_provider&.irs_attempts_api_enabled?, + analytics: Analytics.new( + user: user, + request: nil, + sp: initiating_service_provider&.issuer, + session: {}, + ahoy: nil, + ), + ) + end + private + def track_fraud_review_adjudication(decision:) + if IdentityConfig.store.irs_attempt_api_track_idv_fraud_review + fraud_review_request = user.fraud_review_requests.last + irs_attempts_api_tracker.fraud_review_adjudicated( + decision: decision, + cached_irs_session_id: fraud_review_request&.irs_session_id, + cached_login_session_id: fraud_review_request&.login_session_id, + ) + end + end + def personal_key_generator @personal_key_generator ||= PersonalKeyGenerator.new(user) end diff --git a/app/models/user.rb b/app/models/user.rb index fb50ed5bd1e..740db97ce74 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -44,6 +44,7 @@ class User < ApplicationRecord source: :service_provider_record has_many :sign_in_restrictions, dependent: :destroy has_many :in_person_enrollments, dependent: :destroy + has_many :fraud_review_requests, dependent: :destroy has_one :pending_in_person_enrollment, -> { where(status: :pending).order(created_at: :desc) }, diff --git a/app/services/cloud_front_header_parser.rb b/app/services/cloud_front_header_parser.rb index 11392f4dd13..f350ae2d38a 100644 --- a/app/services/cloud_front_header_parser.rb +++ b/app/services/cloud_front_header_parser.rb @@ -10,6 +10,7 @@ def client_port # Source IP and port for client connection to CloudFront def viewer_address + return nil unless @request&.headers @request.headers['CloudFront-Viewer-Address'] end end diff --git a/app/services/idv/steps/threat_metrix_step_helper.rb b/app/services/idv/steps/threat_metrix_step_helper.rb index 62eae9de24a..143067ed286 100644 --- a/app/services/idv/steps/threat_metrix_step_helper.rb +++ b/app/services/idv/steps/threat_metrix_step_helper.rb @@ -47,18 +47,24 @@ def threatmetrix_iframe_url(session_id) ) end - def log_irs_tmx_fraud_check_event(result) + def log_irs_tmx_fraud_check_event(result, user) return unless IdentityConfig.store.irs_attempt_api_track_tmx_fraud_check_event return unless FeatureManagement.proofing_device_profiling_collecting_enabled? + success = result[:review_status] == 'pass' - if !success && (tmx_summary_reason_code = result.dig( - :response_body, - :tmx_summary_reason_code, - )) - failure_reason = { - tmx_summary_reason_code: tmx_summary_reason_code, - } + unless success + FraudReviewRequest.create( + user: user, + irs_session_id: irs_attempts_api_session_id, + login_session_id: Digest::SHA1.hexdigest(user.unique_session_id.to_s), + ) + + if (tmx_summary_reason_code = result.dig(:response_body, :tmx_summary_reason_code)) + failure_reason = { + tmx_summary_reason_code: tmx_summary_reason_code, + } + end end irs_attempts_api_tracker.idv_tmx_fraud_check( diff --git a/app/services/irs_attempts_api/tracker.rb b/app/services/irs_attempts_api/tracker.rb index a4d64d4a07c..2067cf4cbda 100644 --- a/app/services/irs_attempts_api/tracker.rb +++ b/app/services/irs_attempts_api/tracker.rb @@ -16,7 +16,7 @@ def initialize(session_id:, request:, user:, sp:, cookie_device_uuid:, end def track_event(event_type, metadata = {}) - return if !enabled? + return unless enabled? if metadata.has_key?(:failure_reason) && (metadata[:failure_reason].blank? || diff --git a/app/services/irs_attempts_api/tracker_events.rb b/app/services/irs_attempts_api/tracker_events.rb index 4338a16b002..1f4eb829fa6 100644 --- a/app/services/irs_attempts_api/tracker_events.rb +++ b/app/services/irs_attempts_api/tracker_events.rb @@ -67,6 +67,19 @@ def forgot_password_new_password_submitted(success:, failure_reason: nil) ) end + # @param [String] decision One of 'pass', 'manual_reject', or 'automated_reject' + # @param [String] cached_irs_session_id The IRS session id ('tid') the user had when flagged + # @param [String] cached_login_session_id The Login.gov session id the user had when flagged + # A profile offlined for review has been approved or rejected. + def fraud_review_adjudicated(decision:, cached_irs_session_id:, cached_login_session_id:) + track_event( + :fraud_review_adjudicated, + decision: decision, + cached_irs_session_id: cached_irs_session_id, + cached_login_session_id: cached_login_session_id, + ) + end + # @param ["mobile", "desktop"] upload_method method chosen for uploading id verification # A user has selected id document upload method def idv_document_upload_method_selected(upload_method:) diff --git a/config/application.yml.default b/config/application.yml.default index a66c14792a0..3c025def48a 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -154,6 +154,7 @@ irs_attempt_api_event_ttl_seconds: 86400 irs_attempt_api_event_count_default: 1000 irs_attempt_api_event_count_max: 10000 irs_attempt_api_payload_size_logging_enabled: true +irs_attempt_api_track_idv_fraud_review: false irs_attempt_api_track_tmx_fraud_check_event: false key_pair_generation_percent: 0 logins_per_ip_track_only_mode: false diff --git a/db/primary_migrate/20230322000756_create_fraud_review_requests.rb b/db/primary_migrate/20230322000756_create_fraud_review_requests.rb new file mode 100644 index 00000000000..24116a3e74b --- /dev/null +++ b/db/primary_migrate/20230322000756_create_fraud_review_requests.rb @@ -0,0 +1,14 @@ +class CreateFraudReviewRequests < ActiveRecord::Migration[7.0] + def change + create_table :fraud_review_requests do |t| + t.integer :user_id + t.string :uuid + t.string :irs_session_id + t.string :login_session_id + + t.timestamps + + t.index :user_id, unique: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 823c35bdb60..5dce1006baa 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_03_20_235607) do +ActiveRecord::Schema[7.0].define(version: 2023_03_22_000756) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "pgcrypto" @@ -226,6 +226,16 @@ t.index ["user_id", "created_at"], name: "index_events_on_user_id_and_created_at" end + create_table "fraud_review_requests", force: :cascade do |t| + t.integer "user_id" + t.string "uuid" + t.string "irs_session_id" + t.string "login_session_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_fraud_review_requests_on_user_id" + end + create_table "iaa_gtcs", force: :cascade do |t| t.string "gtc_number", null: false t.integer "mod_number", default: 0, null: false diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 6ec676c51b2..1f11c0f4cd0 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -234,6 +234,7 @@ def self.build_store(config_map) config.add(:irs_attempt_api_event_count_default, type: :integer) config.add(:irs_attempt_api_event_count_max, type: :integer) config.add(:irs_attempt_api_payload_size_logging_enabled, type: :boolean) + config.add(:irs_attempt_api_track_idv_fraud_review, type: :boolean) config.add(:irs_attempt_api_track_tmx_fraud_check_event, type: :boolean) config.add(:irs_attempt_api_public_key) config.add(:irs_attempt_api_public_key_id) diff --git a/spec/models/profile_spec.rb b/spec/models/profile_spec.rb index b79ceaf2990..f701a0c7b99 100644 --- a/spec/models/profile_spec.rb +++ b/spec/models/profile_spec.rb @@ -299,6 +299,69 @@ expect(profile).to be_active end + + context 'when the initiating_sp is the IRS' do + let(:sp) { create(:service_provider, :irs) } + let(:profile) do + create( + :profile, + user: user, + active: false, + fraud_review_pending: true, + initiating_service_provider: sp, + ) + end + + context 'when the feature flag is enabled' do + before do + allow(IdentityConfig.store).to receive(:irs_attempt_api_track_idv_fraud_review). + and_return(true) + end + + it 'logs an attempt event' do + allow(IdentityConfig.store).to receive(:irs_attempt_api_enabled).and_return(true) + expect(profile.initiating_service_provider.irs_attempts_api_enabled?).to be_truthy + + expect(profile.irs_attempts_api_tracker).to receive(:fraud_review_adjudicated). + with( + hash_including(decision: 'pass'), + ) + profile.activate_after_passing_review + end + end + + context 'when the feature flag is disabled' do + before do + allow(IdentityConfig.store).to receive(:irs_attempt_api_track_idv_fraud_review). + and_return(false) + end + + it 'does not log an attempt event' do + allow(IdentityConfig.store).to receive(:irs_attempt_api_enabled).and_return(true) + expect(profile.initiating_service_provider.irs_attempts_api_enabled?).to be_truthy + + expect(profile.irs_attempts_api_tracker).not_to receive(:fraud_review_adjudicated) + profile.activate_after_passing_review + end + end + end + + context 'when the initiating_sp is not the IRS' do + it 'does not log an attempt event' do + sp = create(:service_provider) + profile = create( + :profile, + user: user, + active: false, + fraud_review_pending: true, + initiating_service_provider: sp, + ) + expect(profile.initiating_service_provider.irs_attempts_api_enabled?).to be_falsey + + expect(profile.irs_attempts_api_tracker).not_to receive(:fraud_review_adjudicated) + profile.activate_after_passing_review + end + end end describe '#deactivate_for_fraud_review' do @@ -353,6 +416,73 @@ expect { profile }.to change(ActionMailer::Base.deliveries, :count).by(0) end end + + context 'when the SP is the IRS' do + let(:sp) { create(:service_provider, :irs) } + let(:profile) do + create( + :profile, + user: user, + active: false, + fraud_review_pending: true, + initiating_service_provider: sp, + ) + end + + context 'and notify_user is true' do + it 'logs an event with manual_reject' do + allow(IdentityConfig.store).to receive(:irs_attempt_api_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:irs_attempt_api_track_idv_fraud_review). + and_return(true) + + expect(profile.initiating_service_provider.irs_attempts_api_enabled?).to be_truthy + + expect(profile.irs_attempts_api_tracker).to receive(:fraud_review_adjudicated). + with( + hash_including(decision: 'manual_reject'), + ) + + profile.reject_for_fraud(notify_user: true) + end + end + + context 'and notify_user is false' do + it 'logs an event with automatic_reject' do + allow(IdentityConfig.store).to receive(:irs_attempt_api_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:irs_attempt_api_track_idv_fraud_review). + and_return(true) + + expect(profile.initiating_service_provider.irs_attempts_api_enabled?).to be_truthy + + expect(profile.irs_attempts_api_tracker).to receive(:fraud_review_adjudicated). + with( + hash_including(decision: 'automatic_reject'), + ) + + profile.reject_for_fraud(notify_user: false) + end + end + end + + context 'when the SP is not the IRS' do + it 'does not log an event' do + sp = create(:service_provider) + profile = create( + :profile, + user: user, + active: false, + fraud_review_pending: true, + initiating_service_provider: sp, + ) + allow(IdentityConfig.store).to receive(:irs_attempt_api_enabled).and_return(true) + + expect(profile.initiating_service_provider.irs_attempts_api_enabled?).to be_falsey + + expect(profile.irs_attempts_api_tracker).not_to receive(:fraud_review_adjudicated) + + profile.reject_for_fraud(notify_user: true) + end + end end describe 'scopes' do diff --git a/spec/services/cloud_front_header_parser_spec.rb b/spec/services/cloud_front_header_parser_spec.rb index 159bd2df848..927c084fee7 100644 --- a/spec/services/cloud_front_header_parser_spec.rb +++ b/spec/services/cloud_front_header_parser_spec.rb @@ -39,7 +39,23 @@ describe '#client_port' do it 'returns nil' do - expect(subject.client_port).to eq nil + expect(subject.client_port).to be nil + end + end + end + + context 'with no request included' do + let(:req) { nil } + + describe '#viewer_address' do + it 'returns nil' do + expect(subject.viewer_address).to be nil + end + end + + describe '#client_port' do + it 'returns nil' do + expect(subject.client_port).to be nil end end end