member.rb 9.91 KB
Newer Older
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
1
class Member < ActiveRecord::Base
2
  include Sortable
3
  include Importable
4
  include Expirable
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
5 6
  include Gitlab::Access

7 8
  attr_accessor :raw_invite_token

9
  belongs_to :created_by, class_name: "User"
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
10
  belongs_to :user
11
  belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
12

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

Douwe Maan's avatar
Douwe Maan committed
15
  validates :user, presence: true, unless: :invite?
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
16
  validates :source, presence: true
17
  validates :user_id, uniqueness: { scope: [:source_type, :source_id],
Douwe Maan's avatar
Douwe Maan committed
18 19
                                    message: "already exists in source",
                                    allow_nil: true }
20
  validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true
Douwe Maan's avatar
Douwe Maan committed
21 22 23 24
  validates :invite_email,
    presence: {
      if: :invite?
    },
25
    email: {
Douwe Maan's avatar
Douwe Maan committed
26 27 28 29 30 31
      allow_nil: true
    },
    uniqueness: {
      scope: [:source_type, :source_id],
      allow_nil: true
    }
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
32

33 34 35 36 37 38 39 40 41 42 43
  # 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)

44 45 46 47 48 49 50 51 52 53 54 55
    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, -> do
    left_join_users
      .where(users: { state: 'active' })
56
      .where(requested_at: nil)
57
      .reorder(nil)
58 59
  end

60
  scope :invite, -> { where.not(invite_token: nil) }
61
  scope :non_invite, -> { where(invite_token: nil) }
62
  scope :request, -> { where.not(requested_at: nil) }
63
  scope :non_request, -> { where(requested_at: nil) }
64 65 66 67 68 69 70 71 72

  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 :masters,  -> { active.where(access_level: MASTER) }
  scope :owners,  -> { active.where(access_level: OWNER) }
  scope :owners_and_masters,  -> { active.where(access_level: [OWNER, MASTER]) }
73

74 75 76 77
  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')) }
78

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

81
  after_create :send_invite, if: :invite?, unless: :importing?
James Lopez's avatar
James Lopez committed
82 83 84 85
  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?]
86
  after_destroy :post_destroy_hook, unless: :pending?
87
  after_commit :refresh_member_authorized_projects
Douwe Maan's avatar
Douwe Maan committed
88

89 90
  default_value_for :notification_level, NotificationSetting.levels[:global]

91
  class << self
92 93 94 95 96 97
    def search(query)
      joins(:user).merge(User.search(query))
    end

    def sort(method)
      case method.to_s
98 99
      when 'access_level_asc' then reorder(access_level: :asc)
      when 'access_level_desc' then reorder(access_level: :desc)
100 101 102 103 104 105 106 107 108
      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

109 110 111 112
    def left_join_users
      users = User.arel_table
      members = Member.arel_table

113 114 115
      member_users = members.join(users, Arel::Nodes::OuterJoin)
                             .on(members[:user_id].eq(users[:id]))
                             .join_sources
116 117 118 119

      joins(member_users)
    end

Stan Hu's avatar
Stan Hu committed
120
    def access_for_user_ids(user_ids)
Adam Niedzielski's avatar
Adam Niedzielski committed
121
      where(user_id: user_ids).has_access.pluck(:user_id, :access_level).to_h
Stan Hu's avatar
Stan Hu committed
122 123
    end

124 125 126 127 128
    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

129 130 131
    def add_user(source, user, access_level, current_user: nil, expires_at: nil)
      user = retrieve_user(user)
      access_level = retrieve_access_level(access_level)
132

133
      # `user` can be either a User object or an email to be invited
134 135 136
      member =
        if user.is_a?(User)
          source.members.find_by(user_id: user.id) ||
137 138
            source.requesters.find_by(user_id: user.id) ||
            source.members.build(user_id: user.id)
139 140 141 142 143 144 145 146 147 148 149 150 151
        else
          source.members.build(invite_email: user)
        end

      return member unless can_update_member?(current_user, member)

      member.attributes = {
        created_by: member.created_by || current_user,
        access_level: access_level,
        expires_at: expires_at
      }

      if member.request?
152 153 154 155 156 157
        ::Members::ApproveAccessRequestService.new(
          source,
          current_user,
          id: member.id,
          access_level: access_level
        ).execute
158
      else
159
        member.save
160
      end
161

162 163
      member
    end
164

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

168 169 170 171 172
      # Collect all user ids into separate array
      # so we can use single sql query to get user objects
      user_ids = users.select { |user| user =~ /\A\d+\Z/ }
      users = users - user_ids + User.where(id: user_ids)

173 174 175 176 177 178 179 180 181 182 183 184 185
      self.transaction do
        users.map do |user|
          add_user(
            source,
            user,
            access_level,
            current_user: current_user,
            expires_at: expires_at
          )
        end
      end
    end

186 187
    def access_levels
      Gitlab::Access.sym_options
188
    end
189 190 191

    private

192 193 194 195 196 197 198 199 200 201 202 203
    # 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_access_level(access_level)
      access_levels.fetch(access_level) { access_level.to_i }
    end

204
    def can_update_member?(current_user, member)
Douwe Maan's avatar
Douwe Maan committed
205
      # There is no current user for bulk actions, in which case anything is allowed
206
      !current_user || current_user.can?(:"update_#{member.type.underscore}", member)
207
    end
Douwe Maan's avatar
Douwe Maan committed
208 209
  end

210 211 212 213
  def real_source_type
    source_type
  end

214 215 216 217
  def access_field
    access_level
  end

Douwe Maan's avatar
Douwe Maan committed
218 219 220 221
  def invite?
    self.invite_token.present?
  end

222
  def request?
223
    requested_at.present?
224 225
  end

226 227
  def pending?
    invite? || request?
Douwe Maan's avatar
Douwe Maan committed
228 229
  end

230
  def accept_request
231 232
    return false unless request?

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

236
    updated
237 238
  end

Douwe Maan's avatar
Douwe Maan committed
239
  def accept_invite!(new_user)
Douwe Maan's avatar
Douwe Maan committed
240
    return false unless invite?
241

Douwe Maan's avatar
Douwe Maan committed
242 243 244 245 246 247 248 249 250 251 252 253
    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
254 255 256 257 258 259 260 261 262 263
  def decline_invite!
    return false unless invite?

    destroyed = self.destroy

    after_decline_invite if destroyed

    destroyed
  end

Douwe Maan's avatar
Douwe Maan committed
264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281
  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

282
  def create_notification_setting
283
    user.notification_settings.find_or_create_for(source)
284 285
  end

286
  def notification_setting
287
    @notification_setting ||= user.notification_settings_for(source)
288 289
  end

http://jneen.net/'s avatar
http://jneen.net/ committed
290
  def notifiable?(type, opts = {})
291 292 293 294 295 296
    # always notify when there isn't a user yet
    return true if user.blank?

    NotificationRecipientService.notifiable?(user, type, notifiable_options.merge(opts))
  end

Douwe Maan's avatar
Douwe Maan committed
297 298 299 300 301 302
  private

  def send_invite
    # override in subclass
  end

303
  def send_request
304
    notification_service.new_access_request(self)
Douwe Maan's avatar
Douwe Maan committed
305 306 307 308 309 310 311
  end

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

  def post_update_hook
312
    # override in sub class
Douwe Maan's avatar
Douwe Maan committed
313 314 315 316 317 318
  end

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

319 320 321 322 323 324
  # 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.
325
  def refresh_member_authorized_projects
326 327 328
    # 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
329 330 331 332 333
    return if destroyed_by_association.present?

    UserProjectAccessChangedService.new(user_id).execute
  end

Douwe Maan's avatar
Douwe Maan committed
334 335 336 337
  def after_accept_invite
    post_create_hook
  end

Douwe Maan's avatar
Douwe Maan committed
338 339 340 341
  def after_decline_invite
    # override in subclass
  end

342
  def after_accept_request
Douwe Maan's avatar
Douwe Maan committed
343 344 345 346 347 348 349 350 351 352
    post_create_hook
  end

  def system_hook_service
    SystemHooksService.new
  end

  def notification_service
    NotificationService.new
  end
353

354 355
  def notifiable_options
    {}
356
  end
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
357
end