diff --git a/crates/colibri-daemon/src/daemon.rs b/crates/colibri-daemon/src/daemon.rs index f0b2af9..b5affba 100644 --- a/crates/colibri-daemon/src/daemon.rs +++ b/crates/colibri-daemon/src/daemon.rs @@ -348,11 +348,11 @@ pub(crate) async fn provision_tenant_env( info!( jail = jail_name, - collection = tenant.collection_id, + collection = tenant.tenant_id, "provisioning tenant env from vault" ); - match colibri_vault::provision(&tenant.collection_id, jail_root_path).await { + match colibri_vault::provision(&tenant.tenant_id, jail_root_path).await { Ok(result) => { info!( jail = jail_name, diff --git a/crates/colibri-vault/src/lib.rs b/crates/colibri-vault/src/lib.rs index 17c6a96..4bbfc7f 100644 --- a/crates/colibri-vault/src/lib.rs +++ b/crates/colibri-vault/src/lib.rs @@ -15,8 +15,9 @@ //! //! # Requirements //! -//! - `bw` CLI installed and logged in (`BW_SESSION` or `BW_CLIENTID`+`BW_CLIENTSECRET`) -//! - `BW_SERVER` pointing at the Vaultwarden instance +//! - `bw` CLI installed +//! - `BW_CLIENTID`, `BW_CLIENTSECRET`, and `BW_PASSWORD` available to the daemon +//! - optional `BW_SERVER` pointing at the Vaultwarden instance //! - The authenticated account has read access to the target collection use std::path::{Path, PathBuf}; @@ -32,6 +33,12 @@ pub enum VaultError { #[error("bw command failed: {0}")] BwFailed(String), + #[error("missing vault bootstrap env var: {0}")] + MissingBootstrapEnv(&'static str), + + #[error("bw unlock returned an empty session")] + EmptySession, + #[error("collection not found: {0}")] CollectionNotFound(String), @@ -71,11 +78,84 @@ pub async fn provision( std::fs::write(&test, b"")?; std::fs::remove_file(&test)?; + let session = VaultSession::login_unlock().await?; + let result = provision_with_session(collection_name, target_dir, &session).await; + finish_with_lock(result, session).await +} + +#[derive(Debug, Clone)] +pub struct ProvisionResult { + pub collection: String, + pub items_written: usize, + pub bytes_written: usize, + pub path: PathBuf, +} + +// ── Internal helpers ────────────────────────────────── + +struct VaultSession { + token: String, +} + +impl VaultSession { + async fn login_unlock() -> Result { + require_bootstrap_env("BW_CLIENTID")?; + require_bootstrap_env("BW_CLIENTSECRET")?; + require_bootstrap_env("BW_PASSWORD")?; + + if let Ok(server) = std::env::var("BW_SERVER") { + if !server.trim().is_empty() { + bw(&["config", "server", server.trim()]).await?; + } + } + + match bw(&["login", "--apikey"]).await { + Ok(_) => {} + Err(VaultError::BwFailed(msg)) if is_already_logged_in(&msg) => {} + Err(e) => return Err(e), + } + + let token = bw(&["unlock", "--raw", "--passwordenv", "BW_PASSWORD"]).await?; + if token.trim().is_empty() { + return Err(VaultError::EmptySession); + } + + Ok(Self { token }) + } + + async fn lock(&self) -> Result<(), VaultError> { + bw_with_session(&["lock"], Some(&self.token)) + .await + .map(|_| ()) + } +} + +async fn finish_with_lock( + result: Result, + session: VaultSession, +) -> Result { + let lock_result = session.lock().await; + match (result, lock_result) { + (Ok(result), Ok(())) => Ok(result), + (Ok(_), Err(lock_err)) => Err(lock_err), + (Err(err), Ok(())) => Err(err), + (Err(err), Err(lock_err)) => { + tracing::warn!(error = %lock_err, "bw lock failed after vault provision error"); + Err(err) + } + } +} + +async fn provision_with_session( + collection_name: &str, + target_dir: &Path, + session: &VaultSession, +) -> Result { // 1. Find the collection by name - let collection_id = resolve_collection(collection_name).await?; + let collection_id = resolve_collection(collection_name, session).await?; // 2. Fetch items - let items = list_items(&collection_id).await?; + let items = list_items(&collection_id, session).await?; if items.is_empty() { return Ok(ProvisionResult { collection: collection_name.to_string(), @@ -137,24 +217,17 @@ pub async fn provision( }) } -#[derive(Debug, Clone)] -pub struct ProvisionResult { - pub collection: String, - pub items_written: usize, - pub bytes_written: usize, - pub path: PathBuf, -} - -// ── Internal helpers ────────────────────────────────── - -async fn resolve_collection(name: &str) -> Result { - let output = bw(&["list", "collections", "--search", name]).await?; - let collections: Vec = - serde_json::from_str(&output).map_err(|e| { - VaultError::BwFailed(format!( - "failed to parse collections: {e} — output: {output}" - )) - })?; +async fn resolve_collection(name: &str, session: &VaultSession) -> Result { + let output = bw_with_session( + &["list", "collections", "--search", name], + Some(&session.token), + ) + .await?; + let collections: Vec = serde_json::from_str(&output).map_err(|e| { + VaultError::BwFailed(format!( + "failed to parse collections: {e} — output: {output}" + )) + })?; collections .iter() @@ -163,21 +236,33 @@ async fn resolve_collection(name: &str) -> Result { .ok_or_else(|| VaultError::CollectionNotFound(name.to_string())) } -async fn list_items(collection_id: &str) -> Result, VaultError> { - let output = bw(&["list", "items", "--collectionid", collection_id]).await?; +async fn list_items( + collection_id: &str, + session: &VaultSession, +) -> Result, VaultError> { + let output = bw_with_session( + &["list", "items", "--collectionid", collection_id], + Some(&session.token), + ) + .await?; let items: Vec = serde_json::from_str(&output).map_err(|e| { - VaultError::BwFailed(format!( - "failed to parse items: {e} — output: {output}" - )) + VaultError::BwFailed(format!("failed to parse items: {e} — output: {output}")) })?; Ok(items) } async fn bw(args: &[&str]) -> Result { - let output = Command::new("bw") - .args(args) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) + bw_with_session(args, None).await +} + +async fn bw_with_session(args: &[&str], session: Option<&str>) -> Result { + let mut cmd = Command::new("bw"); + cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); + if let Some(session) = session { + cmd.env("BW_SESSION", session); + } + + let output = cmd .output() .await .map_err(|e| VaultError::BwFailed(format!("bw invocation failed: {e}")))?; @@ -190,6 +275,18 @@ async fn bw(args: &[&str]) -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } +fn require_bootstrap_env(name: &'static str) -> Result<(), VaultError> { + match std::env::var(name) { + Ok(value) if !value.trim().is_empty() => Ok(()), + _ => Err(VaultError::MissingBootstrapEnv(name)), + } +} + +fn is_already_logged_in(message: &str) -> bool { + let msg = message.to_ascii_lowercase(); + msg.contains("already logged in") || msg.contains("you are logged in") +} + fn which(cmd: &str) -> Option { std::env::var_os("PATH").and_then(|path| { std::env::split_paths(&path).find_map(|dir| { @@ -225,6 +322,7 @@ struct SerdeItem { #[derive(serde::Deserialize, Debug)] struct SerdeLogin { + #[allow(dead_code)] username: Option, password: Option, } @@ -266,7 +364,19 @@ mod tests { notes: None, }; assert_eq!(item.name, "OPENROUTER_API_KEY"); - assert_eq!(item.login.as_ref().unwrap().password.as_deref(), Some("sk-or-v1-abc123")); + assert_eq!( + item.login.as_ref().unwrap().password.as_deref(), + Some("sk-or-v1-abc123") + ); + } + + #[test] + fn already_logged_in_detection_matches_bw_cli_text() { + assert!(is_already_logged_in( + "You are already logged in as operator@example.org." + )); + assert!(is_already_logged_in("Already logged in.")); + assert!(!is_already_logged_in("Invalid client secret.")); } #[test]