tsafe-cli 1.0.27

Secrets runtime for developers — inject credentials into processes via exec, never into shell history or .env files
//! Non-core `tsafe bw-pull` handler — import secrets from Bitwarden via the `bw` CLI.
//!
//! # E2E Encryption — why the bw CLI
//!
//! Bitwarden REST API ciphers are always E2E encrypted client-side.  A machine
//! token obtained via `client_credentials` / `api.organization` grants API
//! access but does NOT carry the encryption key needed to decrypt cipher values.
//! The raw `/api/sync` response contains `encryptedString` blobs for every
//! field value.  Decrypting them requires the Bitwarden client SDK and the
//! organization symmetric key derived from the master password, which is not
//! available to a headless REST caller.
//!
//! This module therefore delegates to the `bw` CLI subprocess (identical pattern
//! to `cmd_vault_pull.rs` → `op` CLI, `cmd_bitwarden_pull.rs` → `bw` CLI).
//!
//! # Required environment variables
//!
//! | Variable                  | Purpose                                      |
//! |---------------------------|----------------------------------------------|
//! | `TSAFE_BW_CLIENT_ID`      | Bitwarden API client ID                      |
//! | `TSAFE_BW_CLIENT_SECRET`  | Bitwarden API client secret                  |
//! | `TSAFE_BW_PASSWORD`       | Master password passed to `bw unlock`        |

use anyhow::{Context as _, Result};
use colored::Colorize;
use tsafe_bitwarden::{pull_items, BitwConfig};
use tsafe_core::{audit::AuditEntry, events::emit_event};

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

const DEFAULT_PASSWORD_ENV: &str = "TSAFE_BW_PASSWORD";

/// Pull items from Bitwarden into the local vault.
///
/// Credentials are read from `TSAFE_BW_CLIENT_ID` / `TSAFE_BW_CLIENT_SECRET`.
/// The master password is read from the env var named by `password_env`
/// (default: `TSAFE_BW_PASSWORD`).
#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_bitwarden_pull(
    profile: &str,
    api_url: Option<&str>,
    identity_url: Option<&str>,
    client_id: Option<&str>,
    client_secret: Option<&str>,
    folder: Option<&str>,
    password_env: Option<&str>,
    overwrite: bool,
    on_error: PullOnError,
) -> Result<()> {
    cmd_bitwarden_pull_ns(
        profile,
        api_url,
        identity_url,
        client_id,
        client_secret,
        folder,
        password_env,
        overwrite,
        on_error,
        None,
    )
}

/// Inner implementation with an optional namespace prefix (ADR-012).
///
/// When `ns` is `Some("prod")`, every key imported from Bitwarden is
/// stored as `prod.KEY_NAME` in the local vault.
#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_bitwarden_pull_ns(
    profile: &str,
    api_url: Option<&str>,
    identity_url: Option<&str>,
    client_id: Option<&str>,
    client_secret: Option<&str>,
    folder: Option<&str>,
    password_env: Option<&str>,
    overwrite: bool,
    on_error: PullOnError,
    ns: Option<&str>,
) -> Result<()> {
    // Resolve credentials: manifest fields take priority over env vars.
    let cfg = resolve_config(api_url, identity_url, client_id, client_secret)?;

    let pw_env = password_env.unwrap_or(DEFAULT_PASSWORD_ENV);

    let items = match pull_items(&cfg, pw_env, folder).with_context(|| {
        "failed to pull items from Bitwarden via the bw CLI\n\
         \n  Required env:    TSAFE_BW_CLIENT_ID + TSAFE_BW_CLIENT_SECRET + TSAFE_BW_PASSWORD\
         \n  Install bw CLI:  https://bitwarden.com/help/cli/\
         \n  Help:            tsafe explain pull-auth"
    }) {
        Ok(items) => items,
        Err(err) => match on_error {
            PullOnError::FailAll => return Err(err),
            PullOnError::SkipFailed | PullOnError::WarnOnly => {
                eprintln!("{} Bitwarden pull failed: {err}", "!".yellow());
                return Ok(());
            }
        },
    };

    if items.is_empty() {
        println!(
            "{} No Login items found in Bitwarden{}",
            "i".blue(),
            folder
                .map(|f| format!(" (folder: {f})"))
                .unwrap_or_default()
        );
        return Ok(());
    }

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

    for (raw_key, value) in &items {
        // 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, "bw-pull", None))
        .ok();
    emit_event(profile, "bw-pull", None);
    println!(
        "{} Imported {imported} secret(s) from Bitwarden{}",
        "".green(),
        if skipped > 0 {
            format!(" ({skipped} skipped — use --overwrite to replace)")
        } else {
            String::new()
        }
    );
    Ok(())
}

/// Resolve `BitwConfig` from the optional manifest fields, falling back to
/// environment variables for any field that is not set.
fn resolve_config(
    api_url: Option<&str>,
    identity_url: Option<&str>,
    client_id: Option<&str>,
    client_secret: Option<&str>,
) -> Result<BitwConfig> {
    let resolved_client_id = client_id
        .map(|s| s.to_string())
        .or_else(|| {
            std::env::var("TSAFE_BW_CLIENT_ID")
                .ok()
                .filter(|s| !s.is_empty())
        })
        .ok_or_else(|| {
            anyhow::anyhow!(
                "Bitwarden client_id is not configured\n\
                 \n  Fix:  export TSAFE_BW_CLIENT_ID=<your-client-id>\
                 \n        or set client_id in .tsafe.yml under the 'bw' pull source\
                 \n  Help: tsafe explain pull-auth"
            )
        })?;

    let resolved_client_secret = client_secret
        .map(|s| s.to_string())
        .or_else(|| {
            std::env::var("TSAFE_BW_CLIENT_SECRET")
                .ok()
                .filter(|s| !s.is_empty())
        })
        .ok_or_else(|| {
            anyhow::anyhow!(
                "Bitwarden client_secret is not configured\n\
                 \n  Fix:  export TSAFE_BW_CLIENT_SECRET=<your-client-secret>\
                 \n        or set client_secret in .tsafe.yml under the 'bw' pull source"
            )
        })?;

    let resolved_api_url = api_url
        .map(|s| s.to_string())
        .or_else(|| {
            std::env::var("TSAFE_BW_API_URL")
                .ok()
                .filter(|s| !s.is_empty())
        })
        .unwrap_or_else(|| BitwConfig::default_api_url().to_string());

    let resolved_identity_url = identity_url
        .map(|s| s.to_string())
        .or_else(|| {
            std::env::var("TSAFE_BW_IDENTITY_URL")
                .ok()
                .filter(|s| !s.is_empty())
        })
        .unwrap_or_else(|| BitwConfig::default_identity_url().to_string());

    Ok(BitwConfig::new(
        resolved_api_url,
        resolved_identity_url,
        resolved_client_id,
        resolved_client_secret,
    ))
}