ManageIQ/manageiq

View on GitHub
app/models/user.rb

Summary

Maintainability
A
0 mins
Test Coverage
B
88%
class User < ApplicationRecord
  include RelationshipMixin
  acts_as_miq_taggable
  has_secure_password
  include CustomAttributeMixin
  include ActiveVmAggregationMixin
  include TimezoneMixin
  include CustomActionsMixin
  include ExternalUrlMixin

  before_destroy :check_reference, :prepend => true

  has_many   :miq_approvals, :as => :approver
  has_many   :miq_approval_stamps,  :class_name => "MiqApproval", :foreign_key => :stamper_id
  has_many   :miq_requests, :foreign_key => :requester_id
  has_many   :vms,           :foreign_key => :evm_owner_id
  has_many   :miq_templates, :foreign_key => :evm_owner_id
  has_many   :miq_widgets
  has_many   :miq_widget_contents, :dependent => :destroy
  has_many   :miq_widget_sets, :as => :owner, :dependent => :destroy
  has_many   :miq_reports, :dependent => :nullify
  has_many   :service_orders, :dependent => :nullify
  has_many   :owned_shares, :class_name => "Share"
  has_many   :notification_recipients, :dependent => :delete_all
  has_many   :notifications, :through => :notification_recipients
  has_many   :unseen_notification_recipients, -> { unseen }, :class_name => 'NotificationRecipient'
  has_many   :unseen_notifications, :through => :unseen_notification_recipients, :source => :notification
  has_many   :authentications, :foreign_key => :evm_owner_id, :dependent => :nullify, :inverse_of => :evm_owner
  has_many   :sessions, :dependent => :destroy
  belongs_to :current_group, :class_name => "MiqGroup"
  has_and_belongs_to_many :miq_groups
  scope      :superadmins, lambda {
    joins(:miq_groups => {:miq_user_role => :miq_product_features})
      .where(:miq_product_features => {:identifier => MiqProductFeature::SUPER_ADMIN_FEATURE})
  }

  virtual_has_many :active_vms, :class_name => "VmOrTemplate"

  delegate   :miq_user_role, :current_tenant, :get_filters, :has_filters?, :get_managed_filters, :get_belongsto_filters,
             :to => :current_group, :allow_nil => true
  delegate   :super_admin_user?, :request_admin_user?, :self_service?, :limited_self_service?, :report_admin_user?, :only_my_user_tasks?,
             :to => :miq_user_role, :allow_nil => true

  validates :name, :presence => true, :length => {:maximum => 100}
  validates :first_name, :length => {:maximum => 100}
  validates :last_name, :length => {:maximum => 100}
  validates :userid, :presence => true, :unique_within_region => {:match_case => false}, :length => {:maximum => 255}
  validates :email, :format => {:with => MoreCoreExtensions::StringFormats::RE_EMAIL,
                                :allow_nil => true, :message => "must be a valid email address"},
                    :length => {:maximum => 255}
  validates :current_group, :inclusion => {:in => proc { |u| u.miq_groups }, :allow_nil => true, :if => :current_group_id_changed?}

  # use authenticate_bcrypt rather than .authenticate to avoid confusion
  # with the class method of the same name (User.authenticate)
  alias_method :authenticate_bcrypt, :authenticate

  serialize     :settings, Hash   # Implement settings column as a hash
  default_value_for(:settings) { {} }

  default_value_for :failed_login_attempts, 0

  scope :in_all_regions, ->(id) { where(:userid => User.default_scoped.where(:id => id).select(:userid)) }

  def self.with_roles_excluding(identifier, allowed_ids: nil)
    scope = where.not(
      :id => User
        .unscope(:select)
        .joins(:miq_groups => :miq_product_features)
        .where(:miq_product_features => {:identifier => identifier})
        .select(:id)
    )
    scope = scope.or(where(:id => allowed_ids)) if allowed_ids.present?
    scope
  end

  def self.scope_by_tenant?
    true
  end

  ACCESSIBLE_STRATEGY_WITHOUT_IDS = {:descendant_ids => :descendants, :ancestor_ids => :ancestors}.freeze

  def self.tenant_id_clause(user_or_group)
    strategy = Rbac.accessible_tenant_ids_strategy(self)
    tenant = user_or_group.try(:current_tenant)
    return [] if tenant.root?

    accessible_tenants = tenant.send(ACCESSIBLE_STRATEGY_WITHOUT_IDS[strategy])

    users_ids = accessible_tenants.collect(&:user_ids).flatten + tenant.user_ids

    return if users_ids.empty?

    {table_name => {:id => users_ids}}
  end

  def self.lookup_by_userid(userid)
    in_my_region.find_by(:userid => userid)
  end

  singleton_class.send(:alias_method, :find_by_userid, :lookup_by_userid)
  Vmdb::Deprecation.deprecate_methods(self, :find_by_userid => :lookup_by_userid)

  def self.lookup_by_userid!(userid)
    in_my_region.find_by!(:userid => userid)
  end

  singleton_class.send(:alias_method, :find_by_userid!, :lookup_by_userid!)
  Vmdb::Deprecation.deprecate_methods(singleton_class, :find_by_userid! => :lookup_by_userid!)

  def self.lookup_by_email(email)
    in_my_region.find_by(:email => email)
  end

  singleton_class.send(:alias_method, :find_by_email, :lookup_by_email)
  Vmdb::Deprecation.deprecate_methods(singleton_class, :find_by_email => :lookup_by_email)

  # find a user by lowercase email
  # often we have the most probably user object onhand. so use that if possible
  def self.lookup_by_lower_email(email, cache = [])
    email = email.downcase
    Array.wrap(cache).detect { |u| u.lower_email == email } || find_by(:lower_email => email)
  end

  singleton_class.send(:alias_method, :find_by_lower_email, :lookup_by_lower_email)
  Vmdb::Deprecation.deprecate_methods(singleton_class, :find_by_lower_email => :lookup_by_lower_email)

  def lower_email
    email&.downcase
  end

  virtual_attribute :lower_email, :string, :arel => ->(t) { t.grouping(t[:email].lower) }
  hide_attribute :lower_email

  def lower_userid
    userid&.downcase
  end

  virtual_attribute :lower_userid, :string, :arel => ->(t) { t.grouping(t[:userid].lower) }
  hide_attribute :lower_userid

  virtual_column :ldap_group, :type => :string, :uses => :current_group
  # FIXME: amazon_group too?
  virtual_column :miq_group_description, :type => :string, :uses => :current_group
  virtual_column :miq_user_role_name, :type => :string, :uses => {:current_group => :miq_user_role}

  def validate
    errors.add(:userid, "'system' is reserved for EVM internal operations") unless (userid =~ /^system$/i).nil?
  end

  before_validation :nil_email_field_if_blank
  before_validation :dummy_password_for_external_auth
  before_destroy :destroy_subscribed_widget_sets

  def check_reference
    present_ref = []
    %w[miq_requests vms miq_widgets miq_templates].each do |association|
      present_ref << association.classify unless public_send(association).first.nil?
    end

    unless present_ref.empty?
      errors.add(:base, "user '#{userid}' with id [#{id}] has references to other models: #{present_ref.join(" ")}")
      throw :abort
    end
  end

  def current_group_by_description=(group_description)
    if group_description
      desired_group = miq_groups.detect { |g| g.description == group_description }
      desired_group ||= MiqGroup.in_region(region_id).find_by(:description => group_description) if super_admin_user?
      self.current_group = desired_group if desired_group
    end
  end

  def nil_email_field_if_blank
    self.email = nil if email.blank?
  end

  def dummy_password_for_external_auth
    if password.blank? && password_digest.blank? &&
       !self.class.authenticator(userid).uses_stored_password?
      self.password = "dummy"
    end
  end

  def change_password(oldpwd, newpwd)
    auth = self.class.authenticator(userid)
    unless auth.uses_stored_password?
      raise MiqException::MiqEVMLoginError,
            _("password change not allowed when authentication mode is %{name}") % {:name => auth.class.proper_name}
    end
    if auth.authenticate(userid, oldpwd)
      self.password = newpwd
      save!
    end
  end

  def locked?
    ::Settings.authentication.max_failed_login_attempts.positive? && failed_login_attempts >= ::Settings.authentication.max_failed_login_attempts
  end

  def unlock!
    update!(:failed_login_attempts => 0)
  end

  def fail_login!
    update!(:failed_login_attempts => failed_login_attempts + 1)

    unlock_queue if locked?
  end

  def ldap_group
    current_group.try(:description)
  end
  alias_method :miq_group_description, :ldap_group

  def role_allows?(**options)
    Rbac.role_allows?(:user => self, **options)
  end

  def role_allows_any?(**options)
    Rbac.role_allows?(:user => self, :any => true, **options)
  end

  def miq_user_role_name
    miq_user_role.try(:name)
  end

  def self.authenticator(username = nil)
    Authenticator.for(::Settings.authentication.to_hash, username)
  end

  def self.authenticate(username, password, request = nil, options = {})
    user = authenticator(username).authenticate(username, password, request, options)
    user.try(:link_to_session, request)

    user
  end

  def link_to_session(request)
    return unless request
    return unless (session_id = request.session_options[:id])

    # dalli 3.1 switched to Abstract::PersistedStore from Abstract::Persisted and the resulting session id
    # changed from a string to a SessionID object that can't be coerced in finders. Convert this object to string via
    # the private_id method, see: https://github.com/rack/rack/issues/1432#issuecomment-571688819
    session_id = session_id.private_id if session_id.respond_to?(:private_id)

    sessions << Session.find_or_create_by(:session_id => session_id)
  end

  def broadcast_revoke_sessions
    if Settings.server.session_store == "cache"
      MiqQueue.broadcast(
        :class_name  => self.class.name,
        :instance_id => id,
        :method_name => :revoke_sessions
      )
    else
      # If using SQL or Memory, the sessions don't need to (or can't) be
      # revoked via a broadcast since the session/token stores are not server
      # specific, so execute it inline.
      revoke_sessions
    end
  end

  def revoke_sessions
    current_sessions = Session.where(:user_id => id)
    ManageIQ::Session.revoke(current_sessions.map(&:session_id))
    current_sessions.destroy_all

    TokenStore.token_caches.each do |_, token_store|
      token_store.delete_all_for_user(userid)
    end
  end

  def self.authenticate_with_http_basic(username, password, request = nil, options = {})
    authenticator(username).authenticate_with_http_basic(username, password, request, options)
  end

  def self.lookup_by_identity(username)
    authenticator(username).lookup_by_identity(username)
  end

  def self.authorize_user(userid)
    return if userid.blank? || admin?(userid)

    authenticator(userid).authorize_user(userid)
  end

  def self.authorize_user_with_system_token(userid, user_metadata = {})
    return if userid.blank? || user_metadata.blank? || admin?(userid)

    authenticator(userid).authorize_user_with_system_token(userid, user_metadata)
  end

  def logoff
    self.lastlogoff = Time.now.utc
    save
    AuditEvent.success(:event => "logoff", :message => "User #{userid} has logged off", :userid => userid)
  end

  def get_expressions(db = nil)
    sql = ["((search_type=? and search_key is null) or (search_type=? and search_key is null) or (search_type=? and search_key=?))",
           'default', 'global', 'user', userid
          ]
    unless db.nil?
      sql[0] += "and db=?"
      sql << db.to_s
    end
    MiqSearch.get_expressions(sql)
  end

  def with_my_timezone(&block)
    with_a_timezone(get_timezone, &block)
  end

  def get_timezone
    settings.fetch_path(:display, :timezone) || self.class.server_timezone
  end

  def miq_groups=(groups)
    super
    self.current_group = groups.first if current_group.nil? || !groups.include?(current_group)
  end

  def change_current_group
    user_groups = miq_group_ids
    user_groups.delete(current_group_id)
    raise _("The user's current group cannot be changed because the user does not belong to any other group") if user_groups.empty?

    self.current_group = MiqGroup.find_by(:id => user_groups.first)
    save!
  end

  def admin?
    self.class.admin?(userid)
  end

  def self.admin?(userid)
    userid == "admin"
  end

  def subscribed_widget_sets
    MiqWidgetSet.subscribed_for_user(self)
  end

  def destroy_subscribed_widget_sets
    subscribed_widget_sets.destroy_all
  end

  def accessible_vms
    if limited_self_service?
      vms
    elsif self_service?
      (vms + miq_groups.includes(:vms).collect(&:vms).flatten).uniq
    else
      Vm.all
    end
  end

  def regional_users
    self.class.regional_users(self)
  end

  def self.regional_users(user)
    where(:lower_userid => user.userid.downcase)
  end

  def self.super_admin
    in_my_region.find_by_userid("admin")
  end

  def self.current_tenant
    current_user.try(:current_tenant)
  end

  # Save the current user from the session object as a thread variable to allow lookup from other areas of the code
  def self.with_user(user, userid = nil)
    saved_user   = Thread.current[:user]
    saved_userid = Thread.current[:userid]
    self.current_user = user
    Thread.current[:userid] = userid if userid
    yield
  ensure
    Thread.current[:user]   = saved_user
    Thread.current[:userid] = saved_userid
  end

  def self.with_user_group(user, group, &block)
    return yield if user.nil?

    user = User.find(user) unless user.kind_of?(User)
    if group && group.kind_of?(MiqGroup)
      user.current_group = group
    elsif group != user.current_group_id
      group = MiqGroup.find_by(:id => group)
      user.current_group = group if group
    end
    User.with_user(user, &block)
  end

  def self.current_user=(user)
    Thread.current[:userid] = user.try(:userid)
    Thread.current[:user] = user
  end

  # avoid using this. pass current_user where possible
  def self.current_userid
    Thread.current[:userid]
  end

  def self.current_user
    Thread.current[:user] ||= lookup_by_userid(current_userid)
  end

  # parallel to MiqGroup.with_groups - only show users with these groups
  def self.with_groups(miq_group_ids)
    includes(:miq_groups).where(:miq_groups => {:id => miq_group_ids})
  end

  def self.missing_user_features(db_user)
    if !db_user
      "User"
    elsif !db_user.current_group
      "Group"
    elsif !db_user.current_group.miq_user_role
      "Role"
    end
  end

  def self.metadata_for_system_token(userid)
    return unless authenticator(userid).user_authorizable_with_system_token?

    user = in_my_region.find_by(:userid => userid)
    return if user.blank?

    {
      :userid      => user.userid,
      :name        => user.name,
      :email       => user.email,
      :first_name  => user.first_name,
      :last_name   => user.last_name,
      :group_names => user.miq_groups.try(:collect, &:description)
    }
  end

  def self.seed
    seed_data.each do |user_attributes|
      user_id = user_attributes[:userid]
      next if in_my_region.find_by_userid(user_id)

      log_attrs = user_attributes.slice(:name, :userid, :group)
      _log.info("Creating user with parameters #{log_attrs.inspect}")

      group_description = user_attributes.delete(:group)
      group = MiqGroup.in_my_region.find_by(:description => group_description)

      _log.info("Creating #{user_id} user...")
      user = create(user_attributes)
      user.miq_groups = [group] if group
      user.save
      _log.info("Creating #{user_id} user... Complete")
    end
  end

  def self.seed_file_name
    @seed_file_name ||= Rails.root.join("db", "fixtures", "#{table_name}.yml")
  end
  private_class_method :seed_file_name

  def self.seed_data
    File.exist?(seed_file_name) ? YAML.load_file(seed_file_name) : []
  end
  private_class_method :seed_data

  private

  def unlock_queue
    MiqQueue.create_with(:deliver_on => Time.now.utc + ::Settings.authentication.locked_account_timeout.to_i)
            .put_unless_exists(
      :class_name  => self.class.name,
      :instance_id => id,
      :method_name => 'unlock!',
      :priority    => MiqQueue::MAX_PRIORITY
    )
  end
end