# == 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