diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index b916fda1cb5fb..691e599ddbecb 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,12 @@ +* Support environment specific credentials file. + + For `production` environment look first for `config/credentials/production.yml.enc` file that can be decrypted by + `ENV["RAILS_MASTER_KEY"]` or `config/credentials/production.key` master key. + Edit given environment credentials file by command `rails credentials:edit --environment production`. + Default paths can be overwritten by setting `config.credentials.content_path` and `config.credentials.key_path`. + + *Wojciech Wnętrzak* + * Make `ActiveSupport::Cache::NullStore` the default cache store in the test environment. *Michael C. Nelson* diff --git a/railties/lib/rails/application.rb b/railties/lib/rails/application.rb index 26ed195dcc701..656786246d887 100644 --- a/railties/lib/rails/application.rb +++ b/railties/lib/rails/application.rb @@ -438,8 +438,12 @@ def secret_key_base # Decrypts the credentials hash as kept in +config/credentials.yml.enc+. This file is encrypted with # the Rails master key, which is either taken from ENV["RAILS_MASTER_KEY"] or from loading # +config/master.key+. + # If specific credentials file exists for current environment, it takes precedence, thus for +production+ + # environment look first for +config/credentials/production.yml.enc+ with master key taken + # from ENV["RAILS_MASTER_KEY"] or from loading +config/credentials/production.key+. + # Default behavior can be overwritten by setting +config.credentials.content_path+ and +config.credentials.key_path+. def credentials - @credentials ||= encrypted("config/credentials.yml.enc") + @credentials ||= encrypted(config.credentials.content_path, key_path: config.credentials.key_path) end # Shorthand to decrypt any encrypted configurations or files. diff --git a/railties/lib/rails/application/configuration.rb b/railties/lib/rails/application/configuration.rb index f4cbd2b9d083b..eae902a9385b3 100644 --- a/railties/lib/rails/application/configuration.rb +++ b/railties/lib/rails/application/configuration.rb @@ -17,7 +17,7 @@ class Configuration < ::Rails::Engine::Configuration :session_options, :time_zone, :reload_classes_only_on_change, :beginning_of_week, :filter_redirect, :x, :enable_dependency_loading, :read_encrypted_secrets, :log_level, :content_security_policy_report_only, - :content_security_policy_nonce_generator, :require_master_key + :content_security_policy_nonce_generator, :require_master_key, :credentials attr_reader :encoding, :api_only, :loaded_config_version @@ -60,6 +60,9 @@ def initialize(*) @content_security_policy_nonce_generator = nil @require_master_key = false @loaded_config_version = nil + @credentials = ActiveSupport::OrderedOptions.new + @credentials.content_path = default_credentials_content_path + @credentials.key_path = default_credentials_key_path end def load_defaults(target_version) @@ -273,6 +276,27 @@ def respond_to_missing?(symbol, *) true end end + + private + def credentials_available_for_current_env? + File.exist?("#{root}/config/credentials/#{Rails.env}.yml.enc") + end + + def default_credentials_content_path + if credentials_available_for_current_env? + File.join(root, "config", "credentials", "#{Rails.env}.yml.enc") + else + File.join(root, "config", "credentials.yml.enc") + end + end + + def default_credentials_key_path + if credentials_available_for_current_env? + File.join(root, "config", "credentials", "#{Rails.env}.key") + else + File.join(root, "config", "master.key") + end + end end end end diff --git a/railties/lib/rails/commands/credentials/USAGE b/railties/lib/rails/commands/credentials/USAGE index ea429f58d84f7..6b33d1ab74895 100644 --- a/railties/lib/rails/commands/credentials/USAGE +++ b/railties/lib/rails/commands/credentials/USAGE @@ -38,3 +38,12 @@ the encrypted credentials. When the temporary file is next saved the contents are encrypted and written to `config/credentials.yml.enc` while the file itself is destroyed to prevent credentials from leaking. + +=== Environment Specific Credentials + +It is possible to have credentials for each environment. If the file for current environment exists it will take +precedence over `config/credentials.yml.enc`, thus for `production` environment first look for +`config/credentials/production.yml.enc` that can be decrypted using master key taken from `ENV["RAILS_MASTER_KEY"]` +or stored in `config/credentials/production.key`. +To edit given file use command `rails credentials:edit --environment production` +Default paths can be overwritten by setting `config.credentials.content_path` and `config.credentials.key_path`. diff --git a/railties/lib/rails/commands/credentials/credentials_command.rb b/railties/lib/rails/commands/credentials/credentials_command.rb index 65c5218fc8f01..97e51786e70c1 100644 --- a/railties/lib/rails/commands/credentials/credentials_command.rb +++ b/railties/lib/rails/commands/credentials/credentials_command.rb @@ -8,6 +8,9 @@ module Command class CredentialsCommand < Rails::Command::Base # :nodoc: include Helpers::Editor + class_option :environment, aliases: "-e", type: :string, + desc: "Uses credentials from config/credentials/:environment.yml.enc encrypted by config/credentials/:environment.key key" + no_commands do def help say "Usage:\n #{self.class.banner}" @@ -20,58 +23,78 @@ def edit require_application_and_environment! ensure_editor_available(command: "bin/rails credentials:edit") || (return) - ensure_master_key_has_been_added if Rails.application.credentials.key.nil? - ensure_credentials_have_been_added + + encrypted = Rails.application.encrypted(content_path, key_path: key_path, env_key: env_key) + + ensure_encryption_key_has_been_added(key_path) if encrypted.key.nil? + ensure_encrypted_file_has_been_added(content_path, key_path) catch_editing_exceptions do - change_credentials_in_system_editor + change_encrypted_file_in_system_editor(content_path, key_path, env_key) end - say "New credentials encrypted and saved." + say "File encrypted and saved." + rescue ActiveSupport::MessageEncryptor::InvalidMessage + say "Couldn't decrypt #{content_path}. Perhaps you passed the wrong key?" end def show require_application_and_environment! - say Rails.application.credentials.read.presence || missing_credentials_message + encrypted = Rails.application.encrypted(content_path, key_path: key_path, env_key: env_key) + + say encrypted.read.presence || missing_encrypted_message(key: encrypted.key, key_path: key_path, file_path: content_path) end private - def ensure_master_key_has_been_added - master_key_generator.add_master_key_file - master_key_generator.ignore_master_key_file + def content_path + options[:environment] ? "config/credentials/#{options[:environment]}.yml.enc" : "config/credentials.yml.enc" + end + + def key_path + options[:environment] ? "config/credentials/#{options[:environment]}.key" : "config/master.key" + end + + def env_key + options[:environment] ? "RAILS_#{options[:environment].upcase}_KEY" : "RAILS_MASTER_KEY" + end + + + def ensure_encryption_key_has_been_added(key_path) + encryption_key_file_generator.add_key_file(key_path) + encryption_key_file_generator.ignore_key_file(key_path) end - def ensure_credentials_have_been_added - credentials_generator.add_credentials_file_silently + def ensure_encrypted_file_has_been_added(file_path, key_path) + encrypted_file_generator.add_encrypted_file_silently(file_path, key_path) end - def change_credentials_in_system_editor - Rails.application.credentials.change do |tmp_path| + def change_encrypted_file_in_system_editor(file_path, key_path, env_key) + Rails.application.encrypted(file_path, key_path: key_path, env_key: env_key).change do |tmp_path| system("#{ENV["EDITOR"]} #{tmp_path}") end end - def master_key_generator + def encryption_key_file_generator require "rails/generators" - require "rails/generators/rails/master_key/master_key_generator" + require "rails/generators/rails/encryption_key_file/encryption_key_file_generator" - Rails::Generators::MasterKeyGenerator.new + Rails::Generators::EncryptionKeyFileGenerator.new end - def credentials_generator + def encrypted_file_generator require "rails/generators" - require "rails/generators/rails/credentials/credentials_generator" + require "rails/generators/rails/encrypted_file/encrypted_file_generator" - Rails::Generators::CredentialsGenerator.new + Rails::Generators::EncryptedFileGenerator.new end - def missing_credentials_message - if Rails.application.credentials.key.nil? - "Missing master key to decrypt credentials. See `rails credentials:help`" + def missing_encrypted_message(key:, key_path:, file_path:) + if key.nil? + "Missing '#{key_path}' to decrypt credentials. See `rails credentials:help`" else - "No credentials have been added yet. Use `rails credentials:edit` to change that." + "File '#{file_path}' does not exist. Use `rails credentials:edit` to change that." end end end diff --git a/railties/test/commands/credentials_test.rb b/railties/test/commands/credentials_test.rb index 5b8b9e4eda007..7842b0db61d5e 100644 --- a/railties/test/commands/credentials_test.rb +++ b/railties/test/commands/credentials_test.rb @@ -55,6 +55,14 @@ class Rails::Command::CredentialsCommandTest < ActiveSupport::TestCase end end + test "edit command modifies file specified by environment option" do + assert_match(/access_key_id: 123/, run_edit_command(environment: "production")) + Dir.chdir(app_path) do + assert File.exist?("config/credentials/production.key") + assert File.exist?("config/credentials/production.yml.enc") + end + end + test "show credentials" do assert_match(/access_key_id: 123/, run_show_command) end @@ -70,17 +78,25 @@ class Rails::Command::CredentialsCommandTest < ActiveSupport::TestCase remove_file "config/master.key" add_to_config "config.require_master_key = false" - assert_match(/Missing master key to decrypt credentials/, run_show_command) + assert_match(/Missing 'config\/master\.key' to decrypt credentials/, run_show_command) + end + + test "show command displays content specified by environment option" do + run_edit_command(environment: "production") + + assert_match(/access_key_id: 123/, run_show_command(environment: "production")) end private - def run_edit_command(editor: "cat") + def run_edit_command(editor: "cat", environment: nil, **options) switch_env("EDITOR", editor) do - rails "credentials:edit" + args = environment ? ["--environment", environment] : [] + rails "credentials:edit", args, **options end end - def run_show_command(**options) - rails "credentials:show", **options + def run_show_command(environment: nil, **options) + args = environment ? ["--environment", environment] : [] + rails "credentials:show", args, **options end end diff --git a/railties/test/credentials_test.rb b/railties/test/credentials_test.rb new file mode 100644 index 0000000000000..03370e0fc7f4a --- /dev/null +++ b/railties/test/credentials_test.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "isolation/abstract_unit" + +class Rails::CredentialsTest < ActiveSupport::TestCase + include ActiveSupport::Testing::Isolation + + setup :build_app + teardown :teardown_app + + test "reads credentials from environment specific path" do + with_credentials do |content, key| + Dir.chdir(app_path) do + Dir.mkdir("config/credentials") + File.write("config/credentials/production.yml.enc", content) + File.write("config/credentials/production.key", key) + end + + app("production") + + assert_equal "revealed", Rails.application.credentials.mystery + end + end + + test "reads credentials from customized path and key" do + with_credentials do |content, key| + Dir.chdir(app_path) do + Dir.mkdir("config/credentials") + File.write("config/credentials/staging.yml.enc", content) + File.write("config/credentials/staging.key", key) + end + + add_to_env_config("production", "config.credentials.content_path = config.root.join('config/credentials/staging.yml.enc')") + add_to_env_config("production", "config.credentials.key_path = config.root.join('config/credentials/staging.key')") + app("production") + + assert_equal "revealed", Rails.application.credentials.mystery + end + end + + private + def with_credentials + key = "2117e775dc2024d4f49ddf3aeb585919" + # secret_key_base: secret + # mystery: revealed + content = "vgvKu4MBepIgZ5VHQMMPwnQNsLlWD9LKmJHu3UA/8yj6x+3fNhz3DwL9brX7UA==--qLdxHP6e34xeTAiI--nrcAsleXuo9NqiEuhntAhw==" + yield(content, key) + end +end