Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions app/controllers/webhooks/persona_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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?
Expand Down
77 changes: 77 additions & 0 deletions app/jobs/persona/link_staff_inquiry_job.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion app/jobs/persona/verification_pipeline_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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" ])
Expand Down
2 changes: 1 addition & 1 deletion app/models/identity/backup_code.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 8 additions & 1 deletion app/models/verification/persona_verification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/views/identities/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
</svg>
<span><%= t(".email_typo", suggested: ERB::Util.html_escape(@identity.suggested_email)).html_safe %></span>
<span><%= t(".email_typo_html", suggested: @identity.suggested_email) %></span>
<div class="banner-actions">
<button type="button" class="secondary small-btn" onclick="
var suggested = this.closest('[data-suggested-email]').dataset.suggestedEmail;
Expand Down
19 changes: 19 additions & 0 deletions config/brakeman.ignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,25 @@
470
],
"note": "this is clamped to valid PublicIdentifiable models in @shortcodes.rb"
},
{
"warning_type": "Unmaintained Dependency",
"warning_code": 122,
"fingerprint": "98b26f60d776fd41ee6f088c833725145be9aac2d7c5b33780241c273622db42",
"check_name": "EOLRails",
"message": "Support for Rails 8.0.5.1 ends on 2026-11-07",
"file": "Gemfile.lock",
"line": 466,
"link": "https://brakemanscanner.org/docs/warning_types/unmaintained_dependency/",
"code": null,
"render_path": null,
"location": null,
"user_input": null,
"confidence": "Medium",
"cwe_id": [
1104
],
"note": "rails 8.0 support ends 2026-11-07 \u2014 upgrade to 8.1 before then and drop this ignore. added so --ensure-latest brakeman 8.1.0 doesn't block unrelated PRs."
}
],
"brakeman_version": "8.0.4"
Expand Down
2 changes: 1 addition & 1 deletion config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ en:
email_code: We'll send a one-time code to this address.
continue: Continue
wrong_email: "← Wrong email? Go back"
email_typo: "Looks like a typo... did you mean <strong>%{suggested}</strong>?"
email_typo_html: "Looks like a typo... did you mean <strong>%{suggested}</strong>?"
email_typo_fix: "yeah, that one! →"
email_typo_dismiss: "no, it's correct"
update:
Expand Down
76 changes: 76 additions & 0 deletions spec/jobs/persona/link_staff_inquiry_job_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
require "rails_helper"

RSpec.describe Persona::LinkStaffInquiryJob, type: :job do
let(:identity) { create(:identity) }
let(:reviewer_identity) { create(:identity, primary_email: "reviewer@hackclub.com") }
let!(:reviewer) { create(:backend_user, identity: reviewer_identity, manual_document_verifier: true) }
let(:inquiry_id) { "inq_dashboard123" }
let(:mock_service) { instance_double(Persona::APIService) }

before { allow(Persona).to receive(:instance).and_return(mock_service) }

def perform(creator: "reviewer@hackclub.com", reference_id: identity.public_id)
described_class.perform_now(inquiry_id: inquiry_id, creator: creator, reference_id: reference_id)
end

it "creates a draft persona verification flagged for manual review" do
expect { perform }.to change(Verification::PersonaVerification, :count).by(1)

verification = identity.verifications.last
expect(verification).to be_draft
expect(verification.persona_inquiry_id).to eq(inquiry_id)
expect(verification).to be_staff_created_inquiry
expect(verification).not_to be_auto_approvable
end

it "records which reviewer linked it" do
perform

activity = PublicActivity::Activity.where("key LIKE ?", "%persona_inquiry_linked_by_staff").sole
expect(activity.owner).to eq(reviewer)
expect(activity.parameters[:inquiry_id]).to eq(inquiry_id)
end

it "matches the creator email case-insensitively" do
expect { perform(creator: "Reviewer@HackClub.com") }.to change(Verification::PersonaVerification, :count).by(1)
end

it "ignores creators who aren't backend users" do
expect { perform(creator: "someone@example.com") }.not_to change(Verification, :count)
end

it "ignores backend users without review rights" do
reviewer.update!(manual_document_verifier: false)
expect { perform }.not_to change(Verification, :count)
end

it "ignores deactivated backend users" do
reviewer.deactivate!
expect { perform }.not_to change(Verification, :count)
end

it "ignores a reference id that isn't an identity" do
expect { perform(reference_id: "ident!nope") }.not_to change(Verification, :count)
end

it "is a no-op when the inquiry is already linked" do
create(:persona_verification, identity: identity, persona_inquiry_id: inquiry_id)
expect { perform }.not_to change(Verification, :count)
end

it "leaves a pending verification alone" do
record = create(:identity_persona_record, identity: identity)
create(:persona_verification, :pending, identity: identity, persona_record: record, persona_inquiry_id: record.inquiry_id)
expect { perform }.not_to change(Verification, :count)
end

it "replaces a stale draft and expires its inquiry" do
stale = create(:persona_verification, identity: identity)
expect(mock_service).to receive(:expire_inquiry).with(stale.persona_inquiry_id)

perform

expect(Verification.exists?(stale.id)).to be(false)
expect(identity.verifications.sole.persona_inquiry_id).to eq(inquiry_id)
end
end
35 changes: 35 additions & 0 deletions spec/jobs/persona/verification_pipeline_job_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
require "rails_helper"

RSpec.describe Persona::VerificationPipelineJob, type: :job do
let(:identity) { create(:identity) }
let(:record) { create(:identity_persona_record, identity: identity) }

before { allow(ResemblanceNoticerEngine).to receive(:run) }

def pending_verification(**attrs)
create(:persona_verification, :pending, identity: identity, persona_record: record, persona_inquiry_id: record.inquiry_id, **attrs)
end

context "with a staff-created inquiry" do
let(:verification) { pending_verification(issues: [ Verification::PersonaVerification::STAFF_CREATED_ISSUE ]) }

it "sends it to manual review even when decisioning approves" do
expect {
described_class.perform_now(verification)
}.to have_enqueued_job(Slack::NotifyReviewQueueJob)

verification.reload
expect(verification).to be_pending
expect(verification.issues).to eq([ Verification::PersonaVerification::STAFF_CREATED_ISSUE ])
end
end

context "with an ordinary inquiry" do
let(:verification) { pending_verification }

it "still auto-approves" do
described_class.perform_now(verification)
expect(verification.reload).to be_approved
end
end
end
48 changes: 48 additions & 0 deletions spec/requests/webhooks/persona_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,54 @@ def sign_payload(body, secret: webhook_secret, timestamp: Time.now.to_i)
end
end

context "with inquiry.created" do
def created_payload(creator:)
{
data: {
attributes: {
name: "inquiry.created",
payload: {
data: {
id: "inq_dashboard123",
type: "inquiry",
attributes: { status: "created", "reference-id": identity.public_id, creator: creator }
}
}
}
}
}.to_json
end

def post_signed(body)
post "/webhooks/persona",
params: body,
headers: { "Content-Type" => "application/json", "Persona-Signature" => sign_payload(body) }
end

it "enqueues LinkStaffInquiryJob when a dashboard user created the inquiry" do
body = created_payload(creator: "reviewer@hackclub.com")

expect { post_signed(body) }.to have_enqueued_job(Persona::LinkStaffInquiryJob).with(
inquiry_id: "inq_dashboard123", creator: "reviewer@hackclub.com", reference_id: identity.public_id
)
expect(response).to have_http_status(:ok)
end

it "ignores API-created inquiries" do
body = created_payload(creator: "API")

expect { post_signed(body) }.not_to have_enqueued_job
expect(response).to have_http_status(:ok)
end

it "ignores inquiries without a creator" do
body = created_payload(creator: nil)

expect { post_signed(body) }.not_to have_enqueued_job
expect(response).to have_http_status(:ok)
end
end

context "with multiple signatures (key rotation)" do
it "accepts if any v1 signature is valid" do
valid_sig = sign_payload(inquiry_completed_payload)
Expand Down
Loading