//! An agent's accounts on GitLab instances: an operator hands us a base URL and //! a token, we put them in the swarm's secret store. //! //! The agent end is `nix/agent-modules/gitlab-accounts.nix`, which lists the //! agent's accounts and reads each under the agent's own certificate, and writes //! the `glab` configuration. No hive is in the path. use axum::Json; use axum::extract::State; use axum::http::StatusCode; use serde::{Deserialize, Serialize}; use swarm_secret_client::gitlab; use utoipa::ToSchema; use super::linked_accounts::link; use crate::backend_egress::host_of; use crate::routes::agents::swarm_hive; use crate::routes::{AppState, error_problem}; /// The account to store for one agent's GitLab instance. /// /// No `Debug` derive: this carries a token. #[derive(Deserialize, ToSchema)] pub struct PutGitlabAccountRequest { /// The instance's base URL, `http://` or `https://`, with a DNS name as its /// host. A trailing slash is dropped. #[schema(example = "https://gitlab.example.org")] url: String, /// The access token, scoped `api` and `write_repository`. Never logged, and /// never returned by this route. token: String, } /// `put_gitlab_account`'s success body. #[derive(Debug, Serialize, ToSchema)] pub struct PutGitlabAccountResponse { /// The base URL as stored. url: String, } /// Store an agent's GitLab account, unless one is stored under the label /// already. #[utoipa::path( put, path = "/api/hives/{hive}/agents/{agent}/gitlab-accounts/{label}", params( ("hive" = String, Path, description = "hive the agent runs on"), ("agent" = String, Path, description = "agent the account belongs to"), ("label" = String, Path, description = "the account's name"), ), request_body = PutGitlabAccountRequest, responses( (status = 200, description = "stored", body = PutGitlabAccountResponse), (status = 400, description = "the agent or label is not an identifier, the URL is not http(s) with a DNS name as its host, the token is empty, or the hive is not in this swarm (problem+json)", body = String), (status = 409, description = "an account is stored under the label 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_gitlab_account( State(state): State, axum::extract::Path((hive, agent, label)): axum::extract::Path<(String, String, String)>, Json(req): Json, ) -> 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 label = hive_types::Ident::parse(&label) .map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))? .into_string(); let secret_path = gitlab::account_path(&agent, &label) .map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?; let account = account(req).map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e))?; let store = state.accounts.open().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, &account).await.map_err(|e| { // The path names the agent and the label; the value is not in it. tracing::warn!(path = %secret_path, error = ?e, "linking the gitlab account failed"); e.problem(&existing(&agent, &label)) })?; crate::link_egress::added( &state, &agent, &crate::link_egress::Link::Gitlab { label: &label, url: &account.url, }, ) .await?; tracing::info!(%hive, %agent, %label, url = %account.url, "gitlab account stored"); Ok(Json(PutGitlabAccountResponse { url: account.url })) } /// The account a refused link names. fn existing(agent: &str, label: &str) -> String { format!("agent {agent} already has gitlab account {label:?}") } /// The request as it is stored, or why it cannot be. /// /// The URL must match [`gitlab::URL_PATTERN`], the rule the agent end filters by, /// so a stored account is never one the agent drops. The host must also be a /// name the egress allow list takes: the agent reaches the instance through its /// hive's proxy, so an address or `localhost` would be stored and never reachable. fn account(req: PutGitlabAccountRequest) -> Result { let url = req.url.trim().trim_end_matches('/'); if !(url.starts_with("http://") || url.starts_with("https://")) { return Err("url must start with http:// or https://".to_owned()); } let authority = url .split_once("://") .and_then(|(_, rest)| rest.split(['/', '?', '#']).next()) .unwrap_or_default(); if authority.contains('@') { return Err("url must not carry user information; the token is its own field".to_owned()); } if !gitlab::is_instance_url(url) { return Err(format!( "url must match {}: http(s), a host name, an optional port, and an optional path of \ letters, digits and . _ ~ / - (no query, fragment, '@' or spaces)", gitlab::URL_PATTERN )); } let host = host_of(url).ok_or("url has no host")?; swarm_wire::egress_allow::normalize_domain(host).map_err(|why| format!("url host: {why}"))?; if req.token.trim().is_empty() { return Err("token is required".to_owned()); } Ok(gitlab::Account { value: req.token, url: url.to_owned(), }) } #[cfg(test)] mod tests { use std::sync::Arc; use super::super::linked_accounts::{Accounts, tests::FakeStore}; use super::{PutGitlabAccountRequest, account}; fn request(url: &str, token: &str) -> PutGitlabAccountRequest { PutGitlabAccountRequest { url: url.to_owned(), token: token.to_owned(), } } #[test] fn the_url_loses_its_trailing_slash_and_the_token_is_kept() { let a = account(request("https://gitlab.example.org/ ", "t0k3n")).expect("valid"); assert_eq!(a.url, "https://gitlab.example.org"); assert_eq!(a.value, "t0k3n"); // A port and a subfolder are part of the instance's address. let a = account(request("https://gitlab.example.org:8443/gl/", "t")).expect("valid"); assert_eq!(a.url, "https://gitlab.example.org:8443/gl"); } #[test] fn a_url_that_is_not_http_is_refused() { assert!(account(request("gitlab.example.org", "t")).is_err()); assert!(account(request("file:///etc/passwd", "t")).is_err()); // The control: plain http with a name is accepted. assert!(account(request("http://gitlab.lan", "t")).is_ok()); } #[test] fn a_host_the_egress_list_would_refuse_is_refused() { for url in [ "http://10.0.0.5", "https://10.0.0.5:8443", "http://[::1]", "https://localhost", "https://gitlab", "https://*.example.org", "https://", ] { assert!(account(request(url, "t")).is_err(), "{url}"); } } #[test] fn user_information_in_the_url_is_refused() { assert!(account(request("https://oauth2:t0k3n@gitlab.example.org", "t")).is_err()); // A path character the agent end drops is refused here too. for url in [ "https://gitlab.example.org/@x", "https://gitlab.example.org/?x=1", "https://gitlab.example.org/#f", "https://gitlab.example.org/a b", ] { assert!(account(request(url, "t")).is_err(), "{url}"); } } #[test] fn an_empty_token_is_refused() { assert!(account(request("https://gitlab.example.org", " ")).is_err()); } /// Bare-minimum `AppState`, as `forge_account`'s tests build it. fn state(store: &Arc) -> crate::routes::AppState { crate::routes::AppState { hives: std::sync::Arc::new(vec![crate::routes::hives::HiveEntry { name: "pr1ma".to_owned(), domain: "pr1ma.example".to_owned(), dashboard: "pr1ma.hives.example".to_owned(), }]), links: std::sync::Arc::new(Vec::new()), status: None, wanted: None, agent_status: None, agent_icons: None, agents_md: None, reported_inference: None, agent_login: 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: std::sync::Arc::default(), config_prs: std::sync::Arc::default(), swarm_name: None, auth: None, forge: std::sync::Arc::default(), create_gate: std::sync::Arc::default(), accounts: Accounts::Fake(Arc::clone(store)), try_outs: super::super::review_classifier::TryOuts::default(), reviews: std::sync::Arc::default(), runs: crate::review_classifier::runs::Runs::Live(None), builds: crate::builder::runs::Builds::Live(None), alerts: None, } } async fn put( store: &Arc, agent: &str, label: &str, url: &str, ) -> Result, problem_details::ProblemDetails> { super::put_gitlab_account( axum::extract::State(state(store)), axum::extract::Path(("pr1ma".to_owned(), agent.to_owned(), label.to_owned())), axum::Json(request(url, "t0k3n")), ) .await } #[tokio::test] async fn a_plain_account_reaches_the_store_under_the_agents_gitlab_directory() { let store = Arc::default(); let stored = put(&store, "atlas", "work", "https://gitlab.example.org") .await .expect("nothing is stored"); assert_eq!(stored.url, "https://gitlab.example.org"); assert_eq!(store.written(), ["swarm/agents/atlas/gitlab/work"]); } /// Refused before the store, whatever the part: an address, a label or an /// agent that is not an identifier. #[tokio::test] async fn a_request_the_swarm_would_refuse_is_a_400_and_writes_nothing() { let store = Arc::default(); for (agent, label, url) in [ ("atlas", "work", "http://10.0.0.5:8080"), ("atlas", "work", "https://localhost"), ("atlas", "work", "https://gitlab.example.org/@x"), ("atlas", "work", "https://gitlab.example.org/?x=1"), ("atlas", "My_Label", "https://gitlab.example.org"), ("atlas", "../x", "https://gitlab.example.org"), ("../x", "work", "https://gitlab.example.org"), ] { let problem = put(&store, agent, label, url).await.expect_err("refused"); assert_eq!( problem.status, Some(axum::http::StatusCode::BAD_REQUEST), "{agent} {label} {url}: {problem:?}" ); } assert!(store.written().is_empty()); } #[tokio::test] async fn linking_a_label_twice_is_a_409_and_the_first_account_stays() { use super::super::linked_accounts::{AccountStore, link}; use swarm_secret_client::gitlab; let store = FakeStore::default(); let path = gitlab::account_path("atlas", "work").expect("a valid path"); let first = account(request("https://gitlab.example.org", "t0k3n-first")).expect("valid"); let second = account(request("https://gitlab.lan", "t0k3n-second")).expect("valid"); link(&store, &path, &first) .await .expect("nothing is stored"); let problem = link(&store, &path, &second) .await .expect_err("an account is stored") .problem(&super::existing("atlas", "work")); assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT)); let detail = problem.detail.expect("a detail"); assert!( detail.contains(r#"agent atlas already has gitlab account "work""#), "{detail}" ); assert_eq!(store.written(), std::slice::from_ref(&path)); let kept: gitlab::Account = store .read_optional(&path) .await .expect("store answers") .expect("still stored"); assert_eq!(kept.url, "https://gitlab.example.org"); assert_eq!(kept.value, "t0k3n-first"); } }