tsafe-cli 1.0.21

tsafe CLI — local secret and credential manager (replaces .env files)
Documentation
//! Core `tsafe kv-pull` handler — import secrets from Azure Key Vault.

use anyhow::{Context, Result};
use colored::Colorize;
use tsafe_azure::{acquire_token, pull_secrets, KvConfig};
use tsafe_core::{audit::AuditEntry, events::emit_event};

use crate::helpers::*;
use tsafe_cli::cli::PullOnError;

const AKV_TEST_LOCAL_URL_ENV: &str = "TSAFE_AKV_TEST_LOCAL_URL";
const AKV_TEST_TOKEN_ENV: &str = "TSAFE_AKV_TEST_TOKEN";

fn is_allowed_local_test_url(url: &str) -> bool {
    url.starts_with("http://127.0.0.1:")
        || url.starts_with("http://localhost:")
        || url.starts_with("http://[::1]:")
}

fn local_test_override() -> Result<Option<(KvConfig, String)>> {
    #[cfg(debug_assertions)]
    {
        if let Ok(raw_url) = std::env::var(AKV_TEST_LOCAL_URL_ENV) {
            let url = raw_url.trim();
            if !is_allowed_local_test_url(url) {
                anyhow::bail!(
                    "{AKV_TEST_LOCAL_URL_ENV} must use http://127.0.0.1:<port>, http://localhost:<port>, or http://[::1]:<port>"
                );
            }
            let token = std::env::var(AKV_TEST_TOKEN_ENV).with_context(|| {
                format!("{AKV_TEST_TOKEN_ENV} must be set when {AKV_TEST_LOCAL_URL_ENV} is used")
            })?;
            return Ok(Some((
                KvConfig {
                    vault_url: url.to_string(),
                },
                token,
            )));
        }
    }

    Ok(None)
}

pub(crate) fn cmd_kv_pull(
    profile: &str,
    prefix: Option<&str>,
    overwrite: bool,
    on_error: PullOnError,
) -> Result<()> {
    cmd_kv_pull_ns(profile, prefix, overwrite, on_error, None)
}

/// Inner implementation that supports an optional namespace prefix (ADR-012).
///
/// When `ns` is `Some("prod")`, every key name returned from Key Vault is
/// stored as `prod.KEY_NAME` in the local vault.
pub(crate) fn cmd_kv_pull_ns(
    profile: &str,
    prefix: Option<&str>,
    overwrite: bool,
    on_error: PullOnError,
    ns: Option<&str>,
) -> Result<()> {
    let (cfg, test_token) = match local_test_override()? {
        Some((cfg, token)) => (cfg, Some(token)),
        None => (
            KvConfig::from_env().context("Key Vault is not configured — set TSAFE_AKV_URL")?,
            None,
        ),
    };

    let secrets = match pull_secrets(
        &cfg,
        &|| match &test_token {
            Some(token) => Ok(token.clone()),
            None => acquire_token(),
        },
        prefix,
    )
    .context("failed to pull secrets from Azure Key Vault")
    {
        Ok(secrets) => secrets,
        Err(err) => match on_error {
            PullOnError::FailAll => return Err(err),
            PullOnError::SkipFailed | PullOnError::WarnOnly => {
                eprintln!("{} Azure Key Vault pull failed: {err}", "!".yellow());
                return Ok(());
            }
        },
    };

    if secrets.is_empty() {
        println!(
            "{} No secrets found in Key Vault matching the filter",
            "i".blue()
        );
        return Ok(());
    }

    let mut vault = open_vault(profile)?;
    let mut imported = 0usize;
    let mut skipped = 0usize;

    for (raw_key, value) in &secrets {
        // ADR-012: apply per-source namespace prefix when declared in the manifest.
        let key = match ns {
            Some(prefix) => format!("{prefix}.{raw_key}"),
            None => raw_key.clone(),
        };
        let exists = vault.list().contains(&key.as_str());
        if exists && !overwrite {
            skipped += 1;
            continue;
        }
        vault.set(&key, value, std::collections::HashMap::new())?;
        imported += 1;
    }

    audit(profile)
        .append(&AuditEntry::success(profile, "kv-pull", None))
        .ok();
    emit_event(profile, "kv-pull", None);
    println!(
        "{} Imported {imported} secret(s) from Key Vault '{}'{}",
        "".green(),
        cfg.vault_url,
        if skipped > 0 {
            format!(" ({skipped} skipped — use --overwrite to replace)")
        } else {
            String::new()
        }
    );
    Ok(())
}