Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

78001 - Representative User validation #16084

Closed
wants to merge 17 commits into from
Closed
1 change: 1 addition & 0 deletions app/services/sign_in/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,6 @@ class InvalidAccessTokenAttributeError < StandardError; end
class TermsOfUseNotAcceptedError < StandardError; end
class CredentialLockedError < StandardError; end
class InvalidAudienceError < StandardError; end
class RecordNotFoundError < StandardError; end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,25 @@ def find_valid_user

def reload_user
validate_account_and_session
validate_representative_status
current_user
end

def validate_account_and_session
raise SignIn::Errors::SessionNotFoundError.new message: 'Invalid Session Handle' unless session
end

def validate_representative_status
mpi_profile = mpi_service.find_profile_by_identifier(identifier: session.user_account.icn,
Copy link
Contributor

@gabezurita gabezurita Apr 9, 2024

Choose a reason for hiding this comment

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

ARP will use session.user_verification and have a join table between UserVerification (credential someone logged in as) <> VS::Representative, which will get used to set a RepresentativeUser's poa_codes here.

identifier_type: MPI::Constants::ICN).profile
representative = Veteran::Service::Representative.for_user(first_name: session.user_attributes_hash['first_name'],
last_name: session.user_attributes_hash['last_name'],
ssn: mpi_profile.ssn,
dob: mpi_profile.birth_date)
Copy link
Contributor

@gabezurita gabezurita Apr 3, 2024

Choose a reason for hiding this comment

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

The ARP engine will likely use a VS::Representative's OGC number to get their POA codes, which is currently stored in vets-api's representative_id. This will not be used for validation, but to get the poa_codes associated with a Representative.

See this Zenhub issue exploring ARP engine RepresentativeUser validation/authentication. Summary below.

RepresentativeUser Authentication/Validation Exploration Summary

Interim, Pilot Solution

@amprokop, @nihil2501, and I explored the hypotheses outlined in the above issue. The most feasible pilot alternative seems to be to add an enabled: bool attribute to the RepresentativeUser model, with ARF engineers setting this in a static JSON file within vets-api. This would be a whitelisting approach.

Other Explored Alternatives

  • Is there a private OGC registration number that is treated as a secret?
    • Per the OGC API's v1/accreditations/Representatives/{id} endpoint, there are two returned IDs (in UUID format), repVSoid and accrRepId, that could potentially be used for this. ARF is unsure if these OGC API IDs are treated as a secret or are IDs meant for internal use, and OGC<>ARF coordination will be necessary around that. Even then, thought would need to be given to how ARF would get these identifiers to Representatives. Additionally, given that these identifiers are in UUID format, it would be cumbersome for VSReps to enter them manually in ARP.
  • Could ARP require users to perform one-time authentication using the email address OGC already has on file? (this will probably block some users from access, as ARM found data quality issues with rep contact data, but it may be a sound long-term incentive to keep that data correct?)
    • This is possible, but it may be a heavy lift for the ARP Pilot. Additionally, there would be friction to this alternative, given OGC data quality issues around emails.
  • Could we match on SSN, DOB first name, and last name and have that be enough? (SSN is found in OGC's internal dataset, though we need to verify how reliably it is present)
    • The OGC API's/api/v1/accreditations/Representatives/active endpoint returns DOB and SSN as fields in the response body, but all values are currently empty. The ARF Team would have to check with OGC if they plan on returning populated DOBs and SSNs in their responses. This is a promising alternative if OGC plans to return non-nil values for DOB and SSN.

Copy link
Contributor

Choose a reason for hiding this comment

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

@bosawt, what are your thoughts on pushing forward with the above interim solution for the ARP pilot?


raise SignIn::Errors::RecordNotFoundError.new message: 'User is not a VA representative' if representative.blank?
end

def loa
current_loa = user_is_verified? ? SignIn::Constants::Auth::LOA_THREE : SignIn::Constants::Auth::LOA_ONE
{ current: current_loa, highest: SignIn::Constants::Auth::LOA_THREE }
Expand Down Expand Up @@ -84,5 +96,9 @@ def current_user

@current_user = user
end

def mpi_service
@service ||= MPI::Service.new
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -30,30 +30,64 @@
end

context 'when authenticated' do
let(:mpi_profile) { build(:mpi_profile) }
let(:profile_response) { create(:find_profile_response, profile: mpi_profile) }
let(:representative_attributes) { {} }

before do
cookies[SignIn::Constants::Auth::ACCESS_TOKEN_COOKIE_NAME] = access_token_cookie
create(:representative, representative_attributes)
allow_any_instance_of(MPI::Service).to receive(:find_profile_by_identifier).and_return(profile_response)
end

context 'with a valid audience' do
it 'allows access' do
expect(subject).to have_http_status(:ok)
end
context 'when the representative is found' do
let(:session) { SignIn::OAuthSession.find_by(handle: valid_access_token.session_handle) }
let(:representative_attributes) do
{ first_name: session.user_attributes_hash['first_name'],
last_name: session.user_attributes_hash['last_name'],
ssn: mpi_profile.ssn,
dob: mpi_profile.birth_date }
end

context 'when the representatives_portal_api feature toggle' do
before do
allow(Flipper).to receive(:enabled?).with(:accredited_representative_portal_api).and_return(enabled)
it 'allows access' do
expect(subject).to have_http_status(:ok)
end

context 'is enabled' do
let(:enabled) { true }
context 'when the representatives_portal_api feature toggle' do
before do
allow(Flipper).to receive(:enabled?).with(:accredited_representative_portal_api).and_return(enabled)
end

context 'is enabled' do
let(:enabled) { true }

it { is_expected.to have_http_status(:ok) }
end

it { is_expected.to have_http_status(:ok) }
context 'is disabled' do
let(:enabled) { false }

it { is_expected.to have_http_status(:not_found) }
end
end
end

context 'when the representative is not found' do
let(:expected_response_body) do
{ errors: 'User is not a VA representative' }.to_json
end
let(:expected_error) { 'User is not a VA representative' }
let(:expected_log_payload) { { access_token_cookie: } }

context 'is disabled' do
let(:enabled) { false }
before do
allow(Rails.logger).to receive(:error)
end

it { is_expected.to have_http_status(:not_found) }
it 'raises a representative record not found error' do
expect(subject).to have_http_status(:unauthorized)
expect(subject.body).to eq(expected_response_body)
expect(Rails.logger).to have_received(:error).with("#{expected_error} : #{expected_log_payload}")
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,23 +47,53 @@
auth_broker:,
client_id: }
end
let(:ssn) { Faker::Number.leading_zero_number(digits: 9).to_s }
let(:birth_date) { '1987-10-17' }
let(:mpi_profile) do
build(:mpi_profile, { given_names: [user.first_name], family_name: user.last_name, ssn:, birth_date: })
end
let(:profile_response) { create(:find_profile_response, profile: mpi_profile) }

before do
create(:representative, representative_attributes)
allow_any_instance_of(MPI::Service).to receive(:find_profile_by_identifier).and_return(profile_response)
end

context 'and user is not a VA representative' do
let(:representative_attributes) { {} }
let(:expected_error) { SignIn::Errors::RecordNotFoundError }
let(:expected_error_message) { 'User is not a VA representative' }

it 'raises a representative record not found error' do
expect { representative_user_loader.perform }.to raise_error(expected_error, expected_error_message)
end
end

context 'and user is a VA representative' do
let(:representative_attributes) do
{ first_name: session.user_attributes_hash['first_name'],
last_name: session.user_attributes_hash['last_name'],
ssn:,
dob: birth_date }
end

it 'reloads user object with expected attributes' do
reloaded_user = representative_user_loader.perform

it 'reloads user object with expected attributes' do
reloaded_user = representative_user_loader.perform

expect(reloaded_user).to be_a(AccreditedRepresentativePortal::RepresentativeUser)
expect(reloaded_user.uuid).to eq(user_uuid)
expect(reloaded_user.email).to eq(email)
expect(reloaded_user.first_name).to eq(session.user_attributes_hash['first_name'])
expect(reloaded_user.last_name).to eq(session.user_attributes_hash['last_name'])
expect(reloaded_user.icn).to eq(user_icn)
expect(reloaded_user.idme_uuid).to eq(idme_uuid)
expect(reloaded_user.logingov_uuid).to eq(nil)
expect(reloaded_user.fingerprint).to eq(request_ip)
expect(reloaded_user.last_signed_in).to eq(session.created_at)
expect(reloaded_user.authn_context).to eq(authn_context)
expect(reloaded_user.loa).to eq(user_loa)
expect(reloaded_user.sign_in).to eq(sign_in)
expect(reloaded_user).to be_a(AccreditedRepresentativePortal::RepresentativeUser)
expect(reloaded_user.uuid).to eq(user_uuid)
expect(reloaded_user.email).to eq(email)
expect(reloaded_user.first_name).to eq(session.user_attributes_hash['first_name'])
expect(reloaded_user.last_name).to eq(session.user_attributes_hash['last_name'])
expect(reloaded_user.icn).to eq(user_icn)
expect(reloaded_user.idme_uuid).to eq(idme_uuid)
expect(reloaded_user.logingov_uuid).to eq(nil)
expect(reloaded_user.fingerprint).to eq(request_ip)
expect(reloaded_user.last_signed_in).to eq(session.created_at)
expect(reloaded_user.authn_context).to eq(authn_context)
expect(reloaded_user.loa).to eq(user_loa)
expect(reloaded_user.sign_in).to eq(sign_in)
end
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class Representative < ApplicationRecord
#
# @return [Array(Veteran::Service::Representative)] All representatives found using the submitted search criteria
def self.all_for_user(first_name:, last_name:, ssn: nil, dob: nil, middle_initial: nil, poa_code: nil) # rubocop:disable Metrics/ParameterLists
reps = where('lower(first_name) = ? AND lower(last_name) = ?', first_name.downcase, last_name.downcase)
reps = where('lower(first_name) = ? AND lower(last_name) = ?', first_name&.downcase, last_name&.downcase)
reps = reps.where('? = ANY(poa_codes)', poa_code) if poa_code

reps.select do |rep|
Expand Down
4 changes: 4 additions & 0 deletions spec/factories/representatives.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,9 @@
factory :representative, class: 'Veteran::Service::Representative' do
representative_id { '1234' }
poa_codes { ['A1Q'] }
first_name { Faker::Name.first_name }
last_name { Faker::Name.last_name }
ssn { Faker::Number.leading_zero_number(digits: 9).to_s }
dob { '1987-10-17' }
end
end
Loading