diff options
Diffstat (limited to 'app/services/users')
7 files changed, 299 insertions, 17 deletions
diff --git a/app/services/users/authorized_build_service.rb b/app/services/users/authorized_build_service.rb index eb2386198d3..5029105b087 100644 --- a/app/services/users/authorized_build_service.rb +++ b/app/services/users/authorized_build_service.rb @@ -16,3 +16,5 @@ module Users end end end + +Users::AuthorizedBuildService.prepend_mod_with('Users::AuthorizedBuildService') diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb index dfa9316889e..a378cb09854 100644 --- a/app/services/users/destroy_service.rb +++ b/app/services/users/destroy_service.rb @@ -23,6 +23,11 @@ module Users # `hard_delete: true` implies `delete_solo_owned_groups: true`. To perform # a hard deletion without destroying solo-owned groups, pass # `delete_solo_owned_groups: false, hard_delete: true` in +options+. + # + # To make the service asynchronous, a new behaviour is being introduced + # behind the user_destroy_with_limited_execution_time_worker feature flag. + # Migrating the associated user records, and post-migration cleanup is + # handled by the Users::MigrateRecordsToGhostUserWorker cron worker. def execute(user, options = {}) delete_solo_owned_groups = options.fetch(:delete_solo_owned_groups, options[:hard_delete]) @@ -35,12 +40,14 @@ module Users return user end - # Calling all before/after_destroy hooks for the user because - # there is no dependent: destroy in the relationship. And the removal - # is done by a foreign_key. Otherwise they won't be called - user.members.find_each { |member| member.run_callbacks(:destroy) } + user.block + + # Load the records. Groups are unavailable after membership is destroyed. + solo_owned_groups = user.solo_owned_groups.load + + user.members.each_batch { |batch| batch.destroy_all } # rubocop:disable Style/SymbolProc, Cop/DestroyAll - user.solo_owned_groups.each do |group| + solo_owned_groups.each do |group| Groups::DestroyService.new(group, current_user).execute end @@ -54,22 +61,32 @@ module Users yield(user) if block_given? - MigrateToGhostUserService.new(user).execute(hard_delete: options[:hard_delete]) + hard_delete = options.fetch(:hard_delete, false) - response = Snippets::BulkDestroyService.new(current_user, user.snippets).execute(options) - raise DestroyError, response.message if response.error? + if Feature.enabled?(:user_destroy_with_limited_execution_time_worker) + Users::GhostUserMigration.create!(user: user, + initiator_user: current_user, + hard_delete: hard_delete) - # Rails attempts to load all related records into memory before - # destroying: https://github.com/rails/rails/issues/22510 - # This ensures we delete records in batches. - user.destroy_dependent_associations_in_batches(exclude: [:snippets]) - user.nullify_dependent_associations_in_batches + else + MigrateToGhostUserService.new(user).execute(hard_delete: options[:hard_delete]) - # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing - user_data = user.destroy - namespace.destroy + response = Snippets::BulkDestroyService.new(current_user, user.snippets) + .execute(skip_authorization: hard_delete) + raise DestroyError, response.message if response.error? - user_data + # Rails attempts to load all related records into memory before + # destroying: https://github.com/rails/rails/issues/22510 + # This ensures we delete records in batches. + user.destroy_dependent_associations_in_batches(exclude: [:snippets]) + user.nullify_dependent_associations_in_batches + + # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing + user_data = user.destroy + namespace.destroy + + user_data + end end end end diff --git a/app/services/users/email_verification/base_service.rb b/app/services/users/email_verification/base_service.rb new file mode 100644 index 00000000000..3337beec195 --- /dev/null +++ b/app/services/users/email_verification/base_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Users + module EmailVerification + class BaseService + VALID_ATTRS = %i[unlock_token confirmation_token].freeze + + def initialize(attr:) + @attr = attr + + validate_attr! + end + + protected + + attr_reader :attr, :token + + def validate_attr! + raise ArgumentError, 'Invalid attribute' unless attr.in?(VALID_ATTRS) + end + + def digest + Devise.token_generator.digest(User, attr, token) + end + end + end +end diff --git a/app/services/users/email_verification/generate_token_service.rb b/app/services/users/email_verification/generate_token_service.rb new file mode 100644 index 00000000000..6f0237ce244 --- /dev/null +++ b/app/services/users/email_verification/generate_token_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Users + module EmailVerification + class GenerateTokenService < EmailVerification::BaseService + TOKEN_LENGTH = 6 + + def execute + @token = generate_token + + [token, digest] + end + + private + + def generate_token + SecureRandom.random_number(10**TOKEN_LENGTH).to_s.rjust(TOKEN_LENGTH, '0') + end + end + end +end diff --git a/app/services/users/email_verification/validate_token_service.rb b/app/services/users/email_verification/validate_token_service.rb new file mode 100644 index 00000000000..b1b34e94f49 --- /dev/null +++ b/app/services/users/email_verification/validate_token_service.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Users + module EmailVerification + class ValidateTokenService < EmailVerification::BaseService + include ActionView::Helpers::DateHelper + + TOKEN_VALID_FOR_MINUTES = 60 + + def initialize(attr:, user:, token:) + super(attr: attr) + + @user = user + @token = token + end + + def execute + return failure(:rate_limited) if verification_rate_limited? + return failure(:invalid) unless valid? + return failure(:expired) if expired_token? + + success + end + + private + + attr_reader :user + + def verification_rate_limited? + Gitlab::ApplicationRateLimiter.throttled?(:email_verification, scope: user[attr]) + end + + def valid? + return false unless token.present? + + Devise.secure_compare(user[attr], digest) + end + + def expired_token? + generated_at = case attr + when :unlock_token then user.locked_at + when :confirmation_token then user.confirmation_sent_at + end + + generated_at < TOKEN_VALID_FOR_MINUTES.minutes.ago + end + + def success + { status: :success } + end + + def failure(reason) + { + status: :failure, + reason: reason, + message: failure_message(reason) + } + end + + def failure_message(reason) + case reason + when :rate_limited + format(s_("IdentityVerification|You've reached the maximum amount of tries. "\ + 'Wait %{interval} or send a new code and try again.'), interval: email_verification_interval) + when :expired + s_('IdentityVerification|The code has expired. Send a new code and try again.') + when :invalid + s_('IdentityVerification|The code is incorrect. Enter it again, or send a new code.') + end + end + + def email_verification_interval + interval_in_seconds = Gitlab::ApplicationRateLimiter.rate_limits[:email_verification][:interval] + distance_of_time_in_words(interval_in_seconds) + end + end + end +end diff --git a/app/services/users/migrate_records_to_ghost_user_in_batches_service.rb b/app/services/users/migrate_records_to_ghost_user_in_batches_service.rb new file mode 100644 index 00000000000..7c4a5698ea9 --- /dev/null +++ b/app/services/users/migrate_records_to_ghost_user_in_batches_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Users + class MigrateRecordsToGhostUserInBatchesService + def initialize + @execution_tracker = Gitlab::Utils::ExecutionTracker.new + end + + def execute + Users::GhostUserMigration.find_each do |user_to_migrate| + break if execution_tracker.over_limit? + + service = Users::MigrateRecordsToGhostUserService.new(user_to_migrate.user, + user_to_migrate.initiator_user, + execution_tracker) + service.execute(hard_delete: user_to_migrate.hard_delete) + end + rescue Gitlab::Utils::ExecutionTracker::ExecutionTimeOutError + # no-op + end + + private + + attr_reader :execution_tracker + end +end diff --git a/app/services/users/migrate_records_to_ghost_user_service.rb b/app/services/users/migrate_records_to_ghost_user_service.rb new file mode 100644 index 00000000000..2d92aaed7da --- /dev/null +++ b/app/services/users/migrate_records_to_ghost_user_service.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +# When a user is destroyed, some of their associated records are +# moved to a "Ghost User", to prevent these associated records from +# being destroyed. +# +# For example, all the issues/MRs a user has created are _not_ destroyed +# when the user is destroyed. +module Users + class MigrateRecordsToGhostUserService + extend ActiveSupport::Concern + + DestroyError = Class.new(StandardError) + + attr_reader :ghost_user, :user, :initiator_user, :hard_delete + + def initialize(user, initiator_user, execution_tracker) + @user = user + @initiator_user = initiator_user + @execution_tracker = execution_tracker + @ghost_user = User.ghost + end + + def execute(hard_delete: false) + @hard_delete = hard_delete + + migrate_records + post_migrate_records + end + + private + + attr_reader :execution_tracker + + def migrate_records + return if hard_delete + + migrate_issues + migrate_merge_requests + migrate_notes + migrate_abuse_reports + migrate_award_emoji + migrate_snippets + migrate_reviews + end + + def post_migrate_records + delete_snippets + + # Rails attempts to load all related records into memory before + # destroying: https://github.com/rails/rails/issues/22510 + # This ensures we delete records in batches. + user.destroy_dependent_associations_in_batches(exclude: [:snippets]) + user.nullify_dependent_associations_in_batches + + # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing + user_data = user.destroy + user.namespace.destroy + + user_data + end + + def delete_snippets + response = Snippets::BulkDestroyService.new(initiator_user, user.snippets).execute(skip_authorization: true) + raise DestroyError, response.message if response.error? + end + + def migrate_issues + batched_migrate(Issue, :author_id) + batched_migrate(Issue, :last_edited_by_id) + end + + def migrate_merge_requests + batched_migrate(MergeRequest, :author_id) + batched_migrate(MergeRequest, :merge_user_id) + end + + def migrate_notes + batched_migrate(Note, :author_id) + end + + def migrate_abuse_reports + user.reported_abuse_reports.update_all(reporter_id: ghost_user.id) + end + + def migrate_award_emoji + user.award_emoji.update_all(user_id: ghost_user.id) + end + + def migrate_snippets + snippets = user.snippets.only_project_snippets + snippets.update_all(author_id: ghost_user.id) + end + + def migrate_reviews + batched_migrate(Review, :author_id) + end + + # rubocop:disable CodeReuse/ActiveRecord + def batched_migrate(base_scope, column, batch_size: 50) + loop do + update_count = base_scope.where(column => user.id).limit(batch_size).update_all(column => ghost_user.id) + break if update_count == 0 + raise Gitlab::Utils::ExecutionTracker::ExecutionTimeOutError if execution_tracker.over_limit? + end + end + # rubocop:enable CodeReuse/ActiveRecord + end +end + +Users::MigrateRecordsToGhostUserService.prepend_mod_with('Users::MigrateRecordsToGhostUserService') |