diff --git a/src/api.rs b/src/api.rs index 3597513..5f1cb7d 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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, 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, 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, VaultError> { + self.scoped(scope) + .await? + .list_subfolders(scope, parent) + .await + } + + pub async fn list_entries(&self, scope: &str, folder: &str) -> Result, VaultError> { + self.scoped(scope).await?.list_entries(scope, folder).await + } + + pub async fn get_entry( + &self, + scope: &str, + folder: &str, + entry: &str, + ) -> Result { + 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 { + 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 { + 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, VaultError> { + self.default_client() + .await? + .get_user_credentials(email) + .await + } + + pub async fn put_user_credentials( + &self, + email: &str, + creds: &std::collections::HashMap, + ) -> 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, 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; @@ -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, 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(vault): Extension, ) -> 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, 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(vault): Extension, ) -> 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(vault): Extension, ) -> 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, 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, ) -> 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, Json(req): Json, ) -> 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, ) -> 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, 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, 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, ) -> 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, ) -> 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, 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 diff --git a/src/config.rs b/src/config.rs index 2b741ea..a0349aa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -419,7 +419,20 @@ pub struct Config { pub tls: Option, pub oidc: Option, + /// 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, + /// 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, + /// 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, pub drive: Option, pub theme: Option, pub recording: Option, @@ -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, diff --git a/src/main.rs b/src/main.rs index 28a1643..09d5187 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, +) { + 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()); diff --git a/src/vault.rs b/src/vault.rs index 2f466b4..2812a45 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -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/"]. - 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, 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, 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