From c2a382211a969dbe6a7149ea40ecd7ce5ef4d654 Mon Sep 17 00:00:00 2001 From: Dave Kempe Date: Thu, 16 Apr 2026 19:46:59 +1000 Subject: [PATCH] Address book: subfolder support backend (#101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add hierarchical folder support to the Vault-backed address book. Folders can now be nested (e.g., Clients/Acme/Servers) using Vault KV v2's natural path hierarchy. - Add validate_path() for multi-segment folder paths, replacing validate_name() for folder parameters. Each segment validated individually — blocks traversal, reserved names, special chars. - FolderInfo gains path and has_children fields for tree UI support - New list_subfolders() and list_children() methods on VaultClient - New GET /api/addressbook/folders/{scope}/{folder}/subfolders endpoint - Existing flat folder operations unchanged (backward compatible) - Client percent-encodes folder paths: Clients%2FAcme in URL decodes to Clients/Acme — no wildcard routes needed Tested on sol1-remoteconsole: subfolder CRUD, entry CRUD in subfolders, has_children detection, and existing flat folder compatibility verified. --- src/api.rs | 36 +++++++++++++ src/main.rs | 4 ++ src/vault.rs | 139 ++++++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 162 insertions(+), 17 deletions(-) diff --git a/src/api.rs b/src/api.rs index 1729acd..33b74a5 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1631,6 +1631,42 @@ pub async fn ab_list_folders( Json(json!(visible)).into_response() } +/// GET /api/addressbook/folders/:scope/:folder/subfolders — List subfolders at a path. +pub async fn ab_list_subfolders( + identity: Option>, + 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, + _ => { + return ( + StatusCode::FORBIDDEN, + Json(json!({"error": "operator role required"})), + ) + .into_response() + } + }; + + if let Err(resp) = check_folder_access(&vault, &scope, &folder, id).await { + return resp; + } + + match vault.list_subfolders(&scope, &folder).await { + Ok(subfolders) => Json(json!(subfolders)).into_response(), + Err(crate::vault::VaultError::NotFound) => Json(json!([])).into_response(), + Err(e) => ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": e.to_string()})), + ) + .into_response(), + } +} + /// GET /api/addressbook — Batch endpoint returning all visible folders with entries. /// Replaces the N+1 pattern of listing folders then entries per folder. pub async fn ab_list_all( diff --git a/src/main.rs b/src/main.rs index a5866fe..1ea93d8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -922,6 +922,10 @@ async fn run_server(config: Config, database: Db) { "/api/addressbook/folders/{scope}/{folder}", delete(api::ab_delete_folder), ) + .route( + "/api/addressbook/folders/{scope}/{folder}/subfolders", + get(api::ab_list_subfolders), + ) .route( "/api/addressbook/folders/{scope}/{folder}/entries", get(api::ab_list_entries), diff --git a/src/vault.rs b/src/vault.rs index 7ef0ae0..99dd7c8 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -342,6 +342,12 @@ pub struct FolderInfo { pub description: String, /// "shared" or "instance" pub scope: String, + /// Full path from scope root (e.g. "Clients/Acme"). Same as name for top-level folders. + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + /// Whether this folder has subfolders (for lazy tree loading). + #[serde(skip_serializing_if = "Option::is_none")] + pub has_children: Option, } // ── Vault client ── @@ -561,7 +567,7 @@ impl VaultClient { // ── KV v2 operations ── - /// List folders visible across all scopes (shared + instance). + /// List top-level folders visible across all scopes (shared + instance). pub async fn list_folders(&self) -> Result, VaultError> { let mut folders = Vec::new(); @@ -569,15 +575,16 @@ impl VaultClient { let path = format!("/v1/{}/metadata/{}/{}/", self.mount, self.base_path, prefix); match self.kv_list(&path).await { Ok(keys) => { - for key in keys { - // Folder names end with "/" - if let Some(name) = key.strip_suffix('/') { - folders.push(FolderInfo { - name: name.to_string(), - description: String::new(), - scope: scope_label.to_string(), - }); - } + 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 + }); } } Err(VaultError::NotFound) => { @@ -587,11 +594,64 @@ impl VaultClient { } } - // Enrich with descriptions from .config + // Enrich with descriptions and child detection for folder in &mut folders { if let Ok(config) = self.get_folder_config(&folder.scope, &folder.name).await { folder.description = config.description; } + // Check for subfolders by listing children + if let Ok(children) = self.list_children(&folder.scope, &folder.name).await { + folder.has_children = Some(children.iter().any(|c| c.strip_suffix('/').is_some())); + } + } + + Ok(folders) + } + + /// List immediate children (subfolders and entries) at a given folder path. + /// Subfolder names end with `/` in the returned list. + pub async fn list_children( + &self, + scope: &str, + folder_path: &str, + ) -> Result, VaultError> { + validate_path(folder_path)?; + let scope_prefix = self.resolve_scope_prefix(scope)?; + let path = format!("{}/", self.metadata_path(&scope_prefix, folder_path)); + self.kv_list(&path).await + } + + /// List subfolders at a given path within a scope. + /// Returns FolderInfo for each subfolder, with has_children populated. + pub async fn list_subfolders( + &self, + scope: &str, + parent_path: &str, + ) -> Result, VaultError> { + let children = self.list_children(scope, parent_path).await?; + let mut folders = Vec::new(); + + for key in &children { + if let Some(name) = key.strip_suffix('/') { + let full_path = format!("{}/{}", parent_path, name); + let mut info = FolderInfo { + name: name.to_string(), + description: String::new(), + scope: scope.to_string(), + path: Some(full_path.clone()), + has_children: None, + }; + // Enrich with description + if let Ok(config) = self.get_folder_config(scope, &full_path).await { + info.description = config.description; + } + // Check for grandchildren + if let Ok(grandchildren) = self.list_children(scope, &full_path).await { + info.has_children = + Some(grandchildren.iter().any(|c| c.strip_suffix('/').is_some())); + } + folders.push(info); + } } Ok(folders) @@ -603,7 +663,7 @@ impl VaultClient { scope: &str, folder: &str, ) -> Result { - validate_name(folder)?; + validate_path(folder)?; let scope_prefix = self.resolve_scope_prefix(scope)?; let path = self.data_path(&scope_prefix, &format!("{}/{}", folder, ".config")); let resp = self.request(reqwest::Method::GET, &path, None).await?; @@ -623,7 +683,7 @@ impl VaultClient { /// List entry names in a folder (excludes .config). pub async fn list_entries(&self, scope: &str, folder: &str) -> Result, VaultError> { - validate_name(folder)?; + validate_path(folder)?; let scope_prefix = self.resolve_scope_prefix(scope)?; let path = format!("{}/", self.metadata_path(&scope_prefix, folder)); let keys = self.kv_list(&path).await?; @@ -637,7 +697,7 @@ impl VaultClient { folder: &str, entry: &str, ) -> Result { - validate_name(folder)?; + validate_path(folder)?; validate_name(entry)?; let scope_prefix = self.resolve_scope_prefix(scope)?; let path = self.data_path(&scope_prefix, &format!("{}/{}", folder, entry)); @@ -666,7 +726,7 @@ impl VaultClient { entry: &str, data: &AddressBookEntry, ) -> Result<(), VaultError> { - validate_name(folder)?; + validate_path(folder)?; validate_name(entry)?; let scope_prefix = self.resolve_scope_prefix(scope)?; let path = self.data_path(&scope_prefix, &format!("{}/{}", folder, entry)); @@ -715,7 +775,7 @@ impl VaultClient { folder: &str, config: &FolderConfig, ) -> Result<(), VaultError> { - validate_name(folder)?; + validate_path(folder)?; let scope_prefix = self.resolve_scope_prefix(scope)?; let path = self.data_path(&scope_prefix, &format!("{}/{}", folder, ".config")); let body = serde_json::json!({ "data": config }); @@ -738,7 +798,7 @@ impl VaultClient { /// Delete an entire folder (all entries + .config). pub async fn delete_folder(&self, scope: &str, folder: &str) -> Result<(), VaultError> { - validate_name(folder)?; + validate_path(folder)?; // List and delete all entries let entries = self.list_entries(scope, folder).await.unwrap_or_default(); for entry in entries { @@ -1108,6 +1168,32 @@ fn validate_name(name: &str) -> Result<(), VaultError> { Ok(()) } +/// Validate a folder path that may contain subfolders (e.g. "Clients/Acme/Servers"). +/// Each segment is validated with the same rules as `validate_name`. +/// Empty segments, trailing slashes, and leading slashes are rejected. +fn validate_path(path: &str) -> Result<(), VaultError> { + if path.is_empty() { + return Err(VaultError::BadName("path cannot be empty".into())); + } + if path.len() > 256 { + return Err(VaultError::BadName("path too long (max 256 chars)".into())); + } + if path.starts_with('/') || path.ends_with('/') { + return Err(VaultError::BadName( + "path cannot start or end with /".into(), + )); + } + if path.contains("//") { + return Err(VaultError::BadName( + "path cannot contain empty segments".into(), + )); + } + for segment in path.split('/') { + validate_name(segment)?; + } + Ok(()) +} + /// Sanitize an email address for use as a Vault path component. /// Replaces `@` with `_at_` and strips any characters not in `[a-zA-Z0-9._-]`. fn sanitize_email_key(email: &str) -> String { @@ -1392,6 +1478,25 @@ mod tests { assert!(validate_name(&"a".repeat(65)).is_err()); } + #[test] + fn test_validate_path_ok() { + assert!(validate_path("my-folder").is_ok()); + assert!(validate_path("Clients/Acme").is_ok()); + assert!(validate_path("Clients/Acme/Servers").is_ok()); + assert!(validate_path("a/b/c/d").is_ok()); + } + + #[test] + fn test_validate_path_rejects_bad_input() { + assert!(validate_path("").is_err()); // empty + assert!(validate_path("/leading").is_err()); // leading slash + assert!(validate_path("trailing/").is_err()); // trailing slash + assert!(validate_path("a//b").is_err()); // empty segment + assert!(validate_path("a/../b").is_err()); // traversal + assert!(validate_path("a/.config/b").is_err()); // reserved name + assert!(validate_path(&format!("a/{}", "x".repeat(65))).is_err()); // segment too long + } + #[test] fn test_build_client_mtls_pkcs8_key() { // This test reproduces issue #51: PKCS#8 keys from OpenBao should work.