diff options
author | Marc A. Paradise <marc.paradise@gmail.com> | 2021-07-15 17:19:25 -0400 |
---|---|---|
committer | Marc A. Paradise <marc.paradise@gmail.com> | 2021-07-15 17:43:28 -0400 |
commit | 0d04189350f483edfbc725d9d77aee9946e4f72c (patch) | |
tree | 96b659936901123ce2f5115fd6bf6552cc428c13 | |
parent | 10b6ff915c6fe24ad0708f0df8fa7514d9bca933 (diff) | |
download | chef-0d04189350f483edfbc725d9d77aee9946e4f72c.tar.gz |
Add experimental fetcher for Azure Key Vault
Usage in a recipe looks like this:
value = secret(name: "test1", version: "v1",
service: :azure_key_vault,
config: { vault: "myvault" } )
Signed-off-by: Marc A. Paradise <marc.paradise@gmail.com>
-rw-r--r-- | lib/chef/exceptions.rb | 3 | ||||
-rw-r--r-- | lib/chef/secret_fetcher.rb | 6 | ||||
-rw-r--r-- | lib/chef/secret_fetcher/azure_key_vault.rb | 56 | ||||
-rw-r--r-- | spec/unit/secret_fetcher/azure_key_vault_spec.rb | 63 | ||||
-rw-r--r-- | spec/unit/secret_fetcher_spec.rb | 4 |
5 files changed, 129 insertions, 3 deletions
diff --git a/lib/chef/exceptions.rb b/lib/chef/exceptions.rb index 6a90b0cc52..dcb7146510 100644 --- a/lib/chef/exceptions.rb +++ b/lib/chef/exceptions.rb @@ -295,7 +295,7 @@ class Chef class ConfigurationInvalid < RuntimeError; end class FetchFailed < RuntimeError; end class MissingSecretName < RuntimeError; end - + class InvalidSecretName < 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(" :")}") @@ -308,6 +308,7 @@ class Chef end end + class MissingVaultName < RuntimeError; end end # Exception class for collecting multiple failures. Used when running diff --git a/lib/chef/secret_fetcher.rb b/lib/chef/secret_fetcher.rb index 6745da00f6..9b8acd3790 100644 --- a/lib/chef/secret_fetcher.rb +++ b/lib/chef/secret_fetcher.rb @@ -21,7 +21,7 @@ require_relative "exceptions" class Chef class SecretFetcher - SECRET_FETCHERS = %i{example aws_secrets_manager}.freeze + SECRET_FETCHERS = %i{example aws_secrets_manager azure_key_vault}.freeze # Returns a configured and validated instance # of a [Chef::SecretFetcher::Base] for the given @@ -38,6 +38,9 @@ class Chef when :aws_secrets_manager require_relative "secret_fetcher/aws_secrets_manager" Chef::SecretFetcher::AWSSecretsManager.new(config) + when :azure_key_vault + require_relative "secret_fetcher/azure_key_vault" + Chef::SecretFetcher::AzureKeyVault.new(config) when nil, "" raise Chef::Exceptions::Secret::MissingFetcher.new(SECRET_FETCHERS) else @@ -46,7 +49,6 @@ class Chef fetcher.validate! fetcher end - end end diff --git a/lib/chef/secret_fetcher/azure_key_vault.rb b/lib/chef/secret_fetcher/azure_key_vault.rb new file mode 100644 index 0000000000..05452f0667 --- /dev/null +++ b/lib/chef/secret_fetcher/azure_key_vault.rb @@ -0,0 +1,56 @@ +require_relative "base" + +class Chef + class SecretFetcher + # == Chef::SecretFetcher::AWSSecretsManager + # A fetcher that fetches a secret from Azure Key Vault. Supports fetching with version. + # + # In this initial iteration this authenticates via token obtained from the OAuth2 /token + # endpoint. + # + # Usage Example: + # + # fetcher = SecretFetcher.for_service(:azure_key_vault) + # fetcher.fetch("secretkey1", "v1") + class AzureKeyVault < Base + def validate! + @vault = config[:vault] + if @vault.nil? + raise Chef::Exceptions::Secret::MissingVaultName.new("You must provide a vault name to service options as vault: 'vault_name'") + end + end + + def do_fetch(name, version) + token = fetch_token + + # Note that `version` is optional after the final `/`. If nil/"", the latest secret version will be fetched. + secret_uri = URI.parse("https://#{@vault}.vault.azure.net/secrets/#{name}/#{version}?api-version=7.2") + http = Net::HTTP.new(secret_uri.host, secret_uri.port) + http.use_ssl = true + + response = http.get(secret_uri, { 'Authorization' => "Bearer #{token}", + 'Content-Type' => 'application/json' }) + + # If an exception is not raised, we can be reasonably confident of the + # shape of the result. + result = JSON.parse(response.body) + if result.key? "value" + result["value"] + else + raise Chef::Exceptions::Secret::FetchFailed.new("#{result["error"]["code"]}: #{result["error"]["message"]}") + end + end + + def fetch_token + token_uri = URI.parse("http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fvault.azure.net") + http = Net::HTTP.new(token_uri.host, token_uri.port) + response = http.get(token_uri, { "Metadata" => "true"}) + body = JSON.parse(response.body) + body["access_token"] + end + end + end +end + + + diff --git a/spec/unit/secret_fetcher/azure_key_vault_spec.rb b/spec/unit/secret_fetcher/azure_key_vault_spec.rb new file mode 100644 index 0000000000..61af88caad --- /dev/null +++ b/spec/unit/secret_fetcher/azure_key_vault_spec.rb @@ -0,0 +1,63 @@ + +# +# 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 "../../spec_helper" +require "chef/secret_fetcher" +require "chef/secret_fetcher/azure_key_vault" + +describe Chef::SecretFetcher::AzureKeyVault do + let(:config) { { vault: "myvault" } } + let(:fetcher) { Chef::SecretFetcher::AzureKeyVault.new(config) } + + context "when validating configuration and configuration is missing :vault" do + context "and configuration does not have a 'vault'" do + let(:config) { { } } + it "raises a MissingVaultError error on validate!" do + expect{fetcher.validate!}.to raise_error(Chef::Exceptions::Secret::MissingVaultName) + end + end + end + + context "when performing a fetch" do + let(:body) { "" } + let(:response_mock) { double("response", body: body) } + let(:http_mock) { double("http", :get => response_mock, :use_ssl= => nil) } + + before do + allow(fetcher).to receive(:fetch_token).and_return "a token" + allow(Net::HTTP).to receive(:new).and_return(http_mock) + end + + context "and a valid response is received" do + let(:body) { '{ "value" : "my secret value" }' } + it "returns the expected response" do + expect(fetcher.fetch("value")).to eq "my secret value" + end + end + + context "and an error response is received in the body" do + let(:body) { '{ "error" : { "code" : 404, "message" : "secret not found" } }' } + it "raises FetchFailed" do + expect{fetcher.fetch("value")}.to raise_error(Chef::Exceptions::Secret::FetchFailed) + end + end + + end +end + diff --git a/spec/unit/secret_fetcher_spec.rb b/spec/unit/secret_fetcher_spec.rb index f2a1f96cbb..545176f65a 100644 --- a/spec/unit/secret_fetcher_spec.rb +++ b/spec/unit/secret_fetcher_spec.rb @@ -39,6 +39,10 @@ describe Chef::SecretFetcher do Chef::SecretFetcher.for_service(:example, {}) end + it "resolves the Azure Key Vault fetcher without error" do + Chef::SecretFetcher.for_service(:azure_key_vault, vault: "invalid") + end + it "resolves the AWS fetcher without error" do Chef::SecretFetcher.for_service(:aws_secrets_manager, region: "invalid") end |