summaryrefslogtreecommitdiff
path: root/lib/chef/secret_fetcher/azure_key_vault.rb
blob: 2734234bd440ce7b11ba70681fddb57e44fb3a8f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
require_relative "base"
require_relative "../exceptions"
require "json" unless defined?(JSON)
require "net/http" unless defined?(Net::HTTP)
require "uri" unless defined?(URI)

class Chef
  class SecretFetcher
    # == Chef::SecretFetcher::AzureKeyVault
    # 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.
    #
    # Validation of required configuration (vault name) is not performed until
    # `fetch` time, to allow for embedding the vault name in with the secret
    # name, such as "my_vault/secretkey1".
    #
    # @example
    #
    # fetcher = SecretFetcher.for_service(:azure_key_vault, { vault: "my_vault" }, run_context)
    # fetcher.fetch("secretkey1", "v1")
    #
    # @example
    #
    # fetcher = SecretFetcher.for_service(:azure_key_vault, {}, run_context)
    # fetcher.fetch("my_vault/secretkey1", "v1")
    #
    # @example
    #
    # fetcher = SecretFetcher.for_service(:azure_key_vault, { client_id: "540d76b6-7f76-456c-b68b-ccae4dc9d99d" }, run_context)
    # fetcher.fetch("my_vault/secretkey1", "v1")
    #
    class AzureKeyVault < Base

      def do_fetch(name, version)
        token = fetch_token
        vault, name = resolve_vault_and_secret_name(name)
        if vault.nil?
          raise Chef::Exceptions::Secret::ConfigurationInvalid.new("You must provide a vault name to fetcher options as vault: 'vault_name' or in the secret name as 'vault_name/secret_name'")
        end

        # 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 validate!
        raise Chef::Exceptions::Secret::ConfigurationInvalid, "You may only specify one (these are mutually exclusive): :object_id, :client_id, or :mi_res_id" if [object_id, client_id, mi_res_id].count { |x| !x.nil? } > 1
      end

      private

      # Determine the vault name and secret name from the provided name.
      # If it is not in the provided name in the form "vault_name/secret_name"
      # it will determine the vault name from `config[:vault]`.
      # @param name [String] the secret name or vault and secret name in the form "vault_name/secret_name"
      # @return Array[String, String] vault and secret name respectively
      def resolve_vault_and_secret_name(name)
        # We support a simplified approach where the vault name is not passed i
        # into configuration, but
        if name.include?("/")
          name.split("/", 2)
        else
          [config[:vault], name]
        end
      end

      def api_version
        "2018-02-01"
      end

      def resource
        "https://vault.azure.net"
      end

      def object_id
        config[:object_id]
      end

      def client_id
        config[:client_id]
      end

      def mi_res_id
        config[:mi_res_id]
      end

      def token_query
        @token_query ||= begin
          p = {}
          p["api-version"] = api_version
          p["resource"] = resource
          p["object_id"] = object_id if object_id
          p["client_id"] = client_id if client_id
          p["mi_res_id"] = mi_res_id if mi_res_id
          URI.encode_www_form(p)
        end
      end

      def fetch_token
        token_uri = URI.parse("http://169.254.169.254/metadata/identity/oauth2/token")
        token_uri.query = token_query
        http = Net::HTTP.new(token_uri.host, token_uri.port)
        response = http.get(token_uri, { "Metadata" => "true" })

        case response
        when Net::HTTPSuccess
          body = JSON.parse(response.body)
          body["access_token"]
        when Net::HTTPBadRequest
          body = JSON.parse(response.body)
          raise Chef::Exceptions::Secret::Azure::IdentityNotFound if body["error_description"] =~ /identity not found/i
        else
          body = JSON.parse(response.body)
          body["access_token"]
        end
      end
    end
  end
end