//! The identity entity an agent's login lands on, named after the agent. //! //! A cert login creates an entity with a generated name and an alias named for //! the certificate's common name, `hive-agent-`. The shared policy //! ([`policy::render_shared_agent`]) reads `{{identity.entity.name}}`, so the //! entity has to be called ``. This module makes it so, either by //! creating the entity and its alias before the first login or by renaming the //! entity a login already created. The alias is unique per auth mount and //! name, so there is never a second entity to pick between. //! //! An entity's name is its access under the shared policy, so only an entity //! the store named itself is ever renamed ([`is_generated_name`]) and no alias //! is ever moved. Any other arrangement is a [`Plan::Conflict`]: nothing is //! written and an operator decides. //! //! An agent whose entity is not named after it reads nothing under the shared //! policy, so what a failure costs depends on the caller. [`ensure_agent`], //! which runs before a cert-auth role is narrowed to that policy, returns the //! error and leaves the role as it was. [`ensure_agents`], the pass at every //! start, writes no role and so narrows nothing: it logs at `warn` and goes on. //! The grants are the `identity/` stanzas of `swarm-bao.nix`'s //! `controllerPolicyText`. use anyhow::{Context, Result, bail}; use swarm_secret_client::{ client::{DEFAULT_CERT_MOUNT, EntityRef}, policy, }; use crate::store::AccessStore; /// What the store holds for one agent's entity. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Seen { /// The entity that owns the agent's alias on the cert mount. pub by_alias: Option, /// The id of the entity named after the agent. pub by_name: Option, } /// What to do about it. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Plan { /// The alias's entity is already named after the agent. Keep, /// A login created the entity under a generated name. Rename { id: String }, /// An entity named after the agent exists, and the alias is on no entity. AddAlias { entity_id: String }, /// Neither the entity nor the alias exists. CreateAndAlias, /// The alias sits on an entity that is not the agent's: one with a name of /// someone's choosing, or one while another entity already has the /// agent's name. Nothing is written. Conflict { alias: String, entity_name: String }, } /// Whether `name` is one the store gave an entity at login: `entity_`, eight /// lowercase hex digits, and the root namespace's `.root` suffix, which stores /// that predate namespaces leave off. /// /// Exact on purpose. A name that is merely close, such as `entity_` followed /// by a word, is somebody's choice of name. #[must_use] pub fn is_generated_name(name: &str) -> bool { let Some(rest) = name.strip_prefix("entity_") else { return false; }; let digits = rest.strip_suffix(".root").unwrap_or(rest); digits.len() == 8 && digits .bytes() .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) } /// The plan that leaves the alias on an entity named `agent`. #[must_use] pub fn plan(agent: &str, seen: &Seen) -> Plan { match (&seen.by_alias, &seen.by_name) { (Some(entity), _) if entity.name == agent => Plan::Keep, (Some(entity), None) if is_generated_name(&entity.name) => Plan::Rename { id: entity.id.clone(), }, (Some(entity), _) => Plan::Conflict { alias: format!("{}{agent}", policy::AGENT_PREFIX), entity_name: entity.name.clone(), }, (None, Some(named)) => Plan::AddAlias { entity_id: named.clone(), }, (None, None) => Plan::CreateAndAlias, } } /// Make sure every agent in `agents` has its entity, logging what could not be /// done. Never fails: see the module header. pub async fn ensure_agents(store: &impl AccessStore, agents: &[String]) { let accessor = match store.auth_mount_accessor(DEFAULT_CERT_MOUNT).await { Ok(accessor) => accessor, Err(e) => { tracing::warn!( error = %e, "agent entities are not managed: the cert auth mount's accessor could not be read; an agent whose role attaches the shared policy alone reads nothing until its entity is named after it" ); return; } }; let mut changed = 0_usize; for agent in agents { match ensure_one(store, &accessor, agent).await { Ok(Plan::Keep | Plan::Conflict { .. }) => {} Ok(_) => changed += 1, Err(e) => { tracing::warn!(agent, error = %format!("{e:#}"), "this agent's identity entity could not be set up"); } } } tracing::info!( changed, agents = agents.len(), "agent identity entities checked" ); } /// Make sure `agent` has its entity, or say why not. /// /// For a caller that must not go on without it, such as the one that narrows a /// cert-auth role to the shared policy: an agent whose entity is not named after /// it reads nothing under that policy. /// /// # Errors /// When the cert mount's accessor cannot be read, when a store call fails, and /// on a [`Plan::Conflict`], which [`ensure_agents`] only logs. pub async fn ensure_agent(store: &impl AccessStore, agent: &str) -> Result<()> { let accessor = store .auth_mount_accessor(DEFAULT_CERT_MOUNT) .await .context("reading the cert auth mount's accessor")?; match ensure_one(store, &accessor, agent).await? { Plan::Conflict { alias, entity_name } => bail!( "the alias {alias} is on the entity {entity_name}, which is not {agent}; nothing was changed" ), _ => Ok(()), } } /// Observe, plan and apply for one agent. async fn ensure_one(store: &impl AccessStore, accessor: &str, agent: &str) -> Result { let alias = policy::agent_object_name(agent)?; let seen = Seen { by_alias: store .entity_by_alias(&alias, accessor) .await .with_context(|| format!("looking up the entity owning alias {alias}"))?, by_name: store .entity_id_by_name(agent) .await .with_context(|| format!("looking up the entity named {agent}"))?, }; let plan = plan(agent, &seen); match &plan { Plan::Keep => {} Plan::Rename { id } => { store .rename_entity(id, agent) .await .with_context(|| format!("renaming entity {id} to {agent}"))?; tracing::info!(agent, entity = %id, "agent identity entity renamed"); } Plan::AddAlias { entity_id } => { store .create_entity_alias(&alias, accessor, entity_id) .await .with_context(|| format!("adding alias {alias} to entity {entity_id}"))?; tracing::info!(agent, entity = %entity_id, "agent identity alias added"); } Plan::CreateAndAlias => { store .create_entity(agent) .await .with_context(|| format!("creating the entity {agent}"))?; let Some(entity_id) = store.entity_id_by_name(agent).await? else { bail!("the entity {agent} was created and is not there to read back"); }; store .create_entity_alias(&alias, accessor, &entity_id) .await .with_context(|| format!("adding alias {alias} to entity {entity_id}"))?; tracing::info!(agent, entity = %entity_id, "agent identity entity created"); } Plan::Conflict { alias, entity_name } => { tracing::error!( agent, alias, entity_name, "the agent's alias is on an entity that is not the agent's; nothing was changed; under the shared policy alone the agent reads nothing until an operator resolves it" ); } } Ok(plan) } #[cfg(test)] mod tests { use super::*; fn entity(id: &str, name: &str) -> EntityRef { EntityRef { id: id.to_owned(), name: name.to_owned(), } } fn conflict(entity_name: &str) -> Plan { Plan::Conflict { alias: "hive-agent-atlas".to_owned(), entity_name: entity_name.to_owned(), } } #[test] fn an_entity_already_named_after_the_agent_is_kept() { let seen = Seen { by_alias: Some(entity("e1", "atlas")), by_name: Some("e1".to_owned()), }; assert_eq!(plan("atlas", &seen), Plan::Keep); } #[test] fn nothing_at_all_is_created_with_its_alias() { let seen = Seen { by_alias: None, by_name: None, }; assert_eq!(plan("atlas", &seen), Plan::CreateAndAlias); } #[test] fn an_entity_a_login_named_is_renamed() { for generated in ["entity_1a2b3c4d.root", "entity_1a2b3c4d"] { let seen = Seen { by_alias: Some(entity("e1", generated)), by_name: None, }; assert_eq!( plan("atlas", &seen), Plan::Rename { id: "e1".to_owned() }, "{generated}" ); } } #[test] fn an_entity_somebody_named_is_never_renamed() { let seen = Seen { by_alias: Some(entity("e1", "bob")), by_name: None, }; assert_eq!(plan("atlas", &seen), conflict("bob")); } #[test] fn a_name_only_close_to_the_generated_shape_is_somebodys_choice() { for name in [ "entity_", "entity_backup", "entity_1a2b3c4", "entity_1a2b3c4d5", "entity_1A2B3C4D", "entity_1a2b3c4g", "entity_1a2b3c4d.root2", "entity_1a2b3c4d.other", "entity_1a2b3c4d.root.root", "xentity_1a2b3c4d", "entity-1a2b3c4d", ] { let seen = Seen { by_alias: Some(entity("e1", name)), by_name: None, }; assert_eq!(plan("atlas", &seen), conflict(name), "{name}"); } } #[test] fn an_entity_without_the_alias_gets_it() { let seen = Seen { by_alias: None, by_name: Some("e1".to_owned()), }; assert_eq!( plan("atlas", &seen), Plan::AddAlias { entity_id: "e1".to_owned() } ); } #[test] fn an_alias_on_another_entity_while_the_agents_name_is_taken_is_a_conflict() { let seen = Seen { by_alias: Some(entity("e1", "entity_1a2b3c4d.root")), by_name: Some("e2".to_owned()), }; assert_eq!(plan("atlas", &seen), conflict("entity_1a2b3c4d.root")); } #[test] fn an_alias_on_a_foreign_entity_is_not_moved_to_the_agents() { let seen = Seen { by_alias: Some(entity("e1", "bob")), by_name: Some("e2".to_owned()), }; assert_eq!(plan("atlas", &seen), conflict("bob")); } }