diff --git a/changelogs/unreleased/ldap-secret-command.yml b/changelogs/unreleased/ldap-secret-command.yml new file mode 100644 index 0000000000000000000000000000000000000000..a1c0617867c41e01396b28c744a96cff5668710a --- /dev/null +++ b/changelogs/unreleased/ldap-secret-command.yml @@ -0,0 +1,5 @@ +--- +title: Add encrypted ldap secrets support +merge_request: 45712 +author: +type: added diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 5db0e22ed1022899b7f239219dfb1f4d5be95421..e90f2a7317c2e032a9137c2f240c88e6bc391d2d 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -616,6 +616,9 @@ production: &base enabled: false prevent_ldap_sign_in: false + # File location to read encrypted secrets from + # secret_file: /mnt/gitlab/ldap.yaml.enc # Default: shared/encrypted_settings/ldap.yaml.enc + # This setting controls the number of seconds between LDAP permission checks # for each user. After this time has expired for a given user, their next # interaction with GitLab (a click in the web UI, a git pull, etc.) will be diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 2da7b50cbfc8e8c1fea939971297986baff2e4e3..65f3898aa87e5397c5d4063ef6ef385ff4936c0e 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -13,6 +13,7 @@ Settings['ldap'] ||= Settingslogic.new({}) Settings.ldap['enabled'] = false if Settings.ldap['enabled'].nil? Settings.ldap['prevent_ldap_sign_in'] = false if Settings.ldap['prevent_ldap_sign_in'].blank? +Settings.ldap['secret_file'] = Settings.absolute(Settings.ldap['secret_file'] || File.join(Settings.encrypted_settings['path'], "ldap.yaml.enc")) Gitlab.ee do Settings.ldap['sync_time'] = 3600 if Settings.ldap['sync_time'].nil? diff --git a/lib/gitlab/auth/ldap/config.rb b/lib/gitlab/auth/ldap/config.rb index 88cc840c395f16cc2d7ee4b65bcbd7e536e0e865..f5931a1d5ebb87d4fd0e7cbee3191cc788e40be1 100644 --- a/lib/gitlab/auth/ldap/config.rb +++ b/lib/gitlab/auth/ldap/config.rb @@ -53,6 +53,10 @@ def self.invalid_provider(provider) raise InvalidProvider.new("Unknown provider (#{provider}). Available providers: #{providers}") end + def self.encrypted_secrets + Settings.encrypted(Gitlab.config.ldap.secret_file) + end + def initialize(provider) if self.class.valid_provider?(provider) @provider = provider @@ -89,8 +93,8 @@ def omniauth_options if has_auth? opts.merge!( - bind_dn: options['bind_dn'], - password: options['password'] + bind_dn: auth_username, + password: auth_password ) end @@ -155,7 +159,7 @@ def external_groups end def has_auth? - options['password'] || options['bind_dn'] + auth_password || auth_username end def allow_username_or_email_login @@ -267,12 +271,32 @@ def auth_options { auth: { method: :simple, - username: options['bind_dn'], - password: options['password'] + username: auth_username, + password: auth_password } } end + def secrets + @secrets ||= self.class.encrypted_secrets[@provider.delete_prefix('ldap').to_sym] + rescue => e + Gitlab::AppLogger.error "LDAP encrypted secrets are invalid: #{e.inspect}" + + nil + end + + def auth_password + return options['password'] if options['password'] + + secrets&.fetch(:password, nil)&.chomp + end + + def auth_username + return options['bind_dn'] if options['bind_dn'] + + secrets&.fetch(:bind_dn, nil)&.chomp + end + def omniauth_user_filter uid_filter = Net::LDAP::Filter.eq(uid, '%{username}') diff --git a/lib/gitlab/encrypted_ldap_command.rb b/lib/gitlab/encrypted_ldap_command.rb new file mode 100644 index 0000000000000000000000000000000000000000..cdb3e268b51a5c11d500f06d13fe248bc0c18082 --- /dev/null +++ b/lib/gitlab/encrypted_ldap_command.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +# rubocop:disable Rails/Output +module Gitlab + class EncryptedLdapCommand + class << self + def write(contents) + encrypted = Gitlab::Auth::Ldap::Config.encrypted_secrets + return unless validate_config(encrypted) + + validate_contents(contents) + encrypted.write(contents) + + puts "File encrypted and saved." + rescue Interrupt + puts "Aborted changing file: nothing saved." + rescue ActiveSupport::MessageEncryptor::InvalidMessage + puts "Couldn't decrypt #{encrypted.content_path}. Perhaps you passed the wrong key?" + end + + def edit + encrypted = Gitlab::Auth::Ldap::Config.encrypted_secrets + return unless validate_config(encrypted) + + if ENV["EDITOR"].blank? + puts 'No $EDITOR specified to open file. Please provide one when running the command:' + puts 'gitlab-rake gitlab:ldap:secret:edit EDITOR=vim' + return + end + + temp_file = Tempfile.new(File.basename(encrypted.content_path), File.dirname(encrypted.content_path)) + contents_changed = false + + encrypted.change do |contents| + contents = encrypted_file_template unless File.exist?(encrypted.content_path) + File.write(temp_file.path, contents) + system(ENV['EDITOR'], temp_file.path) + changes = File.read(temp_file.path) + contents_changed = contents != changes + validate_contents(changes) + changes + end + + puts "Contents were unchanged." unless contents_changed + puts "File encrypted and saved." + rescue Interrupt + puts "Aborted changing file: nothing saved." + rescue ActiveSupport::MessageEncryptor::InvalidMessage + puts "Couldn't decrypt #{encrypted.content_path}. Perhaps you passed the wrong key?" + ensure + temp_file&.unlink + end + + def show + encrypted = Gitlab::Auth::Ldap::Config.encrypted_secrets + return unless validate_config(encrypted) + + puts encrypted.read.presence || "File '#{encrypted.content_path}' does not exist. Use `gitlab-rake gitlab:ldap:secret:edit` to change that." + rescue ActiveSupport::MessageEncryptor::InvalidMessage + puts "Couldn't decrypt #{encrypted.content_path}. Perhaps you passed the wrong key?" + end + + private + + def validate_config(encrypted) + dir_path = File.dirname(encrypted.content_path) + + unless File.exist?(dir_path) + puts "Directory #{dir_path} does not exist. Create the directory and try again." + return false + end + + if encrypted.key.nil? + puts "Missing encryption key encrypted_settings_key_base." + return false + end + + true + end + + def validate_contents(contents) + begin + config = YAML.safe_load(contents, permitted_classes: [Symbol]) + error_contents = "Did not include any key-value pairs" unless config.is_a?(Hash) + rescue Psych::Exception => e + error_contents = e.message + end + + puts "WARNING: Content was not a valid LDAP secret yml file. #{error_contents}" if error_contents + + contents + end + + def encrypted_file_template + <<~YAML + # main: + # password: '123' + # user_dn: 'gitlab-adm' + YAML + end + end + end +end +# rubocop:enable Rails/Output diff --git a/lib/tasks/gitlab/ldap.rake b/lib/tasks/gitlab/ldap.rake index 0459de27c965caa1b5318652d63afeb38915671c..fe7920c621f920bfa7dea98ca7573fe67c5f9890 100644 --- a/lib/tasks/gitlab/ldap.rake +++ b/lib/tasks/gitlab/ldap.rake @@ -36,5 +36,23 @@ namespace :gitlab do puts "Successfully updated #{plural_updated_count} out of #{plural_id_count} total" end end + + namespace :secret do + desc 'GitLab | LDAP | Secret | Write LDAP secrets' + task write: [:environment] do + content = STDIN.tty? ? STDIN.gets : STDIN.read + Gitlab::EncryptedLdapCommand.write(content) + end + + desc 'GitLab | LDAP | Secret | Edit LDAP secrets' + task edit: [:environment] do + Gitlab::EncryptedLdapCommand.edit + end + + desc 'GitLab | LDAP | Secret | Show LDAP secrets' + task show: [:environment] do + Gitlab::EncryptedLdapCommand.show + end + end end end diff --git a/spec/tasks/gitlab/ldap_rake_spec.rb b/spec/tasks/gitlab/ldap_rake_spec.rb index 275fcb98dd0e965b71d19fef5b5a96ac4d1af49f..5286cd98944f03bb690ba975d5ea061bf9869356 100644 --- a/spec/tasks/gitlab/ldap_rake_spec.rb +++ b/spec/tasks/gitlab/ldap_rake_spec.rb @@ -13,3 +13,112 @@ run_rake_task('gitlab:ldap:rename_provider', 'ldapmain', 'ldapfoo') end end + +RSpec.describe 'gitlab:ldap:secret rake tasks' do + let(:ldap_secret_file) { 'tmp/tests/ldapenc/ldap_secret.yaml.enc' } + + before do + Rake.application.rake_require 'tasks/gitlab/ldap' + stub_env('EDITOR', 'cat') + stub_warn_user_is_not_gitlab + FileUtils.mkdir_p('tmp/tests/ldapenc/') + allow(Gitlab.config.ldap).to receive(:secret_file).and_return(ldap_secret_file) + allow(Gitlab::Application.secrets).to receive(:encrypted_settings_key_base).and_return(SecureRandom.hex(64)) + end + + after do + FileUtils.rm_rf(Rails.root.join('tmp/tests/ldapenc')) + end + + describe ':show' do + it 'displays error when file does not exist' do + expect { run_rake_task('gitlab:ldap:secret:show') }.to output(/File .* does not exist. Use `gitlab-rake gitlab:ldap:secret:edit` to change that./).to_stdout + end + + it 'displays error when key does not exist' do + Settings.encrypted(ldap_secret_file).write('somevalue') + allow(Gitlab::Application.secrets).to receive(:encrypted_settings_key_base).and_return(nil) + expect { run_rake_task('gitlab:ldap:secret:show') }.to output(/Missing encryption key encrypted_settings_key_base./).to_stdout + end + + it 'displays error when key is changed' do + Settings.encrypted(ldap_secret_file).write('somevalue') + allow(Gitlab::Application.secrets).to receive(:encrypted_settings_key_base).and_return(SecureRandom.hex(64)) + expect { run_rake_task('gitlab:ldap:secret:show') }.to output(/Couldn't decrypt .* Perhaps you passed the wrong key?/).to_stdout + end + + it 'outputs the unencrypted content when present' do + encrypted = Settings.encrypted(ldap_secret_file) + encrypted.write('somevalue') + expect { run_rake_task('gitlab:ldap:secret:show') }.to output(/somevalue/).to_stdout + end + end + + describe 'edit' do + it 'creates encrypted file' do + expect { run_rake_task('gitlab:ldap:secret:edit') }.to output(/File encrypted and saved./).to_stdout + expect(File.exist?(ldap_secret_file)).to be true + value = Settings.encrypted(ldap_secret_file) + expect(value.read).to match(/password: '123'/) + end + + it 'displays error when key does not exist' do + allow(Gitlab::Application.secrets).to receive(:encrypted_settings_key_base).and_return(nil) + expect { run_rake_task('gitlab:ldap:secret:edit') }.to output(/Missing encryption key encrypted_settings_key_base./).to_stdout + end + + it 'displays error when key is changed' do + Settings.encrypted(ldap_secret_file).write('somevalue') + allow(Gitlab::Application.secrets).to receive(:encrypted_settings_key_base).and_return(SecureRandom.hex(64)) + expect { run_rake_task('gitlab:ldap:secret:edit') }.to output(/Couldn't decrypt .* Perhaps you passed the wrong key?/).to_stdout + end + + it 'displays error when write directory does not exist' do + FileUtils.rm_rf(Rails.root.join('tmp/tests/ldapenc')) + expect { run_rake_task('gitlab:ldap:secret:edit') }.to output(/Directory .* does not exist./).to_stdout + end + + it 'shows a warning when content is invalid' do + Settings.encrypted(ldap_secret_file).write('somevalue') + expect { run_rake_task('gitlab:ldap:secret:edit') }.to output(/WARNING: Content was not a valid LDAP secret yml file/).to_stdout + value = Settings.encrypted(ldap_secret_file) + expect(value.read).to match(/somevalue/) + end + + it 'displays error when $EDITOR is not set' do + stub_env('EDITOR', nil) + expect { run_rake_task('gitlab:ldap:secret:edit') }.to output(/No \$EDITOR specified to open file. Please provide one when running the command/).to_stdout + end + end + + describe 'write' do + before do + allow(STDIN).to receive(:tty?).and_return(false) + allow(STDIN).to receive(:read).and_return('testvalue') + end + + it 'creates encrypted file from stdin' do + expect { run_rake_task('gitlab:ldap:secret:write') }.to output(/File encrypted and saved./).to_stdout + expect(File.exist?(ldap_secret_file)).to be true + value = Settings.encrypted(ldap_secret_file) + expect(value.read).to match(/testvalue/) + end + + it 'displays error when key does not exist' do + allow(Gitlab::Application.secrets).to receive(:encrypted_settings_key_base).and_return(nil) + expect { run_rake_task('gitlab:ldap:secret:write') }.to output(/Missing encryption key encrypted_settings_key_base./).to_stdout + end + + it 'displays error when write directory does not exist' do + FileUtils.rm_rf('tmp/tests/ldapenc/') + expect { run_rake_task('gitlab:ldap:secret:write') }.to output(/Directory .* does not exist./).to_stdout + end + + it 'shows a warning when content is invalid' do + Settings.encrypted(ldap_secret_file).write('somevalue') + expect { run_rake_task('gitlab:ldap:secret:edit') }.to output(/WARNING: Content was not a valid LDAP secret yml file/).to_stdout + value = Settings.encrypted(ldap_secret_file) + expect(value.read).to match(/somevalue/) + end + end +end