The seven linked-account handlers (matrix, forge and github link and delete, and the list) read their store from `AppState::accounts` instead of calling `store::connect()` themselves. In production that is `Accounts::Live`, which still logs in to the swarm's secret store on every request: a `SecretStore` holds a token it never renews, and a store that is down at startup must fail only the requests made while it is down. Tests set `Accounts::Fake` over the in-memory `FakeStore`. The eight handler tests that proved validation passed by expecting the store connect to fail with 500 now assert that the write or delete landed in the fake, and lose their `BAO_*`-must-be-unset guards: with `BAO_*` set they no longer reach a real store.
219 lines
8 KiB
Rust
219 lines
8 KiB
Rust
//! 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<AppState>,
|
|
axum::extract::Path((hive, agent)): axum::extract::Path<(String, String)>,
|
|
Json(req): Json<PutGithubAccountRequest>,
|
|
) -> Result<StatusCode, 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 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 = 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, &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<github::Credential, &'static str> {
|
|
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 std::sync::Arc;
|
|
|
|
use super::super::linked_accounts::{Accounts, tests::FakeStore};
|
|
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(store: &Arc<FakeStore>) -> 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(),
|
|
accounts: Accounts::Fake(Arc::clone(store)),
|
|
}
|
|
}
|
|
|
|
async fn put(
|
|
store: &Arc<FakeStore>,
|
|
agent: &str,
|
|
token: &str,
|
|
) -> Result<axum::http::StatusCode, problem_details::ProblemDetails> {
|
|
super::put_github_account(
|
|
axum::extract::State(state(store)),
|
|
axum::extract::Path(("pr1ma".to_owned(), agent.to_owned())),
|
|
axum::Json(request(token)),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// An agent name or an empty token is refused before the store.
|
|
#[tokio::test]
|
|
async fn a_bad_agent_or_an_empty_token_is_refused_before_the_store() {
|
|
let store = Arc::default();
|
|
for (agent, token) in [("Atlas", "t0k3n"), ("../x", "t0k3n"), ("atlas", " ")] {
|
|
let problem = put(&store, agent, token).await.expect_err("refused");
|
|
assert_eq!(
|
|
problem.status,
|
|
Some(axum::http::StatusCode::BAD_REQUEST),
|
|
"{agent:?}: {problem:?}"
|
|
);
|
|
}
|
|
assert!(store.written().is_empty());
|
|
}
|
|
|
|
/// The control: a plain agent and a token are written.
|
|
#[tokio::test]
|
|
async fn a_plain_request_reaches_the_store() {
|
|
use super::super::linked_accounts::AccountStore;
|
|
use swarm_secret_client::github;
|
|
|
|
let store = Arc::default();
|
|
let status = put(&store, "atlas", "t0k3n")
|
|
.await
|
|
.expect("nothing is stored");
|
|
assert_eq!(status, axum::http::StatusCode::NO_CONTENT);
|
|
assert_eq!(store.written(), ["swarm/agents/atlas/github-token"]);
|
|
let stored: github::Credential = store
|
|
.read_optional("swarm/agents/atlas/github-token")
|
|
.await
|
|
.expect("store answers")
|
|
.expect("stored");
|
|
assert_eq!(stored.value, "t0k3n");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn linking_a_second_token_is_a_409_and_the_first_stays() {
|
|
use super::super::linked_accounts::{AccountStore, link};
|
|
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");
|
|
}
|
|
}
|