identity-vault/app/models/identity.rb
nora 9998147a4e
epic: overhaul program management experience (#188)
* temp commit

* lemme do it

* nope

* let them do it too

* collab invite model

* better visuals on progman

* waow

* danger will robinson

* show apps on backend & link user

* first pass on app auditability!

* no lastnaming admins

* async frame that shit!

* waugh

* can't add yourself

* fix reinvite

* sidebar badging

* lint...

* gotta be on the app!

* let that get rescued by applcon

* already in revoke_all_authorizations

* woag

* the routes you grew up with no longer exist

* what would the UI for that even be?

* sorch

* much better!

* frickin validations
2026-03-02 22:15:13 -05:00

453 lines
16 KiB
Ruby

# == Schema Information
#
# Table name: identities
#
# id :bigint not null, primary key
# aadhaar_number_bidx :string
# aadhaar_number_ciphertext :text
# birthday :date
# came_in_through_adult_program :boolean default(FALSE)
# country :integer
# deleted_at :datetime
# first_name :string
# hq_override :boolean default(FALSE)
# is_alum :boolean default(FALSE)
# last_name :string
# legal_first_name :string
# legal_last_name :string
# permabanned :boolean default(FALSE)
# phone_number :string
# primary_email :string
# ysws_eligible :boolean
# created_at :datetime not null
# updated_at :datetime not null
# primary_address_id :bigint
# slack_id :string
#
# Indexes
#
# index_identities_on_aadhaar_number_bidx (aadhaar_number_bidx) UNIQUE
# index_identities_on_deleted_at (deleted_at)
# index_identities_on_primary_address_id (primary_address_id)
# index_identities_on_slack_id (slack_id)
#
# Foreign Keys
#
# fk_rails_... (primary_address_id => addresses.id)
#
class Identity < ApplicationRecord
has_paper_trail
acts_as_paranoid
include PublicActivity::Model
tracked owner: ->(controller, model) { controller&.user_for_public_activity }, only: [ :create, :admin_update ]
include CountryEnumable
include PublicIdentifiable
set_public_id_prefix "ident"
has_country_enum
has_many :sessions, class_name: "IdentitySession", dependent: :destroy
has_many :login_attempts, dependent: :destroy
has_many :login_codes, class_name: "Identity::LoginCode", dependent: :destroy
has_many :v2_login_codes, class_name: "Identity::V2LoginCode", dependent: :destroy
has_many :totps, class_name: "Identity::TOTP", dependent: :destroy
has_many :backup_codes, class_name: "Identity::BackupCode", dependent: :destroy
has_many :webauthn_credentials, class_name: "Identity::WebauthnCredential", dependent: :destroy
has_many :email_change_requests, class_name: "Identity::EmailChangeRequest", dependent: :destroy
has_one :backend_user, class_name: "Backend::User", dependent: :destroy
def active_for_backend?
backend_user&.active?
end
has_many :documents, class_name: "Identity::Document", dependent: :destroy
has_many :verifications, class_name: "Verification", dependent: :destroy
has_many :document_verifications, class_name: "Verification::DocumentVerification", dependent: :destroy
has_many :aadhaar_verifications, class_name: "Verification::AadhaarVerification", dependent: :destroy
has_many :vouch_verifications, class_name: "Verification::VouchVerification", dependent: :destroy
has_many :addresses, class_name: "Address", dependent: :destroy
belongs_to :primary_address, class_name: "Address", optional: true
has_many :access_tokens, -> { where(revoked_at: nil) }, class_name: "OAuthToken", foreign_key: :resource_owner_id
has_many :programs, through: :access_tokens, source: :application
has_many :resemblances, class_name: "Identity::Resemblance", dependent: :destroy
has_many :break_glass_records, as: :break_glassable, dependent: :destroy
has_many :all_access_tokens, class_name: "Doorkeeper::AccessToken", foreign_key: :resource_owner_id, dependent: :destroy
has_many :all_programs, through: :all_access_tokens, source: :application
has_many :owned_developer_apps, class_name: "Program", foreign_key: :owner_identity_id, dependent: :nullify
has_many :program_collaborators, dependent: :destroy
has_many :collaborated_programs, -> { merge(ProgramCollaborator.accepted) },
through: :program_collaborators, source: :program
validates :first_name, :last_name, :country, :primary_email, :birthday, presence: true
validates :primary_email, uniqueness: { conditions: -> { where(deleted_at: nil) } }
validate :validate_primary_email, if: -> { new_record? || primary_email_changed? }
validates :slack_id, uniqueness: { conditions: -> { where(deleted_at: nil) } }, allow_blank: true
validates :aadhaar_number, uniqueness: true, allow_blank: true
validates :aadhaar_number, format: { with: /\A\d{12}\z/, message: "must be 12 digits" }, if: -> { aadhaar_number.present? }
scope :search, ->(term) {
return all if term.blank?
parts = term.split
if parts.length == 2
# Search for "firstname lastname" pattern
where("first_name ILIKE ? AND last_name ILIKE ?", "%#{parts[0]}%", "%#{parts[1]}%")
.or(where("first_name ILIKE ? OR last_name ILIKE ? OR primary_email ILIKE ? OR slack_id ILIKE ?",
"%#{term}%", "%#{term}%", "%#{term}%", "%#{term}%"))
else
sanitized_term = "%#{term}%"
where(
"first_name ILIKE ? OR last_name ILIKE ? OR primary_email ILIKE ? OR slack_id ILIKE ?",
sanitized_term, sanitized_term, sanitized_term, sanitized_term
)
end
}
scope :with_fatal_rejections, -> {
joins(:verifications).where(verifications: { fatal: true, ignored_at: nil })
}
scope :verified_but_ysws_ineligible, -> {
joins(:verifications).where(verifications: { status: "approved", ignored_at: nil }).where(ysws_eligible: false)
}
validate :birthday_must_be_at_least_six_years_ago
has_encrypted :aadhaar_number
blind_index :aadhaar_number
validate :legal_names_must_be_complete
before_validation :downcase_email
before_commit :copy_legal_name_if_needed, on: :create
def full_name = "#{first_name} #{last_name}"
def self.slack_authorize_url(redirect_uri)
params = {
client_id: ENV["SLACK_CLIENT_ID"],
redirect_uri: redirect_uri,
state: SecureRandom.hex(24),
user_scope: "users.profile:read,users:read,users:read.email"
}
URI.parse("https://slack.com/oauth/v2/authorize?#{params.to_query}")
end
def self.link_slack_account(code, redirect_uri, current_identity)
response = HTTP.post("https://slack.com/api/oauth.v2.access", form: {
client_id: ENV["SLACK_CLIENT_ID"],
client_secret: ENV["SLACK_CLIENT_SECRET"],
code: code,
redirect_uri: redirect_uri
})
data = JSON.parse(response.body.to_s)
return { success: false, error: "Failed to exchange OAuth code" } unless data["ok"]
# Get user info
user_response = HTTP.auth("Bearer #{data["authed_user"]["access_token"]}")
.get("https://slack.com/api/users.info?user=#{data["authed_user"]["id"]}")
user_data = JSON.parse(user_response.body.to_s)
return { success: false, error: "Failed to get Slack user information" } unless user_data["ok"]
slack_id = data.dig("authed_user", "id")
existing_identity = find_by(slack_id: slack_id)
if existing_identity && existing_identity != current_identity
return { success: false, error: "This Slack account is already linked to another identity" }
end
current_identity.update!(slack_id: slack_id)
{ success: true, slack_id: slack_id }
end
def pending_collaboration_invitations
ProgramCollaborator.pending
.where(identity_id: id)
.or(ProgramCollaborator.pending.where(identity_id: nil, invited_email: primary_email))
.includes(:program)
end
def accessible_developer_apps
Program.where(id: owned_developer_apps.select(:id))
.or(Program.where(id: collaborated_programs.select(:id)))
end
def slack_linked? = slack_id.present?
def onboarding_scenario_instance
scenario_slug = onboarding_scenario || "default"
scenario_class = OnboardingScenarios::Base.find_by_slug(scenario_slug) || OnboardingScenarios::DefaultJoin
scenario_class.new(self)
end
def onboarding_step
return :basic_info unless persisted?
unless verifications.where(status: %w[approved pending]).any?
if country == "IN" && Flipper.enabled?(:authbridge_aadhaar_2025_07_10, self)
return :aadhaar
else
return :document
end
end
return :address unless primary_address_id.present?
:submitted
end
def onboarding_complete? = onboarding_step == :submitted
def needs_documents? = country != "IN" && onboarding_step == :document
def needs_aadhaar? = country == "IN" && Flipper.enabled?(:authbridge_aadhaar_2025_07_10, self) && onboarding_step == :aadhaar
def latest_verification = verifications.not_ignored.order(created_at: :desc).first
# EWWWW
def verification_status
return "ineligible" if permabanned
verfs = verifications.not_ignored
return "needs_submission" if verfs.empty?
verification_statuses = verfs.pluck(:status)
return "verified" if verification_statuses.include?("approved")
return "pending" if verification_statuses.include?("pending")
verfs.fatal_rejections.any? ? "ineligible" : "needs_submission"
end
def verification_status_reason
return nil unless latest_verification&.rejected?
latest_verification.rejection_reason
end
def verification_status_reason_details
return nil unless latest_verification&.rejected?
latest_verification.rejection_reason_details
end
def needs_resubmission?
# Only show rejection details and resubmission prompts if:
# 1. There are rejected verifications with retryable reasons
# 2. AND there are no pending verifications (user hasn't resubmitted yet)
verifications.not_ignored.retryable_rejections.any? &&
!verifications.not_ignored.pending.any?
end
def rejected_verifications_needing_resubmission
return Verification.none unless needs_resubmission?
verifications.not_ignored.retryable_rejections
end
def in_resubmission_flow?
# Show resubmission context if there are rejected verifications with retryable reasons
# This is used in the document form to show context about previous rejections
verification_status == "pending" &&
verifications.not_ignored.retryable_rejections.any?
end
def rejected_verifications_for_context
verifications.not_ignored.retryable_rejections
end
# TODO: this is schnasty
def onboarding_redirect_path
return Rails.application.routes.url_helpers.basic_info_onboarding_path unless persisted?
if country == "IN" && Flipper.enabled?(:authbridge_aadhaar_2025_07_10, self)
return Rails.application.routes.url_helpers.aadhaar_onboarding_path if needs_aadhaar_upload?
return Rails.application.routes.url_helpers.aadhaar_step_2_onboarding_path unless aadhaar_verifications.pending.any?
else
return Rails.application.routes.url_helpers.document_onboarding_path if needs_document_upload?
end
return Rails.application.routes.url_helpers.address_onboarding_path unless primary_address_id.present?
Rails.application.routes.url_helpers.submitted_onboarding_path
end
def needs_document_upload?
return false if country == "IN" && Flipper.enabled?(:authbridge_aadhaar_2025_07_10, self)
return false if verification_status == "ineligible"
return true unless verifications.not_ignored.where(status: %w[approved pending]).any?
return false if verification_status == "verified"
needs_resubmission?
end
def needs_aadhaar_upload?
return false unless country == "IN"
return false if verification_status == "ineligible"
return true unless verifications.not_ignored.where(status: %w[approved pending draft]).any?
return false if verification_status == "verified"
needs_resubmission?
end
def under_13? = age <= 13
def locked? = locked_at.present?
def unlock! = update!(locked_at: nil)
def lock!
update!(locked_at: Time.now)
sessions.destroy_all
end
def self.calculate_age(birthday)
today = Date.today
age = today.year - birthday.year
age -= 1 if today < birthday + age.years
age
end
def age
self.class.calculate_age(birthday)
end
def totp = totps.verified.first
def backup_codes_enabled? = backup_codes.active.any?
def webauthn_enabled? = webauthn_credentials.any?
# Encode identity ID as base64url for WebAuthn user.id
# Uses 64-bit unsigned big-endian binary format
def webauthn_user_id
user_id_binary = [ id ].pack("Q>")
Base64.urlsafe_encode64(user_id_binary, padding: false)
end
def available_step_up_methods
methods = []
methods << :totp if totp.present?
methods << :backup_code if backup_codes_enabled?
methods << :webauthn if webauthn_enabled?
# Future: methods << :sms if sms_verified?
methods
end
# Generic 2FA method helpers
def two_factor_methods
[
totps.verified,
webauthn_credentials
# Future: sms_two_factors.verified,
].flatten.compact
end
def has_two_factor_method?
two_factor_methods.any?
end
def primary_two_factor_method
two_factor_methods.first
end
def requires_two_factor?
use_two_factor_authentication? && has_two_factor_method?
end
def legacy_migrated? = legacy_migrated_at.present?
def suggested_aadhaar_password
name = "#{legal_first_name}#{legal_last_name}".presence || "#{first_name}#{last_name}"
"#{name.gsub(" ", "")[...4].upcase}#{birthday.year}"
end
def to_saml_nameid(options = {})
SAML2::NameID.new(
"HCID_#{Rails.env.development? ? "DEV" : "PROD"}_#{hashid}",
SAML2::NameID::Format::PERSISTENT,
**options
)
end
# spray & pray - SAML2 gem will only pull the attrs an SP asks for in its metadata
def to_saml_attributes
attrs = []
attrs << SAML2::Attribute.new("User.Email", primary_email, "User Email", SAML2::Attribute::NameFormats::UNSPECIFIED)
attrs << SAML2::Attribute.new("User.FirstName", first_name, "User First Name", SAML2::Attribute::NameFormats::UNSPECIFIED)
attrs << SAML2::Attribute.new("User.LastName", last_name, "User Last Name", SAML2::Attribute::NameFormats::UNSPECIFIED)
attrs << SAML2::Attribute.new("email", primary_email, "User Email", SAML2::Attribute::NameFormats::UNSPECIFIED)
attrs << SAML2::Attribute.new("firstName", first_name, "User First Name", SAML2::Attribute::NameFormats::UNSPECIFIED)
attrs << SAML2::Attribute.new("lastName", last_name, "User Last Name", SAML2::Attribute::NameFormats::UNSPECIFIED)
attrs
end
def has_fatal_rejection? = verifications.not_ignored.fatal_rejections.exists?
alias_method :to_param, :public_id
private
def downcase_email
self.primary_email = primary_email&.downcase
end
def copy_legal_name_if_needed
self.legal_first_name = first_name if legal_first_name.blank?
self.legal_last_name = last_name if legal_last_name.blank?
end
def legal_names_must_be_complete
if legal_first_name.present? && legal_last_name.blank?
errors.add(:legal_last_name, "must be present when legal first name is provided")
elsif legal_last_name.present? && legal_first_name.blank?
errors.add(:legal_first_name, "must be present when legal last name is provided")
end
end
def birthday_must_be_at_least_six_years_ago
return unless birthday.present?
six_years_ago = Date.current - 6.years
if birthday > six_years_ago
errors.add(:base, "Are you sure about that birthday?")
end
end
def validate_primary_email
return unless primary_email.present?
address = ValidEmail2::Address.new(primary_email)
unless address.valid?
errors.add(:primary_email, I18n.t("errors.attributes.primary_email.invalid_format"))
return
end
return unless Rails.env.production?
if address.disposable?
errors.add(:primary_email, I18n.t("errors.attributes.primary_email.temporary"))
return
end
unless address.valid_mx?
errors.add(:primary_email, I18n.t("errors.attributes.primary_email.no_mx_record"))
end
end
end