diff --git a/config/initializers/01_secret_token.rb b/config/initializers/01_secret_token.rb index 886b6916e5f3b309c44fcb212e3a9ae742e7f8d5..fc48606b5c4a545587568e01b5ab9943e4201b15 100644 --- a/config/initializers/01_secret_token.rb +++ b/config/initializers/01_secret_token.rb @@ -10,30 +10,41 @@ require 'securerandom' -def create_tokens - # Inspired by https://github.com/rails/rails/blob/v7.0.8.4/railties/lib/rails/secrets.rb#L25-L36 - raw_secrets = begin - YAML.safe_load(File.read(Rails.root.join('config/secrets.yml'))) - rescue Errno::ENOENT, Psych::SyntaxError - {} - end - raw_secrets ||= {} +def rails_secrets_config_file + Rails.root.join('config/secrets.yml') +end - secrets = {} - secrets.merge!(raw_secrets["shared"].deep_symbolize_keys) if raw_secrets["shared"] - secrets.merge!(raw_secrets[Rails.env].deep_symbolize_keys) if raw_secrets[Rails.env] +def load_secrets_from_file + YAML.safe_load_file(rails_secrets_config_file) +rescue Errno::ENOENT, Psych::SyntaxError + {} +end - # Copy secrets into credentials since Rails.application.secrets is populated from config/secrets.yml - # Later, once config/secrets.yml won't be read automatically, we'll need to do it manually, and set +def set_credentials_from_file_and_env! + # Inspired by https://github.com/rails/rails/blob/v7.0.8.4/railties/lib/rails/secrets.rb#L25-L36 + # Later, once config/secrets.yml won't be read automatically, we'll need to do it manually, so + # we anticipate and do it ourselves here. + file_secrets = load_secrets_from_file + secrets = file_secrets.fetch("shared", {}).deep_symbolize_keys + .merge(file_secrets.fetch(Rails.env, {}).deep_symbolize_keys) + + # Copy secrets from config/secrets.yml into Rails.application.credentials + # If we support native Rails.application.credentials later + # (e.g. config.credentials.yml.enc + config/master.key ), this loop would + # become a no-op as long as credentials are migrated to config.credentials.yml.enc. secrets.each do |key, value| + next if Rails.application.credentials.public_send(key).present? + Rails.application.credentials[key] = value end - # Historically, ENV['SECRET_KEY_BASE'] takes precedence over secrets.yml, so we maintain that - # behavior by ensuring the environment variable always overrides secrets.yml. + # Historically, ENV['SECRET_KEY_BASE'] takes precedence over config/secrets.yml, so we maintain that + # behavior by ensuring the environment variable always overrides the value from config/secrets.yml. env_secret_key = ENV['SECRET_KEY_BASE'] Rails.application.credentials.secret_key_base = env_secret_key if env_secret_key.present? +end +def set_missing_from_defaults! defaults = { secret_key_base: generate_new_secure_token, otp_key_base: generate_new_secure_token, @@ -43,12 +54,16 @@ def create_tokens # encrypted_settings_key_base is optional for now if ENV['GITLAB_GENERATE_ENCRYPTED_SETTINGS_KEY_BASE'] - defaults[:encrypted_settings_key_base] = - generate_new_secure_token + defaults[:encrypted_settings_key_base] = generate_new_secure_token end missing_secrets = set_missing_keys(defaults) - write_secrets_yml(missing_secrets) unless missing_secrets.empty? + write_secrets_yml!(missing_secrets) if missing_secrets.any? +end + +def create_tokens + set_credentials_from_file_and_env! + set_missing_from_defaults! end def generate_new_secure_token @@ -62,7 +77,8 @@ def generate_new_rsa_private_key def warn_missing_secret(secret) return if Rails.env.test? - warn "Missing Rails.application.credentials.#{secret} for #{Rails.env} environment. The secret will be generated and stored in config/secrets.yml." + warn "Missing Rails.application.credentials.#{secret} for #{Rails.env} environment. " \ + "The secret will be generated and stored in config/secrets.yml." end def set_missing_keys(defaults) @@ -74,15 +90,12 @@ def set_missing_keys(defaults) end end -def write_secrets_yml(missing_secrets) - secrets_yml = Rails.root.join('config/secrets.yml') +def write_secrets_yml!(missing_secrets) rails_env = Rails.env.to_s - secrets = YAML.load_file(secrets_yml) if File.exist?(secrets_yml) - secrets ||= {} + secrets = load_secrets_from_file secrets[rails_env] ||= {} - secrets[rails_env].merge!(missing_secrets) - File.write(secrets_yml, YAML.dump(secrets), mode: 'w', perm: 0o600) + File.write(rails_secrets_config_file, YAML.dump(secrets), mode: 'w', perm: 0o600) end create_tokens diff --git a/spec/initializers/secret_token_spec.rb b/spec/initializers/secret_token_spec.rb index 67228539db9acd9280b4da880f0c51fa59f32eaa..f61c2cad1bbfc499c5757f498361623dd7d0e6b6 100644 --- a/spec/initializers/secret_token_spec.rb +++ b/spec/initializers/secret_token_spec.rb @@ -6,18 +6,32 @@ RSpec.describe 'create_tokens' do include StubENV - let(:secrets) { ActiveSupport::OrderedOptions.new } + let(:allowed_keys) do + %w[ + secret_key_base + db_key_base + otp_key_base + openid_connect_signing_key + ] + end + let(:hex_key) { /\h{128}/ } let(:rsa_key) { /\A-----BEGIN RSA PRIVATE KEY-----\n.+\n-----END RSA PRIVATE KEY-----\n\Z/m } + around do |example| + original_credentials = Rails.application.credentials + # ensure we clear any existing `encrypted_settings_key_base` credential + allowed_keys.each do |key| + Rails.application.credentials.public_send(:"#{key}=", nil) + end + example.run + Rails.application.credentials = original_credentials + end + before do - allow(Rails).to receive_message_chain(:application, :credentials).and_return(secrets) allow(Rails).to receive_message_chain(:root, :join) { |string| string } - allow(File).to receive(:write).and_call_original - allow(File).to receive(:write).with(Rails.root.join('config/secrets.yml')) - allow(self).to receive(:warn) - allow(self).to receive(:exit) + allow(File).to receive(:write).with('config/secrets.yml') end describe 'ensure acknowledged secrets in any installations' do @@ -45,182 +59,177 @@ end end - context 'setting secret keys' do - context 'when none of the secrets exist' do - before do - stub_env('SECRET_KEY_BASE', nil) - allow(File).to receive(:exist?).with('config/secrets.yml').and_return(false) - allow(self).to receive(:warn_missing_secret) + context 'when none of the secrets exist' do + before do + # ensure we clear any existing `encrypted_settings_key_base` credential + allowed_keys.each do |key| + Rails.application.credentials.public_send(:"#{key}=", nil) end - it 'generates different hashes for secret_key_base, otp_key_base, and db_key_base' do - create_tokens + allow(self).to receive(:load_secrets_from_file).and_return({}) + stub_env('SECRET_KEY_BASE', nil) + end - keys = secrets.values_at(:secret_key_base, :otp_key_base, :db_key_base) + it 'generates different hashes for secret_key_base, otp_key_base, and db_key_base' do + create_tokens - expect(keys.uniq).to eq(keys) - expect(keys).to all(match(hex_key)) - end + keys = Rails.application.credentials.values_at(:secret_key_base, :otp_key_base, :db_key_base) - it 'generates an RSA key for openid_connect_signing_key' do - create_tokens + expect(keys.uniq).to eq(keys) + expect(keys).to all(match(hex_key)) + end - keys = secrets.values_at(:openid_connect_signing_key) + it 'generates an RSA key for openid_connect_signing_key' do + create_tokens - expect(keys.uniq).to eq(keys) - expect(keys).to all(match(rsa_key)) - end + keys = Rails.application.credentials.values_at(:openid_connect_signing_key) - it 'warns about the secrets to add to secrets.yml' do - expect(self).to receive(:warn_missing_secret).with('secret_key_base') - expect(self).to receive(:warn_missing_secret).with('otp_key_base') - expect(self).to receive(:warn_missing_secret).with('db_key_base') - expect(self).to receive(:warn_missing_secret).with('openid_connect_signing_key') + expect(keys.uniq).to eq(keys) + expect(keys).to all(match(rsa_key)) + end - create_tokens + it 'warns about the secrets to add to secrets.yml' do + allowed_keys.each do |key| + expect(self).to receive(:warn_missing_secret).with(key) end - it 'writes the secrets to secrets.yml' do - expect(File).to receive(:write).with('config/secrets.yml', any_args) do |filename, contents, options| - new_secrets = YAML.safe_load(contents)[Rails.env] + create_tokens + end + + it 'writes the secrets to secrets.yml' do + expect(File).to receive(:write).with('config/secrets.yml', any_args) do |_filename, contents, _options| + new_secrets = YAML.safe_load(contents)['test'] - expect(new_secrets['secret_key_base']).to eq(secrets.secret_key_base) - expect(new_secrets['otp_key_base']).to eq(secrets.otp_key_base) - expect(new_secrets['db_key_base']).to eq(secrets.db_key_base) - expect(new_secrets['openid_connect_signing_key']).to eq(secrets.openid_connect_signing_key) - expect(new_secrets['encrypted_settings_key_base']).to eq(secrets.encrypted_settings_key_base) + allowed_keys.each do |key| + expect(new_secrets[key]).to eq(Rails.application.credentials.values_at(key.to_sym).first) end - - create_tokens + expect(new_secrets['encrypted_settings_key_base']).to be_nil # encrypted_settings_key_base is optional end + + create_tokens end - context 'when the other secrets all exist' do - before do - secrets.db_key_base = 'db_key_base' - secrets.openid_connect_signing_key = 'openid_connect_signing_key' - secrets.encrypted_settings_key_base = 'encrypted_settings_key_base' + context 'when GITLAB_GENERATE_ENCRYPTED_SETTINGS_KEY_BASE is set' do + let(:allowed_keys) do + super() + ['encrypted_settings_key_base'] end - context 'when secret_key_base exists in the environment and secrets.yml' do - before do - stub_env('SECRET_KEY_BASE', 'env_key') - secrets.secret_key_base = 'secret_key_base' - secrets.otp_key_base = 'otp_key_base' - secrets.openid_connect_signing_key = 'openid_connect_signing_key' - end + before do + stub_env('GITLAB_GENERATE_ENCRYPTED_SETTINGS_KEY_BASE', '1') + allow(self).to receive(:warn_missing_secret) + end - it 'does not issue a warning' do - expect(self).not_to receive(:warn) + it 'writes the encrypted_settings_key_base secret' do + expect(self).to receive(:warn_missing_secret).with('encrypted_settings_key_base') + expect(File).to receive(:write).with('config/secrets.yml', any_args) do |_filename, contents, _options| + new_secrets = YAML.safe_load(contents)['test'] - create_tokens + expect(new_secrets['encrypted_settings_key_base']).to eq(Rails.application.credentials.encrypted_settings_key_base) end - it 'uses the environment variable' do - create_tokens + create_tokens + end + end + end - expect(secrets.secret_key_base).to eq('env_key') - end + shared_examples 'credentials are properly set' do + it 'sets Rails.application.credentials' do + create_tokens - it 'does not update secrets.yml' do - expect(File).not_to receive(:write) + expect(Rails.application.credentials.values_at(*allowed_keys.map(&:to_sym))).to eq(allowed_keys) + end - create_tokens - end - end + it 'does not issue warnings' do + expect(self).not_to receive(:warn_missing_secret) - context 'when secret_key_base and otp_key_base exist' do - before do - secrets.secret_key_base = 'secret_key_base' - secrets.otp_key_base = 'otp_key_base' - secrets.openid_connect_signing_key = 'openid_connect_signing_key' - end + create_tokens + end - it 'does not write any files' do - expect(File).not_to receive(:write) + it 'does not update secrets.yml' do + expect(File).not_to receive(:write) - create_tokens - end + create_tokens + end + end - it 'sets the keys to the values from the environment and secrets.yml' do - create_tokens + context 'when secrets exist in secrets.yml' do + let(:credentials) do + Hash[allowed_keys.zip(allowed_keys)] + end - expect(secrets.secret_key_base).to eq('secret_key_base') - expect(secrets.otp_key_base).to eq('otp_key_base') - expect(secrets.db_key_base).to eq('db_key_base') - expect(secrets.openid_connect_signing_key).to eq('openid_connect_signing_key') - expect(secrets.encrypted_settings_key_base).to eq('encrypted_settings_key_base') - end + before do + # ensure we clear any existing `encrypted_settings_key_base` credential + allowed_keys.each do |key| + Rails.application.credentials.public_send(:"#{key}=", nil) end - context 'when secret_key_base and otp_key_base do not exist' do - before do - allow(File).to receive(:exist?).with('config/secrets.yml').and_return(true) - allow(YAML).to receive(:load_file).with('config/secrets.yml').and_return('test' => secrets.to_h.stringify_keys) - allow(self).to receive(:warn_missing_secret) - end + allow(self).to receive(:load_secrets_from_file).and_return({ + 'test' => credentials + }) + end - it 'keeps the other secrets as they were' do - create_tokens + it_behaves_like 'credentials are properly set' - expect(secrets.db_key_base).to eq('db_key_base') - end + context 'when secret_key_base also exist in the environment variable' do + before do + stub_env('SECRET_KEY_BASE', 'env_key') + end - it 'warns about the missing secrets' do - expect(self).to receive(:warn_missing_secret).with('secret_key_base') - expect(self).to receive(:warn_missing_secret).with('otp_key_base') + it 'sets Rails.application.credentials.secret_key_base from the environment variable' do + create_tokens - create_tokens - end + expect(Rails.application.credentials.secret_key_base).to eq('env_key') end + end + end - context 'when rotated_encrypted_settings_key_base does not exist' do - before do - secrets.secret_key_base = 'secret_key_base' - secrets.otp_key_base = 'otp_key_base' - secrets.openid_connect_signing_key = 'openid_connect_signing_key' - secrets.encrypted_settings_key_base = 'encrypted_settings_key_base' - end + context 'when secrets exist in Rails.application.credentials' do + before do + allowed_keys.each do |key| + Rails.application.credentials.public_send(:"#{key}=", key) + end + end - it 'does not warn about the missing secrets' do - expect(self).not_to receive(:warn_missing_secret).with('rotated_encrypted_settings_key_base') + it_behaves_like 'credentials are properly set' - create_tokens - end + context 'when secret_key_base also exist in the environment variable' do + before do + stub_env('SECRET_KEY_BASE', 'env_key') + end - it 'does not update secrets.yml' do - expect(File).not_to receive(:write) + it 'sets Rails.application.credentials.secret_key_base from the environment variable' do + create_tokens - create_tokens - end + expect(Rails.application.credentials.secret_key_base).to eq('env_key') end end + end - context 'when db_key_base is blank but exists in secrets.yml' do - before do - secrets.otp_key_base = 'otp_key_base' - secrets.secret_key_base = 'secret_key_base' - secrets.encrypted_settings_key_base = 'encrypted_settings_key_base' - yaml_secrets = secrets.to_h.stringify_keys.merge('db_key_base' => '<%= an_erb_expression %>') - - allow(File).to receive(:exist?).with('.secret').and_return(false) - allow(File).to receive(:exist?).with('config/secrets.yml').and_return(true) - allow(YAML).to receive(:load_file).with('config/secrets.yml').and_return('test' => yaml_secrets) - allow(self).to receive(:warn_missing_secret) - end + context 'some secrets miss, some are in env, some are in Rails.application.credentials, and some are in secrets.yml' do + before do + stub_env('SECRET_KEY_BASE', 'env_key') - it 'warns about updating db_key_base' do - expect(self).to receive(:warn_missing_secret).with('db_key_base') + Rails.application.credentials.db_key_base = 'db_key_base' - create_tokens - end + allow(self).to receive(:load_secrets_from_file).and_return({ + 'test' => { 'otp_key_base' => 'otp_key_base' } + }) + end - it 'does not update secrets.yml' do - expect(self).to receive(:exit).with(1).and_call_original - expect(File).not_to receive(:write) + it 'sets Rails.application.credentials properly, issue a warning and writes config.secrets.yml' do + expect(self).to receive(:warn_missing_secret).with('openid_connect_signing_key') + expect(File).to receive(:write).with('config/secrets.yml', any_args) do |_filename, contents, _options| + new_secrets = YAML.safe_load(contents)['test'] - expect { create_tokens }.to raise_error(SystemExit) + expect(new_secrets['otp_key_base']).to eq('otp_key_base') + expect(new_secrets['openid_connect_signing_key']).to match(rsa_key) end + + create_tokens + + expect(Rails.application.credentials.secret_key_base).to eq('env_key') + expect(Rails.application.credentials.db_key_base).to eq('db_key_base') + expect(Rails.application.credentials.otp_key_base).to eq('otp_key_base') end end end