Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarmctl/src/forge.rs
atlas 2df19e4899 swarmctl: log in with the operator's own SSO account and reach the controller through the swarm domain
`swarmctl --swarm https://<swarm> login` runs the OAuth 2.0
authorization-code flow with PKCE against the swarm's authelia as a new
public `swarmctl` client: a pushed authorization request, the browser login
and explicit consent, and the code delivered by form_post to a loopback
port. The access and refresh tokens are cached at mode 0600 under
$XDG_STATE_HOME/swarmctl/ and refreshed under a file lock, since authelia
rotates the refresh token and revokes the login when a used one is
presented again. With --swarm (or SWARMCTL_SWARM), the agent, schedule and
forge verbs send their requests to https://<swarm>/api/ with the bearer
token instead of over the controller's unix socket, which stays the default.

authelia registers the client on every authelia host with a swarm domain:
`kind = "native"`, public, no minted secret, scopes offline_access and
authelia.bearer.authz, audience https://<swarm>/api/ (matched as a URL
prefix), redirect http://127.0.0.1/callback (any loopback port). Every
field is one authelia 4.39.20's validator requires for a public client
holding the bearer scope.

The swarm apex's /api/ answers a refused caller that sent an
Authorization header with the 401 itself instead of the login redirect,
so the CLI can tell "log in again" apart from an answer; a browser still
gets the redirect. The bearer token is not forwarded to the controller.

Refs #4409 (mara: c94764, c94782).
2026-10-12 01:25:58 +02:00

65 lines
2.1 KiB
Rust

//! `swarmctl forge make-admin` — make an existing human forge account a site
//! admin, through the swarm-controller over the forge's admin API.
//!
//! Unlike the `agent` verbs this one waits: the controller answers with what
//! it found. Same round trip as [`crate::agent`], over the controller's unix
//! socket; the shape below mirrors the controller's own private
//! `MakeForgeAdminResponse` for the reason that module's doc comment gives.
use anyhow::{Context as _, Result};
use serde::Deserialize;
use crate::agent::{parse_ident, post};
use crate::remote::Target;
/// Success body of `POST /api/forge/users/{name}/admin`.
#[derive(Deserialize)]
struct MakeForgeAdminResponse {
change: AdminChange,
}
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
enum AdminChange {
Promoted,
AlreadyAdmin,
}
/// Run `swarmctl forge make-admin`.
///
/// Synchronous for the same reason `agent create` is.
pub(crate) fn make_admin(target: &Target, name: &str) -> Result<()> {
let name = parse_ident(name, "user name")?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("starting a tokio runtime for the controller request")?;
let resp: MakeForgeAdminResponse = rt.block_on(post(
target,
&format!("/api/forge/users/{name}/admin"),
&serde_json::json!({}),
"forge-make-admin",
))?;
match resp.change {
AdminChange::Promoted => println!("forge: {name:?} is now a site admin"),
AdminChange::AlreadyAdmin => println!("forge: {name:?} was already a site admin"),
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{AdminChange, MakeForgeAdminResponse};
/// The controller's two answers, as it spells them.
#[test]
fn both_changes_decode() {
for (wire, want) in [("promoted", true), ("already_admin", false)] {
let resp: MakeForgeAdminResponse =
serde_json::from_str(&format!(r#"{{"change":"{wire}"}}"#)).expect("decodes");
assert_eq!(matches!(resp.change, AdminChange::Promoted), want);
}
}
}