github: PATs live in swarm bao; the agent fetches them itself
An operator links an agent's GitHub personal access token in the swarm UI
(LinkGithubAccountForm, "link github account" on /agents). swarm-controller's
PUT /api/hives/{hive}/agents/{agent}/github-account stores it at
swarm/agents/<agent>/github-token (swarm_secret_client::github), a flat leaf
under the agent's prefix that the agent's existing read grant already covers:
no policy change, and no list grant, since there is one token per agent.
In the agent, hive-agent-github-token (oneshot + 2-minute timer, as the agent
user, under its own store certificate, ordered before hive-github-notify)
reads that path and writes <state>/github-token, 0600 and agent-owned, the
file the gh wrapper, git credential helper and hive-github-notify already
read. It replaces the file by rename only when the bytes changed and never
deletes it: a hive-written github-token stays until a token is linked in the
swarm UI. It is installed only with a store address and
services.hyperhive.agent.github.enable.
Removed: the dashboard's CR3D3NTIALS page (credentials.html/js/css, its
build entries and H0M3 tile; GITHUB was its only tab), hive-c0re's
dashboard/matrix_accounts.rs with GET/POST /api/github-account,
priv_client::write_agent_github_token, the host socket's
SetAgentGithubToken and `hivectl github set-token`, and hive-priv's
WriteAgentGithubToken with write_agent_state_file, its only caller gone.
Docs: integrations/github.md and swarm/ui.md describe the swarm path,
swarm/credentials.md gains the store-path row, and the hive UI docs,
hivectl docs and security.md's hive-priv table drop the removed pieces.
Closes #4347
This commit is contained in:
parent
2b2608a491
commit
8e23feb01b
41 changed files with 804 additions and 846 deletions
|
|
@ -28,10 +28,10 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients.
|
|||
- **Agent credentials** — at start and every five minutes it re-checks every
|
||||
agent's forge token and matrix account, and renews store certificates and
|
||||
queue secrets as they age.
|
||||
- **Linked external accounts** — an operator-supplied matrix or forge
|
||||
account for one agent, stored in the swarm secret store
|
||||
- **Linked external accounts** — an operator-supplied matrix, forge or
|
||||
GitHub account for one agent, stored in the swarm secret store
|
||||
(`PUT /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}`,
|
||||
`.../forge-accounts/{label}`); distinct from the agent's own swarm-minted
|
||||
`.../forge-accounts/{label}`, `.../github-account`); distinct from the agent's own swarm-minted
|
||||
accounts above.
|
||||
- **Config PR status** — each agent's open config-repo PR, cached from forge
|
||||
webhooks (`GET /api/config-prs`, `/api/agents/{name}/config-pr`).
|
||||
|
|
|
|||
176
swarm-controller/src/github_account.rs
Normal file
176
swarm-controller/src/github_account.rs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
//! 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::{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.
|
||||
///
|
||||
/// Idempotent: the store keeps versions, so repeating a call replaces the
|
||||
/// token the agent will next read.
|
||||
#[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 = 500, description = "the store write failed (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 = 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())
|
||||
})?;
|
||||
store.write(&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, "writing the github token failed");
|
||||
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||
})?;
|
||||
|
||||
tracing::info!(%hive, %agent, "github token stored");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// 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 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,7 @@ mod auth;
|
|||
mod config_pr;
|
||||
mod forge;
|
||||
mod forge_account;
|
||||
mod github_account;
|
||||
mod issue_report;
|
||||
mod matrix_account;
|
||||
mod otel_http_client;
|
||||
|
|
@ -2890,6 +2891,7 @@ fn build_app(state: AppState) -> axum::Router {
|
|||
.routes(routes!(set_agent_state))
|
||||
.routes(routes!(matrix_account::put_matrix_account))
|
||||
.routes(routes!(forge_account::put_forge_account))
|
||||
.routes(routes!(github_account::put_github_account))
|
||||
.routes(routes!(get_hive_wanted))
|
||||
.routes(routes!(term_stream::stream_agent_term))
|
||||
.routes(routes!(agent_state_stream::stream_agent_state))
|
||||
|
|
|
|||
Loading…
Reference in a new issue