506bf42817
in a project members' list. Add tests for possible scenarios Re-factor and remove N + 1 queries Remove author from changelog Don't use memoisation when not needed Include users part of parents of project's group Re-factor tests Create and add users according to roles Re-use group created earlier Add incomplete test for ancestoral groups Rename method to clarify category of groups Skip pending test, remove comments not needed Remove extra line Include ancestors from invited groups as well Add specs for participants service Add more specs Add more specs use instead of Use public group owner instead of project maintainer to test owner acess Remove tests that have now been moved into participants_service_spec Use :context instead of :all Create nested group instead of creating an ancestor separately Add comment explaining doubt on the failing spec Imrpove test setup Optimize sql queries Refactor specs file Add rubocop disablement Add special case for project owners Add small refactor Add explanation to the docs Fix wording Refactor group check Add small changes in specs Add cr remarks Add cr remarks Add specs Add small refactor Add code review remarks Refactor for better database usage Fix failing spec Remove rubocop offences Add cr remarks
464 lines
13 KiB
Ruby
464 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
|
|
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')
|