//! The accounts linked to one agent, as kind, name and host: what the swarm //! UI's agent panel lists. //! //! Read from the paths [`crate::matrix_account`], [`crate::forge_account`] and //! [`crate::github_account`] write, plus the agent's own `main` matrix account, //! which `matrix_account::agent_token` mints into the same directory. Each //! entry is read for its host and nothing else leaves this module: //! [`LinkedAccount`] has no field a credential could land in. //! //! Naming the matrix accounts and forge labels takes `list` on each agent's //! `matrix` and `forge` metadata directories, which `controllerPolicyText` in //! `nix/host-modules/swarm-bao.nix` grants on those two directories alone. use std::future::Future; use axum::Json; use axum::extract::State; use axum::http::StatusCode; use serde::Serialize; use serde::de::DeserializeOwned; use swarm_secret_client::{Error, SecretStore, forge, github, matrix}; use utoipa::ToSchema; use super::{AppState, error_problem, swarm_hive}; /// The host shown for a GitHub token. The store keeps none: a token is only /// ever used against github.com. const GITHUB_HOST: &str = "github.com"; /// Which kind of account a [`LinkedAccount`] is. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum AccountKind { Matrix, Forgejo, Github, } /// One account linked to an agent. Never carries the credential. #[derive(Clone, Debug, PartialEq, Eq, Serialize, ToSchema)] pub struct LinkedAccount { kind: AccountKind, /// The matrix account name, the forge label, or `github`. #[schema(example = "main")] name: String, /// The matrix homeserver, the forge base URL, or `github.com`. `None` for a /// matrix account stored without a homeserver. #[schema(example = "https://matrix.example.org")] host: Option, /// The agent's own matrix account, which the swarm mints and an operator /// does not link. reserved: bool, } /// The store reads a listing makes, so a test can stand in for the store. pub(crate) trait AccountStore { /// As [`SecretStore::list`]. fn list(&self, dir: &str) -> impl Future, Error>> + Send; /// As [`SecretStore::read_optional`]. fn read_optional( &self, path: &str, ) -> impl Future, Error>> + Send; } impl AccountStore for SecretStore { async fn list(&self, dir: &str) -> Result, Error> { SecretStore::list(self, dir).await } async fn read_optional( &self, path: &str, ) -> Result, Error> { SecretStore::read_optional(self, path).await } } /// The object names directly under `dir`. A key ending in `/` is a directory /// below it, not an account. async fn objects(store: &impl AccountStore, dir: &str) -> Result, Error> { let keys = store.list(dir).await?; Ok(keys.into_iter().filter(|k| !k.ends_with('/')).collect()) } /// Every account the store holds for `agent`: matrix, then forgejo, then /// github. /// /// A missing directory lists nothing, and a listed name whose object is gone /// gets no entry. /// /// # Errors /// Anything [`SecretStore::list`] or [`SecretStore::read_optional`] does not /// treat as absence, such as a denial or an unreachable store, and an `agent` /// or listed name that is not a single path segment. pub(crate) async fn linked_accounts( store: &impl AccountStore, agent: &str, ) -> Result, Error> { let mut out = Vec::new(); for name in objects(store, &matrix::accounts_dir(agent)?).await? { let path = matrix::account_path(agent, &name)?; if let Some(c) = store.read_optional::(&path).await? { out.push(LinkedAccount { kind: AccountKind::Matrix, reserved: crate::matrix_account::is_reserved_account(&name), host: c.homeserver, name, }); } } for label in objects(store, &forge::accounts_dir(agent)?).await? { let path = forge::account_path(agent, &label)?; if let Some(a) = store.read_optional::(&path).await? { out.push(LinkedAccount { kind: AccountKind::Forgejo, name: label, host: Some(a.url), reserved: false, }); } } let path = github::account_path(agent)?; if store .read_optional::(&path) .await? .is_some() { out.push(LinkedAccount { kind: AccountKind::Github, name: "github".to_owned(), host: Some(GITHUB_HOST.to_owned()), reserved: false, }); } Ok(out) } /// List the accounts linked to an agent: names and hosts, never a credential. #[utoipa::path( get, path = "/api/hives/{hive}/agents/{agent}/linked-accounts", params( ("hive" = String, Path, description = "hive the agent runs on"), ("agent" = String, Path, description = "agent whose accounts to list"), ), responses( (status = 200, description = "the agent's linked accounts, empty when it has none", body = Vec), (status = 400, description = "the agent is not an identifier, or the hive is not in this swarm (problem+json)", body = String), (status = 500, description = "the store could not be read (problem+json)", body = String), ), tag = "agents" )] pub async fn get_linked_accounts( State(state): State, axum::extract::Path((hive, agent)): axum::extract::Path<(String, String)>, ) -> Result>, problem_details::ProblemDetails> { let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?; let agent = hive_types::Ident::parse(&agent) .map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))? .into_string(); let store = crate::store::connect().await.map_err(|e| { tracing::warn!(error = %e, "connecting to the swarm secret store failed"); error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()) })?; let accounts = linked_accounts(&store, &agent).await.map_err(|e| { tracing::warn!(%hive, %agent, error = %e, "listing linked accounts failed"); error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()) })?; Ok(Json(accounts)) } #[cfg(test)] mod tests { use std::collections::BTreeMap; use serde::de::DeserializeOwned; use serde_json::{Value, json}; use swarm_secret_client::Error; use super::{AccountKind, AccountStore, LinkedAccount, linked_accounts}; /// Objects by path. A list answers the next segment of every path under /// the directory, with a trailing `/` when it goes deeper, as the store /// does. #[derive(Default)] struct FakeStore { objects: BTreeMap, denied: bool, } impl FakeStore { fn with(mut self, path: &str, object: Value) -> Self { self.objects.insert(path.to_owned(), object); self } } impl AccountStore for FakeStore { async fn list(&self, dir: &str) -> Result, Error> { if self.denied { return Err(Error::MissingEnv("BAO_ADDR")); } let prefix = format!("{dir}/"); let mut keys: Vec = self .objects .keys() .filter_map(|p| p.strip_prefix(&prefix)) .map(|rest| match rest.split_once('/') { Some((head, _)) => format!("{head}/"), None => rest.to_owned(), }) .collect(); keys.dedup(); Ok(keys) } async fn read_optional( &self, path: &str, ) -> Result, Error> { if self.denied { return Err(Error::MissingEnv("BAO_ADDR")); } Ok(self .objects .get(path) .map(|v| serde_json::from_value(v.clone()).expect("fixture decodes"))) } } fn row(kind: AccountKind, name: &str, host: Option<&str>, reserved: bool) -> LinkedAccount { LinkedAccount { kind, name: name.to_owned(), host: host.map(str::to_owned), reserved, } } fn every_kind() -> FakeStore { FakeStore::default() .with( "swarm/agents/atlas/matrix/main", json!({"value": "t0k3n-main", "homeserver": "https://matrix.swarm"}), ) .with( "swarm/agents/atlas/matrix/catgirl", json!({"value": "t0k3n-cat", "homeserver": "https://matrix.example.org"}), ) .with( "swarm/agents/atlas/matrix/old", json!({"value": "t0k3n-old"}), ) .with( "swarm/agents/atlas/forge/codeberg", json!({"value": "t0k3n-forge", "url": "https://codeberg.org"}), ) .with( "swarm/agents/atlas/github-token", json!({"value": "t0k3n-gh"}), ) // Another agent's accounts, and the agent's own forge token beside // its `forge/` directory: neither is a linked account of atlas. .with( "swarm/agents/red/matrix/catgirl", json!({"value": "t0k3n-red", "homeserver": "https://matrix.example.org"}), ) .with( "swarm/agents/atlas/forge-token", json!({"value": "t0k3n-own", "name": "atlas"}), ) } #[tokio::test] async fn every_kind_is_listed_with_its_name_and_host() { let got = linked_accounts(&every_kind(), "atlas") .await .expect("store answers"); assert_eq!( got, vec![ row( AccountKind::Matrix, "catgirl", Some("https://matrix.example.org"), false ), row( AccountKind::Matrix, "main", Some("https://matrix.swarm"), true ), row(AccountKind::Matrix, "old", None, false), row( AccountKind::Forgejo, "codeberg", Some("https://codeberg.org"), false ), row(AccountKind::Github, "github", Some("github.com"), false), ] ); } #[tokio::test] async fn the_response_carries_no_credential() { let got = linked_accounts(&every_kind(), "atlas") .await .expect("store answers"); let body = serde_json::to_value(&got).expect("serialises"); for entry in body.as_array().expect("an array") { let mut keys: Vec<&str> = entry .as_object() .expect("an object") .keys() .map(String::as_str) .collect(); keys.sort_unstable(); assert_eq!(keys, ["host", "kind", "name", "reserved"], "{entry}"); } let text = body.to_string(); assert!(!text.contains("value"), "{text}"); assert!(!text.contains("t0k3n"), "{text}"); // The control: the same serialisation does carry the names, so the // absence above is not an empty body. assert!(text.contains("catgirl"), "{text}"); } #[tokio::test] async fn an_agent_with_nothing_stored_lists_nothing() { let got = linked_accounts(&FakeStore::default(), "atlas") .await .expect("absence is not an error"); assert!(got.is_empty(), "{got:?}"); } #[tokio::test] async fn a_store_that_refuses_is_an_error_not_an_empty_list() { let store = FakeStore { denied: true, ..every_kind() }; assert!(linked_accounts(&store, "atlas").await.is_err()); } }