diff options
-rw-r--r-- | cspell.json | 1 | ||||
-rw-r--r-- | lib/chef/dsl.rb | 1 | ||||
-rw-r--r-- | lib/chef/dsl/secret.rb | 59 | ||||
-rw-r--r-- | lib/chef/dsl/universal.rb | 2 | ||||
-rw-r--r-- | lib/chef/exceptions.rb | 20 | ||||
-rw-r--r-- | lib/chef/secret_fetcher.rb | 49 | ||||
-rw-r--r-- | lib/chef/secret_fetcher/base.rb | 68 | ||||
-rw-r--r-- | lib/chef/secret_fetcher/example.rb | 46 | ||||
-rw-r--r-- | spec/unit/dsl/secret_spec.rb | 50 | ||||
-rw-r--r-- | spec/unit/secret_fetcher_spec.rb | 74 |
10 files changed, 370 insertions, 0 deletions
diff --git a/cspell.json b/cspell.json index 614bb8f06d..751a40e597 100644 --- a/cspell.json +++ b/cspell.json @@ -1434,6 +1434,7 @@ "secoption", "secopts", "secp", + "secretkey", "securerandom", "SECURITYPOLICY", "secvalue", diff --git a/lib/chef/dsl.rb b/lib/chef/dsl.rb index 6893298d1d..0b43b1ae21 100644 --- a/lib/chef/dsl.rb +++ b/lib/chef/dsl.rb @@ -4,3 +4,4 @@ require_relative "dsl/data_query" require_relative "dsl/include_recipe" require_relative "dsl/include_attribute" require_relative "dsl/registry_helper" +require_relative "dsl/secret" diff --git a/lib/chef/dsl/secret.rb b/lib/chef/dsl/secret.rb new file mode 100644 index 0000000000..258bb93f14 --- /dev/null +++ b/lib/chef/dsl/secret.rb @@ -0,0 +1,59 @@ +# +# Author:: Marc Paradise (<marc@chef.io>) +# Copyright:: Copyright (c) Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +require_relative "../secret_fetcher" + +class Chef + module DSL + module Secret + + # Helper method which looks up a secret using the given service and configuration, + # and returns the retrieved secret value. + # This DSL providers a wrapper around [Chef::SecretFetcher] + # + # @option name [Object] The identifier or name for this secret + # @option service [Symbol] The service identifier for the service that will + # perform the secret lookup + # @option config [Hash] The configuration that the named service expects + # + # @example + # + # This example uses the built-in :example secret manager service, which + # accepts a hash of secrets. + # + # value = secret(name: "test1", service: :example, config: { "test1" => "value1" } ) + # log "My secret is #{value}" + # + # value = secret(name: "test1", service: :aws_secrets_manager, config: { "region" => "us-west-1" }) + # log "My secret is #{value}" + # + # @note + # + # This is pretty straightforward, but should also extend nicely to support + # named config (as 'service') with override config. Some future potential + # usage examples: + # value = secret(name: "test1") # If a default is configured + # value = secret(name: "test1", service: "my_aws_east") + # value = secret(name: "test1", service: "my_aws_west", config: { region: "override-region" }) + def secret(name: nil, service: nil, config: nil) + Chef::SecretFetcher.for_service(service, config).fetch(name) + end + end + end +end + + diff --git a/lib/chef/dsl/universal.rb b/lib/chef/dsl/universal.rb index 1e4e4218d8..4b93fa2b7b 100644 --- a/lib/chef/dsl/universal.rb +++ b/lib/chef/dsl/universal.rb @@ -22,6 +22,7 @@ require_relative "data_query" require_relative "chef_vault" require_relative "registry_helper" require_relative "powershell" +require_relative "secret" require_relative "../mixin/powershell_exec" require_relative "../mixin/powershell_out" require_relative "../mixin/shell_out" @@ -47,6 +48,7 @@ class Chef include Chef::DSL::ChefVault include Chef::DSL::RegistryHelper include Chef::DSL::Powershell + include Chef::DSL::Secret include Chef::Mixin::PowershellExec include Chef::Mixin::PowershellOut include Chef::Mixin::ShellOut diff --git a/lib/chef/exceptions.rb b/lib/chef/exceptions.rb index ba34b22e61..6a90b0cc52 100644 --- a/lib/chef/exceptions.rb +++ b/lib/chef/exceptions.rb @@ -290,6 +290,26 @@ class Chef end + class Secret + class RetrievalError < RuntimeError; end + class ConfigurationInvalid < RuntimeError; end + class FetchFailed < RuntimeError; end + class MissingSecretName < RuntimeError; end + + class InvalidFetcherService < RuntimeError + def initialize(given, fetcher_service_names) + super("#{given} is not a supported secrets service. Supported services are: :#{fetcher_service_names.join(" :")}") + end + end + + class MissingFetcher < RuntimeError + def initialize(fetcher_service_names) + super("No secret service provided. Supported services are: :#{fetcher_service_names.join(" :")}") + end + end + + end + # Exception class for collecting multiple failures. Used when running # delayed notifications so that chef can process each delayed # notification even if chef client or other notifications fail. diff --git a/lib/chef/secret_fetcher.rb b/lib/chef/secret_fetcher.rb new file mode 100644 index 0000000000..31499da0fa --- /dev/null +++ b/lib/chef/secret_fetcher.rb @@ -0,0 +1,49 @@ +# +# Author:: Marc Paradise (<marc@chef.io>) +# Copyright:: Copyright (c) Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require_relative "exceptions" + +class Chef + class SecretFetcher + + SECRET_FETCHERS = [ :example ].freeze + + # Returns a configured and validated instance + # of a [Chef::SecretFetcher::Base] for the given + # service and configuration. + # + # @param service [Symbol] the identifier for the service that will support this request. Must be in + # SECRET_FETCHERS + # @param config [Hash] configuration that the secrets service requires + def self.for_service(service, config) + fetcher = case service + when :example + require_relative "secret_fetcher/example" + Chef::SecretFetcher::Example.new(config) + when nil, "" + raise Chef::Exceptions::Secret::MissingFetcher.new(SECRET_FETCHERS) + else + raise Chef::Exceptions::Secret::InvalidFetcherService.new("Unsupported secret service: #{service}", SECRET_FETCHERS) + end + fetcher.validate! + fetcher + end + + end +end + diff --git a/lib/chef/secret_fetcher/base.rb b/lib/chef/secret_fetcher/base.rb new file mode 100644 index 0000000000..a80ecea0fb --- /dev/null +++ b/lib/chef/secret_fetcher/base.rb @@ -0,0 +1,68 @@ +# +# Author:: Marc Paradise (<marc@chef.io>) +# Copyright:: Copyright (c) Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require_relative "../exceptions" + +class Chef + # == Chef::SecretFetcher + # An abstract base class that defines the methods required to implement + # a Secret Fetcher. + class SecretFetcher + class Base + attr_reader :config + + # Initialize a new SecretFetcher::Base + # + # @param config [Hash] Configuration hash. Expected configuration keys and values + # will vary based on implementation, and are validated in `validate!`. + def initialize(config) + @config = config + end + + # Fetch the named secret by invoking implementation-specific [Chef::SecretFetcher::Base#do_fetch] + # + # @param name [Object] the name or identifier of the secret. + # @note - the name parameter will probably see a narrowing of type as we learn more about different integrations. + # @return [Object] the result of the secret fetch + # @raise [Chef::Exceptions::Secret::MissingSecretName] when secret name is not provided + # @raise [Chef::Exceptions::Secret::FetchFailed] when the underlying attempt to fetch the secret fails. + def fetch(name) + raise Chef::Exceptions::Secret::MissingSecretName.new if name.nil? || name.to_s == "" + + do_fetch(name) + end + + # Validate that the instance is correctly configured. + # @raise [Chef::Exceptions::Secret::ConfigurationInvalid] if it is not. + def validate!; end + + # Called to fetch the secret identified by 'identifer'. Implementations + # should expect that `validate!` has been invoked before `do_fetch`. + # + # @param identifier [Object] Unique identifier of the secret to be retrieved. + # When invoked via DSL, this is pre-verified to be not nil/not empty string. + # The expected data type and form can vary by implementation. + # + # @return [Object] The secret as returned from the implementation. The data type + # will vary implementation. + # + # @raise [Chef::Exceptions::Secret::FetchFailed] if the secret could not be fetched + def do_fetch(identifier); raise NotImplementedError.new; end + end + end +end diff --git a/lib/chef/secret_fetcher/example.rb b/lib/chef/secret_fetcher/example.rb new file mode 100644 index 0000000000..7a0e671929 --- /dev/null +++ b/lib/chef/secret_fetcher/example.rb @@ -0,0 +1,46 @@ +# +# Author:: Marc Paradise (<marc@chef.io>) +# Copyright:: Copyright (c) Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require_relative "base" + +class Chef + # == Chef::SecretFetcher::Example + # A simple implementation of a secrets fetcher. + # It expects to be initialized with a hash of + # keys and secret values. + # + # Usage Example: + # + # fetcher = SecretFetcher.for_service(:example, "secretkey1" => { "secret" => "lives here" }) + # fetcher.fetch("secretkey1") + class SecretFetcher + class Example < Base + def validate! + if config.class != Hash + raise Chef::Exceptions::Secret::ConfigurationInvalid.new("The Example fetcher requires a hash of secrets") + end + end + + def do_fetch(identifier) + raise Chef::Exceptions::Secret::FetchFailed.new("Secret #{identifier}) not found.") unless config.key?(identifier) + + config[identifier] + end + end + end +end diff --git a/spec/unit/dsl/secret_spec.rb b/spec/unit/dsl/secret_spec.rb new file mode 100644 index 0000000000..280b4f5114 --- /dev/null +++ b/spec/unit/dsl/secret_spec.rb @@ -0,0 +1,50 @@ +# +# Author:: Marc Paradise <marc@chef.io> +# Copyright:: Copyright (c) Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require "spec_helper" +require "chef/dsl/secret" +require "chef/secret_fetcher/base" +class SecretDSLTester + include Chef::DSL::Secret +end + +class SecretFetcherImpl < Chef::SecretFetcher::Base + def do_fetch(name) + name + end +end + +describe Chef::DSL::Secret do + let(:dsl) { SecretDSLTester.new } + it "responds to 'secret'" do + expect(dsl.respond_to?(:secret)).to eq true + end + + it "uses SecretFetcher.for_service to find the fetcher" do + substitute_fetcher = SecretFetcherImpl.new({}) + expect(Chef::SecretFetcher).to receive(:for_service).with(:example, {}).and_return(substitute_fetcher) + expect(substitute_fetcher).to receive(:fetch).with "key1" + dsl.secret(name: "key1", service: :example, config: {}) + end + + it "resolves a secret when using the example fetcher" do + secret_value = dsl.secret(name: "test1", service: :example, + config: { "test1" => "secret value" }) + expect(secret_value).to eq "secret value" + end +end diff --git a/spec/unit/secret_fetcher_spec.rb b/spec/unit/secret_fetcher_spec.rb new file mode 100644 index 0000000000..3aa9efb5f1 --- /dev/null +++ b/spec/unit/secret_fetcher_spec.rb @@ -0,0 +1,74 @@ +# +# Author:: Marc Paradise <marc@chef.io> +# Copyright:: Copyright (c) Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require "chef/secret_fetcher" +require "chef/secret_fetcher/example" + +class SecretFetcherImpl < Chef::SecretFetcher::Base + def do_fetch(name) + name + end + + def validate!; end +end + +describe Chef::SecretFetcher do + let(:fetcher_impl) { SecretFetcherImpl.new({}) } + + before do + allow(Chef::SecretFetcher::Example).to receive(:new).and_return fetcher_impl + end + + context ".for_service" do + it "resolves a known secrets service to a fetcher" do + Chef::SecretFetcher.for_service(:example, {}) + end + + it "raises Chef::Exceptions::Secret::MissingFetcher when service is blank" do + expect { Chef::SecretFetcher.for_service(nil, {}) }.to raise_error(Chef::Exceptions::Secret::MissingFetcher) + end + + it "raises Chef::Exceptions::Secret::MissingFetcher when service is nil" do + expect { Chef::SecretFetcher.for_service("", {}) }.to raise_error(Chef::Exceptions::Secret::MissingFetcher) + end + + it "raises Chef::Exceptions::Secret::InvalidFetcher for an unknown fetcher" do + expect { Chef::SecretFetcher.for_service(:bad_example, {}) }.to raise_error(Chef::Exceptions::Secret::InvalidFetcherService) + end + + it "ensures fetcher configuration is valid by invoking validate!" do + expect(fetcher_impl).to receive(:validate!) + Chef::SecretFetcher.for_service(:example, {}) + end + end + + context "#fetch" do + let(:fetcher) { + Chef::SecretFetcher.for_service(:example, { "key1" => "value1" }) + } + + it "fetches from the underlying service when secret name is provided " do + expect(fetcher_impl).to receive(:fetch).with("key1") + fetcher.fetch("key1") + end + + it "raises an error when the secret name is not provided" do + expect { fetcher.fetch(nil) }.to raise_error(Chef::Exceptions::Secret::MissingSecretName) + end + end +end |