From d76a302def4dd678b99389e286d15aebde3d9c62 Mon Sep 17 00:00:00 2001 From: Vladimir Shushlin <vshushlin@gitlab.com> Date: Mon, 2 Oct 2023 15:46:13 +0000 Subject: [PATCH] Add rake task for resetting unreadable encrypted tokens We have runners_tokens in DB that can not be decrypted. Automatically regenerating them is dangerous, so we provide admins with the rake task to do that. --- lib/gitlab/doctor/reset_tokens.rb | 66 +++++++++ lib/tasks/gitlab/doctor/secrets.rake | 16 +++ spec/lib/gitlab/doctor/reset_tokens_spec.rb | 133 ++++++++++++++++++ spec/tasks/gitlab/doctor/secrets_rake_spec.rb | 56 ++++++++ 4 files changed, 271 insertions(+) create mode 100644 lib/gitlab/doctor/reset_tokens.rb create mode 100644 spec/lib/gitlab/doctor/reset_tokens_spec.rb create mode 100644 spec/tasks/gitlab/doctor/secrets_rake_spec.rb diff --git a/lib/gitlab/doctor/reset_tokens.rb b/lib/gitlab/doctor/reset_tokens.rb new file mode 100644 index 0000000000000..45333e2effb93 --- /dev/null +++ b/lib/gitlab/doctor/reset_tokens.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Gitlab + module Doctor + class ResetTokens + attr_reader :logger + + PRINT_PROGRESS_EVERY = 1000 + + def initialize(logger, model_names:, token_names:, dry_run: true) + @logger = logger + @model_names = model_names + @token_names = token_names + @dry_run = dry_run + end + + def run! + logger.info "Resetting #{@token_names.join(', ')} on #{@model_names.join(', ')} if they can not be read" + logger.info "Executing in DRY RUN mode, no records will actually be updated" if @dry_run + Rails.application.eager_load! + + models_with_encrypted_tokens.each do |model| + fix_model(model) + end + logger.info "Done!" + end + + private + + def fix_model(model) + matched_token_names = @token_names & model.encrypted_token_authenticatable_fields.map(&:to_s) + + return if matched_token_names.empty? + + total_count = model.count + + model.find_each.with_index do |instance, index| + matched_token_names.each do |attribute_name| + fix_attribute(instance, attribute_name) + end + + logger.info "Checked #{index + 1}/#{total_count} #{model.name.pluralize}" if index % PRINT_PROGRESS_EVERY == 0 + end + logger.info "Checked #{total_count} #{model.name.pluralize}" + end + + def fix_attribute(instance, attribute_name) + instance.public_send(attribute_name) # rubocop:disable GitlabSecurity/PublicSend + rescue OpenSSL::Cipher::CipherError, TypeError + logger.debug "> Fix #{instance.class.name}[#{instance.id}].#{attribute_name}" + instance.public_send("reset_#{attribute_name}!") unless @dry_run # rubocop:disable GitlabSecurity/PublicSend + rescue StandardError => e + logger.debug( + "> Something went wrong for #{instance.class.name}[#{instance.id}].#{attribute_name}: #{e}".color(:red)) + + false + end + + def models_with_encrypted_tokens + ApplicationRecord.descendants.select do |model| + @model_names.include?(model.name) && model.include?(TokenAuthenticatable) + end + end + end + end +end diff --git a/lib/tasks/gitlab/doctor/secrets.rake b/lib/tasks/gitlab/doctor/secrets.rake index 29f0f36c705d0..dd005005edf29 100644 --- a/lib/tasks/gitlab/doctor/secrets.rake +++ b/lib/tasks/gitlab/doctor/secrets.rake @@ -10,5 +10,21 @@ namespace :gitlab do Gitlab::Doctor::Secrets.new(logger).run! end + + desc "GitLab | Reset encrypted tokens for specific models" + task reset_encrypted_tokens: :gitlab_environment do + logger = Logger.new($stdout) + + logger.level = Gitlab::Utils.to_boolean(ENV['VERBOSE']) ? Logger::DEBUG : Logger::INFO + model_names = ENV['MODEL_NAMES']&.split(',') + token_names = ENV['TOKEN_NAMES']&.split(',') + dry_run = Gitlab::Utils.to_boolean(ENV['DRY_RUN']) + dry_run = true if dry_run.nil? + + next logger.info("No models were specified, please use MODEL_NAMES environment variable") unless model_names + next logger.info("No tokens were specified, please use TOKEN_NAMES environment variable") unless token_names + + Gitlab::Doctor::ResetTokens.new(logger, model_names: model_names, token_names: token_names, dry_run: dry_run).run! + end end end diff --git a/spec/lib/gitlab/doctor/reset_tokens_spec.rb b/spec/lib/gitlab/doctor/reset_tokens_spec.rb new file mode 100644 index 0000000000000..0cc947efdb479 --- /dev/null +++ b/spec/lib/gitlab/doctor/reset_tokens_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Doctor::ResetTokens, feature_category: :runner_fleet do + let(:logger) { instance_double('Logger') } + let(:model_names) { %w[Project Group] } + let(:token_names) { %w[runners_token] } + let(:dry_run) { false } + let(:doctor) { described_class.new(logger, model_names: model_names, token_names: token_names, dry_run: dry_run) } + + let_it_be(:functional_project) { create(:project).tap(&:runners_token) } + let_it_be(:functional_group) { create(:group).tap(&:runners_token) } + + let(:broken_project) { create(:project).tap { |project| project.update_columns(runners_token_encrypted: 'aaa') } } + let(:project_with_cipher_error) do + create(:project).tap do |project| + project.update_columns( + runners_token_encrypted: '|rXs75DSHXPE9MGAIgyxcut8pZc72gaa/2ojU0GS1+R+cXNqkbUB13Vb5BaMwf47d98980fc1') + end + end + + let(:broken_group) { create(:group, runners_token_encrypted: 'aaa') } + + subject(:run!) do + expect(logger).to receive(:info).with( + "Resetting #{token_names.join(', ')} on #{model_names.join(', ')} if they can not be read" + ) + expect(logger).to receive(:info).with('Done!') + doctor.run! + end + + before do + allow(logger).to receive(:info).with(%r{Checked \d/\d Projects}) + allow(logger).to receive(:info).with(%r{Checked \d Projects}) + allow(logger).to receive(:info).with(%r{Checked \d/\d Groups}) + allow(logger).to receive(:info).with(%r{Checked \d Groups}) + end + + it 'fixes broken project and not the functional project' do + expect(logger).to receive(:debug).with("> Fix Project[#{broken_project.id}].runners_token") + + expect { run! }.to change { broken_project.reload.runners_token_encrypted }.from('aaa') + .and not_change { functional_project.reload.runners_token_encrypted } + expect { broken_project.runners_token }.not_to raise_error + end + + it 'fixes project with cipher error' do + expect { project_with_cipher_error.runners_token }.to raise_error(OpenSSL::Cipher::CipherError) + expect(logger).to receive(:debug).with("> Fix Project[#{project_with_cipher_error.id}].runners_token") + + expect { run! }.to change { project_with_cipher_error.reload.runners_token_encrypted } + expect { project_with_cipher_error.runners_token }.not_to raise_error + end + + it 'fixes broken group and not the functional group' do + expect(logger).to receive(:debug).with("> Fix Group[#{broken_group.id}].runners_token") + + expect { run! }.to change { broken_group.reload.runners_token_encrypted }.from('aaa') + .and not_change { functional_group.reload.runners_token_encrypted } + + expect { broken_group.runners_token }.not_to raise_error + end + + context 'when one model specified' do + let(:model_names) { %w[Project] } + + it 'fixes broken project' do + expect(logger).to receive(:debug).with("> Fix Project[#{broken_project.id}].runners_token") + + expect { run! }.to change { broken_project.reload.runners_token_encrypted }.from('aaa') + expect { broken_project.runners_token }.not_to raise_error + end + + it 'does not fix other models' do + expect { run! }.not_to change { broken_group.reload.runners_token_encrypted }.from('aaa') + end + end + + context 'when non-existing token field is given' do + let(:token_names) { %w[nonexisting_token] } + + it 'does not fix anything' do + expect { run! }.not_to change { broken_project.reload.runners_token_encrypted }.from('aaa') + end + end + + context 'when executing in a dry-run mode' do + let(:dry_run) { true } + + it 'prints info about fixed project, but does not actually do anything' do + expect(logger).to receive(:info).with('Executing in DRY RUN mode, no records will actually be updated') + expect(logger).to receive(:debug).with("> Fix Project[#{broken_project.id}].runners_token") + + expect { run! }.not_to change { broken_project.reload.runners_token_encrypted }.from('aaa') + expect { broken_project.runners_token }.to raise_error(TypeError) + end + end + + it 'prints progress along the way' do + stub_const('Gitlab::Doctor::ResetTokens::PRINT_PROGRESS_EVERY', 1) + + broken_project + project_with_cipher_error + + expect(logger).to receive(:info).with( + "Resetting #{token_names.join(', ')} on #{model_names.join(', ')} if they can not be read" + ) + expect(logger).to receive(:info).with('Checked 1/3 Projects') + expect(logger).to receive(:debug).with("> Fix Project[#{broken_project.id}].runners_token") + expect(logger).to receive(:info).with('Checked 2/3 Projects') + expect(logger).to receive(:debug).with("> Fix Project[#{project_with_cipher_error.id}].runners_token") + expect(logger).to receive(:info).with('Checked 3/3 Projects') + expect(logger).to receive(:info).with('Done!') + + doctor.run! + end + + it "prints 'Something went wrong' error when encounters unexpected exception, but continues" do + broken_project + project_with_cipher_error + + expect(logger).to receive(:debug).with( + "> Something went wrong for Project[#{broken_project.id}].runners_token: Error message") + expect(logger).to receive(:debug).with("> Fix Project[#{project_with_cipher_error.id}].runners_token") + + expect(broken_project).to receive(:runners_token).and_raise("Error message") + expect(Project).to receive(:find_each).and_return([broken_project, project_with_cipher_error].each) + + expect { run! }.to not_change { broken_project.reload.runners_token_encrypted }.from('aaa') + .and change { project_with_cipher_error.reload.runners_token_encrypted } + end +end diff --git a/spec/tasks/gitlab/doctor/secrets_rake_spec.rb b/spec/tasks/gitlab/doctor/secrets_rake_spec.rb new file mode 100644 index 0000000000000..22ecd91e1a0be --- /dev/null +++ b/spec/tasks/gitlab/doctor/secrets_rake_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'rake_helper' + +RSpec.describe 'gitlab:doctor:reset_encrypted_tokens', :silence_stdout, feature_category: :runner_fleet do + let(:model_names) { 'Project,Group' } + let(:token_names) { 'runners_token' } + + let(:project_with_cipher_error) do + create(:project).tap do |project| + project.update_columns(runners_token_encrypted: + '|rXs75DSHXPE9MGAIgyxcut8pZc72gaa/2ojU0GS1+R+cXNqkbUB13Vb5BaMwf47d98980fc1') + end + end + + before(:context) do + Rake.application.rake_require 'tasks/gitlab/doctor/secrets' + end + + before do + stub_env('MODEL_NAMES', model_names) + stub_env('TOKEN_NAMES', token_names) + end + + subject(:run!) do + run_rake_task('gitlab:doctor:reset_encrypted_tokens') + end + + it 'properly parces parameters from the environment variables' do + expect_next_instance_of(::Gitlab::Doctor::ResetTokens, anything, + model_names: %w[Project Group], + token_names: %w[runners_token], + dry_run: true) do |service| + expect(service).to receive(:run!).and_call_original + end + + run! + end + + it "doesn't do anything in DRY_RUN mode(default)" do + expect do + run! + end.not_to change { project_with_cipher_error.reload.runners_token_encrypted } + end + + it 'regenerates broken token if DRY_RUN is set to false' do + stub_env('DRY_RUN', false) + + expect { project_with_cipher_error.runners_token }.to raise_error(OpenSSL::Cipher::CipherError) + expect do + run! + end.to change { project_with_cipher_error.reload.runners_token_encrypted } + + expect { project_with_cipher_error.runners_token }.not_to raise_error + end +end -- GitLab