fix(vault): use tenant collection names with per-call unlock (Sam & Pi) #94

Merged
clawdie merged 1 commit from fix/vault-first-proof into main 2026-06-20 06:47:29 +02:00
2 changed files with 144 additions and 34 deletions

View file

@ -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,

View file

@ -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<Self, VaultError> {
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<ProvisionResult, VaultError>,
session: VaultSession,
) -> Result<ProvisionResult, VaultError> {
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<ProvisionResult, VaultError> {
// 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<String, VaultError> {
let output = bw(&["list", "collections", "--search", name]).await?;
let collections: Vec<SerdeCollection> =
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<String, VaultError> {
let output = bw_with_session(
&["list", "collections", "--search", name],
Some(&session.token),
)
.await?;
let collections: Vec<SerdeCollection> = 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<String, VaultError> {
.ok_or_else(|| VaultError::CollectionNotFound(name.to_string()))
}
async fn list_items(collection_id: &str) -> Result<Vec<SerdeItem>, VaultError> {
let output = bw(&["list", "items", "--collectionid", collection_id]).await?;
async fn list_items(
collection_id: &str,
session: &VaultSession,
) -> Result<Vec<SerdeItem>, VaultError> {
let output = bw_with_session(
&["list", "items", "--collectionid", collection_id],
Some(&session.token),
)
.await?;
let items: Vec<SerdeItem> = 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<String, VaultError> {
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<String, VaultError> {
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<String, VaultError> {
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<PathBuf> {
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<String>,
password: Option<String>,
}
@ -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]