2bd4d580d1
When updating group and project members, new system hooks `user_update_for_group` and `user_update_for_team` will be executed. Fixes https://gitlab.com/gitlab-org/gitlab-ee/issues/12252
461 lines
13 KiB
Ruby
461 lines
13 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class Member < ApplicationRecord
|
|
include AfterCommitQueue
|
|
include Sortable
|
|
include Importable
|
|
include Expirable
|
|
include Gitlab::Access
|
|
include Presentable
|
|
include Gitlab::Utils::StrongMemoize
|
|
|
|
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
|