diff --git a/Gemfile.lock b/Gemfile.lock index eb316d54..9e8a35a5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -147,7 +147,7 @@ GEM block_cipher_kit (0.0.4) bootsnap (1.23.0) msgpack (~> 1.2) - brakeman (8.0.6) + brakeman (8.1.0) racc browser (6.2.0) builder (3.3.0) diff --git a/app/controllers/webhooks/persona_controller.rb b/app/controllers/webhooks/persona_controller.rb index 12ba48d9..71d9c634 100644 --- a/app/controllers/webhooks/persona_controller.rb +++ b/app/controllers/webhooks/persona_controller.rb @@ -16,6 +16,7 @@ def create inquiry_id = parsed_body.dig(:data, :attributes, :payload, :data, :id) return head(:bad_request) if event_name.blank? || inquiry_id.blank? + return link_staff_inquiry(inquiry_id) if event_name == "inquiry.created" return head(:ok) unless HANDLED_EVENTS.include?(event_name) Persona::ProcessInquiryEventJob.perform_later( @@ -28,6 +29,25 @@ def create private + # inquiries we create through the API have creator "API"; anything else is + # the email of whoever made it by hand in the persona dashboard — see + # LinkStaffInquiryJob + def link_staff_inquiry(inquiry_id) + attributes = parsed_body.dig(:data, :attributes, :payload, :data, :attributes) || {} + creator = attributes[:creator] + reference_id = attributes[:"reference-id"] + + if creator.present? && creator != "API" && reference_id.present? + Persona::LinkStaffInquiryJob.perform_later( + inquiry_id: inquiry_id, + creator: creator, + reference_id: reference_id + ) + end + + head :ok + end + def verify_signature! signature_header = request.headers["Persona-Signature"] return head(:unauthorized) if signature_header.blank? diff --git a/app/jobs/persona/link_staff_inquiry_job.rb b/app/jobs/persona/link_staff_inquiry_job.rb new file mode 100644 index 00000000..afdfeb54 --- /dev/null +++ b/app/jobs/persona/link_staff_inquiry_job.rb @@ -0,0 +1,77 @@ +# links an inquiry that staff created by hand in the persona dashboard to +# the identity named in its reference id. +# +# this is the account recovery path: the user can't sign in or read their +# email, so staff make an inquiry with reference id = the identity's public +# id and send them persona's hosted link directly. persona's inquiry.created +# webhook carries the dashboard user's email as `creator` — only active +# reviewers count. once linked, the inquiry is an ordinary PersonaVerification +# and the usual completed/approved webhooks drive it, except it never +# auto-approves (see PersonaVerification::STAFF_CREATED_ISSUE). +class Persona::LinkStaffInquiryJob < ApplicationJob + queue_as :default + + PERSONA_TYPES = %w[Verification::PersonaVerification Verification::PersonaStudentIdVerification].freeze + + def perform(inquiry_id:, creator:, reference_id:) + return if Verification.exists?(persona_inquiry_id: inquiry_id) + return if VerificationCase.exists?(persona_inquiry_id: inquiry_id) + + Sentry.set_tags(component: "persona", event: "inquiry.created") + Sentry.set_extras(inquiry_id: inquiry_id, reference_id: reference_id) + + staff = staff_user_for(creator) + unless staff + Sentry.capture_message("[Persona] dashboard inquiry created by someone who isn't an active reviewer", level: :warning) + return + end + + identity = Identity.find_by_public_id(reference_id) + unless identity + Sentry.capture_message("[Persona] dashboard inquiry reference id doesn't match an identity", level: :warning) + return + end + + stale_inquiry_ids = [] + verification = identity.with_lock do + # something already under review — don't stack a second submission on it + if identity.verifications.where(type: PERSONA_TYPES, status: :pending).exists? + Sentry.capture_message("[Persona] dashboard inquiry for an identity with a pending verification", level: :warning) + nil + else + # a draft would shadow this one when the user next signs in, and its + # inquiry is stale anyway — same cleanup the verification flow does + identity.verifications.where(type: PERSONA_TYPES, status: :draft).find_each do |draft| + stale_inquiry_ids << draft.persona_inquiry_id if draft.persona_inquiry_id.present? + draft.destroy! + end + + Verification::PersonaVerification.create!( + identity: identity, + persona_inquiry_id: inquiry_id, + issues: [ Verification::PersonaVerification::STAFF_CREATED_ISSUE ] + ) + end + end + return unless verification + + stale_inquiry_ids.each do |stale_id| + Persona.instance.expire_inquiry(stale_id) + rescue Persona::APIError => e + Sentry.capture_exception(e) + end + + verification.create_activity(:persona_inquiry_linked_by_staff, owner: staff, recipient: identity, + parameters: { inquiry_id: inquiry_id, expired_inquiry_ids: stale_inquiry_ids }) + end + + private + + def staff_user_for(creator) + Backend::User.joins(:identity) + .where(active: true) + .where("lower(identities.primary_email) = ?", creator.to_s.strip.downcase) + .where("backend_users.super_admin OR backend_users.manual_document_verifier") + .first + end +end diff --git a/app/jobs/persona/verification_pipeline_job.rb b/app/jobs/persona/verification_pipeline_job.rb index 007a5eb4..b10110bd 100644 --- a/app/jobs/persona/verification_pipeline_job.rb +++ b/app/jobs/persona/verification_pipeline_job.rb @@ -31,7 +31,10 @@ def run_decisioning case verdict when :approved if !@verification.auto_approvable? - @verification.update(issues: @verification.issues + [ "Student ID — requires manual review" ]) + # staff-created inquiries already carry their own issue + unless @verification.try(:staff_created_inquiry?) + @verification.update(issues: @verification.issues + [ "Student ID — requires manual review" ]) + end Slack::NotifyReviewQueueJob.perform_later(@verification) elsif @record&.birthdate && Identity.calculate_age(@record.birthdate) < 13 @verification.update(issues: @verification.issues + [ "Under 13 — requires manual review" ]) diff --git a/app/models/identity/backup_code.rb b/app/models/identity/backup_code.rb index d8c1b6ba..1d4e4fc3 100644 --- a/app/models/identity/backup_code.rb +++ b/app/models/identity/backup_code.rb @@ -27,7 +27,7 @@ class Identity::BackupCode < ApplicationRecord end end event :mark_discarded do - transitions from: [:active, :previewed], to: :discarded + transitions from: [ :active, :previewed ], to: :discarded end end diff --git a/app/models/verification/persona_verification.rb b/app/models/verification/persona_verification.rb index 84f85426..29b42598 100644 --- a/app/models/verification/persona_verification.rb +++ b/app/models/verification/persona_verification.rb @@ -106,9 +106,16 @@ def relevant_record = persona_record def needs_break_glass? = true def auto_break_glass_reason = pending? ? "to review submission" : nil def nukeable? = draft? || pending? - def auto_approvable? = true + def auto_approvable? = !staff_created_inquiry? def status_pending_partial = "verifications/status/pending_persona" + # set by Persona::LinkStaffInquiryJob. a hand-made inquiry is an account + # recovery: persona passing the document says nothing about whether the + # person owns this account, so a human always makes the call. + STAFF_CREATED_ISSUE = "Inquiry created by staff in the Persona dashboard — requires manual review" + + def staff_created_inquiry? = issues.include?(STAFF_CREATED_ISSUE) + def inquiry_unlinked? return false unless persona_inquiry_id.present? && persona_record_id.present? persona_record&.inquiry_id != persona_inquiry_id diff --git a/app/views/identities/new.html.erb b/app/views/identities/new.html.erb index 34b18b28..14465ea9 100644 --- a/app/views/identities/new.html.erb +++ b/app/views/identities/new.html.erb @@ -34,7 +34,7 @@ - <%= t(".email_typo", suggested: ERB::Util.html_escape(@identity.suggested_email)).html_safe %> + <%= t(".email_typo_html", suggested: @identity.suggested_email) %>