From d56d8f2b3665022884016225ff71e6a093642bfd Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 25 Sep 2026 02:01:57 +0200 Subject: [PATCH] swarmctl: forge make-admin Calls POST /api/forge/users/{name}/admin and prints what it found. It fails with the controller's message when the user has not logged in via SSO yet, and when the name is an agent's. Refs #3782 --- docs/tools/swarmctl-cli.md | 35 ++++++++++++++++++++ swarmctl/src/agent.rs | 4 +-- swarmctl/src/forge.rs | 66 ++++++++++++++++++++++++++++++++++++++ swarmctl/src/main.rs | 54 +++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 swarmctl/src/forge.rs diff --git a/docs/tools/swarmctl-cli.md b/docs/tools/swarmctl-cli.md index 1810d02d..031d536f 100644 --- a/docs/tools/swarmctl-cli.md +++ b/docs/tools/swarmctl-cli.md @@ -13,6 +13,8 @@ This document contains the help content for the `swarmctl` command-line program. * [`swarmctl user add`↴](#swarmctl-user-add) * [`swarmctl user update`↴](#swarmctl-user-update) * [`swarmctl user list`↴](#swarmctl-user-list) +* [`swarmctl forge`↴](#swarmctl-forge) +* [`swarmctl forge make-admin`↴](#swarmctl-forge-make-admin) * [`swarmctl completions`↴](#swarmctl-completions) ## `swarmctl` @@ -25,6 +27,7 @@ swarm-level operator CLI * `agent` — Manage agents across the swarm * `user` — Manage subjects in the swarm's SSO provider +* `forge` — Manage human accounts on the swarm's forge * `completions` — Generate a shell completion script for `swarmctl` and print it to stdout ###### **Options:** @@ -191,6 +194,38 @@ Read-only: it never writes the file. Shows every subject in it, including agent +## `swarmctl forge` + +Manage human accounts on the swarm's forge + +**Usage:** `swarmctl forge ` + +###### **Subcommands:** + +* `make-admin` — Make an existing forge user a site admin + + + +## `swarmctl forge make-admin` + +Make an existing forge user a site admin. + +The forge creates a human's account on their first SSO login, and this fails until that login has happened. Running it on a site admin changes nothing. Refused for an agent. + +**Usage:** `swarmctl forge make-admin [OPTIONS] ` + +###### **Arguments:** + +* `` — Forge username, the same as the user's SSO username + +###### **Options:** + +* `--controller-socket ` — swarm-controller's unix socket. + + Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`. + + + ## `swarmctl completions` Generate a shell completion script for `swarmctl` and print it to stdout. diff --git a/swarmctl/src/agent.rs b/swarmctl/src/agent.rs index e6b57bf5..d22a4aab 100644 --- a/swarmctl/src/agent.rs +++ b/swarmctl/src/agent.rs @@ -107,7 +107,7 @@ pub(crate) fn create(socket: &Path, name: &str, hive: &str) -> Result<()> { /// Parse a CLI-supplied name into a validated identifier, naming which /// argument was wrong — `invalid hive` and `invalid agent name` send the /// operator to different flags. -fn parse_ident(value: &str, what: &str) -> Result { +pub(crate) fn parse_ident(value: &str, what: &str) -> Result { hive_types::Ident::parse(value) .map(hive_types::Ident::into_string) .map_err(|reason| anyhow::anyhow!("invalid {what} {value:?}: {reason}")) @@ -186,7 +186,7 @@ async fn post_create(socket: &Path, name: &str, hive: &str) -> Result( +pub(crate) async fn post( socket: &Path, uri: &str, request: &Req, diff --git a/swarmctl/src/forge.rs b/swarmctl/src/forge.rs new file mode 100644 index 00000000..c7f3f1f6 --- /dev/null +++ b/swarmctl/src/forge.rs @@ -0,0 +1,66 @@ +//! `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 std::path::Path; + +use anyhow::{Context as _, Result}; +use serde::Deserialize; + +use crate::agent::{parse_ident, post}; + +/// 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(socket: &Path, name: &str) -> Result<()> { + let name = parse_ident(name, "user name")?; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_io() + .build() + .context("starting a tokio runtime for the controller request")?; + let resp: MakeForgeAdminResponse = rt.block_on(post( + socket, + &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); + } + } +} diff --git a/swarmctl/src/main.rs b/swarmctl/src/main.rs index 7832e335..d1c032eb 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -28,6 +28,7 @@ //! for the same reason `hivectl` does not link `hive-c0re`. mod agent; +mod forge; mod users; use std::fs::{self, File, Permissions}; @@ -123,6 +124,11 @@ enum Verb { #[command(subcommand)] command: UserVerb, }, + /// Manage human accounts on the swarm's forge. + Forge { + #[command(subcommand)] + command: ForgeVerb, + }, /// Emit the full CLI reference as `CommonMark` to stdout. /// /// Hidden tooling command used by the docs build to keep the published @@ -255,6 +261,29 @@ struct AgentMintIdentityArgs { controller_socket: Option, } +#[derive(Subcommand)] +enum ForgeVerb { + /// Make an existing forge user a site admin. + /// + /// The forge creates a human's account on their first SSO login, and + /// this fails until that login has happened. Running it on a site admin + /// changes nothing. Refused for an agent. + MakeAdmin(ForgeMakeAdminArgs), +} + +#[derive(Args)] +struct ForgeMakeAdminArgs { + /// Forge username, the same as the user's SSO username. + name: String, + /// swarm-controller's unix socket. + /// + /// Supplied by the nix module that installs this binary, from the same + /// `socketPath` option the daemon binds; falls back to + /// `SWARM_CONTROLLER_SOCKET`. + #[arg(long, value_name = "PATH")] + controller_socket: Option, +} + #[derive(Subcommand)] enum UserVerb { /// Add a user, generating a password for them. @@ -337,6 +366,13 @@ fn main() -> Result<()> { let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?; agent::mint_forge_token(&socket, &args.name) } + // Same socket-resolution reasoning as `Create` above. + Verb::Forge { + command: ForgeVerb::MakeAdmin(args), + } => { + let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?; + forge::make_admin(&socket, &args.name) + } // Resolved lazily, inside the one arm that actually touches the // deployment env vars — see the `MarkdownDocs` doc comment above // for why an unconditional resolve up front would be wrong. @@ -751,6 +787,24 @@ mod tests { ); } + #[test] + fn the_make_admin_verb_takes_a_user_name() { + let cli = Cli::try_parse_from(["swarmctl", "forge", "make-admin", "mara"]) + .expect("the minimal form parses"); + let Verb::Forge { + command: ForgeVerb::MakeAdmin(args), + } = cli.command + else { + panic!("expected `forge make-admin`"); + }; + assert_eq!(args.name, "mara"); + assert!(args.controller_socket.is_none()); + assert!( + Cli::try_parse_from(["swarmctl", "forge", "make-admin"]).is_err(), + "the user must be named" + ); + } + #[test] fn parses_authelia_hash_output() { let out = "Random Password: hunter2\nDigest: $argon2id$v=19$m=65536$abc\n";