//! Each hive's read-only account on the swarm's forge, `hive-`: created //! here with the admin API, put in the [`HIVES_TEAM`] of `agent-configs` (read //! on every config repo, including ones created later), and given one token, //! stored at `swarm/hives//forge/token`, where hive-c0re reads it under //! the hive's own store identity. //! //! The roster is the controller's hive directory, as for //! `matrix_account::hive_sender`. The token decision is //! [`super::agent_token`]'s, under this module's name and scopes, so a stored //! token the forge still lists with the same last eight characters and scopes //! is kept, and a pass whose reads fail queues nothing. //! //! A new agent may not be called `hive-` //! (`hive_types::reserved_agent_prefix_violation`, checked at agent creation), //! which is the primary guard against an agent claiming a hive's account. An //! agent created before that rule can still hold such a name, so each side //! also refuses an account carrying the other's email: neither ever locks //! down, joins the team with, or mints a token for the other's account. use std::sync::Arc; use anyhow::{Context, Result, bail}; use forgejo_api::structs::{ AccessToken, CreateTeamOption, CreateTeamOptionPermission, EditTeamOption, EditTeamOptionPermission, RepoSearchQuery, Team, TeamPermission, }; use forgejo_api::{Auth, Forgejo}; use swarm_secret_client::forge; use super::agent_token::{Decision, Observed, classify_named, is_not_found}; use super::{CONFIG_ORG, Client, folds_into_success}; /// The forge's name for every hive token this module mints. pub const HIVE_TOKEN_NAME: &str = "swarm-hive"; /// What a hive token may do: read repositories. Which repositories is the /// account's business, and the account is only in [`HIVES_TEAM`]. pub const HIVE_TOKEN_SCOPES: &str = "read:repository"; /// The team in `agent-configs` every hive account is a member of. const HIVES_TEAM: &str = "hives"; const HIVES_TEAM_DESCRIPTION: &str = "hyperhive hives — read on every agent config repo"; /// What a hive reads in a config repo: the git tree, and which PR merged a /// commit. const HIVES_TEAM_UNITS: [&str; 2] = ["repo.code", "repo.pulls"]; /// How often [`spawn`] re-checks every hive. const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5); /// The email this module creates `hive`'s account with, and the one it /// requires before acting on an account by that name. /// /// Under its own domain, so it can equal neither email an agent account /// carries (`super::agent_email`, `super::legacy_agent_email`) whatever the /// agent is called. fn hive_email(hive: &str) -> String { format!("{}@hives.hyperhive.local", forge::hive_login(hive)) } /// Whether the account named [`forge::hive_login`]`(hive)`, carrying `email`, /// is the one this module creates. fn is_hive_account(hive: &str, email: Option<&str>) -> bool { email == Some(hive_email(hive).as_str()) } /// Whether `t` has every setting [`Client::ensure_hives_team`] gives it. fn hives_team_matches(t: &Team) -> bool { let units_match = t.units.as_ref().is_some_and(|units| { let mut have: Vec<&str> = units.iter().map(String::as_str).collect(); let mut want = HIVES_TEAM_UNITS.to_vec(); have.sort_unstable(); want.sort_unstable(); have == want }); units_match && t.permission == Some(TeamPermission::Read) && t.includes_all_repositories == Some(true) && t.can_create_org_repo == Some(false) && t.description.as_deref() == Some(HIVES_TEAM_DESCRIPTION) } /// [`HIVES_TEAM`] as one pass found it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TeamSeen { /// Present with every setting [`hives_team_matches`] checks. Matches(i64), /// Present with some other setting, which may be a wider grant. Drifted(i64), Absent, /// The read failed. Unknown, } /// What one pass found for one hive. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct HiveSeen { pub token: Observed, /// Whether the account is in [`HIVES_TEAM`]; `None` when that read failed /// or the team id is not known. pub member: Option, } /// The hives a pass queues a `MintHiveForgeToken` job for. /// /// A token that has to be minted, or an account that does not exist, always /// queues. A kept token queues only when the account or the team is not as /// the job leaves them; that job finds the token current again and does not /// rotate it. /// /// [`Observed::Unknown`] never queues: a forge or store outage must not turn /// into a rotation of every hive's token. Neither does a /// [`Decision::Conflict`], which the job would refuse anyway. pub fn plan(team: TeamSeen, hives: &[(String, HiveSeen)]) -> Vec { let team_needs_work = matches!(team, TeamSeen::Absent | TeamSeen::Drifted(_)); hives .iter() .filter(|(_, seen)| match seen.token { Observed::Unknown | Observed::Decided(Decision::Conflict) => false, Observed::NoForgeUser | Observed::Decided(Decision::Mint(_)) => true, Observed::Decided(Decision::Keep) => team_needs_work || seen.member == Some(false), }) .map(|(hive, _)| hive.clone()) .collect() } impl Client { /// Make sure `hive`'s account exists, is in [`HIVES_TEAM`], and holds a /// live [`HIVE_TOKEN_NAME`] token in the store, minting one when it does /// not. The whole job of the `MintHiveForgeToken` node. /// /// A rotation is [`Client::replace_token`]'s delete then create. The new /// token is used once, and the forge's and the store's state is read back /// and classified again, so the node does not report success on a write /// nobody has read. /// /// # Errors /// When the store or the forge refuses a step, when an account by the /// hive's name exists and is not the hive's, or when the read-back does /// not show the new token. pub async fn ensure_hive_forge_token(&self, hive: &str) -> Result<()> { let login = forge::hive_login(hive); let path = forge::hive_token_path(hive)?; self.ensure_hive_user(hive, &login).await?; let team = self.ensure_hives_team().await?; self.api .call(|api| api.org_add_team_member(team, &login).into_future()) .await .with_context(|| format!("adding {login} to {CONFIG_ORG}/{HIVES_TEAM}"))?; let store = crate::store::connect() .await .context("logging in to the swarm secret store")?; let (stored, listed) = self.read_hive_token(&store, &login, &path).await?; let reason = match classify_named(HIVE_TOKEN_NAME, HIVE_TOKEN_SCOPES, stored.as_ref(), &listed) { Decision::Keep => { tracing::debug!(hive, "hive forge token is current; left as it is"); return Ok(()); } Decision::Conflict => bail!( "the forge lists more than one {HIVE_TOKEN_NAME:?} token for {login:?}; \ delete them by id and re-run" ), Decision::Mint(reason) => reason, }; let credential = self .replace_token( &store, &login, HIVE_TOKEN_NAME, HIVE_TOKEN_SCOPES, &listed, &path, ) .await?; Forgejo::new(Auth::Token(&credential.value), self.url.clone()) .context("building a forge client with the new token")? .repo_search(RepoSearchQuery::default()) .await .with_context(|| format!("using {login}'s new forge token"))?; let (stored, listed) = self.read_hive_token(&store, &login, &path).await?; let now = classify_named(HIVE_TOKEN_NAME, HIVE_TOKEN_SCOPES, stored.as_ref(), &listed); if now != Decision::Keep { bail!("{login}'s new forge token does not read back as current: {now:?}"); } tracing::info!(hive, ?reason, %path, "hive forge token minted and stored"); Ok(()) } /// The stored token at `path` and every token the forge lists for /// `login`, which must exist. async fn read_hive_token( &self, store: &swarm_secret_client::SecretStore, login: &str, path: &str, ) -> Result<(Option, Vec)> { let stored = store .read_optional(path) .await .with_context(|| format!("reading {path}"))?; let Some(listed) = self.list_agent_tokens(login).await? else { bail!("the forge has no user {login:?} after this job created it"); }; Ok((stored, listed)) } /// Create `hive`'s account if there is none, refuse one that is not the /// hive's, and lock it out of creating repos. async fn ensure_hive_user(&self, hive: &str, login: &str) -> Result<()> { self.create_user(login, &hive_email(hive)).await?; let user = self .api .call(|api| api.user_get(login).into_future()) .await .with_context(|| format!("read forge user {login}"))?; if !is_hive_account(hive, user.email.as_deref()) { bail!( "forge account `{login}` exists but is not hive `{hive}`'s (email {:?}, expected \ `{}`); refusing to give it the hive's access", user.email, hive_email(hive) ); } self.api .call(|api| { api.admin_edit_user(login, super::repo_creation_lockdown(login)) .into_future() }) .await .with_context(|| format!("disable repo creation for forge user {login}"))?; Ok(()) } /// The id of [`HIVES_TEAM`] in `agent-configs`, creating it or /// correcting its settings first when needed. Its members are not /// touched here. pub(super) async fn ensure_hives_team(&self) -> Result { match self.observe_hives_team().await { TeamSeen::Matches(id) => Ok(id), TeamSeen::Drifted(id) => { self.api .call(|api| { api.org_edit_team( id, EditTeamOption { can_create_org_repo: Some(false), description: Some(HIVES_TEAM_DESCRIPTION.to_owned()), includes_all_repositories: Some(true), name: HIVES_TEAM.to_owned(), permission: Some(EditTeamOptionPermission::Read), units: Some(HIVES_TEAM_UNITS.map(str::to_owned).to_vec()), units_map: None, }, ) .into_future() }) .await .with_context(|| { format!("reconcile {CONFIG_ORG}/{HIVES_TEAM} team settings") })?; tracing::info!("swarm forge: reconciled {CONFIG_ORG}/{HIVES_TEAM} team settings"); Ok(id) } TeamSeen::Absent => self.create_hives_team().await, TeamSeen::Unknown => bail!("could not read the teams of {CONFIG_ORG}"), } } async fn create_hives_team(&self) -> Result { let team = CreateTeamOption { can_create_org_repo: Some(false), description: Some(HIVES_TEAM_DESCRIPTION.to_owned()), includes_all_repositories: Some(true), name: HIVES_TEAM.to_owned(), permission: Some(CreateTeamOptionPermission::Read), units: Some(HIVES_TEAM_UNITS.map(str::to_owned).to_vec()), units_map: None, }; match self .api .call(|api| api.org_create_team(CONFIG_ORG, team.clone()).into_future()) .await { Ok(created) => { tracing::info!("swarm forge: created {CONFIG_ORG}/{HIVES_TEAM} team"); created .id .context("the forge created the hives team but returned no id") } // A duplicate is a 409 or a 422; either counts once a re-read // finds the team, which also supplies its id. Err(e) => match self.observe_hives_team().await { TeamSeen::Matches(id) | TeamSeen::Drifted(id) if folds_into_success(&e, true) => { Ok(id) } _ => Err(e).with_context(|| format!("create team {CONFIG_ORG}/{HIVES_TEAM}")), }, } } pub(super) async fn observe_hives_team(&self) -> TeamSeen { match self .api .call(|api| api.org_list_teams(CONFIG_ORG).into_future()) .await { Ok((_, teams)) => teams .into_iter() .find(|t| t.name.as_deref() == Some(HIVES_TEAM)) .map_or(TeamSeen::Absent, |t| match t.id { Some(id) if hives_team_matches(&t) => TeamSeen::Matches(id), Some(id) => TeamSeen::Drifted(id), None => TeamSeen::Unknown, }), Err(e) => { tracing::warn!(error = %e, "hive forge tokens: reading the teams of {CONFIG_ORG} failed"); TeamSeen::Unknown } } } /// What the store, the forge and the team say about `hive`. async fn observe_hive( &self, store: &swarm_secret_client::SecretStore, team: TeamSeen, hive: &str, ) -> HiveSeen { let login = forge::hive_login(hive); let stored = match forge::hive_token_path(hive) { Ok(path) => store.read_optional::(&path).await, Err(e) => Err(e), }; let token = match stored { Err(e) => { tracing::warn!(hive, error = %e, "hive forge token: store read failed"); Observed::Unknown } Ok(stored) => match self.list_agent_tokens(&login).await { Ok(Some(listed)) => Observed::Decided(classify_named( HIVE_TOKEN_NAME, HIVE_TOKEN_SCOPES, stored.as_ref(), &listed, )), Ok(None) => Observed::NoForgeUser, Err(e) => { tracing::warn!(hive, error = %format!("{e:#}"), "hive forge token: forge read failed"); Observed::Unknown } }, }; let member = match team { TeamSeen::Matches(id) | TeamSeen::Drifted(id) => { match self .api .call(|api| api.org_list_team_member(id, &login).into_future()) .await { Ok(_) => Some(true), Err(e) if is_not_found(&e) => Some(false), Err(e) => { tracing::warn!(hive, error = %e, "hive forge token: team membership read failed"); None } } } TeamSeen::Absent => Some(false), TeamSeen::Unknown => None, }; HiveSeen { token, member } } /// One pass: the team, then every hive in `hives`. async fn observe_hives(&self, hives: &[String]) -> Result<(TeamSeen, Vec<(String, HiveSeen)>)> { let store = crate::store::connect() .await .context("logging in to the swarm secret store")?; let team = self.observe_hives_team().await; let mut observed = Vec::with_capacity(hives.len()); for hive in hives { observed.push((hive.clone(), self.observe_hive(&store, team, hive).await)); } Ok((team, observed)) } } /// Check every hive now and every [`RECONCILE_INTERVAL`] after, and hand the /// hives that need a job to `enqueue`, which inserts a `MintHiveForgeToken` /// node for each. /// /// The roster is the controller's hive directory. A pass that fails is /// logged and retried on the next tick; it never stops the daemon. pub fn spawn( client: Arc, hives: Vec, enqueue: impl Fn(Vec) + Send + 'static, ) { tokio::spawn(async move { let mut ticker = tokio::time::interval(RECONCILE_INTERVAL); loop { ticker.tick().await; let Some(outcome) = super::within_forge_deadline("hive forge tokens", client.observe_hives(&hives)) .await else { continue; }; match outcome { Ok((team, observed)) => { let queued = plan(team, &observed); if queued.is_empty() { tracing::debug!(checked = observed.len(), "hive forge tokens: all current"); } else { tracing::info!( checked = observed.len(), queued = queued.len(), "hive forge tokens: queueing jobs" ); enqueue(queued); } } Err(e) => tracing::warn!( error = %format!("{e:#}"), retry_in_s = RECONCILE_INTERVAL.as_secs(), "hive forge tokens: pass failed; retrying next tick" ), } } }); } #[cfg(test)] mod tests { use super::super::agent_token::MintReason; use super::*; const VALUE: &str = "0123456789abcdef0123456789abcdef01234567"; fn stored() -> forge::Credential { forge::Credential { value: VALUE.to_owned(), name: HIVE_TOKEN_NAME.to_owned(), } } fn token(name: &str, last_eight: &str, scopes: &[&str]) -> AccessToken { AccessToken { created_at: None, id: Some(1), name: Some(name.to_owned()), repositories: None, scopes: Some(scopes.iter().map(|s| (*s).to_owned()).collect()), sha1: None, token_last_eight: Some(last_eight.to_owned()), } } fn decide(stored: Option<&forge::Credential>, listed: &[AccessToken]) -> Decision { classify_named(HIVE_TOKEN_NAME, HIVE_TOKEN_SCOPES, stored, listed) } fn seen(token: Observed, member: Option) -> HiveSeen { HiveSeen { token, member } } #[test] fn the_token_reads_repositories_and_nothing_else() { assert_eq!(HIVE_TOKEN_SCOPES, "read:repository"); } #[test] fn nothing_on_the_forge_or_in_the_store_creates() { assert_eq!(decide(None, &[]), Decision::Mint(MintReason::NotOnForge)); let ours = token(HIVE_TOKEN_NAME, "01234567", &["read:repository"]); assert_eq!(decide(None, &[ours]), Decision::Mint(MintReason::NotStored)); } #[test] fn a_current_token_is_kept() { let ours = token(HIVE_TOKEN_NAME, "01234567", &["read:repository"]); assert_eq!(decide(Some(&stored()), &[ours]), Decision::Keep); } #[test] fn another_value_or_wider_scopes_rotate() { let other = token(HIVE_TOKEN_NAME, "ffffffff", &["read:repository"]); assert_eq!( decide(Some(&stored()), &[other]), Decision::Mint(MintReason::LastEightMismatch) ); let wider = token(HIVE_TOKEN_NAME, "01234567", &["write:repository"]); assert_eq!( decide(Some(&stored()), &[wider]), Decision::Mint(MintReason::ScopeMismatch) ); } #[test] fn an_agent_token_on_the_account_is_not_the_hive_token() { // The two names must differ, or a hive account would be judged by the // agent token's scopes and the other way round. assert_ne!(HIVE_TOKEN_NAME, super::super::agent_token::AGENT_TOKEN_NAME); let agents = token( super::super::agent_token::AGENT_TOKEN_NAME, "01234567", &["read:repository"], ); assert_eq!( decide(Some(&stored()), &[agents]), Decision::Mint(MintReason::NotOnForge) ); } #[test] fn create_and_rotate_are_queued_and_keep_is_not() { let hives = [ ( "keep".to_owned(), seen(Observed::Decided(Decision::Keep), Some(true)), ), ( "rotate".to_owned(), seen( Observed::Decided(Decision::Mint(MintReason::LastEightMismatch)), Some(true), ), ), ( "create".to_owned(), seen(Observed::NoForgeUser, Some(false)), ), ( "conflict".to_owned(), seen(Observed::Decided(Decision::Conflict), Some(true)), ), ]; assert_eq!(plan(TeamSeen::Matches(7), &hives), ["rotate", "create"]); } #[test] fn an_unknown_hive_is_never_queued() { // Not even when the team is missing, which queues every hive whose // token is known. let hives = [ ("a".to_owned(), seen(Observed::Unknown, Some(false))), ("b".to_owned(), seen(Observed::Unknown, None)), ]; for team in [ TeamSeen::Matches(7), TeamSeen::Drifted(7), TeamSeen::Absent, TeamSeen::Unknown, ] { assert!(plan(team, &hives).is_empty(), "{team:?}"); } } #[test] fn a_kept_token_outside_the_team_is_queued() { let keep = |member| seen(Observed::Decided(Decision::Keep), member); let hives = [ ("in".to_owned(), keep(Some(true))), ("out".to_owned(), keep(Some(false))), ("unread".to_owned(), keep(None)), ]; assert_eq!(plan(TeamSeen::Matches(7), &hives), ["out"]); assert_eq!(plan(TeamSeen::Unknown, &hives), ["out"]); // A missing or drifted team queues every known hive: the job fixes the // team, and finds each token current. assert_eq!(plan(TeamSeen::Absent, &hives), ["in", "out", "unread"]); assert_eq!(plan(TeamSeen::Drifted(7), &hives), ["in", "out", "unread"]); } #[test] fn only_the_hives_own_email_is_its_account() { assert!(is_hive_account( "alpha", Some("hive-alpha@hives.hyperhive.local") )); assert!(!is_hive_account("alpha", None)); assert!(!is_hive_account( "beta", Some("hive-alpha@hives.hyperhive.local") )); } #[test] fn an_agent_called_hive_x_is_not_hive_xs_account() { // Both ways round: the hive job refuses the agent's account, and the // agent's own guard (`disable_repo_creation`) refuses the hive's, // because neither accepts the other's email. let login = forge::hive_login("alpha"); for agents in [ super::super::agent_email(&login), super::super::legacy_agent_email(&login), ] { assert!(!is_hive_account("alpha", Some(&agents)), "{agents}"); assert_ne!(hive_email("alpha"), agents); } } /// The job-side guard, against a stand-in forge: an existing account /// named `hive-alpha` that carries an agent's email is refused before the /// lockdown, so nothing after it (team, token) runs either. #[tokio::test] async fn the_job_refuses_an_agent_account_named_like_the_hive() { use super::super::tests::{lockdown_patch, stub_forge}; let (client, seen) = stub_forge( (409, r#"{"message":"user already exists"}"#), (200, r#"{"email":"hive-alpha@hyperhive.local"}"#), (200, "{}"), ) .await; let err = client .ensure_hive_user("alpha", "hive-alpha") .await .expect_err("an agent's account is not the hive's"); let msg = format!("{err:#}"); assert!(msg.contains("not hive `alpha`'s"), "{msg}"); assert!(lockdown_patch(&seen, "hive-alpha").is_none()); } /// The other way round: creating an agent called `hive-alpha` refuses /// hive `alpha`'s account rather than lock it down as the agent's. #[tokio::test] async fn an_agent_create_refuses_the_hives_account() { use super::super::tests::{lockdown_patch, stub_forge}; let (client, seen) = stub_forge( (409, r#"{"message":"user already exists"}"#), (200, r#"{"email":"hive-alpha@hives.hyperhive.local"}"#), (200, "{}"), ) .await; client .ensure_agent_user("hive-alpha") .await .expect_err("the hive's account is not the agent's"); assert!(lockdown_patch(&seen, "hive-alpha").is_none()); } #[tokio::test] async fn the_job_locks_down_the_hives_own_account() { use super::super::tests::{lockdown_patch, stub_forge}; let (client, seen) = stub_forge( (409, r#"{"message":"user already exists"}"#), (200, r#"{"email":"hive-alpha@hives.hyperhive.local"}"#), (200, "{}"), ) .await; client .ensure_hive_user("alpha", "hive-alpha") .await .expect("the hive's own account"); let body = lockdown_patch(&seen, "hive-alpha").expect("no lockdown PATCH sent"); assert_eq!(body["max_repo_creation"], 0); } fn team(permission: TeamPermission, units: &[&str]) -> Team { Team { can_create_org_repo: Some(false), description: Some(HIVES_TEAM_DESCRIPTION.to_owned()), id: Some(7), includes_all_repositories: Some(true), name: Some(HIVES_TEAM.to_owned()), organization: None, permission: Some(permission), units: Some(units.iter().map(|u| (*u).to_owned()).collect()), units_map: None, } } #[test] fn the_team_matches_only_as_a_read_team_on_every_repo() { assert!(hives_team_matches(&team( TeamPermission::Read, &["repo.pulls", "repo.code"] ))); assert!(!hives_team_matches(&team( TeamPermission::Write, &HIVES_TEAM_UNITS ))); assert!(!hives_team_matches(&team( TeamPermission::Read, &["repo.code"] ))); let mut some_repos = team(TeamPermission::Read, &HIVES_TEAM_UNITS); some_repos.includes_all_repositories = Some(false); assert!(!hives_team_matches(&some_repos)); } }