From 9c396e884a70afbfbb3c97910d24585daddd4800 Mon Sep 17 00:00:00 2001 From: Mitchell Henke Date: Fri, 3 Jan 2025 12:10:02 -0600 Subject: [PATCH 1/3] Create classes for tracking Attempts API events changelog: Internal, Attempts API, Create class for tracking Attempts API events --- app/services/attempts_api/tracker.rb | 88 ++++++++++++ app/services/attempts_api/tracker_events.rb | 16 +++ config/application.yml.default | 1 + lib/identity_config.rb | 1 + spec/services/attempts_api/tracker_spec.rb | 150 ++++++++++++++++++++ 5 files changed, 256 insertions(+) create mode 100644 app/services/attempts_api/tracker.rb create mode 100644 app/services/attempts_api/tracker_events.rb create mode 100644 spec/services/attempts_api/tracker_spec.rb diff --git a/app/services/attempts_api/tracker.rb b/app/services/attempts_api/tracker.rb new file mode 100644 index 00000000000..d6de6a52ca7 --- /dev/null +++ b/app/services/attempts_api/tracker.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module AttemptsApi + class Tracker + attr_reader :session_id, :enabled_for_session, :request, :user, :sp, :cookie_device_uuid, + :sp_request_uri, :analytics + + def initialize(session_id:, request:, user:, sp:, cookie_device_uuid:, + sp_request_uri:, enabled_for_session:, analytics:) + @session_id = session_id + @request = request + @user = user + @sp = sp + @cookie_device_uuid = cookie_device_uuid + @sp_request_uri = sp_request_uri + @enabled_for_session = enabled_for_session + @analytics = analytics + end + include TrackerEvents + + def track_event(event_type, metadata = {}) + return unless enabled? + + if metadata.has_key?(:failure_reason) && + (metadata[:failure_reason].blank? || + metadata[:success].present?) + metadata.delete(:failure_reason) + end + + event_metadata = { + user_agent: request&.user_agent, + unique_session_id: hashed_session_id, + user_uuid: sp && AgencyIdentityLinker.for(user: user, service_provider: sp)&.uuid, + device_fingerprint: hashed_cookie_device_uuid, + user_ip_address: request&.remote_ip, + application_url: sp_request_uri, + client_port: CloudFrontHeaderParser.new(request).client_port, + } + + event_metadata.merge!(metadata) + + event = AttemptEvent.new( + event_type: event_type, + session_id: session_id, + occurred_at: Time.zone.now, + event_metadata: event_metadata, + ) + + jwe = event.to_jwe( + issuer: sp.issuer, + public_key: sp.ssl_certs.first.public_key, + ) + + redis_client.write_event( + event_key: event.jti, + jwe: jwe, + timestamp: event.occurred_at, + issuer: sp.issuer, + ) + + event + end + + def parse_failure_reason(result) + return result.to_h[:error_details] || result.errors.presence + end + + private + + def hashed_session_id + return nil unless user&.unique_session_id + Digest::SHA1.hexdigest(user&.unique_session_id) + end + + def hashed_cookie_device_uuid + return nil unless cookie_device_uuid + Digest::SHA1.hexdigest(cookie_device_uuid) + end + + def enabled? + IdentityConfig.store.attempts_api_enabled && @enabled_for_session + end + + def redis_client + @redis_client ||= AttemptsApi::RedisClient.new + end + end +end diff --git a/app/services/attempts_api/tracker_events.rb b/app/services/attempts_api/tracker_events.rb new file mode 100644 index 00000000000..2b8041a13d9 --- /dev/null +++ b/app/services/attempts_api/tracker_events.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module AttemptsApi + module TrackerEvents + # @param [String] email The submitted email address + # @param [Boolean] success True if the email and password matched + # A user has submitted an email address and password for authentication + def email_and_password_auth(email:, success:) + track_event( + :email_and_password_auth, + email: email, + success: success, + ) + end + end +end diff --git a/config/application.yml.default b/config/application.yml.default index 0c72eaaba56..a1342a8344c 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -40,6 +40,7 @@ allowed_verified_within_providers: '[]' asset_host: '' async_stale_job_timeout_seconds: 300 async_wait_timeout_seconds: 60 +attempts_api_enabled: false attempts_api_event_ttl_seconds: 3_600 attribute_encryption_key: attribute_encryption_key_queue: '[]' diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 9ea782ad0d8..29514b6968d 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -59,6 +59,7 @@ def self.store config.add(:async_stale_job_timeout_seconds, type: :integer) config.add(:async_wait_timeout_seconds, type: :integer) config.add(:attempts_api_event_ttl_seconds, type: :integer) + config.add(:attempts_api_enabled, type: :boolean) config.add(:attribute_encryption_key, type: :string) config.add(:attribute_encryption_key_queue, type: :json) config.add(:available_locales, type: :comma_separated_string_list) diff --git a/spec/services/attempts_api/tracker_spec.rb b/spec/services/attempts_api/tracker_spec.rb new file mode 100644 index 00000000000..971a8da3681 --- /dev/null +++ b/spec/services/attempts_api/tracker_spec.rb @@ -0,0 +1,150 @@ +require 'rails_helper' + +RSpec.describe AttemptsApi::Tracker do + before do + allow(IdentityConfig.store).to receive(:attempts_api_enabled) + .and_return(attempts_api_enabled) + allow(request).to receive(:user_agent).and_return('example/1.0') + allow(request).to receive(:remote_ip).and_return('192.0.2.1') + allow(request).to receive(:headers).and_return( + { 'CloudFront-Viewer-Address' => '192.0.2.1:1234' }, + ) + end + + let(:attempts_api_enabled) { true } + let(:session_id) { 'test-session-id' } + let(:enabled_for_session) { true } + let(:request) { instance_double(ActionDispatch::Request) } + let(:service_provider) { create(:service_provider) } + let(:cookie_device_uuid) { 'device_id' } + let(:sp_request_uri) { 'https://example.com/auth_page' } + let(:user) { create(:user) } + let(:analytics) { FakeAnalytics.new } + + subject do + described_class.new( + session_id: session_id, + request: request, + user: user, + sp: service_provider, + cookie_device_uuid: cookie_device_uuid, + sp_request_uri: sp_request_uri, + enabled_for_session: enabled_for_session, + analytics: analytics, + ) + end + + describe '#track_event' do + it 'omit failure reason when success is true' do + freeze_time do + event = subject.track_event(:test_event, foo: :bar, success: true, failure_reason: nil) + expect(event.event_metadata).to_not have_key(:failure_reason) + end + end + + it 'omit failure reason when failure_reason is blank' do + freeze_time do + event = subject.track_event(:test_event, foo: :bar, failure_reason: nil) + expect(event.event_metadata).to_not have_key(:failure_reason) + end + end + + it 'should not omit failure reason when success is false and failure_reason is not blank' do + freeze_time do + event = subject.track_event( + :test_event, foo: :bar, success: false, + failure_reason: { foo: [:bar] } + ) + expect(event.event_metadata).to have_key(:failure_reason) + expect(event.event_metadata).to have_key(:success) + end + end + + it 'records the event in redis' do + freeze_time do + subject.track_event(:test_event, foo: :bar) + + events = AttemptsApi::RedisClient.new.read_events( + timestamp: Time.zone.now, + issuer: service_provider.issuer, + ) + + expect(events.values.length).to eq(1) + end + end + + it 'does not store events in plaintext in redis' do + freeze_time do + subject.track_event(:event, first_name: Idp::Constants::MOCK_IDV_APPLICANT[:first_name]) + + events = AttemptsApi::RedisClient.new.read_events( + timestamp: Time.zone.now, + issuer: service_provider.issuer, + ) + + expect(events.keys.first).to_not include('first_name') + expect(events.values.first).to_not include(Idp::Constants::MOCK_IDV_APPLICANT[:first_name]) + end + end + + context 'the current session is not an attempts API session' do + let(:enabled_for_session) { false } + + it 'does not record any events in redis' do + freeze_time do + subject.track_event(:test_event, foo: :bar) + + events = AttemptsApi::RedisClient.new.read_events( + timestamp: Time.zone.now, + issuer: service_provider.issuer, + ) + + expect(events.values.length).to eq(0) + end + end + end + + context 'the attempts API is not enabled' do + let(:attempts_api_enabled) { false } + + it 'does not record any events in redis' do + freeze_time do + subject.track_event(:test_event, foo: :bar) + + events = AttemptsApi::RedisClient.new.read_events( + timestamp: Time.zone.now, + issuer: service_provider.issuer, + ) + + expect(events.values.length).to eq(0) + end + end + end + end + + describe '#parse_failure_reason' do + let(:mock_error_message) { 'failure_reason_from_error' } + let(:mock_error_details) { [{ mock_error: 'failure_reason_from_error_details' }] } + + it 'parses failure_reason from error_details' do + test_failure_reason = subject.parse_failure_reason( + { errors: mock_error_message, + error_details: mock_error_details }, + ) + + expect(test_failure_reason).to eq(mock_error_details) + end + + it 'parses failure_reason from errors when no error_details present' do + mock_failure_reason = double( + 'MockFailureReason', + errors: mock_error_message, + to_h: {}, + ) + + test_failure_reason = subject.parse_failure_reason(mock_failure_reason) + + expect(test_failure_reason).to eq(mock_error_message) + end + end +end From 031b6939f7a988ed71d47b628fe67a7c8cb3c26e Mon Sep 17 00:00:00 2001 From: Mitchell Henke Date: Tue, 7 Jan 2025 15:20:31 -0600 Subject: [PATCH 2/3] Add linting for tracker events --- Makefile | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index a2aba78f2db..e928fb8f99e 100644 --- a/Makefile +++ b/Makefile @@ -25,6 +25,7 @@ ARTIFACT_DESTINATION_FILE ?= ./tmp/idp.tar.gz lint \ lint_analytics_events \ lint_analytics_events_sorted \ + lint_tracker_events \ lint_country_dialing_codes \ lint_database_schema_files \ lint_erb \ @@ -76,6 +77,7 @@ endif @echo "--- analytics_events ---" make lint_analytics_events make lint_analytics_events_sorted + make lint_tracker_events @echo "--- brakeman ---" make brakeman # JavaScript @@ -303,11 +305,14 @@ lint_analytics_events_sorted: @test "$(shell grep '^ def ' app/services/analytics_events.rb)" = "$(shell grep '^ def ' app/services/analytics_events.rb | sort)" \ || (echo '\033[1;31mError: methods in analytics_events.rb are not sorted alphabetically\033[0m' && exit 1) +lint_tracker_events: .yardoc ## Checks that all methods on AnalyticsEvents are documented + bundle exec ruby lib/analytics_events_documenter.rb --class-name="AttemptsApi::TrackerEvents" --check --skip-extra-params $< + public/api/_analytics-events.json: .yardoc .yardoc/objects/root.dat mkdir -p public/api bundle exec ruby lib/analytics_events_documenter.rb --class-name="AnalyticsEvents" --json $< > $@ -.yardoc .yardoc/objects/root.dat: app/services/analytics_events.rb +.yardoc .yardoc/objects/root.dat: app/services/analytics_events.rb app/services/attempts_api/tracker_events.rb bundle exec yard doc \ --fail-on-warning \ --type-tag identity.idp.previous_event_name:"Previous Event Name" \ From 8a0c39260fe828634d4c74a85f897f51dece6ddd Mon Sep 17 00:00:00 2001 From: Mitchell Henke Date: Tue, 7 Jan 2025 15:39:43 -0600 Subject: [PATCH 3/3] use dashed name --- app/services/attempts_api/tracker_events.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/attempts_api/tracker_events.rb b/app/services/attempts_api/tracker_events.rb index 2b8041a13d9..88c189d0616 100644 --- a/app/services/attempts_api/tracker_events.rb +++ b/app/services/attempts_api/tracker_events.rb @@ -7,7 +7,7 @@ module TrackerEvents # A user has submitted an email address and password for authentication def email_and_password_auth(email:, success:) track_event( - :email_and_password_auth, + 'login-email-and-password-auth', email: email, success: success, )