arclutevests 0.1.0

Retrieve secret data from a vault (Hashicorp) instance
Documentation
// Copyright (c) 2022 arclutevests developers
//
// Licensed under the Apache License, Version 2.0
// <LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0> or the MIT
// license <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
// option. All files in the project carrying such notice may not be copied,
// modified, or distributed except according to those terms.

#[cfg(feature = "check_approle")]
use crate::model::vault::AppRolesResponse;
#[cfg(feature = "wrapped")]
use crate::model::{approle::AppRole, vault::EmptyResponse};
use crate::{
    constants::X_VAULT_TOKEN,
    error::{Error, Result},
    model::vault::{AttResponse, AuthResponse, Login, SecretDataResponse},
    util::{as_empty, as_json, req, EMPTY_BODY},
    Config,
};
use futures::FutureExt;
use reqwest::{
    header::{HeaderMap, HeaderValue},
    Client, Method,
};
use serde::de::DeserializeOwned;
use tracing::{error, trace};
use uuid::Uuid;

#[cfg(feature = "wrapped")]
/// Get the secrets data from vault, given an approle and associated `role-id`.
///
/// # Example
/// ```
/// # use anyhow::Result;
/// # use arclutevests::{secrets, Config};
/// # use serde::Deserialize;
/// # use uuid::uuid;
/// #
/// #[derive(Clone, Debug, Deserialize)]
/// struct TestData {
///     test_data: String,
/// }
/// #
/// #[tokio::main]
/// async fn main() -> Result<()> {
/// #    let uuid = uuid!("3628c169-fbe8-b5b8-b529-434f8d64fe38");
/// #    let config = Config::builder()
/// #        .vault_base_url("https://vault.allthetyme.info:8200/v1/")
/// #        .app_role("test")
/// #        .secrets_path("test/data/config")
/// #        .role_id(uuid)
/// #        .toad_base_url("https://toad.allthetyme.info:8443/")
/// #        .build();
///     let test_data: TestData = secrets(&config).await?;
///     assert_eq!(test_data.test_data, "this isn't really secret");
/// #
/// #    let uuid_bad = uuid!("3628c169-fbe8-b5b8-b529-434f8d64fe37");
/// #    let config_bad = Config::builder()
/// #        .vault_base_url("https://vault.allthetyme.info:8200/v1/")
/// #        .app_role("test")
/// #        .secrets_path("test/data/config")
/// #        .role_id(uuid_bad)
/// #        .toad_base_url("https://toad.allthetyme.info:8443/")
/// #        .build();
///     match secrets::<TestData>(&config_bad).await {
///         Ok(_) => assert!(false, "This shouldn't happen"),
///         Err(e) => assert!(format!("{e}").starts_with("uri: /v1/s/error/authfailed, title: Authentication Failed, status: 500, detail: Vault was unable to authenticate the client")),
///     }
///     Ok(())
/// }
/// ```
///
/// # Errors
///
/// * Error building the reqwest client
/// * Error getting the wrapping token response from vault
/// * Error on an empty wrapping token response
/// * Error converting the wrap token into a header value
/// * Error getting the secret data response from vault
/// * Error on empty secret data response
/// * Error response attempting to login to vault
/// * Error on an empty login respone
/// * Error converting the client token into a header value
/// * Error getting the secret data response from vault
/// * Error on an empty secret data response
///
pub async fn secrets<T>(config: &Config) -> Result<T>
where
    T: DeserializeOwned + Clone,
{
    let client = build_client()?;
    if let Ok(true) = check_approle(&client, config).await {
        let wrap_token = get_wrap_token(&client, config).await?;
        let secret_id = get_approle_secret_id(&client, &wrap_token, config).await?;
        let client_token = get_client_token(&client, secret_id, config).await?;
        let secrets = get_secret_data(&client, &client_token, config).await?;
        revoke_token(&client, &client_token, config).await?;
        Ok(secrets)
    } else {
        Err(Error::wrapped_response())
    }
}

#[cfg(not(feature = "wrapped"))]
/// Get the secrets data from vault, given an approle and associated `role-id`.
///
/// # Example
/// ```no_run
/// # use anyhow::Result;
/// # use arclutevests::{secrets, Config};
/// # use serde::Deserialize;
/// # use uuid::uuid;
/// #
/// #[derive(Clone, Debug, Deserialize)]
/// struct TestData {
///     test_data: String,
/// }
/// #
/// #[tokio::main]
/// async fn main() -> Result<()> {
/// #    let uuid = uuid!("3628c169-fbe8-b5b8-b529-434f8d64fe38");
/// #    let config = Config::builder()
/// #        .vault_base_url("https://vault.allthetyme.info:8200/v1/")
/// #        .app_role("test")
/// #        .secrets_path("test/data/config")
/// #        .role_id(uuid)
/// #        .wrapping_token("")
/// #        .build();
///     let test_data: TestData = secrets(&config).await?;
///     assert_eq!(test_data.test_data, "this isn't really secret");
/// #
/// #    let uuid_bad = uuid!("3628c169-fbe8-b5b8-b529-434f8d64fe37");
/// #    let config_bad = Config::builder()
/// #        .vault_base_url("https://vault.allthetyme.info:8200/v1/")
/// #        .app_role("test")
/// #        .secrets_path("test/data/config")
/// #        .role_id(uuid)
/// #        .wrapping_token("")
/// #        .build();
///     match secrets::<TestData>(&config_bad).await {
///         Ok(_) => assert!(false, "This shouldn't happen"),
///         Err(e) => assert!(format!("{e}").starts_with("uri: /v1/s/error/authfailed, title: Authentication Failed, status: 500, detail: Vault was unable to authenticate the client")),
///     }
///     Ok(())
/// }
/// ```
///
/// # Errors
///
/// * Error building the reqwest client
/// * Error getting the wrapping token response from vault
/// * Error on an empty wrapping token response
/// * Error converting the wrap token into a header value
/// * Error getting the secret data response from vault
/// * Error on empty secret data response
/// * Error response attempting to login to vault
/// * Error on an empty login respone
/// * Error converting the client token into a header value
/// * Error getting the secret data response from vault
/// * Error on an empty secret data response
///
pub async fn secrets<T>(config: &Config) -> Result<T>
where
    T: DeserializeOwned + Clone,
{
    let client = build_client()?;
    let secret_id = get_approle_secret_id(&client, config.wrapping_token(), config).await?;
    let client_token = get_client_token(&client, secret_id, config).await?;
    let secrets = get_secret_data(&client, &client_token, config).await?;
    revoke_token(&client, &client_token, config).await?;

    Ok(secrets)
}

#[cfg(windows)]
fn build_client() -> Result<Client> {
    Client::builder().build().map_err(Error::reqwest_error)
}

#[cfg(not(windows))]
fn build_client() -> Result<Client> {
    Client::builder()
        .trust_dns(true)
        .use_rustls_tls()
        .build()
        .map_err(Error::reqwest_error)
}

#[cfg(feature = "check_approle")]
async fn check_approle(client: &Client, config: &Config) -> Result<bool> {
    trace!("Checking the approle against toad");
    let approles_res: AppRolesResponse = req(
        client,
        Method::GET,
        config.toad_approles_url(),
        None,
        EMPTY_BODY,
    )
    .then(as_json)
    .await
    .map_err(Error::approles_check_failed)?;

    let approles = approles_res
        .data()
        .as_ref()
        .ok_or_else(Error::approles_response)?;
    Ok(approles.keys().contains(config.app_role()))
}

#[cfg(all(feature = "wrapped", not(feature = "check_approle")))]
#[allow(clippy::unused_async)]
async fn check_approle(_: &Client, _: &Config) -> Result<bool> {
    Ok(true)
}

#[cfg(feature = "wrapped")]
async fn get_wrap_token(client: &Client, config: &Config) -> Result<String> {
    trace!("Getting the wrapping token from toad");
    let app_role = AppRole::new(config.app_role());
    let wrap_tok_res: EmptyResponse = req(
        client,
        Method::POST,
        config.toad_approle_url(),
        None,
        Some(app_role),
    )
    .then(as_json)
    .await
    .map_err(Error::token_wrap_failed)?;

    let wrap_info = wrap_tok_res
        .wrap_info()
        .as_ref()
        .ok_or_else(Error::wrapped_response)?;
    Ok(wrap_info.token().clone())
}

async fn get_approle_secret_id(client: &Client, wrap_token: &str, config: &Config) -> Result<Uuid> {
    trace!("Getting the app role secret id from vault");
    let headers = add_x_vault_token_header(wrap_token)?;
    let unwrap_res: SecretDataResponse = req(
        client,
        Method::POST,
        config.unwrap_url(),
        Some(headers),
        EMPTY_BODY,
    )
    .then(as_json)
    .await
    .map_err(Error::token_unwrap_failed)?;

    let data = unwrap_res.data().as_ref().ok_or_else(Error::vault_data)?;
    Ok(*data.secret_id())
}

async fn get_client_token(client: &Client, secret_id: Uuid, config: &Config) -> Result<String> {
    trace!("Getting the client token from vault");
    let login_res: AuthResponse = req(
        client,
        Method::POST,
        config.login_url(),
        None,
        Some(
            Login::builder()
                .role_id(*config.role_id())
                .secret_id(secret_id)
                .build(),
        ),
    )
    .then(as_json)
    .await
    .map_err(Error::auth_failed)?;

    let auth = login_res.auth().as_ref().ok_or_else(Error::vault_auth)?;
    Ok(auth.client_token().clone())
}

async fn get_secret_data<T>(client: &Client, client_token: &str, config: &Config) -> Result<T>
where
    T: DeserializeOwned + Clone,
{
    trace!("Getting the secret data from vault");
    let headers = add_x_vault_token_header(client_token)?;
    let secrets_res: AttResponse<T> = req(
        client,
        Method::GET,
        config.secrets_url(),
        Some(headers),
        EMPTY_BODY,
    )
    .then(as_json)
    .await
    .map_err(Error::data_retrieval_failed)?;

    let data = secrets_res.data().as_ref().ok_or_else(Error::vault_data)?;
    Ok(data.data().clone())
}

async fn revoke_token(client: &Client, client_token: &str, config: &Config) -> Result<()> {
    trace!("Revoking the client token");
    let headers = add_x_vault_token_header(client_token)?;
    if let Err(err) = req(
        client,
        Method::POST,
        config.revoke_url(),
        Some(headers),
        EMPTY_BODY,
    )
    .then(as_empty)
    .await
    .map_err(Error::token_revoke_failed)
    {
        error!("{err}");
    }

    Ok(())
}

fn add_x_vault_token_header(value: &str) -> Result<HeaderMap> {
    let mut headers = HeaderMap::new();
    let _h = headers.insert(
        X_VAULT_TOKEN,
        HeaderValue::from_str(value).map_err(Error::invalid_header_error)?,
    );
    Ok(headers)
}