fix(vault): use tenant collection names with per-call unlock (Sam & Pi) #94
2 changed files with 144 additions and 34 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue