From 8380a017fc9022ad46e0b260738338c1bdf816f6 Mon Sep 17 00:00:00 2001 From: Ethan Urie Date: Fri, 9 Jun 2023 14:55:09 -0400 Subject: [PATCH 01/18] Create services to modify custom attributes --- app/models/user.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/models/user.rb b/app/models/user.rb index 507a58dfd4900b..5352b26ede44ab 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2493,6 +2493,10 @@ def ci_namespace_mirrors_for_group_members(level) Ci::NamespaceMirror.contains_traversal_ids(traversal_ids) end + def allow_possible_spam? + custom_attributes.by_key(UserCustomAttribute::ALLOW_POSSIBLE_SPAM).exists? + end + def prefix_for_feed_token FEED_TOKEN_PREFIX end -- GitLab From be602e1be642e52a55156c6a1c7d0b2664f5eb52 Mon Sep 17 00:00:00 2001 From: Ethan Urie Date: Thu, 29 Jun 2023 14:37:56 -0400 Subject: [PATCH 02/18] Add ability to trust/untrust user from abuse reports Changelog: added --- app/controllers/admin/users_controller.rb | 20 + app/models/user.rb | 8 + app/models/user.rb.orig | 2606 +++++++++++++++++ app/models/user_custom_attribute.rb | 1 + .../users/allow_possible_spam_service.rb | 1 + .../users/disallow_possible_spam_service.rb | 1 + .../abuse_reports/_abuse_report.html.haml | 45 + app/views/admin/users/_users.html.haml | 3 + config/routes/admin.rb | 2 + locale/gitlab.pot | 15 + 10 files changed, 2702 insertions(+) create mode 100644 app/models/user.rb.orig create mode 100644 app/views/admin/abuse_reports/_abuse_report.html.haml diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 1f05e4e7b21c26..8a3a796dea3937 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -164,6 +164,26 @@ def unlock end end + def allow_possible_spam + result = Users::AllowPossibleSpamService.new(current_user).execute(user) + + if result[:status] == :success + redirect_back_or_admin_user(notice: _("Successfully trusted")) + else + redirect_back_or_admin_user(alert: _("Error occurred. User was not updated")) + end + end + + def disallow_possible_spam + result = Users::DisallowPossibleSpamService.new(current_user).execute(user) + + if result[:status] == :success + redirect_back_or_admin_user(notice: _("Successfully untrusted")) + else + redirect_back_or_admin_user(alert: _("Error occurred. User was not updated")) + end + end + def confirm if update_user(&:force_confirm) redirect_back_or_admin_user(notice: _("Successfully confirmed")) diff --git a/app/models/user.rb b/app/models/user.rb index 5352b26ede44ab..f13f811a895781 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -565,6 +565,12 @@ def blocked? scope :by_provider_and_extern_uid, ->(provider, extern_uid) { joins(:identities).merge(Identity.with_extern_uid(provider, extern_uid)) } scope :by_ids_or_usernames, -> (ids, usernames) { where(username: usernames).or(where(id: ids)) } scope :without_forbidden_states, -> { where.not(state: FORBIDDEN_SEARCH_STATES) } + scope :trusted, -> do + where('EXISTS (?)', ::UserCustomAttribute + .select(1) + .where('user_id = users.id') + .trusted_with_spam) + end strip_attributes! :name @@ -733,6 +739,8 @@ def filter_items(filter_name) external when 'deactivated' deactivated + when "trusted" + trusted else active_without_ghosts end diff --git a/app/models/user.rb.orig b/app/models/user.rb.orig new file mode 100644 index 00000000000000..99fa44213256aa --- /dev/null +++ b/app/models/user.rb.orig @@ -0,0 +1,2606 @@ +# frozen_string_literal: true + +require 'carrierwave/orm/activerecord' + +class User < ApplicationRecord + extend Gitlab::ConfigHelper + + include Gitlab::ConfigHelper + include Gitlab::SQL::Pattern + include AfterCommitQueue + include Avatarable + include Referable + include Sortable + include CaseSensitivity + include TokenAuthenticatable + include FeatureGate + include CreatedAtFilterable + include BulkMemberAccessLoad + include BlocksUnsafeSerialization + include WithUploads + include OptionallySearch + include FromUnion + include BatchDestroyDependentAssociations + include BatchNullifyDependentAssociations + include HasUniqueInternalUsers + include IgnorableColumns + include UpdateHighestRole + include HasUserType + include Gitlab::Auth::Otp::Fortinet + include Gitlab::Auth::Otp::DuoAuth + include RestrictedSignup + include StripAttribute + include EachBatch + include SafelyChangeColumnDefault + + DEFAULT_NOTIFICATION_LEVEL = :participating + + INSTANCE_ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10 + + BLOCKED_PENDING_APPROVAL_STATE = 'blocked_pending_approval' + + COUNT_CACHE_VALIDITY_PERIOD = 24.hours + + OTP_SECRET_LENGTH = 32 + OTP_SECRET_TTL = 2.minutes + + MAX_USERNAME_LENGTH = 255 + MIN_USERNAME_LENGTH = 2 + + MAX_LIMIT_FOR_ASSIGNEED_ISSUES_COUNT = 100 + + SECONDARY_EMAIL_ATTRIBUTES = [ + :commit_email, + :notification_email, + :public_email + ].freeze + + FORBIDDEN_SEARCH_STATES = %w(blocked banned ldap_blocked).freeze + + columns_changing_default :notified_of_own_activity + + add_authentication_token_field :incoming_email_token, token_generator: -> { SecureRandom.hex.to_i(16).to_s(36) } + add_authentication_token_field :feed_token + add_authentication_token_field :static_object_token, encrypted: :optional + + attribute :admin, default: false + attribute :external, default: -> { Gitlab::CurrentSettings.user_default_external } + attribute :can_create_group, default: -> { Gitlab::CurrentSettings.can_create_group } + attribute :private_profile, default: -> { Gitlab::CurrentSettings.user_defaults_to_private_profile } + attribute :can_create_team, default: false + attribute :hide_no_ssh_key, default: false + attribute :hide_no_password, default: false + attribute :project_view, default: :files + attribute :notified_of_own_activity, default: false + attribute :preferred_language, default: -> { Gitlab::CurrentSettings.default_preferred_language } + attribute :theme_id, default: -> { gitlab_config.default_theme } + attribute :color_scheme_id, default: -> { Gitlab::CurrentSettings.default_syntax_highlighting_theme } + + attr_encrypted :otp_secret, + key: Gitlab::Application.secrets.otp_key_base, + mode: :per_attribute_iv_and_salt, + insecure_mode: true, + algorithm: 'aes-256-cbc' + + devise :two_factor_authenticatable, + otp_secret_encryption_key: Gitlab::Application.secrets.otp_key_base + + devise :two_factor_backupable, otp_number_of_backup_codes: 10 + devise :two_factor_backupable_pbkdf2 + serialize :otp_backup_codes, JSON # rubocop:disable Cop/ActiveRecordSerialize + + devise :lockable, :recoverable, :rememberable, :trackable, + :validatable, :omniauthable, :confirmable, :registerable + + # Must be included after `devise` + include EncryptedUserPassword + include RecoverableByAnyEmail + + include AdminChangedPasswordNotifier + + # This module adds async behaviour to Devise emails + # and should be added after Devise modules are initialized. + include AsyncDeviseEmail + include ForcedEmailConfirmation + include RequireEmailVerification + + MINIMUM_DAYS_CREATED = 7 + + # Override Devise::Models::Trackable#update_tracked_fields! + # to limit database writes to at most once every hour + # rubocop: disable CodeReuse/ServiceClass + def update_tracked_fields!(request) + return if Gitlab::Database.read_only? + + update_tracked_fields(request) + + Gitlab::ExclusiveLease.throttle(id) do + ::Ability.forgetting(/admin/) do + Users::UpdateService.new(self, user: self).execute(validate: false) + end + end + end + # rubocop: enable CodeReuse/ServiceClass + + attr_accessor :force_random_password + + # Virtual attribute for authenticating by either username or email + attr_accessor :login + + # Virtual attribute for impersonator + attr_accessor :impersonator + + # + # Relations + # + + # Namespace for personal projects + has_one :namespace, + -> { where(type: Namespaces::UserNamespace.sti_name) }, + dependent: :destroy, # rubocop:disable Cop/ActiveRecordDependent + foreign_key: :owner_id, + inverse_of: :owner, + autosave: true # rubocop:disable Cop/ActiveRecordDependent + + # Profile + has_many :keys, -> { regular_keys }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :expired_today_and_unnotified_keys, -> { expired_today_and_not_notified }, class_name: 'Key' + has_many :expiring_soon_and_unnotified_keys, -> { expiring_soon_and_not_notified }, class_name: 'Key' + has_many :deploy_keys, -> { where(type: 'DeployKey') }, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent + has_many :group_deploy_keys + has_many :gpg_keys + + has_many :emails + has_many :personal_access_tokens, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :identities, dependent: :destroy, autosave: true # rubocop:disable Cop/ActiveRecordDependent + has_many :webauthn_registrations + has_many :chat_names, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :saved_replies, class_name: '::Users::SavedReply' + has_one :user_synced_attributes_metadata, autosave: true + has_one :aws_role, class_name: 'Aws::Role' + + # Followers + has_many :followed_users, foreign_key: :follower_id, class_name: 'Users::UserFollowUser' + has_many :followees, through: :followed_users + + has_many :following_users, foreign_key: :followee_id, class_name: 'Users::UserFollowUser' + has_many :followers, through: :following_users + + # Groups + has_many :members + has_many :group_members, -> { where(requested_at: nil).where("access_level >= ?", Gitlab::Access::GUEST) }, class_name: 'GroupMember' + has_many :groups, through: :group_members + has_many :groups_with_active_memberships, -> { where(members: { state: ::Member::STATE_ACTIVE }) }, through: :group_members, source: :group + has_many :owned_groups, -> { where(members: { access_level: Gitlab::Access::OWNER }) }, through: :group_members, source: :group + has_many :maintainers_groups, -> { where(members: { access_level: Gitlab::Access::MAINTAINER }) }, through: :group_members, source: :group + has_many :developer_groups, -> { where(members: { access_level: ::Gitlab::Access::DEVELOPER }) }, through: :group_members, source: :group + has_many :owned_or_maintainers_groups, + -> { where(members: { access_level: [Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) }, + through: :group_members, + source: :group + alias_attribute :masters_groups, :maintainers_groups + has_many :developer_maintainer_owned_groups, + -> { where(members: { access_level: [Gitlab::Access::DEVELOPER, Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) }, + through: :group_members, + source: :group + has_many :reporter_developer_maintainer_owned_groups, + -> { where(members: { access_level: [Gitlab::Access::REPORTER, Gitlab::Access::DEVELOPER, Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) }, + through: :group_members, + source: :group + has_many :minimal_access_group_members, -> { where(access_level: [Gitlab::Access::MINIMAL_ACCESS]) }, class_name: 'GroupMember' + has_many :minimal_access_groups, through: :minimal_access_group_members, source: :group + + # Projects + has_many :groups_projects, through: :groups, source: :projects + has_many :personal_projects, through: :namespace, source: :projects + has_many :project_members, -> { where(requested_at: nil) } + has_many :projects, through: :project_members + has_many :created_projects, foreign_key: :creator_id, class_name: 'Project', dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent + has_many :projects_with_active_memberships, -> { where(members: { state: ::Member::STATE_ACTIVE }) }, through: :project_members, source: :project + has_many :users_star_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :starred_projects, through: :users_star_projects, source: :project + has_many :project_authorizations, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent + has_many :authorized_projects, through: :project_authorizations, source: :project + + has_many :user_interacted_projects + has_many :project_interactions, through: :user_interacted_projects, source: :project, class_name: 'Project' + + has_many :snippets, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent + has_many :notes, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent + has_many :issues, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent + has_many :legacy_assigned_merge_requests, class_name: 'MergeRequest', dependent: :nullify, foreign_key: :assignee_id # rubocop:disable Cop/ActiveRecordDependent + has_many :merged_merge_requests, class_name: 'MergeRequest::Metrics', dependent: :nullify, foreign_key: :merged_by_id # rubocop:disable Cop/ActiveRecordDependent + has_many :closed_merge_requests, class_name: 'MergeRequest::Metrics', dependent: :nullify, foreign_key: :latest_closed_by_id # rubocop:disable Cop/ActiveRecordDependent + has_many :updated_merge_requests, class_name: 'MergeRequest', dependent: :nullify, foreign_key: :updated_by_id # rubocop:disable Cop/ActiveRecordDependent + has_many :updated_issues, class_name: 'Issue', dependent: :nullify, foreign_key: :updated_by_id # rubocop:disable Cop/ActiveRecordDependent + has_many :closed_issues, class_name: 'Issue', dependent: :nullify, foreign_key: :closed_by_id # rubocop:disable Cop/ActiveRecordDependent + has_many :merge_requests, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent + has_many :events, dependent: :delete_all, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent + has_many :releases, dependent: :nullify, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent + has_many :subscriptions, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :abuse_reports, dependent: :nullify, foreign_key: :user_id, inverse_of: :user # rubocop:disable Cop/ActiveRecordDependent + has_many :reported_abuse_reports, dependent: :nullify, foreign_key: :reporter_id, class_name: "AbuseReport", inverse_of: :reporter # rubocop:disable Cop/ActiveRecordDependent + has_many :assigned_abuse_reports, foreign_key: :assignee_id, class_name: "AbuseReport", inverse_of: :assignee + has_many :resolved_abuse_reports, foreign_key: :resolved_by_id, class_name: "AbuseReport", inverse_of: :resolved_by + has_many :abuse_events, foreign_key: :user_id, class_name: 'Abuse::Event', inverse_of: :user + has_many :spam_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :abuse_trust_scores, class_name: 'Abuse::TrustScore', foreign_key: :user_id + has_many :builds, class_name: 'Ci::Build' + has_many :pipelines, class_name: 'Ci::Pipeline' + has_many :todos, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :authored_todos, class_name: 'Todo', dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent + has_many :notification_settings + has_many :award_emoji, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :triggers, class_name: 'Ci::Trigger', foreign_key: :owner_id + has_many :audit_events, foreign_key: :author_id, inverse_of: :user + + has_many :alert_assignees, class_name: '::AlertManagement::AlertAssignee', inverse_of: :assignee + has_many :issue_assignees, inverse_of: :assignee + has_many :merge_request_assignees, inverse_of: :assignee, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :merge_request_reviewers, inverse_of: :reviewer, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue + has_many :assigned_merge_requests, class_name: "MergeRequest", through: :merge_request_assignees, source: :merge_request + has_many :created_custom_emoji, class_name: 'CustomEmoji', inverse_of: :creator + + has_many :bulk_imports + + has_many :custom_attributes, class_name: 'UserCustomAttribute' + has_many :callouts, class_name: 'Users::Callout' + has_many :group_callouts, class_name: 'Users::GroupCallout' + has_many :project_callouts, class_name: 'Users::ProjectCallout' + has_many :term_agreements + belongs_to :accepted_term, class_name: 'ApplicationSetting::Term' + + has_many :metrics_users_starred_dashboards, class_name: 'Metrics::UsersStarredDashboard', inverse_of: :user + + has_one :status, class_name: 'UserStatus' + has_one :user_preference + has_one :user_detail + has_one :user_highest_role + has_one :user_canonical_email + has_one :credit_card_validation, class_name: '::Users::CreditCardValidation' + has_one :phone_number_validation, class_name: '::Users::PhoneNumberValidation' + has_one :atlassian_identity, class_name: 'Atlassian::Identity' + has_one :banned_user, class_name: '::Users::BannedUser' + + has_many :reviews, foreign_key: :author_id, inverse_of: :author + + has_many :in_product_marketing_emails, class_name: '::Users::InProductMarketingEmail' + + has_many :timelogs + + has_many :resource_label_events, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent + has_many :resource_state_events, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent + has_many :issue_assignment_events, class_name: 'ResourceEvents::IssueAssignmentEvent', dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent + has_many :merge_request_assignment_events, class_name: 'ResourceEvents::MergeRequestAssignmentEvent', dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent + has_many :authored_events, class_name: 'Event', dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent + has_many :namespace_commit_emails, class_name: 'Users::NamespaceCommitEmail' + has_many :user_achievements, class_name: 'Achievements::UserAchievement', inverse_of: :user + has_many :awarded_user_achievements, class_name: 'Achievements::UserAchievement', foreign_key: 'awarded_by_user_id', inverse_of: :awarded_by_user + has_many :revoked_user_achievements, class_name: 'Achievements::UserAchievement', foreign_key: 'revoked_by_user_id', inverse_of: :revoked_by_user + has_many :achievements, through: :user_achievements, class_name: 'Achievements::Achievement', inverse_of: :users + + # + # Validations + # + # Note: devise :validatable above adds validations for :email and :password + validates :name, presence: true, length: { maximum: 255 } + validates :first_name, length: { maximum: 127 } + validates :last_name, length: { maximum: 127 } + validates :email, confirmation: true + validates :notification_email, devise_email: true, allow_blank: true + validates :public_email, uniqueness: true, devise_email: true, allow_blank: true + validates :commit_email, devise_email: true, allow_blank: true, unless: ->(user) { user.commit_email == Gitlab::PrivateCommitEmail::TOKEN } + validates :projects_limit, + presence: true, + numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE } + validates :username, presence: true + validate :check_password_weakness, if: :encrypted_password_changed? + + validates :namespace, presence: true + validate :namespace_move_dir_allowed, if: :username_changed?, unless: :new_record? + + validate :unique_email, if: :email_changed? + validate :notification_email_verified, if: :notification_email_changed? + validate :public_email_verified, if: :public_email_changed? + validate :commit_email_verified, if: :commit_email_changed? + validate :email_allowed_by_restrictions?, if: ->(user) { user.new_record? ? !user.created_by_id : user.email_changed? } + validate :check_username_format, if: :username_changed? + + validates :theme_id, allow_nil: true, inclusion: { in: Gitlab::Themes.valid_ids, + message: ->(*) { _("%{placeholder} is not a valid theme") % { placeholder: '%{value}' } } } + + validates :color_scheme_id, allow_nil: true, inclusion: { in: Gitlab::ColorSchemes.valid_ids, + message: ->(*) { _("%{placeholder} is not a valid color scheme") % { placeholder: '%{value}' } } } + + after_initialize :set_projects_limit + before_validation :sanitize_attrs + before_validation :ensure_namespace_correct + after_validation :set_username_errors + before_save :ensure_incoming_email_token + before_save :ensure_user_rights_and_limits, if: ->(user) { user.new_record? || user.external_changed? } + before_save :skip_reconfirmation!, if: ->(user) { user.email_changed? && user.read_only_attribute?(:email) } + before_save :check_for_verified_email, if: ->(user) { user.email_changed? && !user.new_record? } + before_save :ensure_namespace_correct # in case validation is skipped + after_update :username_changed_hook, if: :saved_change_to_username? + after_destroy :post_destroy_hook + after_destroy :remove_key_cache + after_save if: -> { (saved_change_to_email? || saved_change_to_confirmed_at?) && confirmed? } do + email_to_confirm = self.emails.find_by(email: self.email) + + if email_to_confirm.present? + if skip_confirmation_period_expiry_check + email_to_confirm.force_confirm + else + email_to_confirm.confirm + end + else + add_primary_email_to_emails! + end + end + after_commit(on: :update) do + update_invalid_gpg_signatures if previous_changes.key?('email') + end + + # User's Layout preference + enum layout: { fixed: 0, fluid: 1 } + + # User's Dashboard preference + enum dashboard: { projects: 0, stars: 1, your_activity: 10, project_activity: 2, starred_project_activity: 3, groups: 4, todos: 5, issues: 6, merge_requests: 7, operations: 8, followed_user_activity: 9 } + + # User's Project preference + enum project_view: { readme: 0, activity: 1, files: 2, wiki: 3 } + + # User's role + enum role: { software_developer: 0, development_team_lead: 1, devops_engineer: 2, systems_administrator: 3, security_analyst: 4, data_analyst: 5, product_manager: 6, product_designer: 7, other: 8 }, _suffix: true + + delegate :notes_filter_for, + :set_notes_filter, + :first_day_of_week, :first_day_of_week=, + :timezone, :timezone=, + :time_display_relative, :time_display_relative=, + :show_whitespace_in_diffs, :show_whitespace_in_diffs=, + :view_diffs_file_by_file, :view_diffs_file_by_file=, + :pass_user_identities_to_ci_jwt, :pass_user_identities_to_ci_jwt=, + :tab_width, :tab_width=, + :sourcegraph_enabled, :sourcegraph_enabled=, + :gitpod_enabled, :gitpod_enabled=, + :setup_for_company, :setup_for_company=, + :project_shortcut_buttons, :project_shortcut_buttons=, + :render_whitespace_in_code, :render_whitespace_in_code=, + :markdown_surround_selection, :markdown_surround_selection=, + :markdown_automatic_lists, :markdown_automatic_lists=, + :diffs_deletion_color, :diffs_deletion_color=, + :diffs_addition_color, :diffs_addition_color=, + :use_new_navigation, :use_new_navigation=, + :pinned_nav_items, :pinned_nav_items=, + :achievements_enabled, :achievements_enabled=, + :enabled_following, :enabled_following=, + to: :user_preference + + delegate :path, to: :namespace, allow_nil: true, prefix: true + delegate :job_title, :job_title=, to: :user_detail, allow_nil: true + delegate :bio, :bio=, to: :user_detail, allow_nil: true + delegate :webauthn_xid, :webauthn_xid=, to: :user_detail, allow_nil: true + delegate :pronouns, :pronouns=, to: :user_detail, allow_nil: true + delegate :pronunciation, :pronunciation=, to: :user_detail, allow_nil: true + delegate :registration_objective, :registration_objective=, to: :user_detail, allow_nil: true + delegate :linkedin, :linkedin=, to: :user_detail, allow_nil: true + delegate :twitter, :twitter=, to: :user_detail, allow_nil: true + delegate :skype, :skype=, to: :user_detail, allow_nil: true + delegate :website_url, :website_url=, to: :user_detail, allow_nil: true + delegate :location, :location=, to: :user_detail, allow_nil: true + delegate :organization, :organization=, to: :user_detail, allow_nil: true + delegate :discord, :discord=, to: :user_detail, allow_nil: true + + accepts_nested_attributes_for :user_preference, update_only: true + accepts_nested_attributes_for :user_detail, update_only: true + accepts_nested_attributes_for :credit_card_validation, update_only: true, allow_destroy: true + + state_machine :state, initial: :active do + # state_machine uses this method at class loading time to fetch the default + # value for the `state` column but in doing so it also evaluates all other + # columns default values which could trigger the recursive generation of + # ApplicationSetting records. We're setting it to `nil` here because we + # don't have a database default for the `state` column. + # + def owner_class_attribute_default; end + + event :block do + transition active: :blocked + transition deactivated: :blocked + transition ldap_blocked: :blocked + transition blocked_pending_approval: :blocked + end + + event :ldap_block do + transition active: :ldap_blocked + transition deactivated: :ldap_blocked + end + + # aliasing system_block to set ldap_blocked statuses + # ldap_blocked is used for LDAP, SAML, and SCIM blocked users + # Issue for improving this naming: + # https://gitlab.com/gitlab-org/gitlab/-/issues/388487 + event :system_block do + transition active: :ldap_blocked + transition deactivated: :ldap_blocked + end + + event :activate do + transition deactivated: :active + transition blocked: :active + transition ldap_blocked: :active + transition blocked_pending_approval: :active + transition banned: :active + end + + event :block_pending_approval do + transition active: :blocked_pending_approval + end + + event :ban do + transition active: :banned + end + + event :unban do + transition banned: :active + end + + event :deactivate do + # Any additional changes to this event should be also + # reflected in app/workers/users/deactivate_dormant_users_worker.rb + transition active: :deactivated + end + + state :blocked, :ldap_blocked, :blocked_pending_approval, :banned do + def blocked? + true + end + end + + before_transition do + !Gitlab::Database.read_only? + end + + # rubocop: disable CodeReuse/ServiceClass + after_transition any => :blocked do |user| + user.run_after_commit do + Ci::DropPipelineService.new.execute_async_for_all(user.pipelines, :user_blocked, user) + Ci::DisableUserPipelineSchedulesService.new.execute(user) + end + end + + after_transition any => :deactivated do |user| + next unless Gitlab::CurrentSettings.user_deactivation_emails_enabled + + user.run_after_commit do + NotificationService.new.user_deactivated(user.name, user.notification_email_or_default) + end + end + # rubocop: enable CodeReuse/ServiceClass + + after_transition active: :banned do |user| + user.create_banned_user + end + + after_transition banned: :active do |user| + user.banned_user&.destroy + end + + after_transition any => :active do |user| + user.starred_projects.update_counters(star_count: 1) + end + + after_transition active: any do |user| + user.starred_projects.update_counters(star_count: -1) + end + end + + # Scopes + scope :admins, -> { where(admin: true) } + scope :instance_access_request_approvers_to_be_notified, -> { admins.active.order_recent_sign_in.limit(INSTANCE_ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) } + scope :blocked, -> { with_states(:blocked, :ldap_blocked) } + scope :blocked_pending_approval, -> { with_states(:blocked_pending_approval) } + scope :banned, -> { with_states(:banned) } + scope :external, -> { where(external: true) } + scope :non_external, -> { where(external: false) } + scope :confirmed, -> { where.not(confirmed_at: nil) } + scope :active, -> { with_state(:active).non_internal } + scope :active_without_ghosts, -> { with_state(:active).without_ghosts } + scope :deactivated, -> { with_state(:deactivated).non_internal } + scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) } + scope :by_username, -> (usernames) { iwhere(username: Array(usernames).map(&:to_s)) } + scope :by_name, -> (names) { iwhere(name: Array(names)) } + scope :by_login, -> (login) do + return none if login.blank? + + login.include?('@') ? iwhere(email: login) : iwhere(username: login) + end + scope :by_user_email, -> (emails) { iwhere(email: Array(emails)) } + scope :by_emails, -> (emails) { joins(:emails).where(emails: { email: Array(emails).map(&:downcase) }) } + scope :for_todos, -> (todos) { where(id: todos.select(:user_id).distinct) } + scope :with_emails, -> { preload(:emails) } + scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) } + scope :with_public_profile, -> { where(private_profile: false) } + scope :with_expiring_and_not_notified_personal_access_tokens, ->(at) do + where('EXISTS (?)', ::PersonalAccessToken + .where('personal_access_tokens.user_id = users.id') + .without_impersonation + .expiring_and_not_notified(at).select(1) + ) + end + scope :with_personal_access_tokens_expired_today, -> do + where('EXISTS (?)', ::PersonalAccessToken + .select(1) + .where('personal_access_tokens.user_id = users.id') + .without_impersonation + .expired_today_and_not_notified + ) + end + + scope :with_ssh_key_expiring_soon, -> do + includes(:expiring_soon_and_unnotified_keys) + .where('EXISTS (?)', ::Key + .select(1) + .where('keys.user_id = users.id') + .expiring_soon_and_not_notified) + end + scope :order_recent_sign_in, -> { reorder(arel_table[:current_sign_in_at].desc.nulls_last) } + scope :order_oldest_sign_in, -> { reorder(arel_table[:current_sign_in_at].asc.nulls_last) } + scope :order_recent_last_activity, -> { reorder(arel_table[:last_activity_on].desc.nulls_last, arel_table[:id].asc) } + scope :order_oldest_last_activity, -> { reorder(arel_table[:last_activity_on].asc.nulls_first, arel_table[:id].desc) } + scope :dormant, -> { with_state(:active).human_or_service_user.where('last_activity_on <= ?', Gitlab::CurrentSettings.deactivate_dormant_users_period.day.ago.to_date) } + scope :with_no_activity, -> { with_state(:active).human_or_service_user.where(last_activity_on: nil).where('created_at <= ?', MINIMUM_DAYS_CREATED.day.ago.to_date) } + scope :by_provider_and_extern_uid, ->(provider, extern_uid) { joins(:identities).merge(Identity.with_extern_uid(provider, extern_uid)) } + scope :by_ids_or_usernames, -> (ids, usernames) { where(username: usernames).or(where(id: ids)) } + scope :without_forbidden_states, -> { where.not(state: FORBIDDEN_SEARCH_STATES) } + + strip_attributes! :name + + def preferred_language + read_attribute('preferred_language').presence || Gitlab::CurrentSettings.default_preferred_language + end + + def active_for_authentication? + return false unless super + + check_ldap_if_ldap_blocked! + + can?(:log_in) + end + + # The messages for these keys are defined in `devise.en.yml` + def inactive_message + if blocked_pending_approval? + :blocked_pending_approval + elsif blocked? + :blocked + elsif internal? + :forbidden + else + super + end + end + + def self.with_visible_profile(user) + return with_public_profile if user.nil? + + if user.admin? + all + else + with_public_profile.or(where(id: user.id)) + end + end + + # Limits the users to those that have TODOs, optionally in the given state. + # + # user - The user to get the todos for. + # + # with_todos - If we should limit the result set to users that are the + # authors of todos. + # + # todo_state - An optional state to require the todos to be in. + def self.limit_to_todo_authors(user: nil, with_todos: false, todo_state: nil) + if user && with_todos + where(id: Todo.where(user: user, state: todo_state).select(:author_id)) + else + all + end + end + + # Returns a relation that optionally includes the given user. + # + # user_id - The ID of the user to include. + def self.union_with_user(user_id = nil) + if user_id.present? + # We use "unscoped" here so that any inner conditions are not repeated for + # the outer query, which would be redundant. + User.unscoped.from_union([all, User.unscoped.where(id: user_id)]) + else + all + end + end + + def self.with_two_factor + where(otp_required_for_login: true) + .or(where_exists(WebauthnRegistration.where(WebauthnRegistration.arel_table[:user_id].eq(arel_table[:id])))) + end + + def self.without_two_factor + where + .missing(:webauthn_registrations) + .where(otp_required_for_login: false) + end + + # + # Class methods + # + class << self + # Devise method overridden to allow support for dynamic password lengths + def password_length + Gitlab::CurrentSettings.minimum_password_length..Devise.password_length.max + end + + # Generate a random password that conforms to the current password length settings + def random_password + Devise.friendly_token(password_length.max) + end + + # Devise method overridden to allow sign in with email or username + def find_for_database_authentication(warden_conditions) + conditions = warden_conditions.dup + if login = conditions.delete(:login) + where(conditions).find_by("lower(username) = :value OR lower(email) = :value", value: login.downcase.strip) + else + find_by(conditions) + end + end + + def sort_by_attribute(method) + order_method = method || 'id_desc' + + case order_method.to_s + when 'recent_sign_in' then order_recent_sign_in + when 'oldest_sign_in' then order_oldest_sign_in + when 'last_activity_on_desc' then order_recent_last_activity + when 'last_activity_on_asc' then order_oldest_last_activity + else + order_by(order_method) + end + end + + # Find a User by their primary email or any associated confirmed secondary email + def find_by_any_email(email, confirmed: false) + return unless email + + by_any_email(email, confirmed: confirmed).take + end + + # Returns a relation containing all found users by their primary email + # or any associated confirmed secondary email + # + # @param emails [String, Array] email addresses to check + # @param confirmed [Boolean] Only return users where the primary email is confirmed + def by_any_email(emails, confirmed: false) + from_users = by_user_email(emails) + from_users = from_users.confirmed if confirmed + + from_emails = by_emails(emails).merge(Email.confirmed) + from_emails = from_emails.confirmed if confirmed + + items = [from_users, from_emails] + + user_ids = Gitlab::PrivateCommitEmail.user_ids_for_emails(Array(emails).map(&:downcase)) + items << where(id: user_ids) if user_ids.present? + + from_union(items) + end + + def find_by_private_commit_email(email) + user_id = Gitlab::PrivateCommitEmail.user_id_for_email(email) + + find_by(id: user_id) + end + + def filter_items(filter_name) + case filter_name + when 'admins' + admins + when 'blocked' + blocked + when 'blocked_pending_approval' + blocked_pending_approval + when 'banned' + banned + when 'two_factor_disabled' + without_two_factor + when 'two_factor_enabled' + with_two_factor + when 'wop' + without_projects + when 'external' + external + when 'deactivated' + deactivated + else + active_without_ghosts + end + end + + # Searches users matching the given query. + # + # This method uses ILIKE on PostgreSQL. + # + # query - The search query as a String + # with_private_emails - include private emails in search + # + # Returns an ActiveRecord::Relation. + def search(query, **options) + return none unless query.is_a?(String) + + query = query&.delete_prefix('@') + return none if query.blank? + + query = query.downcase + + order = <<~SQL + CASE + WHEN LOWER(users.name) = :query THEN 0 + WHEN LOWER(users.username) = :query THEN 1 + WHEN LOWER(users.public_email) = :query THEN 2 + ELSE 3 + END + SQL + + sanitized_order_sql = Arel.sql(sanitize_sql_array([order, query: query])) + + scope = options[:with_private_emails] ? with_primary_or_secondary_email(query) : with_public_email(query) + scope = scope.or(search_by_name_or_username(query, use_minimum_char_limit: options[:use_minimum_char_limit])) + + order = Gitlab::Pagination::Keyset::Order.build( + [ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'users_match_priority', + order_expression: sanitized_order_sql.asc, + add_to_projections: true, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'users_name', + order_expression: arel_table[:name].asc, + add_to_projections: true, + nullable: :not_nullable, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'users_id', + order_expression: arel_table[:id].asc, + add_to_projections: true, + nullable: :not_nullable, + distinct: true + ) + ]) + scope.reorder(order) + end + + # Limits the result set to users _not_ in the given query/list of IDs. + # + # users - The list of users to ignore. This can be an + # `ActiveRecord::Relation`, or an Array. + def where_not_in(users = nil) + users ? where.not(id: users) : all + end + + def reorder_by_name + reorder(:name) + end + + # searches user by given pattern + # it compares name and username fields with given pattern + # This method uses ILIKE on PostgreSQL. + def search_by_name_or_username(query, use_minimum_char_limit: nil) + use_minimum_char_limit = user_search_minimum_char_limit if use_minimum_char_limit.nil? + + where( + fuzzy_arel_match(:name, query, use_minimum_char_limit: use_minimum_char_limit) + .or(fuzzy_arel_match(:username, query, use_minimum_char_limit: use_minimum_char_limit)) + ) + end + + def with_public_email(email_address) + where(public_email: email_address) + end + + def with_primary_or_secondary_email(email_address) + email_table = Email.arel_table + matched_by_email_user_id = email_table + .project(email_table[:user_id]) + .where(email_table[:email].eq(email_address)) + .where(email_table[:confirmed_at].not_eq(nil)) + .take(1) # at most 1 record as there is a unique constraint + + where( + arel_table[:email].eq(email_address) + .or(arel_table[:id].eq(matched_by_email_user_id)) + ) + end + + # This method is overridden in JiHu. + # https://gitlab.com/gitlab-org/gitlab/-/issues/348509 + def user_search_minimum_char_limit + true + end + + def find_by_login(login) + by_login(login).take + end + + def find_by_username(username) + by_username(username).take + end + + def find_by_username!(username) + by_username(username).take! + end + + # Returns a user for the given SSH key. + def find_by_ssh_key_id(key_id) + find_by('EXISTS (?)', Key.select(1).where('keys.user_id = users.id').auth.where(id: key_id)) + end + + def find_by_full_path(path, follow_redirects: false) + namespace = Namespace.user_namespaces.find_by_full_path(path, follow_redirects: follow_redirects) + namespace&.owner + end + + def reference_prefix + '@' + end + + # Pattern used to extract `@user` user references from text + def reference_pattern + @reference_pattern ||= + %r{ + (?#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX}) + }x + end + + # Return (create if necessary) the ghost user. The ghost user + # owns records previously belonging to deleted users. + def ghost + email = 'ghost%s@example.com' + unique_internal(where(user_type: :ghost), 'ghost', email) do |u| + u.bio = _('This is a "Ghost User", created to hold all issues authored by users that have since been deleted. This user cannot be removed.') + u.name = 'Ghost User' + end + end + + def alert_bot + email_pattern = "alert%s@#{Settings.gitlab.host}" + + unique_internal(where(user_type: :alert_bot), 'alert-bot', email_pattern) do |u| + u.bio = 'The GitLab alert bot' + u.name = 'GitLab Alert Bot' + u.avatar = bot_avatar(image: 'alert-bot.png') + end + end + + def migration_bot + email_pattern = "noreply+gitlab-migration-bot%s@#{Settings.gitlab.host}" + + unique_internal(where(user_type: :migration_bot), 'migration-bot', email_pattern) do |u| + u.bio = 'The GitLab migration bot' + u.name = 'GitLab Migration Bot' + u.confirmed_at = Time.zone.now + end + end + + def security_bot + email_pattern = "security-bot%s@#{Settings.gitlab.host}" + + unique_internal(where(user_type: :security_bot), 'GitLab-Security-Bot', email_pattern) do |u| + u.bio = 'System bot that monitors detected vulnerabilities for solutions and creates merge requests with the fixes.' + u.name = 'GitLab Security Bot' + u.website_url = Gitlab::Routing.url_helpers.help_page_url('user/application_security/security_bot/index.md') + u.avatar = bot_avatar(image: 'security-bot.png') + u.confirmed_at = Time.zone.now + end + end + + def support_bot + email_pattern = "support%s@#{Settings.gitlab.host}" + + unique_internal(where(user_type: :support_bot), 'support-bot', email_pattern) do |u| + u.bio = 'The GitLab support bot used for Service Desk' + u.name = 'GitLab Support Bot' + u.avatar = bot_avatar(image: 'support-bot.png') + u.confirmed_at = Time.zone.now + end + end + + def automation_bot + email_pattern = "automation%s@#{Settings.gitlab.host}" + + unique_internal(where(user_type: :automation_bot), 'automation-bot', email_pattern) do |u| + u.bio = 'The GitLab automation bot used for automated workflows and tasks' + u.name = 'GitLab Automation Bot' + u.avatar = bot_avatar(image: 'support-bot.png') # todo: add an avatar for automation-bot + end + end + + def llm_bot + email_pattern = "llm-bot%s@#{Settings.gitlab.host}" + + unique_internal(where(user_type: :llm_bot), 'GitLab-Llm-Bot', email_pattern) do |u| + u.bio = 'The Gitlab LLM bot used for fetching LLM-generated content' + u.name = 'GitLab LLM Bot' + u.avatar = bot_avatar(image: 'support-bot.png') # todo: add an avatar for llm-bot + u.confirmed_at = Time.zone.now + end + end + + def admin_bot + email_pattern = "admin-bot%s@#{Settings.gitlab.host}" + + unique_internal(where(user_type: :admin_bot), 'GitLab-Admin-Bot', email_pattern) do |u| + u.bio = 'Admin bot used for tasks that require admin privileges' + u.name = 'GitLab Admin Bot' + u.avatar = bot_avatar(image: 'admin-bot.png') + u.admin = true + u.confirmed_at = Time.zone.now + end + end + + # Return true if there is only single non-internal user in the deployment, + # ghost user is ignored. + def single_user? + User.non_internal.limit(2).count == 1 + end + + def single_user + User.non_internal.first if single_user? + end + + def get_ids_by_ids_or_usernames(ids, usernames) + by_ids_or_usernames(ids, usernames).pluck(:id) + end + end + + # + # Instance methods + # + + def full_path + username + end + + def to_param + username + end + + def to_reference(_from = nil, target_project: nil, full: nil) + "#{self.class.reference_prefix}#{username}" + end + + def skip_confirmation=(bool) + skip_confirmation! if bool + end + + def skip_reconfirmation=(bool) + skip_reconfirmation! if bool + end + + def generate_reset_token + @reset_token, enc = Devise.token_generator.generate(self.class, :reset_password_token) + + self.reset_password_token = enc + self.reset_password_sent_at = Time.current.utc + + @reset_token + end + + def recently_sent_password_reset? + reset_password_sent_at.present? && reset_password_sent_at >= 1.minute.ago + end + + # Overwrites valid_password? from Devise::Models::DatabaseAuthenticatable + # In constant-time, check both that the password isn't on a denylist AND + # that the password is the user's password + def valid_password?(password) + return false unless password_allowed?(password) + return false if password_automatically_set? + + super + end + + def generate_otp_backup_codes! + if Gitlab::FIPS.enabled? + generate_otp_backup_codes_pbkdf2! + else + super + end + end + + def invalidate_otp_backup_code!(code) + if Gitlab::FIPS.enabled? && pbkdf2? + invalidate_otp_backup_code_pdkdf2!(code) + else + super(code) + end + end + + # See https://gitlab.com/gitlab-org/security/gitlab/-/issues/638 + DISALLOWED_PASSWORDS = %w[123qweQWE!@#000000000].freeze + + def password_allowed?(password) + password_allowed = true + + DISALLOWED_PASSWORDS.each do |disallowed_password| + password_allowed = false if Devise.secure_compare(password, disallowed_password) + end + + password_allowed + end + + # Override Devise Rememberable#remember_me! + # + # In Devise this method sets `remember_created_at` and writes the session token + # to the session cookie. When remember me is disabled this method ensures these + # values aren't set. + def remember_me! + super if ::Gitlab::Database.read_write? && ::Gitlab::CurrentSettings.remember_me_enabled? + end + + def forget_me! + super if ::Gitlab::Database.read_write? + end + + # Override Devise Rememberable#remember_me? + # + # In Devise this method compares the remember me token received from the user session + # and compares to the stored value. When remember me is disabled this method ensures + # the upstream comparison does not happen. + def remember_me?(token, generated_at) + return false unless ::Gitlab::CurrentSettings.remember_me_enabled? + + super + end + + def disable_two_factor! + transaction do + self.disable_webauthn! + self.disable_two_factor_otp! + self.reset_backup_codes! + end + end + + def disable_two_factor_otp! + update( + otp_required_for_login: false, + encrypted_otp_secret: nil, + encrypted_otp_secret_iv: nil, + encrypted_otp_secret_salt: nil, + otp_grace_period_started_at: nil, + otp_secret_expires_at: nil + ) + end + + def disable_webauthn! + self.webauthn_registrations.destroy_all # rubocop:disable Cop/DestroyAll + end + + def reset_backup_codes! + update(otp_backup_codes: nil) + end + + def two_factor_enabled? + two_factor_otp_enabled? || two_factor_webauthn_enabled? + end + + def two_factor_otp_enabled? + otp_required_for_login? || + forti_authenticator_enabled?(self) || + forti_token_cloud_enabled?(self) || + duo_auth_enabled?(self) + end + + def two_factor_webauthn_enabled? + (webauthn_registrations.loaded? && webauthn_registrations.any?) || (!webauthn_registrations.loaded? && webauthn_registrations.exists?) + end + + def needs_new_otp_secret? + !two_factor_enabled? && otp_secret_expired? + end + + def otp_secret_expired? + return true unless otp_secret_expires_at + + otp_secret_expires_at < Time.current + end + + def update_otp_secret! + self.otp_secret = User.generate_otp_secret(OTP_SECRET_LENGTH) + self.otp_secret_expires_at = Time.current + OTP_SECRET_TTL + end + + def namespace_move_dir_allowed + if namespace&.any_project_has_container_registry_tags? + errors.add(:username, _('cannot be changed if a personal project has container registry tags.')) + end + end + + # will_save_change_to_attribute? is used by Devise to check if it is necessary + # to clear any existing reset_password_tokens before updating an authentication_key + # and login in our case is a virtual attribute to allow login by username or email. + def will_save_change_to_login? + will_save_change_to_username? || will_save_change_to_email? + end + + def unique_email + return if errors.added?(:email, _('has already been taken')) + + if !emails.exists?(email: email) && Email.exists?(email: email) + errors.add(:email, _('has already been taken')) + end + end + + def commit_email_or_default + if self.commit_email == Gitlab::PrivateCommitEmail::TOKEN + return private_commit_email + end + + # The commit email is the same as the primary email if undefined + self.commit_email.presence || self.email + end + + def notification_email_or_default + # The notification email is the same as the primary email if undefined + self.notification_email.presence || self.email + end + + def private_commit_email + Gitlab::PrivateCommitEmail.for_user(self) + end + + # see if the new email is already a verified secondary email + def check_for_verified_email + skip_reconfirmation! if emails.confirmed.where(email: self.email).any? + end + + def update_invalid_gpg_signatures + gpg_keys.each(&:update_invalid_gpg_signatures) + end + + # Returns the groups a user has access to, either through a membership or a project authorization + def authorized_groups + Group.unscoped do + authorized_groups_with_shared_membership + end + end + + # Returns the groups a user is a member of, either directly or through a parent group + def membership_groups + groups.self_and_descendants + end + + # Returns a relation of groups the user has access to, including their parent + # and child groups (recursively). + def all_expanded_groups + return groups if groups.empty? + + Gitlab::ObjectHierarchy.new(groups).all_objects + end + + def expanded_groups_requiring_two_factor_authentication + all_expanded_groups.where(require_two_factor_authentication: true) + end + + def source_groups_of_two_factor_authentication_requirement + Gitlab::ObjectHierarchy.new(expanded_groups_requiring_two_factor_authentication) + .all_objects + .where(id: groups) + end + + # rubocop: disable CodeReuse/ServiceClass + def refresh_authorized_projects(source: nil) + Users::RefreshAuthorizedProjectsService.new(self, source: source).execute + end + # rubocop: enable CodeReuse/ServiceClass + + def authorized_projects(min_access_level = nil) + # We're overriding an association, so explicitly call super with no + # arguments or it would be passed as `force_reload` to the association + projects = super() + + if min_access_level + projects = projects + .where('project_authorizations.access_level >= ?', min_access_level) + end + + projects + end + + def authorized_project?(project, min_access_level = nil) + authorized_projects(min_access_level).exists?({ id: project.id }) + end + + # Typically used in conjunction with projects table to get projects + # a user has been given access to. + # The param `related_project_column` is the column to compare to the + # project_authorizations. By default is projects.id + # + # Example use: + # `Project.where('EXISTS(?)', user.authorizations_for_projects)` + def authorizations_for_projects(min_access_level: nil, related_project_column: 'projects.id') + authorizations = project_authorizations + .select(1) + .where("project_authorizations.project_id = #{related_project_column}") + + return authorizations unless min_access_level.present? + + authorizations.where('project_authorizations.access_level >= ?', min_access_level) + end + + # Returns the projects this user has reporter (or greater) access to, limited + # to at most the given projects. + # + # This method is useful when you have a list of projects and want to + # efficiently check to which of these projects the user has at least reporter + # access. + def projects_with_reporter_access_limited_to(projects) + authorized_projects(Gitlab::Access::REPORTER).where(id: projects) + end + + def owned_projects + @owned_projects ||= Project.from_union( + [ + Project.where(namespace: namespace), + Project.joins(:project_authorizations) + .where.not('projects.namespace_id' => namespace.id) + .where(project_authorizations: { user_id: id, access_level: Gitlab::Access::OWNER }) + ], + remove_duplicates: false + ) + end + + # Returns projects which user can admin issues on (for example to move an issue to that project). + # + # This logic is duplicated from `Ability#project_abilities` into a SQL form. + def projects_where_can_admin_issues + authorized_projects(Gitlab::Access::REPORTER).non_archived.with_issues_enabled + end + + def preloaded_member_roles_for_projects(projects) + # overridden in EE + end + + # rubocop: disable CodeReuse/ServiceClass + def require_ssh_key? + count = Users::KeysCountService.new(self).count + + count == 0 && Gitlab::ProtocolAccess.allowed?('ssh') + end + # rubocop: enable CodeReuse/ServiceClass + + def require_password_creation_for_web? + allow_password_authentication_for_web? && password_automatically_set? + end + + def require_password_creation_for_git? + allow_password_authentication_for_git? && password_automatically_set? + end + + def require_personal_access_token_creation_for_git_auth? + return false if allow_password_authentication_for_git? || password_based_omniauth_user? + + PersonalAccessTokensFinder.new(user: self, impersonation: false, state: 'active').execute.none? + end + + def require_extra_setup_for_git_auth? + require_password_creation_for_git? || require_personal_access_token_creation_for_git_auth? + end + + def allow_password_authentication? + allow_password_authentication_for_web? || allow_password_authentication_for_git? + end + + def allow_password_authentication_for_web? + Gitlab::CurrentSettings.password_authentication_enabled_for_web? && !ldap_user? + end + + def allow_password_authentication_for_git? + Gitlab::CurrentSettings.password_authentication_enabled_for_git? && !password_based_omniauth_user? + end + + # method overriden in EE + def password_based_login_forbidden? + false + end + + def can_change_username? + gitlab_config.username_changing_enabled + end + + def can_create_project? + projects_limit_left > 0 + end + + def can_create_group? + can?(:create_group) + end + + def can_select_namespace? + several_namespaces? || admin + end + + def can?(action, subject = :global) + Ability.allowed?(self, action, subject) + end + + def confirm_deletion_with_password? + !password_automatically_set? && allow_password_authentication? + end + + def first_name + read_attribute(:first_name) || begin + name.split(' ').first unless name.blank? + end + end + + def last_name + read_attribute(:last_name) || begin + name.split(' ').drop(1).join(' ') unless name.blank? + end + end + + def projects_limit_left + projects_limit - personal_projects_count + end + + # rubocop: disable CodeReuse/ServiceClass + def recent_push(project = nil) + service = Users::LastPushEventService.new(self) + + if project + service.last_event_for_project(project) + else + service.last_event_for_user + end + end + # rubocop: enable CodeReuse/ServiceClass + + def several_namespaces? + union_sql = ::Gitlab::SQL::Union.new( + [owned_groups, + maintainers_groups, + groups_with_developer_maintainer_project_access]).to_sql + + ::Group.from("(#{union_sql}) #{::Group.table_name}").any? + end + + def namespace_id + namespace.try :id + end + + def name_with_username + "#{name} (#{username})" + end + + def already_forked?(project) + !!fork_of(project) + end + + def fork_of(project) + namespace.find_fork_of(project) + end + + def password_based_omniauth_user? + ldap_user? || crowd_user? + end + + def crowd_user? + if identities.loaded? + identities.find { |identity| identity.provider == 'crowd' && identity.extern_uid.present? } + else + identities.with_any_extern_uid('crowd').exists? + end + end + + def ldap_user? + if identities.loaded? + identities.find { |identity| Gitlab::Auth::OAuth::Provider.ldap_provider?(identity.provider) && !identity.extern_uid.nil? } + else + identities.exists?(["provider LIKE ? AND extern_uid IS NOT NULL", "ldap%"]) + end + end + + def ldap_identity + @ldap_identity ||= identities.find_by(["provider LIKE ?", "ldap%"]) + end + + def matches_identity?(provider, extern_uid) + identities.with_extern_uid(provider, extern_uid).exists? + end + + def project_deploy_keys + @project_deploy_keys ||= DeployKey.in_projects(authorized_projects.select(:id)).distinct(:id) + end + + def highest_role + user_highest_role&.highest_access_level || Gitlab::Access::NO_ACCESS + end + + def credit_card_validated_at + credit_card_validation&.credit_card_validated_at + end + + def accessible_deploy_keys + DeployKey.from_union( + [ + DeployKey.where(id: project_deploy_keys.select(:deploy_key_id)), + DeployKey.are_public + ]) + end + + def created_by + User.find_by(id: created_by_id) if created_by_id + end + + def sanitize_attrs + sanitize_name + end + + def sanitize_name + return unless self.name + + self.name = self.name.gsub(%r{]*>}, '') + end + + def unset_secondary_emails_matching_deleted_email!(deleted_email) + secondary_email_attribute_changed = false + SECONDARY_EMAIL_ATTRIBUTES.each do |attribute| + if read_attribute(attribute) == deleted_email + self.write_attribute(attribute, nil) + secondary_email_attribute_changed = true + end + end + save if secondary_email_attribute_changed + end + + def admin_unsubscribe! + update_column :admin_email_unsubscribed_at, Time.current + end + + def set_projects_limit + # `User.select(:id)` raises + # `ActiveModel::MissingAttributeError: missing attribute: projects_limit` + # without this safeguard! + return unless has_attribute?(:projects_limit) && projects_limit.nil? + + self.projects_limit = Gitlab::CurrentSettings.default_projects_limit + end + + def requires_ldap_check? + if !Gitlab.config.ldap.enabled + false + elsif ldap_user? + !last_credential_check_at || (last_credential_check_at + ldap_sync_time) < Time.current + else + false + end + end + + def ldap_sync_time + # This number resides in this method so it can be redefined in EE. + 1.hour + end + + def try_obtain_ldap_lease + # After obtaining this lease LDAP checks will be blocked for 600 seconds + # (10 minutes) for this user. + lease = Gitlab::ExclusiveLease.new("user_ldap_check:#{id}", timeout: 600) + lease.try_obtain + end + + def solo_owned_groups + # For each owned group, count the owners found in self and ancestors. + counts = GroupMember + .from('unnest(namespaces.traversal_ids) AS ancestors(ancestor_id), members') + .where('members.source_id = ancestors.ancestor_id') + .all_by_access_level(GroupMember::OWNER) + .having('count(members.user_id) = 1') + + Group + .from(owned_groups, :namespaces) + .where_exists(counts) + end + + def with_defaults + User.defaults.each do |k, v| + public_send("#{k}=", v) # rubocop:disable GitlabSecurity/PublicSend + end + + self + end + + def can_leave_project?(project) + project.namespace != namespace && + project.member(self) + end + + def full_website_url + return "http://#{website_url}" if website_url !~ %r{\Ahttps?://} + + website_url + end + + def short_website_url + website_url.sub(%r{\Ahttps?://}, '') + end + + def all_ssh_keys + keys.map(&:publishable_key) + end + + def temp_oauth_email? + email.start_with?('temp-email-for-oauth') + end + + # rubocop: disable CodeReuse/ServiceClass + def avatar_url(size: nil, scale: 2, **args) + GravatarService.new.execute(email, size, scale, username: username) + end + # rubocop: enable CodeReuse/ServiceClass + + def primary_email_verified? + return false unless confirmed? && !temp_oauth_email? + + !email_changed? || new_record? + end + + def accept_pending_invitations! + pending_invitations.select do |member| + member.accept_invite!(self) + end + end + + def pending_invitations + Member.where(invite_email: verified_emails).invite + end + + def all_emails(include_private_email: true) + all_emails = [] + all_emails << email unless temp_oauth_email? + all_emails << private_commit_email if include_private_email + all_emails.concat(emails.filter_map { |email| email.email if email.confirmed? }) + all_emails.uniq + end + + def verified_emails(include_private_email: true) + verified_emails = [] + verified_emails << email if primary_email_verified? + verified_emails << private_commit_email if include_private_email + verified_emails.concat(emails.confirmed.pluck(:email)) + verified_emails.uniq + end + + def public_verified_emails + strong_memoize(:public_verified_emails) do + emails = verified_emails(include_private_email: false) + emails << email unless temp_oauth_email? + emails.uniq + end + end + + def any_email?(check_email) + downcased = check_email.downcase + + # handle the outdated private commit email case + return true if persisted? && + id == Gitlab::PrivateCommitEmail.user_id_for_email(downcased) + + all_emails.include?(check_email.downcase) + end + + def verified_email?(check_email) + downcased = check_email.downcase + + # handle the outdated private commit email case + return true if persisted? && + id == Gitlab::PrivateCommitEmail.user_id_for_email(downcased) + + verified_emails.include?(check_email.downcase) + end + + def hook_attrs + { + id: id, + name: name, + username: username, + avatar_url: avatar_url(only_path: false), + email: webhook_email + } + end + + def ensure_namespace_correct + if namespace + namespace.path = username if username_changed? + namespace.name = name if name_changed? + else + # TODO: we should no longer need the `type` parameter once we can make the + # the `has_one :namespace` association use the correct class. + # issue https://gitlab.com/gitlab-org/gitlab/-/issues/341070 + namespace = build_namespace(path: username, name: name, type: ::Namespaces::UserNamespace.sti_name) + namespace.build_namespace_settings + end + end + + def set_username_errors + namespace_path_errors = self.errors.delete(:"namespace.path") + + return unless namespace_path_errors&.any? + + if namespace_path_errors.include?('has already been taken') && !User.exists?(username: username) + self.errors.add(:base, :username_exists_as_a_different_namespace) + else + namespace_path_errors.each do |msg| + self.errors.add(:username, msg) + end + end + end + + def username_changed_hook + system_hook_service.execute_hooks_for(self, :rename) + end + + def post_destroy_hook + log_info("User \"#{name}\" (#{email}) was removed") + + system_hook_service.execute_hooks_for(self, :destroy) + end + + # rubocop: disable CodeReuse/ServiceClass + def remove_key_cache + Users::KeysCountService.new(self).delete_cache + end + # rubocop: enable CodeReuse/ServiceClass + + DELETION_DELAY_IN_DAYS = 7.days + + def delete_async(deleted_by:, params: {}) + if should_delay_delete?(deleted_by) + new_note = format(_("User deleted own account on %{timestamp}"), timestamp: Time.zone.now) + self.note = "#{new_note}\n#{note}".strip + + block_or_ban + DeleteUserWorker.perform_in(DELETION_DELAY_IN_DAYS, deleted_by.id, id, params.to_h) + + return + end + + block if params[:hard_delete] + + DeleteUserWorker.perform_async(deleted_by.id, id, params.to_h) + end + + # rubocop: disable CodeReuse/ServiceClass + def notification_service + NotificationService.new + end + # rubocop: enable CodeReuse/ServiceClass + + def log_info(message) + Gitlab::AppLogger.info message + end + + # rubocop: disable CodeReuse/ServiceClass + def system_hook_service + SystemHooksService.new + end + # rubocop: enable CodeReuse/ServiceClass + + def starred?(project) + starred_projects.exists?(project.id) + end + + def toggle_star(project) + UsersStarProject.transaction do + user_star_project = users_star_projects + .where(project: project, user: self).lock(true).first + + if user_star_project + user_star_project.destroy + else + UsersStarProject.create!(project: project, user: self) + end + end + end + + def following?(user) + self.followees.exists?(user.id) + end + + def followed_by?(user) + self.followers.include?(user) + end + + def follow(user) + return false unless following_users_allowed?(user) + + begin + followee = Users::UserFollowUser.create(follower_id: self.id, followee_id: user.id) + self.followees.reset if followee.persisted? + followee + rescue ActiveRecord::RecordNotUnique + nil + end + end + + def unfollow(user) + if Users::UserFollowUser.where(follower_id: self.id, followee_id: user.id).delete_all > 0 + self.followees.reset + else + false + end + end + + def following_users_allowed?(user) + return false if self.id == user.id + + following_users_enabled? && user.following_users_enabled? + end + + def following_users_enabled? + return true unless ::Feature.enabled?(:disable_follow_users, self) + + enabled_following + end + + def forkable_namespaces + strong_memoize(:forkable_namespaces) do + personal_namespace = Namespace.where(id: namespace_id) + groups_allowing_project_creation = Groups::AcceptingProjectCreationsFinder.new(self).execute + + Namespace.from_union( + [ + groups_allowing_project_creation, + personal_namespace + ]) + end + end + + def manageable_groups(include_groups_with_developer_maintainer_access: false) + owned_and_maintainer_group_hierarchy = owned_or_maintainers_groups.self_and_descendants + + if include_groups_with_developer_maintainer_access + union_sql = ::Gitlab::SQL::Union.new( + [owned_and_maintainer_group_hierarchy, + groups_with_developer_maintainer_project_access]).to_sql + + ::Group.from("(#{union_sql}) #{::Group.table_name}") + else + owned_and_maintainer_group_hierarchy + end + end + + def namespaces(owned_only: false) + user_groups = owned_only ? owned_groups : groups + personal_namespace = Namespace.where(id: namespace.id) + + Namespace.from_union([user_groups, personal_namespace]) + end + + def oauth_authorized_tokens + OauthAccessToken.where(resource_owner_id: id, revoked_at: nil) + end + + # Returns the projects a user contributed to in the last year. + # + # This method relies on a subquery as this performs significantly better + # compared to a JOIN when coupled with, for example, + # `Project.visible_to_user`. That is, consider the following code: + # + # some_user.contributed_projects.visible_to_user(other_user) + # + # If this method were to use a JOIN the resulting query would take roughly 200 + # ms on a database with a similar size to GitLab.com's database. On the other + # hand, using a subquery means we can get the exact same data in about 40 ms. + def contributed_projects + events = Event.select(:project_id) + .contributions.where(author_id: self) + .where("created_at > ?", Time.current - 1.year) + .distinct + .reorder(nil) + + Project.where(id: events).not_aimed_for_deletion + end + + def can_be_removed? + !solo_owned_groups.present? + end + + def can_remove_self? + true + end + + def authorized_project_mirrors(level) + projects = Ci::ProjectMirror.by_project_id(ci_project_ids_for_project_members(level)) + + namespace_projects = Ci::ProjectMirror.by_namespace_id(ci_namespace_mirrors_for_group_members(level).select(:namespace_id)) + + Ci::ProjectMirror.from_union([projects, namespace_projects]) + end + + def ci_owned_runners + @ci_owned_runners ||= Ci::Runner + .from_union([ci_owned_project_runners_from_project_members, + ci_owned_project_runners_from_group_members, + ci_owned_group_runners]) + end + + def owns_runner?(runner) + ci_owned_runners.include?(runner) + end + + def notification_email_for(notification_group) + # Return group-specific email address if present, otherwise return global notification email address + group_email = if notification_group && notification_group.respond_to?(:notification_email_for) + notification_group.notification_email_for(self) + end + + group_email || notification_email_or_default + end + + def notification_settings_for(source, inherit: false) + if notification_settings.loaded? + notification_settings.find do |notification| + notification.source_type == source.class.base_class.name && + notification.source_id == source.id + end + else + notification_settings.find_or_initialize_by(source: source) do |ns| + next unless source.is_a?(Group) && inherit + + # If we're here it means we're trying to create a NotificationSetting for a group that doesn't have one. + # Find the closest parent with a notification_setting that's not Global level, or that has an email set. + ancestor_ns = source + .notification_settings(hierarchy_order: :asc) + .where(user: self) + .find_by('level != ? OR notification_email IS NOT NULL', NotificationSetting.levels[:global]) + # Use it to seed the settings + ns.assign_attributes(ancestor_ns&.slice(*NotificationSetting.allowed_fields)) + ns.source = source + ns.user = self + end + end + end + + def notification_settings_for_groups(groups) + ids = groups.is_a?(ActiveRecord::Relation) ? groups.select(:id) : groups.map(&:id) + notification_settings.for_groups.where(source_id: ids) + end + + # Lazy load global notification setting + # Initializes User setting with Participating level if setting not persisted + def global_notification_setting + return @global_notification_setting if defined?(@global_notification_setting) + + @global_notification_setting = notification_settings.find_or_initialize_by(source: nil) + @global_notification_setting.update(level: NotificationSetting.levels[DEFAULT_NOTIFICATION_LEVEL]) unless @global_notification_setting.persisted? + + @global_notification_setting + end + + def assigned_open_merge_requests_count(force: false) + Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do + MergeRequestsFinder.new(self, assignee_id: self.id, state: 'opened', non_archived: true).execute.count + end + end + + def review_requested_open_merge_requests_count(force: false) + Rails.cache.fetch(['users', id, 'review_requested_open_merge_requests_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do + MergeRequestsFinder.new(self, reviewer_id: id, state: 'opened', non_archived: true).execute.count + end + end + + def assigned_open_issues_count(force: false) + Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do + IssuesFinder.new(self, assignee_id: self.id, state: 'opened', non_archived: true).execute.count + end + end + + def todos_done_count(force: false) + Rails.cache.fetch(['users', id, 'todos_done_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do + TodosFinder.new(self, state: :done).execute.count + end + end + + def todos_pending_count(force: false) + Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do + TodosFinder.new(self, state: :pending).execute.count + end + end + + def personal_projects_count(force: false) + Rails.cache.fetch(['users', id, 'personal_projects_count'], force: force, expires_in: 24.hours, raw: true) do + personal_projects.count + end.to_i + end + + def update_todos_count_cache + todos_done_count(force: true) + todos_pending_count(force: true) + end + + def invalidate_cache_counts + invalidate_issue_cache_counts + invalidate_merge_request_cache_counts + invalidate_todos_cache_counts + invalidate_personal_projects_count + end + + def invalidate_issue_cache_counts + Rails.cache.delete(['users', id, 'assigned_open_issues_count']) + Rails.cache.delete(['users', id, 'max_assigned_open_issues_count']) + end + + def invalidate_merge_request_cache_counts + Rails.cache.delete(['users', id, 'assigned_open_merge_requests_count']) + Rails.cache.delete(['users', id, 'review_requested_open_merge_requests_count']) + end + + def invalidate_todos_cache_counts + Rails.cache.delete(['users', id, 'todos_done_count']) + Rails.cache.delete(['users', id, 'todos_pending_count']) + end + + def invalidate_personal_projects_count + Rails.cache.delete(['users', id, 'personal_projects_count']) + end + + # This is copied from Devise::Models::Lockable#valid_for_authentication?, as our auth + # flow means we don't call that automatically (and can't conveniently do so). + # + # See: + # + # + # rubocop: disable CodeReuse/ServiceClass + def increment_failed_attempts! + return if ::Gitlab::Database.read_only? + + increment_failed_attempts + + if attempts_exceeded? + lock_access! unless access_locked? + else + Users::UpdateService.new(self, user: self).execute(validate: false) + end + end + # rubocop: enable CodeReuse/ServiceClass + + def access_level + if admin? + :admin + else + :regular + end + end + + def access_level=(new_level) + new_level = new_level.to_s + return unless %w(admin regular).include?(new_level) + + self.admin = (new_level == 'admin') + end + + def can_read_all_resources? + can?(:read_all_resources) + end + + def can_admin_all_resources? + can?(:admin_all_resources) + end + + def update_two_factor_requirement + periods = expanded_groups_requiring_two_factor_authentication.pluck(:two_factor_grace_period) + + self.require_two_factor_authentication_from_group = periods.any? + self.two_factor_grace_period = periods.min || User.column_defaults['two_factor_grace_period'] + + save + end + + # each existing user needs to have a `feed_token`. + # we do this on read since migrating all existing users is not a feasible + # solution. + def feed_token + ensure_feed_token! unless Gitlab::CurrentSettings.disable_feed_token + end + + # Each existing user needs to have a `static_object_token`. + # We do this on read since migrating all existing users is not a feasible + # solution. + def static_object_token + ensure_static_object_token! + end + + def enabled_static_object_token + static_object_token if Gitlab::CurrentSettings.static_objects_external_storage_enabled? + end + + def enabled_incoming_email_token + incoming_email_token if Gitlab::Email::IncomingEmail.supports_issue_creation? + end + + def sync_attribute?(attribute) + return true if ldap_user? && attribute == :email + + attributes = Gitlab.config.omniauth.sync_profile_attributes + + if attributes.is_a?(Array) + attributes.include?(attribute.to_s) + else + attributes + end + end + + def read_only_attribute?(attribute) + user_synced_attributes_metadata&.read_only?(attribute) + end + + # override, from Devise + def lock_access!(opts = {}) + Gitlab::AppLogger.info("Account Locked: username=#{username}") + super + end + + # Determine the maximum access level for a group of projects in bulk. + # + # Returns a Hash mapping project ID -> maximum access level. + def max_member_access_for_project_ids(project_ids) + Gitlab::SafeRequestLoader.execute( + resource_key: max_member_access_for_resource_key(Project), + resource_ids: project_ids, + default_value: Gitlab::Access::NO_ACCESS + ) do |project_ids| + project_authorizations.where(project: project_ids) + .group(:project_id) + .maximum(:access_level) + end + end + + def max_member_access_for_project(project_id) + max_member_access_for_project_ids([project_id])[project_id] + end + + # Determine the maximum access level for a group of groups in bulk. + # + # Returns a Hash mapping project ID -> maximum access level. + def max_member_access_for_group_ids(group_ids) + Gitlab::SafeRequestLoader.execute( + resource_key: max_member_access_for_resource_key(Group), + resource_ids: group_ids, + default_value: Gitlab::Access::NO_ACCESS + ) do |group_ids| + group_members.where(source: group_ids).group(:source_id).maximum(:access_level) + end + end + + def max_member_access_for_group(group_id) + max_member_access_for_group_ids([group_id])[group_id] + end + + def terms_accepted? + return true if project_bot? + + accepted_term_id.present? + end + + def required_terms_not_accepted? + Gitlab::CurrentSettings.current_application_settings.enforce_terms? && + !terms_accepted? + end + + def requires_usage_stats_consent? + self.admin? && 7.days.ago > self.created_at && !has_current_license? && User.single_user? && !consented_usage_stats? + end + + # Avoid migrations only building user preference object when needed. + def user_preference + super.presence || build_user_preference + end + + def user_detail + super.presence || build_user_detail + end + + def pending_todo_for(target) + todos.find_by(target: target, state: :pending) + end + + def password_expired? + !!(password_expires_at && password_expires_at < Time.current) + end + + def password_expired_if_applicable? + return false if bot? + return false unless password_expired? + return false if password_automatically_set? + return false unless allow_password_authentication? + + true + end + + def can_log_in_with_non_expired_password? + can?(:log_in) && !password_expired_if_applicable? + end + + def can_be_deactivated? + active? && no_recent_activity? && !internal? + end + + def last_active_at + last_activity = last_activity_on&.to_time&.in_time_zone + last_sign_in = current_sign_in_at + + [last_activity, last_sign_in].compact.max + end + + REQUIRES_ROLE_VALUE = 99 + + def role_required? + role_before_type_cast == REQUIRES_ROLE_VALUE + end + + def set_role_required! + update_column(:role, REQUIRES_ROLE_VALUE) + end + + def dismissed_callout?(feature_name:, ignore_dismissal_earlier_than: nil) + callout = callouts_by_feature_name[feature_name] + + callout_dismissed?(callout, ignore_dismissal_earlier_than) + end + + def dismissed_callout_before?(feature_name, dismissed_before) + callout = callouts_by_feature_name[feature_name] + + return false unless callout + + callout.dismissed_before?(dismissed_before) + end + + def dismissed_callout_for_group?(feature_name:, group:, ignore_dismissal_earlier_than: nil) + source_feature_name = "#{feature_name}_#{group.id}" + callout = group_callouts_by_feature_name[source_feature_name] + + callout_dismissed?(callout, ignore_dismissal_earlier_than) + end + + def dismissed_callout_for_project?(feature_name:, project:, ignore_dismissal_earlier_than: nil) + callout = project_callouts.find_by(feature_name: feature_name, project: project) + + callout_dismissed?(callout, ignore_dismissal_earlier_than) + end + + # Load the current highest access by looking directly at the user's memberships + def current_highest_access_level + members.non_request.maximum(:access_level) + end + + def confirmation_required_on_sign_in? + return false if confirmed? + + if ::Gitlab::CurrentSettings.email_confirmation_setting_off? + false + elsif ::Gitlab::CurrentSettings.email_confirmation_setting_soft? + !in_confirmation_period? + elsif ::Gitlab::CurrentSettings.email_confirmation_setting_hard? + true + end + end + + def impersonated? + impersonator.present? + end + + def created_recently? + created_at > Devise.confirm_within.ago + end + + def find_or_initialize_callout(feature_name) + callouts.find_or_initialize_by(feature_name: ::Users::Callout.feature_names[feature_name]) + end + + def find_or_initialize_group_callout(feature_name, group_id) + group_callouts + .find_or_initialize_by(feature_name: ::Users::GroupCallout.feature_names[feature_name], group_id: group_id) + end + + def find_or_initialize_project_callout(feature_name, project_id) + project_callouts + .find_or_initialize_by(feature_name: ::Users::ProjectCallout.feature_names[feature_name], project_id: project_id) + end + + def can_trigger_notifications? + confirmed? && !blocked? && !ghost? + end + + # This attribute hosts a Ci::JobToken::Scope object which is set when + # the user is authenticated successfully via CI_JOB_TOKEN. + def ci_job_token_scope + Gitlab::SafeRequestStore[ci_job_token_scope_cache_key] + end + + def set_ci_job_token_scope!(job) + Gitlab::SafeRequestStore[ci_job_token_scope_cache_key] = Ci::JobToken::Scope.new(job.project) + end + + def from_ci_job_token? + ci_job_token_scope.present? + end + + def user_project + strong_memoize(:user_project) do + personal_projects.find_by(path: username, visibility_level: Gitlab::VisibilityLevel::PUBLIC) + end + end + + def user_readme + strong_memoize(:user_readme) do + user_project&.repository&.readme + end + end + + def account_age_in_days + (Date.current - created_at.to_date).to_i + end + + def webhook_email + public_email.presence || _('[REDACTED]') + end + + def namespace_commit_email_for_project(project) + return if project.nil? + + namespace_commit_emails.find_by(namespace: project.project_namespace) || + namespace_commit_emails.find_by(namespace: project.root_namespace) + end + + def spammer? + spam_score > Abuse::TrustScore::SPAMCHECK_HAM_THRESHOLD + end + + def spam_score + abuse_trust_scores.spamcheck.average(:score) || 0.0 + end + + def telesign_score + abuse_trust_scores.telesign.order(created_at: :desc).first&.score || 0.0 + end + + def arkose_global_score + abuse_trust_scores.arkose_global_score.order(created_at: :desc).first&.score || 0.0 + end + + def arkose_custom_score + abuse_trust_scores.arkose_custom_score.order(created_at: :desc).first&.score || 0.0 + end + + def trust_scores_for_source(source) + abuse_trust_scores.where(source: source) + end + + def abuse_metadata + { + account_age: account_age_in_days, + two_factor_enabled: two_factor_enabled? ? 1 : 0 + } + end + +<<<<<<< HEAD + def namespace_commit_email_for_namespace(namespace) + return if namespace.nil? + + namespace_commit_emails.find_by(namespace: namespace) +======= + def allow_possible_spam? + custom_attributes.by_key(UserCustomAttribute::ALLOW_POSSIBLE_SPAM).exists? +>>>>>>> c68084987dc5 (Add allow/disallow services and specs) + end + + protected + + # override, from Devise::Validatable + def password_required? + return false if internal? || project_bot? || security_policy_bot? + + super + end + + # override from Devise::Confirmable + def confirmation_period_valid? + return super if ::Gitlab::CurrentSettings.email_confirmation_setting_soft? + + # Following devise logic for method, we want to return `true` + # See: https://github.com/heartcombo/devise/blob/main/lib/devise/models/confirmable.rb#L191-L218 + true + end + alias_method :in_confirmation_period?, :confirmation_period_valid? + + # This is copied from Devise::Models::TwoFactorAuthenticatable#consume_otp! + # + # An OTP cannot be used more than once in a given timestep + # Storing timestep of last valid OTP is sufficient to satisfy this requirement + # + # See: + # + # + def consume_otp! + if self.consumed_timestep != current_otp_timestep + self.consumed_timestep = current_otp_timestep + return Gitlab::Database.read_only? ? true : save(validate: false) + end + + false + end + + private + + def block_or_ban + if spammer? && account_age_in_days < 7 + ban_and_report + else + block + end + end + + def ban_and_report + msg = 'Potential spammer account deletion' + attrs = { user_id: id, reporter: User.security_bot, category: 'spam' } + abuse_report = AbuseReport.find_by(attrs) + + if abuse_report.nil? + abuse_report = AbuseReport.create!(attrs.merge(message: msg)) + else + abuse_report.update(message: "#{abuse_report.message}\n\n#{msg}") + end + + UserCustomAttribute.set_banned_by_abuse_report(abuse_report) + + ban + end + + def has_possible_spam_contributions? + events + .for_action('commented') + .or(events.for_action('created').where(target_type: %w[Issue MergeRequest])) + .any? + end + + def should_delay_delete?(deleted_by) + is_deleting_own_record = deleted_by.id == id + + is_deleting_own_record && + ::Feature.enabled?(:delay_delete_own_user) && + has_possible_spam_contributions? + end + + def pbkdf2? + return false unless otp_backup_codes&.any? + + otp_backup_codes.first.start_with?("$pbkdf2-sha512$") + end + + # rubocop: disable CodeReuse/ServiceClass + def add_primary_email_to_emails! + Emails::CreateService.new(self, user: self, email: self.email).execute(confirmed_at: self.confirmed_at) + end + # rubocop: enable CodeReuse/ServiceClass + + def ci_project_ids_for_project_members(level) + project_members.where('access_level >= ?', level).pluck(:source_id) + end + + def notification_email_verified + return if notification_email.blank? || temp_oauth_email? + + errors.add(:notification_email, _("must be an email you have verified")) unless verified_emails.include?(notification_email_or_default) + end + + def public_email_verified + return if public_email.blank? + + errors.add(:public_email, _("must be an email you have verified")) unless verified_emails.include?(public_email) + end + + def commit_email_verified + return if commit_email.blank? + + errors.add(:commit_email, _("must be an email you have verified")) unless verified_emails.include?(commit_email_or_default) + end + + def callout_dismissed?(callout, ignore_dismissal_earlier_than) + return false unless callout + return callout.dismissed_after?(ignore_dismissal_earlier_than) if ignore_dismissal_earlier_than + + true + end + + def callouts_by_feature_name + @callouts_by_feature_name ||= callouts.index_by(&:feature_name) + end + + def group_callouts_by_feature_name + @group_callouts_by_feature_name ||= group_callouts.index_by(&:source_feature_name) + end + + def authorized_groups_without_shared_membership + Group.from_union( + [ + groups, + Group.id_in(authorized_projects.select(:namespace_id)) + ] + ) + end + + def authorized_groups_with_shared_membership + cte = Gitlab::SQL::CTE.new(:direct_groups, authorized_groups_without_shared_membership) + cte_alias = cte.table.alias(Group.table_name) + + Group + .with(cte.to_arel) + .from_union([ + Group.from(cte_alias), + Group.joins(:shared_with_group_links) + .where(group_group_links: { shared_with_group_id: Group.from(cte_alias) }) + ]) + end + + def has_current_license? + false + end + + def consented_usage_stats? + # Bypass the cache here because it's possible the admin enabled the + # usage ping, and we don't want to annoy the user again if they + # already set the value. This is a bit of hack, but the alternative + # would be to put in a more complex cache invalidation step. Since + # this call only gets called in the uncommon situation where the + # user is an admin and the only user in the instance, this shouldn't + # cause too much load on the system. + ApplicationSetting.current_without_cache&.usage_stats_set_by_user_id == self.id + end + + def ensure_user_rights_and_limits + if external? + self.can_create_group = false + self.projects_limit = 0 + else + # Only revert these back to the default if they weren't specifically changed in this update. + self.can_create_group = Gitlab::CurrentSettings.can_create_group unless can_create_group_changed? + self.projects_limit = Gitlab::CurrentSettings.default_projects_limit unless projects_limit_changed? + end + end + + def email_allowed_by_restrictions? + error = validate_admin_signup_restrictions(email) + + errors.add(:email, error) if error + end + + def signup_email_invalid_message + self.new_record? ? _('is not allowed for sign-up. Please use your regular email address.') : _('is not allowed. Please use your regular email address.') + end + + def check_username_format + return if username.blank? || Mime::EXTENSION_LOOKUP.keys.none? { |type| username.end_with?(".#{type}") } + + errors.add(:username, _('ending with a reserved file extension is not allowed.')) + end + + def check_password_weakness + if password.present? && Security::WeakPasswords.weak_for_user?(password, self) + errors.add(:password, _('must not contain commonly used combinations of words and letters')) + end + end + + def groups_with_developer_maintainer_project_access + project_creation_levels = [::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS] + + if ::Gitlab::CurrentSettings.default_project_creation == ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS + project_creation_levels << nil + end + + developer_groups.self_and_descendants.where(project_creation_level: project_creation_levels) + end + + def no_recent_activity? + last_active_at.to_i <= Gitlab::CurrentSettings.deactivate_dormant_users_period.days.ago.to_i + end + + def update_highest_role? + return false unless persisted? + + (previous_changes.keys & %w(state user_type)).any? + end + + def update_highest_role_attribute + id + end + + def ci_job_token_scope_cache_key + "users:#{id}:ci:job_token_scope" + end + + # An `ldap_blocked` user will be unblocked if LDAP indicates they are allowed. + def check_ldap_if_ldap_blocked! + return unless ::Gitlab::Auth::Ldap::Config.enabled? && ldap_blocked? + + ::Gitlab::Auth::Ldap::Access.allowed?(self) + end + + def ci_owned_project_runners_from_project_members + project_ids = ci_project_ids_for_project_members(Gitlab::Access::MAINTAINER) + + Ci::Runner + .joins(:runner_projects) + .where(runner_projects: { project: project_ids }) + end + + def ci_owned_project_runners_from_group_members + cte_namespace_ids = Gitlab::SQL::CTE.new( + :cte_namespace_ids, + ci_namespace_mirrors_for_group_members(Gitlab::Access::MAINTAINER).select(:namespace_id) + ) + + cte_project_ids = Gitlab::SQL::CTE.new( + :cte_project_ids, + Ci::ProjectMirror + .select(:project_id) + .where('ci_project_mirrors.namespace_id IN (SELECT namespace_id FROM cte_namespace_ids)') + ) + + Ci::Runner + .with(cte_namespace_ids.to_arel) + .with(cte_project_ids.to_arel) + .joins(:runner_projects) + .where('ci_runner_projects.project_id IN (SELECT project_id FROM cte_project_ids)') + end + + def ci_owned_group_runners + cte_namespace_ids = Gitlab::SQL::CTE.new( + :cte_namespace_ids, + ci_namespace_mirrors_for_group_members(Gitlab::Access::OWNER).select(:namespace_id) + ) + + Ci::Runner + .with(cte_namespace_ids.to_arel) + .joins(:runner_namespaces) + .where('ci_runner_namespaces.namespace_id IN (SELECT namespace_id FROM cte_namespace_ids)') + end + + def ci_namespace_mirrors_for_group_members(level) + search_members = group_members.where('access_level >= ?', level) + + # This reduces searched prefixes to only shortest ones + # to avoid querying descendants since they are already covered + # by ancestor namespaces. If the FF is not available fallback to + # inefficient search: https://gitlab.com/gitlab-org/gitlab/-/issues/336436 + unless Feature.enabled?(:use_traversal_ids) + return Ci::NamespaceMirror.contains_any_of_namespaces(search_members.pluck(:source_id)) + end + + traversal_ids = Group.joins(:all_group_members) + .merge(search_members) + .shortest_traversal_ids_prefixes + + Ci::NamespaceMirror.contains_traversal_ids(traversal_ids) + end +end + +User.prepend_mod_with('User') diff --git a/app/models/user_custom_attribute.rb b/app/models/user_custom_attribute.rb index 425f2cc062b068..eba338a7e2aca2 100644 --- a/app/models/user_custom_attribute.rb +++ b/app/models/user_custom_attribute.rb @@ -10,6 +10,7 @@ class UserCustomAttribute < ApplicationRecord scope :by_user_id, ->(user_id) { where(user_id: user_id) } scope :by_updated_at, ->(updated_at) { where(updated_at: updated_at) } scope :arkose_sessions, -> { by_key('arkose_session') } + scope :trusted_with_spam, -> { by_key(ALLOW_POSSIBLE_SPAM) } BLOCKED_BY = 'blocked_by' UNBLOCKED_BY = 'unblocked_by' diff --git a/app/services/users/allow_possible_spam_service.rb b/app/services/users/allow_possible_spam_service.rb index d9273fe0fc1cf4..2caa577056fc93 100644 --- a/app/services/users/allow_possible_spam_service.rb +++ b/app/services/users/allow_possible_spam_service.rb @@ -13,6 +13,7 @@ def execute(user) value: "#{current_user.username}/#{current_user.id}+#{Time.current}" } UserCustomAttribute.upsert_custom_attributes([custom_attribute]) + success end end end diff --git a/app/services/users/disallow_possible_spam_service.rb b/app/services/users/disallow_possible_spam_service.rb index e31ba7ddff02a4..5f4c109aae2b27 100644 --- a/app/services/users/disallow_possible_spam_service.rb +++ b/app/services/users/disallow_possible_spam_service.rb @@ -8,6 +8,7 @@ def initialize(current_user) def execute(user) user.custom_attributes.by_key(UserCustomAttribute::ALLOW_POSSIBLE_SPAM).delete_all + success end end end diff --git a/app/views/admin/abuse_reports/_abuse_report.html.haml b/app/views/admin/abuse_reports/_abuse_report.html.haml new file mode 100644 index 00000000000000..b2ab3942a8bced --- /dev/null +++ b/app/views/admin/abuse_reports/_abuse_report.html.haml @@ -0,0 +1,45 @@ +- reporter = abuse_report.reporter +- user = abuse_report.user +%tr + %th.d-block.d-sm-none + %strong= _('User') + %td + - if user + = link_to user.name, user + .light.small + = html_escape(_('Joined %{time_ago}')) % { time_ago: time_ago_with_tooltip(user.created_at).html_safe } + - else + = _('(removed)') + %td + - if reporter + %strong.subheading.d-block.d-sm-none + = _('Reported by %{reporter}').html_safe % { reporter: reporter ? link_to(reporter.name, reporter) : _('(removed)') } + .light.gl-display-none.gl-sm-display-block + = link_to(reporter.name, reporter) + .light.small + = time_ago_with_tooltip(abuse_report.created_at) + - else + = _('(removed)') + %td + %strong.subheading.d-block.d-sm-none + = _('Message') + .message + = markdown_field(abuse_report, :message) + %td + - if user && user != current_user + = render Pajamas::ButtonComponent.new(href: admin_abuse_report_path(abuse_report, remove_user: true), variant: :danger, block: true, button_options: { data: { confirm: _("USER %{user} WILL BE REMOVED! Are you sure?") % { user: user.name }, confirm_btn_variant: "danger", remote: true, method: :delete }, class: "js-remove-tr" }) do + = _('Remove user & report') + - if user.blocked? + = render Pajamas::ButtonComponent.new(href: block_admin_user_path(user), block: true, disabled: true, button_options: { data: { confirm: _('USER WILL BE BLOCKED! Are you sure?'), method: :put } }) do + = _('Already blocked') + - else + = render Pajamas::ButtonComponent.new(href: block_admin_user_path(user), block: true, button_options: { data: { confirm: _('USER WILL BE BLOCKED! Are you sure?'), method: :put } }) do + = _('Block user') + - if user.allow_possible_spam? + = render Pajamas::ButtonComponent.new(href: disallow_possible_spam_admin_user_path(user), block: true, button_options: { data: { confirm: _('USER WILL NOT BE ALLOWED TO CREATE POSSIBLE SPAM! Are you sure?'), method: :put } }) do + = _('Untrust user') + - else + = render Pajamas::ButtonComponent.new(href: allow_possible_spam_admin_user_path(user), block: true, button_options: { data: { confirm: _('USER WILL BE ALLOWED TO CREATE POSSIBLE SPAM! Are you sure?'), method: :put } }) do + = _('Trust user') + = render Pajamas::ButtonComponent.new(href: [:admin, abuse_report], block: true, button_options: { data: { remote: true, method: :delete }, class: "js-remove-tr" }) do + = _('Remove report') diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml index 213d58479860dd..b28e0e1a113ac9 100644 --- a/app/views/admin/users/_users.html.haml +++ b/app/views/admin/users/_users.html.haml @@ -44,6 +44,9 @@ = gl_tab_link_to admin_users_path(filter: "wop"), { item_active: active_when(params[:filter] == 'wop'), class: 'gl-border-0!' } do = s_('AdminUsers|Without projects') = gl_tab_counter_badge(limited_counter_with_delimiter(User.without_projects)) + = gl_tab_link_to admin_users_path(filter: "trusted"), { item_active: active_when(params[:filter] == 'trusted'), class: 'gl-border-0!' } do + = s_('AdminUsers|Trusted') + = gl_tab_counter_badge(limited_counter_with_delimiter(User.trusted)) .nav-controls = render_if_exists 'admin/users/admin_email_users' = render_if_exists 'admin/users/admin_export_user_permissions' diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 5513ac1813a0a5..fcea63d7f632e3 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -22,6 +22,8 @@ put :unlock put :confirm put :approve + put :allow_possible_spam + put :disallow_possible_spam delete :reject post :impersonate patch :disable_two_factor diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 634e0ebcecfe90..4dd19a4daa25df 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2416,6 +2416,12 @@ msgstr "" msgid "AbuseReport|Tier" msgstr "" +msgid "AbuseReport|Trust user" +msgstr "" + +msgid "AbuseReport|Untrust user" +msgstr "" + msgid "AbuseReport|Verification" msgstr "" @@ -4054,6 +4060,9 @@ msgstr "" msgid "AdminUsers|To confirm, type %{username}." msgstr "" +msgid "AdminUsers|Trusted" +msgstr "" + msgid "AdminUsers|Unban user" msgstr "" @@ -50112,6 +50121,12 @@ msgstr "" msgid "USER WILL BE BLOCKED! Are you sure?" msgstr "" +msgid "USER WILL BE ALLOWED TO CREATE POSSIBLE SPAM! Are you sure?" +msgstr "" + +msgid "USER WILL NOT BE ALLOWED TO CREATE POSSIBLE SPAM! Are you sure?" +msgstr "" + msgid "UTC" msgstr "" -- GitLab From 76caa78a85a80fa0e8fe241ad497455bc8df8640 Mon Sep 17 00:00:00 2001 From: Ethan Urie Date: Thu, 29 Jun 2023 15:22:38 -0400 Subject: [PATCH 03/18] Updated locale file --- app/models/user.rb | 6 +- app/models/user.rb.orig | 2606 --------------------------------------- locale/gitlab.pot | 44 +- 3 files changed, 38 insertions(+), 2618 deletions(-) delete mode 100644 app/models/user.rb.orig diff --git a/app/models/user.rb b/app/models/user.rb index f13f811a895781..547efba169dfdc 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2202,7 +2202,7 @@ def abuse_metadata end def allow_possible_spam? - custom_attributes.by_key(UserCustomAttribute::ALLOW_POSSIBLE_SPAM).exists? + custom_attributes.trusted_with_spam.exists? end def namespace_commit_email_for_namespace(namespace) @@ -2501,10 +2501,6 @@ def ci_namespace_mirrors_for_group_members(level) Ci::NamespaceMirror.contains_traversal_ids(traversal_ids) end - def allow_possible_spam? - custom_attributes.by_key(UserCustomAttribute::ALLOW_POSSIBLE_SPAM).exists? - end - def prefix_for_feed_token FEED_TOKEN_PREFIX end diff --git a/app/models/user.rb.orig b/app/models/user.rb.orig deleted file mode 100644 index 99fa44213256aa..00000000000000 --- a/app/models/user.rb.orig +++ /dev/null @@ -1,2606 +0,0 @@ -# frozen_string_literal: true - -require 'carrierwave/orm/activerecord' - -class User < ApplicationRecord - extend Gitlab::ConfigHelper - - include Gitlab::ConfigHelper - include Gitlab::SQL::Pattern - include AfterCommitQueue - include Avatarable - include Referable - include Sortable - include CaseSensitivity - include TokenAuthenticatable - include FeatureGate - include CreatedAtFilterable - include BulkMemberAccessLoad - include BlocksUnsafeSerialization - include WithUploads - include OptionallySearch - include FromUnion - include BatchDestroyDependentAssociations - include BatchNullifyDependentAssociations - include HasUniqueInternalUsers - include IgnorableColumns - include UpdateHighestRole - include HasUserType - include Gitlab::Auth::Otp::Fortinet - include Gitlab::Auth::Otp::DuoAuth - include RestrictedSignup - include StripAttribute - include EachBatch - include SafelyChangeColumnDefault - - DEFAULT_NOTIFICATION_LEVEL = :participating - - INSTANCE_ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10 - - BLOCKED_PENDING_APPROVAL_STATE = 'blocked_pending_approval' - - COUNT_CACHE_VALIDITY_PERIOD = 24.hours - - OTP_SECRET_LENGTH = 32 - OTP_SECRET_TTL = 2.minutes - - MAX_USERNAME_LENGTH = 255 - MIN_USERNAME_LENGTH = 2 - - MAX_LIMIT_FOR_ASSIGNEED_ISSUES_COUNT = 100 - - SECONDARY_EMAIL_ATTRIBUTES = [ - :commit_email, - :notification_email, - :public_email - ].freeze - - FORBIDDEN_SEARCH_STATES = %w(blocked banned ldap_blocked).freeze - - columns_changing_default :notified_of_own_activity - - add_authentication_token_field :incoming_email_token, token_generator: -> { SecureRandom.hex.to_i(16).to_s(36) } - add_authentication_token_field :feed_token - add_authentication_token_field :static_object_token, encrypted: :optional - - attribute :admin, default: false - attribute :external, default: -> { Gitlab::CurrentSettings.user_default_external } - attribute :can_create_group, default: -> { Gitlab::CurrentSettings.can_create_group } - attribute :private_profile, default: -> { Gitlab::CurrentSettings.user_defaults_to_private_profile } - attribute :can_create_team, default: false - attribute :hide_no_ssh_key, default: false - attribute :hide_no_password, default: false - attribute :project_view, default: :files - attribute :notified_of_own_activity, default: false - attribute :preferred_language, default: -> { Gitlab::CurrentSettings.default_preferred_language } - attribute :theme_id, default: -> { gitlab_config.default_theme } - attribute :color_scheme_id, default: -> { Gitlab::CurrentSettings.default_syntax_highlighting_theme } - - attr_encrypted :otp_secret, - key: Gitlab::Application.secrets.otp_key_base, - mode: :per_attribute_iv_and_salt, - insecure_mode: true, - algorithm: 'aes-256-cbc' - - devise :two_factor_authenticatable, - otp_secret_encryption_key: Gitlab::Application.secrets.otp_key_base - - devise :two_factor_backupable, otp_number_of_backup_codes: 10 - devise :two_factor_backupable_pbkdf2 - serialize :otp_backup_codes, JSON # rubocop:disable Cop/ActiveRecordSerialize - - devise :lockable, :recoverable, :rememberable, :trackable, - :validatable, :omniauthable, :confirmable, :registerable - - # Must be included after `devise` - include EncryptedUserPassword - include RecoverableByAnyEmail - - include AdminChangedPasswordNotifier - - # This module adds async behaviour to Devise emails - # and should be added after Devise modules are initialized. - include AsyncDeviseEmail - include ForcedEmailConfirmation - include RequireEmailVerification - - MINIMUM_DAYS_CREATED = 7 - - # Override Devise::Models::Trackable#update_tracked_fields! - # to limit database writes to at most once every hour - # rubocop: disable CodeReuse/ServiceClass - def update_tracked_fields!(request) - return if Gitlab::Database.read_only? - - update_tracked_fields(request) - - Gitlab::ExclusiveLease.throttle(id) do - ::Ability.forgetting(/admin/) do - Users::UpdateService.new(self, user: self).execute(validate: false) - end - end - end - # rubocop: enable CodeReuse/ServiceClass - - attr_accessor :force_random_password - - # Virtual attribute for authenticating by either username or email - attr_accessor :login - - # Virtual attribute for impersonator - attr_accessor :impersonator - - # - # Relations - # - - # Namespace for personal projects - has_one :namespace, - -> { where(type: Namespaces::UserNamespace.sti_name) }, - dependent: :destroy, # rubocop:disable Cop/ActiveRecordDependent - foreign_key: :owner_id, - inverse_of: :owner, - autosave: true # rubocop:disable Cop/ActiveRecordDependent - - # Profile - has_many :keys, -> { regular_keys }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :expired_today_and_unnotified_keys, -> { expired_today_and_not_notified }, class_name: 'Key' - has_many :expiring_soon_and_unnotified_keys, -> { expiring_soon_and_not_notified }, class_name: 'Key' - has_many :deploy_keys, -> { where(type: 'DeployKey') }, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent - has_many :group_deploy_keys - has_many :gpg_keys - - has_many :emails - has_many :personal_access_tokens, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :identities, dependent: :destroy, autosave: true # rubocop:disable Cop/ActiveRecordDependent - has_many :webauthn_registrations - has_many :chat_names, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :saved_replies, class_name: '::Users::SavedReply' - has_one :user_synced_attributes_metadata, autosave: true - has_one :aws_role, class_name: 'Aws::Role' - - # Followers - has_many :followed_users, foreign_key: :follower_id, class_name: 'Users::UserFollowUser' - has_many :followees, through: :followed_users - - has_many :following_users, foreign_key: :followee_id, class_name: 'Users::UserFollowUser' - has_many :followers, through: :following_users - - # Groups - has_many :members - has_many :group_members, -> { where(requested_at: nil).where("access_level >= ?", Gitlab::Access::GUEST) }, class_name: 'GroupMember' - has_many :groups, through: :group_members - has_many :groups_with_active_memberships, -> { where(members: { state: ::Member::STATE_ACTIVE }) }, through: :group_members, source: :group - has_many :owned_groups, -> { where(members: { access_level: Gitlab::Access::OWNER }) }, through: :group_members, source: :group - has_many :maintainers_groups, -> { where(members: { access_level: Gitlab::Access::MAINTAINER }) }, through: :group_members, source: :group - has_many :developer_groups, -> { where(members: { access_level: ::Gitlab::Access::DEVELOPER }) }, through: :group_members, source: :group - has_many :owned_or_maintainers_groups, - -> { where(members: { access_level: [Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) }, - through: :group_members, - source: :group - alias_attribute :masters_groups, :maintainers_groups - has_many :developer_maintainer_owned_groups, - -> { where(members: { access_level: [Gitlab::Access::DEVELOPER, Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) }, - through: :group_members, - source: :group - has_many :reporter_developer_maintainer_owned_groups, - -> { where(members: { access_level: [Gitlab::Access::REPORTER, Gitlab::Access::DEVELOPER, Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) }, - through: :group_members, - source: :group - has_many :minimal_access_group_members, -> { where(access_level: [Gitlab::Access::MINIMAL_ACCESS]) }, class_name: 'GroupMember' - has_many :minimal_access_groups, through: :minimal_access_group_members, source: :group - - # Projects - has_many :groups_projects, through: :groups, source: :projects - has_many :personal_projects, through: :namespace, source: :projects - has_many :project_members, -> { where(requested_at: nil) } - has_many :projects, through: :project_members - has_many :created_projects, foreign_key: :creator_id, class_name: 'Project', dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent - has_many :projects_with_active_memberships, -> { where(members: { state: ::Member::STATE_ACTIVE }) }, through: :project_members, source: :project - has_many :users_star_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :starred_projects, through: :users_star_projects, source: :project - has_many :project_authorizations, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent - has_many :authorized_projects, through: :project_authorizations, source: :project - - has_many :user_interacted_projects - has_many :project_interactions, through: :user_interacted_projects, source: :project, class_name: 'Project' - - has_many :snippets, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent - has_many :notes, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent - has_many :issues, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent - has_many :legacy_assigned_merge_requests, class_name: 'MergeRequest', dependent: :nullify, foreign_key: :assignee_id # rubocop:disable Cop/ActiveRecordDependent - has_many :merged_merge_requests, class_name: 'MergeRequest::Metrics', dependent: :nullify, foreign_key: :merged_by_id # rubocop:disable Cop/ActiveRecordDependent - has_many :closed_merge_requests, class_name: 'MergeRequest::Metrics', dependent: :nullify, foreign_key: :latest_closed_by_id # rubocop:disable Cop/ActiveRecordDependent - has_many :updated_merge_requests, class_name: 'MergeRequest', dependent: :nullify, foreign_key: :updated_by_id # rubocop:disable Cop/ActiveRecordDependent - has_many :updated_issues, class_name: 'Issue', dependent: :nullify, foreign_key: :updated_by_id # rubocop:disable Cop/ActiveRecordDependent - has_many :closed_issues, class_name: 'Issue', dependent: :nullify, foreign_key: :closed_by_id # rubocop:disable Cop/ActiveRecordDependent - has_many :merge_requests, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent - has_many :events, dependent: :delete_all, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent - has_many :releases, dependent: :nullify, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent - has_many :subscriptions, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :abuse_reports, dependent: :nullify, foreign_key: :user_id, inverse_of: :user # rubocop:disable Cop/ActiveRecordDependent - has_many :reported_abuse_reports, dependent: :nullify, foreign_key: :reporter_id, class_name: "AbuseReport", inverse_of: :reporter # rubocop:disable Cop/ActiveRecordDependent - has_many :assigned_abuse_reports, foreign_key: :assignee_id, class_name: "AbuseReport", inverse_of: :assignee - has_many :resolved_abuse_reports, foreign_key: :resolved_by_id, class_name: "AbuseReport", inverse_of: :resolved_by - has_many :abuse_events, foreign_key: :user_id, class_name: 'Abuse::Event', inverse_of: :user - has_many :spam_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :abuse_trust_scores, class_name: 'Abuse::TrustScore', foreign_key: :user_id - has_many :builds, class_name: 'Ci::Build' - has_many :pipelines, class_name: 'Ci::Pipeline' - has_many :todos, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :authored_todos, class_name: 'Todo', dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent - has_many :notification_settings - has_many :award_emoji, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :triggers, class_name: 'Ci::Trigger', foreign_key: :owner_id - has_many :audit_events, foreign_key: :author_id, inverse_of: :user - - has_many :alert_assignees, class_name: '::AlertManagement::AlertAssignee', inverse_of: :assignee - has_many :issue_assignees, inverse_of: :assignee - has_many :merge_request_assignees, inverse_of: :assignee, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :merge_request_reviewers, inverse_of: :reviewer, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue - has_many :assigned_merge_requests, class_name: "MergeRequest", through: :merge_request_assignees, source: :merge_request - has_many :created_custom_emoji, class_name: 'CustomEmoji', inverse_of: :creator - - has_many :bulk_imports - - has_many :custom_attributes, class_name: 'UserCustomAttribute' - has_many :callouts, class_name: 'Users::Callout' - has_many :group_callouts, class_name: 'Users::GroupCallout' - has_many :project_callouts, class_name: 'Users::ProjectCallout' - has_many :term_agreements - belongs_to :accepted_term, class_name: 'ApplicationSetting::Term' - - has_many :metrics_users_starred_dashboards, class_name: 'Metrics::UsersStarredDashboard', inverse_of: :user - - has_one :status, class_name: 'UserStatus' - has_one :user_preference - has_one :user_detail - has_one :user_highest_role - has_one :user_canonical_email - has_one :credit_card_validation, class_name: '::Users::CreditCardValidation' - has_one :phone_number_validation, class_name: '::Users::PhoneNumberValidation' - has_one :atlassian_identity, class_name: 'Atlassian::Identity' - has_one :banned_user, class_name: '::Users::BannedUser' - - has_many :reviews, foreign_key: :author_id, inverse_of: :author - - has_many :in_product_marketing_emails, class_name: '::Users::InProductMarketingEmail' - - has_many :timelogs - - has_many :resource_label_events, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent - has_many :resource_state_events, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent - has_many :issue_assignment_events, class_name: 'ResourceEvents::IssueAssignmentEvent', dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent - has_many :merge_request_assignment_events, class_name: 'ResourceEvents::MergeRequestAssignmentEvent', dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent - has_many :authored_events, class_name: 'Event', dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent - has_many :namespace_commit_emails, class_name: 'Users::NamespaceCommitEmail' - has_many :user_achievements, class_name: 'Achievements::UserAchievement', inverse_of: :user - has_many :awarded_user_achievements, class_name: 'Achievements::UserAchievement', foreign_key: 'awarded_by_user_id', inverse_of: :awarded_by_user - has_many :revoked_user_achievements, class_name: 'Achievements::UserAchievement', foreign_key: 'revoked_by_user_id', inverse_of: :revoked_by_user - has_many :achievements, through: :user_achievements, class_name: 'Achievements::Achievement', inverse_of: :users - - # - # Validations - # - # Note: devise :validatable above adds validations for :email and :password - validates :name, presence: true, length: { maximum: 255 } - validates :first_name, length: { maximum: 127 } - validates :last_name, length: { maximum: 127 } - validates :email, confirmation: true - validates :notification_email, devise_email: true, allow_blank: true - validates :public_email, uniqueness: true, devise_email: true, allow_blank: true - validates :commit_email, devise_email: true, allow_blank: true, unless: ->(user) { user.commit_email == Gitlab::PrivateCommitEmail::TOKEN } - validates :projects_limit, - presence: true, - numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE } - validates :username, presence: true - validate :check_password_weakness, if: :encrypted_password_changed? - - validates :namespace, presence: true - validate :namespace_move_dir_allowed, if: :username_changed?, unless: :new_record? - - validate :unique_email, if: :email_changed? - validate :notification_email_verified, if: :notification_email_changed? - validate :public_email_verified, if: :public_email_changed? - validate :commit_email_verified, if: :commit_email_changed? - validate :email_allowed_by_restrictions?, if: ->(user) { user.new_record? ? !user.created_by_id : user.email_changed? } - validate :check_username_format, if: :username_changed? - - validates :theme_id, allow_nil: true, inclusion: { in: Gitlab::Themes.valid_ids, - message: ->(*) { _("%{placeholder} is not a valid theme") % { placeholder: '%{value}' } } } - - validates :color_scheme_id, allow_nil: true, inclusion: { in: Gitlab::ColorSchemes.valid_ids, - message: ->(*) { _("%{placeholder} is not a valid color scheme") % { placeholder: '%{value}' } } } - - after_initialize :set_projects_limit - before_validation :sanitize_attrs - before_validation :ensure_namespace_correct - after_validation :set_username_errors - before_save :ensure_incoming_email_token - before_save :ensure_user_rights_and_limits, if: ->(user) { user.new_record? || user.external_changed? } - before_save :skip_reconfirmation!, if: ->(user) { user.email_changed? && user.read_only_attribute?(:email) } - before_save :check_for_verified_email, if: ->(user) { user.email_changed? && !user.new_record? } - before_save :ensure_namespace_correct # in case validation is skipped - after_update :username_changed_hook, if: :saved_change_to_username? - after_destroy :post_destroy_hook - after_destroy :remove_key_cache - after_save if: -> { (saved_change_to_email? || saved_change_to_confirmed_at?) && confirmed? } do - email_to_confirm = self.emails.find_by(email: self.email) - - if email_to_confirm.present? - if skip_confirmation_period_expiry_check - email_to_confirm.force_confirm - else - email_to_confirm.confirm - end - else - add_primary_email_to_emails! - end - end - after_commit(on: :update) do - update_invalid_gpg_signatures if previous_changes.key?('email') - end - - # User's Layout preference - enum layout: { fixed: 0, fluid: 1 } - - # User's Dashboard preference - enum dashboard: { projects: 0, stars: 1, your_activity: 10, project_activity: 2, starred_project_activity: 3, groups: 4, todos: 5, issues: 6, merge_requests: 7, operations: 8, followed_user_activity: 9 } - - # User's Project preference - enum project_view: { readme: 0, activity: 1, files: 2, wiki: 3 } - - # User's role - enum role: { software_developer: 0, development_team_lead: 1, devops_engineer: 2, systems_administrator: 3, security_analyst: 4, data_analyst: 5, product_manager: 6, product_designer: 7, other: 8 }, _suffix: true - - delegate :notes_filter_for, - :set_notes_filter, - :first_day_of_week, :first_day_of_week=, - :timezone, :timezone=, - :time_display_relative, :time_display_relative=, - :show_whitespace_in_diffs, :show_whitespace_in_diffs=, - :view_diffs_file_by_file, :view_diffs_file_by_file=, - :pass_user_identities_to_ci_jwt, :pass_user_identities_to_ci_jwt=, - :tab_width, :tab_width=, - :sourcegraph_enabled, :sourcegraph_enabled=, - :gitpod_enabled, :gitpod_enabled=, - :setup_for_company, :setup_for_company=, - :project_shortcut_buttons, :project_shortcut_buttons=, - :render_whitespace_in_code, :render_whitespace_in_code=, - :markdown_surround_selection, :markdown_surround_selection=, - :markdown_automatic_lists, :markdown_automatic_lists=, - :diffs_deletion_color, :diffs_deletion_color=, - :diffs_addition_color, :diffs_addition_color=, - :use_new_navigation, :use_new_navigation=, - :pinned_nav_items, :pinned_nav_items=, - :achievements_enabled, :achievements_enabled=, - :enabled_following, :enabled_following=, - to: :user_preference - - delegate :path, to: :namespace, allow_nil: true, prefix: true - delegate :job_title, :job_title=, to: :user_detail, allow_nil: true - delegate :bio, :bio=, to: :user_detail, allow_nil: true - delegate :webauthn_xid, :webauthn_xid=, to: :user_detail, allow_nil: true - delegate :pronouns, :pronouns=, to: :user_detail, allow_nil: true - delegate :pronunciation, :pronunciation=, to: :user_detail, allow_nil: true - delegate :registration_objective, :registration_objective=, to: :user_detail, allow_nil: true - delegate :linkedin, :linkedin=, to: :user_detail, allow_nil: true - delegate :twitter, :twitter=, to: :user_detail, allow_nil: true - delegate :skype, :skype=, to: :user_detail, allow_nil: true - delegate :website_url, :website_url=, to: :user_detail, allow_nil: true - delegate :location, :location=, to: :user_detail, allow_nil: true - delegate :organization, :organization=, to: :user_detail, allow_nil: true - delegate :discord, :discord=, to: :user_detail, allow_nil: true - - accepts_nested_attributes_for :user_preference, update_only: true - accepts_nested_attributes_for :user_detail, update_only: true - accepts_nested_attributes_for :credit_card_validation, update_only: true, allow_destroy: true - - state_machine :state, initial: :active do - # state_machine uses this method at class loading time to fetch the default - # value for the `state` column but in doing so it also evaluates all other - # columns default values which could trigger the recursive generation of - # ApplicationSetting records. We're setting it to `nil` here because we - # don't have a database default for the `state` column. - # - def owner_class_attribute_default; end - - event :block do - transition active: :blocked - transition deactivated: :blocked - transition ldap_blocked: :blocked - transition blocked_pending_approval: :blocked - end - - event :ldap_block do - transition active: :ldap_blocked - transition deactivated: :ldap_blocked - end - - # aliasing system_block to set ldap_blocked statuses - # ldap_blocked is used for LDAP, SAML, and SCIM blocked users - # Issue for improving this naming: - # https://gitlab.com/gitlab-org/gitlab/-/issues/388487 - event :system_block do - transition active: :ldap_blocked - transition deactivated: :ldap_blocked - end - - event :activate do - transition deactivated: :active - transition blocked: :active - transition ldap_blocked: :active - transition blocked_pending_approval: :active - transition banned: :active - end - - event :block_pending_approval do - transition active: :blocked_pending_approval - end - - event :ban do - transition active: :banned - end - - event :unban do - transition banned: :active - end - - event :deactivate do - # Any additional changes to this event should be also - # reflected in app/workers/users/deactivate_dormant_users_worker.rb - transition active: :deactivated - end - - state :blocked, :ldap_blocked, :blocked_pending_approval, :banned do - def blocked? - true - end - end - - before_transition do - !Gitlab::Database.read_only? - end - - # rubocop: disable CodeReuse/ServiceClass - after_transition any => :blocked do |user| - user.run_after_commit do - Ci::DropPipelineService.new.execute_async_for_all(user.pipelines, :user_blocked, user) - Ci::DisableUserPipelineSchedulesService.new.execute(user) - end - end - - after_transition any => :deactivated do |user| - next unless Gitlab::CurrentSettings.user_deactivation_emails_enabled - - user.run_after_commit do - NotificationService.new.user_deactivated(user.name, user.notification_email_or_default) - end - end - # rubocop: enable CodeReuse/ServiceClass - - after_transition active: :banned do |user| - user.create_banned_user - end - - after_transition banned: :active do |user| - user.banned_user&.destroy - end - - after_transition any => :active do |user| - user.starred_projects.update_counters(star_count: 1) - end - - after_transition active: any do |user| - user.starred_projects.update_counters(star_count: -1) - end - end - - # Scopes - scope :admins, -> { where(admin: true) } - scope :instance_access_request_approvers_to_be_notified, -> { admins.active.order_recent_sign_in.limit(INSTANCE_ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) } - scope :blocked, -> { with_states(:blocked, :ldap_blocked) } - scope :blocked_pending_approval, -> { with_states(:blocked_pending_approval) } - scope :banned, -> { with_states(:banned) } - scope :external, -> { where(external: true) } - scope :non_external, -> { where(external: false) } - scope :confirmed, -> { where.not(confirmed_at: nil) } - scope :active, -> { with_state(:active).non_internal } - scope :active_without_ghosts, -> { with_state(:active).without_ghosts } - scope :deactivated, -> { with_state(:deactivated).non_internal } - scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) } - scope :by_username, -> (usernames) { iwhere(username: Array(usernames).map(&:to_s)) } - scope :by_name, -> (names) { iwhere(name: Array(names)) } - scope :by_login, -> (login) do - return none if login.blank? - - login.include?('@') ? iwhere(email: login) : iwhere(username: login) - end - scope :by_user_email, -> (emails) { iwhere(email: Array(emails)) } - scope :by_emails, -> (emails) { joins(:emails).where(emails: { email: Array(emails).map(&:downcase) }) } - scope :for_todos, -> (todos) { where(id: todos.select(:user_id).distinct) } - scope :with_emails, -> { preload(:emails) } - scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) } - scope :with_public_profile, -> { where(private_profile: false) } - scope :with_expiring_and_not_notified_personal_access_tokens, ->(at) do - where('EXISTS (?)', ::PersonalAccessToken - .where('personal_access_tokens.user_id = users.id') - .without_impersonation - .expiring_and_not_notified(at).select(1) - ) - end - scope :with_personal_access_tokens_expired_today, -> do - where('EXISTS (?)', ::PersonalAccessToken - .select(1) - .where('personal_access_tokens.user_id = users.id') - .without_impersonation - .expired_today_and_not_notified - ) - end - - scope :with_ssh_key_expiring_soon, -> do - includes(:expiring_soon_and_unnotified_keys) - .where('EXISTS (?)', ::Key - .select(1) - .where('keys.user_id = users.id') - .expiring_soon_and_not_notified) - end - scope :order_recent_sign_in, -> { reorder(arel_table[:current_sign_in_at].desc.nulls_last) } - scope :order_oldest_sign_in, -> { reorder(arel_table[:current_sign_in_at].asc.nulls_last) } - scope :order_recent_last_activity, -> { reorder(arel_table[:last_activity_on].desc.nulls_last, arel_table[:id].asc) } - scope :order_oldest_last_activity, -> { reorder(arel_table[:last_activity_on].asc.nulls_first, arel_table[:id].desc) } - scope :dormant, -> { with_state(:active).human_or_service_user.where('last_activity_on <= ?', Gitlab::CurrentSettings.deactivate_dormant_users_period.day.ago.to_date) } - scope :with_no_activity, -> { with_state(:active).human_or_service_user.where(last_activity_on: nil).where('created_at <= ?', MINIMUM_DAYS_CREATED.day.ago.to_date) } - scope :by_provider_and_extern_uid, ->(provider, extern_uid) { joins(:identities).merge(Identity.with_extern_uid(provider, extern_uid)) } - scope :by_ids_or_usernames, -> (ids, usernames) { where(username: usernames).or(where(id: ids)) } - scope :without_forbidden_states, -> { where.not(state: FORBIDDEN_SEARCH_STATES) } - - strip_attributes! :name - - def preferred_language - read_attribute('preferred_language').presence || Gitlab::CurrentSettings.default_preferred_language - end - - def active_for_authentication? - return false unless super - - check_ldap_if_ldap_blocked! - - can?(:log_in) - end - - # The messages for these keys are defined in `devise.en.yml` - def inactive_message - if blocked_pending_approval? - :blocked_pending_approval - elsif blocked? - :blocked - elsif internal? - :forbidden - else - super - end - end - - def self.with_visible_profile(user) - return with_public_profile if user.nil? - - if user.admin? - all - else - with_public_profile.or(where(id: user.id)) - end - end - - # Limits the users to those that have TODOs, optionally in the given state. - # - # user - The user to get the todos for. - # - # with_todos - If we should limit the result set to users that are the - # authors of todos. - # - # todo_state - An optional state to require the todos to be in. - def self.limit_to_todo_authors(user: nil, with_todos: false, todo_state: nil) - if user && with_todos - where(id: Todo.where(user: user, state: todo_state).select(:author_id)) - else - all - end - end - - # Returns a relation that optionally includes the given user. - # - # user_id - The ID of the user to include. - def self.union_with_user(user_id = nil) - if user_id.present? - # We use "unscoped" here so that any inner conditions are not repeated for - # the outer query, which would be redundant. - User.unscoped.from_union([all, User.unscoped.where(id: user_id)]) - else - all - end - end - - def self.with_two_factor - where(otp_required_for_login: true) - .or(where_exists(WebauthnRegistration.where(WebauthnRegistration.arel_table[:user_id].eq(arel_table[:id])))) - end - - def self.without_two_factor - where - .missing(:webauthn_registrations) - .where(otp_required_for_login: false) - end - - # - # Class methods - # - class << self - # Devise method overridden to allow support for dynamic password lengths - def password_length - Gitlab::CurrentSettings.minimum_password_length..Devise.password_length.max - end - - # Generate a random password that conforms to the current password length settings - def random_password - Devise.friendly_token(password_length.max) - end - - # Devise method overridden to allow sign in with email or username - def find_for_database_authentication(warden_conditions) - conditions = warden_conditions.dup - if login = conditions.delete(:login) - where(conditions).find_by("lower(username) = :value OR lower(email) = :value", value: login.downcase.strip) - else - find_by(conditions) - end - end - - def sort_by_attribute(method) - order_method = method || 'id_desc' - - case order_method.to_s - when 'recent_sign_in' then order_recent_sign_in - when 'oldest_sign_in' then order_oldest_sign_in - when 'last_activity_on_desc' then order_recent_last_activity - when 'last_activity_on_asc' then order_oldest_last_activity - else - order_by(order_method) - end - end - - # Find a User by their primary email or any associated confirmed secondary email - def find_by_any_email(email, confirmed: false) - return unless email - - by_any_email(email, confirmed: confirmed).take - end - - # Returns a relation containing all found users by their primary email - # or any associated confirmed secondary email - # - # @param emails [String, Array] email addresses to check - # @param confirmed [Boolean] Only return users where the primary email is confirmed - def by_any_email(emails, confirmed: false) - from_users = by_user_email(emails) - from_users = from_users.confirmed if confirmed - - from_emails = by_emails(emails).merge(Email.confirmed) - from_emails = from_emails.confirmed if confirmed - - items = [from_users, from_emails] - - user_ids = Gitlab::PrivateCommitEmail.user_ids_for_emails(Array(emails).map(&:downcase)) - items << where(id: user_ids) if user_ids.present? - - from_union(items) - end - - def find_by_private_commit_email(email) - user_id = Gitlab::PrivateCommitEmail.user_id_for_email(email) - - find_by(id: user_id) - end - - def filter_items(filter_name) - case filter_name - when 'admins' - admins - when 'blocked' - blocked - when 'blocked_pending_approval' - blocked_pending_approval - when 'banned' - banned - when 'two_factor_disabled' - without_two_factor - when 'two_factor_enabled' - with_two_factor - when 'wop' - without_projects - when 'external' - external - when 'deactivated' - deactivated - else - active_without_ghosts - end - end - - # Searches users matching the given query. - # - # This method uses ILIKE on PostgreSQL. - # - # query - The search query as a String - # with_private_emails - include private emails in search - # - # Returns an ActiveRecord::Relation. - def search(query, **options) - return none unless query.is_a?(String) - - query = query&.delete_prefix('@') - return none if query.blank? - - query = query.downcase - - order = <<~SQL - CASE - WHEN LOWER(users.name) = :query THEN 0 - WHEN LOWER(users.username) = :query THEN 1 - WHEN LOWER(users.public_email) = :query THEN 2 - ELSE 3 - END - SQL - - sanitized_order_sql = Arel.sql(sanitize_sql_array([order, query: query])) - - scope = options[:with_private_emails] ? with_primary_or_secondary_email(query) : with_public_email(query) - scope = scope.or(search_by_name_or_username(query, use_minimum_char_limit: options[:use_minimum_char_limit])) - - order = Gitlab::Pagination::Keyset::Order.build( - [ - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'users_match_priority', - order_expression: sanitized_order_sql.asc, - add_to_projections: true, - distinct: false - ), - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'users_name', - order_expression: arel_table[:name].asc, - add_to_projections: true, - nullable: :not_nullable, - distinct: false - ), - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'users_id', - order_expression: arel_table[:id].asc, - add_to_projections: true, - nullable: :not_nullable, - distinct: true - ) - ]) - scope.reorder(order) - end - - # Limits the result set to users _not_ in the given query/list of IDs. - # - # users - The list of users to ignore. This can be an - # `ActiveRecord::Relation`, or an Array. - def where_not_in(users = nil) - users ? where.not(id: users) : all - end - - def reorder_by_name - reorder(:name) - end - - # searches user by given pattern - # it compares name and username fields with given pattern - # This method uses ILIKE on PostgreSQL. - def search_by_name_or_username(query, use_minimum_char_limit: nil) - use_minimum_char_limit = user_search_minimum_char_limit if use_minimum_char_limit.nil? - - where( - fuzzy_arel_match(:name, query, use_minimum_char_limit: use_minimum_char_limit) - .or(fuzzy_arel_match(:username, query, use_minimum_char_limit: use_minimum_char_limit)) - ) - end - - def with_public_email(email_address) - where(public_email: email_address) - end - - def with_primary_or_secondary_email(email_address) - email_table = Email.arel_table - matched_by_email_user_id = email_table - .project(email_table[:user_id]) - .where(email_table[:email].eq(email_address)) - .where(email_table[:confirmed_at].not_eq(nil)) - .take(1) # at most 1 record as there is a unique constraint - - where( - arel_table[:email].eq(email_address) - .or(arel_table[:id].eq(matched_by_email_user_id)) - ) - end - - # This method is overridden in JiHu. - # https://gitlab.com/gitlab-org/gitlab/-/issues/348509 - def user_search_minimum_char_limit - true - end - - def find_by_login(login) - by_login(login).take - end - - def find_by_username(username) - by_username(username).take - end - - def find_by_username!(username) - by_username(username).take! - end - - # Returns a user for the given SSH key. - def find_by_ssh_key_id(key_id) - find_by('EXISTS (?)', Key.select(1).where('keys.user_id = users.id').auth.where(id: key_id)) - end - - def find_by_full_path(path, follow_redirects: false) - namespace = Namespace.user_namespaces.find_by_full_path(path, follow_redirects: follow_redirects) - namespace&.owner - end - - def reference_prefix - '@' - end - - # Pattern used to extract `@user` user references from text - def reference_pattern - @reference_pattern ||= - %r{ - (?#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX}) - }x - end - - # Return (create if necessary) the ghost user. The ghost user - # owns records previously belonging to deleted users. - def ghost - email = 'ghost%s@example.com' - unique_internal(where(user_type: :ghost), 'ghost', email) do |u| - u.bio = _('This is a "Ghost User", created to hold all issues authored by users that have since been deleted. This user cannot be removed.') - u.name = 'Ghost User' - end - end - - def alert_bot - email_pattern = "alert%s@#{Settings.gitlab.host}" - - unique_internal(where(user_type: :alert_bot), 'alert-bot', email_pattern) do |u| - u.bio = 'The GitLab alert bot' - u.name = 'GitLab Alert Bot' - u.avatar = bot_avatar(image: 'alert-bot.png') - end - end - - def migration_bot - email_pattern = "noreply+gitlab-migration-bot%s@#{Settings.gitlab.host}" - - unique_internal(where(user_type: :migration_bot), 'migration-bot', email_pattern) do |u| - u.bio = 'The GitLab migration bot' - u.name = 'GitLab Migration Bot' - u.confirmed_at = Time.zone.now - end - end - - def security_bot - email_pattern = "security-bot%s@#{Settings.gitlab.host}" - - unique_internal(where(user_type: :security_bot), 'GitLab-Security-Bot', email_pattern) do |u| - u.bio = 'System bot that monitors detected vulnerabilities for solutions and creates merge requests with the fixes.' - u.name = 'GitLab Security Bot' - u.website_url = Gitlab::Routing.url_helpers.help_page_url('user/application_security/security_bot/index.md') - u.avatar = bot_avatar(image: 'security-bot.png') - u.confirmed_at = Time.zone.now - end - end - - def support_bot - email_pattern = "support%s@#{Settings.gitlab.host}" - - unique_internal(where(user_type: :support_bot), 'support-bot', email_pattern) do |u| - u.bio = 'The GitLab support bot used for Service Desk' - u.name = 'GitLab Support Bot' - u.avatar = bot_avatar(image: 'support-bot.png') - u.confirmed_at = Time.zone.now - end - end - - def automation_bot - email_pattern = "automation%s@#{Settings.gitlab.host}" - - unique_internal(where(user_type: :automation_bot), 'automation-bot', email_pattern) do |u| - u.bio = 'The GitLab automation bot used for automated workflows and tasks' - u.name = 'GitLab Automation Bot' - u.avatar = bot_avatar(image: 'support-bot.png') # todo: add an avatar for automation-bot - end - end - - def llm_bot - email_pattern = "llm-bot%s@#{Settings.gitlab.host}" - - unique_internal(where(user_type: :llm_bot), 'GitLab-Llm-Bot', email_pattern) do |u| - u.bio = 'The Gitlab LLM bot used for fetching LLM-generated content' - u.name = 'GitLab LLM Bot' - u.avatar = bot_avatar(image: 'support-bot.png') # todo: add an avatar for llm-bot - u.confirmed_at = Time.zone.now - end - end - - def admin_bot - email_pattern = "admin-bot%s@#{Settings.gitlab.host}" - - unique_internal(where(user_type: :admin_bot), 'GitLab-Admin-Bot', email_pattern) do |u| - u.bio = 'Admin bot used for tasks that require admin privileges' - u.name = 'GitLab Admin Bot' - u.avatar = bot_avatar(image: 'admin-bot.png') - u.admin = true - u.confirmed_at = Time.zone.now - end - end - - # Return true if there is only single non-internal user in the deployment, - # ghost user is ignored. - def single_user? - User.non_internal.limit(2).count == 1 - end - - def single_user - User.non_internal.first if single_user? - end - - def get_ids_by_ids_or_usernames(ids, usernames) - by_ids_or_usernames(ids, usernames).pluck(:id) - end - end - - # - # Instance methods - # - - def full_path - username - end - - def to_param - username - end - - def to_reference(_from = nil, target_project: nil, full: nil) - "#{self.class.reference_prefix}#{username}" - end - - def skip_confirmation=(bool) - skip_confirmation! if bool - end - - def skip_reconfirmation=(bool) - skip_reconfirmation! if bool - end - - def generate_reset_token - @reset_token, enc = Devise.token_generator.generate(self.class, :reset_password_token) - - self.reset_password_token = enc - self.reset_password_sent_at = Time.current.utc - - @reset_token - end - - def recently_sent_password_reset? - reset_password_sent_at.present? && reset_password_sent_at >= 1.minute.ago - end - - # Overwrites valid_password? from Devise::Models::DatabaseAuthenticatable - # In constant-time, check both that the password isn't on a denylist AND - # that the password is the user's password - def valid_password?(password) - return false unless password_allowed?(password) - return false if password_automatically_set? - - super - end - - def generate_otp_backup_codes! - if Gitlab::FIPS.enabled? - generate_otp_backup_codes_pbkdf2! - else - super - end - end - - def invalidate_otp_backup_code!(code) - if Gitlab::FIPS.enabled? && pbkdf2? - invalidate_otp_backup_code_pdkdf2!(code) - else - super(code) - end - end - - # See https://gitlab.com/gitlab-org/security/gitlab/-/issues/638 - DISALLOWED_PASSWORDS = %w[123qweQWE!@#000000000].freeze - - def password_allowed?(password) - password_allowed = true - - DISALLOWED_PASSWORDS.each do |disallowed_password| - password_allowed = false if Devise.secure_compare(password, disallowed_password) - end - - password_allowed - end - - # Override Devise Rememberable#remember_me! - # - # In Devise this method sets `remember_created_at` and writes the session token - # to the session cookie. When remember me is disabled this method ensures these - # values aren't set. - def remember_me! - super if ::Gitlab::Database.read_write? && ::Gitlab::CurrentSettings.remember_me_enabled? - end - - def forget_me! - super if ::Gitlab::Database.read_write? - end - - # Override Devise Rememberable#remember_me? - # - # In Devise this method compares the remember me token received from the user session - # and compares to the stored value. When remember me is disabled this method ensures - # the upstream comparison does not happen. - def remember_me?(token, generated_at) - return false unless ::Gitlab::CurrentSettings.remember_me_enabled? - - super - end - - def disable_two_factor! - transaction do - self.disable_webauthn! - self.disable_two_factor_otp! - self.reset_backup_codes! - end - end - - def disable_two_factor_otp! - update( - otp_required_for_login: false, - encrypted_otp_secret: nil, - encrypted_otp_secret_iv: nil, - encrypted_otp_secret_salt: nil, - otp_grace_period_started_at: nil, - otp_secret_expires_at: nil - ) - end - - def disable_webauthn! - self.webauthn_registrations.destroy_all # rubocop:disable Cop/DestroyAll - end - - def reset_backup_codes! - update(otp_backup_codes: nil) - end - - def two_factor_enabled? - two_factor_otp_enabled? || two_factor_webauthn_enabled? - end - - def two_factor_otp_enabled? - otp_required_for_login? || - forti_authenticator_enabled?(self) || - forti_token_cloud_enabled?(self) || - duo_auth_enabled?(self) - end - - def two_factor_webauthn_enabled? - (webauthn_registrations.loaded? && webauthn_registrations.any?) || (!webauthn_registrations.loaded? && webauthn_registrations.exists?) - end - - def needs_new_otp_secret? - !two_factor_enabled? && otp_secret_expired? - end - - def otp_secret_expired? - return true unless otp_secret_expires_at - - otp_secret_expires_at < Time.current - end - - def update_otp_secret! - self.otp_secret = User.generate_otp_secret(OTP_SECRET_LENGTH) - self.otp_secret_expires_at = Time.current + OTP_SECRET_TTL - end - - def namespace_move_dir_allowed - if namespace&.any_project_has_container_registry_tags? - errors.add(:username, _('cannot be changed if a personal project has container registry tags.')) - end - end - - # will_save_change_to_attribute? is used by Devise to check if it is necessary - # to clear any existing reset_password_tokens before updating an authentication_key - # and login in our case is a virtual attribute to allow login by username or email. - def will_save_change_to_login? - will_save_change_to_username? || will_save_change_to_email? - end - - def unique_email - return if errors.added?(:email, _('has already been taken')) - - if !emails.exists?(email: email) && Email.exists?(email: email) - errors.add(:email, _('has already been taken')) - end - end - - def commit_email_or_default - if self.commit_email == Gitlab::PrivateCommitEmail::TOKEN - return private_commit_email - end - - # The commit email is the same as the primary email if undefined - self.commit_email.presence || self.email - end - - def notification_email_or_default - # The notification email is the same as the primary email if undefined - self.notification_email.presence || self.email - end - - def private_commit_email - Gitlab::PrivateCommitEmail.for_user(self) - end - - # see if the new email is already a verified secondary email - def check_for_verified_email - skip_reconfirmation! if emails.confirmed.where(email: self.email).any? - end - - def update_invalid_gpg_signatures - gpg_keys.each(&:update_invalid_gpg_signatures) - end - - # Returns the groups a user has access to, either through a membership or a project authorization - def authorized_groups - Group.unscoped do - authorized_groups_with_shared_membership - end - end - - # Returns the groups a user is a member of, either directly or through a parent group - def membership_groups - groups.self_and_descendants - end - - # Returns a relation of groups the user has access to, including their parent - # and child groups (recursively). - def all_expanded_groups - return groups if groups.empty? - - Gitlab::ObjectHierarchy.new(groups).all_objects - end - - def expanded_groups_requiring_two_factor_authentication - all_expanded_groups.where(require_two_factor_authentication: true) - end - - def source_groups_of_two_factor_authentication_requirement - Gitlab::ObjectHierarchy.new(expanded_groups_requiring_two_factor_authentication) - .all_objects - .where(id: groups) - end - - # rubocop: disable CodeReuse/ServiceClass - def refresh_authorized_projects(source: nil) - Users::RefreshAuthorizedProjectsService.new(self, source: source).execute - end - # rubocop: enable CodeReuse/ServiceClass - - def authorized_projects(min_access_level = nil) - # We're overriding an association, so explicitly call super with no - # arguments or it would be passed as `force_reload` to the association - projects = super() - - if min_access_level - projects = projects - .where('project_authorizations.access_level >= ?', min_access_level) - end - - projects - end - - def authorized_project?(project, min_access_level = nil) - authorized_projects(min_access_level).exists?({ id: project.id }) - end - - # Typically used in conjunction with projects table to get projects - # a user has been given access to. - # The param `related_project_column` is the column to compare to the - # project_authorizations. By default is projects.id - # - # Example use: - # `Project.where('EXISTS(?)', user.authorizations_for_projects)` - def authorizations_for_projects(min_access_level: nil, related_project_column: 'projects.id') - authorizations = project_authorizations - .select(1) - .where("project_authorizations.project_id = #{related_project_column}") - - return authorizations unless min_access_level.present? - - authorizations.where('project_authorizations.access_level >= ?', min_access_level) - end - - # Returns the projects this user has reporter (or greater) access to, limited - # to at most the given projects. - # - # This method is useful when you have a list of projects and want to - # efficiently check to which of these projects the user has at least reporter - # access. - def projects_with_reporter_access_limited_to(projects) - authorized_projects(Gitlab::Access::REPORTER).where(id: projects) - end - - def owned_projects - @owned_projects ||= Project.from_union( - [ - Project.where(namespace: namespace), - Project.joins(:project_authorizations) - .where.not('projects.namespace_id' => namespace.id) - .where(project_authorizations: { user_id: id, access_level: Gitlab::Access::OWNER }) - ], - remove_duplicates: false - ) - end - - # Returns projects which user can admin issues on (for example to move an issue to that project). - # - # This logic is duplicated from `Ability#project_abilities` into a SQL form. - def projects_where_can_admin_issues - authorized_projects(Gitlab::Access::REPORTER).non_archived.with_issues_enabled - end - - def preloaded_member_roles_for_projects(projects) - # overridden in EE - end - - # rubocop: disable CodeReuse/ServiceClass - def require_ssh_key? - count = Users::KeysCountService.new(self).count - - count == 0 && Gitlab::ProtocolAccess.allowed?('ssh') - end - # rubocop: enable CodeReuse/ServiceClass - - def require_password_creation_for_web? - allow_password_authentication_for_web? && password_automatically_set? - end - - def require_password_creation_for_git? - allow_password_authentication_for_git? && password_automatically_set? - end - - def require_personal_access_token_creation_for_git_auth? - return false if allow_password_authentication_for_git? || password_based_omniauth_user? - - PersonalAccessTokensFinder.new(user: self, impersonation: false, state: 'active').execute.none? - end - - def require_extra_setup_for_git_auth? - require_password_creation_for_git? || require_personal_access_token_creation_for_git_auth? - end - - def allow_password_authentication? - allow_password_authentication_for_web? || allow_password_authentication_for_git? - end - - def allow_password_authentication_for_web? - Gitlab::CurrentSettings.password_authentication_enabled_for_web? && !ldap_user? - end - - def allow_password_authentication_for_git? - Gitlab::CurrentSettings.password_authentication_enabled_for_git? && !password_based_omniauth_user? - end - - # method overriden in EE - def password_based_login_forbidden? - false - end - - def can_change_username? - gitlab_config.username_changing_enabled - end - - def can_create_project? - projects_limit_left > 0 - end - - def can_create_group? - can?(:create_group) - end - - def can_select_namespace? - several_namespaces? || admin - end - - def can?(action, subject = :global) - Ability.allowed?(self, action, subject) - end - - def confirm_deletion_with_password? - !password_automatically_set? && allow_password_authentication? - end - - def first_name - read_attribute(:first_name) || begin - name.split(' ').first unless name.blank? - end - end - - def last_name - read_attribute(:last_name) || begin - name.split(' ').drop(1).join(' ') unless name.blank? - end - end - - def projects_limit_left - projects_limit - personal_projects_count - end - - # rubocop: disable CodeReuse/ServiceClass - def recent_push(project = nil) - service = Users::LastPushEventService.new(self) - - if project - service.last_event_for_project(project) - else - service.last_event_for_user - end - end - # rubocop: enable CodeReuse/ServiceClass - - def several_namespaces? - union_sql = ::Gitlab::SQL::Union.new( - [owned_groups, - maintainers_groups, - groups_with_developer_maintainer_project_access]).to_sql - - ::Group.from("(#{union_sql}) #{::Group.table_name}").any? - end - - def namespace_id - namespace.try :id - end - - def name_with_username - "#{name} (#{username})" - end - - def already_forked?(project) - !!fork_of(project) - end - - def fork_of(project) - namespace.find_fork_of(project) - end - - def password_based_omniauth_user? - ldap_user? || crowd_user? - end - - def crowd_user? - if identities.loaded? - identities.find { |identity| identity.provider == 'crowd' && identity.extern_uid.present? } - else - identities.with_any_extern_uid('crowd').exists? - end - end - - def ldap_user? - if identities.loaded? - identities.find { |identity| Gitlab::Auth::OAuth::Provider.ldap_provider?(identity.provider) && !identity.extern_uid.nil? } - else - identities.exists?(["provider LIKE ? AND extern_uid IS NOT NULL", "ldap%"]) - end - end - - def ldap_identity - @ldap_identity ||= identities.find_by(["provider LIKE ?", "ldap%"]) - end - - def matches_identity?(provider, extern_uid) - identities.with_extern_uid(provider, extern_uid).exists? - end - - def project_deploy_keys - @project_deploy_keys ||= DeployKey.in_projects(authorized_projects.select(:id)).distinct(:id) - end - - def highest_role - user_highest_role&.highest_access_level || Gitlab::Access::NO_ACCESS - end - - def credit_card_validated_at - credit_card_validation&.credit_card_validated_at - end - - def accessible_deploy_keys - DeployKey.from_union( - [ - DeployKey.where(id: project_deploy_keys.select(:deploy_key_id)), - DeployKey.are_public - ]) - end - - def created_by - User.find_by(id: created_by_id) if created_by_id - end - - def sanitize_attrs - sanitize_name - end - - def sanitize_name - return unless self.name - - self.name = self.name.gsub(%r{]*>}, '') - end - - def unset_secondary_emails_matching_deleted_email!(deleted_email) - secondary_email_attribute_changed = false - SECONDARY_EMAIL_ATTRIBUTES.each do |attribute| - if read_attribute(attribute) == deleted_email - self.write_attribute(attribute, nil) - secondary_email_attribute_changed = true - end - end - save if secondary_email_attribute_changed - end - - def admin_unsubscribe! - update_column :admin_email_unsubscribed_at, Time.current - end - - def set_projects_limit - # `User.select(:id)` raises - # `ActiveModel::MissingAttributeError: missing attribute: projects_limit` - # without this safeguard! - return unless has_attribute?(:projects_limit) && projects_limit.nil? - - self.projects_limit = Gitlab::CurrentSettings.default_projects_limit - end - - def requires_ldap_check? - if !Gitlab.config.ldap.enabled - false - elsif ldap_user? - !last_credential_check_at || (last_credential_check_at + ldap_sync_time) < Time.current - else - false - end - end - - def ldap_sync_time - # This number resides in this method so it can be redefined in EE. - 1.hour - end - - def try_obtain_ldap_lease - # After obtaining this lease LDAP checks will be blocked for 600 seconds - # (10 minutes) for this user. - lease = Gitlab::ExclusiveLease.new("user_ldap_check:#{id}", timeout: 600) - lease.try_obtain - end - - def solo_owned_groups - # For each owned group, count the owners found in self and ancestors. - counts = GroupMember - .from('unnest(namespaces.traversal_ids) AS ancestors(ancestor_id), members') - .where('members.source_id = ancestors.ancestor_id') - .all_by_access_level(GroupMember::OWNER) - .having('count(members.user_id) = 1') - - Group - .from(owned_groups, :namespaces) - .where_exists(counts) - end - - def with_defaults - User.defaults.each do |k, v| - public_send("#{k}=", v) # rubocop:disable GitlabSecurity/PublicSend - end - - self - end - - def can_leave_project?(project) - project.namespace != namespace && - project.member(self) - end - - def full_website_url - return "http://#{website_url}" if website_url !~ %r{\Ahttps?://} - - website_url - end - - def short_website_url - website_url.sub(%r{\Ahttps?://}, '') - end - - def all_ssh_keys - keys.map(&:publishable_key) - end - - def temp_oauth_email? - email.start_with?('temp-email-for-oauth') - end - - # rubocop: disable CodeReuse/ServiceClass - def avatar_url(size: nil, scale: 2, **args) - GravatarService.new.execute(email, size, scale, username: username) - end - # rubocop: enable CodeReuse/ServiceClass - - def primary_email_verified? - return false unless confirmed? && !temp_oauth_email? - - !email_changed? || new_record? - end - - def accept_pending_invitations! - pending_invitations.select do |member| - member.accept_invite!(self) - end - end - - def pending_invitations - Member.where(invite_email: verified_emails).invite - end - - def all_emails(include_private_email: true) - all_emails = [] - all_emails << email unless temp_oauth_email? - all_emails << private_commit_email if include_private_email - all_emails.concat(emails.filter_map { |email| email.email if email.confirmed? }) - all_emails.uniq - end - - def verified_emails(include_private_email: true) - verified_emails = [] - verified_emails << email if primary_email_verified? - verified_emails << private_commit_email if include_private_email - verified_emails.concat(emails.confirmed.pluck(:email)) - verified_emails.uniq - end - - def public_verified_emails - strong_memoize(:public_verified_emails) do - emails = verified_emails(include_private_email: false) - emails << email unless temp_oauth_email? - emails.uniq - end - end - - def any_email?(check_email) - downcased = check_email.downcase - - # handle the outdated private commit email case - return true if persisted? && - id == Gitlab::PrivateCommitEmail.user_id_for_email(downcased) - - all_emails.include?(check_email.downcase) - end - - def verified_email?(check_email) - downcased = check_email.downcase - - # handle the outdated private commit email case - return true if persisted? && - id == Gitlab::PrivateCommitEmail.user_id_for_email(downcased) - - verified_emails.include?(check_email.downcase) - end - - def hook_attrs - { - id: id, - name: name, - username: username, - avatar_url: avatar_url(only_path: false), - email: webhook_email - } - end - - def ensure_namespace_correct - if namespace - namespace.path = username if username_changed? - namespace.name = name if name_changed? - else - # TODO: we should no longer need the `type` parameter once we can make the - # the `has_one :namespace` association use the correct class. - # issue https://gitlab.com/gitlab-org/gitlab/-/issues/341070 - namespace = build_namespace(path: username, name: name, type: ::Namespaces::UserNamespace.sti_name) - namespace.build_namespace_settings - end - end - - def set_username_errors - namespace_path_errors = self.errors.delete(:"namespace.path") - - return unless namespace_path_errors&.any? - - if namespace_path_errors.include?('has already been taken') && !User.exists?(username: username) - self.errors.add(:base, :username_exists_as_a_different_namespace) - else - namespace_path_errors.each do |msg| - self.errors.add(:username, msg) - end - end - end - - def username_changed_hook - system_hook_service.execute_hooks_for(self, :rename) - end - - def post_destroy_hook - log_info("User \"#{name}\" (#{email}) was removed") - - system_hook_service.execute_hooks_for(self, :destroy) - end - - # rubocop: disable CodeReuse/ServiceClass - def remove_key_cache - Users::KeysCountService.new(self).delete_cache - end - # rubocop: enable CodeReuse/ServiceClass - - DELETION_DELAY_IN_DAYS = 7.days - - def delete_async(deleted_by:, params: {}) - if should_delay_delete?(deleted_by) - new_note = format(_("User deleted own account on %{timestamp}"), timestamp: Time.zone.now) - self.note = "#{new_note}\n#{note}".strip - - block_or_ban - DeleteUserWorker.perform_in(DELETION_DELAY_IN_DAYS, deleted_by.id, id, params.to_h) - - return - end - - block if params[:hard_delete] - - DeleteUserWorker.perform_async(deleted_by.id, id, params.to_h) - end - - # rubocop: disable CodeReuse/ServiceClass - def notification_service - NotificationService.new - end - # rubocop: enable CodeReuse/ServiceClass - - def log_info(message) - Gitlab::AppLogger.info message - end - - # rubocop: disable CodeReuse/ServiceClass - def system_hook_service - SystemHooksService.new - end - # rubocop: enable CodeReuse/ServiceClass - - def starred?(project) - starred_projects.exists?(project.id) - end - - def toggle_star(project) - UsersStarProject.transaction do - user_star_project = users_star_projects - .where(project: project, user: self).lock(true).first - - if user_star_project - user_star_project.destroy - else - UsersStarProject.create!(project: project, user: self) - end - end - end - - def following?(user) - self.followees.exists?(user.id) - end - - def followed_by?(user) - self.followers.include?(user) - end - - def follow(user) - return false unless following_users_allowed?(user) - - begin - followee = Users::UserFollowUser.create(follower_id: self.id, followee_id: user.id) - self.followees.reset if followee.persisted? - followee - rescue ActiveRecord::RecordNotUnique - nil - end - end - - def unfollow(user) - if Users::UserFollowUser.where(follower_id: self.id, followee_id: user.id).delete_all > 0 - self.followees.reset - else - false - end - end - - def following_users_allowed?(user) - return false if self.id == user.id - - following_users_enabled? && user.following_users_enabled? - end - - def following_users_enabled? - return true unless ::Feature.enabled?(:disable_follow_users, self) - - enabled_following - end - - def forkable_namespaces - strong_memoize(:forkable_namespaces) do - personal_namespace = Namespace.where(id: namespace_id) - groups_allowing_project_creation = Groups::AcceptingProjectCreationsFinder.new(self).execute - - Namespace.from_union( - [ - groups_allowing_project_creation, - personal_namespace - ]) - end - end - - def manageable_groups(include_groups_with_developer_maintainer_access: false) - owned_and_maintainer_group_hierarchy = owned_or_maintainers_groups.self_and_descendants - - if include_groups_with_developer_maintainer_access - union_sql = ::Gitlab::SQL::Union.new( - [owned_and_maintainer_group_hierarchy, - groups_with_developer_maintainer_project_access]).to_sql - - ::Group.from("(#{union_sql}) #{::Group.table_name}") - else - owned_and_maintainer_group_hierarchy - end - end - - def namespaces(owned_only: false) - user_groups = owned_only ? owned_groups : groups - personal_namespace = Namespace.where(id: namespace.id) - - Namespace.from_union([user_groups, personal_namespace]) - end - - def oauth_authorized_tokens - OauthAccessToken.where(resource_owner_id: id, revoked_at: nil) - end - - # Returns the projects a user contributed to in the last year. - # - # This method relies on a subquery as this performs significantly better - # compared to a JOIN when coupled with, for example, - # `Project.visible_to_user`. That is, consider the following code: - # - # some_user.contributed_projects.visible_to_user(other_user) - # - # If this method were to use a JOIN the resulting query would take roughly 200 - # ms on a database with a similar size to GitLab.com's database. On the other - # hand, using a subquery means we can get the exact same data in about 40 ms. - def contributed_projects - events = Event.select(:project_id) - .contributions.where(author_id: self) - .where("created_at > ?", Time.current - 1.year) - .distinct - .reorder(nil) - - Project.where(id: events).not_aimed_for_deletion - end - - def can_be_removed? - !solo_owned_groups.present? - end - - def can_remove_self? - true - end - - def authorized_project_mirrors(level) - projects = Ci::ProjectMirror.by_project_id(ci_project_ids_for_project_members(level)) - - namespace_projects = Ci::ProjectMirror.by_namespace_id(ci_namespace_mirrors_for_group_members(level).select(:namespace_id)) - - Ci::ProjectMirror.from_union([projects, namespace_projects]) - end - - def ci_owned_runners - @ci_owned_runners ||= Ci::Runner - .from_union([ci_owned_project_runners_from_project_members, - ci_owned_project_runners_from_group_members, - ci_owned_group_runners]) - end - - def owns_runner?(runner) - ci_owned_runners.include?(runner) - end - - def notification_email_for(notification_group) - # Return group-specific email address if present, otherwise return global notification email address - group_email = if notification_group && notification_group.respond_to?(:notification_email_for) - notification_group.notification_email_for(self) - end - - group_email || notification_email_or_default - end - - def notification_settings_for(source, inherit: false) - if notification_settings.loaded? - notification_settings.find do |notification| - notification.source_type == source.class.base_class.name && - notification.source_id == source.id - end - else - notification_settings.find_or_initialize_by(source: source) do |ns| - next unless source.is_a?(Group) && inherit - - # If we're here it means we're trying to create a NotificationSetting for a group that doesn't have one. - # Find the closest parent with a notification_setting that's not Global level, or that has an email set. - ancestor_ns = source - .notification_settings(hierarchy_order: :asc) - .where(user: self) - .find_by('level != ? OR notification_email IS NOT NULL', NotificationSetting.levels[:global]) - # Use it to seed the settings - ns.assign_attributes(ancestor_ns&.slice(*NotificationSetting.allowed_fields)) - ns.source = source - ns.user = self - end - end - end - - def notification_settings_for_groups(groups) - ids = groups.is_a?(ActiveRecord::Relation) ? groups.select(:id) : groups.map(&:id) - notification_settings.for_groups.where(source_id: ids) - end - - # Lazy load global notification setting - # Initializes User setting with Participating level if setting not persisted - def global_notification_setting - return @global_notification_setting if defined?(@global_notification_setting) - - @global_notification_setting = notification_settings.find_or_initialize_by(source: nil) - @global_notification_setting.update(level: NotificationSetting.levels[DEFAULT_NOTIFICATION_LEVEL]) unless @global_notification_setting.persisted? - - @global_notification_setting - end - - def assigned_open_merge_requests_count(force: false) - Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do - MergeRequestsFinder.new(self, assignee_id: self.id, state: 'opened', non_archived: true).execute.count - end - end - - def review_requested_open_merge_requests_count(force: false) - Rails.cache.fetch(['users', id, 'review_requested_open_merge_requests_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do - MergeRequestsFinder.new(self, reviewer_id: id, state: 'opened', non_archived: true).execute.count - end - end - - def assigned_open_issues_count(force: false) - Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do - IssuesFinder.new(self, assignee_id: self.id, state: 'opened', non_archived: true).execute.count - end - end - - def todos_done_count(force: false) - Rails.cache.fetch(['users', id, 'todos_done_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do - TodosFinder.new(self, state: :done).execute.count - end - end - - def todos_pending_count(force: false) - Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do - TodosFinder.new(self, state: :pending).execute.count - end - end - - def personal_projects_count(force: false) - Rails.cache.fetch(['users', id, 'personal_projects_count'], force: force, expires_in: 24.hours, raw: true) do - personal_projects.count - end.to_i - end - - def update_todos_count_cache - todos_done_count(force: true) - todos_pending_count(force: true) - end - - def invalidate_cache_counts - invalidate_issue_cache_counts - invalidate_merge_request_cache_counts - invalidate_todos_cache_counts - invalidate_personal_projects_count - end - - def invalidate_issue_cache_counts - Rails.cache.delete(['users', id, 'assigned_open_issues_count']) - Rails.cache.delete(['users', id, 'max_assigned_open_issues_count']) - end - - def invalidate_merge_request_cache_counts - Rails.cache.delete(['users', id, 'assigned_open_merge_requests_count']) - Rails.cache.delete(['users', id, 'review_requested_open_merge_requests_count']) - end - - def invalidate_todos_cache_counts - Rails.cache.delete(['users', id, 'todos_done_count']) - Rails.cache.delete(['users', id, 'todos_pending_count']) - end - - def invalidate_personal_projects_count - Rails.cache.delete(['users', id, 'personal_projects_count']) - end - - # This is copied from Devise::Models::Lockable#valid_for_authentication?, as our auth - # flow means we don't call that automatically (and can't conveniently do so). - # - # See: - # - # - # rubocop: disable CodeReuse/ServiceClass - def increment_failed_attempts! - return if ::Gitlab::Database.read_only? - - increment_failed_attempts - - if attempts_exceeded? - lock_access! unless access_locked? - else - Users::UpdateService.new(self, user: self).execute(validate: false) - end - end - # rubocop: enable CodeReuse/ServiceClass - - def access_level - if admin? - :admin - else - :regular - end - end - - def access_level=(new_level) - new_level = new_level.to_s - return unless %w(admin regular).include?(new_level) - - self.admin = (new_level == 'admin') - end - - def can_read_all_resources? - can?(:read_all_resources) - end - - def can_admin_all_resources? - can?(:admin_all_resources) - end - - def update_two_factor_requirement - periods = expanded_groups_requiring_two_factor_authentication.pluck(:two_factor_grace_period) - - self.require_two_factor_authentication_from_group = periods.any? - self.two_factor_grace_period = periods.min || User.column_defaults['two_factor_grace_period'] - - save - end - - # each existing user needs to have a `feed_token`. - # we do this on read since migrating all existing users is not a feasible - # solution. - def feed_token - ensure_feed_token! unless Gitlab::CurrentSettings.disable_feed_token - end - - # Each existing user needs to have a `static_object_token`. - # We do this on read since migrating all existing users is not a feasible - # solution. - def static_object_token - ensure_static_object_token! - end - - def enabled_static_object_token - static_object_token if Gitlab::CurrentSettings.static_objects_external_storage_enabled? - end - - def enabled_incoming_email_token - incoming_email_token if Gitlab::Email::IncomingEmail.supports_issue_creation? - end - - def sync_attribute?(attribute) - return true if ldap_user? && attribute == :email - - attributes = Gitlab.config.omniauth.sync_profile_attributes - - if attributes.is_a?(Array) - attributes.include?(attribute.to_s) - else - attributes - end - end - - def read_only_attribute?(attribute) - user_synced_attributes_metadata&.read_only?(attribute) - end - - # override, from Devise - def lock_access!(opts = {}) - Gitlab::AppLogger.info("Account Locked: username=#{username}") - super - end - - # Determine the maximum access level for a group of projects in bulk. - # - # Returns a Hash mapping project ID -> maximum access level. - def max_member_access_for_project_ids(project_ids) - Gitlab::SafeRequestLoader.execute( - resource_key: max_member_access_for_resource_key(Project), - resource_ids: project_ids, - default_value: Gitlab::Access::NO_ACCESS - ) do |project_ids| - project_authorizations.where(project: project_ids) - .group(:project_id) - .maximum(:access_level) - end - end - - def max_member_access_for_project(project_id) - max_member_access_for_project_ids([project_id])[project_id] - end - - # Determine the maximum access level for a group of groups in bulk. - # - # Returns a Hash mapping project ID -> maximum access level. - def max_member_access_for_group_ids(group_ids) - Gitlab::SafeRequestLoader.execute( - resource_key: max_member_access_for_resource_key(Group), - resource_ids: group_ids, - default_value: Gitlab::Access::NO_ACCESS - ) do |group_ids| - group_members.where(source: group_ids).group(:source_id).maximum(:access_level) - end - end - - def max_member_access_for_group(group_id) - max_member_access_for_group_ids([group_id])[group_id] - end - - def terms_accepted? - return true if project_bot? - - accepted_term_id.present? - end - - def required_terms_not_accepted? - Gitlab::CurrentSettings.current_application_settings.enforce_terms? && - !terms_accepted? - end - - def requires_usage_stats_consent? - self.admin? && 7.days.ago > self.created_at && !has_current_license? && User.single_user? && !consented_usage_stats? - end - - # Avoid migrations only building user preference object when needed. - def user_preference - super.presence || build_user_preference - end - - def user_detail - super.presence || build_user_detail - end - - def pending_todo_for(target) - todos.find_by(target: target, state: :pending) - end - - def password_expired? - !!(password_expires_at && password_expires_at < Time.current) - end - - def password_expired_if_applicable? - return false if bot? - return false unless password_expired? - return false if password_automatically_set? - return false unless allow_password_authentication? - - true - end - - def can_log_in_with_non_expired_password? - can?(:log_in) && !password_expired_if_applicable? - end - - def can_be_deactivated? - active? && no_recent_activity? && !internal? - end - - def last_active_at - last_activity = last_activity_on&.to_time&.in_time_zone - last_sign_in = current_sign_in_at - - [last_activity, last_sign_in].compact.max - end - - REQUIRES_ROLE_VALUE = 99 - - def role_required? - role_before_type_cast == REQUIRES_ROLE_VALUE - end - - def set_role_required! - update_column(:role, REQUIRES_ROLE_VALUE) - end - - def dismissed_callout?(feature_name:, ignore_dismissal_earlier_than: nil) - callout = callouts_by_feature_name[feature_name] - - callout_dismissed?(callout, ignore_dismissal_earlier_than) - end - - def dismissed_callout_before?(feature_name, dismissed_before) - callout = callouts_by_feature_name[feature_name] - - return false unless callout - - callout.dismissed_before?(dismissed_before) - end - - def dismissed_callout_for_group?(feature_name:, group:, ignore_dismissal_earlier_than: nil) - source_feature_name = "#{feature_name}_#{group.id}" - callout = group_callouts_by_feature_name[source_feature_name] - - callout_dismissed?(callout, ignore_dismissal_earlier_than) - end - - def dismissed_callout_for_project?(feature_name:, project:, ignore_dismissal_earlier_than: nil) - callout = project_callouts.find_by(feature_name: feature_name, project: project) - - callout_dismissed?(callout, ignore_dismissal_earlier_than) - end - - # Load the current highest access by looking directly at the user's memberships - def current_highest_access_level - members.non_request.maximum(:access_level) - end - - def confirmation_required_on_sign_in? - return false if confirmed? - - if ::Gitlab::CurrentSettings.email_confirmation_setting_off? - false - elsif ::Gitlab::CurrentSettings.email_confirmation_setting_soft? - !in_confirmation_period? - elsif ::Gitlab::CurrentSettings.email_confirmation_setting_hard? - true - end - end - - def impersonated? - impersonator.present? - end - - def created_recently? - created_at > Devise.confirm_within.ago - end - - def find_or_initialize_callout(feature_name) - callouts.find_or_initialize_by(feature_name: ::Users::Callout.feature_names[feature_name]) - end - - def find_or_initialize_group_callout(feature_name, group_id) - group_callouts - .find_or_initialize_by(feature_name: ::Users::GroupCallout.feature_names[feature_name], group_id: group_id) - end - - def find_or_initialize_project_callout(feature_name, project_id) - project_callouts - .find_or_initialize_by(feature_name: ::Users::ProjectCallout.feature_names[feature_name], project_id: project_id) - end - - def can_trigger_notifications? - confirmed? && !blocked? && !ghost? - end - - # This attribute hosts a Ci::JobToken::Scope object which is set when - # the user is authenticated successfully via CI_JOB_TOKEN. - def ci_job_token_scope - Gitlab::SafeRequestStore[ci_job_token_scope_cache_key] - end - - def set_ci_job_token_scope!(job) - Gitlab::SafeRequestStore[ci_job_token_scope_cache_key] = Ci::JobToken::Scope.new(job.project) - end - - def from_ci_job_token? - ci_job_token_scope.present? - end - - def user_project - strong_memoize(:user_project) do - personal_projects.find_by(path: username, visibility_level: Gitlab::VisibilityLevel::PUBLIC) - end - end - - def user_readme - strong_memoize(:user_readme) do - user_project&.repository&.readme - end - end - - def account_age_in_days - (Date.current - created_at.to_date).to_i - end - - def webhook_email - public_email.presence || _('[REDACTED]') - end - - def namespace_commit_email_for_project(project) - return if project.nil? - - namespace_commit_emails.find_by(namespace: project.project_namespace) || - namespace_commit_emails.find_by(namespace: project.root_namespace) - end - - def spammer? - spam_score > Abuse::TrustScore::SPAMCHECK_HAM_THRESHOLD - end - - def spam_score - abuse_trust_scores.spamcheck.average(:score) || 0.0 - end - - def telesign_score - abuse_trust_scores.telesign.order(created_at: :desc).first&.score || 0.0 - end - - def arkose_global_score - abuse_trust_scores.arkose_global_score.order(created_at: :desc).first&.score || 0.0 - end - - def arkose_custom_score - abuse_trust_scores.arkose_custom_score.order(created_at: :desc).first&.score || 0.0 - end - - def trust_scores_for_source(source) - abuse_trust_scores.where(source: source) - end - - def abuse_metadata - { - account_age: account_age_in_days, - two_factor_enabled: two_factor_enabled? ? 1 : 0 - } - end - -<<<<<<< HEAD - def namespace_commit_email_for_namespace(namespace) - return if namespace.nil? - - namespace_commit_emails.find_by(namespace: namespace) -======= - def allow_possible_spam? - custom_attributes.by_key(UserCustomAttribute::ALLOW_POSSIBLE_SPAM).exists? ->>>>>>> c68084987dc5 (Add allow/disallow services and specs) - end - - protected - - # override, from Devise::Validatable - def password_required? - return false if internal? || project_bot? || security_policy_bot? - - super - end - - # override from Devise::Confirmable - def confirmation_period_valid? - return super if ::Gitlab::CurrentSettings.email_confirmation_setting_soft? - - # Following devise logic for method, we want to return `true` - # See: https://github.com/heartcombo/devise/blob/main/lib/devise/models/confirmable.rb#L191-L218 - true - end - alias_method :in_confirmation_period?, :confirmation_period_valid? - - # This is copied from Devise::Models::TwoFactorAuthenticatable#consume_otp! - # - # An OTP cannot be used more than once in a given timestep - # Storing timestep of last valid OTP is sufficient to satisfy this requirement - # - # See: - # - # - def consume_otp! - if self.consumed_timestep != current_otp_timestep - self.consumed_timestep = current_otp_timestep - return Gitlab::Database.read_only? ? true : save(validate: false) - end - - false - end - - private - - def block_or_ban - if spammer? && account_age_in_days < 7 - ban_and_report - else - block - end - end - - def ban_and_report - msg = 'Potential spammer account deletion' - attrs = { user_id: id, reporter: User.security_bot, category: 'spam' } - abuse_report = AbuseReport.find_by(attrs) - - if abuse_report.nil? - abuse_report = AbuseReport.create!(attrs.merge(message: msg)) - else - abuse_report.update(message: "#{abuse_report.message}\n\n#{msg}") - end - - UserCustomAttribute.set_banned_by_abuse_report(abuse_report) - - ban - end - - def has_possible_spam_contributions? - events - .for_action('commented') - .or(events.for_action('created').where(target_type: %w[Issue MergeRequest])) - .any? - end - - def should_delay_delete?(deleted_by) - is_deleting_own_record = deleted_by.id == id - - is_deleting_own_record && - ::Feature.enabled?(:delay_delete_own_user) && - has_possible_spam_contributions? - end - - def pbkdf2? - return false unless otp_backup_codes&.any? - - otp_backup_codes.first.start_with?("$pbkdf2-sha512$") - end - - # rubocop: disable CodeReuse/ServiceClass - def add_primary_email_to_emails! - Emails::CreateService.new(self, user: self, email: self.email).execute(confirmed_at: self.confirmed_at) - end - # rubocop: enable CodeReuse/ServiceClass - - def ci_project_ids_for_project_members(level) - project_members.where('access_level >= ?', level).pluck(:source_id) - end - - def notification_email_verified - return if notification_email.blank? || temp_oauth_email? - - errors.add(:notification_email, _("must be an email you have verified")) unless verified_emails.include?(notification_email_or_default) - end - - def public_email_verified - return if public_email.blank? - - errors.add(:public_email, _("must be an email you have verified")) unless verified_emails.include?(public_email) - end - - def commit_email_verified - return if commit_email.blank? - - errors.add(:commit_email, _("must be an email you have verified")) unless verified_emails.include?(commit_email_or_default) - end - - def callout_dismissed?(callout, ignore_dismissal_earlier_than) - return false unless callout - return callout.dismissed_after?(ignore_dismissal_earlier_than) if ignore_dismissal_earlier_than - - true - end - - def callouts_by_feature_name - @callouts_by_feature_name ||= callouts.index_by(&:feature_name) - end - - def group_callouts_by_feature_name - @group_callouts_by_feature_name ||= group_callouts.index_by(&:source_feature_name) - end - - def authorized_groups_without_shared_membership - Group.from_union( - [ - groups, - Group.id_in(authorized_projects.select(:namespace_id)) - ] - ) - end - - def authorized_groups_with_shared_membership - cte = Gitlab::SQL::CTE.new(:direct_groups, authorized_groups_without_shared_membership) - cte_alias = cte.table.alias(Group.table_name) - - Group - .with(cte.to_arel) - .from_union([ - Group.from(cte_alias), - Group.joins(:shared_with_group_links) - .where(group_group_links: { shared_with_group_id: Group.from(cte_alias) }) - ]) - end - - def has_current_license? - false - end - - def consented_usage_stats? - # Bypass the cache here because it's possible the admin enabled the - # usage ping, and we don't want to annoy the user again if they - # already set the value. This is a bit of hack, but the alternative - # would be to put in a more complex cache invalidation step. Since - # this call only gets called in the uncommon situation where the - # user is an admin and the only user in the instance, this shouldn't - # cause too much load on the system. - ApplicationSetting.current_without_cache&.usage_stats_set_by_user_id == self.id - end - - def ensure_user_rights_and_limits - if external? - self.can_create_group = false - self.projects_limit = 0 - else - # Only revert these back to the default if they weren't specifically changed in this update. - self.can_create_group = Gitlab::CurrentSettings.can_create_group unless can_create_group_changed? - self.projects_limit = Gitlab::CurrentSettings.default_projects_limit unless projects_limit_changed? - end - end - - def email_allowed_by_restrictions? - error = validate_admin_signup_restrictions(email) - - errors.add(:email, error) if error - end - - def signup_email_invalid_message - self.new_record? ? _('is not allowed for sign-up. Please use your regular email address.') : _('is not allowed. Please use your regular email address.') - end - - def check_username_format - return if username.blank? || Mime::EXTENSION_LOOKUP.keys.none? { |type| username.end_with?(".#{type}") } - - errors.add(:username, _('ending with a reserved file extension is not allowed.')) - end - - def check_password_weakness - if password.present? && Security::WeakPasswords.weak_for_user?(password, self) - errors.add(:password, _('must not contain commonly used combinations of words and letters')) - end - end - - def groups_with_developer_maintainer_project_access - project_creation_levels = [::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS] - - if ::Gitlab::CurrentSettings.default_project_creation == ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS - project_creation_levels << nil - end - - developer_groups.self_and_descendants.where(project_creation_level: project_creation_levels) - end - - def no_recent_activity? - last_active_at.to_i <= Gitlab::CurrentSettings.deactivate_dormant_users_period.days.ago.to_i - end - - def update_highest_role? - return false unless persisted? - - (previous_changes.keys & %w(state user_type)).any? - end - - def update_highest_role_attribute - id - end - - def ci_job_token_scope_cache_key - "users:#{id}:ci:job_token_scope" - end - - # An `ldap_blocked` user will be unblocked if LDAP indicates they are allowed. - def check_ldap_if_ldap_blocked! - return unless ::Gitlab::Auth::Ldap::Config.enabled? && ldap_blocked? - - ::Gitlab::Auth::Ldap::Access.allowed?(self) - end - - def ci_owned_project_runners_from_project_members - project_ids = ci_project_ids_for_project_members(Gitlab::Access::MAINTAINER) - - Ci::Runner - .joins(:runner_projects) - .where(runner_projects: { project: project_ids }) - end - - def ci_owned_project_runners_from_group_members - cte_namespace_ids = Gitlab::SQL::CTE.new( - :cte_namespace_ids, - ci_namespace_mirrors_for_group_members(Gitlab::Access::MAINTAINER).select(:namespace_id) - ) - - cte_project_ids = Gitlab::SQL::CTE.new( - :cte_project_ids, - Ci::ProjectMirror - .select(:project_id) - .where('ci_project_mirrors.namespace_id IN (SELECT namespace_id FROM cte_namespace_ids)') - ) - - Ci::Runner - .with(cte_namespace_ids.to_arel) - .with(cte_project_ids.to_arel) - .joins(:runner_projects) - .where('ci_runner_projects.project_id IN (SELECT project_id FROM cte_project_ids)') - end - - def ci_owned_group_runners - cte_namespace_ids = Gitlab::SQL::CTE.new( - :cte_namespace_ids, - ci_namespace_mirrors_for_group_members(Gitlab::Access::OWNER).select(:namespace_id) - ) - - Ci::Runner - .with(cte_namespace_ids.to_arel) - .joins(:runner_namespaces) - .where('ci_runner_namespaces.namespace_id IN (SELECT namespace_id FROM cte_namespace_ids)') - end - - def ci_namespace_mirrors_for_group_members(level) - search_members = group_members.where('access_level >= ?', level) - - # This reduces searched prefixes to only shortest ones - # to avoid querying descendants since they are already covered - # by ancestor namespaces. If the FF is not available fallback to - # inefficient search: https://gitlab.com/gitlab-org/gitlab/-/issues/336436 - unless Feature.enabled?(:use_traversal_ids) - return Ci::NamespaceMirror.contains_any_of_namespaces(search_members.pluck(:source_id)) - end - - traversal_ids = Group.joins(:all_group_members) - .merge(search_members) - .shortest_traversal_ids_prefixes - - Ci::NamespaceMirror.contains_traversal_ids(traversal_ids) - end -end - -User.prepend_mod_with('User') diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 4dd19a4daa25df..31747b4940528e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2416,12 +2416,6 @@ msgstr "" msgid "AbuseReport|Tier" msgstr "" -msgid "AbuseReport|Trust user" -msgstr "" - -msgid "AbuseReport|Untrust user" -msgstr "" - msgid "AbuseReport|Verification" msgstr "" @@ -3814,6 +3808,9 @@ msgstr "" msgid "AdminUsers|Admins" msgstr "" +msgid "AdminUsers|Allow user %{username} to create possible spam?" +msgstr "" + msgid "AdminUsers|An error occurred while fetching this user's contributions, and the request cannot return the number of issues, merge requests, groups, and projects linked to this user. If you proceed with deleting the user, all their contributions will still be deleted." msgstr "" @@ -3916,6 +3913,9 @@ msgstr "" msgid "AdminUsers|Delete user and contributions" msgstr "" +msgid "AdminUsers|Disallow user %{username} to create possible spam?" +msgstr "" + msgid "AdminUsers|Export permissions as CSV (max 100,000 users)" msgstr "" @@ -4030,6 +4030,9 @@ msgstr "" msgid "AdminUsers|The maximum compute minutes that jobs in this namespace can use on shared runners each month. Set 0 for unlimited. Set empty to inherit the global setting of %{minutes}" msgstr "" +msgid "AdminUsers|The user can create issues, notes, snippets, and merge requests without being blocked." +msgstr "" + msgid "AdminUsers|The user can't access git repositories." msgstr "" @@ -4108,6 +4111,9 @@ msgstr "" msgid "AdminUsers|What does this mean?" msgstr "" +msgid "AdminUsers|When allowed to create possible spam:" +msgstr "" + msgid "AdminUsers|When banned:" msgstr "" @@ -4126,6 +4132,9 @@ msgstr "" msgid "AdminUsers|You are about to permanently delete the user %{username}. This will delete all issues, merge requests, groups, and projects linked to them. To avoid data loss, consider using the %{strongStart}Block user%{strongEnd} feature instead. After you %{strongStart}Delete user%{strongEnd}, you cannot undo this action or recover the data." msgstr "" +msgid "AdminUsers|You can allow possible spam in the future if necessary." +msgstr "" + msgid "AdminUsers|You can always block their account again if needed." msgstr "" @@ -4141,6 +4150,9 @@ msgstr "" msgid "AdminUsers|You can ban their account in the future if necessary." msgstr "" +msgid "AdminUsers|You can disallow creating possible spam in the future." +msgstr "" + msgid "AdminUsers|You can unban their account in the future. Their data remains intact." msgstr "" @@ -18740,6 +18752,9 @@ msgstr "" msgid "Error occurred. User was not unlocked" msgstr "" +msgid "Error occurred. User was not updated" +msgstr "" + msgid "Error parsing CSV file. Please make sure it has" msgstr "" @@ -45980,6 +45995,9 @@ msgstr "" msgid "Successfully synced %{synced_timeago}." msgstr "" +msgid "Successfully trusted" +msgstr "" + msgid "Successfully unbanned" msgstr "" @@ -45992,6 +46010,9 @@ msgstr "" msgid "Successfully unlocked" msgstr "" +msgid "Successfully untrusted" +msgstr "" + msgid "Successfully updated %{last_updated_timeago}." msgstr "" @@ -49956,6 +49977,9 @@ msgstr "" msgid "Trigger|Trigger description" msgstr "" +msgid "Trust user" +msgstr "" + msgid "Trusted" msgstr "" @@ -50118,12 +50142,15 @@ msgstr "" msgid "USER %{user_name} WILL BE REMOVED! Are you sure?" msgstr "" -msgid "USER WILL BE BLOCKED! Are you sure?" +msgid "USER %{user} WILL BE REMOVED! Are you sure?" msgstr "" msgid "USER WILL BE ALLOWED TO CREATE POSSIBLE SPAM! Are you sure?" msgstr "" +msgid "USER WILL BE BLOCKED! Are you sure?" +msgstr "" + msgid "USER WILL NOT BE ALLOWED TO CREATE POSSIBLE SPAM! Are you sure?" msgstr "" @@ -50466,6 +50493,9 @@ msgstr "" msgid "Untitled" msgstr "" +msgid "Untrust user" +msgstr "" + msgid "Unused" msgstr "" -- GitLab From 8cfd865bb1f9a1ab322196c0d42c21ebb999a881 Mon Sep 17 00:00:00 2001 From: Ethan Urie Date: Thu, 13 Jul 2023 11:03:33 -0400 Subject: [PATCH 04/18] Added menu items for the admin users page --- .../admin/users/components/actions/index.js | 4 ++ .../admin/users/components/actions/trust.vue | 61 +++++++++++++++++++ .../users/components/actions/untrust.vue | 55 +++++++++++++++++ .../javascripts/admin/users/constants.js | 2 + app/helpers/admin/user_actions_helper.rb | 11 ++++ app/helpers/users_helper.rb | 4 +- app/models/user.rb | 2 +- locale/gitlab.pot | 6 ++ 8 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 app/assets/javascripts/admin/users/components/actions/trust.vue create mode 100644 app/assets/javascripts/admin/users/components/actions/untrust.vue diff --git a/app/assets/javascripts/admin/users/components/actions/index.js b/app/assets/javascripts/admin/users/components/actions/index.js index 4e63a85df891f2..373d47d29d9c94 100644 --- a/app/assets/javascripts/admin/users/components/actions/index.js +++ b/app/assets/javascripts/admin/users/components/actions/index.js @@ -9,6 +9,8 @@ import Reject from './reject.vue'; import Unban from './unban.vue'; import Unblock from './unblock.vue'; import Unlock from './unlock.vue'; +import Trust from './trust.vue'; +import Untrust from './untrust.vue'; export default { Activate, @@ -22,4 +24,6 @@ export default { Unblock, Unlock, Reject, + Trust, + Untrust, }; diff --git a/app/assets/javascripts/admin/users/components/actions/trust.vue b/app/assets/javascripts/admin/users/components/actions/trust.vue new file mode 100644 index 00000000000000..2722442ae0d258 --- /dev/null +++ b/app/assets/javascripts/admin/users/components/actions/trust.vue @@ -0,0 +1,61 @@ + + + diff --git a/app/assets/javascripts/admin/users/components/actions/untrust.vue b/app/assets/javascripts/admin/users/components/actions/untrust.vue new file mode 100644 index 00000000000000..919e9dfc4f2066 --- /dev/null +++ b/app/assets/javascripts/admin/users/components/actions/untrust.vue @@ -0,0 +1,55 @@ + + + diff --git a/app/assets/javascripts/admin/users/constants.js b/app/assets/javascripts/admin/users/constants.js index 9cd61d6b1dbcc6..43c9a8749cde76 100644 --- a/app/assets/javascripts/admin/users/constants.js +++ b/app/assets/javascripts/admin/users/constants.js @@ -19,4 +19,6 @@ export const I18N_USER_ACTIONS = { deleteWithContributions: s__('AdminUsers|Delete user and contributions'), ban: s__('AdminUsers|Ban user'), unban: s__('AdminUsers|Unban user'), + trust: s__('AdminUsers|Trust user'), + untrust: s__('AdminUsers|Untrust user'), }; diff --git a/app/helpers/admin/user_actions_helper.rb b/app/helpers/admin/user_actions_helper.rb index 969c5d5a0b5b14..a04bdb08314ab7 100644 --- a/app/helpers/admin/user_actions_helper.rb +++ b/app/helpers/admin/user_actions_helper.rb @@ -16,6 +16,7 @@ def admin_actions(user) unlock_actions delete_actions ban_actions + trust_actions @actions end @@ -66,5 +67,15 @@ def ban_actions @actions << 'ban' end end + + def trust_actions + return if @user.internal? + + @actions << if @user.allow_possible_spam? + 'untrust' + else + 'trust' + end + end end end diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index ac279904fd27e9..e2b3d9b99a0900 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -260,7 +260,9 @@ def admin_users_paths delete_with_contributions: admin_user_path(:id, hard_delete: true), admin_user: admin_user_path(:id), ban: ban_admin_user_path(:id), - unban: unban_admin_user_path(:id) + unban: unban_admin_user_path(:id), + trust: allow_possible_spam_admin_user_path(:id), + untrust: disallow_possible_spam_admin_user_path(:id) } end diff --git a/app/models/user.rb b/app/models/user.rb index 547efba169dfdc..b00305c31318d9 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2202,7 +2202,7 @@ def abuse_metadata end def allow_possible_spam? - custom_attributes.trusted_with_spam.exists? + custom_attributes.trusted_with_spam.present? end def namespace_commit_email_for_namespace(namespace) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 31747b4940528e..5f4ed4dbccba16 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -4063,6 +4063,9 @@ msgstr "" msgid "AdminUsers|To confirm, type %{username}." msgstr "" +msgid "AdminUsers|Trust user" +msgstr "" + msgid "AdminUsers|Trusted" msgstr "" @@ -4081,6 +4084,9 @@ msgstr "" msgid "AdminUsers|Unlock user %{username}?" msgstr "" +msgid "AdminUsers|Untrust user" +msgstr "" + msgid "AdminUsers|User administration" msgstr "" -- GitLab From d4dfe56582ffbed1af12ded91d04bdf5a0c1d1d0 Mon Sep 17 00:00:00 2001 From: Ethan Urie Date: Mon, 17 Jul 2023 13:47:04 -0400 Subject: [PATCH 05/18] Add Trust/Untrust to spam logs UI --- app/views/admin/spam_logs/_spam_log.html.haml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml index 6aed8508a6a48f..af811bfb62d4dc 100644 --- a/app/views/admin/spam_logs/_spam_log.html.haml +++ b/app/views/admin/spam_logs/_spam_log.html.haml @@ -53,6 +53,18 @@ - else = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'disabled gl-mb-3'}) do = _("Already blocked") + - if user && !user.allow_possible_spam? + = render Pajamas::ButtonComponent.new(size: :small, + method: :put, + href: allow_possible_spam_admin_user_path(user), + button_options: { class: 'gl-mb-3', data: {confirm: _('USER WILL BE ALLOWED TO CREATE POSSIBLE SPAM! Are you sure?')} }) do + = _('Trust user') + - else + = render Pajamas::ButtonComponent.new(size: :small, + method: :put, + href: disallow_possible_spam_admin_user_path(user), + button_options: { class: 'gl-mb-3', data: {confirm: _('USER WILL NOT BE ALLOWED TO CREATE POSSIBLE SPAM! Are you sure?')} }) do + = _('Untrust user') = render Pajamas::ButtonComponent.new(size: :small, method: :delete, href: [:admin, spam_log], -- GitLab From 7d28dd6d85ce6303c212f3abc23c8b3fc156216b Mon Sep 17 00:00:00 2001 From: Ethan Urie Date: Tue, 18 Jul 2023 15:58:59 -0400 Subject: [PATCH 06/18] Refactor allow_possible_spam to trust --- app/controllers/admin/users_controller.rb | 8 ++++---- app/helpers/admin/user_actions_helper.rb | 2 +- app/helpers/users_helper.rb | 4 ++-- app/models/user.rb | 2 +- app/models/user_custom_attribute.rb | 4 ++-- app/services/spam/spam_verdict_service.rb | 2 +- .../{allow_possible_spam_service.rb => trust_service.rb} | 4 ++-- ...sallow_possible_spam_service.rb => untrust_service.rb} | 4 ++-- app/views/admin/abuse_reports/_abuse_report.html.haml | 6 +++--- app/views/admin/spam_logs/_spam_log.html.haml | 6 +++--- config/routes/admin.rb | 4 ++-- 11 files changed, 23 insertions(+), 23 deletions(-) rename app/services/users/{allow_possible_spam_service.rb => trust_service.rb} (79%) rename app/services/users/{disallow_possible_spam_service.rb => untrust_service.rb} (55%) diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 8a3a796dea3937..9bb0d902119f26 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -164,8 +164,8 @@ def unlock end end - def allow_possible_spam - result = Users::AllowPossibleSpamService.new(current_user).execute(user) + def trust + result = Users::TrustService.new(current_user).execute(user) if result[:status] == :success redirect_back_or_admin_user(notice: _("Successfully trusted")) @@ -174,8 +174,8 @@ def allow_possible_spam end end - def disallow_possible_spam - result = Users::DisallowPossibleSpamService.new(current_user).execute(user) + def untrust + result = Users::UntrustService.new(current_user).execute(user) if result[:status] == :success redirect_back_or_admin_user(notice: _("Successfully untrusted")) diff --git a/app/helpers/admin/user_actions_helper.rb b/app/helpers/admin/user_actions_helper.rb index a04bdb08314ab7..63c4d3e0db5bca 100644 --- a/app/helpers/admin/user_actions_helper.rb +++ b/app/helpers/admin/user_actions_helper.rb @@ -71,7 +71,7 @@ def ban_actions def trust_actions return if @user.internal? - @actions << if @user.allow_possible_spam? + @actions << if @user.trusted? 'untrust' else 'trust' diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index e2b3d9b99a0900..d5e9b0a095d4b0 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -261,8 +261,8 @@ def admin_users_paths admin_user: admin_user_path(:id), ban: ban_admin_user_path(:id), unban: unban_admin_user_path(:id), - trust: allow_possible_spam_admin_user_path(:id), - untrust: disallow_possible_spam_admin_user_path(:id) + trust: trust_admin_user_path(:id), + untrust: untrust_admin_user_path(:id) } end diff --git a/app/models/user.rb b/app/models/user.rb index b00305c31318d9..0146162f56645a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2201,7 +2201,7 @@ def abuse_metadata } end - def allow_possible_spam? + def trusted? custom_attributes.trusted_with_spam.present? end diff --git a/app/models/user_custom_attribute.rb b/app/models/user_custom_attribute.rb index eba338a7e2aca2..67c9abbf269f86 100644 --- a/app/models/user_custom_attribute.rb +++ b/app/models/user_custom_attribute.rb @@ -10,13 +10,13 @@ class UserCustomAttribute < ApplicationRecord scope :by_user_id, ->(user_id) { where(user_id: user_id) } scope :by_updated_at, ->(updated_at) { where(updated_at: updated_at) } scope :arkose_sessions, -> { by_key('arkose_session') } - scope :trusted_with_spam, -> { by_key(ALLOW_POSSIBLE_SPAM) } + scope :trusted_with_spam, -> { by_key(TRUSTED_BY) } BLOCKED_BY = 'blocked_by' UNBLOCKED_BY = 'unblocked_by' ARKOSE_RISK_BAND = 'arkose_risk_band' AUTO_BANNED_BY_ABUSE_REPORT_ID = 'auto_banned_by_abuse_report_id' - ALLOW_POSSIBLE_SPAM = 'allow_possible_spam' + TRUSTED_BY = 'trusted_by' IDENTITY_VERIFICATION_PHONE_EXEMPT = 'identity_verification_phone_exempt' class << self diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb index 9efe51b43b815e..ce5c5201b41921 100644 --- a/app/services/spam/spam_verdict_service.rb +++ b/app/services/spam/spam_verdict_service.rb @@ -90,7 +90,7 @@ def override_via_allow_possible_spam?(verdict:) end def allow_possible_spam? - target.allow_possible_spam?(user) || user.allow_possible_spam? + target.allow_possible_spam? || user.trusted? end def spamcheck_client diff --git a/app/services/users/allow_possible_spam_service.rb b/app/services/users/trust_service.rb similarity index 79% rename from app/services/users/allow_possible_spam_service.rb rename to app/services/users/trust_service.rb index 2caa577056fc93..b9d45fa9c31f0f 100644 --- a/app/services/users/allow_possible_spam_service.rb +++ b/app/services/users/trust_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Users - class AllowPossibleSpamService < BaseService + class TrustService < BaseService def initialize(current_user) @current_user = current_user end @@ -9,7 +9,7 @@ def initialize(current_user) def execute(user) custom_attribute = { user_id: user.id, - key: UserCustomAttribute::ALLOW_POSSIBLE_SPAM, + key: UserCustomAttribute::TRUSTED_BY, value: "#{current_user.username}/#{current_user.id}+#{Time.current}" } UserCustomAttribute.upsert_custom_attributes([custom_attribute]) diff --git a/app/services/users/disallow_possible_spam_service.rb b/app/services/users/untrust_service.rb similarity index 55% rename from app/services/users/disallow_possible_spam_service.rb rename to app/services/users/untrust_service.rb index 5f4c109aae2b27..6db05d6fa7765c 100644 --- a/app/services/users/disallow_possible_spam_service.rb +++ b/app/services/users/untrust_service.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true module Users - class DisallowPossibleSpamService < BaseService + class UntrustService < BaseService def initialize(current_user) @current_user = current_user end def execute(user) - user.custom_attributes.by_key(UserCustomAttribute::ALLOW_POSSIBLE_SPAM).delete_all + user.custom_attributes.by_key(UserCustomAttribute::TRUSTED_BY).delete_all success end end diff --git a/app/views/admin/abuse_reports/_abuse_report.html.haml b/app/views/admin/abuse_reports/_abuse_report.html.haml index b2ab3942a8bced..ea0433b789d82d 100644 --- a/app/views/admin/abuse_reports/_abuse_report.html.haml +++ b/app/views/admin/abuse_reports/_abuse_report.html.haml @@ -35,11 +35,11 @@ - else = render Pajamas::ButtonComponent.new(href: block_admin_user_path(user), block: true, button_options: { data: { confirm: _('USER WILL BE BLOCKED! Are you sure?'), method: :put } }) do = _('Block user') - - if user.allow_possible_spam? - = render Pajamas::ButtonComponent.new(href: disallow_possible_spam_admin_user_path(user), block: true, button_options: { data: { confirm: _('USER WILL NOT BE ALLOWED TO CREATE POSSIBLE SPAM! Are you sure?'), method: :put } }) do + - if user.trusted? + = render Pajamas::ButtonComponent.new(href: untrust_admin_user_path(user), block: true, button_options: { data: { confirm: _('USER WILL NOT BE ALLOWED TO CREATE POSSIBLE SPAM! Are you sure?'), method: :put } }) do = _('Untrust user') - else - = render Pajamas::ButtonComponent.new(href: allow_possible_spam_admin_user_path(user), block: true, button_options: { data: { confirm: _('USER WILL BE ALLOWED TO CREATE POSSIBLE SPAM! Are you sure?'), method: :put } }) do + = render Pajamas::ButtonComponent.new(href: trust_admin_user_path(user), block: true, button_options: { data: { confirm: _('USER WILL BE ALLOWED TO CREATE POSSIBLE SPAM! Are you sure?'), method: :put } }) do = _('Trust user') = render Pajamas::ButtonComponent.new(href: [:admin, abuse_report], block: true, button_options: { data: { remote: true, method: :delete }, class: "js-remove-tr" }) do = _('Remove report') diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml index af811bfb62d4dc..bcd09a55ac1bf2 100644 --- a/app/views/admin/spam_logs/_spam_log.html.haml +++ b/app/views/admin/spam_logs/_spam_log.html.haml @@ -53,16 +53,16 @@ - else = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'disabled gl-mb-3'}) do = _("Already blocked") - - if user && !user.allow_possible_spam? + - if user && !user.trusted? = render Pajamas::ButtonComponent.new(size: :small, method: :put, - href: allow_possible_spam_admin_user_path(user), + href: trust_admin_user_path(user), button_options: { class: 'gl-mb-3', data: {confirm: _('USER WILL BE ALLOWED TO CREATE POSSIBLE SPAM! Are you sure?')} }) do = _('Trust user') - else = render Pajamas::ButtonComponent.new(size: :small, method: :put, - href: disallow_possible_spam_admin_user_path(user), + href: untrust_admin_user_path(user), button_options: { class: 'gl-mb-3', data: {confirm: _('USER WILL NOT BE ALLOWED TO CREATE POSSIBLE SPAM! Are you sure?')} }) do = _('Untrust user') = render Pajamas::ButtonComponent.new(size: :small, diff --git a/config/routes/admin.rb b/config/routes/admin.rb index fcea63d7f632e3..e8ad19624e9823 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -22,8 +22,8 @@ put :unlock put :confirm put :approve - put :allow_possible_spam - put :disallow_possible_spam + put :trust + put :untrust delete :reject post :impersonate patch :disable_two_factor -- GitLab From a57b001b7e99275bbe33dea1c69643b93b48bfec Mon Sep 17 00:00:00 2001 From: Ethan Urie Date: Wed, 19 Jul 2023 14:16:54 -0400 Subject: [PATCH 07/18] Add trust specs for UserActionsHelper --- app/helpers/admin/user_actions_helper.rb | 6 +- spec/factories/users.rb | 9 +++ .../helpers/admin/user_actions_helper_spec.rb | 59 ++++++++++++++++--- spec/models/user_spec.rb | 8 +-- .../spam/spam_verdict_service_spec.rb | 2 +- .../users/allow_possible_spam_service_spec.rb | 24 -------- .../disallow_possible_spam_service_spec.rb | 34 ----------- 7 files changed, 71 insertions(+), 71 deletions(-) delete mode 100644 spec/services/users/allow_possible_spam_service_spec.rb delete mode 100644 spec/services/users/disallow_possible_spam_service_spec.rb diff --git a/app/helpers/admin/user_actions_helper.rb b/app/helpers/admin/user_actions_helper.rb index 63c4d3e0db5bca..ba40b3c8a8df5a 100644 --- a/app/helpers/admin/user_actions_helper.rb +++ b/app/helpers/admin/user_actions_helper.rb @@ -69,7 +69,11 @@ def ban_actions end def trust_actions - return if @user.internal? + return if @user.internal? || + @user.blocked_pending_approval? || + @user.banned? || + @user.blocked? || + @user.deactivated? @actions << if @user.trusted? 'untrust' diff --git a/spec/factories/users.rb b/spec/factories/users.rb index d61d5cc2d78f9b..de2b5159fe7107 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -48,6 +48,15 @@ after(:build) { |user, _| user.ban! } end + trait :trusted do + after(:create) do |user, _| + user.custom_attributes.create!( + key: UserCustomAttribute::TRUSTED_BY, + value: "placeholder" + ) + end + end + trait :ldap_blocked do after(:build) { |user, _| user.ldap_block! } end diff --git a/spec/helpers/admin/user_actions_helper_spec.rb b/spec/helpers/admin/user_actions_helper_spec.rb index 87d2308690c617..abfdabf3413df9 100644 --- a/spec/helpers/admin/user_actions_helper_spec.rb +++ b/spec/helpers/admin/user_actions_helper_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -RSpec.describe Admin::UserActionsHelper do +RSpec.describe Admin::UserActionsHelper, feature_category: :user_management do describe '#admin_actions' do let_it_be(:current_user) { build(:user) } @@ -29,13 +29,33 @@ context 'the user is a standard user' do let_it_be(:user) { create(:user) } - it { is_expected.to contain_exactly("edit", "block", "ban", "deactivate", "delete", "delete_with_contributions") } + it do + is_expected.to contain_exactly( + "edit", + "block", + "ban", + "deactivate", + "delete", + "delete_with_contributions", + "trust" + ) + end end context 'the user is an admin user' do let_it_be(:user) { create(:user, :admin) } - it { is_expected.to contain_exactly("edit", "block", "ban", "deactivate", "delete", "delete_with_contributions") } + it do + is_expected.to contain_exactly( + "edit", + "block", + "ban", + "deactivate", + "delete", + "delete_with_contributions", + "trust" + ) + end end context 'the user is blocked by LDAP' do @@ -59,7 +79,16 @@ context 'the user is deactivated' do let_it_be(:user) { create(:user, :deactivated) } - it { is_expected.to contain_exactly("edit", "block", "ban", "activate", "delete", "delete_with_contributions") } + it do + is_expected.to contain_exactly( + "edit", + "block", + "ban", + "activate", + "delete", + "delete_with_contributions" + ) + end end context 'the user is locked' do @@ -77,7 +106,8 @@ "deactivate", "unlock", "delete", - "delete_with_contributions" + "delete_with_contributions", + "trust" ) } end @@ -88,6 +118,21 @@ it { is_expected.to contain_exactly("edit", "unban", "delete", "delete_with_contributions") } end + context 'the user is trusted' do + let_it_be(:user) { create(:user, :trusted) } + + it do + is_expected.to contain_exactly("edit", + "block", + "deactivate", + "ban", + "delete", + "delete_with_contributions", + "untrust" + ) + end + end + context 'the current_user does not have permission to delete the user' do let_it_be(:user) { build(:user) } @@ -95,7 +140,7 @@ allow(helper).to receive(:can?).with(current_user, :destroy_user, user).and_return(false) end - it { is_expected.to contain_exactly("edit", "block", "ban", "deactivate") } + it { is_expected.to contain_exactly("edit", "block", "ban", "deactivate", "trust") } end context 'the user is a sole owner of a group' do @@ -106,7 +151,7 @@ group.add_owner(user) end - it { is_expected.to contain_exactly("edit", "block", "ban", "deactivate", "delete_with_contributions") } + it { is_expected.to contain_exactly("edit", "block", "ban", "deactivate", "delete_with_contributions", "trust") } end context 'the user is a bot' do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index f286d678360314..2be47fe0dc51a6 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -6116,10 +6116,10 @@ def add_user(access) end end - describe '#allow_possible_spam?' do + describe '#trusted?' do context 'when no custom attribute is set' do it 'is false' do - expect(user.allow_possible_spam?).to be_falsey + expect(user.trusted?).to be_falsey end end @@ -6128,13 +6128,13 @@ def add_user(access) user.custom_attributes.upsert_custom_attributes( [{ user_id: user.id, - key: UserCustomAttribute::ALLOW_POSSIBLE_SPAM, + key: UserCustomAttribute::TRUSTED_BY, value: "test" }]) end it '#allow_possible_spam? is true' do - expect(user.allow_possible_spam?).to be_truthy + expect(user.trusted?).to be_truthy end end end diff --git a/spec/services/spam/spam_verdict_service_spec.rb b/spec/services/spam/spam_verdict_service_spec.rb index 70f43d82ead04b..909f0d5e784dd5 100644 --- a/spec/services/spam/spam_verdict_service_spec.rb +++ b/spec/services/spam/spam_verdict_service_spec.rb @@ -141,7 +141,7 @@ UserCustomAttribute.upsert_custom_attributes( [{ user_id: user.id, - key: 'allow_possible_spam', + key: 'trusted_by', value: 'does not matter' }] ) diff --git a/spec/services/users/allow_possible_spam_service_spec.rb b/spec/services/users/allow_possible_spam_service_spec.rb deleted file mode 100644 index 53618f0c8e9614..00000000000000 --- a/spec/services/users/allow_possible_spam_service_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Users::AllowPossibleSpamService, feature_category: :user_management do - let_it_be(:current_user) { create(:admin) } - - subject(:service) { described_class.new(current_user) } - - describe '#execute' do - let(:user) { create(:user) } - - subject(:operation) { service.execute(user) } - - it 'updates the custom attributes', :aggregate_failures do - expect(user.custom_attributes).to be_empty - - operation - user.reload - - expect(user.custom_attributes.by_key(UserCustomAttribute::ALLOW_POSSIBLE_SPAM)).to be_present - end - end -end diff --git a/spec/services/users/disallow_possible_spam_service_spec.rb b/spec/services/users/disallow_possible_spam_service_spec.rb deleted file mode 100644 index 32a47e05525a7b..00000000000000 --- a/spec/services/users/disallow_possible_spam_service_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Users::DisallowPossibleSpamService, feature_category: :user_management do - let_it_be(:current_user) { create(:admin) } - - subject(:service) { described_class.new(current_user) } - - describe '#execute' do - let(:user) { create(:user) } - - subject(:operation) { service.execute(user) } - - before do - UserCustomAttribute.upsert_custom_attributes( - [{ - user_id: user.id, - key: :allow_possible_spam, - value: 'not important' - }] - ) - end - - it 'updates the custom attributes', :aggregate_failures do - expect(user.custom_attributes.by_key(UserCustomAttribute::ALLOW_POSSIBLE_SPAM)).to be_present - - operation - user.reload - - expect(user.custom_attributes).to be_empty - end - end -end -- GitLab From 87ff28cf5ee4501c347ef5c3656ba04c0e4139e5 Mon Sep 17 00:00:00 2001 From: Ethan Urie Date: Wed, 19 Jul 2023 14:36:45 -0400 Subject: [PATCH 08/18] Add request specs --- spec/requests/admin/users_controller_spec.rb | 20 ++++++++++++ spec/services/users/trust_service_spec.rb | 24 ++++++++++++++ spec/services/users/untrust_service_spec.rb | 34 ++++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 spec/services/users/trust_service_spec.rb create mode 100644 spec/services/users/untrust_service_spec.rb diff --git a/spec/requests/admin/users_controller_spec.rb b/spec/requests/admin/users_controller_spec.rb index e525d615b50311..ef3a1c1375a535 100644 --- a/spec/requests/admin/users_controller_spec.rb +++ b/spec/requests/admin/users_controller_spec.rb @@ -74,4 +74,24 @@ expect { request }.to change { user.reload.access_locked? }.from(true).to(false) end end + + describe 'PUT #trust' do + subject(:request) { put trust_admin_user_path(user) } + + it 'trusts the user' do + expect { request }.to change { user.reload.trusted? }.from(false).to(true) + end + end + + describe 'PUT #untrust' do + before do + user.custom_attributes.create!(key: UserCustomAttribute::TRUSTED_BY, value: "placeholder") + end + + subject(:request) { put untrust_admin_user_path(user) } + + it 'trusts the user' do + expect { request }.to change { user.reload.trusted? }.from(true).to(false) + end + end end diff --git a/spec/services/users/trust_service_spec.rb b/spec/services/users/trust_service_spec.rb new file mode 100644 index 00000000000000..1f71992ce9b38e --- /dev/null +++ b/spec/services/users/trust_service_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Users::TrustService, feature_category: :user_management do + let_it_be(:current_user) { create(:admin) } + + subject(:service) { described_class.new(current_user) } + + describe '#execute' do + let(:user) { create(:user) } + + subject(:operation) { service.execute(user) } + + it 'updates the custom attributes', :aggregate_failures do + expect(user.custom_attributes).to be_empty + + operation + user.reload + + expect(user.custom_attributes.by_key(UserCustomAttribute::TRUSTED_BY)).to be_present + end + end +end diff --git a/spec/services/users/untrust_service_spec.rb b/spec/services/users/untrust_service_spec.rb new file mode 100644 index 00000000000000..db7e4351d9cda3 --- /dev/null +++ b/spec/services/users/untrust_service_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Users::UntrustService, feature_category: :user_management do + let_it_be(:current_user) { create(:admin) } + + subject(:service) { described_class.new(current_user) } + + describe '#execute' do + let(:user) { create(:user) } + + subject(:operation) { service.execute(user) } + + before do + UserCustomAttribute.upsert_custom_attributes( + [{ + user_id: user.id, + key: :allow_possible_spam, + value: 'not important' + }] + ) + end + + it 'updates the custom attributes', :aggregate_failures do + expect(user.custom_attributes.by_key(UserCustomAttribute::TRUSTED_BY)).to be_present + + operation + user.reload + + expect(user.custom_attributes).to be_empty + end + end +end -- GitLab From edca4ae268af599e85c2e5249a52930e9a83ade3 Mon Sep 17 00:00:00 2001 From: Ethan Urie Date: Wed, 19 Jul 2023 14:59:22 -0400 Subject: [PATCH 09/18] Fix trust/untrust spec in model --- spec/frontend/admin/users/constants.js | 5 +++++ spec/models/user_spec.rb | 14 ++++++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/spec/frontend/admin/users/constants.js b/spec/frontend/admin/users/constants.js index d341eb03b1b33c..af8bb579cde833 100644 --- a/spec/frontend/admin/users/constants.js +++ b/spec/frontend/admin/users/constants.js @@ -9,6 +9,8 @@ const REJECT = 'reject'; const APPROVE = 'approve'; const BAN = 'ban'; const UNBAN = 'unban'; +const TRUST = 'trust'; +const UNTRUST = 'untrust'; export const EDIT = 'edit'; @@ -24,6 +26,9 @@ export const CONFIRMATION_ACTIONS = [ UNBAN, APPROVE, REJECT, + TRUST, + UNTRUST, + , ]; export const DELETE_ACTIONS = [DELETE, DELETE_WITH_CONTRIBUTIONS]; diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 2be47fe0dc51a6..6e56c9b335629b 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -6118,22 +6118,20 @@ def add_user(access) describe '#trusted?' do context 'when no custom attribute is set' do - it 'is false' do + it 'is falsey' do expect(user.trusted?).to be_falsey end end context 'when the custom attribute is set' do before do - user.custom_attributes.upsert_custom_attributes( - [{ - user_id: user.id, - key: UserCustomAttribute::TRUSTED_BY, - value: "test" - }]) + user.custom_attributes.create!( + key: UserCustomAttribute::TRUSTED_BY, + value: "test" + ) end - it '#allow_possible_spam? is true' do + it 'is truthy' do expect(user.trusted?).to be_truthy end end -- GitLab From f65f318fdf854d4f9d209ba6d757e9d0799c175d Mon Sep 17 00:00:00 2001 From: Ethan Urie Date: Thu, 27 Jul 2023 16:33:26 -0400 Subject: [PATCH 10/18] Create new relation that can be preloaded --- app/controllers/admin/spam_logs_controller.rb | 3 ++- app/models/spam_log.rb | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb index b27185a6addd29..619616a2f4048c 100644 --- a/app/controllers/admin/spam_logs_controller.rb +++ b/app/controllers/admin/spam_logs_controller.rb @@ -5,7 +5,8 @@ class Admin::SpamLogsController < Admin::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def index - @spam_logs = SpamLog.includes(:user).order(id: :desc).page(params[:page]).without_count + #@spam_logs = SpamLog.includes(:user).order(id: :desc).page(params[:page]).without_count + @spam_logs = SpamLog.includes(:users_with_trust).order(id: :desc).page(params[:page]).without_count end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/models/spam_log.rb b/app/models/spam_log.rb index 2ec53f58e5faea..cf01ad1ef6787a 100644 --- a/app/models/spam_log.rb +++ b/app/models/spam_log.rb @@ -2,6 +2,27 @@ class SpamLog < ApplicationRecord belongs_to :user + belongs_to :users_with_trust, -> do + users = User.arel_table + attributes = UserCustomAttribute.arel_table + spam_logs = SpamLog.arel_table + + join_spam_logs = users.join(spam_logs, Arel::Nodes::OuterJoin).on( + spam_logs[:user_id].eq(users[:id]) + ) + + join_attributes = join_spam_logs.join(attributes).on( + attributes[:user_id].eq(users[:id])) + + is_trusted = Arel::Nodes::Case.new.when( + attributes[:key].eq('trusted_by')) + .then(Arel::Nodes.build_quoted(true)) + .else(Arel::Nodes.build_quoted(false)) + .as('is_trusted') + + query = join_attributes.project(users[Arel.star], is_trusted) + User.from(query, :users) + end, class_name: 'User' validates :user, presence: true -- GitLab From c04f7ad10b30b580b5321240c0bf7458cb273da1 Mon Sep 17 00:00:00 2001 From: Ethan Urie Date: Wed, 2 Aug 2023 09:35:05 -0400 Subject: [PATCH 11/18] Simplify the query --- app/controllers/admin/spam_logs_controller.rb | 3 +-- app/models/spam_log.rb | 21 ------------------- spec/frontend/admin/users/constants.js | 1 - 3 files changed, 1 insertion(+), 24 deletions(-) diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb index 619616a2f4048c..6e6fa9bd5a9448 100644 --- a/app/controllers/admin/spam_logs_controller.rb +++ b/app/controllers/admin/spam_logs_controller.rb @@ -5,8 +5,7 @@ class Admin::SpamLogsController < Admin::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def index - #@spam_logs = SpamLog.includes(:user).order(id: :desc).page(params[:page]).without_count - @spam_logs = SpamLog.includes(:users_with_trust).order(id: :desc).page(params[:page]).without_count + @spam_logs = SpamLog.preload(user: [:custom_attributes]).order(id: :desc).page(params[:page]).without_count end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/models/spam_log.rb b/app/models/spam_log.rb index cf01ad1ef6787a..2ec53f58e5faea 100644 --- a/app/models/spam_log.rb +++ b/app/models/spam_log.rb @@ -2,27 +2,6 @@ class SpamLog < ApplicationRecord belongs_to :user - belongs_to :users_with_trust, -> do - users = User.arel_table - attributes = UserCustomAttribute.arel_table - spam_logs = SpamLog.arel_table - - join_spam_logs = users.join(spam_logs, Arel::Nodes::OuterJoin).on( - spam_logs[:user_id].eq(users[:id]) - ) - - join_attributes = join_spam_logs.join(attributes).on( - attributes[:user_id].eq(users[:id])) - - is_trusted = Arel::Nodes::Case.new.when( - attributes[:key].eq('trusted_by')) - .then(Arel::Nodes.build_quoted(true)) - .else(Arel::Nodes.build_quoted(false)) - .as('is_trusted') - - query = join_attributes.project(users[Arel.star], is_trusted) - User.from(query, :users) - end, class_name: 'User' validates :user, presence: true diff --git a/spec/frontend/admin/users/constants.js b/spec/frontend/admin/users/constants.js index af8bb579cde833..39e8e51f43ca0b 100644 --- a/spec/frontend/admin/users/constants.js +++ b/spec/frontend/admin/users/constants.js @@ -28,7 +28,6 @@ export const CONFIRMATION_ACTIONS = [ REJECT, TRUST, UNTRUST, - , ]; export const DELETE_ACTIONS = [DELETE, DELETE_WITH_CONTRIBUTIONS]; -- GitLab From c2c16d42cf3625fb08bb3d77c01e6173b1385b9f Mon Sep 17 00:00:00 2001 From: Ethan Urie Date: Thu, 3 Aug 2023 11:26:11 -0400 Subject: [PATCH 12/18] Fixed N+1 issue for SpamLogsController --- app/controllers/admin/spam_logs_controller.rb | 4 +++- app/models/user.rb | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb index 6e6fa9bd5a9448..d7ed6aa33ef209 100644 --- a/app/controllers/admin/spam_logs_controller.rb +++ b/app/controllers/admin/spam_logs_controller.rb @@ -5,7 +5,9 @@ class Admin::SpamLogsController < Admin::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def index - @spam_logs = SpamLog.preload(user: [:custom_attributes]).order(id: :desc).page(params[:page]).without_count + @spam_logs = SpamLog.preload(user: [:trusted_with_spam_attribute]) + .order(id: :desc) + .page(params[:page]).without_count end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/models/user.rb b/app/models/user.rb index 0146162f56645a..5d0a065fd3dedf 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -249,6 +249,7 @@ def update_tracked_fields!(request) has_many :bulk_imports has_many :custom_attributes, class_name: 'UserCustomAttribute' + has_one :trusted_with_spam_attribute, -> { UserCustomAttribute.trusted_with_spam }, class_name: 'UserCustomAttribute' has_many :callouts, class_name: 'Users::Callout' has_many :group_callouts, class_name: 'Users::GroupCallout' has_many :project_callouts, class_name: 'Users::ProjectCallout' @@ -2202,7 +2203,7 @@ def abuse_metadata end def trusted? - custom_attributes.trusted_with_spam.present? + trusted_with_spam_attribute.present? end def namespace_commit_email_for_namespace(namespace) -- GitLab From a51a900103e200fc996db23967af3cb8606831d5 Mon Sep 17 00:00:00 2001 From: Ethan Urie Date: Thu, 3 Aug 2023 14:05:56 -0400 Subject: [PATCH 13/18] Fix bad method call in SpamVerdictService --- app/services/spam/spam_verdict_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb index ce5c5201b41921..2d4bebc8b2bb60 100644 --- a/app/services/spam/spam_verdict_service.rb +++ b/app/services/spam/spam_verdict_service.rb @@ -90,7 +90,7 @@ def override_via_allow_possible_spam?(verdict:) end def allow_possible_spam? - target.allow_possible_spam? || user.trusted? + target.allow_possible_spam?(user) || user.trusted? end def spamcheck_client -- GitLab From bfcfa4f16777d212f2bcd702a73d47c411ebf6bc Mon Sep 17 00:00:00 2001 From: Ethan Urie Date: Thu, 3 Aug 2023 14:28:29 -0400 Subject: [PATCH 14/18] Fix fixture for user action helper --- .../entities/admin_users_data_attributes_paths.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/spec/fixtures/api/schemas/entities/admin_users_data_attributes_paths.json b/spec/fixtures/api/schemas/entities/admin_users_data_attributes_paths.json index 44d8e48a972c48..1fdd5b1323317a 100644 --- a/spec/fixtures/api/schemas/entities/admin_users_data_attributes_paths.json +++ b/spec/fixtures/api/schemas/entities/admin_users_data_attributes_paths.json @@ -13,7 +13,9 @@ "delete_with_contributions": { "type": "string" }, "admin_user": { "type": "string" }, "ban": { "type": "string" }, - "unban": { "type": "string" } + "unban": { "type": "string" }, + "trust": { "type": "string" }, + "untrust": { "type": "string" } }, "required": [ "edit", @@ -28,7 +30,9 @@ "delete_with_contributions", "admin_user", "ban", - "unban" + "unban", + "trust", + "untrust" ], "additionalProperties": false } -- GitLab From 3c5b9202fb4e88ab5182bfca06a4ebf1106f49f4 Mon Sep 17 00:00:00 2001 From: Ethan Urie Date: Thu, 3 Aug 2023 14:32:23 -0400 Subject: [PATCH 15/18] Fix fixture for user action helper --- .../admin_users_data_attributes_paths.json | 60 ++++++++++++++----- 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/spec/fixtures/api/schemas/entities/admin_users_data_attributes_paths.json b/spec/fixtures/api/schemas/entities/admin_users_data_attributes_paths.json index 1fdd5b1323317a..61472b273e1342 100644 --- a/spec/fixtures/api/schemas/entities/admin_users_data_attributes_paths.json +++ b/spec/fixtures/api/schemas/entities/admin_users_data_attributes_paths.json @@ -1,21 +1,51 @@ { "type": "object", "properties": { - "edit": { "type": "string" }, - "approve": { "type": "string" }, - "reject": { "type": "string" }, - "unblock": { "type": "string" }, - "block": { "type": "string" }, - "deactivate": { "type": "string" }, - "activate": { "type": "string" }, - "unlock": { "type": "string" }, - "delete": { "type": "string" }, - "delete_with_contributions": { "type": "string" }, - "admin_user": { "type": "string" }, - "ban": { "type": "string" }, - "unban": { "type": "string" }, - "trust": { "type": "string" }, - "untrust": { "type": "string" } + "edit": { + "type": "string" + }, + "approve": { + "type": "string" + }, + "reject": { + "type": "string" + }, + "unblock": { + "type": "string" + }, + "block": { + "type": "string" + }, + "deactivate": { + "type": "string" + }, + "activate": { + "type": "string" + }, + "unlock": { + "type": "string" + }, + "delete": { + "type": "string" + }, + "delete_with_contributions": { + "type": "string" + }, + "admin_user": { + "type": "string" + }, + "ban": { + "type": "string" + }, + "unban": { + "type": "string" + }, + "trust": { + "type": "string" + }, + "untrust": { + "type": "string" + } }, "required": [ "edit", -- GitLab From 4fb6d8be0b1b61af10d338c1f2674ebe77946f42 Mon Sep 17 00:00:00 2001 From: Ethan Urie Date: Thu, 3 Aug 2023 14:45:34 -0400 Subject: [PATCH 16/18] Fix UntrustService spec --- spec/services/users/untrust_service_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/services/users/untrust_service_spec.rb b/spec/services/users/untrust_service_spec.rb index db7e4351d9cda3..bfa81bb233a298 100644 --- a/spec/services/users/untrust_service_spec.rb +++ b/spec/services/users/untrust_service_spec.rb @@ -16,7 +16,7 @@ UserCustomAttribute.upsert_custom_attributes( [{ user_id: user.id, - key: :allow_possible_spam, + key: UserCustomAttribute::TRUSTED_BY, value: 'not important' }] ) -- GitLab From 66b5d4e55c8b12598733bbcb40088a79f90f621f Mon Sep 17 00:00:00 2001 From: Ethan Urie Date: Thu, 3 Aug 2023 15:52:08 -0400 Subject: [PATCH 17/18] Renamed vue component to comply with eslint rules --- .../javascripts/admin/users/components/actions/index.js | 4 ++-- .../users/components/actions/{trust.vue => trust_user.vue} | 0 .../components/actions/{untrust.vue => untrust_user.vue} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename app/assets/javascripts/admin/users/components/actions/{trust.vue => trust_user.vue} (100%) rename app/assets/javascripts/admin/users/components/actions/{untrust.vue => untrust_user.vue} (100%) diff --git a/app/assets/javascripts/admin/users/components/actions/index.js b/app/assets/javascripts/admin/users/components/actions/index.js index 373d47d29d9c94..633bc4d8b15c32 100644 --- a/app/assets/javascripts/admin/users/components/actions/index.js +++ b/app/assets/javascripts/admin/users/components/actions/index.js @@ -9,8 +9,8 @@ import Reject from './reject.vue'; import Unban from './unban.vue'; import Unblock from './unblock.vue'; import Unlock from './unlock.vue'; -import Trust from './trust.vue'; -import Untrust from './untrust.vue'; +import Trust from './trust_user.vue'; +import Untrust from './untrust_user.vue'; export default { Activate, diff --git a/app/assets/javascripts/admin/users/components/actions/trust.vue b/app/assets/javascripts/admin/users/components/actions/trust_user.vue similarity index 100% rename from app/assets/javascripts/admin/users/components/actions/trust.vue rename to app/assets/javascripts/admin/users/components/actions/trust_user.vue diff --git a/app/assets/javascripts/admin/users/components/actions/untrust.vue b/app/assets/javascripts/admin/users/components/actions/untrust_user.vue similarity index 100% rename from app/assets/javascripts/admin/users/components/actions/untrust.vue rename to app/assets/javascripts/admin/users/components/actions/untrust_user.vue -- GitLab From 8e7751e2edeb0de536226368a11dbe0e4b5faaf9 Mon Sep 17 00:00:00 2001 From: Ethan Urie Date: Mon, 21 Aug 2023 09:30:29 -0400 Subject: [PATCH 18/18] Adding specs for N+1 queries. --- .../admin/abuse_report/constants.js | 2 + app/controllers/admin/users_controller.rb | 2 +- .../abuse_report_events_helper.rb | 4 ++ .../resource_events/abuse_report_event.rb | 7 +++- app/models/user_custom_attribute.rb | 12 ++++++ .../abuse_reports/moderate_user_service.rb | 5 +++ app/services/users/trust_service.rb | 7 +--- locale/gitlab.pot | 24 ++++++++++++ .../admin/admin_browse_spam_logs_spec.rb | 1 + spec/features/admin/admin_users_spec.rb | 8 ++++ .../moderate_user_service_spec.rb | 37 +++++++++++++++++++ 11 files changed, 100 insertions(+), 9 deletions(-) diff --git a/app/assets/javascripts/admin/abuse_report/constants.js b/app/assets/javascripts/admin/abuse_report/constants.js index 6cae6b24f20a04..62561630b18821 100644 --- a/app/assets/javascripts/admin/abuse_report/constants.js +++ b/app/assets/javascripts/admin/abuse_report/constants.js @@ -30,6 +30,7 @@ export const USER_ACTION_OPTIONS = [ NO_ACTION, { value: 'block_user', text: s__('AbuseReport|Block user') }, { value: 'ban_user', text: s__('AbuseReport|Ban user') }, + { value: 'trust_user', text: s__('AbuseReport|Trust user') }, { value: 'delete_user', text: s__('AbuseReport|Delete user') }, ]; @@ -48,6 +49,7 @@ export const REASON_OPTIONS = [ text: s__('AbuseReport|Confirmed violation of a copyright or a trademark'), }, { value: 'malware', text: s__('AbuseReport|Confirmed posting of malware') }, + { value: 'trusted', text: s__(`AbuseReport|Confirmed trusted user`) }, { value: 'other', text: s__('AbuseReport|Something else') }, { value: 'unconfirmed', text: s__('AbuseReport|Abuse unconfirmed') }, ]; diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 9bb0d902119f26..ae6311c4d09f0d 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -310,7 +310,7 @@ def paginate_without_count? end def users_with_included_associations(users) - users.includes(:authorized_projects) # rubocop: disable CodeReuse/ActiveRecord + users.includes(:authorized_projects, :trusted_with_spam_attribute) # rubocop: disable CodeReuse/ActiveRecord end def admin_making_changes_for_another_user? diff --git a/app/helpers/resource_events/abuse_report_events_helper.rb b/app/helpers/resource_events/abuse_report_events_helper.rb index 8adbc891184002..207ec73454b2ad 100644 --- a/app/helpers/resource_events/abuse_report_events_helper.rb +++ b/app/helpers/resource_events/abuse_report_events_helper.rb @@ -10,6 +10,8 @@ def success_message_for_action(action) s_('AbuseReportEvent|Successfully blocked the user') when 'delete_user' s_('AbuseReportEvent|Successfully scheduled the user for deletion') + when 'trust_user' + s_('AbuseReportEvent|Successfully trusted the user') when 'close_report' s_('AbuseReportEvent|Successfully closed the report') when 'ban_user_and_close_report' @@ -18,6 +20,8 @@ def success_message_for_action(action) s_('AbuseReportEvent|Successfully blocked the user and closed the report') when 'delete_user_and_close_report' s_('AbuseReportEvent|Successfully scheduled the user for deletion and closed the report') + when 'trust_user_and_close_report' + s_('AbuseReportEvent|Successfully trusted the user and closed the report') end end end diff --git a/app/models/resource_events/abuse_report_event.rb b/app/models/resource_events/abuse_report_event.rb index 59f88a63998eec..5881f87241d8a8 100644 --- a/app/models/resource_events/abuse_report_event.rb +++ b/app/models/resource_events/abuse_report_event.rb @@ -16,7 +16,9 @@ class AbuseReportEvent < ApplicationRecord close_report: 4, ban_user_and_close_report: 5, block_user_and_close_report: 6, - delete_user_and_close_report: 7 + delete_user_and_close_report: 7, + trust_user: 8, + trust_user_and_close_report: 9 } enum reason: { @@ -28,7 +30,8 @@ class AbuseReportEvent < ApplicationRecord copyright: 6, malware: 7, other: 8, - unconfirmed: 9 + unconfirmed: 9, + trusted: 10 } def success_message diff --git a/app/models/user_custom_attribute.rb b/app/models/user_custom_attribute.rb index 67c9abbf269f86..2dee3a25994309 100644 --- a/app/models/user_custom_attribute.rb +++ b/app/models/user_custom_attribute.rb @@ -46,6 +46,18 @@ def set_banned_by_abuse_report(abuse_report) upsert_custom_attributes([custom_attribute]) end + def set_trusted_by(user:, trusted_by:) + return unless user && trusted_by + + custom_attribute = { + user_id: user.id, + key: UserCustomAttribute::TRUSTED_BY, + value: "#{trusted_by.username}/#{trusted_by.id}+#{Time.current}" + } + + upsert_custom_attributes([custom_attribute]) + end + private def blocked_users diff --git a/app/services/admin/abuse_reports/moderate_user_service.rb b/app/services/admin/abuse_reports/moderate_user_service.rb index da61a4dc8f60be..9a214425c41581 100644 --- a/app/services/admin/abuse_reports/moderate_user_service.rb +++ b/app/services/admin/abuse_reports/moderate_user_service.rb @@ -42,6 +42,7 @@ def perform_action when :block_user then block_user when :delete_user then delete_user when :close_report then close_report + when :trust_user then trust_user end end @@ -65,6 +66,10 @@ def close_report success end + def trust_user + Users::TrustService.new(current_user).execute(abuse_report.user) + end + def close_report_and_record_event event = action diff --git a/app/services/users/trust_service.rb b/app/services/users/trust_service.rb index b9d45fa9c31f0f..faf0b9c40eabc0 100644 --- a/app/services/users/trust_service.rb +++ b/app/services/users/trust_service.rb @@ -7,12 +7,7 @@ def initialize(current_user) end def execute(user) - custom_attribute = { - user_id: user.id, - key: UserCustomAttribute::TRUSTED_BY, - value: "#{current_user.username}/#{current_user.id}+#{Time.current}" - } - UserCustomAttribute.upsert_custom_attributes([custom_attribute]) + UserCustomAttribute.set_trusted_by(user: user, trusted_by: @current_user) success end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 5f4ed4dbccba16..adf7ce7fe4b328 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2233,6 +2233,12 @@ msgstr "" msgid "AbuseReportEvent|Successfully scheduled the user for deletion and closed the report" msgstr "" +msgid "AbuseReportEvent|Successfully trusted the user" +msgstr "" + +msgid "AbuseReportEvent|Successfully trusted the user and closed the report" +msgstr "" + msgid "AbuseReports|%{reportedUser} reported for %{category} by %{count} users" msgstr "" @@ -2302,6 +2308,9 @@ msgstr "" msgid "AbuseReport|Confirmed spam" msgstr "" +msgid "AbuseReport|Confirmed trusted user" +msgstr "" + msgid "AbuseReport|Confirmed violation of a copyright or a trademark" msgstr "" @@ -2416,6 +2425,9 @@ msgstr "" msgid "AbuseReport|Tier" msgstr "" +msgid "AbuseReport|Trust user" +msgstr "" + msgid "AbuseReport|Verification" msgstr "" @@ -27354,6 +27366,9 @@ msgstr "" msgid "Join your team on GitLab and contribute to an existing project" msgstr "" +msgid "Joined %{time_ago}" +msgstr "" + msgid "Joined %{user_created_time}" msgstr "" @@ -39267,6 +39282,9 @@ msgstr "" msgid "Remove priority" msgstr "" +msgid "Remove report" +msgstr "" + msgid "Remove reviewer" msgstr "" @@ -39291,6 +39309,9 @@ msgstr "" msgid "Remove user" msgstr "" +msgid "Remove user & report" +msgstr "" + msgid "Remove user from group" msgstr "" @@ -39570,6 +39591,9 @@ msgstr "" msgid "Reported %{timeAgo} by %{reportedBy}" msgstr "" +msgid "Reported by %{reporter}" +msgstr "" + msgid "Reporter" msgstr "" diff --git a/spec/features/admin/admin_browse_spam_logs_spec.rb b/spec/features/admin/admin_browse_spam_logs_spec.rb index c272a8630b76de..9fab4f545c292b 100644 --- a/spec/features/admin/admin_browse_spam_logs_spec.rb +++ b/spec/features/admin/admin_browse_spam_logs_spec.rb @@ -22,6 +22,7 @@ expect(page).to have_content("#{spam_log.description[0...97]}...") expect(page).to have_link('Remove user') expect(page).to have_link('Block user') + expect(page).to have_link('Trust user') end it 'does not perform N+1 queries' do diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb index ca08bc9e577da3..9ab5b1fd3bb875 100644 --- a/spec/features/admin/admin_users_spec.rb +++ b/spec/features/admin/admin_users_spec.rb @@ -82,4 +82,12 @@ end end end + + it 'does not perform N+1 queries' do + control_queries = ActiveRecord::QueryRecorder.new { visit admin_users_path } + + expect { create(:user) }.to change { User.count }.by(1) + + expect { visit admin_users_path }.not_to exceed_query_limit(control_queries) + end end diff --git a/spec/services/admin/abuse_reports/moderate_user_service_spec.rb b/spec/services/admin/abuse_reports/moderate_user_service_spec.rb index 6e8a59f4e49f59..08fbd734c9fc17 100644 --- a/spec/services/admin/abuse_reports/moderate_user_service_spec.rb +++ b/spec/services/admin/abuse_reports/moderate_user_service_spec.rb @@ -193,6 +193,43 @@ end end + describe 'when trusting the user' do + let(:action) { 'trust_user' } + + it 'calls the Users::TrustService method' do + expect_next_instance_of(Users::TrustService, admin) do |service| + expect(service).to receive(:execute).with(abuse_report.user).and_return(status: :success) + end + + subject + end + + context 'when not closing the report' do + let(:close) { false } + + it_behaves_like 'does not close the report' + it_behaves_like 'records an event', action: 'trust_user' + end + + context 'when closing the report' do + it_behaves_like 'closes the report' + it_behaves_like 'records an event', action: 'trust_user_and_close_report' + end + + context 'when trusting the user fails' do + before do + allow_next_instance_of(Users::TrustService) do |service| + allow(service).to receive(:execute).with(abuse_report.user) + .and_return(status: :error, message: 'Trusting the user failed') + end + end + + it_behaves_like 'returns an error response', 'Trusting the user failed' + it_behaves_like 'does not close the report' + it_behaves_like 'does not record an event' + end + end + describe 'when only closing the report' do let(:action) { '' } -- GitLab