//! The one-shot move of the permissions this hive's meta repo held //! (`meta/capabilities.json` and `meta/tool-groups.json`) into the swarm's //! `agent-permissions` bucket, as one list per agent. //! //! At boot, before any agent starts, [`seed_local`] copies the files' grants //! into [`crate::permissions`] when nothing is stored there yet, so agents //! keep their mounts until the swarm answers. [`send_pending`] then sends the //! grants to swarm-controller, which writes each agent's key only where there //! is none, and on its answer deletes both files from the meta repo. Nothing //! reads them after that. use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use hive_sh4re::permissions::Permission; use swarm_queue_client::agent_permissions::{ self, AgentPermissions, IMPORT_REPLY_DEADLINE, Import, ImportReply, }; const CAPABILITIES_FILE: &str = "capabilities.json"; const TOOL_GROUPS_FILE: &str = "tool-groups.json"; /// The meta repo files the grants are moved out of. pub const META_FILES: &[&str] = &[CAPABILITIES_FILE, TOOL_GROUPS_FILE]; /// Tool groups the files may name that give nothing and have no /// [`Permission`]: dropped. const EMPTY_TOOL_GROUPS: &[&str] = &["lifecycle", "forge"]; type Grants = BTreeMap>; type FileMap = BTreeMap>; fn parse(agent: &str, name: &str) -> Option { let found = Permission::ALL .iter() .copied() .find(|p| name.eq_ignore_ascii_case(<&str>::from(*p))); if found.is_none() && !EMPTY_TOOL_GROUPS .iter() .any(|g| name.eq_ignore_ascii_case(g)) { tracing::warn!(%agent, permission = %name, "import: dropping unrecognised name"); } found } /// The one list per agent the two files add up to, as the swarm stores it. /// `None` for either file means it does not exist. /// /// Each half is what the agent held under the old readers: /// - tool groups: the entry's known names; an absent entry, or one naming /// only unknown names, was the default groups. `lifecycle`/`forge` count as /// known (they parsed) and are then dropped, so an entry of only those /// held no tools and imports as none. /// - capabilities: the entry's names. With the file present, `ruth` having no /// entry means her grant was revoked: the file was created holding it, and /// a revoke removed the entry. With no file, the default. /// /// An agent whose list equals [`Permission::default_for`] is left out, so its /// key stays unset and follows the default. fn grants(caps: Option<&FileMap>, groups: Option<&FileMap>) -> Grants { let mut agents: BTreeSet<&str> = BTreeSet::new(); agents.extend(caps.iter().flat_map(|m| m.keys().map(String::as_str))); agents.extend(groups.iter().flat_map(|m| m.keys().map(String::as_str))); if caps.is_some() { agents.insert(crate::lifecycle::MANAGER_NAME); } let mut out = Grants::new(); for agent in agents { let default = Permission::default_for(agent); let tool_default: Vec = default .iter() .copied() .filter(|p| *p != Permission::ManageRootAgent) .collect(); let tool_part = match groups.and_then(|m| m.get(agent)) { Some(names) if names.iter().any(|n| { parse(agent, n).is_some() || EMPTY_TOOL_GROUPS.iter().any(|g| n.eq_ignore_ascii_case(g)) }) => { names.iter().filter_map(|n| parse(agent, n)).collect() } _ => tool_default, }; let cap_part: Vec = match caps { Some(m) => m .get(agent) .map(|names| names.iter().filter_map(|n| parse(agent, n)).collect()) .unwrap_or_default(), None => default .iter() .copied() .filter(|p| *p == Permission::ManageRootAgent) .collect(), }; let mut list: Vec = Vec::new(); for perm in tool_part.into_iter().chain(cap_part) { if !list.contains(&perm) { list.push(perm); } } let same_as_default = list.len() == default.len() && default.iter().all(|p| list.contains(p)); if !same_as_default { out.insert(agent.to_owned(), list); } } out } fn read_file(path: &Path) -> Result> { if !path.exists() { return Ok(None); } let map = serde_json::from_slice( &std::fs::read(path).with_context(|| format!("reading {}", path.display()))?, ) .with_context(|| format!("parsing {}", path.display()))?; Ok(Some(map)) } /// The grants the files in `meta` hold, or `None` when neither exists. fn read_grants(meta: &Path) -> Result> { let caps = read_file(&meta.join(CAPABILITIES_FILE))?; let groups = read_file(&meta.join(TOOL_GROUPS_FILE))?; if caps.is_none() && groups.is_none() { return Ok(None); } Ok(Some(grants(caps.as_ref(), groups.as_ref()))) } fn meta_dir() -> PathBuf { crate::paths::meta_root() } /// Whether a meta repo file is still waiting to be imported. #[must_use] pub fn pending() -> bool { let meta = meta_dir(); META_FILES.iter().any(|f| meta.join(f).exists()) } /// Store the files' grants as the last-read swarm permissions, when a file /// is still there and nothing has been stored yet. pub fn seed_local() { if let Err(e) = seed_local_at(&meta_dir(), &crate::permissions::path()) { tracing::warn!(error = ?e, "import: seeding the stored permissions from the meta repo failed"); } } fn seed_local_at(meta: &Path, stored: &Path) -> Result<()> { if stored.exists() { return Ok(()); } let Some(grants) = read_grants(meta)? else { return Ok(()); }; let records = grants .into_iter() .map(|(agent, perms)| { ( agent, AgentPermissions { permissions: Some(perms), }, ) }) .collect(); crate::permissions::write_at(stored, &records).context("storing the seeded permissions")?; tracing::info!("import: seeded the stored permissions from the meta repo"); Ok(()) } /// What one attempt to send the import came to. #[derive(Debug)] pub enum ImportOutcome { /// No meta file is left: nothing to import. NothingPending, /// The controller took the grants and the files are deleted. Imported, /// The controller answered with a refusal; the files stay. Refused(String), /// No answer (the controller or the queue is down), or a file could not /// be read or deleted; the files stay. Unsettled(anyhow::Error), } impl ImportOutcome { /// Whether the swarm's keys may be read after this outcome. While a file /// is left, an unset key may only mean its grants are not imported yet, /// and reading it as the default would undo them. #[must_use] pub fn read_allowed(&self) -> bool { matches!(self, Self::NothingPending | Self::Imported) } /// Why reading is not allowed, for the caller's log and banner. #[must_use] pub fn into_error(self) -> Option { match self { Self::NothingPending | Self::Imported => None, Self::Refused(why) => Some(anyhow::anyhow!("the controller refused the import: {why}")), Self::Unsettled(e) => Some(e.context("importing the meta repo's permissions")), } } } /// Send the files' grants to swarm-controller while a file is there, and /// delete both once the controller has answered that it handled them. The /// next pass sends again after anything but [`ImportOutcome::Imported`]; the /// controller keeps a key it already has. pub async fn send_pending(client: &async_nats::Client, hive: &str) -> ImportOutcome { match send_at(client, hive, &meta_dir()).await { Ok(outcome) => outcome, Err(e) => ImportOutcome::Unsettled(e), } } async fn send_at(client: &async_nats::Client, hive: &str, meta: &Path) -> Result { let Some(permissions) = read_grants(meta)? else { return Ok(ImportOutcome::NothingPending); }; swarm_queue_client::ensure_connected(client)?; let payload = serde_json::to_vec(&Import { permissions })?; let reply = tokio::time::timeout( IMPORT_REPLY_DEADLINE, client.request(agent_permissions::import_subject(hive), payload.into()), ) .await .context("the controller did not answer the import")? .context("sending the import")?; match serde_json::from_slice(&reply.payload).context("decoding the controller's answer")? { ImportReply::Imported { written, kept, skipped, } => { tracing::info!( ?written, ?kept, ?skipped, "import: the controller took the meta repo's permissions" ); crate::meta::remove_permission_files() .await .context("deleting the imported permission files")?; Ok(ImportOutcome::Imported) } ImportReply::Failed { error } => Ok(ImportOutcome::Refused(error)), } } #[cfg(test)] mod tests { use super::{FileMap, grants, seed_local_at}; use hive_sh4re::permissions::Permission::{self, *}; fn file(json: &str) -> FileMap { serde_json::from_str(json).expect("decodes") } /// Both files add up per agent; the empty groups and unknown names drop; /// an agent left on the default is not sent. #[test] fn the_two_files_merge_into_one_list() { let caps = file(r#"{"ruth": ["manage_root_agent"], "atlas": ["manage_root_agent"]}"#); let groups = file( r#"{"ruth": ["messaging", "meta", "inbox", "lifecycle", "execution"], "atlas": ["messaging", "web_tools", "forge"], "iris": ["messaging", "fly_to_the_moon"], "sock": ["lifecycle"]}"#, ); let out = grants(Some(&caps), Some(&groups)); assert!(!out.contains_key("ruth"), "ruth's seed is her default"); assert_eq!(out["atlas"], vec![Messaging, WebTools, ManageRootAgent]); assert_eq!(out["iris"], vec![Messaging]); assert_eq!(out["sock"], Vec::::new()); } /// Without a capabilities entry, ruth's existing file means a revoke; with /// no capabilities file at all, her default stands. #[test] fn a_revoked_ruth_stays_revoked() { let out = grants(Some(&file("{}")), None); assert_eq!(out["ruth"], vec![Messaging, Meta, Inbox, Execution]); let out = grants(None, Some(&file(r#"{"atlas": ["messaging"]}"#))); assert!(!out.contains_key("ruth")); assert_eq!(out["atlas"], vec![Messaging]); } /// The seed writes once, from the files, and never over a stored read. #[test] fn the_seed_copies_the_files_only_when_nothing_is_stored() { let dir = tempfile::tempdir().expect("tempdir"); let meta = dir.path(); let stored = dir.path().join("stored.json"); seed_local_at(meta, &stored).expect("no file is no seed"); assert!(!stored.exists()); std::fs::write( meta.join("capabilities.json"), r#"{"atlas": ["manage_root_agent"]}"#, ) .expect("seed"); seed_local_at(meta, &stored).expect("seeded"); assert!(crate::permissions::holds(&stored, "atlas", ManageRootAgent).expect("read")); assert!(!crate::permissions::holds(&stored, "ruth", ManageRootAgent).expect("read")); std::fs::write(&stored, "{}").expect("a later read"); seed_local_at(meta, &stored).expect("left alone"); assert_eq!(std::fs::read_to_string(&stored).expect("read"), "{}"); } /// The read gate, one row per outcome: only a settled import lets the /// swarm's keys be read. #[test] fn keys_are_read_only_once_the_import_is_settled() { use super::ImportOutcome; for (outcome, allowed) in [ (ImportOutcome::NothingPending, true), (ImportOutcome::Imported, true), (ImportOutcome::Refused("not a hive".to_owned()), false), ( ImportOutcome::Unsettled(anyhow::anyhow!("no responders")), false, ), ] { assert_eq!(outcome.read_allowed(), allowed, "{outcome:?}"); assert_eq!(outcome.into_error().is_none(), allowed); } } }