diff --git a/ee/app/models/cloud_connector/keys.rb b/ee/app/models/cloud_connector/keys.rb index d13f3be50b58e08b452d448c18306d6d67b0a0b6..e9de0b987c146475c9f03dd28917c9e92f5ede91 100644 --- a/ee/app/models/cloud_connector/keys.rb +++ b/ee/app/models/cloud_connector/keys.rb @@ -8,21 +8,59 @@ class Keys < ApplicationRecord validates :secret_key, rsa_key: true, allow_nil: true scope :valid, -> { where.not(secret_key: nil) } + scope :ordered_by_date, -> { valid.order(created_at: :asc) } class << self def current - valid.order(created_at: :asc).first + ordered_by_date.first end def current_as_jwk - current&.secret_key&.then do |key_data| - ::JWT::JWK.new(OpenSSL::PKey::RSA.new(key_data), kid_generator: ::JWT::JWK::Thumbprint) - end + current&.secret_key&.then { |keydata| pem_to_jwk(keydata) } end def all_as_pem valid.map(&:secret_key) end + + def create_new_key! + create!(secret_key: new_private_key.to_pem) + end + + def rotate! + keys = ordered_by_date + key_count = keys.count + + raise "Key rotation requires exactly 2 keys, found #{key_count}" if key_count != 2 + + current_key, next_key = *keys + + transaction do + current_key_data = current_key.secret_key + current_key.update!(secret_key: next_key.secret_key) + next_key.update!(secret_key: current_key_data) + end + end + + def trim! + raise 'Refusing to remove single key, as it is in use' if valid.count == 1 + + ordered_by_date.last&.destroy + end + + private + + def new_private_key + OpenSSL::PKey::RSA.new(2048) + end + + def pem_to_jwk(key_data) + ::JWT::JWK.new(OpenSSL::PKey::RSA.new(key_data), kid_generator: ::JWT::JWK::Thumbprint) + end + end + + def truncated_pem + secret_key&.truncate(90) end end end diff --git a/ee/lib/tasks/cloud_connector/keys.rake b/ee/lib/tasks/cloud_connector/keys.rake new file mode 100644 index 0000000000000000000000000000000000000000..265a47cd97aee5b87ff4e7cf8e25303f1953fa88 --- /dev/null +++ b/ee/lib/tasks/cloud_connector/keys.rake @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +namespace :cloud_connector do + namespace :keys do + desc 'GitLab | Cloud Connector | List private keys in PEM format' + task :list, [:truncate] => :environment do |_, args| + truncate_keys = Gitlab::Utils.to_boolean(args[:truncate], default: true) + keys = truncate_keys ? CloudConnector::Keys.valid.map(&:truncated_pem) : CloudConnector::Keys.all_as_pem + + puts <<~TXT.strip + ================================================================================ + #{keys.any? ? keys.join("\n") : 'No keys found.'} + ================================================================================ + TXT + rescue StandardError => e + exit_with_message(e.message) + end + + desc 'GitLab | Cloud Connector | Create and store a new private key' + task create: :environment do + key = CloudConnector::Keys.create_new_key! + + puts <<~TXT.strip + ================================================================================ + Key created: #{key.truncated_pem} + + This key will now be published via /oauth/discovery/keys. + + If an older key existed prior to creation, run + `bundle exec rake cloud_connector:keys:rotate` to use it for signing tokens. + ================================================================================ + TXT + rescue StandardError => e + exit_with_message(e.message) + end + + desc 'GitLab | Cloud Connector | Performs key rotation by swapping two keys' + task rotate: :environment do + CloudConnector::Keys.rotate! + + puts <<~TXT.strip + ================================================================================ + Keys swapped successfully. + + Run this task again to revert to using the previous key. + + If the key that is now used to sign tokens should remain in use, run + `bundle exec rake cloud_connector:keys:trim` to remove the old key. + ================================================================================ + TXT + rescue StandardError => e + exit_with_message(e.message) + end + + desc 'GitLab | Cloud Connector | Removes the newest key' + task trim: :environment do + key = CloudConnector::Keys.trim! + + puts <<~TXT.strip + ================================================================================ + Key removed: #{key.truncated_pem} + ================================================================================ + TXT + rescue StandardError => e + exit_with_message(e.message) + end + + private + + def exit_with_message(message) + puts <<~TXT.strip + ================================================================================ + ERROR: #{message} + ================================================================================ + TXT + + exit(1) + end + end +end diff --git a/ee/spec/models/cloud_connector/keys_spec.rb b/ee/spec/models/cloud_connector/keys_spec.rb index f5b025e9d00b905b86b507483a74d0660eba3bd6..bbe49017a81494a33d4587976f8b7d288bb201f2 100644 --- a/ee/spec/models/cloud_connector/keys_spec.rb +++ b/ee/spec/models/cloud_connector/keys_spec.rb @@ -44,13 +44,22 @@ end describe '.valid' do - subject(:jwks) { described_class.valid } + subject(:keys) { described_class.valid } include_examples 'serving valid keys' end + describe '.ordered_by_date' do + subject(:keys) { described_class.ordered_by_date } + + let_it_be(:first_key) { create(:cloud_connector_keys, created_at: Time.current - 1.minute) } + let_it_be(:second_key) { create(:cloud_connector_keys) } + + it { is_expected.to eq([first_key, second_key]) } + end + describe '.all_as_pem' do - subject(:jwks) { described_class.all_as_pem } + subject(:keys) { described_class.all_as_pem } include_examples 'serving valid keys' @@ -132,4 +141,70 @@ end end end + + describe '.create_new_key!' do + it 'creates a new valid key' do + expect { described_class.create_new_key! }.to change { described_class.valid.count }.from(0).to(1) + end + end + + describe '.rotate!' do + subject(:rotate) { described_class.rotate! } + + let_it_be(:first_key) { create(:cloud_connector_keys, created_at: Time.current - 1.minute) } + + context 'when fewer than 2 keys exist' do + it { expect { rotate }.to raise_error(StandardError, 'Key rotation requires exactly 2 keys, found 1') } + end + + context 'when more than 2 keys exist' do + before do + create_list(:cloud_connector_keys, 2) + end + + it { expect { rotate }.to raise_error(StandardError, 'Key rotation requires exactly 2 keys, found 3') } + end + + context 'when exactly 2 keys exist' do + let_it_be(:second_key) { create(:cloud_connector_keys) } + + it 'swaps the keys' do + expect { rotate }.to change { described_class.valid.pluck(:secret_key) } + .from([first_key.secret_key, second_key.secret_key]) + .to([second_key.secret_key, first_key.secret_key]) + end + end + end + + describe '.trim!' do + subject(:trimmed_key) { described_class.trim! } + + context 'with no keys' do + it { is_expected.to be_nil } + end + + context 'with a single key' do + let_it_be(:key) { create(:cloud_connector_keys) } + + it { expect { trimmed_key }.to raise_error(StandardError, 'Refusing to remove single key, as it is in use') } + end + + context 'with more than 1 key' do + let_it_be(:first_key) { create(:cloud_connector_keys, created_at: Time.current - 1.minute) } + let_it_be(:second_key) { create(:cloud_connector_keys) } + + it 'removes the newest key' do + expect(trimmed_key).to eq(second_key) + expect(described_class.all).to contain_exactly(first_key) + end + end + end + + describe '#truncated_pem' do + subject(:short_pem) { create(:cloud_connector_keys).truncated_pem } + + it 'truncates the PEM string to 90 characters' do + expect(short_pem.length).to eq(90) + end + end end diff --git a/ee/spec/tasks/cloud_connector/keys_rake_spec.rb b/ee/spec/tasks/cloud_connector/keys_rake_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..2dbfb387c5c1a241fe4d428e645bc266af6333a0 --- /dev/null +++ b/ee/spec/tasks/cloud_connector/keys_rake_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'cloud_connector:keys', :silence_stdout, feature_category: :cloud_connector do + include RakeHelpers + + let(:key) { build(:cloud_connector_keys) } + + before do + Rake.application.rake_require('ee/lib/tasks/cloud_connector/keys', [Rails.root.to_s]) + end + + shared_examples 'handles errors' do + it { expect { task }.to raise_error(SystemExit) } + end + + describe 'list' do + subject(:task) { run_rake_task('cloud_connector:keys:list', args) } + + let(:args) { [] } + + before do + allow(CloudConnector::Keys).to receive(:valid).and_return([key]) + end + + context 'without arguments' do + it 'lists PEM private keys in truncated form' do + expect(key).to receive(:truncated_pem).and_return('ABC...') + + expect { task }.to output(/ABC.../).to_stdout + end + end + + context 'with truncate: false' do + let(:args) { ['false'] } + + it 'lists full PEM private keys' do + expect(key).to receive(:secret_key).and_return('ABCDEF') + + expect { task }.to output(/ABCDEF/).to_stdout + end + end + + context 'when an error occurs' do + before do + allow(CloudConnector::Keys).to receive(:valid).and_raise(StandardError) + end + + include_examples 'handles errors' + end + end + + describe 'create' do + subject(:task) { run_rake_task('cloud_connector:keys:create') } + + it 'creates a new key' do + expect(CloudConnector::Keys).to receive(:create_new_key!).and_return(key) + expect(key).to receive(:truncated_pem).and_return('ABCDEF') + + expect { task }.to output(/Key created: ABCDEF/).to_stdout + end + + context 'when an error occurs' do + before do + allow(CloudConnector::Keys).to receive(:create_new_key!).and_raise(StandardError) + end + + include_examples 'handles errors' + end + end + + describe 'rotate' do + subject(:task) { run_rake_task('cloud_connector:keys:rotate') } + + it 'rotates keys' do + expect(CloudConnector::Keys).to receive(:rotate!) + + expect { task }.to output(/Keys swapped successfully/).to_stdout + end + + context 'when an error occurs' do + before do + allow(CloudConnector::Keys).to receive(:rotate!).and_raise(StandardError) + end + + include_examples 'handles errors' + end + end + + describe 'trim' do + subject(:task) { run_rake_task('cloud_connector:keys:trim') } + + it 'trims keys' do + expect(CloudConnector::Keys).to receive(:trim!).and_return(key) + expect(key).to receive(:truncated_pem).and_return('ABCDEF') + + expect { task }.to output(/Key removed: ABCDEF/).to_stdout + end + + context 'when an error occurs' do + before do + allow(CloudConnector::Keys).to receive(:trim!).and_raise(StandardError) + end + + include_examples 'handles errors' + end + end +end