//! An agent's GitHub personal access token: an operator hands us the token, we //! put it in the swarm's secret store. //! //! The agent end is `nix/agent-modules/github-token.nix`, which reads it under //! the agent's own certificate into the `github-token` file its `gh` wrapper, //! git credential helper and `hive-github-notify` read. No hive is in the path. use axum::Json; use axum::extract::State; use axum::http::StatusCode; use serde::Deserialize; use swarm_secret_client::github; use utoipa::ToSchema; use super::linked_accounts::link; use super::{AppState, error_problem, swarm_hive}; /// The token to store for one agent. /// /// No `Debug` derive: this carries a token. #[derive(Deserialize, ToSchema)] pub struct PutGithubAccountRequest { /// The personal access token. Never logged, and never returned by this /// route. token: String, } /// Store an agent's GitHub token, unless it has one stored already. #[utoipa::path( put, path = "/api/hives/{hive}/agents/{agent}/github-account", params( ("hive" = String, Path, description = "hive the agent runs on"), ("agent" = String, Path, description = "agent the token belongs to"), ), request_body = PutGithubAccountRequest, responses( (status = 204, description = "stored"), (status = 400, description = "the agent is not an identifier, the token is empty, or the hive is not in this swarm (problem+json)", body = String), (status = 409, description = "the agent has a token stored already; nothing was written (problem+json)", body = String), (status = 500, description = "the store could not be read or written (problem+json)", body = String), ), tag = "agents" )] pub async fn put_github_account( State(state): State, axum::extract::Path((hive, agent)): axum::extract::Path<(String, String)>, Json(req): Json, ) -> Result { 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 secret_path = github::account_path(&agent) .map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?; let credential = credential(&req).map_err(|e| error_problem(StatusCode::BAD_REQUEST, e))?; 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()) })?; link(&store, &secret_path, &credential).await.map_err(|e| { // The path names the agent; the value is not in it. tracing::warn!(path = %secret_path, error = ?e, "linking the github token failed"); e.problem(&existing(&agent)) })?; tracing::info!(%hive, %agent, "github token stored"); Ok(StatusCode::NO_CONTENT) } /// The token a refused link names. fn existing(agent: &str) -> String { format!("agent {agent} already has a github token") } /// The request as it is stored, or why it cannot be. fn credential(req: &PutGithubAccountRequest) -> Result { let token = req.token.trim(); if token.is_empty() { return Err("token is required"); } Ok(github::Credential { value: token.to_owned(), }) } #[cfg(test)] mod tests { use super::{PutGithubAccountRequest, credential}; fn request(token: &str) -> PutGithubAccountRequest { PutGithubAccountRequest { token: token.to_owned(), } } #[test] fn the_token_is_kept_without_surrounding_whitespace() { let c = credential(&request(" t0k3n\n")).expect("valid"); assert_eq!(c.value, "t0k3n"); } #[test] fn an_empty_token_is_refused() { assert!(credential(&request(" ")).is_err()); assert!(credential(&request("")).is_err()); } /// Bare-minimum `AppState`, as `forge_account`'s tests build it. fn state() -> super::super::AppState { super::super::AppState { hives: std::sync::Arc::new(vec![super::super::HiveEntry { name: "pr1ma".to_owned(), domain: "pr1ma.example".to_owned(), }]), links: std::sync::Arc::new(Vec::new()), status: None, wanted: None, agent_status: None, agent_icons: None, jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new( hive_jobq::Graph::new(), hive_jobq::resources::ResourceTable::new(), ))), webhook_secret: None, config_prs: None, swarm_name: None, auth: None, forge: None, create_gate: std::sync::Arc::default(), } } async fn put(agent: &str, token: &str) -> problem_details::ProblemDetails { super::put_github_account( axum::extract::State(state()), axum::extract::Path(("pr1ma".to_owned(), agent.to_owned())), axum::Json(request(token)), ) .await .expect_err("no store is configured in a test") } fn assert_store_unset() { for var in ["BAO_ADDR", "BAO_CLIENT_CERT", "BAO_CLIENT_KEY"] { assert!( std::env::var(var).is_err(), "{var} must be unset for this test to prove anything" ); } } /// An agent name or an empty token is refused before the store: with /// `BAO_*` unset a store connect would answer 500. #[tokio::test] async fn a_bad_agent_or_an_empty_token_is_refused_before_the_store() { assert_store_unset(); for (agent, token) in [("Atlas", "t0k3n"), ("../x", "t0k3n"), ("atlas", " ")] { let problem = put(agent, token).await; assert_eq!( problem.status, Some(axum::http::StatusCode::BAD_REQUEST), "{agent:?}: {problem:?}" ); } } /// The control: a plain agent and a token reach the store connect. #[tokio::test] async fn a_plain_request_reaches_the_store() { assert_store_unset(); let problem = put("atlas", "t0k3n").await; assert_eq!( problem.status, Some(axum::http::StatusCode::INTERNAL_SERVER_ERROR), "{problem:?}" ); } #[tokio::test] async fn linking_a_second_token_is_a_409_and_the_first_stays() { use super::super::linked_accounts::{AccountStore, link, tests::FakeStore}; use swarm_secret_client::github; let store = FakeStore::default(); let path = github::account_path("atlas").expect("a valid path"); let first = credential(&request("t0k3n-first")).expect("valid"); let second = credential(&request("t0k3n-second")).expect("valid"); link(&store, &path, &first) .await .expect("nothing is stored"); let problem = link(&store, &path, &second) .await .expect_err("a token is stored") .problem(&super::existing("atlas")); assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT)); let detail = problem.detail.expect("a detail"); assert!( detail.contains("agent atlas already has a github token"), "{detail}" ); assert_eq!(store.written(), std::slice::from_ref(&path)); let kept: github::Credential = store .read_optional(&path) .await .expect("store answers") .expect("still stored"); assert_eq!(kept.value, "t0k3n-first"); } }