Watch
0
0
Fork
You've already forked hyperhive
0

add external gitlab accounts: stored in bao, linked per agent, allowed in squid, usable with glab

This commit is contained in:
atlas 2026-11-02 16:11:25 +01:00
commit d6e983995d

View file

@ -0,0 +1,322 @@
//! 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<AppState>,
axum::extract::Path((hive, agent, label)): axum::extract::Path<(String, String, String)>,
Json(req): Json<PutGitlabAccountRequest>,
) -> Result<Json<PutGitlabAccountResponse>, 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<gitlab::Account, String> {
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<FakeStore>) -> 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: None,
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<FakeStore>,
agent: &str,
label: &str,
url: &str,
) -> Result<axum::Json<super::PutGitlabAccountResponse>, 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");
}
}