tsafe-cli 1.0.21

tsafe CLI — local secret and credential manager (replaces .env files)
Documentation
//! `tsafe aws-push` — push local vault secrets to AWS Secrets Manager.
//!
//! Implements the ADR-030 write contract: upsert semantics, pre-flight diff,
//! confirmation prompt, `--dry-run`, `--delete-missing` (off by default), and
//! audit log entries with no plaintext secret values.

use std::collections::HashMap;
use std::io::{IsTerminal, Write as _};

use anyhow::{Context, Result};
use colored::Colorize;
use sha2::{Digest, Sha256};
use tsafe_cli::tsafe_aws::{
    pull_secrets, push_secret, AwsConfig, AwsCredentials, AwsError, SmPushOutcome,
};
use tsafe_core::{audit::AuditEntry, events::emit_event};

use crate::helpers::*;

/// Compute a 12-hex-char SHA-256 fingerprint of a secret value.
/// Used for diff display only — never a full hash for security purposes.
fn value_fingerprint(value: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(value.as_bytes());
    let result = hasher.finalize();
    result[..6].iter().map(|b| format!("{b:02x}")).collect()
}

/// Reverse-normalize a local vault key name to an AWS Secrets Manager secret name.
/// `MY_SECRET` → `my-secret`.
pub fn normalize_to_provider(local_key: &str) -> String {
    local_key.replace('_', "-").to_lowercase()
}

#[tracing::instrument(skip_all, fields(provider = "aws-sm", dry_run))]
pub(crate) fn cmd_aws_push(
    profile: &str,
    region: Option<&str>,
    prefix: Option<&str>,
    dry_run: bool,
    yes: bool,
    delete_missing: bool,
) -> Result<()> {
    tracing::Span::current().record("dry_run", dry_run);

    let mut cfg = match region {
        Some(r) => {
            let endpoint = format!("https://secretsmanager.{r}.amazonaws.com");
            AwsConfig::with_endpoint(r, endpoint)
        }
        None => AwsConfig::from_env().with_context(|| {
            "AWS region is not configured\n\
             \n  Fix:  export AWS_DEFAULT_REGION=us-east-1  (or pass --region)\
             \n  Help: tsafe explain pull-auth"
        })?,
    };

    // Test / LocalStack endpoint override (e.g. mockito in integration tests).
    if let Ok(test_url) = std::env::var("TSAFE_AWS_SM_TEST_ENDPOINT") {
        cfg.endpoint = test_url;
    } else if !cfg.endpoint.contains("secretsmanager") {
        cfg.endpoint = format!("https://secretsmanager.{}.amazonaws.com", cfg.region);
    }

    let get_creds = || -> Result<AwsCredentials, AwsError> {
        AwsCredentials::from_env_or_imds().map_err(|e| AwsError::Auth(format!("{e}")))
    };

    // ── 1. Fetch remote secrets (list + get) ──────────────────────────────────
    // pull_secrets returns (UPPER_SNAKE, value); re-key by provider name for diff.
    let remote_raw: Vec<(String, String)> =
        pull_secrets(&cfg, &get_creds, prefix).with_context(|| {
            "failed to fetch secrets from AWS Secrets Manager\n\
             \n  Credential setup: tsafe explain pull-auth\
             \n  Required env:    AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY + AWS_DEFAULT_REGION"
        })?;

    // Build a map: provider_name (lower-hyphen) → value
    let mut remote_secrets: HashMap<String, String> = HashMap::new();
    for (upper_key, value) in &remote_raw {
        // pull_secrets returns UPPER_SNAKE; reverse to lower-hyphen for comparison.
        let provider_name = upper_key.replace('_', "-").to_lowercase();
        remote_secrets.insert(provider_name, value.clone());
    }

    // ── 2. Fetch local vault secrets ──────────────────────────────────────────
    let vault = open_vault(profile)?;
    let all_keys: Vec<String> = vault
        .list()
        .iter()
        .map(|k| k.to_string())
        .filter(|k| {
            if let Some(pfx) = prefix {
                return k.to_uppercase().starts_with(&pfx.to_uppercase());
            }
            true
        })
        .collect();

    // ── 3. Collision detection (pre-flight) ───────────────────────────────────
    let mut provider_name_map: HashMap<String, Vec<String>> = HashMap::new();
    for local_key in &all_keys {
        let provider_name = normalize_to_provider(local_key);
        provider_name_map
            .entry(provider_name)
            .or_default()
            .push(local_key.clone());
    }
    let mut collisions: Vec<(String, Vec<String>)> = provider_name_map
        .into_iter()
        .filter(|(_, locals)| locals.len() > 1)
        .collect();
    collisions.sort_by(|(a, _), (b, _)| a.cmp(b));
    if !collisions.is_empty() {
        let msg = collisions
            .iter()
            .map(|(provider, locals)| {
                format!(
                    "  provider key '{}' claimed by: {}",
                    provider,
                    locals.join(", ")
                )
            })
            .collect::<Vec<_>>()
            .join("\n");
        anyhow::bail!(
            "reverse-normalization collision detected — two local keys map to the same \
             AWS Secrets Manager name:\n{msg}\n\
             Rename one of the colliding local keys before pushing."
        );
    }

    // ── 4. Compute diff ───────────────────────────────────────────────────────
    let mut to_create: Vec<(String, String, String)> = Vec::new(); // (local, provider, hash)
    let mut to_update: Vec<(String, String, String, String)> = Vec::new(); // (local, provider, old_hash, new_hash)
    let mut unchanged = 0usize;

    for local_key in &all_keys {
        let provider_name = normalize_to_provider(local_key);
        let local_value = vault.get(local_key).map_err(|e| anyhow::anyhow!("{e}"))?;
        let local_hash = value_fingerprint(local_value.as_str());

        match remote_secrets.get(&provider_name) {
            None => {
                to_create.push((local_key.clone(), provider_name, local_hash));
            }
            Some(remote_value) => {
                let remote_hash = value_fingerprint(remote_value);
                if remote_hash == local_hash {
                    unchanged += 1;
                } else {
                    to_update.push((local_key.clone(), provider_name, remote_hash, local_hash));
                }
            }
        }
    }

    // Deletions: remote keys not present in local selection (only with --delete-missing)
    let mut to_delete: Vec<String> = Vec::new();
    if delete_missing {
        let local_provider_names: std::collections::HashSet<String> =
            all_keys.iter().map(|k| normalize_to_provider(k)).collect();
        for provider_name in remote_secrets.keys() {
            if !local_provider_names.contains(provider_name) {
                let should_delete = if let Some(pfx) = prefix {
                    provider_name
                        .to_uppercase()
                        .starts_with(&pfx.to_uppercase())
                } else {
                    true
                };
                if should_delete {
                    to_delete.push(provider_name.clone());
                }
            }
        }
        to_delete.sort();
    }

    // ── 5. Print pre-flight diff ──────────────────────────────────────────────
    let total_changes = to_create.len() + to_update.len() + to_delete.len();

    if total_changes == 0 && unchanged == 0 {
        println!(
            "{} No secrets to push — local vault is empty for the given filter.",
            "i".blue()
        );
        return Ok(());
    }

    println!(
        "{} Pre-flight diff for AWS Secrets Manager (region: {}):",
        "".cyan().bold(),
        cfg.region
    );
    for (local_key, provider_name, hash) in &to_create {
        println!(
            "  {}  {}  {} (local: {})",
            "create".green().bold(),
            provider_name,
            format!("(sha256: {hash})").dimmed(),
            local_key
        );
    }
    for (local_key, provider_name, old_hash, new_hash) in &to_update {
        println!(
            "  {}  {}  {} (local: {})",
            "update".yellow().bold(),
            provider_name,
            format!("(sha256: {old_hash}{new_hash})").dimmed(),
            local_key
        );
    }
    for provider_name in &to_delete {
        println!(
            "  {}  {}  {}",
            "delete".red().bold(),
            provider_name,
            "(--delete-missing)".dimmed()
        );
    }

    let delete_note = if !to_delete.is_empty() {
        format!(", {} delete(s)", to_delete.len())
    } else {
        String::new()
    };
    println!(
        "  {} {} create(s), {} update(s){delete_note}, {} unchanged",
        "---".dimmed(),
        to_create.len(),
        to_update.len(),
        unchanged
    );

    // ── 6. Dry-run exit ───────────────────────────────────────────────────────
    if dry_run {
        println!("{} Dry-run complete — no writes made.", "".green());
        return Ok(());
    }

    if total_changes == 0 {
        println!(
            "{} Nothing to push — all secrets are up to date.",
            "".green()
        );
        return Ok(());
    }

    // ── 7. Confirmation prompt ────────────────────────────────────────────────
    if !yes {
        if std::io::stdin().is_terminal() {
            let delete_prompt = if !to_delete.is_empty() {
                format!(", {} delete(s)", to_delete.len())
            } else {
                String::new()
            };
            eprint!(
                "Push {} create(s), {} update(s){delete_prompt} to AWS Secrets Manager? [y/N] ",
                to_create.len(),
                to_update.len()
            );
            std::io::stderr().flush().ok();

            let mut response = String::new();
            std::io::stdin()
                .read_line(&mut response)
                .context("failed to read confirmation")?;
            if !response.trim().eq_ignore_ascii_case("y") {
                println!("{} Push aborted.", "i".blue());
                return Ok(());
            }
        } else {
            anyhow::bail!(
                "non-interactive stdin and --yes not passed — refusing to push without confirmation.\n\
                 Add --yes to push from CI or non-interactive contexts:\n  tsafe aws-push --yes"
            );
        }
    }

    // ── 8. Execute writes sequentially ───────────────────────────────────────
    let mut created = 0usize;
    let mut updated = 0usize;
    let deleted = 0usize;
    let mut errors: Vec<String> = Vec::new();

    for (local_key, provider_name, _hash) in &to_create {
        let value = vault.get(local_key).map_err(|e| anyhow::anyhow!("{e}"))?;
        match push_secret(&cfg, &get_creds, provider_name, value.as_str()) {
            Ok(SmPushOutcome::Created | SmPushOutcome::Updated) => {
                tracing::debug!(provider_name = %provider_name, local_key = %local_key, "created secret in AWS SM");
                created += 1;
            }
            Ok(SmPushOutcome::Unchanged) => {
                // Value became identical between diff and execution — no-op.
            }
            Ok(SmPushOutcome::Deleted) => {}
            Err(e) => {
                errors.push(format!("failed to create '{provider_name}': {e}"));
            }
        }
    }

    for (local_key, provider_name, _old_hash, _new_hash) in &to_update {
        let value = vault.get(local_key).map_err(|e| anyhow::anyhow!("{e}"))?;
        match push_secret(&cfg, &get_creds, provider_name, value.as_str()) {
            Ok(SmPushOutcome::Updated | SmPushOutcome::Created | SmPushOutcome::Unchanged) => {
                tracing::debug!(provider_name = %provider_name, local_key = %local_key, "updated secret in AWS SM");
                updated += 1;
            }
            Ok(SmPushOutcome::Deleted) => {}
            Err(e) => {
                errors.push(format!("failed to update '{provider_name}': {e}"));
            }
        }
    }

    // Deletion: AWS Secrets Manager has no soft-delete by default (unlike AKV), so we
    // skip the delete side in this implementation. --delete-missing shows the diff but
    // does not delete, preventing accidental mass-deletion. A future release will add
    // DeleteSecret calls gated behind an additional confirmation.
    for provider_name in &to_delete {
        tracing::warn!(
            provider_name = %provider_name,
            "aws-push: --delete-missing delete skipped — DeleteSecret not yet implemented"
        );
        errors.push(format!(
            "delete '{provider_name}' skipped — aws-push --delete-missing delete is not yet implemented; \
             remove the secret manually in the AWS Console or CLI"
        ));
    }
    // ── 9. Audit log entry (no plaintext values) ──────────────────────────────
    let audit_context =
        format!("created={created} updated={updated} unchanged={unchanged} deleted={deleted}");
    audit(profile)
        .append(&AuditEntry::success(
            profile,
            "aws-push",
            Some(&audit_context),
        ))
        .ok();
    emit_event(profile, "aws-push", Some(&audit_context));

    // ── 10. Summary ───────────────────────────────────────────────────────────
    if errors.is_empty() {
        println!(
            "{} Pushed to AWS Secrets Manager (region: {}): {created} created, {updated} updated, {unchanged} unchanged.",
            "".green(),
            cfg.region
        );
    } else {
        println!(
            "{} Partial push to AWS Secrets Manager (region: {}): {created} created, {updated} updated, {unchanged} unchanged.",
            "!".yellow(),
            cfg.region
        );
        for error in &errors {
            eprintln!("{} {error}", "error:".red().bold());
        }
        anyhow::bail!("{} secret(s) failed to push", errors.len());
    }

    Ok(())
}