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