From cc01469b07a0d7e24d2c4060bd6dfe2df338e772 Mon Sep 17 00:00:00 2001 From: Jad Seifeddine Date: Tue, 13 Oct 2026 13:58:04 +0700 Subject: [PATCH] connections: folder default credentials for entries without their own A folder can lend a username, password, private key and domain per session type to entries that have no password or private key of their own, so an entry created without secrets (a NetBox webhook, a bulk import) connects the moment it exists. The defaults live at `/.defaults` beside `.config`, are reserved like it (never listed, not a valid entry name) and are removed with the folder. `GET/PUT/DELETE .../defaults` manage them (secrets write-only, audited as update_folder_defaults / delete_folder_defaults) and the folder dialog gains a Default credentials section. Listings report such entries as `has_credentials: true` with `inherited_credentials: true`. An entry's own credentials always win and an entry set to prompt still prompts. A subfolder without its own `.defaults` takes its nearest ancestor's, per type, but the walk follows the access rule: it only continues past a folder whose `.config` has `inherit_from_parent: true`. That flag is how a folder is isolated for access, so a key set on a parent must not reach a subfolder shared with someone else through `allowed_users`. The isolated folder's own `.defaults` still apply to its entries. A folder with no `.config`, or one written before the flag existed, counts as not inheriting, exactly as `resolve_folder_access` treats it. The walk is `resolve_effective_defaults` over a small `DefaultsSource` trait so it is tested without a Vault. Both connect paths resolve defaults through one helper, `lend_folder_defaults`: the Connections endpoint (`POST .../entries/:entry/connect`) and `/api/connect` quick links, so an entry that works from the page works from a link. `rustguac vault-migrate` copies each folder's `.defaults` alongside its `.config`, so a DR split keeps them (the dry run lists them as `[defaults]`). Docs cover the storage layout, the inheritance rule and its boundary, the migrate behaviour, and the warning that a web entry using a default password has that password typed into a page the user can read. Part 2/3 of #253. --- CLAUDE.md | 1 + docs/api.md | 36 +- docs/credential-variables.md | 42 +++ docs/migration.md | 3 +- docs/roles-and-access-control.md | 2 + src/api.rs | 459 ++++++++++++++++++++++++- src/main.rs | 12 + src/migrate.rs | 24 +- src/vault.rs | 570 ++++++++++++++++++++++++++++++- static/connections.html | 90 +++++ 10 files changed, 1222 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6e73679..0c21eca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,7 @@ Vault KV v2 path structure: - `/shared//` — shared across all instances - `/instance///` — instance-specific - `/.config` — folder metadata: `{"allowed_groups":["group1"], "description":"..."}` +- `/.defaults` — default credentials per session type, lent to entries without their own; subfolders inherit them up to (not past) a folder with `inherit_from_parent: false` - `/users/` — per-user credential variables #### Multiple Vault backends (DR) diff --git a/docs/api.md b/docs/api.md index b278de7..fe38ef3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -38,7 +38,7 @@ Quick-connect endpoint for external integrations (e.g., NetBox Custom Links). Cr When `scope`, `folder`, and `entry` are all provided, the endpoint connects via the connections (credentials from Vault). Otherwise it creates an ad-hoc session. No credentials are passed in the URL for ad-hoc mode — if the target requires authentication, the user will see guacd's login prompt. -If the connections entry has `prompt_credentials: true` or has no stored password/key, the endpoint returns an inline credential form instead of creating the session immediately. The user enters credentials, which are POSTed to the connect endpoint and used for that session only (never stored). +If the connections entry has `prompt_credentials: true` or has no stored password/key, the endpoint returns an inline credential form instead of creating the session immediately. The user enters credentials, which are POSTed to the connect endpoint and used for that session only (never stored). An entry without its own secret that is covered by [folder default credentials](credential-variables.md#folder-default-credentials) connects with those, exactly as it does from the Connections page. See [NetBox Integration](netbox.md) for usage with NetBox Custom Links. @@ -597,6 +597,40 @@ For `spice` and `proxmox` entries, the `spice_*` and `proxmox_*` fields listed u Update a connection entry. Uses read-modify-write: reads existing entry from Vault, merges incoming fields on top. Credentials (`password`, `private_key`) that are omitted from the request are preserved from the existing entry. Jump host credentials are merged per-hop by index. +### `GET /api/addressbook/folders/:scope/:folder/defaults` (admin) + +The folder's default credentials, secrets stripped. These are lent to entries in the folder (and in subfolders without their own, per session type, up to but not past a subfolder with `inherit_from_parent: false`) that have no password or private key of their own, so an entry created without secrets by a webhook or an import is connectable the moment it exists. An entry's own credentials always win, and an entry set to `prompt_credentials` still prompts. See [Folder default credentials](credential-variables.md#folder-default-credentials) for the storage layout, the inheritance rule and the note on web entries. + +```json +{ + "defaults": { + "ssh": { "username": "root", "domain": null, "has_password": false, "has_private_key": true }, + "web": { "username": null, "domain": null, "has_password": true, "has_private_key": false } + } +} +``` + +Entries covered by folder defaults list with `has_credentials: true` and `inherited_credentials: true`. + +### `PUT /api/addressbook/folders/:scope/:folder/defaults` (admin) + +Set default credentials per session type. Types not mentioned keep what is stored; `null` removes a type. Within a type an omitted field keeps the stored value and `""` clears it, the same rule entry PUTs use for secrets. + +```json +{ + "ssh": { "username": "root", "private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n..." }, + "web": { "password": "router-password" }, + "rdp": null +} +``` + +Returns the same summary as `GET`. A type left with nothing in it is dropped; a folder left with no types loses its defaults entirely. Audited as `update_folder_defaults`. + +### `DELETE /api/addressbook/folders/:scope/:folder/defaults` (admin) + +Remove the folder's default credentials. Returns `204`. + + ### `DELETE /api/addressbook/folders/:scope/:folder/entries/:entry` (admin) Delete a connection entry. Returns `404` if there is no such entry. diff --git a/docs/credential-variables.md b/docs/credential-variables.md index 2efcd4c..4583d5a 100644 --- a/docs/credential-variables.md +++ b/docs/credential-variables.md @@ -133,3 +133,45 @@ path "secret/metadata/rustguac/users/*" { | `GET` | `/api/me/credentials` | operator+ | List own saved variables (passwords masked) | | `PUT` | `/api/me/credentials` | operator+ | Save/update own variables | | `GET` | `/api/credential-variables` | operator+ | List all variables used across accessible entries | + +## Folder default credentials + +A folder can lend credentials to entries that have none of their own, per session type. An entry created without secrets (a NetBox webhook, a bulk import) is then connectable the moment it exists, with no second pass to fill it in. Admins set them from the folder dialog in Connections ("Default credentials") or with the `/api/addressbook/folders/:scope/:folder/defaults` endpoints (see [API reference](api.md)). + +### Where they live + +Defaults are stored in Vault at `/.defaults`, beside the folder's `.config`: + +``` +/shared//.config access settings +/shared//.defaults default credentials, keyed by session type +``` + +```json +{ + "ssh": { "username": "root", "private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n..." }, + "web": { "password": "router-password" } +} +``` + +Each type can carry `username`, `password`, `private_key` and `domain`. A type with no password or private key lends nothing (a username alone cannot authenticate). The existing connections Vault policy covers this path; no policy change is needed. `.defaults` is a reserved name, like `.config`: it is never listed as an entry and an entry cannot be called that. + +### How they apply + +At connect time, an entry with no password or private key of its own, and not set to `prompt_credentials`, takes the defaults for its session type. The entry's own `username` and `domain` win when set; otherwise the defaults' values fill them. An entry with its own secret is never touched, and an entry set to prompt still prompts. This happens on both connect paths, the Connections page (`POST .../entries/:entry/connect`) and quick-connect links (`GET /api/connect?scope=...&folder=...&entry=...`), through the same resolver, so an entry that works from the page works from a link. + +Entries covered by defaults list with `has_credentials: true` and `inherited_credentials: true`, and show a `[folder]` marker in Connections. + +### Inheritance and the `inherit_from_parent` boundary + +A subfolder without its own `.defaults` uses its nearest ancestor's, per session type: the nearest folder on the path wins for each type, and a type it lacks comes from the next folder up. + +The walk up the tree follows the same rule as access: it continues past a folder only while that folder's `.config` has `inherit_from_parent: true`. A folder with `inherit_from_parent: false` is isolated for access (see [Roles and access control](roles-and-access-control.md#inheritance)), and defaults set on its parent do not flow into it. Otherwise a parent's key would be usable in a subfolder shared with someone else through `allowed_users`. The isolated folder's own `.defaults` still apply to its entries and to subfolders that inherit from it. A folder with no `.config`, or one written before the flag existed, counts as not inheriting, exactly as the access check treats it. + +### Migration between Vaults + +`rustguac vault-migrate` copies each folder's `.defaults` alongside its `.config`, so a DR split keeps the defaults; the dry run lists them as `[defaults] `. Deleting a folder removes its `.defaults` with it. + +### Web entries: the password is visible to the user + +A **web** entry with a default password has that password typed into the page by the login autofill, the same as a web entry with its own stored password. The user can read it from the page (a show-password toggle, the browser's developer tools, a form that echoes it). Treat a web default as shared with everyone who can connect through that folder, and prefer per-entry or per-user credentials (`$variables`) where the password must stay secret. SSH, RDP and VNC defaults are passed to guacd and never reach the browser. diff --git a/docs/migration.md b/docs/migration.md index adaab37..dfd7a62 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -170,7 +170,8 @@ scopes and want to move a scope onto a dedicated Vault (see copies a scope's whole subtree between two configured backends. Because the scope-to-path layout is identical in every backend, this is a same-identity copy, not a rewrite: it moves the entries **and** each folder's access config -(`.config`), so `allowed_groups` and inheritance travel with them. +(`.config`) and default credentials (`.defaults`), so `allowed_groups`, +inheritance and the credentials entries borrow all travel with them. ## Step 1: Preview diff --git a/docs/roles-and-access-control.md b/docs/roles-and-access-control.md index 6a97777..a32d790 100644 --- a/docs/roles-and-access-control.md +++ b/docs/roles-and-access-control.md @@ -151,6 +151,8 @@ Because folder settings live in Vault, they apply on every rustguac instance sha A subfolder created with `inherit_from_parent: true` (the default for new subfolders) grants access to anyone who can access its parent. A subfolder with `inherit_from_parent: false` is gated solely by its own `allowed_groups` and `allowed_users`, independent of the parent. +The same flag bounds [folder default credentials](credential-variables.md#folder-default-credentials): defaults set on a parent do not flow into a subfolder with `inherit_from_parent: false`, so isolating a folder for access also keeps the parent's secrets out of it. + ### Examples A folder with `allowed_groups: ["engineering", "devops"]`: diff --git a/src/api.rs b/src/api.rs index cfe236d..40d0c1d 100644 --- a/src/api.rs +++ b/src/api.rs @@ -3,7 +3,7 @@ use crate::auth::{self, client_ip, role_level, AuthIdentity, TrustedProxies}; use crate::db::{self, Db}; use crate::session::{CreateSessionRequest, SessionManager, SessionType}; -use crate::vault::{AddressBookEntry, FolderConfig, VaultClient, VaultError}; +use crate::vault::{AddressBookEntry, DefaultsSource, FolderConfig, VaultClient, VaultError}; use axum::{ body::Body, extract::{ConnectInfo, Path, Query, State}, @@ -2193,6 +2193,51 @@ impl VaultBackends { .await } + pub async fn get_folder_defaults( + &self, + scope: &str, + folder: &str, + ) -> Result { + self.scoped(scope) + .await? + .get_folder_defaults(scope, folder) + .await + } + + pub async fn put_folder_defaults( + &self, + scope: &str, + folder: &str, + defaults: &crate::vault::FolderDefaults, + ) -> Result<(), VaultError> { + self.scoped(scope) + .await? + .put_folder_defaults(scope, folder, defaults) + .await + } + + pub async fn delete_folder_defaults( + &self, + scope: &str, + folder: &str, + ) -> Result<(), VaultError> { + self.scoped(scope) + .await? + .delete_folder_defaults(scope, folder) + .await + } + + pub async fn effective_folder_defaults( + &self, + scope: &str, + folder: &str, + ) -> Result, VaultError> { + self.scoped(scope) + .await? + .effective_folder_defaults(scope, folder) + .await + } + pub async fn delete_folder( &self, scope: &str, @@ -2323,6 +2368,28 @@ impl VaultBackends { } } +/// The defaults walk over the multi-backend router: each read goes to the +/// backend that serves the scope, like every other folder read here. +impl DefaultsSource for VaultBackends { + async fn folder_defaults( + &self, + scope: &str, + folder: &str, + ) -> Result, VaultError> { + self.scoped(scope) + .await? + .folder_defaults(scope, folder) + .await + } + + async fn inherits_from_parent(&self, scope: &str, folder: &str) -> Result { + self.scoped(scope) + .await? + .inherits_from_parent(scope, folder) + .await + } +} + pub type VaultState = Arc; // ── Docs endpoint ── @@ -2517,6 +2584,258 @@ pub async fn ab_list_subfolders( } } +// ── Folder default credentials ── + +/// Which default credentials a folder lends, without the secrets. +fn folder_defaults_summary(d: &crate::vault::FolderDefaults) -> serde_json::Value { + let mut out = serde_json::Map::new(); + for (session_type, c) in d { + out.insert( + session_type.clone(), + json!({ + "username": c.username, + "domain": c.domain, + "has_password": c.password.as_deref().is_some_and(|p| !p.is_empty()), + "has_private_key": c.private_key.as_deref().is_some_and(|k| !k.is_empty()), + }), + ); + } + serde_json::Value::Object(out) +} + +/// GET /api/addressbook/folders/:scope/:folder/defaults — admin only. The +/// folder's own `.defaults` (not what it inherits), secrets stripped. +pub async fn ab_get_folder_defaults( + identity: Option>, + Extension(vault): Extension, + Path((scope, folder)): Path<(String, String)>, +) -> impl IntoResponse { + if !matches!(identity, Some(Extension(ref id)) if id.has_role("admin")) { + return ( + StatusCode::FORBIDDEN, + Json(json!({"error": "admin role required"})), + ) + .into_response(); + } + match vault.get_folder_defaults(&scope, &folder).await { + Ok(d) => Json(json!({ "defaults": folder_defaults_summary(&d) })).into_response(), + Err(VaultError::NotFound) => Json(json!({ "defaults": {} })).into_response(), + Err(e) => ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": e.to_string()})), + ) + .into_response(), + } +} + +/// One session type's part of a `PUT .../defaults` body. An omitted field +/// keeps what is stored, an empty string clears it; the same rule as entry +/// PUTs use for their secrets. +#[derive(Deserialize, Default)] +pub struct DefaultCredentialsPatch { + #[serde(default)] + pub username: Option, + #[serde(default)] + pub password: Option, + #[serde(default)] + pub private_key: Option, + #[serde(default)] + pub domain: Option, +} + +/// PUT /api/addressbook/folders/:scope/:folder/defaults — admin only. +/// +/// Body: `{ "": { username?, password?, private_key?, domain? } | null, ... }`. +/// Types not mentioned keep what is stored; `null` removes a type. Within a +/// type an omitted secret keeps the stored one and `""` clears it. A type +/// left with nothing in it is dropped; a folder left with no types loses its +/// `.defaults` key. +#[allow(clippy::too_many_arguments)] +pub async fn ab_put_folder_defaults( + ConnectInfo(addr): ConnectInfo, + headers: axum::http::HeaderMap, + identity: Option>, + trusted: Option>, + Extension(database): Extension, + Extension(vault): Extension, + Path((scope, folder)): Path<(String, String)>, + Json(patch): Json>>, +) -> impl IntoResponse { + let admin_email = match identity.as_ref() { + Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(), + _ => { + return ( + StatusCode::FORBIDDEN, + Json(json!({"error": "admin role required"})), + ) + .into_response() + } + }; + + for session_type in patch.keys() { + let ok = !session_type.is_empty() + && session_type.len() <= 32 + && session_type + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-'); + if !ok { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": format!("invalid session type key {:?}", session_type)})), + ) + .into_response(); + } + } + + let mut current = match vault.get_folder_defaults(&scope, &folder).await { + Ok(d) => d, + Err(VaultError::NotFound) => crate::vault::FolderDefaults::new(), + Err(e) => { + return ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": e.to_string()})), + ) + .into_response() + } + }; + + fn non_empty(v: String) -> Option { + if v.is_empty() { + None + } else { + Some(v) + } + } + + for (session_type, part) in patch { + match part { + None => { + current.remove(&session_type); + } + Some(p) => { + let c = current.entry(session_type).or_default(); + if let Some(u) = p.username { + c.username = non_empty(u); + } + if let Some(d) = p.domain { + c.domain = non_empty(d); + } + if let Some(pw) = p.password { + c.password = non_empty(pw); + } + if let Some(k) = p.private_key { + c.private_key = non_empty(k); + } + } + } + } + current.retain(|_, c| c.username.is_some() || c.domain.is_some() || c.has_secret()); + + match vault.put_folder_defaults(&scope, &folder, ¤t).await { + Ok(()) => { + let ip = audit_client_ip(&headers, &addr, trusted.as_ref()); + let details = json!({ "types": current.keys().collect::>() }).to_string(); + log_ab_event( + &database, + &admin_email, + "update_folder_defaults", + &scope, + &folder, + None, + &ip, + Some(&details), + ) + .await; + Json(json!({ "ok": true, "defaults": folder_defaults_summary(¤t) })) + .into_response() + } + Err(e) => ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": e.to_string()})), + ) + .into_response(), + } +} + +/// DELETE /api/addressbook/folders/:scope/:folder/defaults — admin only. +pub async fn ab_delete_folder_defaults( + ConnectInfo(addr): ConnectInfo, + headers: axum::http::HeaderMap, + identity: Option>, + trusted: Option>, + Extension(database): Extension, + Extension(vault): Extension, + Path((scope, folder)): Path<(String, String)>, +) -> impl IntoResponse { + let admin_email = match identity.as_ref() { + Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(), + _ => { + return ( + StatusCode::FORBIDDEN, + Json(json!({"error": "admin role required"})), + ) + .into_response() + } + }; + match vault.delete_folder_defaults(&scope, &folder).await { + Ok(()) => { + let ip = audit_client_ip(&headers, &addr, trusted.as_ref()); + log_ab_event( + &database, + &admin_email, + "delete_folder_defaults", + &scope, + &folder, + None, + &ip, + None, + ) + .await; + StatusCode::NO_CONTENT.into_response() + } + Err(e) => ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": e.to_string()})), + ) + .into_response(), + } +} + +/// Lend an entry without credentials of its own the folder defaults for its +/// session type: the nearest folder on the path wins per type, and the walk +/// stops at a folder with `inherit_from_parent: false` (see +/// `crate::vault::resolve_effective_defaults`). This is the one place the +/// Connections connect endpoint and `/api/connect` quick links resolve +/// defaults, so an entry behaves the same from either. An entry with its own +/// secret, or set to prompt, is returned untouched; a Vault error leaves it +/// as it was, and the caller then prompts as it did before defaults existed. +async fn lend_folder_defaults( + src: &S, + scope: &str, + folder: &str, + entry: &str, + mut ab_entry: AddressBookEntry, +) -> AddressBookEntry { + if !crate::vault::entry_takes_folder_defaults(&ab_entry) { + return ab_entry; + } + match crate::vault::resolve_effective_defaults(src, scope, folder).await { + Ok(Some(d)) => { + if crate::vault::apply_folder_defaults(&mut ab_entry, &d) { + tracing::debug!( + scope, + folder, + entry, + "entry using folder default credentials" + ); + } + } + Ok(None) => {} + Err(err) => tracing::warn!("Failed to read folder defaults from Vault: {}", err), + } + ab_entry +} + /// 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( @@ -2583,10 +2902,17 @@ pub async fn ab_list_all( .list_entries(&folder.scope, &folder.name) .await .unwrap_or_default(); + let defaults = vault + .effective_folder_defaults(&folder.scope, &folder.name) + .await + .ok() + .flatten(); let mut entries = Vec::new(); for name in &entry_names { if let Ok(entry) = vault.get_entry(&folder.scope, &folder.name, name).await { - entries.push(crate::vault::EntryInfo::from((name.as_str(), &entry))); + let mut info = crate::vault::EntryInfo::from((name.as_str(), &entry)); + info.apply_inherited(&entry, defaults.as_ref()); + entries.push(info); } } @@ -2674,12 +3000,19 @@ pub async fn ab_search_index( Ok(n) => n, Err(_) => continue, }; + let defaults = vault + .effective_folder_defaults(&scope, &path) + .await + .ok() + .flatten(); for name in &names { if let Ok(entry) = vault.get_entry(&scope, &path, name).await { + let mut info = crate::vault::EntryInfo::from((name.as_str(), &entry)); + info.apply_inherited(&entry, defaults.as_ref()); emitted.push(json!({ "scope": scope, "folder_path": path, - "entry": crate::vault::EntryInfo::from((name.as_str(), &entry)), + "entry": info, })); } } @@ -2722,10 +3055,17 @@ pub async fn ab_list_entries( }; // Fetch each entry and strip credentials + let defaults = vault + .effective_folder_defaults(&scope, &folder) + .await + .ok() + .flatten(); let mut entries = Vec::new(); for name in &entry_names { if let Ok(entry) = vault.get_entry(&scope, &folder, name).await { - entries.push(crate::vault::EntryInfo::from((name.as_str(), &entry))); + let mut info = crate::vault::EntryInfo::from((name.as_str(), &entry)); + info.apply_inherited(&entry, defaults.as_ref()); + entries.push(info); } } @@ -2875,6 +3215,8 @@ pub async fn ab_connect_entry( } }; + let ab_entry = lend_folder_defaults(vault.as_ref(), &scope, &folder, &entry, ab_entry).await; + // Resolve credential variable references ($domain_user, $domain_password, etc.) let ab_entry = if !crate::vault::entry_credential_variables(&ab_entry).is_empty() { let user_email = match &id { @@ -5355,6 +5697,10 @@ pub async fn quick_connect( } }; + // Same folder defaults as the Connections connect endpoint, so an + // entry that works from the page works from a link too. + let ab_entry = lend_folder_defaults(vault.as_ref(), scope, folder, entry, ab_entry).await; + // Resolve credential variable references let ab_entry = if !crate::vault::entry_credential_variables(&ab_entry).is_empty() { let user_email = match &id { @@ -5877,6 +6223,111 @@ mod tests { (a.to_string(), b.to_string()) } + // ── Folder default credentials: the one resolver both connect paths use + // (the Connections endpoint and /api/connect quick links). ── + struct FakeDefaultsSource { + defaults: std::collections::HashMap, + inherit: std::collections::HashSet, + } + impl DefaultsSource for FakeDefaultsSource { + async fn folder_defaults( + &self, + _scope: &str, + folder: &str, + ) -> Result, VaultError> { + Ok(self.defaults.get(folder).cloned()) + } + async fn inherits_from_parent( + &self, + _scope: &str, + folder: &str, + ) -> Result { + Ok(self.inherit.contains(folder)) + } + } + + fn defaults_with_ssh_key(key: &str) -> crate::vault::FolderDefaults { + let mut d = crate::vault::FolderDefaults::new(); + d.insert( + "ssh".into(), + crate::vault::DefaultCredentials { + username: Some("root".into()), + private_key: Some(key.into()), + ..Default::default() + }, + ); + d + } + + #[tokio::test] + async fn lend_folder_defaults_fills_a_bare_entry_and_respects_the_boundary() { + let src = FakeDefaultsSource { + defaults: [("corp".to_string(), defaults_with_ssh_key("corp-key"))] + .into_iter() + .collect(), + inherit: ["corp/linux"].into_iter().map(String::from).collect(), + }; + let bare = || AddressBookEntry { + session_type: "ssh".into(), + hostname: Some("10.0.0.1".into()), + ..Default::default() + }; + + // a subfolder that inherits gets corp's key and username + let e = lend_folder_defaults(&src, "shared", "corp/linux", "box", bare()).await; + assert_eq!(e.private_key.as_deref(), Some("corp-key")); + assert_eq!(e.username.as_deref(), Some("root")); + + // an isolated subfolder (inherit_from_parent: false) does not + let e = lend_folder_defaults(&src, "shared", "corp/guests", "box", bare()).await; + assert!(e.private_key.is_none() && e.username.is_none()); + + // the entry's own secret and username win + let own = AddressBookEntry { + password: Some("own".into()), + username: Some("admin".into()), + ..bare() + }; + let e = lend_folder_defaults(&src, "shared", "corp/linux", "box", own).await; + assert_eq!(e.password.as_deref(), Some("own")); + assert_eq!(e.username.as_deref(), Some("admin")); + assert!(e.private_key.is_none()); + + // an entry set to prompt is left to prompt + let prompting = AddressBookEntry { + prompt_credentials: Some(true), + ..bare() + }; + let e = lend_folder_defaults(&src, "shared", "corp/linux", "box", prompting).await; + assert!(e.private_key.is_none()); + } + + #[tokio::test] + async fn lend_folder_defaults_leaves_the_entry_alone_when_vault_fails() { + struct Down; + impl DefaultsSource for Down { + async fn folder_defaults( + &self, + _s: &str, + _f: &str, + ) -> Result, VaultError> { + Err(VaultError::Forbidden) + } + async fn inherits_from_parent(&self, _s: &str, _f: &str) -> Result { + Err(VaultError::Forbidden) + } + } + let e = AddressBookEntry { + session_type: "ssh".into(), + username: Some("admin".into()), + ..Default::default() + }; + let out = lend_folder_defaults(&Down, "shared", "corp/linux", "box", e).await; + // a failed read must not change the entry + assert_eq!(out.username.as_deref(), Some("admin")); + assert!(out.password.is_none() && out.private_key.is_none()); + } + #[tokio::test] async fn credential_vars_reach_accessible_folder_under_inaccessible_parent() { // shared/corp is NOT accessible; shared/corp/jumpcloud IS (a subfolder diff --git a/src/main.rs b/src/main.rs index 06f018b..4da6f93 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1295,6 +1295,18 @@ async fn run_server(config: Config, database: Db) { "/api/addressbook/folders/{scope}/{folder}/subfolders", get(api::ab_list_subfolders), ) + .route( + "/api/addressbook/folders/{scope}/{folder}/defaults", + get(api::ab_get_folder_defaults), + ) + .route( + "/api/addressbook/folders/{scope}/{folder}/defaults", + put(api::ab_put_folder_defaults), + ) + .route( + "/api/addressbook/folders/{scope}/{folder}/defaults", + delete(api::ab_delete_folder_defaults), + ) .route( "/api/addressbook/folders/{scope}/{folder}/entries", get(api::ab_list_entries), diff --git a/src/migrate.rs b/src/migrate.rs index a28a503..62b8ce8 100644 --- a/src/migrate.rs +++ b/src/migrate.rs @@ -6,9 +6,11 @@ //! rewrite: reading `scope=shared, folder=Foo, entry=Bar` from the source and //! writing it back with the same coordinates maps `/shared/Foo/Bar` //! to `/shared/Foo/Bar`, each client applying its own mount / -//! base_path / instance_name. Entries AND the `.config` sentinel at every -//! folder level are copied (the latter carries allowed_groups / -//! inherit_from_parent — dropping it would silently lose a folder's ACL). +//! base_path / instance_name. Entries AND the `.config` and `.defaults` +//! sentinels at every folder level are copied (`.config` carries +//! allowed_groups / inherit_from_parent, `.defaults` the folder's default +//! credentials — dropping either would silently lose a folder's ACL or leave +//! its entries unable to connect). use std::sync::Arc; @@ -93,6 +95,22 @@ pub async fn cmd_vault_migrate( Err(e) => eprintln!(" Warning: read .config for '{}' failed: {}", path, e), } + // Copy the folder's .defaults sentinel (if any): the default + // credentials its entries borrow. Without it, entries that connect + // on the source would prompt on the destination. + match src.get_folder_defaults(scope, path).await { + Ok(defaults) => { + if dry_run { + println!(" [defaults] {}", path); + } else if let Err(e) = dst.put_folder_defaults(scope, path, &defaults).await { + eprintln!(" Warning: write .defaults for '{}' failed: {}", path, e); + failures += 1; + } + } + Err(VaultError::NotFound) => {} + Err(e) => eprintln!(" Warning: read .defaults for '{}' failed: {}", path, e), + } + // Copy entries. let entries = src.list_entries(scope, path).await.unwrap_or_default(); for name in &entries { diff --git a/src/vault.rs b/src/vault.rs index 1cc97ab..e530429 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -83,6 +83,155 @@ pub struct FolderSubject<'a> { pub groups: &'a [String], } +/// Credentials a folder lends to entries that have none of their own, keyed +/// by session type (`ssh`, `rdp`, `vnc`, `web`, ...). Stored at +/// `/.defaults` beside `.config`, so an entry created without secrets +/// (a NetBox webhook, a bulk import) is connectable the moment it exists, +/// with no second pass to fill it in. A subfolder without its own `.defaults` +/// inherits its nearest ancestor's, per type, as far up as +/// `inherit_from_parent` allows (see `resolve_effective_defaults`). +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct DefaultCredentials { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub private_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub domain: Option, +} + +impl DefaultCredentials { + /// Whether these defaults carry something that can authenticate (a + /// username alone does not). + pub fn has_secret(&self) -> bool { + self.password.as_deref().is_some_and(|p| !p.is_empty()) + || self.private_key.as_deref().is_some_and(|k| !k.is_empty()) + } +} + +/// Per-session-type default credentials of one folder. +pub type FolderDefaults = std::collections::BTreeMap; + +/// Whether the entry carries a password or private key of its own. +pub fn entry_has_own_credentials(e: &AddressBookEntry) -> bool { + e.password.as_deref().is_some_and(|p| !p.is_empty()) + || e.private_key.as_deref().is_some_and(|k| !k.is_empty()) +} + +/// Whether an entry is a candidate for folder defaults: it has no password +/// or private key of its own and is not set to prompt at connect time (a +/// prompting entry keeps prompting, defaults or not). +pub fn entry_takes_folder_defaults(e: &AddressBookEntry) -> bool { + !entry_has_own_credentials(e) && e.prompt_credentials != Some(true) +} + +/// Give an entry without credentials of its own the folder defaults for its +/// session type. The entry's own username and domain win when set; the +/// defaults' password and private key are taken as they are. Returns whether +/// anything was applied: false when the entry already has a secret or is set +/// to prompt, when the folder has no defaults for the type, or when those +/// defaults have no secret. +pub fn apply_folder_defaults(entry: &mut AddressBookEntry, defaults: &FolderDefaults) -> bool { + if !entry_takes_folder_defaults(entry) { + return false; + } + let Some(d) = defaults.get(&entry.session_type) else { + return false; + }; + if !d.has_secret() { + return false; + } + if entry.username.as_deref().is_none_or(str::is_empty) && d.username.is_some() { + entry.username = d.username.clone(); + } + if entry.domain.as_deref().is_none_or(str::is_empty) && d.domain.is_some() { + entry.domain = d.domain.clone(); + } + entry.password = d.password.clone().filter(|p| !p.is_empty()); + entry.private_key = d.private_key.clone().filter(|k| !k.is_empty()); + true +} + +/// What the defaults walk reads per folder: its own `.defaults`, and whether +/// its `.config` lets it inherit from the parent. A trait so the walk in +/// `resolve_effective_defaults` is unit-testable without a Vault; both +/// `VaultClient` and the multi-backend router implement it. +#[allow(async_fn_in_trait)] +pub trait DefaultsSource { + /// The folder's own `.defaults`; `None` when it has none. + async fn folder_defaults( + &self, + scope: &str, + folder: &str, + ) -> Result, VaultError>; + + /// The folder's `inherit_from_parent` flag. A folder with no `.config` + /// does not inherit, which is how `resolve_folder_access` treats it too. + async fn inherits_from_parent(&self, scope: &str, folder: &str) -> Result; +} + +/// The defaults that apply to entries in `folder`: its own `.defaults`, then +/// each ancestor's for any session type still unset, walking up only while +/// the folder just visited has `inherit_from_parent: true`. +/// +/// That flag is how a folder is isolated for access (its own +/// `allowed_groups` / `allowed_users` decide, nothing is taken from the +/// parent), so a secret set on a parent must not flow past it either: a +/// subfolder shared with someone through `allowed_users` would otherwise +/// hand them the parent's key. The isolated folder's own `.defaults` still +/// apply to its entries. `None` when no folder on the walk has any. +pub async fn resolve_effective_defaults( + src: &S, + scope: &str, + folder: &str, +) -> Result, VaultError> { + let mut merged = FolderDefaults::new(); + let mut path = folder.to_string(); + loop { + if let Some(d) = src.folder_defaults(scope, &path).await? { + for (session_type, creds) in d { + merged.entry(session_type).or_insert(creds); + } + } + // Top-level folder: nothing above it to consult. + let Some(i) = path.rfind('/') else { break }; + // Isolated folder: what its parent lends stops here. + if !src.inherits_from_parent(scope, &path).await? { + break; + } + path.truncate(i); + } + Ok(if merged.is_empty() { + None + } else { + Some(merged) + }) +} + +impl DefaultsSource for VaultClient { + async fn folder_defaults( + &self, + scope: &str, + folder: &str, + ) -> Result, VaultError> { + match self.get_folder_defaults(scope, folder).await { + Ok(d) => Ok(Some(d)), + Err(VaultError::NotFound) => Ok(None), + Err(e) => Err(e), + } + } + + async fn inherits_from_parent(&self, scope: &str, folder: &str) -> Result { + match self.get_folder_config(scope, folder).await { + Ok(c) => Ok(c.inherit_from_parent), + Err(VaultError::NotFound) => Ok(false), + Err(e) => Err(e), + } + } +} + impl FolderConfig { /// Whether this folder's own settings let `who` in: a shared group, or /// their email in `allowed_users`. Inheritance is the caller's concern. @@ -378,8 +527,13 @@ pub struct EntryInfo { pub kdc_url: Option, /// Whether to prompt for credentials at connect time. pub prompt_credentials: Option, - /// Whether the entry has a stored password or private key. + /// Whether the entry can authenticate without prompting: a stored + /// password or private key of its own, or folder defaults for its type. pub has_credentials: bool, + /// True when `has_credentials` comes from folder defaults rather than + /// the entry itself. Omitted when false. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub inherited_credentials: bool, /// VNC color depth. pub color_depth: Option, /// SSH tunnel jump hosts (no credentials exposed). @@ -528,6 +682,22 @@ pub struct EntryInfo { pub wol_wait_time: Option, } +impl EntryInfo { + /// Mark an entry as connectable through folder defaults when it has no + /// secret of its own and the defaults cover its session type. + pub fn apply_inherited(&mut self, e: &AddressBookEntry, defaults: Option<&FolderDefaults>) { + if self.has_credentials { + return; + } + if let Some(d) = defaults.and_then(|d| d.get(&e.session_type)) { + if d.has_secret() { + self.has_credentials = true; + self.inherited_credentials = true; + } + } + } +} + impl From<(&str, &AddressBookEntry)> for EntryInfo { fn from((name, e): (&str, &AddressBookEntry)) -> Self { let jump_hosts = e.jump_hosts.as_ref().map(|hops| { @@ -558,8 +728,8 @@ impl From<(&str, &AddressBookEntry)> for EntryInfo { auth_pkg: e.auth_pkg.clone(), kdc_url: e.kdc_url.clone(), prompt_credentials: e.prompt_credentials, - has_credentials: e.password.as_ref().is_some_and(|p| !p.is_empty()) - || e.private_key.as_ref().is_some_and(|k| !k.is_empty()), + has_credentials: entry_has_own_credentials(e), + inherited_credentials: false, color_depth: e.color_depth, jump_hosts, remote_app: e.remote_app.clone(), @@ -999,13 +1169,17 @@ impl VaultClient { } } - /// List entry names in a folder (excludes .config). + /// List entry names in a folder (excludes the `.config` and `.defaults` + /// sentinels). pub async fn list_entries(&self, scope: &str, folder: &str) -> Result, VaultError> { 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?; - Ok(keys.into_iter().filter(|k| k != ".config").collect()) + Ok(keys + .into_iter() + .filter(|k| k != ".config" && k != ".defaults") + .collect()) } /// Get a full entry (with credentials). @@ -1148,6 +1322,93 @@ impl VaultClient { } } + /// Read a folder's `.defaults` (see `FolderDefaults`). + pub async fn get_folder_defaults( + &self, + scope: &str, + folder: &str, + ) -> Result { + validate_path(folder)?; + let scope_prefix = self.resolve_scope_prefix(scope)?; + let path = self.data_path(&scope_prefix, &format!("{}/{}", folder, ".defaults")); + let resp = self.request(reqwest::Method::GET, &path, None).await?; + + match resp.status().as_u16() { + 200 => { + let json: serde_json::Value = resp.json().await?; + let data = &json["data"]["data"]; + serde_json::from_value(data.clone()) + .map_err(|e| VaultError::Parse(format!("invalid .defaults: {}", e))) + } + 404 => Err(VaultError::NotFound), + 403 => Err(VaultError::Forbidden), + s => Err(VaultError::Parse(format!("unexpected status {}", s))), + } + } + + /// Write a folder's `.defaults`. An empty map removes the key instead, + /// so a folder with nothing to lend has no sentinel at all. + pub async fn put_folder_defaults( + &self, + scope: &str, + folder: &str, + defaults: &FolderDefaults, + ) -> Result<(), VaultError> { + if defaults.is_empty() { + return self.delete_folder_defaults(scope, folder).await; + } + validate_path(folder)?; + let scope_prefix = self.resolve_scope_prefix(scope)?; + let path = self.data_path(&scope_prefix, &format!("{}/{}", folder, ".defaults")); + let body = serde_json::json!({ "data": defaults }); + let resp = self + .request(reqwest::Method::POST, &path, Some(&body)) + .await?; + + match resp.status().as_u16() { + 200 | 204 => Ok(()), + 403 => Err(VaultError::Forbidden), + s => { + let text = resp.text().await.unwrap_or_default(); + Err(VaultError::Parse(format!( + "put folder defaults failed ({}): {}", + s, text + ))) + } + } + } + + /// Remove a folder's `.defaults`. Absent is fine. + pub async fn delete_folder_defaults( + &self, + scope: &str, + folder: &str, + ) -> Result<(), VaultError> { + validate_path(folder)?; + let scope_prefix = self.resolve_scope_prefix(scope)?; + let path = self.metadata_path(&scope_prefix, &format!("{}/{}", folder, ".defaults")); + let resp = self.request(reqwest::Method::DELETE, &path, None).await?; + match resp.status().as_u16() { + 200 | 204 | 404 => Ok(()), + 403 => Err(VaultError::Forbidden), + s => Err(VaultError::Parse(format!( + "delete folder defaults failed ({})", + s + ))), + } + } + + /// The defaults that apply to entries in `folder`: see + /// `resolve_effective_defaults` for the walk and where it stops. + pub async fn effective_folder_defaults( + &self, + scope: &str, + folder: &str, + ) -> Result, VaultError> { + validate_path(folder)?; + resolve_effective_defaults(self, scope, folder).await + } + /// Delete an entire folder and its subtree (all entries + .config at every /// level). Pre-v1.6.0 this only cleared the top folder, which silently /// left subfolder keys in Vault; post-subfolders the UI would then refresh @@ -1187,8 +1448,13 @@ impl VaultClient { let _ = self.delete_entry(scope, path, &entry).await; entry_count += 1; } - let cfg_path = self.metadata_path(&scope_prefix, &format!("{}/{}", path, ".config")); - let _ = self.request(reqwest::Method::DELETE, &cfg_path, None).await; + for sentinel in [".config", ".defaults"] { + let sentinel_path = + self.metadata_path(&scope_prefix, &format!("{}/{}", path, sentinel)); + let _ = self + .request(reqwest::Method::DELETE, &sentinel_path, None) + .await; + } } let subfolder_count = queue.len().saturating_sub(1); @@ -1563,7 +1829,7 @@ fn validate_name(name: &str) -> Result<(), VaultError> { if name.is_empty() || name.len() > 64 { return Err(VaultError::BadName("name must be 1-64 characters".into())); } - if name == ".config" || name == "." || name == ".." { + if name == ".config" || name == ".defaults" || name == "." || name == ".." { return Err(VaultError::BadName("reserved name".into())); } if name.contains('/') || name.contains('\\') { @@ -2186,6 +2452,294 @@ mod tests { assert!(is_credential_variable("$has-dashes")); // hyphens allowed since v0.8.0 } + #[test] + fn folder_defaults_fill_only_entries_without_secrets() { + let mut defaults = FolderDefaults::new(); + defaults.insert( + "ssh".into(), + DefaultCredentials { + username: Some("root".into()), + private_key: Some("-----BEGIN KEY-----".into()), + ..Default::default() + }, + ); + defaults.insert( + "web".into(), + DefaultCredentials { + password: Some("router-pw".into()), + ..Default::default() + }, + ); + // username only: nothing to authenticate with + defaults.insert( + "rdp".into(), + DefaultCredentials { + username: Some("Administrator".into()), + ..Default::default() + }, + ); + + // ssh entry without secrets: takes the key, keeps its own username + let mut e = AddressBookEntry { + session_type: "ssh".into(), + username: Some("admin".into()), + ..Default::default() + }; + assert!(apply_folder_defaults(&mut e, &defaults)); + assert_eq!(e.username.as_deref(), Some("admin")); + assert_eq!(e.private_key.as_deref(), Some("-----BEGIN KEY-----")); + assert!(e.password.is_none()); + + // no username at all: the default's username fills it + let mut e = AddressBookEntry { + session_type: "web".into(), + ..Default::default() + }; + assert!(apply_folder_defaults(&mut e, &defaults)); + assert_eq!(e.password.as_deref(), Some("router-pw")); + assert!(e.username.is_none(), "web defaults carry no username"); + + // an entry with its own secret is left alone + let mut e = AddressBookEntry { + session_type: "ssh".into(), + password: Some("own".into()), + ..Default::default() + }; + assert!(!apply_folder_defaults(&mut e, &defaults)); + assert_eq!(e.password.as_deref(), Some("own")); + assert!(e.private_key.is_none()); + + // defaults with no secret do nothing; a type without defaults too + let mut e = AddressBookEntry { + session_type: "rdp".into(), + ..Default::default() + }; + assert!(!apply_folder_defaults(&mut e, &defaults)); + assert!(e.username.is_none()); + let mut e = AddressBookEntry { + session_type: "vnc".into(), + ..Default::default() + }; + assert!(!apply_folder_defaults(&mut e, &defaults)); + + // an entry set to prompt keeps prompting: no defaults for it + let mut e = AddressBookEntry { + session_type: "ssh".into(), + prompt_credentials: Some(true), + ..Default::default() + }; + assert!(!entry_takes_folder_defaults(&e)); + assert!(!apply_folder_defaults(&mut e, &defaults)); + assert!(e.private_key.is_none()); + } + + /// In-memory stand-in for the two per-folder reads the defaults walk + /// makes. `inherit` lists the folders whose `.config` has + /// `inherit_from_parent: true`; everything else is treated as isolated, + /// including folders with no `.config` at all. + struct FakeDefaults { + defaults: std::collections::HashMap, + inherit: std::collections::HashSet, + } + + impl DefaultsSource for FakeDefaults { + async fn folder_defaults( + &self, + _scope: &str, + folder: &str, + ) -> Result, VaultError> { + Ok(self.defaults.get(folder).cloned()) + } + async fn inherits_from_parent( + &self, + _scope: &str, + folder: &str, + ) -> Result { + Ok(self.inherit.contains(folder)) + } + } + + fn ssh_key(key: &str) -> FolderDefaults { + let mut d = FolderDefaults::new(); + d.insert( + "ssh".into(), + DefaultCredentials { + private_key: Some(key.into()), + ..Default::default() + }, + ); + d + } + + fn web_pw(pw: &str) -> FolderDefaults { + let mut d = FolderDefaults::new(); + d.insert( + "web".into(), + DefaultCredentials { + password: Some(pw.into()), + ..Default::default() + }, + ); + d + } + + #[tokio::test] + async fn effective_defaults_merge_nearest_first_through_inheriting_folders() { + // corp/.defaults: ssh key + web pw; corp/linux/.defaults: ssh key only; + // corp/linux/web has none. Every subfolder inherits. + let mut defaults = std::collections::HashMap::new(); + let mut corp = ssh_key("corp-key"); + corp.extend(web_pw("corp-web")); + defaults.insert("corp".to_string(), corp); + defaults.insert("corp/linux".to_string(), ssh_key("linux-key")); + let src = FakeDefaults { + defaults, + inherit: ["corp/linux", "corp/linux/web"] + .into_iter() + .map(String::from) + .collect(), + }; + + let d = resolve_effective_defaults(&src, "shared", "corp/linux/web") + .await + .unwrap() + .expect("defaults found on the path"); + // nearest folder wins per type; a type it lacks comes from higher up + assert_eq!(d["ssh"].private_key.as_deref(), Some("linux-key")); + assert_eq!(d["web"].password.as_deref(), Some("corp-web")); + + // nothing anywhere on the path: None, not an empty map + let bare = FakeDefaults { + defaults: Default::default(), + inherit: ["a/b"].into_iter().map(String::from).collect(), + }; + assert!(resolve_effective_defaults(&bare, "shared", "a/b") + .await + .unwrap() + .is_none()); + } + + #[tokio::test] + async fn effective_defaults_stop_at_a_folder_that_does_not_inherit() { + // corp lends an ssh key. corp/guests is isolated for access + // (inherit_from_parent: false, shared with an outside user through + // allowed_users); corp/guests/lab inherits from corp/guests. The + // corp key must reach neither of them. + let mut defaults = std::collections::HashMap::new(); + defaults.insert("corp".to_string(), ssh_key("corp-key")); + let src = FakeDefaults { + defaults: defaults.clone(), + inherit: ["corp/guests/lab"].into_iter().map(String::from).collect(), + }; + assert!( + resolve_effective_defaults(&src, "shared", "corp/guests") + .await + .unwrap() + .is_none(), + "an isolated folder must not receive its parent's defaults" + ); + assert!( + resolve_effective_defaults(&src, "shared", "corp/guests/lab") + .await + .unwrap() + .is_none(), + "the walk must not cross the isolated folder on its way up" + ); + + // the isolated folder's own .defaults still apply to its entries and + // to subfolders that inherit from it, but corp's key stays out + defaults.insert("corp/guests".to_string(), web_pw("guest-web")); + let src = FakeDefaults { + defaults, + inherit: ["corp/guests/lab"].into_iter().map(String::from).collect(), + }; + for folder in ["corp/guests", "corp/guests/lab"] { + let d = resolve_effective_defaults(&src, "shared", folder) + .await + .unwrap() + .expect("the isolated folder's own defaults apply"); + assert_eq!(d["web"].password.as_deref(), Some("guest-web")); + assert!( + !d.contains_key("ssh"), + "{folder}: corp's key crossed the boundary" + ); + } + + // a folder with no .config is treated as isolated, as access does + let src = FakeDefaults { + defaults: [("corp".to_string(), ssh_key("corp-key"))] + .into_iter() + .collect(), + inherit: Default::default(), + }; + assert!( + resolve_effective_defaults(&src, "shared", "corp/unconfigured") + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn effective_defaults_propagate_read_errors() { + struct Failing; + impl DefaultsSource for Failing { + async fn folder_defaults( + &self, + _s: &str, + _f: &str, + ) -> Result, VaultError> { + Ok(Some(ssh_key("k"))) + } + async fn inherits_from_parent(&self, _s: &str, _f: &str) -> Result { + Err(VaultError::Forbidden) + } + } + // a .config that cannot be read must not be mistaken for "inherits" + assert!(resolve_effective_defaults(&Failing, "shared", "a/b") + .await + .is_err()); + // but a top-level folder never needs its config + let d = resolve_effective_defaults(&Failing, "shared", "a") + .await + .unwrap(); + assert!(d.is_some()); + } + + #[test] + fn entry_info_marks_inherited_credentials() { + let mut defaults = FolderDefaults::new(); + defaults.insert( + "ssh".into(), + DefaultCredentials { + private_key: Some("k".into()), + ..Default::default() + }, + ); + let e = AddressBookEntry { + session_type: "ssh".into(), + ..Default::default() + }; + let mut info = EntryInfo::from(("box", &e)); + assert!(!info.has_credentials); + info.apply_inherited(&e, Some(&defaults)); + assert!(info.has_credentials && info.inherited_credentials); + + // own secret: has_credentials without the inherited flag + let e = AddressBookEntry { + session_type: "ssh".into(), + password: Some("p".into()), + ..Default::default() + }; + let mut info = EntryInfo::from(("box", &e)); + info.apply_inherited(&e, Some(&defaults)); + assert!(info.has_credentials && !info.inherited_credentials); + + // the JSON omits the flag when false + let v = serde_json::to_value(&info).unwrap(); + assert!(v.get("inherited_credentials").is_none()); + } + #[test] fn test_entry_credential_variables() { let entry = AddressBookEntry { diff --git a/static/connections.html b/static/connections.html index 4b0f0b2..3b7ea89 100644 --- a/static/connections.html +++ b/static/connections.html @@ -194,6 +194,15 @@ .folder-actions strong { font-size: var(--fz-lg); } .folder-actions .desc { font-size: var(--fz-sm); } } + /* Folder modal: default credentials */ + .fm-defaults { margin: var(--s-3) 0; border: 1px solid var(--border); border-radius: var(--radius); padding: var(--s-2) var(--s-3); } + .fm-defaults summary { cursor: pointer; color: var(--text); font-size: var(--fz-md); } + .fm-defaults-table { width: 100%; border-collapse: collapse; margin-top: var(--s-2); } + .fm-defaults-table th { text-align: left; font-weight: normal; color: var(--text-muted); font-size: var(--fz-sm); padding: var(--s-1) var(--s-2) var(--s-1) 0; width: 3em; vertical-align: top; } + .fm-defaults-table td { padding: var(--s-1); vertical-align: top; } + .fm-defaults-table input, .fm-defaults-table textarea { width: 100%; margin: 0; font-size: var(--fz-sm); } + .fm-defaults-table textarea { min-height: var(--ctl-sm); resize: vertical; } + .fmd-clear-label { display: flex; align-items: center; gap: var(--s-1); font-size: var(--fz-xs); color: var(--text-dim); white-space: nowrap; padding-top: var(--s-2); } /* Active sessions strip */ .active-sessions { margin-bottom: var(--s-5); } @@ -667,6 +676,13 @@ Inherit permissions from parent folder
When ticked, if this folder's own groups list doesn't grant a user access, the check walks up to the parent (and further ancestors if they also inherit). Uncheck to enforce this folder's groups list on its own. Top-level folders have no parent so this has no effect.
+
+ Default credentials +
Lent to entries in this folder that have no password or key of their own, per session type, so an entry created without secrets (a NetBox webhook, an import) connects straight away. An entry's own credentials always win; subfolders inherit per type, but not past a folder with "Inherit permissions from parent folder" unticked. A web entry's default password is typed into the page, where the user can read it. Secrets are write-only: leave a field blank to keep what is stored, tick clear to drop a type.
+ + +
+