member.rb 13 KB
Newer Older
1 2
# frozen_string_literal: true

Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
3
class Member < ActiveRecord::Base
4
  include AfterCommitQueue
5
  include Sortable
6
  include Importable
7
  include Expirable
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
8
  include Gitlab::Access
9
  include Presentable
10
  include Gitlab::Utils::StrongMemoize
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
11

12 13
  attr_accessor :raw_invite_token

14
  belongs_to :created_by, class_name: "User"
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
15
  belongs_to :user
16
  belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
17

Douwe Maan's avatar
Douwe Maan committed
18 19
  delegate :name, :username, :email, to: :user, prefix: true

Douwe Maan's avatar
Douwe Maan committed
20
  validates :user, presence: true, unless: :invite?
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
21
  validates :source, presence: true
22
  validates :user_id, uniqueness: { scope: [:source_type, :source_id],
Douwe Maan's avatar
Douwe Maan committed
23 24
                                    message: "already exists in source",
                                    allow_nil: true }
25
  validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true
26
  validate :higher_access_level_than_group, unless: :importing?
Douwe Maan's avatar
Douwe Maan committed
27 28 29 30
  validates :invite_email,
    presence: {
      if: :invite?
    },
31
    email: {
Douwe Maan's avatar
Douwe Maan committed
32 33 34 35 36 37
      allow_nil: true
    },
    uniqueness: {
      scope: [:source_type, :source_id],
      allow_nil: true
    }
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
38

39 40 41 42 43 44 45 46 47 48 49
  # 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)

50 51 52 53 54 55 56 57 58
    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.
59
  scope :active_without_invites_and_requests, -> do
60 61
    left_join_users
      .where(users: { state: 'active' })
62
      .non_request
63
      .reorder(nil)
64 65
  end

66
  scope :invite, -> { where.not(invite_token: nil) }
67
  scope :non_invite, -> { where(invite_token: nil) }
68
  scope :request, -> { where.not(requested_at: nil) }
69
  scope :non_request, -> { where(requested_at: nil) }
70 71 72 73 74 75

  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) }
76 77
  scope :maintainers, -> { active.where(access_level: MAINTAINER) }
  scope :masters, -> { maintainers } # @deprecated
78
  scope :owners,  -> { active.where(access_level: OWNER) }
79
  scope :owners_and_maintainers, -> { active.where(access_level: [OWNER, MAINTAINER]) }
80
  scope :owners_and_masters,  -> { owners_and_maintainers } # @deprecated
81
  scope :with_user, -> (user) { where(user: user) }
82

83 84 85 86
  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')) }
87

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

Douwe Maan's avatar
Douwe Maan committed
90
  before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? }
91

92
  after_create :send_invite, if: :invite?, unless: :importing?
James Lopez's avatar
James Lopez committed
93 94 95 96
  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?]
97
  after_destroy :destroy_notification_setting
98
  after_destroy :post_destroy_hook, unless: :pending?
99
  after_commit :refresh_member_authorized_projects
Douwe Maan's avatar
Douwe Maan committed
100

101 102
  default_value_for :notification_level, NotificationSetting.levels[:global]

103
  class << self
104 105 106 107
    def search(query)
      joins(:user).merge(User.search(query))
    end

108 109 110
    def filter_by_2fa(value)
      case value
      when 'enabled'
111
        left_join_users.merge(User.with_two_factor)
112 113 114 115 116 117 118
      when 'disabled'
        left_join_users.merge(User.without_two_factor)
      else
        all
      end
    end

119
    def sort_by_attribute(method)
120
      case method.to_s
121 122
      when 'access_level_asc' then reorder(access_level: :asc)
      when 'access_level_desc' then reorder(access_level: :desc)
123 124 125 126 127 128 129 130 131
      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

132 133 134 135
    def left_join_users
      users = User.arel_table
      members = Member.arel_table

136 137 138
      member_users = members.join(users, Arel::Nodes::OuterJoin)
                             .on(members[:user_id].eq(users[:id]))
                             .join_sources
139 140 141 142

      joins(member_users)
    end

Stan Hu's avatar
Stan Hu committed
143
    def access_for_user_ids(user_ids)
Adam Niedzielski's avatar
Adam Niedzielski committed
144
      where(user_id: user_ids).has_access.pluck(:user_id, :access_level).to_h
Stan Hu's avatar
Stan Hu committed
145 146
    end

147 148 149 150 151
    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

Rémy Coutable's avatar
Rémy Coutable committed
152
    def add_user(source, user, access_level, existing_members: nil, current_user: nil, expires_at: nil, ldap: false)
153
      # rubocop: disable CodeReuse/ServiceClass
154 155
      # `user` can be either a User object, User ID or an email to be invited
      member = retrieve_member(source, user, existing_members)
156
      access_level = retrieve_access_level(access_level)
157

158 159
      return member unless can_update_member?(current_user, member)

160 161 162 163 164 165 166
      set_member_attributes(
        member,
        access_level,
        current_user: current_user,
        expires_at: expires_at,
        ldap: ldap
      )
167 168

      if member.request?
169 170 171
        ::Members::ApproveAccessRequestService.new(
          current_user,
          access_level: access_level
Rémy Coutable's avatar
Rémy Coutable committed
172 173 174 175 176
        ).execute(
          member,
          skip_authorization: ldap,
          skip_log_audit_event: ldap
        )
177
      else
178
        member.save
179
      end
180

181
      member
182
      # rubocop: enable CodeReuse/ServiceClass
183
    end
184

185 186 187 188 189 190 191 192 193 194 195 196
    # 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

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

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

202
      self.transaction do
203
        (emails + users).map! do |user|
204 205 206 207
          add_user(
            source,
            user,
            access_level,
208
            existing_members: existing_members,
209 210 211 212 213 214 215
            current_user: current_user,
            expires_at: expires_at
          )
        end
      end
    end

216 217
    def access_levels
      Gitlab::Access.sym_options
218
    end
219 220 221

    private

222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246
    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

247 248 249 250 251 252 253 254
    # 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

255 256 257 258 259 260 261 262 263 264 265 266 267 268
    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

269 270 271 272
    def retrieve_access_level(access_level)
      access_levels.fetch(access_level) { access_level.to_i }
    end

273
    def can_update_member?(current_user, member)
Douwe Maan's avatar
Douwe Maan committed
274
      # There is no current user for bulk actions, in which case anything is allowed
275
      !current_user || current_user.can?(:"update_#{member.type.underscore}", member)
276
    end
Douwe Maan's avatar
Douwe Maan committed
277 278
  end

279 280 281 282
  def real_source_type
    source_type
  end

283 284 285 286
  def access_field
    access_level
  end

Douwe Maan's avatar
Douwe Maan committed
287 288 289 290
  def invite?
    self.invite_token.present?
  end

291
  def request?
292
    requested_at.present?
293 294
  end

295 296
  def pending?
    invite? || request?
Douwe Maan's avatar
Douwe Maan committed
297 298
  end

299
  def accept_request
300 301
    return false unless request?

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

305
    updated
306 307
  end

Douwe Maan's avatar
Douwe Maan committed
308
  def accept_invite!(new_user)
Douwe Maan's avatar
Douwe Maan committed
309
    return false unless invite?
310

Douwe Maan's avatar
Douwe Maan committed
311 312 313 314 315 316 317 318 319 320 321 322
    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

Douwe Maan's avatar
Douwe Maan committed
323 324 325 326 327 328 329 330 331 332
  def decline_invite!
    return false unless invite?

    destroyed = self.destroy

    after_decline_invite if destroyed

    destroyed
  end

Douwe Maan's avatar
Douwe Maan committed
333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350
  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

351
  def create_notification_setting
352
    user.notification_settings.find_or_create_for(source)
353 354
  end

355 356 357 358
  def destroy_notification_setting
    notification_setting&.destroy
  end

359
  def notification_setting
360
    @notification_setting ||= user&.notification_settings_for(source)
361 362
  end

363
  # rubocop: disable CodeReuse/ServiceClass
http://jneen.net/'s avatar
http://jneen.net/ committed
364
  def notifiable?(type, opts = {})
365 366 367 368 369
    # always notify when there isn't a user yet
    return true if user.blank?

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

372 373 374 375 376 377 378 379 380
  # 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

Douwe Maan's avatar
Douwe Maan committed
381 382 383 384 385 386
  private

  def send_invite
    # override in subclass
  end

387
  def send_request
Rémy Coutable's avatar
Rémy Coutable committed
388
    notification_service.new_access_request(self)
Douwe Maan's avatar
Douwe Maan committed
389 390 391 392 393 394 395
  end

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

  def post_update_hook
396
    # override in sub class
Douwe Maan's avatar
Douwe Maan committed
397 398 399 400 401 402
  end

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

403 404 405 406 407 408
  # 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.
409
  # rubocop: disable CodeReuse/ServiceClass
410
  def refresh_member_authorized_projects
411 412 413
    # 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
414 415 416 417
    return if destroyed_by_association.present?

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

Douwe Maan's avatar
Douwe Maan committed
420 421 422 423
  def after_accept_invite
    post_create_hook
  end

Douwe Maan's avatar
Douwe Maan committed
424 425 426 427
  def after_decline_invite
    # override in subclass
  end

428
  def after_accept_request
Douwe Maan's avatar
Douwe Maan committed
429 430 431
    post_create_hook
  end

432
  # rubocop: disable CodeReuse/ServiceClass
Douwe Maan's avatar
Douwe Maan committed
433 434 435
  def system_hook_service
    SystemHooksService.new
  end
436
  # rubocop: enable CodeReuse/ServiceClass
Douwe Maan's avatar
Douwe Maan committed
437

438
  # rubocop: disable CodeReuse/ServiceClass
Douwe Maan's avatar
Douwe Maan committed
439 440 441
  def notification_service
    NotificationService.new
  end
442
  # rubocop: enable CodeReuse/ServiceClass
443

444 445
  def notifiable_options
    {}
446
  end
447 448 449 450 451 452 453 454

  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 higher than %{access} inherited membership from group %{group_name}") % error_parameters)
    end
  end
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
455
end