lib/ex_azure_key_vault.ex

defmodule ExAzureKeyVault.Client do
  @moduledoc """
  Client for creating or getting Azure Key Vault.
  """
  alias __MODULE__
  alias ExAzureKeyVault.APIVersion
  alias ExAzureKeyVault.Auth
  alias ExAzureKeyVault.ClientAssertionAuth
  alias ExAzureKeyVault.HTTPUtils
  alias ExAzureKeyVault.Url

  @enforce_keys [:api_version, :bearer_token, :vault_name]
  defstruct(
    api_version: nil,
    bearer_token: nil,
    vault_name: nil
  )

  @type t :: %__MODULE__{
          api_version: String.t(),
          bearer_token: String.t(),
          vault_name: String.t()
        }

  @doc """
  Creates `%ExAzureKeyVault.Client{}` struct with connection information.

  ## Examples

  Using default API version.

      iex(1)> ExAzureKeyVault.Client.new("Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "my-vault")
      %ExAzureKeyVault.Client{
        api_version: "7.3",
        bearer_token: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
        vault_name: "my-vault"
      }

  Passing custom API version.

      iex(1)> ExAzureKeyVault.Client.new("Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "my-vault", "2015-06-01")
      %ExAzureKeyVault.Client{
        api_version: "2015-06-01",
        bearer_token: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
        vault_name: "my-vault"
      }

  """
  @spec new(String.t(), String.t(), String.t() | nil) :: Client.t()
  def new(bearer_token, vault_name, api_version \\ nil) do
    %Client{
      api_version: api_version || APIVersion.version(),
      bearer_token: bearer_token,
      vault_name: vault_name
    }
  end

  @doc """
  Connects to Azure Key Vault.

  ## Examples

  When defining environment variables and/or adding to configuration.

      $ export AZURE_CLIENT_ID="14e79d90-9abf..."
      $ export AZURE_CLIENT_SECRET="14e7a11e-9abf..."
      $ export AZURE_TENANT_ID="14e7a376-9abf..."
      $ export AZURE_VAULT_NAME="my-vault"

      # Config.exs
      config :ex_azure_key_vault,
        azure_client_id: {:system, "AZURE_CLIENT_ID"},
        azure_client_secret: {:system, "AZURE_CLIENT_SECRET"},
        azure_tenant_id: {:system, "AZURE_TENANT_ID"},
        azure_vault_name: {:system, "AZURE_VAULT_NAME"}

      iex(1)> ExAzureKeyVault.Client.connect()
      %ExAzureKeyVault.Client{
        api_version: "7.3",
        bearer_token: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
        vault_name: "my-vault"
      }

  Passing custom parameters.

      iex(1)> ExAzureKeyVault.Client.connect("custom-vault", "14e7a376-9abf...", "14e79d90-9abf...", "14e7a11e-9abf...")
      %ExAzureKeyVault.Client{
        api_version: "7.3",
        bearer_token: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
        vault_name: "custom-vault"
      }

  """
  @spec connect() :: Client.t() | {:error, any}
  @spec connect(String.t() | nil, String.t() | nil, String.t() | nil, String.t() | nil) ::
          Client.t() | {:error, any}
  def connect(vault_name \\ nil, tenant_id \\ nil, client_id \\ nil, client_secret \\ nil) do
    vault_name = get_env(:azure_vault_name, vault_name)
    tenant_id = get_env(:azure_tenant_id, tenant_id)
    client_id = get_env(:azure_client_id, client_id)
    client_secret = get_env(:azure_client_secret, client_secret)
    if is_empty(vault_name), do: raise(ArgumentError, message: "Vault name is not present")
    if is_empty(tenant_id), do: raise(ArgumentError, message: "Tenant ID is not present")
    if is_empty(client_id), do: raise(ArgumentError, message: "Client ID is not present")
    if is_empty(client_secret), do: raise(ArgumentError, message: "Client secret is not present")

    with %Auth{} = auth <- Auth.new(client_id, client_secret, tenant_id),
         {:ok, bearer_token} <- auth |> Auth.get_bearer_token(),
         %Client{} = client <- bearer_token |> Client.new(vault_name, APIVersion.version()) do
      client
    else
      {:error, reason} -> {:error, reason}
    end
  end

  @doc ~S"""
  Connects to Azure Key Vault using client assertion.

  ## Examples

  When defining environment variables and/or adding to configuration.

      $ export AZURE_CLIENT_ID="14e79d90-9abf..."
      $ export AZURE_TENANT_ID="14e7a376-9abf..."
      $ export AZURE_VAULT_NAME="my-vault"
      $ export AZURE_CERT_BASE64_THUMBPRINT="Dss7v2YI3GgCGfl..."
      $ export AZURE_CERT_PRIVATE_KEY_PEM="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEF..."

      # Config.exs
      config :ex_azure_key_vault,
        azure_client_id: {:system, "AZURE_CLIENT_ID"},
        azure_tenant_id: {:system, "AZURE_TENANT_ID"},
        azure_vault_name: {:system, "AZURE_VAULT_NAME"}
        azure_cert_base64_thumbprint: {:system, "AZURE_CERT_BASE64_THUMBPRINT"},
        azure_cert_private_key_pem: {:system, "AZURE_CERT_PRIVATE_KEY_PEM"}

      iex(1)> ExAzureKeyVault.Client.cert_connect()
      %ExAzureKeyVault.Client{
        api_version: "7.3",
        bearer_token: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
        vault_name: "my-vault"
      }

  Passing custom parameters.

      iex(1)> ExAzureKeyVault.Client.cert_connect("custom-vault", "14e7a376-9abf...", "14e79d90-9abf...", "Dss7v2YI3GgCGfl...", "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEF...")
      %ExAzureKeyVault.Client{
        api_version: "7.3",
        bearer_token: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
        vault_name: "custom-vault"
      }

  """
  @spec cert_connect() :: Client.t() | {:error, any}
  @spec cert_connect(
          String.t() | nil,
          String.t() | nil,
          String.t() | nil,
          String.t() | nil,
          String.t() | nil
        ) :: Client.t() | {:error, any}
  def cert_connect(
        vault_name \\ nil,
        tenant_id \\ nil,
        client_id \\ nil,
        cert_base64_thumbprint \\ nil,
        cert_private_key_pem \\ nil
      ) do
    vault_name = get_env(:azure_vault_name, vault_name)
    tenant_id = get_env(:azure_tenant_id, tenant_id)
    client_id = get_env(:azure_client_id, client_id)
    cert_base64_thumbprint = get_env(:azure_cert_base64_thumbprint, cert_base64_thumbprint)
    cert_private_key_pem = get_env(:azure_cert_private_key_pem, cert_private_key_pem)
    if is_empty(vault_name), do: raise(ArgumentError, message: "Vault name is not present")
    if is_empty(tenant_id), do: raise(ArgumentError, message: "Tenant ID is not present")
    if is_empty(client_id), do: raise(ArgumentError, message: "Client ID is not present")

    if is_empty(cert_base64_thumbprint),
      do: raise(ArgumentError, message: "Certificate base64 thumbprint is not present")

    if is_empty(cert_private_key_pem),
      do: raise(ArgumentError, message: "Certificate private key PEM is not present")

    with cert_private_key_pem <- cert_private_key_pem |> String.replace("\\n", "\n"),
         %ClientAssertionAuth{} = auth <-
           ClientAssertionAuth.new(
             client_id,
             tenant_id,
             cert_base64_thumbprint,
             cert_private_key_pem
           ),
         {:ok, bearer_token} <- auth |> ClientAssertionAuth.get_bearer_token(),
         %Client{} = client <- bearer_token |> Client.new(vault_name, APIVersion.version()) do
      client
    else
      {:error, reason} -> {:error, reason}
    end
  end

  @doc """
  Returns secret value.

  ## Examples

  Ignoring secret version.

      iex(1)> ExAzureKeyVault.Client.connect() |> ExAzureKeyVault.Client.get_secret("my-secret")
      {:ok, "my-value"}

  Passing secret version.

      iex(1)> ExAzureKeyVault.Client.connect() |> ExAzureKeyVault.Client.get_secret("my-secret", "03b424a49ac3...")
      {:ok, "my-other-value"}

  """
  @spec get_secret(Client.t(), String.t(), String.t() | nil) :: {:ok, String.t()} | {:error, any}
  def get_secret(%Client{} = params, secret_name, secret_version \\ nil) do
    url =
      Url.new(secret_name, params.vault_name) |> Url.get_url(secret_version, params.api_version)

    headers = HTTPUtils.headers_authorization(params.bearer_token)
    options = HTTPUtils.options_ssl()

    HTTPoison.get(url, headers, options)
    |> handle_http_response(url, fn body ->
      response = Jason.decode!(body)
      {:ok, response["value"]}
    end)
  end

  @doc """
  Returns list of secrets.

  ## Examples

  Passing a maximum number of 2 results in a page.

      iex(1)> ExAzureKeyVault.Client.connect() |> ExAzureKeyVault.Client.get_secrets(2)
      {:ok,
        %{
          "nextLink" => "https://my-vault.vault.azure.net:443/secrets?api-version=7.3&$skiptoken=eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6...&maxresults=2",
          "value" => [
            %{
              "attributes" => %{
                "created" => 1533704004,
                "enabled" => true,
                "recoveryLevel" => "Purgeable",
                "updated" => 1533704004
              },
              "id" => "https://my-vault.vault.azure.net/secrets/my-secret"
            },
            %{
              "attributes" => %{
                "created" => 1532633078,
                "enabled" => true,
                "recoveryLevel" => "Purgeable",
                "updated" => 1532633078
              },
              "id" => "https://my-vault.vault.azure.net/secrets/another-secret"
            }
          ]
        }}

  Ignoring maximum number of results.

      iex(1)> ExAzureKeyVault.Client.connect() |> ExAzureKeyVault.Client.get_secrets()
      {:ok,
        %{
          "nextLink" => nil,
          "value" => [
            %{
              "attributes" => %{
                "created" => 1533704004,
                "enabled" => true,
                "recoveryLevel" => "Purgeable",
                "updated" => 1533704004
              },
              "id" => "https://my-vault.vault.azure.net/secrets/my-secret"
            },
            %{
              "attributes" => %{
                "created" => 1532633078,
                "enabled" => true,
                "recoveryLevel" => "Purgeable",
                "updated" => 1532633078
              },
              "id" => "https://my-vault.vault.azure.net/secrets/another-secret"
            },
            %{
              "attributes" => %{
                "created" => 1532633078,
                "enabled" => true,
                "recoveryLevel" => "Purgeable",
                "updated" => 1532633078
              },
              "id" => "https://my-vault.vault.azure.net/secrets/test-secret"
            }
          ]
        }}
  """
  @spec get_secrets(Client.t(), integer | nil) :: {:ok, String.t()} | {:error, any}
  def get_secrets(%Client{} = params, max_results \\ nil) do
    url = Url.new(nil, params.vault_name) |> Url.get_secrets_url(max_results, params.api_version)
    headers = HTTPUtils.headers_authorization(params.bearer_token)
    options = HTTPUtils.options_ssl()

    HTTPoison.get(url, headers, options)
    |> handle_http_response(url, fn body ->
      response = Jason.decode!(body)
      {:ok, response}
    end)
  end

  @doc """
  Returns next page of secrets in the pagination.

  ## Examples

      iex(1)> client = ExAzureKeyVault.Client.connect()
      ...
      iex(1)> {_, secrets} = client |> ExAzureKeyVault.Client.get_secrets(2)
      ...
      iex(1)> {_, next_secrets} = client |> ExAzureKeyVault.Client.get_secrets_next(secrets["nextLink"])
      {:ok,
        %{
          "nextLink" => nil,
          "value" => [
            %{
              "attributes" => %{
                "created" => 1532633078,
                "enabled" => true,
                "recoveryLevel" => "Purgeable",
                "updated" => 1532633078
              },
              "id" => "https://my-vault.vault.azure.net/secrets/test-secret"
            }
          ]
        }}
  """
  @spec get_secrets_next(Client.t(), String.t()) :: {:ok, String.t()} | {:error, any}
  def get_secrets_next(%Client{} = params, next_link) do
    if is_empty(next_link), do: raise(ArgumentError, message: "Next link is not present")

    unless next_link
           |> String.starts_with?("https://#{params.vault_name}.vault.azure.net") do
      raise ArgumentError, message: "Next link #{next_link} is not valid"
    end

    headers = HTTPUtils.headers_authorization(params.bearer_token)
    options = HTTPUtils.options_ssl()

    HTTPoison.get(next_link, headers, options)
    |> handle_http_response(next_link, fn body ->
      response = Jason.decode!(body)
      {:ok, response}
    end)
  end

  @doc """
  Creates a new secret.

  ## Examples

      iex(1)> ExAzureKeyVault.Client.connect() |> ExAzureKeyVault.Client.create_secret("my-new-secret", "my-new-value")
      :ok

  """
  @spec create_secret(Client.t(), String.t(), String.t()) :: :ok | {:error, any}
  def create_secret(%Client{} = params, secret_name, secret_value) do
    url = Url.new(secret_name, params.vault_name) |> Url.get_url(params.api_version)
    body = Url.get_body(secret_value)
    headers = HTTPUtils.headers_authorization(params.bearer_token)
    options = HTTPUtils.options_ssl()
    HTTPoison.put(url, body, headers, options) |> handle_http_response(url)
  end

  @doc """
  Deletes a secret.

  ## Examples

      iex(1)> ExAzureKeyVault.Client.connect() |> ExAzureKeyVault.Client.delete_secret("my-secret")
      :ok

  """
  @spec delete_secret(Client.t(), String.t()) :: :ok | {:error, any}
  def delete_secret(%Client{} = params, secret_name) do
    url = Url.new(secret_name, params.vault_name) |> Url.get_url(params.api_version)
    headers = HTTPUtils.headers_authorization(params.bearer_token)
    options = HTTPUtils.options_ssl()
    HTTPoison.delete(url, headers, options) |> handle_http_response(url)
  end

  @spec get_env(atom, String.t() | nil) :: String.t()
  defp get_env(key, default) do
    default || Application.get_env(:ex_azure_key_vault, key) |> return_value()
  end

  @spec return_value(tuple) :: String.t()
  defp return_value({:system, key}) when is_binary(key) do
    System.get_env(key)
  end

  @spec return_value(String.t()) :: String.t()
  defp return_value(value), do: value

  @spec is_empty(String.t() | nil) :: boolean
  defp is_empty(string) do
    is_nil(string) || String.trim(string) == ""
  end

  defp handle_http_response(http_response, request_url, callback_fun \\ fn _body -> :ok end) do
    case http_response do
      {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
        callback_fun.(body)

      {:ok, %HTTPoison.Response{status_code: status, body: ""}} ->
        HTTPUtils.response_client_error_or_ok(status, request_url)

      {:ok, %HTTPoison.Response{status_code: status, body: body}} ->
        HTTPUtils.response_client_error_or_ok(status, request_url, body)

      {:error, %HTTPoison.Error{reason: :nxdomain}} ->
        HTTPUtils.response_server_error(:nxdomain, request_url)

      {:error, %HTTPoison.Error{reason: reason}} ->
        HTTPUtils.response_server_error(reason)

      _ ->
        {:error, "Something went wrong"}
    end
  end
end