//! Per-agent capability configuration. Stored at //! `/var/lib/hyperhive/meta/capabilities.json` alongside `topology.json` //! and `tool-groups.json`. //! //! Format: a JSON object mapping agent name to an array of //! `hive_sh4re::permissions::Capability` `snake_case` strings: //! //! ```json //! { //! "atlas": ["manage_root_agent"] //! } //! ``` //! //! An absent entry (or an absent file) means "no extra capabilities". //! `render_flake` in `meta.rs` reads this file and injects //! `HIVE_CAPABILITIES` into each agent's systemd service env; absent //! entries emit no env var so agents without capabilities don't trigger //! a spurious rebuild. //! //! Write path: `set_caps` is called from the dashboard action handler //! that the operator uses to grant/revoke capabilities per agent. use hive_sh4re::permissions::Capability; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; const CAPABILITIES_FILE: &str = "capabilities.json"; #[must_use] pub fn capabilities_path() -> PathBuf { crate::paths::meta_root().join(CAPABILITIES_FILE) } /// True if `name` matches a recognised [`Capability`], case-insensitive /// (same comparison [`has_cap`] uses). #[must_use] fn is_known(name: &str) -> bool { Capability::ALL .iter() .any(|cap| name.eq_ignore_ascii_case(<&str>::from(*cap))) } /// Drop any capability string in `map` that isn't a recognised /// [`Capability`], warning per dropped string so a typo'd or stale /// grant doesn't look like it worked. An agent left with no /// capabilities is removed entirely, matching [`set_caps`]'s /// empty-vec behaviour. Returns whether anything was dropped. fn prune_unknown(map: &mut BTreeMap>) -> bool { let mut dropped_any = false; map.retain(|agent, caps| { caps.retain(|cap| { let known = is_known(cap); if !known { dropped_any = true; tracing::warn!(agent, capability = %cap, "dropping unrecognised capability"); } known }); !caps.is_empty() }); dropped_any } /// Read the per-agent capability map. An absent file is the empty map — /// callers treat a missing entry as "no extra capabilities". A file /// that exists but can't be read or parsed is an error. Any entry that /// isn't a recognised [`Capability`] is dropped (with a `warn!`) from /// what's returned — a stale or typo'd name is never honoured — but /// `read` itself never writes; the on-disk file only gets repaired the /// next time something calls `set_caps`/`remove_agent` anyway. pub fn read() -> std::io::Result>> { read_from(&capabilities_path()) } fn read_from(path: &Path) -> std::io::Result>> { let mut map = super::read_map(path)?; prune_unknown(&mut map); Ok(map) } /// Look up the configured capabilities for one agent. Returns an empty /// vec when the agent has no entry. Errors as [`read`] does. pub fn caps_for(name: &str) -> std::io::Result> { Ok(read()?.get(name).cloned().unwrap_or_default()) } /// Check whether an agent holds a specific capability. Errors as /// [`read`] does. pub fn has_cap(name: &str, cap: hive_sh4re::permissions::Capability) -> std::io::Result { Ok(caps_for(name)? .iter() .any(|s| s.eq_ignore_ascii_case(<&str>::from(cap)))) } /// Filter `caps` down to recognised [`Capability`] names (warning per /// drop) and apply the result to `current` for `name`: an empty /// result — whether `caps` started empty or every name in it was /// unknown — removes the entry, matching `read`'s empty-entry /// convention. Split out from [`set_caps`] so the decision logic is /// testable without touching the real capabilities file. fn apply_known_caps(current: &mut BTreeMap>, name: &str, caps: &[String]) { let known: Vec = caps .iter() .filter(|cap| { let ok = is_known(cap); if !ok { tracing::warn!(agent = name, capability = %cap, "dropping unrecognised capability"); } ok }) .cloned() .collect(); if known.is_empty() { current.remove(name); } else { current.insert(name.to_owned(), known); } } /// Set the capabilities for one agent and persist the map. Any name /// that isn't a recognised [`Capability`] is dropped (with a `warn!`) /// rather than written — an unknown grant should never look like it /// took effect. An empty `caps` vec, or one that becomes empty after /// dropping unknown names, removes the entry (agent has no /// capabilities). Fails without writing if the existing file can't be /// read or parsed. pub fn set_caps(name: &str, caps: &[String]) -> std::io::Result<()> { set_caps_at(&capabilities_path(), name, caps) } fn set_caps_at(path: &Path, name: &str, caps: &[String]) -> std::io::Result<()> { let mut current = read_from(path)?; apply_known_caps(&mut current, name, caps); super::write_map(path, ¤t) } /// Remove an agent from the capability map entirely. Called by /// `meta::sync_agents` when an agent is deprovisioned so stale entries /// don't accumulate. No-op if the agent has no entry. Fails without /// writing if the existing file can't be read or parsed. pub fn remove_agent(name: &str) -> std::io::Result<()> { remove_agent_at(&capabilities_path(), name) } fn remove_agent_at(path: &Path, name: &str) -> std::io::Result<()> { let mut current = read_from(path)?; if current.remove(name).is_some() { super::write_map(path, ¤t)?; } Ok(()) } #[cfg(test)] mod tests { use super::*; // `read`/`set_caps`/`remove_agent` shell out to `crate::paths::meta_root`, // which is hardcoded to `/var/lib/hyperhive` (no test override) — so // these tests pin the pure decision logic (`prune_unknown`, // `apply_known_caps`) against in-memory maps, and the file handling // through the path-taking `*_at` / `read_from` variants. const TRUNCATED: &str = "{\n \"atlas\": [\"manage_root_ag"; fn corrupt_file() -> (tempfile::TempDir, std::path::PathBuf) { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join(CAPABILITIES_FILE); std::fs::write(&path, TRUNCATED).expect("seed"); (dir, path) } #[test] fn read_of_a_corrupt_file_is_an_error() { let (_dir, path) = corrupt_file(); read_from(&path).expect_err("truncated JSON must not read as a map"); } #[test] fn set_caps_leaves_a_corrupt_file_untouched() { let (_dir, path) = corrupt_file(); set_caps_at(&path, "ruth", &["manage_root_agent".to_owned()]) .expect_err("a corrupt file must not be overwritten"); assert_eq!(std::fs::read(&path).expect("read"), TRUNCATED.as_bytes()); } #[test] fn remove_agent_leaves_a_corrupt_file_untouched() { let (_dir, path) = corrupt_file(); remove_agent_at(&path, "atlas").expect_err("a corrupt file must not be overwritten"); assert_eq!(std::fs::read(&path).expect("read"), TRUNCATED.as_bytes()); } #[test] fn set_caps_round_trips_through_a_missing_file() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join(CAPABILITIES_FILE); assert!(read_from(&path).expect("missing file is empty").is_empty()); set_caps_at(&path, "ruth", &["manage_root_agent".to_owned()]).expect("set"); let map = read_from(&path).expect("read"); assert_eq!(map["ruth"], vec!["manage_root_agent".to_owned()]); } #[test] fn prune_unknown_keeps_known_name() { let mut map = BTreeMap::from([("atlas".to_owned(), vec!["manage_root_agent".to_owned()])]); assert!(!prune_unknown(&mut map)); assert_eq!(map["atlas"], vec!["manage_root_agent".to_owned()]); } #[test] fn prune_unknown_drops_unrecognised_name() { let mut map = BTreeMap::from([("atlas".to_owned(), vec!["fly_to_the_moon".to_owned()])]); assert!(prune_unknown(&mut map)); assert!(!map.contains_key("atlas")); } #[test] fn prune_unknown_keeps_known_and_drops_unknown_in_same_entry() { let mut map = BTreeMap::from([( "atlas".to_owned(), vec!["manage_root_agent".to_owned(), "fly_to_the_moon".to_owned()], )]); assert!(prune_unknown(&mut map)); assert_eq!(map["atlas"], vec!["manage_root_agent".to_owned()]); } #[test] fn apply_known_caps_removes_entry_on_empty_input() { let mut current = BTreeMap::from([("atlas".to_owned(), vec!["manage_root_agent".to_owned()])]); apply_known_caps(&mut current, "atlas", &[]); assert!(!current.contains_key("atlas")); } #[test] fn apply_known_caps_removes_entry_when_only_unknown_names_given() { let mut current = BTreeMap::from([("atlas".to_owned(), vec!["manage_root_agent".to_owned()])]); apply_known_caps(&mut current, "atlas", &["fly_to_the_moon".to_owned()]); assert!(!current.contains_key("atlas")); } #[test] fn apply_known_caps_keeps_only_the_known_name() { let mut current = BTreeMap::new(); apply_known_caps( &mut current, "atlas", &["manage_root_agent".to_owned(), "fly_to_the_moon".to_owned()], ); assert_eq!(current["atlas"], vec!["manage_root_agent".to_owned()]); } }