feat(vault): route address-book scopes to per-scope backends

Add optional [vault_shared] and [vault_local] config blocks so the shared
and instance scopes can each be served by a dedicated Vault, with its own
connection, retry loop, and token renewal. A bare [vault] is unchanged:
shared and local alias the default cell, so every scope resolves to the
one connection exactly as before.

VaultBackends gains scope-dispatching wrappers (get_entry, list_entries,
resolve_folder_access, ...) that route by the scope argument, plus a
fan-out list_all_folders across scopes tolerant of a down backend. Folder
listing is split into list_folders_in_scope; a new VaultError::Unavailable
marks a configured-but-unconnected backend. Per-user credential variables
and the LUKS key stay on the default backend for now.

Secret IDs: VAULT_SECRET_ID (default), VAULT_SHARED_SECRET_ID,
VAULT_LOCAL_SECRET_ID.
This commit is contained in:
Dave Kempe
2026-07-29 15:53:36 +10:00
parent 0db14a2ac6
commit a8a0a6be11
4 changed files with 367 additions and 221 deletions
+185 -106
View File
@@ -1505,7 +1505,7 @@ pub async fn me(
) -> impl IntoResponse {
match identity {
Some(Extension(id)) => {
let vault_available = vault.default.read().await.is_some();
let vault_available = vault.any_connected().await;
Json(json!({
"name": id.display_name(),
"role": id.role(),
@@ -1877,25 +1877,186 @@ pub struct VaultBackends {
}
impl VaultBackends {
/// Single-backend construction: every scope shares one cell (today's
/// behaviour). Multi-backend construction lands with the config wiring.
pub fn single(cell: VaultCell) -> Self {
Self {
default: cell.clone(),
shared: cell.clone(),
local: cell,
}
}
/// The backend cell serving a given address-book scope (`"shared"` or
/// `"instance"`). Anything else falls back to the shared backend.
#[allow(dead_code)] // wired in once scope-aware routing lands (step 2)
pub fn cell_for_scope(&self, scope: &str) -> &VaultCell {
match scope {
"instance" => &self.local,
_ => &self.shared,
}
}
/// Resolve the connected client for `scope`, or `Unavailable` if that
/// backend is down / not yet connected.
async fn scoped(&self, scope: &str) -> Result<Arc<VaultClient>, VaultError> {
self.cell_for_scope(scope)
.read()
.await
.clone()
.ok_or(VaultError::Unavailable)
}
/// Resolve the connected default (`[vault]`) client — home of unscoped
/// secrets and, for now, per-user credential variables.
async fn default_client(&self) -> Result<Arc<VaultClient>, VaultError> {
self.default
.read()
.await
.clone()
.ok_or(VaultError::Unavailable)
}
/// True if at least one configured backend is currently connected.
pub async fn any_connected(&self) -> bool {
self.default.read().await.is_some()
|| self.shared.read().await.is_some()
|| self.local.read().await.is_some()
}
// ── Scope-routed address-book operations (dispatch to the scope's backend) ──
pub async fn list_subfolders(
&self,
scope: &str,
parent: &str,
) -> Result<Vec<crate::vault::FolderInfo>, VaultError> {
self.scoped(scope)
.await?
.list_subfolders(scope, parent)
.await
}
pub async fn list_entries(&self, scope: &str, folder: &str) -> Result<Vec<String>, VaultError> {
self.scoped(scope).await?.list_entries(scope, folder).await
}
pub async fn get_entry(
&self,
scope: &str,
folder: &str,
entry: &str,
) -> Result<AddressBookEntry, VaultError> {
self.scoped(scope)
.await?
.get_entry(scope, folder, entry)
.await
}
pub async fn put_entry(
&self,
scope: &str,
folder: &str,
entry: &str,
data: &AddressBookEntry,
) -> Result<(), VaultError> {
self.scoped(scope)
.await?
.put_entry(scope, folder, entry, data)
.await
}
pub async fn delete_entry(
&self,
scope: &str,
folder: &str,
entry: &str,
) -> Result<(), VaultError> {
self.scoped(scope)
.await?
.delete_entry(scope, folder, entry)
.await
}
pub async fn get_folder_config(
&self,
scope: &str,
folder: &str,
) -> Result<FolderConfig, VaultError> {
self.scoped(scope)
.await?
.get_folder_config(scope, folder)
.await
}
pub async fn put_folder_config(
&self,
scope: &str,
folder: &str,
config: &FolderConfig,
) -> Result<(), VaultError> {
self.scoped(scope)
.await?
.put_folder_config(scope, folder, config)
.await
}
pub async fn delete_folder(
&self,
scope: &str,
folder: &str,
) -> Result<(usize, usize), VaultError> {
self.scoped(scope).await?.delete_folder(scope, folder).await
}
pub async fn resolve_folder_access(
&self,
scope: &str,
folder: &str,
user_groups: &[String],
) -> Result<bool, VaultError> {
self.scoped(scope)
.await?
.resolve_folder_access(scope, folder, user_groups)
.await
}
// ── Unscoped operations (default backend) ──
pub async fn get_user_credentials(
&self,
email: &str,
) -> Result<std::collections::HashMap<String, String>, VaultError> {
self.default_client()
.await?
.get_user_credentials(email)
.await
}
pub async fn put_user_credentials(
&self,
email: &str,
creds: &std::collections::HashMap<String, String>,
) -> Result<(), VaultError> {
self.default_client()
.await?
.put_user_credentials(email, creds)
.await
}
// ── Fan-out across scopes ──
/// List top-level folders across every scope, routing each to its backend.
/// Tolerant of a down backend: a scope whose backend is unavailable simply
/// contributes nothing, unless NO backend is connected at all, in which
/// case `Unavailable` is returned so callers surface the outage.
pub async fn list_all_folders(&self) -> Result<Vec<crate::vault::FolderInfo>, VaultError> {
let mut folders = Vec::new();
let mut any = false;
for scope in ["shared", "instance"] {
if let Some(client) = self.cell_for_scope(scope).read().await.clone() {
any = true;
match client.list_folders_in_scope(scope).await {
Ok(fs) => folders.extend(fs),
Err(VaultError::NotFound) => {}
Err(e) => tracing::warn!(scope, error = %e, "listing folders for scope failed"),
}
}
}
if !any {
return Err(VaultError::Unavailable);
}
Ok(folders)
}
}
pub type VaultState = Arc<VaultBackends>;
@@ -1913,24 +2074,12 @@ pub async fn get_docs() -> impl IntoResponse {
Json(json!(sections))
}
/// Helper: require Vault to be available, or return an appropriate error.
async fn require_vault(vault: &VaultState) -> Result<Arc<VaultClient>, Response> {
let guard = vault.default.read().await;
guard.clone().ok_or_else(|| {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({"error": "Vault is not available — address book is temporarily offline"})),
)
.into_response()
})
}
/// Helper: check if the identity has group access to a folder, honouring
/// `inherit_from_parent` on the folder's config (a subfolder may inherit
/// access from any ancestor whose `allowed_groups` matches the caller's
/// OIDC groups). Admin role bypasses all checks.
async fn check_folder_access(
vault: &VaultClient,
vault: &VaultBackends,
scope: &str,
folder: &str,
identity: &AuthIdentity,
@@ -1973,7 +2122,7 @@ async fn check_folder_access(
/// practice; folders the user CAN access return immediately without
/// descending.
fn folder_or_descendant_accessible<'a>(
vault: &'a VaultClient,
vault: &'a VaultBackends,
scope: &'a str,
path: &'a str,
user_groups: &'a [String],
@@ -2004,10 +2153,6 @@ pub async fn ab_list_folders(
identity: Option<Extension<AuthIdentity>>,
Extension(vault): Extension<VaultState>,
) -> impl IntoResponse {
let vault = match require_vault(&vault).await {
Ok(v) => v,
Err(resp) => return resp,
};
let id = match identity {
Some(Extension(ref id)) if id.has_role("operator") => id,
_ => {
@@ -2019,7 +2164,7 @@ pub async fn ab_list_folders(
}
};
let folders = match vault.list_folders().await {
let folders = match vault.list_all_folders().await {
Ok(f) => f,
Err(e) => {
return (
@@ -2056,10 +2201,6 @@ pub async fn ab_list_subfolders(
Extension(vault): Extension<VaultState>,
Path((scope, folder)): Path<(String, String)>,
) -> impl IntoResponse {
let vault = match require_vault(&vault).await {
Ok(v) => v,
Err(resp) => return resp,
};
let id = match identity {
Some(Extension(ref id)) if id.has_role("operator") => id,
_ => {
@@ -2111,10 +2252,6 @@ pub async fn ab_list_all(
identity: Option<Extension<AuthIdentity>>,
Extension(vault): Extension<VaultState>,
) -> impl IntoResponse {
let vault = match require_vault(&vault).await {
Ok(v) => v,
Err(resp) => return resp,
};
let id = match identity {
Some(Extension(ref id)) if id.has_role("operator") => id,
_ => {
@@ -2126,7 +2263,7 @@ pub async fn ab_list_all(
}
};
let folders = match vault.list_folders().await {
let folders = match vault.list_all_folders().await {
Ok(f) => f,
Err(e) => {
return (
@@ -2194,10 +2331,6 @@ pub async fn ab_search_index(
identity: Option<Extension<AuthIdentity>>,
Extension(vault): Extension<VaultState>,
) -> impl IntoResponse {
let vault = match require_vault(&vault).await {
Ok(v) => v,
Err(resp) => return resp,
};
let id = match identity {
Some(Extension(ref id)) if id.has_role("operator") => id,
_ => {
@@ -2212,7 +2345,7 @@ pub async fn ab_search_index(
let user_groups = id.groups();
let is_admin = id.has_role("admin");
let top = match vault.list_folders().await {
let top = match vault.list_all_folders().await {
Ok(f) => f,
Err(e) => {
return (
@@ -2270,10 +2403,6 @@ pub async fn ab_list_entries(
Extension(vault): Extension<VaultState>,
Path((scope, folder)): Path<(String, String)>,
) -> impl IntoResponse {
let vault = match require_vault(&vault).await {
Ok(v) => v,
Err(resp) => return resp,
};
let id = match identity {
Some(Extension(ref id)) if id.has_role("operator") => id,
_ => {
@@ -2394,10 +2523,6 @@ pub async fn ab_connect_entry(
Path((scope, folder, entry)): Path<(String, String, String)>,
Json(req): Json<ConnectRequest>,
) -> impl IntoResponse {
let vault = match require_vault(&vault).await {
Ok(v) => v,
Err(resp) => return resp,
};
let id = match identity {
Some(Extension(ref id)) if id.has_role("operator") => id.clone(),
_ => {
@@ -2673,10 +2798,6 @@ pub async fn ab_create_folder(
Extension(vault): Extension<VaultState>,
Json(req): Json<CreateFolderRequest>,
) -> impl IntoResponse {
let vault = match require_vault(&vault).await {
Ok(v) => v,
Err(resp) => return resp,
};
let admin_email = match identity.as_ref() {
Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(),
_ => {
@@ -2749,10 +2870,6 @@ pub async fn ab_update_folder(
Path((scope, folder)): Path<(String, String)>,
Json(req): Json<UpdateFolderRequest>,
) -> impl IntoResponse {
let vault = match require_vault(&vault).await {
Ok(v) => v,
Err(resp) => return resp,
};
let admin_email = match identity.as_ref() {
Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(),
_ => {
@@ -2809,10 +2926,6 @@ pub async fn ab_get_folder_config(
Extension(vault): Extension<VaultState>,
Path((scope, folder)): Path<(String, String)>,
) -> impl IntoResponse {
let vault = match require_vault(&vault).await {
Ok(v) => v,
Err(resp) => return resp,
};
if !identity
.as_ref()
.map(|Extension(id)| id.has_role("admin"))
@@ -2856,10 +2969,6 @@ pub async fn ab_delete_folder(
Extension(vault): Extension<VaultState>,
Path((scope, folder)): Path<(String, String)>,
) -> impl IntoResponse {
let vault = match require_vault(&vault).await {
Ok(v) => v,
Err(resp) => return resp,
};
let admin_email = match identity.as_ref() {
Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(),
_ => {
@@ -2924,10 +3033,6 @@ pub async fn ab_create_entry(
Path((scope, folder)): Path<(String, String)>,
Json(req): Json<CreateEntryRequest>,
) -> impl IntoResponse {
let vault = match require_vault(&vault).await {
Ok(v) => v,
Err(resp) => return resp,
};
let admin_email = match identity.as_ref() {
Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(),
_ => {
@@ -2983,10 +3088,6 @@ pub async fn ab_update_entry(
Path((scope, folder, entry)): Path<(String, String, String)>,
Json(data): Json<AddressBookEntry>,
) -> impl IntoResponse {
let vault = match require_vault(&vault).await {
Ok(v) => v,
Err(resp) => return resp,
};
let admin_email = match identity.as_ref() {
Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(),
_ => {
@@ -3085,10 +3186,6 @@ pub async fn ab_delete_entry(
Extension(vault): Extension<VaultState>,
Path((scope, folder, entry)): Path<(String, String, String)>,
) -> impl IntoResponse {
let vault = match require_vault(&vault).await {
Ok(v) => v,
Err(resp) => return resp,
};
let admin_email = match identity.as_ref() {
Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(),
_ => {
@@ -3444,11 +3541,6 @@ pub async fn get_my_credentials(
}
};
let vault = match require_vault(&vault).await {
Ok(v) => v,
Err(resp) => return resp,
};
match vault.get_user_credentials(&email).await {
Ok(creds) => {
// Return variable names with masked values (indicate set vs unset)
@@ -3506,11 +3598,6 @@ pub async fn put_my_credentials(
}
};
let vault = match require_vault(&vault).await {
Ok(v) => v,
Err(resp) => return resp,
};
// Expect { "credentials": { "corp_user": "alice", "corp_password": "secret", ... } }
let creds_val = match body.get("credentials") {
Some(v) => v,
@@ -3599,14 +3686,9 @@ pub async fn list_credential_variables(
}
};
let vault = match require_vault(&vault).await {
Ok(v) => v,
Err(resp) => return resp,
};
// Scan all accessible folders and entries for variable references,
// recursing into subfolders so vars inside nested trees surface too.
let folders = match vault.list_folders().await {
let folders = match vault.list_all_folders().await {
Ok(f) => f,
Err(e) => {
return (
@@ -4062,15 +4144,12 @@ pub async fn quick_connect(
);
}
let vault = match require_vault(&vault).await {
Ok(v) => v,
Err(_) => {
return quick_connect_error(
StatusCode::SERVICE_UNAVAILABLE,
"Address book is temporarily unavailable (Vault offline).",
);
}
};
if !vault.any_connected().await {
return quick_connect_error(
StatusCode::SERVICE_UNAVAILABLE,
"Address book is temporarily unavailable (Vault offline).",
);
}
if check_folder_access(&vault, scope, folder, &id)
.await
+15
View File
@@ -419,7 +419,20 @@ pub struct Config {
pub tls: Option<TlsConfig>,
pub oidc: Option<OidcConfig>,
/// Primary/default Vault backend. Serves any address-book scope that does
/// not have a dedicated backend below, and is the home of unscoped secrets
/// (the LUKS key). A bare `[vault]` with no overrides behaves exactly as a
/// single-Vault deployment always has.
pub vault: Option<VaultConfig>,
/// Optional dedicated backend for the `shared` scope (e.g. a central,
/// fleet-wide Vault). When set, shared-scope folders/entries route here
/// instead of `[vault]`. Secret ID via `VAULT_SHARED_SECRET_ID`.
pub vault_shared: Option<VaultConfig>,
/// Optional dedicated backend for the `instance` (local) scope (e.g. a
/// per-host Vault that stays reachable during a central outage). When set,
/// instance-scope folders/entries route here. Secret ID via
/// `VAULT_LOCAL_SECRET_ID`.
pub vault_local: Option<VaultConfig>,
pub drive: Option<DriveConfig>,
pub theme: Option<ThemeConfig>,
pub recording: Option<RecordingConfig>,
@@ -1146,6 +1159,8 @@ impl Default for Config {
tls: None,
oidc: None,
vault: None,
vault_shared: None,
vault_local: None,
drive: None,
theme: None,
recording: None,
+132 -84
View File
@@ -455,6 +455,83 @@ async fn security_headers(
response
}
/// Connect a single Vault backend into `cell`. On a failed initial connect,
/// spawns a background 30s retry loop; the cell stays `None` (and that scope's
/// address book stays unavailable) until a connect succeeds. `luks_drive` is
/// `Some` only for the default backend, so the LUKS volume mounts as soon as
/// that backend comes up on a retry (the initial-boot mount happens in the
/// drive-init block once the awaited connect below has populated the cell).
async fn connect_vault_backend(
label: &'static str,
cell: VaultCell,
config: crate::config::VaultConfig,
secret_id: String,
luks_drive: Option<crate::config::DriveConfig>,
) {
match vault::VaultClient::new(&config, &secret_id).await {
Ok(client) => {
let client = Arc::new(client);
client.spawn_renewal_task();
tracing::info!("Vault backend '{}' initialized: {}", label, config.addr);
*cell.write().await = Some(client);
}
Err(e) => {
tracing::error!(
"Vault backend '{}' connect to {} failed: {} \
— that scope's address book is unavailable; retrying every 30s",
label,
config.addr,
e
);
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
interval.tick().await; // skip immediate tick
loop {
interval.tick().await;
tracing::debug!(
"Retrying Vault backend '{}' connect to {}...",
label,
config.addr
);
match vault::VaultClient::new(&config, &secret_id).await {
Ok(client) => {
let client = Arc::new(client);
client.spawn_renewal_task();
tracing::info!(
"Vault backend '{}' connected (retry succeeded): {}",
label,
config.addr
);
*cell.write().await = Some(client.clone());
// Mount LUKS now that the default backend is available.
if let Some(ref dc) = luks_drive {
if dc.enabled && drive::luks_configured(dc) {
match drive::mount_luks(dc, &client).await {
Ok(_) => {
tracing::info!("LUKS drive volume mounted (deferred)")
}
Err(e) => tracing::error!(
"Failed to mount LUKS drive volume: {}",
e
),
}
}
}
break;
}
Err(e) => tracing::warn!(
"Vault backend '{}' retry failed: {} — will retry in 30s",
label,
e
),
}
}
});
}
}
}
async fn run_server(config: Config, database: Db) {
// Initialize logging
tracing_subscriber::fmt()
@@ -484,93 +561,61 @@ async fn run_server(config: Config, database: Db) {
None
};
// Initialize Vault client if configured.
// Initialize Vault backend(s) if configured.
//
// `vault_cell` is the single backend connection cell used throughout setup
// (initial connect, background retry, LUKS mount). It is wrapped into the
// shared `VaultBackends` state after drive init below; in the single-Vault
// configuration every address-book scope aliases this one cell.
let vault_cell: VaultCell = Arc::new(tokio::sync::RwLock::new(None));
// `[vault]` is the default/primary backend and the home of unscoped secrets
// (the LUKS key). Optional `[vault_shared]` / `[vault_local]` route the
// shared / instance address-book scopes to dedicated Vaults so one being
// down cannot take the others with it. Each backend gets its own connection
// cell, background retry, and token renewal. A bare `[vault]` behaves
// exactly as a single-Vault deployment: shared and local alias the default
// cell, so every scope resolves to the one connection.
let default_cell: VaultCell = Arc::new(tokio::sync::RwLock::new(None));
let mut shared_cell = default_cell.clone();
let mut local_cell = default_cell.clone();
if let Some(ref vault_config) = config.vault {
let secret_id = match std::env::var("VAULT_SECRET_ID") {
Ok(s) => s,
Err(_) => {
match std::env::var("VAULT_SECRET_ID") {
Ok(sid) if !sid.is_empty() => {
connect_vault_backend(
"default",
default_cell.clone(),
vault_config.clone(),
sid,
config.drive.clone(),
)
.await;
}
_ => {
tracing::error!("VAULT_SECRET_ID env var required when [vault] is configured");
tracing::error!("Address book and drive features will be unavailable");
String::new()
}
};
}
}
if !secret_id.is_empty() {
match vault::VaultClient::new(vault_config, &secret_id).await {
Ok(client) => {
let client = Arc::new(client);
client.spawn_renewal_task();
tracing::info!("Vault client initialized: {}", vault_config.addr);
*vault_cell.write().await = Some(client);
}
Err(e) => {
tracing::error!("=============================================");
tracing::error!("VAULT CONNECTION FAILED");
tracing::error!(" Address: {}", vault_config.addr);
tracing::error!(" Error: {}", e);
tracing::error!(" Address book and drive features are UNAVAILABLE");
tracing::error!(" Sessions (SSH/RDP/VNC) will still work normally");
tracing::error!(" Retrying Vault connection every 30s in background");
tracing::error!("=============================================");
// Spawn background retry task
let retry_vault_config = vault_config.clone();
let retry_secret_id = secret_id.clone();
let retry_vault_state = vault_cell.clone();
let retry_drive_config = config.drive.clone();
tokio::spawn(async move {
let mut interval =
tokio::time::interval(std::time::Duration::from_secs(30));
interval.tick().await; // skip immediate tick
loop {
interval.tick().await;
tracing::debug!(
"Retrying Vault connection to {}...",
retry_vault_config.addr
);
match vault::VaultClient::new(&retry_vault_config, &retry_secret_id)
.await
{
Ok(client) => {
let client = Arc::new(client);
client.spawn_renewal_task();
tracing::info!(
"Vault client connected (retry succeeded): {}",
retry_vault_config.addr
);
*retry_vault_state.write().await = Some(client.clone());
// Mount LUKS now that Vault is available
if let Some(ref dc) = retry_drive_config {
if dc.enabled && drive::luks_configured(dc) {
match drive::mount_luks(dc, &client).await {
Ok(_) => tracing::info!(
"LUKS drive volume mounted (deferred)"
),
Err(e) => tracing::error!(
"Failed to mount LUKS drive volume: {}",
e
),
}
}
}
break;
}
Err(e) => {
tracing::warn!("Vault retry failed: {} — will retry in 30s", e);
}
}
}
});
}
if let Some(ref vc) = config.vault_shared {
match std::env::var("VAULT_SHARED_SECRET_ID") {
Ok(sid) if !sid.is_empty() => {
let cell: VaultCell = Arc::new(tokio::sync::RwLock::new(None));
connect_vault_backend("shared", cell.clone(), vc.clone(), sid, None).await;
shared_cell = cell;
}
_ => tracing::error!(
"VAULT_SHARED_SECRET_ID required for [vault_shared]; shared-scope connections unavailable"
),
}
}
if let Some(ref vc) = config.vault_local {
match std::env::var("VAULT_LOCAL_SECRET_ID") {
Ok(sid) if !sid.is_empty() => {
let cell: VaultCell = Arc::new(tokio::sync::RwLock::new(None));
connect_vault_backend("local", cell.clone(), vc.clone(), sid, None).await;
local_cell = cell;
}
_ => tracing::error!(
"VAULT_LOCAL_SECRET_ID required for [vault_local]; instance-scope connections unavailable"
),
}
}
@@ -579,7 +624,7 @@ async fn run_server(config: Config, database: Db) {
if drive_config.enabled {
// Mount LUKS volume if configured and Vault is available now
if drive::luks_configured(drive_config) {
let vc = vault_cell.read().await;
let vc = default_cell.read().await;
if let Some(ref client) = *vc {
match drive::mount_luks(drive_config, client).await {
Ok(_) => tracing::info!("LUKS drive volume mounted"),
@@ -598,10 +643,13 @@ async fn run_server(config: Config, database: Db) {
}
}
// Wrap the backend cell into the shared VaultBackends state. Single-Vault
// for now: `shared` and `local` scopes both alias this one cell. The
// multi-backend split adds dedicated cells here without touching handlers.
let vault_client: VaultState = Arc::new(VaultBackends::single(vault_cell));
// Assemble the shared VaultBackends state. `shared`/`local` alias the
// default cell unless a dedicated backend was configured above.
let vault_client: VaultState = Arc::new(VaultBackends {
default: default_cell,
shared: shared_cell,
local: local_cell,
});
let oidc_enabled = OidcEnabled(oidc_state.is_some());
let vault_configured = VaultConfigured(config.vault.is_some());
+35 -31
View File
@@ -26,6 +26,10 @@ pub enum VaultError {
Http(reqwest::Error),
Parse(String),
BadName(String),
/// The backend serving this scope is configured but not currently
/// connected (initial connect pending or the Vault is down). Distinct from
/// a Vault that returns an error: the request never left rustguac.
Unavailable,
}
impl std::fmt::Display for VaultError {
@@ -37,6 +41,7 @@ impl std::fmt::Display for VaultError {
Self::Http(e) => write!(f, "vault HTTP error: {}", e),
Self::Parse(msg) => write!(f, "vault response parse error: {}", msg),
Self::BadName(msg) => write!(f, "invalid name: {}", msg),
Self::Unavailable => write!(f, "vault backend not available"),
}
}
}
@@ -735,15 +740,6 @@ impl VaultClient {
// ── Path helpers ──
/// Returns the path prefixes to scan: ["shared"] and optionally ["instance/<name>"].
fn scope_prefixes(&self) -> Vec<(&str, String)> {
let mut prefixes = vec![("shared", "shared".to_string())];
if let Some(ref name) = self.instance_name {
prefixes.push(("instance", format!("instance/{}", name)));
}
prefixes
}
fn data_path(&self, scope_prefix: &str, rest: &str) -> String {
format!(
"/v1/{}/data/{}/{}/{}",
@@ -760,31 +756,39 @@ impl VaultClient {
// ── KV v2 operations ──
/// List top-level folders visible across all scopes (shared + instance).
pub async fn list_folders(&self) -> Result<Vec<FolderInfo>, VaultError> {
let mut folders = Vec::new();
/// List top-level folders for a single scope (`"shared"` or `"instance"`).
///
/// Returns an empty vec (not an error) when the scope isn't applicable to
/// this client — e.g. `"instance"` with no `instance_name` configured — so
/// the multi-backend fan-out can call it unconditionally.
pub async fn list_folders_in_scope(&self, scope: &str) -> Result<Vec<FolderInfo>, VaultError> {
let prefix = match scope {
"shared" => "shared".to_string(),
"instance" => match &self.instance_name {
Some(name) => format!("instance/{}", name),
None => return Ok(Vec::new()),
},
_ => return Err(VaultError::BadName(format!("invalid scope: {}", scope))),
};
for (scope_label, prefix) in self.scope_prefixes() {
let path = format!("/v1/{}/metadata/{}/{}/", self.mount, self.base_path, prefix);
match self.kv_list(&path).await {
Ok(keys) => {
let has_subfolders: Vec<&str> =
keys.iter().filter_map(|k| k.strip_suffix('/')).collect();
for name in &has_subfolders {
folders.push(FolderInfo {
name: name.to_string(),
description: String::new(),
scope: scope_label.to_string(),
path: Some(name.to_string()),
has_children: None, // enriched below
});
}
let mut folders = Vec::new();
let path = format!("/v1/{}/metadata/{}/{}/", self.mount, self.base_path, prefix);
match self.kv_list(&path).await {
Ok(keys) => {
for name in keys.iter().filter_map(|k| k.strip_suffix('/')) {
folders.push(FolderInfo {
name: name.to_string(),
description: String::new(),
scope: scope.to_string(),
path: Some(name.to_string()),
has_children: None, // enriched below
});
}
Err(VaultError::NotFound) => {
// No folders in this scope — that's fine
}
Err(e) => return Err(e),
}
Err(VaultError::NotFound) => {
// No folders in this scope — that's fine
}
Err(e) => return Err(e),
}
// Enrich with descriptions and child detection