Address book: subfolder support backend (#101)

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.
This commit is contained in:
Dave Kempe
2026-04-16 19:46:59 +10:00
parent 8c248edb53
commit c2a382211a
3 changed files with 162 additions and 17 deletions
+36
View File
@@ -1631,6 +1631,42 @@ pub async fn ab_list_folders(
Json(json!(visible)).into_response() 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<AuthIdentity>>,
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,
_ => {
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. /// GET /api/addressbook — Batch endpoint returning all visible folders with entries.
/// Replaces the N+1 pattern of listing folders then entries per folder. /// Replaces the N+1 pattern of listing folders then entries per folder.
pub async fn ab_list_all( pub async fn ab_list_all(
+4
View File
@@ -922,6 +922,10 @@ async fn run_server(config: Config, database: Db) {
"/api/addressbook/folders/{scope}/{folder}", "/api/addressbook/folders/{scope}/{folder}",
delete(api::ab_delete_folder), delete(api::ab_delete_folder),
) )
.route(
"/api/addressbook/folders/{scope}/{folder}/subfolders",
get(api::ab_list_subfolders),
)
.route( .route(
"/api/addressbook/folders/{scope}/{folder}/entries", "/api/addressbook/folders/{scope}/{folder}/entries",
get(api::ab_list_entries), get(api::ab_list_entries),
+122 -17
View File
@@ -342,6 +342,12 @@ pub struct FolderInfo {
pub description: String, pub description: String,
/// "shared" or "instance" /// "shared" or "instance"
pub scope: String, 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<String>,
/// Whether this folder has subfolders (for lazy tree loading).
#[serde(skip_serializing_if = "Option::is_none")]
pub has_children: Option<bool>,
} }
// ── Vault client ── // ── Vault client ──
@@ -561,7 +567,7 @@ impl VaultClient {
// ── KV v2 operations ── // ── 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<Vec<FolderInfo>, VaultError> { pub async fn list_folders(&self) -> Result<Vec<FolderInfo>, VaultError> {
let mut folders = Vec::new(); let mut folders = Vec::new();
@@ -569,15 +575,16 @@ impl VaultClient {
let path = format!("/v1/{}/metadata/{}/{}/", self.mount, self.base_path, prefix); let path = format!("/v1/{}/metadata/{}/{}/", self.mount, self.base_path, prefix);
match self.kv_list(&path).await { match self.kv_list(&path).await {
Ok(keys) => { Ok(keys) => {
for key in keys { let has_subfolders: Vec<&str> =
// Folder names end with "/" keys.iter().filter_map(|k| k.strip_suffix('/')).collect();
if let Some(name) = key.strip_suffix('/') { for name in &has_subfolders {
folders.push(FolderInfo { folders.push(FolderInfo {
name: name.to_string(), name: name.to_string(),
description: String::new(), description: String::new(),
scope: scope_label.to_string(), scope: scope_label.to_string(),
}); path: Some(name.to_string()),
} has_children: None, // enriched below
});
} }
} }
Err(VaultError::NotFound) => { 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 { for folder in &mut folders {
if let Ok(config) = self.get_folder_config(&folder.scope, &folder.name).await { if let Ok(config) = self.get_folder_config(&folder.scope, &folder.name).await {
folder.description = config.description; 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<Vec<String>, 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<Vec<FolderInfo>, 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) Ok(folders)
@@ -603,7 +663,7 @@ impl VaultClient {
scope: &str, scope: &str,
folder: &str, folder: &str,
) -> Result<FolderConfig, VaultError> { ) -> Result<FolderConfig, VaultError> {
validate_name(folder)?; validate_path(folder)?;
let scope_prefix = self.resolve_scope_prefix(scope)?; let scope_prefix = self.resolve_scope_prefix(scope)?;
let path = self.data_path(&scope_prefix, &format!("{}/{}", folder, ".config")); let path = self.data_path(&scope_prefix, &format!("{}/{}", folder, ".config"));
let resp = self.request(reqwest::Method::GET, &path, None).await?; let resp = self.request(reqwest::Method::GET, &path, None).await?;
@@ -623,7 +683,7 @@ impl VaultClient {
/// List entry names in a folder (excludes .config). /// List entry names in a folder (excludes .config).
pub async fn list_entries(&self, scope: &str, folder: &str) -> Result<Vec<String>, VaultError> { pub async fn list_entries(&self, scope: &str, folder: &str) -> Result<Vec<String>, VaultError> {
validate_name(folder)?; validate_path(folder)?;
let scope_prefix = self.resolve_scope_prefix(scope)?; let scope_prefix = self.resolve_scope_prefix(scope)?;
let path = format!("{}/", self.metadata_path(&scope_prefix, folder)); let path = format!("{}/", self.metadata_path(&scope_prefix, folder));
let keys = self.kv_list(&path).await?; let keys = self.kv_list(&path).await?;
@@ -637,7 +697,7 @@ impl VaultClient {
folder: &str, folder: &str,
entry: &str, entry: &str,
) -> Result<AddressBookEntry, VaultError> { ) -> Result<AddressBookEntry, VaultError> {
validate_name(folder)?; validate_path(folder)?;
validate_name(entry)?; validate_name(entry)?;
let scope_prefix = self.resolve_scope_prefix(scope)?; let scope_prefix = self.resolve_scope_prefix(scope)?;
let path = self.data_path(&scope_prefix, &format!("{}/{}", folder, entry)); let path = self.data_path(&scope_prefix, &format!("{}/{}", folder, entry));
@@ -666,7 +726,7 @@ impl VaultClient {
entry: &str, entry: &str,
data: &AddressBookEntry, data: &AddressBookEntry,
) -> Result<(), VaultError> { ) -> Result<(), VaultError> {
validate_name(folder)?; validate_path(folder)?;
validate_name(entry)?; validate_name(entry)?;
let scope_prefix = self.resolve_scope_prefix(scope)?; let scope_prefix = self.resolve_scope_prefix(scope)?;
let path = self.data_path(&scope_prefix, &format!("{}/{}", folder, entry)); let path = self.data_path(&scope_prefix, &format!("{}/{}", folder, entry));
@@ -715,7 +775,7 @@ impl VaultClient {
folder: &str, folder: &str,
config: &FolderConfig, config: &FolderConfig,
) -> Result<(), VaultError> { ) -> Result<(), VaultError> {
validate_name(folder)?; validate_path(folder)?;
let scope_prefix = self.resolve_scope_prefix(scope)?; let scope_prefix = self.resolve_scope_prefix(scope)?;
let path = self.data_path(&scope_prefix, &format!("{}/{}", folder, ".config")); let path = self.data_path(&scope_prefix, &format!("{}/{}", folder, ".config"));
let body = serde_json::json!({ "data": config }); let body = serde_json::json!({ "data": config });
@@ -738,7 +798,7 @@ impl VaultClient {
/// Delete an entire folder (all entries + .config). /// Delete an entire folder (all entries + .config).
pub async fn delete_folder(&self, scope: &str, folder: &str) -> Result<(), VaultError> { pub async fn delete_folder(&self, scope: &str, folder: &str) -> Result<(), VaultError> {
validate_name(folder)?; validate_path(folder)?;
// List and delete all entries // List and delete all entries
let entries = self.list_entries(scope, folder).await.unwrap_or_default(); let entries = self.list_entries(scope, folder).await.unwrap_or_default();
for entry in entries { for entry in entries {
@@ -1108,6 +1168,32 @@ fn validate_name(name: &str) -> Result<(), VaultError> {
Ok(()) 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. /// Sanitize an email address for use as a Vault path component.
/// Replaces `@` with `_at_` and strips any characters not in `[a-zA-Z0-9._-]`. /// Replaces `@` with `_at_` and strips any characters not in `[a-zA-Z0-9._-]`.
fn sanitize_email_key(email: &str) -> String { fn sanitize_email_key(email: &str) -> String {
@@ -1392,6 +1478,25 @@ mod tests {
assert!(validate_name(&"a".repeat(65)).is_err()); 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] #[test]
fn test_build_client_mtls_pkcs8_key() { fn test_build_client_mtls_pkcs8_key() {
// This test reproduces issue #51: PKCS#8 keys from OpenBao should work. // This test reproduces issue #51: PKCS#8 keys from OpenBao should work.