gitlabhq/gitlabhq

View on GitHub
app/models/member.rb

Summary

Maintainability
C
1 day
Test Coverage
# frozen_string_literal: true

class Member < ApplicationRecord
  include AfterCommitQueue
  include Sortable
  include Importable
  include Expirable
  include Gitlab::Access
  include Presentable
  include Gitlab::Utils::StrongMemoize
  include FromUnion

  attr_accessor :raw_invite_token

  belongs_to :created_by, class_name: "User"
  belongs_to :user
  belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations

  delegate :name, :username, :email, to: :user, prefix: true

  validates :user, presence: true, unless: :invite?
  validates :source, presence: true
  validates :user_id, uniqueness: { scope: [:source_type, :source_id],
                                    message: "already exists in source",
                                    allow_nil: true }
  validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true
  validate :higher_access_level_than_group, unless: :importing?
  validates :invite_email,
    presence: {
      if: :invite?
    },
    devise_email: {
      allow_nil: true
    },
    uniqueness: {
      scope: [:source_type, :source_id],
      allow_nil: true
    }

  # This scope encapsulates (most of) the conditions a row in the member table
  # must satisfy if it is a valid permission. Of particular note:
  #
  #   * Access requests must be excluded
  #   * Blocked users must be excluded
  #   * Invitations take effect immediately
  #   * expires_at is not implemented. A background worker purges expired rows
  scope :active, -> do
    is_external_invite = arel_table[:user_id].eq(nil).and(arel_table[:invite_token].not_eq(nil))
    user_is_active = User.arel_table[:state].eq(:active)

    user_ok = Arel::Nodes::Grouping.new(is_external_invite).or(user_is_active)

    left_join_users
      .where(user_ok)
      .where(requested_at: nil)
      .reorder(nil)
  end

  # Like active, but without invites. For when a User is required.
  scope :active_without_invites_and_requests, -> do
    left_join_users
      .where(users: { state: 'active' })
      .non_request
      .reorder(nil)
  end

  scope :invite, -> { where.not(invite_token: nil) }
  scope :non_invite, -> { where(invite_token: nil) }
  scope :request, -> { where.not(requested_at: nil) }
  scope :non_request, -> { where(requested_at: nil) }

  scope :has_access, -> { active.where('access_level > 0') }

  scope :guests, -> { active.where(access_level: GUEST) }
  scope :reporters, -> { active.where(access_level: REPORTER) }
  scope :developers, -> { active.where(access_level: DEVELOPER) }
  scope :maintainers, -> { active.where(access_level: MAINTAINER) }
  scope :masters, -> { maintainers } # @deprecated
  scope :owners,  -> { active.where(access_level: OWNER) }
  scope :owners_and_maintainers, -> { active.where(access_level: [OWNER, MAINTAINER]) }
  scope :owners_and_masters,  -> { owners_and_maintainers } # @deprecated
  scope :with_user, -> (user) { where(user: user) }

  scope :with_source_id, ->(source_id) { where(source_id: source_id) }

  scope :order_name_asc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'ASC')) }
  scope :order_name_desc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'DESC')) }
  scope :order_recent_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'DESC')) }
  scope :order_oldest_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'ASC')) }

  scope :on_project_and_ancestors, ->(project) { where(source: [project] + project.ancestors) }

  before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? }

  after_create :send_invite, if: :invite?, unless: :importing?
  after_create :send_request, if: :request?, unless: :importing?
  after_create :create_notification_setting, unless: [:pending?, :importing?]
  after_create :post_create_hook, unless: [:pending?, :importing?]
  after_update :post_update_hook, unless: [:pending?, :importing?]
  after_destroy :destroy_notification_setting
  after_destroy :post_destroy_hook, unless: :pending?
  after_commit :refresh_member_authorized_projects

  default_value_for :notification_level, NotificationSetting.levels[:global]

  class << self
    def search(query)
      joins(:user).merge(User.search(query))
    end

    def search_invite_email(query)
      invite.where(['invite_email ILIKE ?', "%#{query}%"])
    end

    def filter_by_2fa(value)
      case value
      when 'enabled'
        left_join_users.merge(User.with_two_factor)
      when 'disabled'
        left_join_users.merge(User.without_two_factor)
      else
        all
      end
    end

    def sort_by_attribute(method)
      case method.to_s
      when 'access_level_asc' then reorder(access_level: :asc)
      when 'access_level_desc' then reorder(access_level: :desc)
      when 'recent_sign_in' then order_recent_sign_in
      when 'oldest_sign_in' then order_oldest_sign_in
      when 'last_joined' then order_created_desc
      when 'oldest_joined' then order_created_asc
      else
        order_by(method)
      end
    end

    def left_join_users
      users = User.arel_table
      members = Member.arel_table

      member_users = members.join(users, Arel::Nodes::OuterJoin)
                             .on(members[:user_id].eq(users[:id]))
                             .join_sources

      joins(member_users)
    end

    def access_for_user_ids(user_ids)
      where(user_id: user_ids).has_access.pluck(:user_id, :access_level).to_h
    end

    def find_by_invite_token(invite_token)
      invite_token = Devise.token_generator.digest(self, :invite_token, invite_token)
      find_by(invite_token: invite_token)
    end

    def add_user(source, user, access_level, existing_members: nil, current_user: nil, expires_at: nil, ldap: false)
      # rubocop: disable CodeReuse/ServiceClass
      # `user` can be either a User object, User ID or an email to be invited
      member = retrieve_member(source, user, existing_members)
      access_level = retrieve_access_level(access_level)

      return member unless can_update_member?(current_user, member)

      set_member_attributes(
        member,
        access_level,
        current_user: current_user,
        expires_at: expires_at,
        ldap: ldap
      )

      if member.request?
        ::Members::ApproveAccessRequestService.new(
          current_user,
          access_level: access_level
        ).execute(
          member,
          skip_authorization: ldap,
          skip_log_audit_event: ldap
        )
      else
        member.save
      end

      member
      # rubocop: enable CodeReuse/ServiceClass
    end

    # Populates the attributes of a member.
    #
    # This logic resides in a separate method so that EE can extend this logic,
    # without having to patch the `add_user` method directly.
    def set_member_attributes(member, access_level, current_user: nil, expires_at: nil, ldap: false)
      member.attributes = {
        created_by: member.created_by || current_user,
        access_level: access_level,
        expires_at: expires_at
      }
    end

    def add_users(source, users, access_level, current_user: nil, expires_at: nil)
      return [] unless users.present?

      emails, users, existing_members = parse_users_list(source, users)

      self.transaction do
        (emails + users).map! do |user|
          add_user(
            source,
            user,
            access_level,
            existing_members: existing_members,
            current_user: current_user,
            expires_at: expires_at
          )
        end
      end
    end

    def access_levels
      Gitlab::Access.sym_options
    end

    private

    def parse_users_list(source, list)
      emails, user_ids, users = [], [], []
      existing_members = {}

      list.each do |item|
        case item
        when User
          users << item
        when Integer
          user_ids << item
        when /\A\d+\Z/
          user_ids << item.to_i
        when Devise.email_regexp
          emails << item
        end
      end

      if user_ids.present?
        users.concat(User.where(id: user_ids))
        existing_members = source.members_and_requesters.where(user_id: user_ids).index_by(&:user_id)
      end

      [emails, users, existing_members]
    end

    # This method is used to find users that have been entered into the "Add members" field.
    # These can be the User objects directly, their IDs, their emails, or new emails to be invited.
    def retrieve_user(user)
      return user if user.is_a?(User)

      User.find_by(id: user) || User.find_by(email: user) || user
    end

    def retrieve_member(source, user, existing_members)
      user = retrieve_user(user)

      if user.is_a?(User)
        if existing_members
          existing_members[user.id] || source.members.build(user_id: user.id)
        else
          source.members_and_requesters.find_or_initialize_by(user_id: user.id)
        end
      else
        source.members.build(invite_email: user)
      end
    end

    def retrieve_access_level(access_level)
      access_levels.fetch(access_level) { access_level.to_i }
    end

    def can_update_member?(current_user, member)
      # There is no current user for bulk actions, in which case anything is allowed
      !current_user || current_user.can?(:"update_#{member.type.underscore}", member)
    end
  end

  def real_source_type
    source_type
  end

  def access_field
    access_level
  end

  def invite?
    self.invite_token.present?
  end

  def request?
    requested_at.present?
  end

  def pending?
    invite? || request?
  end

  def accept_request
    return false unless request?

    updated = self.update(requested_at: nil)
    after_accept_request if updated

    updated
  end

  def accept_invite!(new_user)
    return false unless invite?

    self.invite_token = nil
    self.invite_accepted_at = Time.now.utc

    self.user = new_user

    saved = self.save

    after_accept_invite if saved

    saved
  end

  def decline_invite!
    return false unless invite?

    destroyed = self.destroy

    after_decline_invite if destroyed

    destroyed
  end

  def generate_invite_token
    raw, enc = Devise.token_generator.generate(self.class, :invite_token)
    @raw_invite_token = raw
    self.invite_token = enc
  end

  def generate_invite_token!
    generate_invite_token && save(validate: false)
  end

  def resend_invite
    return unless invite?

    generate_invite_token! unless @raw_invite_token

    send_invite
  end

  def create_notification_setting
    user.notification_settings.find_or_create_for(source)
  end

  def destroy_notification_setting
    notification_setting&.destroy
  end

  def notification_setting
    @notification_setting ||= user&.notification_settings_for(source)
  end

  # rubocop: disable CodeReuse/ServiceClass
  def notifiable?(type, opts = {})
    # always notify when there isn't a user yet
    return true if user.blank?

    NotificationRecipientService.notifiable?(user, type, notifiable_options.merge(opts))
  end
  # rubocop: enable CodeReuse/ServiceClass

  # Find the user's group member with a highest access level
  def highest_group_member
    strong_memoize(:highest_group_member) do
      next unless user_id && source&.ancestors&.any?

      GroupMember.where(source: source.ancestors, user_id: user_id).order(:access_level).last
    end
  end

  private

  def send_invite
    # override in subclass
  end

  def send_request
    notification_service.new_access_request(self)
  end

  def post_create_hook
    system_hook_service.execute_hooks_for(self, :create)
  end

  def post_update_hook
    system_hook_service.execute_hooks_for(self, :update)
  end

  def post_destroy_hook
    system_hook_service.execute_hooks_for(self, :destroy)
  end

  # Refreshes authorizations of the current member.
  #
  # This method schedules a job using Sidekiq and as such **must not** be called
  # in a transaction. Doing so can lead to the job running before the
  # transaction has been committed, resulting in the job either throwing an
  # error or not doing any meaningful work.
  # rubocop: disable CodeReuse/ServiceClass
  def refresh_member_authorized_projects
    # If user/source is being destroyed, project access are going to be
    # destroyed eventually because of DB foreign keys, so we shouldn't bother
    # with refreshing after each member is destroyed through association
    return if destroyed_by_association.present?

    UserProjectAccessChangedService.new(user_id).execute
  end
  # rubocop: enable CodeReuse/ServiceClass

  def after_accept_invite
    post_create_hook
  end

  def after_decline_invite
    # override in subclass
  end

  def after_accept_request
    post_create_hook
  end

  # rubocop: disable CodeReuse/ServiceClass
  def system_hook_service
    SystemHooksService.new
  end
  # rubocop: enable CodeReuse/ServiceClass

  # rubocop: disable CodeReuse/ServiceClass
  def notification_service
    NotificationService.new
  end
  # rubocop: enable CodeReuse/ServiceClass

  def notifiable_options
    {}
  end

  def higher_access_level_than_group
    if highest_group_member && highest_group_member.access_level > access_level
      error_parameters = { access: highest_group_member.human_access, group_name: highest_group_member.group.name }

      errors.add(:access_level, s_("should be greater than or equal to %{access} inherited membership from group %{group_name}") % error_parameters)
    end
  end
end

Member.prepend_if_ee('EE::Member')