lib/providers/azure_managed_identity.ex

defmodule ExSecrets.Providers.AzureManagedIdentity do
  use ExSecrets.Providers.Base

  alias ExSecrets.Utils.Config

  @moduledoc """
  Azure Key Vault provider provides secrets from an Azure Key Vault through a rest API.

  Only the keyvault name is required here once the managed identity has been given access to the keyvault.

  ```
      config :ex_secrets, :providers, %{
         azure_managed_identity: %{
         key_vault_name: "key-vault-name"
      }
  ```

  The provider will handle token renewals and secret fetch.
  """

  @headers %{"Content-Type" => "application/x-www-form-urlencoded", "Metadata" => "true"}
  @process_name :ex_secrets_azure_managed_identity

  def init(_) do
    case get_access_token() do
      {:ok, data} ->
        {:ok, data |> Map.put("issued_at", get_current_epoch())}

      _ ->
        {:ok, %{}}
    end
  end

  def reset() do
    :ok
  end

  def get(name) do
    name = name |> String.split("_") |> Enum.join("-")

    with process when not is_nil(process) <-
           GenServer.whereis(@process_name) do
      GenServer.call(@process_name, {:get, name})
    else
      nil ->
        case get_secret(name, %{}, nil) do
          {:ok, value, _} -> value
          _ -> nil
        end
    end
  end

  def set(name, value) do
    name = name |> String.split("_") |> Enum.join("-")

    with process when not is_nil(process) <-
           GenServer.whereis(@process_name) do
      GenServer.call(@process_name, {:set, name, value})
    else
      nil ->
        case set_secret(name, value, %{}, nil) do
          {:ok, _value, _} ->
            :ok

          _ ->
            :error
        end
    end
  end

  def handle_call({:get, name}, _from, state) do
    case get_secret(name, state, get_current_epoch()) do
      {:ok, secret, state} -> {:reply, secret, state}
      _ -> {:reply, nil, state}
    end
  end

  def handle_call({:set, name, value}, _from, state) do
    case set_secret(name, value, state, get_current_epoch()) do
      {:ok, _secret, state} -> {:reply, :ok, state}
      _ -> {:reply, :error, state}
    end
  end

  defp token_uri() do
    "http://169.254.169.254/metadata/identity/oauth2/token"
    |> Kernel.<>("?api-version=2018-02-01&resource=https://vault.azure.net")
  end

  defp get_secret(
         name,
         %{"access_token" => access_token, "issued_at" => issued_at, "expires_in" => expires_in} =
           state,
         current_time
       )
       when issued_at + expires_in - current_time > 5 do
    with {:ok, value} <- get_secret_call(name, access_token),
         true <- is_binary(value) do
      {:ok, value, state}
    else
      _ -> {:error, "Failed to get secret"}
    end
  end

  defp get_secret(name, state, _) do
    with {:ok, %{"access_token" => access_token} = new_state} <- get_access_token(),
         {:ok, value} <- get_secret_call(name, access_token) do
      {:ok, value, state |> Map.merge(new_state)}
    else
      _ -> {:error, "Failed to get secret"}
    end
  end

  def set_secret(
        name,
        value,
        %{"access_token" => access_token, "issued_at" => issued_at, "expires_in" => expires_in} =
          state,
        current_time
      )
      when issued_at + expires_in - current_time > 5 do
    with {:ok, value} <- set_secret_call(name, value, access_token),
         true <- is_binary(value) do
      {:ok, value, state}
    else
      err ->
        err
    end
  end

  def set_secret(name, value, state, _) do
    with {:ok, %{"access_token" => access_token} = new_state} <-
           get_access_token(),
         {:ok, value} <- set_secret_call(name, value, access_token) do
      {:ok, value, state |> Map.merge(new_state) |> Map.put("issued_at", get_current_epoch())}
    else
      _ -> {:error, "Failed to get secret"}
    end
  end

  defp get_secret_call(name, access_token) do
    client = http_adpater()

    with {:ok, %{body: body, status_code: 200}} <-
           name
           |> secret_url()
           |> client.get(%{"Authorization" => "Bearer #{access_token}"}),
         {:ok, %{"value" => value}} <- Poison.decode(body) do
      {:ok, value}
    else
      _ -> {:error, "Failed to get secret"}
    end
  end

  defp set_secret_call(name, value, access_token) do
    client = http_adpater()

    with {:ok, %{body: body, status_code: 200}} <-
           name
           |> secret_url()
           |> client.put(Poison.encode!(%{value: value}), %{
             "Authorization" => "Bearer #{access_token}",
             "content-type" => "application/json"
           }),
         {:ok, %{"value" => value}} <- Poison.decode(body) do
      {:ok, value}
    else
      err -> err
    end
  end

  defp get_access_token() do
    client = http_adpater()

    with {:ok, %{body: body, status_code: 200}} <-
           token_uri()
           |> client.get(@headers),
         {:ok, data} <- Poison.decode(body) do
      {:ok, data |> Map.put("issued_at", get_current_epoch())}
    else
      _ ->
        {:error, "Failed to get access token"}
    end
  end

  defp get_current_epoch() do
    System.system_time(:second)
  end

  defp http_adpater() do
    Application.get_env(:ex_secrets, :http_adapter, HTTPoison)
  end

  defp secret_url(name) do
    key_vault_name = Config.provider_config_value(:azure_managed_identity, :key_vault_name)
    "https://#{key_vault_name}.vault.azure.net/secrets/#{name}?api-version=2016-10-01"
  end

  def process_name() do
    @process_name
  end
end