Skip to content
5 changes: 5 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ def analytics_user
def irs_attempts_api_tracker
@irs_attempts_api_tracker ||= IrsAttemptsApi::Tracker.new(
session_id: irs_attempts_api_session_id,
request: request,
user: effective_user,
sp: current_sp,
device_fingerprint: cookies[:device],
sp_request_uri: decorated_session.request_url_params[:redirect_uri],
enabled_for_session: irs_attempt_api_enabled_for_session?,
)
end
Expand Down
4 changes: 4 additions & 0 deletions app/decorators/session_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ def requested_more_recent_verification?

def irs_attempts_api_session_id; end

def request_url_params
{}
end

private

attr_reader :view_context
Expand Down
16 changes: 16 additions & 0 deletions app/services/agency_identity_linker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ def link_identity
AgencyIdentity.new(user_id: @sp_identity.user_id, uuid: @sp_identity.uuid)
end

# @return [AgencyIdentity, ServiceProviderIdentity] the AgencyIdentity for this user at this
# service provider or falls back to the ServiceProviderIdentity if one does not exist.
def self.for(user:, service_provider:)
agency = service_provider.agency

ai = AgencyIdentity.where(user: user, agency: agency).take
return ai if ai.present?

spi = ServiceProviderIdentity.where(
user: user, service_provider: service_provider.issuer,
).take

return nil unless spi.present?
new(spi).link_identity
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would love a version of this method that is read-only and doesn't attempt to link? But I'm not sure that would work here, for the AttemptsAPI.... since usually the ID is only created after an account is created + consented to? Maybe we put in a pin and circle back

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I was unclear on some of the existing semantics here. Just to be clear, are you suggesting to leave this as-is for now but consider circling back on this later? Or are you saying that this is will prematurely create these links and should be changed now?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry that was a mess of a comment. Let's leave this as-is

end
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This works and is in keeping with some of the existing patterns, but it feels a bit ugly to me.


def self.sp_identity_from_uuid_and_sp(uuid, service_provider)
ai = AgencyIdentity.where(uuid: uuid).take
criteria = if ai
Expand Down
31 changes: 26 additions & 5 deletions app/services/irs_attempts_api/tracker.rb
Original file line number Diff line number Diff line change
@@ -1,20 +1,36 @@
module IrsAttemptsApi
class Tracker
attr_reader :session_id, :enabled_for_session

def initialize(session_id:, enabled_for_session:)
@session_id = session_id
attr_reader :session_id, :enabled_for_session, :request, :user, :sp, :device_fingerprint,
:sp_request_uri

def initialize(session_id:, request:, user:, sp:, device_fingerprint:,
sp_request_uri:, enabled_for_session:)
@session_id = session_id # IRS session ID
@request = request
@user = user
@sp = sp
@device_fingerprint = device_fingerprint
@sp_request_uri = sp_request_uri
@enabled_for_session = enabled_for_session
end

def track_event(event_type, metadata = {})
return unless enabled?

event_metadata = {
user_agent: request&.user_agent,
unique_session_id: hashed_session_id,
user_uuid: AgencyIdentityLinker.for(user: user, service_provider: sp)&.uuid,
device_fingerprint: device_fingerprint,
user_ip_address: request&.remote_ip,
irs_application_url: sp_request_uri,
}.merge(metadata)

event = AttemptEvent.new(
event_type: event_type,
session_id: session_id,
occurred_at: Time.zone.now,
event_metadata: metadata,
event_metadata: event_metadata,
)

redis_client.write_event(jti: event.jti, jwe: event.to_jwe)
Expand All @@ -25,6 +41,11 @@ def track_event(event_type, metadata = {})

private

def hashed_session_id
return nil unless user&.unique_session_id
Digest::SHA1.hexdigest(user&.unique_session_id)
end

def enabled?
IdentityConfig.store.irs_attempt_api_enabled && @enabled_for_session
end
Expand Down
78 changes: 63 additions & 15 deletions spec/services/agency_identity_linker_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,29 @@
before(:each) { init_env(user) }

it 'links identities from 2 sps' do
sp1 = create_identity(user, 'http://localhost:3000', 'UUID1')
create_identity(user, 'urn:gov:gsa:openidconnect:test', 'UUID2')
sp1 = create_service_provider_identity(user, 'http://localhost:3000', 'UUID1')
create_service_provider_identity(user, 'urn:gov:gsa:openidconnect:test', 'UUID2')
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I renamed this which created a lot of noise.

ai = AgencyIdentityLinker.new(sp1).link_identity
expect(ai.uuid).to eq('UUID1')
ai = AgencyIdentity.where(user_id: user.id).first
expect(ai.uuid).to eq('UUID1')
end

it 'does not allow 2 sp uuids to be reused after user deletes account' do
create_identity(user, 'http://localhost:3000', 'UUID1')
create_identity(user, 'urn:gov:gsa:openidconnect:test', 'UUID2')
create_service_provider_identity(user, 'http://localhost:3000', 'UUID1')
create_service_provider_identity(user, 'urn:gov:gsa:openidconnect:test', 'UUID2')
user.destroy!
expect(User.where(id: user.id).count).to eq(0)
user2 = create(:user)
expect { create_identity(user2, 'sp3', 'UUID1') }.
expect { create_service_provider_identity(user2, 'sp3', 'UUID1') }.
to raise_error ActiveRecord::RecordNotUnique
expect { create_identity(user2, 'sp4', 'UUID2') }.
expect { create_service_provider_identity(user2, 'sp4', 'UUID2') }.
to raise_error ActiveRecord::RecordNotUnique
end

it 'does not allow agency_identity uuid to be reused after user deletes account' do
sp1 = create_identity(user, 'http://localhost:3000', 'UUID1')
create_identity(user, 'urn:gov:gsa:openidconnect:test', 'UUID2')
sp1 = create_service_provider_identity(user, 'http://localhost:3000', 'UUID1')
create_service_provider_identity(user, 'urn:gov:gsa:openidconnect:test', 'UUID2')
AgencyIdentityLinker.new(sp1).link_identity
expect(AgencyIdentity.where(user_id: user.id).count).to eq(1)
expect(AgencyIdentity.where(uuid: 'UUID1').count).to eq(1)
Expand All @@ -37,22 +37,22 @@
expect(AgencyIdentity.where(user_id: user.id).count).to eq(0)
expect(AgencyIdentity.where(uuid: 'UUID1').count).to eq(0)
user2 = create(:user)
expect { create_identity(user2, 'sp3', 'UUID1') }.
expect { create_service_provider_identity(user2, 'sp3', 'UUID1') }.
to raise_error ActiveRecord::RecordNotUnique
expect { create_identity(user2, 'sp4', 'UUID2') }.
expect { create_service_provider_identity(user2, 'sp4', 'UUID2') }.
to raise_error ActiveRecord::RecordNotUnique
end

it 'links identity with 1 sp' do
sp1 = create_identity(user, 'http://localhost:3000', 'UUID1')
sp1 = create_service_provider_identity(user, 'http://localhost:3000', 'UUID1')
ai = AgencyIdentityLinker.new(sp1).link_identity
expect(ai.uuid).to eq('UUID1')
ai = AgencyIdentity.where(user_id: user.id).first
expect(ai.uuid).to eq('UUID1')
end

it 'does not link identity without an agency_id' do
sp1 = create_identity(user, 'sp:with:no:agency_id', 'UUID1')
sp1 = create_service_provider_identity(user, 'sp:with:no:agency_id', 'UUID1')
ai = AgencyIdentityLinker.new(sp1).link_identity
expect(ai.agency_id).to eq(nil)
expect(ai.uuid).to eq('UUID1')
Expand All @@ -61,7 +61,7 @@
end

it 'returns the existing agency_identity if it exists' do
sp1 = create_identity(user, 'http://localhost:3000', 'UUID1')
sp1 = create_service_provider_identity(user, 'http://localhost:3000', 'UUID1')
ai = AgencyIdentityLinker.new(sp1).link_identity
expect(ai.uuid).to eq('UUID1')
ai = AgencyIdentity.where(user_id: user.id).first
Expand All @@ -73,7 +73,7 @@
before(:each) { init_env(user) }

it 'returns sp_identity if it exists' do
create_identity(user, 'http://localhost:3000', 'UUID1')
create_service_provider_identity(user, 'http://localhost:3000', 'UUID1')
AgencyIdentity.create(user_id: user.id, agency_id: 1, uuid: 'UUID2')
sp_identity = AgencyIdentityLinker.sp_identity_from_uuid_and_sp(
'UUID2',
Expand All @@ -92,12 +92,60 @@
end
end

describe '#for' do
before(:each) { init_env(user) }
let(:sp) { create(:service_provider) }
let(:agency) { sp.agency }
let(:uuid) { SecureRandom.uuid }

subject { described_class.for(user: user, service_provider: sp) }

context 'when there is already an agency identity' do
before { create_agency_identity(user, agency, uuid) }

it 'returns the existing agency identity' do
expect(subject).not_to be_nil
expect(subject.user_id).to eq user.id
expect(subject.agency_id).to eq agency.id
expect(subject.uuid).to eq uuid
end
end

context 'when there is not an agency identity' do
context 'but there is a service provider identity' do
before { create_service_provider_identity(user, sp.issuer, uuid) }
it 'returns the service provider identity' do
expect(subject).not_to be_nil
expect(subject.user_id).to eq user.id
expect(subject.agency_id).to eq agency.id
expect(subject.uuid).to eq uuid
end

it 'persists the service provider identity as an agency identity' do
expect(subject.uuid).to eq uuid
ai = AgencyIdentity.where(user: user, agency: agency).take
expect(subject).to eq ai
end
end

context 'and there is no service provider identity' do
it 'returns nil' do
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Does this seem like reasonable behavior?

expect(subject).to be_nil
end
end
end
end

def init_env(user)
ServiceProviderIdentity.where(user_id: user.id).delete_all
AgencyIdentity.where(user_id: user.id).delete_all
end

def create_identity(user, service_provider, uuid)
def create_service_provider_identity(user, service_provider, uuid)
ServiceProviderIdentity.create(user_id: user.id, service_provider: service_provider, uuid: uuid)
end

def create_agency_identity(user, agency, uuid)
AgencyIdentity.create!(user: user, agency: agency, uuid: uuid)
end
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is only used in one place, but I wanted to follow the pattern of the existing create_identity. I paid a bit of a price in terms of PR noise, though...

end
19 changes: 18 additions & 1 deletion spec/services/irs_attempts_api/tracker_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,30 @@
allow(IdentityConfig.store).to receive(:irs_attempt_api_enabled).and_return(
irs_attempt_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')
end

let(:irs_attempt_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(:device_fingerprint) { 'device_id' }
let(:sp_request_uri) { 'https://example.com/auth_page' }
let(:user) { create(:user) }

subject { described_class.new(session_id: session_id, enabled_for_session: enabled_for_session) }
subject do
described_class.new(
session_id: session_id,
request: request,
user: user,
sp: service_provider,
device_fingerprint: device_fingerprint,
sp_request_uri: sp_request_uri,
enabled_for_session: enabled_for_session,
)
end
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

To be clear, this isn't testing anything new, just passing in everything now needed.

Trying to reason about here a good test for this should go, though... I feel like there should probably be an end-to-end test somewhere for this.


describe '#track_event' do
it 'records the event in redis' do
Expand Down