Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for multi environment credentials. #33521

Merged
merged 1 commit into from Sep 19, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
9 changes: 9 additions & 0 deletions 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*
Expand Down
6 changes: 5 additions & 1 deletion railties/lib/rails/application.rb
Expand Up @@ -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 <tt>ENV["RAILS_MASTER_KEY"]</tt> 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 <tt>ENV["RAILS_MASTER_KEY"]</tt> 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)
Copy link

@l33z3r l33z3r Apr 20, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there an issue here where this line is ignoring env_key ? If I set the key I used to encrypt my credentials file in RAILS_STAGING_KEY for example, I think it is being ignored here

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although RAILS_#{options[:environment].upcase}_KEY was originally supported, it was removed in #33928. Use RAILS_MASTER_KEY instead.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect, I see how to use it now thanks

end

# Shorthand to decrypt any encrypted configurations or files.
Expand Down
26 changes: 25 additions & 1 deletion railties/lib/rails/application/configuration.rb
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
9 changes: 9 additions & 0 deletions railties/lib/rails/commands/credentials/USAGE
Expand Up @@ -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`.
67 changes: 45 additions & 22 deletions railties/lib/rails/commands/credentials/credentials_command.rb
Expand Up @@ -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}"
Expand All @@ -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
Expand Down
26 changes: 21 additions & 5 deletions railties/test/commands/credentials_test.rb
Expand Up @@ -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
Expand All @@ -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
49 changes: 49 additions & 0 deletions 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