From 113f3fe6e2a26945307bbb73b1760dd8929fb7f0 Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 24 Sep 2026 23:53:15 +0200 Subject: [PATCH 1/5] hive-forge: a first authelia login creates the forge account [oauth2_client] turns on auto-registration through the authelia login source, with the account named after authelia's preferred_username. DISABLE_REGISTRATION stays true: forgejo 16's auto-registration checks only ALLOW_ONLY_INTERNAL_REGISTRATION, so local sign-up stays off. ACCOUNT_LINKING is `login`, forgejo's default, set explicitly. With `auto`, an SSO login whose name matches an existing local account would be handed that account, and agents, `core` and `swarm-controller` all have one. `login` asks for that account's own password instead. Refs #3782 --- nix/host-modules/hive-forge/default.nix | 26 ++++++++++++++++++++++--- nix/module-eval/forge-placement.nix | 26 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 76bc63e4..732e730c 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -775,13 +775,33 @@ in # the custom theme CSS baked straight into the nix store. STATIC_ROOT_PATH = staticRootWithTheme; }; - # Registration off — operator seeds agent users via - # `nixos-container run hive-forge -- forgejo admin - # user create …`. + # Local sign-up off. An agent's account is made by + # swarm-controller over the admin API, a human's by their + # first authelia login (`oauth2_client` below), which this + # does not block: forgejo's auto-registration checks only + # `ALLOW_ONLY_INTERNAL_REGISTRATION` + # (`routers/web/auth/oauth.go`, 16.0.5). service = { DISABLE_REGISTRATION = true; REQUIRE_SIGNIN_VIEW = false; }; + # A first login through the authelia source creates the + # account, named after authelia's `preferred_username` — the + # authelia username, not the opaque `sub`. + # + # ⚠️ `ACCOUNT_LINKING = login`, never `auto`. A login whose + # name is already taken does not create an account; `auto` + # would then hand the SSO user the existing one. Agents, + # `core` and `swarm-controller` all have local accounts here, + # the last two site admins, and `swarmctl user add` refuses + # none of those names. `login` asks for the existing + # account's own password instead, which an agent's or a + # service account's never leaves the forge. + oauth2_client = { + ENABLE_AUTO_REGISTRATION = true; + USERNAME = "preferred_username"; + ACCOUNT_LINKING = "login"; + }; repository = { DEFAULT_BRANCH = "main"; DEFAULT_PRIVATE = "private"; diff --git a/nix/module-eval/forge-placement.nix b/nix/module-eval/forge-placement.nix index 7776bd2a..02c75288 100644 --- a/nix/module-eval/forge-placement.nix +++ b/nix/module-eval/forge-placement.nix @@ -70,6 +70,8 @@ let tokenFile = m: m.services.hyperhive.deploy.swarm-controller.forgeTokenFile; forgePath = "/var/lib/hyperhive-forge/swarm-controller.token"; + forgeSettings = forgeHere.containers.hive-forge.config.services.forgejo.settings; + cases = [ { # The absence the whole option exists for: a second forge in a swarm @@ -139,6 +141,30 @@ let && tokenFile forgeHere == forgePath && tokenFile allLocal == forgePath; } + { + # The only thing that makes a human's forge account: nothing else + # creates one. + name = "a first authelia login creates the forge account, named by preferred_username"; + ok = + let + o = forgeSettings.oauth2_client; + in + o.ENABLE_AUTO_REGISTRATION == true && o.USERNAME == "preferred_username"; + } + { + # `auto` would give an SSO user whatever local account carries their + # name — an agent's, or `core`'s. + name = "an SSO login adopts an existing forge account only with that account's password"; + ok = forgeSettings.oauth2_client.ACCOUNT_LINKING == "login"; + } + { + # Both halves: local sign-up stays off, and nothing turns on the one + # setting forgejo's auto-registration does check. + name = "local sign-up stays off without blocking the SSO registration"; + ok = + forgeSettings.service.DISABLE_REGISTRATION == true + && !(forgeSettings.service.ALLOW_ONLY_INTERNAL_REGISTRATION or false); + } ]; in runGroup "forge-placement" cases From ef494af188f043f32c93a2c5430359aee0edaaa4 Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 24 Sep 2026 23:59:13 +0200 Subject: [PATCH 2/5] hivectl: drop forge create-user; SSO makes a human's forge account The forge now creates a human's account on their first authelia login, so the verb has no job left. Deletes it, HostRequest::ForgeCreateUser, its handler, provision_user_token, change_user_password and the hive's TOKEN_SCOPES. change_user_password also passed the password as an argument to `forgejo admin user change-password`, so it showed in the container's process list. ensure_user_exists and mint_token stay for the `core` bootstrap, their one caller now. ensure_user_exists loses its password parameter: only the deleted path set one. Refs #3782 --- docs/tools/hivectl-cli.md | 29 +------ hive-c0re/src/forge/mod.rs | 2 +- hive-c0re/src/forge/users.rs | 100 +++++----------------- hive-c0re/src/matrix.rs | 6 +- hive-c0re/src/server.rs | 40 --------- hive-host-sock/src/lib.rs | 11 --- hivectl/src/cli.rs | 33 +------ hivectl/src/forge.rs | 29 +------ hivectl/src/main.rs | 7 +- swarm-controller/src/forge/agent_token.rs | 10 +-- 10 files changed, 40 insertions(+), 227 deletions(-) diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index cf635f73..06ce86e9 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -6,7 +6,6 @@ This document contains the help content for the `hivectl` command-line program. * [`hivectl`↴](#hivectl) * [`hivectl forge`↴](#hivectl-forge) -* [`hivectl forge create-user`↴](#hivectl-forge-create-user) * [`hivectl forge reconcile-config`↴](#hivectl-forge-reconcile-config) * [`hivectl matrix`↴](#hivectl-matrix) * [`hivectl matrix create-user`↴](#hivectl-matrix-create-user) @@ -63,13 +62,13 @@ This document contains the help content for the `hivectl` command-line program. ## `hivectl` -Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that don't go through the broker — manual user provisioning on the bundled forge + matrix containers, plus future recovery / debugging verbs. +Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that don't go through the broker — manual user provisioning on the bundled matrix container, plus future recovery / debugging verbs. **Usage:** `hivectl [OPTIONS] ` ###### **Subcommands:** -* `forge` — Forgejo user provisioning +* `forge` — Reconcile an agent's config between this hive and the forge * `matrix` — matrix-tuwunel user provisioning * `github` — GitHub account provisioning * `gateway` — Gateway htpasswd user management @@ -95,38 +94,18 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that ## `hivectl forge` -Forgejo user provisioning. +Reconcile an agent's config between this hive and the forge. -Manual entry point to the same idempotent provisioning c0re runs at boot — for recovery, ad-hoc reprovisioning, or fixing one agent without bouncing the daemon. +A human's first SSO login to the forge makes their forge account. **Usage:** `hivectl forge ` ###### **Subcommands:** -* `create-user` — Create or refresh a non-agent Forgejo account + token for `` * `reconcile-config` — Show + reconcile the divergence between an agent's local applied config checkout and its forge `agent-configs/` main -## `hivectl forge create-user` - -Create or refresh a non-agent Forgejo account + token for ``. - -Prints the token to stdout. Set a password to enable forge web-UI login (otherwise it uses a random throwaway). Refused for an existing agent: swarm-controller mints an agent's token (`swarmctl agent mint-forge-token `). - -**Usage:** `hivectl forge create-user [OPTIONS] ` - -###### **Arguments:** - -* `` — Forgejo username of a human/other account — `mara`, `damocles`, etc - -###### **Options:** - -* `--password ` — Set the account password to this string instead of a random throwaway. Use this for operator accounts that need to log into the forge web UI. Mutually exclusive with `--password-stdin`. WARNING: the password is visible in shell history + process listings; prefer `--password-stdin` for anything sensitive -* `--password-stdin` — Read the password from stdin (single line, trailing newline stripped) instead of an inline flag. Mutually exclusive with `--password` - - - ## `hivectl forge reconcile-config` Show + reconcile the divergence between an agent's local applied config checkout and its forge `agent-configs/` main. diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index e79e1d65..292aebe8 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -21,7 +21,7 @@ pub use repos::{ ensure_meta_remote, ensure_repo, ensure_shared_docs_repo, fast_forward_applied_main, fetch_config_main_into_applied, meta_read_access, push_config, push_meta, shared_docs_access, }; -pub use users::{core_token, provision_user_token}; +pub use users::core_token; use std::sync::OnceLock; use std::time::{Duration, Instant}; diff --git a/hive-c0re/src/forge/users.rs b/hive-c0re/src/forge/users.rs index fc36a113..b5a4af57 100644 --- a/hive-c0re/src/forge/users.rs +++ b/hive-c0re/src/forge/users.rs @@ -12,7 +12,7 @@ use forgejo_api::structs::{EditUserOption, UpdateUserAvatarOption}; use forgejo_api::{ApiErrorKind, ForgejoError}; use reqwest::StatusCode; -use super::{CONFIG_ORG, api, forge_admin, is_present}; +use super::{CONFIG_ORG, api, forge_admin}; const TOKEN_NAME_PREFIX: &str = "hyperhive"; // Where the host-side `core` admin token lives. Used by hive-c0re itself @@ -58,18 +58,12 @@ fn config_org_avatar_png_path() -> std::path::PathBuf { this process was started outside that unit", )) } -/// Per-agent token scopes (broad-but-not-admin) for tokens hive-c0re -/// mints itself on the **internal** forge. See `docs/integrations/forge.md::Token -/// scopes` for the per-scope rationale. Not `pub(super)` — external -/// forges (`dashboard/extra_forges.rs`) take an operator-pasted token -/// verbatim, so their scope is whatever the operator's remote account -/// happened to grant; we never mint there and don't need to know it. -const TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc"; /// Bootstrap `core` token scopes — adds `read:admin,write:admin` on -/// top of `TOKEN_SCOPES` so the host daemon can drive -/// `/api/v1/admin/*`. Site-admin membership alone isn't enough: the -/// token's own scope gate runs before the user-permission check. +/// top of the agent scopes (swarm-controller's `AGENT_TOKEN_SCOPES`) +/// so the host daemon can drive `/api/v1/admin/*`. Site-admin +/// membership alone isn't enough: the token's own scope gate runs +/// before the user-permission check. /// See `docs/integrations/forge.md::Token scopes`. const CORE_TOKEN_SCOPES: &str = "read:admin,write:admin,read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc"; @@ -137,18 +131,20 @@ fn is_forbidden(e: &ForgejoError) -> bool { /// Ensure a forgejo user named `name` exists. Idempotent: forgejo /// returns a "user already exists" error which we treat as success. /// `admin` adds `--admin` (site admin) — used for the bootstrap -/// `core` user that drives the API. `password` picks the initial -/// account password: `None` uses `--random-password` (the existing -/// agent provisioning shape — the password is never read, agents auth -/// by token); `Some(pw)` uses `--password ` so the operator path -/// in `hivectl` can set a real password for matrix-style web-UI login. -async fn ensure_user_exists(name: &str, admin: bool, password: Option<&str>) -> Result<()> { +/// `core` user that drives the API. The password is random and never +/// read: the account authenticates by token. +async fn ensure_user_exists(name: &str, admin: bool) -> Result<()> { let email = agent_email(name); - let mut args = vec!["user", "create", "--username", name, "--email", &email]; - match password { - Some(pw) => args.extend(["--password", pw, "--must-change-password=false"]), - None => args.extend(["--random-password", "--must-change-password=false"]), - } + let mut args = vec![ + "user", + "create", + "--username", + name, + "--email", + &email, + "--random-password", + "--must-change-password=false", + ]; if admin { args.push("--admin"); } @@ -174,30 +170,6 @@ async fn ensure_user_exists(name: &str, admin: bool, password: Option<&str>) -> } } -/// Set the forgejo password for an existing user. Used by the operator -/// path in `hivectl forge create-user --password` so re-running on an -/// already-created account still updates the password (covers the -/// "I forgot the password I set last week" case + the "argus retried -/// the verb to verify the fix" case — `forgejo admin user create` -/// silently skips a password change once the account exists). Idempotent -/// from the operator's point of view: same password input → same final -/// account state. -async fn change_user_password(name: &str, password: &str) -> Result<()> { - let args = [ - "user", - "change-password", - "--username", - name, - "--password", - password, - ]; - forge_admin(&args) - .await - .with_context(|| format!("forgejo admin user change-password {name}"))?; - tracing::info!(%name, "forge: changed user password"); - Ok(()) -} - /// Idempotently align the Forgejo account email to `agent_email(name)`. /// Existing agents were created with `{name}@hive.local`; this corrects /// that so git commits (which use `{name}@hyperhive`) link to profiles. @@ -313,8 +285,7 @@ pub(super) async fn ensure_repo_creation_disabled(name: &str) { /// a monotonic clock so re-issuing doesn't collide with an existing /// token of the same name in the DB. `scopes` is the scope string /// passed to `forgejo admin user generate-access-token --scopes`; -/// use `TOKEN_SCOPES` for `hivectl forge create-user` accounts and -/// `CORE_TOKEN_SCOPES` for the bootstrap `core` user. +/// the bootstrap `core` user, its one caller, passes `CORE_TOKEN_SCOPES`. async fn mint_token(name: &str, scopes: &str) -> Result { let token_name = format!( "{TOKEN_NAME_PREFIX}-{}", @@ -366,37 +337,6 @@ async fn mint_and_persist_core_token(path: &Path) -> Result<()> { Ok(()) } -/// Provision a forgejo user for `name` and return the freshly-minted -/// token, which is **not** persisted to disk — the caller is responsible -/// for storing it. Used by `hivectl forge create-user` for human -/// (non-agent) accounts. Agent tokens are minted by swarm-controller -/// (`swarm-controller/src/forge/agent_token.rs`), not here. -/// -/// `password` picks the account password. `None` keeps the existing -/// random-throwaway shape (caller doesn't need web UI access — token -/// alone is enough). `Some(pw)` sets `pw` as the password, including -/// running `forgejo admin user change-password` if the account already -/// exists, so the operator can log into the forge web UI afterwards. -/// Idempotent: re-running with the same `Some(pw)` lands on the same -/// final state. -pub async fn provision_user_token(name: &str, password: Option<&str>) -> Result { - if !is_present().await { - anyhow::bail!( - "hive-forge container not running — wait for hive-c0re to start it before provisioning forge users" - ); - } - ensure_user_exists(name, false, password).await?; - if let Some(pw) = password { - // `user create` silently no-ops on an existing account, so - // we run change-password unconditionally when the caller - // asked for a specific password — keeps the verb idempotent - // for "set or reset" use. - change_user_password(name, pw).await?; - } - ensure_user_email(name).await; - mint_token(name, TOKEN_SCOPES).await -} - /// Set `core`'s Forgejo avatar to the hyperhive logo once, then /// remember it so subsequent startups don't re-upload. Best-effort /// — any non-2xx is logged at the caller; the project runs fine @@ -555,7 +495,7 @@ pub(super) async fn ensure_core_user_and_token() -> Result { } } } - ensure_user_exists("core", true, None).await?; + ensure_user_exists("core", true).await?; mint_and_persist_core_token(path).await?; let raw = std::fs::read_to_string(path) .with_context(|| format!("read {CORE_TOKEN_PATH} after mint"))?; diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 224ea86a..59f222ab 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -868,9 +868,9 @@ async fn finish_user_provisioning(name: &str, access_token: &str) -> Result<()> /// can pass [`random_password`] to keep the existing throwaway /// behaviour. /// -/// **Not idempotent** (unlike [`crate::forge::provision_user_token`]): the -/// matrix `/register` endpoint returns `M_USER_IN_USE` (HTTP 400) on a -/// second call for the same localpart, appservice-authorised or not. +/// **Not idempotent**: the matrix `/register` endpoint returns +/// `M_USER_IN_USE` (HTTP 400) on a second call for the same localpart, +/// appservice-authorised or not. /// Callers re-running this for a known-existing matrix user should expect /// a hard error from this fn and route to a password-reset path instead. pub async fn provision_user_token( diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index f2c4b389..8b1ae675 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -233,9 +233,6 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { HostRequest::MatrixInvite { user, room } => { handle_matrix_invite(user, room.as_deref()).await? } - HostRequest::ForgeCreateUser { name, password } => { - handle_forge_create_user(name, password.as_deref()).await? - } HostRequest::ReconcileConfigStatus { agent, verbose } => { crate::forge::reconcile_config_status(agent.as_str(), *verbose).await? } @@ -591,43 +588,6 @@ async fn handle_matrix_create_user( Ok(HostResponse::messages(out)) } -async fn handle_forge_create_user( - name: &hive_types::Ident, - password: Option<&str>, -) -> Result { - if !crate::forge::is_present().await { - anyhow::bail!( - "hive-forge container not running — wait for hive-c0re to start it before provisioning forge users" - ); - } - if agent_exists(name)? { - // An agent's forge user and token are swarm-controller's: it mints - // the token into the swarm secret store and the agent fetches it - // from there. A hive-minted token would be one the swarm neither - // tracks nor rotates. - anyhow::bail!( - "forge create-user: '{name}' is an agent; its forge token is minted by \ - swarm-controller — run `swarmctl agent mint-forge-token {name}` on the \ - controller host" - ); - } - let token = crate::forge::provision_user_token(name.as_str(), password) - .await - .with_context(|| format!("forge create-user {name}"))?; - let mut out = vec![ - format!("forge: provisioned user '{name}' (not an agent — token not persisted)"), - format!("token: {token}"), - ]; - if password.is_some() { - out.push("password: set as supplied — use it to log into the forge web UI".to_owned()); - } else { - out.push( - "password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(), - ); - } - Ok(HostResponse::messages(out)) -} - async fn handle_set_agent_github_token(agent: &str, token: &str) -> Result { crate::priv_client::write_agent_github_token(agent, token) .await diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index d7263bfb..40cfa32e 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -315,17 +315,6 @@ pub enum HostRequest { #[serde(default)] room: Option, }, - /// Create or refresh a forge account + API token for `name`. Daemon-side - /// equivalent of `hivectl forge create-user`: for a non-agent - /// (operator/human) it mints a user and returns the token in - /// [`HostResponse::messages`]. An existing agent is refused: its token is - /// swarm-controller's to mint. `password` is resolved client-side - /// (inline flag or stdin) and only meaningful for non-agent accounts. - ForgeCreateUser { - name: Ident, - #[serde(default)] - password: Option, - }, /// Report the divergence between agent `agent`'s local applied config /// checkout and its forge `agent-configs/` `main`. The daemon /// fetches forge `main` read-only and returns a human-readable report diff --git a/hivectl/src/cli.rs b/hivectl/src/cli.rs index 35bc83d0..42c2b6fd 100644 --- a/hivectl/src/cli.rs +++ b/hivectl/src/cli.rs @@ -11,7 +11,7 @@ use std::path::PathBuf; long_about = "\ Sibling to the `hive-c0re` daemon binary. Covers host-side admin \ operations that don't go through the broker — manual user \ -provisioning on the bundled forge + matrix containers, plus future \ +provisioning on the bundled matrix container, plus future \ recovery / debugging verbs.\ " )] @@ -28,11 +28,10 @@ pub struct Cli { #[derive(Subcommand)] pub enum Cmd { - /// Forgejo user provisioning. + /// Reconcile an agent's config between this hive and the forge. /// - /// Manual entry point to the same idempotent provisioning c0re runs at - /// boot — for recovery, ad-hoc reprovisioning, or fixing one agent - /// without bouncing the daemon. + /// A human's first SSO login to the forge makes their forge + /// account. Forge { #[command(subcommand)] cmd: ForgeCmd, @@ -248,30 +247,6 @@ impl ScopeArgs { #[derive(Subcommand)] pub enum ForgeCmd { - /// Create or refresh a non-agent Forgejo account + token for ``. - /// - /// Prints the token to stdout. Set a password to enable forge web-UI - /// login (otherwise it uses a random throwaway). Refused for an - /// existing agent: swarm-controller mints an agent's token - /// (`swarmctl agent mint-forge-token `). - CreateUser { - /// Forgejo username of a human/other account — `mara`, - /// `damocles`, etc. - name: String, - /// Set the account password to this string instead of a random - /// throwaway. Use this for operator accounts that need to log - /// into the forge web UI. Mutually exclusive with - /// `--password-stdin`. WARNING: the password is visible in - /// shell history + process listings; prefer `--password-stdin` - /// for anything sensitive. - #[arg(long)] - password: Option, - /// Read the password from stdin (single line, trailing newline - /// stripped) instead of an inline flag. Mutually exclusive with - /// `--password`. - #[arg(long, conflicts_with = "password")] - password_stdin: bool, - }, /// Show + reconcile the divergence between an agent's local applied /// config checkout and its forge `agent-configs/` main. /// diff --git a/hivectl/src/forge.rs b/hivectl/src/forge.rs index 12ca8964..15fdb33e 100644 --- a/hivectl/src/forge.rs +++ b/hivectl/src/forge.rs @@ -1,6 +1,5 @@ -//! `hivectl forge create-user ` — provision a Forgejo account via the -//! daemon (which owns the forge admin token); -//! hivectl just resolves the password client-side and relays the request. +//! `hivectl forge reconcile-config ` — show and reconcile an +//! agent's config divergence via the daemon. use std::io::{self, Write}; use std::path::Path; @@ -9,29 +8,7 @@ use anyhow::Result; use hive_host_sock::{HostRequest, ReconcileDirection}; use crate::cli::ReconcileFrom; -use crate::util::{daemon_request, resolve_password}; - -pub(crate) async fn forge_create_user( - socket: &Path, - name: &str, - password: Option<&str>, - password_stdin: bool, -) -> Result<()> { - // Resolve the password client-side (inline flag or stdin read); the - // daemon never touches this process's stdin. The is-present check, the - // agent-vs-operator branch, and token persistence now live in the - // daemon handler. - let password = resolve_password(password, password_stdin)?; - daemon_request( - socket, - hive_host_sock::HostRequest::ForgeCreateUser { - name: crate::util::parse_ident(name)?, - password, - }, - "forge", - ) - .await -} +use crate::util::daemon_request; /// `hivectl forge reconcile-config [--from ] [--verbose]`. /// Always shows the divergence first (daemon computes it read-only), then diff --git a/hivectl/src/main.rs b/hivectl/src/main.rs index 3182deb7..8bb066dc 100644 --- a/hivectl/src/main.rs +++ b/hivectl/src/main.rs @@ -45,7 +45,7 @@ mod github; mod watch; use github::github_set_token; mod forge; -use forge::{forge_create_user, forge_reconcile_config}; +use forge::forge_reconcile_config; mod agents; use agents::{agents_list, run_agent}; mod power; @@ -66,11 +66,6 @@ async fn main() -> Result<()> { let socket = cli.socket; match cli.cmd { Cmd::Forge { cmd } => match cmd { - ForgeCmd::CreateUser { - name, - password, - password_stdin, - } => forge_create_user(&socket, &name, password.as_deref(), password_stdin).await, ForgeCmd::ReconcileConfig { agent, from, diff --git a/swarm-controller/src/forge/agent_token.rs b/swarm-controller/src/forge/agent_token.rs index 19a322d8..caca0cac 100644 --- a/swarm-controller/src/forge/agent_token.rs +++ b/swarm-controller/src/forge/agent_token.rs @@ -30,12 +30,10 @@ use super::Client; /// of its re-mints, and a later sweep of those must never match this one. pub const AGENT_TOKEN_NAME: &str = "swarm-agent"; -/// The scopes an agent token carries. Byte-identical to `hive-c0re`'s -/// `forge::users::TOKEN_SCOPES`, so the move from hive to swarm changes -/// nothing an agent can do. Narrowing it is a separate decision. -/// -/// ⚠️ Duplicated across the crate boundary until the last `hive-c0re` caller -/// of that constant goes. A test here pins the literal. +/// The scopes an agent token carries. Byte-identical to the `TOKEN_SCOPES` +/// `hive-c0re` minted with, so the move from hive to swarm changes nothing an +/// agent can do. Narrowing it is a separate decision. A test here pins the +/// literal. pub const AGENT_TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc"; /// How often [`spawn`] re-checks every agent's token. From f1f59ea1659aea455470ed652db92f5acd73e09a Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 25 Sep 2026 00:04:44 +0200 Subject: [PATCH 3/5] swarm-controller: make an existing forge user a site admin POST /api/forge/users/{name}/admin reads the account and, when it is not already a site admin, sets `admin` with admin_edit_user. It never creates one: a human's account is made by their first authelia login, so a missing one answers 404, saying the user has not logged in via SSO yet. An existing admin is a success with nothing sent. The edit carries `admin` alone. repo_creation_lockdown's login_name + source_id = 0 would turn an SSO-made account into a local one: in Forgejo 16 a source_id sets the login type. An agent's name is refused, and so is any name when the roster can't be read: a site admin ignores max_repo_creation, the lockdown that keeps an agent's token from creating a repo and self-merging in it. Refs #3782 --- swarm-controller/src/forge.rs | 75 ++++++++ swarm-controller/src/forge/agent_token.rs | 2 +- swarm-controller/src/forge/site_admin.rs | 128 +++++++++++++ swarm-controller/src/main.rs | 207 +++++++++++++++++++++- 4 files changed, 407 insertions(+), 5 deletions(-) create mode 100644 swarm-controller/src/forge/site_admin.rs diff --git a/swarm-controller/src/forge.rs b/swarm-controller/src/forge.rs index ec4cf3d9..e6f7a544 100644 --- a/swarm-controller/src/forge.rs +++ b/swarm-controller/src/forge.rs @@ -34,6 +34,7 @@ use utoipa::ToSchema; use crate::webhook::DeliveryKind; pub mod agent_token; +pub mod site_admin; /// An agent's open config-PR, as [`Client::list_open_config_prs`] reports it /// and `GET /api/agents/{name}/config-pr` serves it. @@ -1659,4 +1660,78 @@ mod tests { let body = lockdown_patch(&seen, "alice").expect("no lockdown PATCH sent"); assert_eq!(body["max_repo_creation"], 0); } + + /// Every `PATCH` body the stub saw. + fn patches(seen: &Seen) -> Vec { + seen.lock() + .unwrap() + .iter() + .filter(|(method, _, _)| method == "PATCH") + .map(|(_, _, body)| body.clone()) + .collect() + } + + /// Only `admin` goes out: a `source_id` would reset an SSO-made + /// account's login type, and the other fields would overwrite the user's + /// own settings. + #[tokio::test] + async fn make_site_admin_sets_admin_and_nothing_else() { + let (client, seen) = stub_forge( + (404, "{}"), + (200, r#"{"login":"mara","is_admin":false}"#), + (200, "{}"), + ) + .await; + + let plan = client.make_site_admin("mara").await.unwrap(); + + assert_eq!(plan, site_admin::Plan::Promote); + let bodies = patches(&seen); + assert_eq!(bodies.len(), 1, "{bodies:?}"); + let set: Vec<(&String, &serde_json::Value)> = bodies[0] + .as_object() + .unwrap() + .iter() + .filter(|(_, v)| !v.is_null()) + .collect(); + assert_eq!(set, [(&"admin".to_owned(), &serde_json::Value::Bool(true))]); + } + + #[tokio::test] + async fn make_site_admin_leaves_an_admin_alone() { + let (client, seen) = stub_forge( + (404, "{}"), + (200, r#"{"login":"mara","is_admin":true}"#), + (200, "{}"), + ) + .await; + + let plan = client.make_site_admin("mara").await.unwrap(); + + assert_eq!(plan, site_admin::Plan::AlreadyAdmin); + assert!(patches(&seen).is_empty()); + } + + /// A user who has not logged in yet is reported, not created: nothing is + /// sent but the read. + #[tokio::test] + async fn make_site_admin_does_not_create_a_missing_user() { + let (client, seen) = stub_forge( + (201, "{}"), + (404, r#"{"message":"user does not exist"}"#), + (200, "{}"), + ) + .await; + + let plan = client.make_site_admin("mara").await.unwrap(); + + assert_eq!(plan, site_admin::Plan::NoSuchUser); + let methods: Vec = seen + .lock() + .unwrap() + .iter() + .map(|(m, _, _)| m.clone()) + .collect(); + assert_eq!(methods, ["GET"]); + } } diff --git a/swarm-controller/src/forge/agent_token.rs b/swarm-controller/src/forge/agent_token.rs index caca0cac..5932bce8 100644 --- a/swarm-controller/src/forge/agent_token.rs +++ b/swarm-controller/src/forge/agent_token.rs @@ -153,7 +153,7 @@ pub fn plan(observed: &[(String, Observed)]) -> Vec { } /// Whether a forge error is a 404, whichever of its two shapes it came in. -fn is_not_found(e: &ForgejoError) -> bool { +pub(super) fn is_not_found(e: &ForgejoError) -> bool { match e { ForgejoError::ApiError(api) => { matches!(api.error_kind(), ApiErrorKind::NotFound { .. }) diff --git a/swarm-controller/src/forge/site_admin.rs b/swarm-controller/src/forge/site_admin.rs new file mode 100644 index 00000000..7d4de581 --- /dev/null +++ b/swarm-controller/src/forge/site_admin.rs @@ -0,0 +1,128 @@ +//! Making an existing human forge account a site admin: the whole job of +//! `POST /api/forge/users/{name}/admin`, which `swarmctl forge make-admin` +//! calls. +//! +//! Never creates the account. A human's account is made by their first +//! authelia login to the forge (`oauth2_client` in +//! `nix/host-modules/hive-forge/default.nix`), so a missing one means that +//! login has not happened yet, and making it here would be a second way in. +//! +//! The decision is pure ([`plan`]), so the tests pin it; the IO on either side +//! only reads or acts. Same split as [`super::agent_token`]. + +use anyhow::{Context, Result}; +use forgejo_api::structs::{EditUserOption, User}; + +use super::Client; +use super::agent_token::is_not_found; + +/// What one request does about the account. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Plan { + /// The forge has no user by this name: its owner has not logged in yet. + NoSuchUser, + /// Already a site admin: nothing to send. + AlreadyAdmin, + /// An ordinary account: make it a site admin. + Promote, +} + +/// Decide from the account as the forge reports it (`None` when there is no +/// such user). +pub fn plan(user: Option<&User>) -> Plan { + match user { + None => Plan::NoSuchUser, + Some(u) if u.is_admin == Some(true) => Plan::AlreadyAdmin, + Some(_) => Plan::Promote, + } +} + +/// The `admin_edit_user` body that sets `admin` and nothing else. +/// +/// Unlike [`super::repo_creation_lockdown`], no `login_name` + `source_id`: +/// in Forgejo 16 both are optional, and a `source_id` sets the account's login +/// type (`services/user/update.go`, `UpdateAuth`). `source_id = 0` would turn +/// an SSO-made account into a local one. +fn admin_edit() -> EditUserOption { + EditUserOption { + active: None, + admin: Some(true), + allow_create_organization: None, + allow_git_hook: None, + allow_import_local: None, + description: None, + email: None, + full_name: None, + hide_email: None, + location: None, + login_name: None, + max_repo_creation: None, + must_change_password: None, + password: None, + prohibit_login: None, + pronouns: None, + restricted: None, + source_id: None, + visibility: None, + website: None, + } +} + +impl Client { + /// Make the existing account `name` a site admin, and say what that took. + /// The caller has already refused an agent's name. + /// + /// # Errors + /// When the forge refuses the read or the edit. + pub async fn make_site_admin(&self, name: &str) -> Result { + let user = match self.api.user_get(name).await { + Ok(user) => Some(user), + Err(e) if is_not_found(&e) => None, + Err(e) => return Err(e).with_context(|| format!("read forge user {name}")), + }; + let plan = plan(user.as_ref()); + if plan == Plan::Promote { + self.api + .admin_edit_user(name, admin_edit()) + .await + .with_context(|| format!("make forge user {name} a site admin"))?; + tracing::info!(%name, "swarm forge: made the user a site admin"); + } + Ok(plan) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn user(is_admin: Option) -> User { + serde_json::from_value(serde_json::json!({ + "login": "mara", + "is_admin": is_admin, + })) + .expect("a user decodes") + } + + #[test] + fn a_missing_user_is_not_created() { + assert_eq!(plan(None), Plan::NoSuchUser); + } + + #[test] + fn an_admin_is_left_alone() { + assert_eq!(plan(Some(&user(Some(true)))), Plan::AlreadyAdmin); + } + + #[test] + fn an_ordinary_user_is_promoted() { + assert_eq!(plan(Some(&user(Some(false)))), Plan::Promote); + } + + /// No flag in the answer: the edit is sent. On an admin it is a no-op; + /// skipping it would report success for an ordinary account. + #[test] + fn an_unreported_admin_flag_is_promoted() { + assert_eq!(plan(Some(&user(None))), Plan::Promote); + } +} diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index e43fcb0e..3af90807 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -525,6 +525,7 @@ fn socket_path() -> PathBuf { (name = "agents", description = "creating agent identities at swarm level"), (name = "webhook", description = "swarm-wide forge webhook receipt"), (name = "repos", description = "forge repo browsing (swarm-ui's issue-report page)"), + (name = "forge", description = "forge site admins (`swarmctl forge make-admin`)"), ) )] struct ApiDoc; @@ -599,7 +600,8 @@ struct AppState { /// it only for a name that breaks a naming rule, to tell a new agent /// from an existing one, and warns when it cannot (`name_verdict`). **GET /// has nowhere to defer to** — there is no job, only an answer it - /// either has or does not. + /// either has or does not. `POST /api/forge/users/{name}/admin` reads + /// it for every name and refuses when it cannot (`admin_unless_agent`). auth: Option>, /// HMAC secret for swarm-wide forge webhooks, loaded once at startup. /// `None` when it could not be read or created — the webhook endpoint @@ -626,7 +628,7 @@ struct AppState { swarm_name: Option>, /// The forge client itself, for the read-only repo/issue-report /// routes (`GET /api/repos`, `GET /api/repos/{org}/{repo}/issue-report`) - /// — distinct from `config_prs`, which holds a *cache* built off this + /// and `POST /api/forge/users/{name}/admin` — distinct from `config_prs`, which holds a *cache* built off this /// same client rather than the client. `None` under the same /// "no forge configured on this host" shape every other /// forge-backed field here uses. @@ -1370,8 +1372,9 @@ fn broken_name_rules( } /// Whether `agent` is already in the swarm roster, or why that could not be -/// told. Asked only of a name that breaks a rule, so an ordinary creation -/// still never waits on the bridge. +/// told. `create_agent` asks only of a name that breaks a rule, so an +/// ordinary creation still never waits on the bridge; `make_forge_admin` +/// asks of every name. async fn in_roster(auth: Option<&auth::AuthBridge>, agent: &str) -> Result { let Some(auth) = auth else { return Err("no identity bridge is configured".to_owned()); @@ -1828,6 +1831,113 @@ async fn mint_agent_forge_token( Ok(Json(MintAgentForgeTokenResponse { node_id: id.get() })) } +/// What `POST /api/forge/users/{name}/admin` found. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +enum ForgeAdminChange { + Promoted, + AlreadyAdmin, +} + +/// Success body of `POST /api/forge/users/{name}/admin`. +#[derive(Serialize, Deserialize, ToSchema)] +struct MakeForgeAdminResponse { + change: ForgeAdminChange, +} + +/// Make an existing human forge account a site admin. Idempotent. +/// +/// Never creates the account: a human's is made by their first SSO login to +/// the forge, and until then this answers 404. Refuses an agent's name: a +/// site admin can create repos whatever its `max_repo_creation`, and an +/// agent's account is locked to 0 so its token cannot create a repo and +/// self-merge in it. +#[utoipa::path( + post, + path = "/api/forge/users/{name}/admin", + params(("name" = String, Path, description = "forge username")), + responses( + (status = 200, description = "the account is a site admin", body = MakeForgeAdminResponse), + (status = 400, description = "`name` is not a valid identifier (problem+json)", body = String), + (status = 404, description = "no such account: the user has not logged in via SSO yet (problem+json)", body = String), + (status = 409, description = "`name` is an agent (problem+json)", body = String), + (status = 500, description = "the forge refused the read or the edit (problem+json)", body = String), + (status = 503, description = "no forge configured, or the agent roster could not be read (problem+json)", body = String), + ), + tag = "forge" +)] +async fn make_forge_admin( + State(state): State, + Path(name): Path, +) -> Result, problem_details::ProblemDetails> { + let name = hive_types::Ident::parse(&name) + .map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))? + .into_string(); + let Some(forge) = state.forge.clone() else { + return Err(error_problem( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "no forge is configured on this host", + )); + }; + let is_agent = in_roster(state.auth.as_deref(), &name).await; + admin_unless_agent(&name, is_agent, || async { + forge.make_site_admin(&name).await + }) + .await +} + +/// Run `promote` only if `name` is known not to be an agent, and answer with +/// what it found. +/// +/// An unreadable roster refuses too: a site admin is the one thing an agent's +/// account must never become, so "could not tell" is not "no". +async fn admin_unless_agent( + name: &str, + is_agent: Result, + promote: F, +) -> Result, problem_details::ProblemDetails> +where + F: FnOnce() -> Fut, + Fut: std::future::Future>, +{ + match is_agent { + Ok(false) => {} + Ok(true) => { + return Err(error_problem( + axum::http::StatusCode::CONFLICT, + &format!("{name:?} is an agent; an agent's forge account is never a site admin"), + )); + } + Err(e) => { + return Err(error_problem( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + &format!("cannot tell whether {name:?} is an agent, so refusing: {e}"), + )); + } + } + let plan = promote().await.map_err(|e| { + error_problem( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + &format!("{e:#}"), + ) + })?; + let change = match plan { + forge::site_admin::Plan::NoSuchUser => { + return Err(error_problem( + axum::http::StatusCode::NOT_FOUND, + &format!( + "the forge has no user {name:?}: the user has not logged in via SSO yet. \ + The forge creates the account on their first authelia login; run this \ + again after it" + ), + )); + } + forge::site_admin::Plan::AlreadyAdmin => ForgeAdminChange::AlreadyAdmin, + forge::site_admin::Plan::Promote => ForgeAdminChange::Promoted, + }; + Ok(Json(MakeForgeAdminResponse { change })) +} + /// Every agent with an open config PR, in one response — the bulk /// counterpart to [`get_agent_config_pr`]. swarm-ui's config-PR table needs /// every agent's status to render, and fetching them one at a time doesn't @@ -2175,6 +2285,7 @@ fn build_app(state: AppState) -> axum::Router { .routes(routes!(create_agent)) .routes(routes!(mint_agent_identity)) .routes(routes!(mint_agent_forge_token)) + .routes(routes!(make_forge_admin)) .routes(routes!(get_agents)) .routes(routes!(get_agents_status)) .routes(routes!(set_agent_state)) @@ -2516,6 +2627,94 @@ mod tests { assert!(warnings[0].contains("bridge down"), "{warnings:?}"); } + /// `admin_unless_agent` with a `promote` that records whether it ran and + /// answers `plan`. + async fn forge_admin_route( + is_agent: Result, + plan: crate::forge::site_admin::Plan, + ) -> ( + Result, problem_details::ProblemDetails>, + bool, + ) { + let ran = std::sync::atomic::AtomicBool::new(false); + let result = super::admin_unless_agent("atlas", is_agent, || async { + ran.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(plan) + }) + .await; + (result, ran.into_inner()) + } + + /// An agent's name never reaches the forge. + #[tokio::test] + async fn the_forge_admin_route_refuses_an_agent() { + let (result, ran) = + forge_admin_route(Ok(true), crate::forge::site_admin::Plan::Promote).await; + let Err(problem) = result else { + panic!("an agent's name must be refused"); + }; + assert!(!ran, "the account must not be touched"); + assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT)); + } + + /// A roster that can't be read can't vouch for the name, so the route + /// refuses rather than guessing "not an agent". + #[tokio::test] + async fn the_forge_admin_route_refuses_on_an_unreadable_roster() { + let (result, ran) = forge_admin_route( + Err("bridge down".to_owned()), + crate::forge::site_admin::Plan::Promote, + ) + .await; + let Err(problem) = result else { + panic!("an unreadable roster must refuse"); + }; + assert!(!ran, "the account must not be touched"); + assert_eq!( + problem.status, + Some(axum::http::StatusCode::SERVICE_UNAVAILABLE) + ); + } + + /// A missing account is a 404 that tells the operator what to wait for. + #[tokio::test] + async fn the_forge_admin_route_names_the_missing_sso_login() { + let (result, ran) = + forge_admin_route(Ok(false), crate::forge::site_admin::Plan::NoSuchUser).await; + let Err(problem) = result else { + panic!("a missing account must not report success"); + }; + assert!(ran); + assert_eq!(problem.status, Some(axum::http::StatusCode::NOT_FOUND)); + let rendered = format!("{problem:?}"); + assert!( + rendered.contains("has not logged in via SSO yet"), + "{rendered}" + ); + } + + #[tokio::test] + async fn the_forge_admin_route_promotes_a_non_agent() { + let (result, ran) = + forge_admin_route(Ok(false), crate::forge::site_admin::Plan::Promote).await; + let Ok(axum::Json(resp)) = result else { + panic!("a non-agent must be promoted"); + }; + assert!(ran); + assert_eq!(resp.change, super::ForgeAdminChange::Promoted); + } + + /// Idempotent: a second run succeeds and says nothing changed. + #[tokio::test] + async fn the_forge_admin_route_succeeds_for_an_admin() { + let (result, _) = + forge_admin_route(Ok(false), crate::forge::site_admin::Plan::AlreadyAdmin).await; + let Ok(axum::Json(resp)) = result else { + panic!("an existing admin must succeed"); + }; + assert_eq!(resp.change, super::ForgeAdminChange::AlreadyAdmin); + } + /// The backfill route's roster check, asserted by effect for the same /// reason its sibling above is: a refusal that queued first would still /// re-mint the agent's certificate, which every running agent on the From d56d8f2b3665022884016225ff71e6a093642bfd Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 25 Sep 2026 02:01:57 +0200 Subject: [PATCH 4/5] 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"; From 0cbb7db2c0f037b002021221ef01585a25123a6c Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 25 Sep 2026 02:11:44 +0200 Subject: [PATCH 5/5] docs: a first SSO login makes a human's forge account setup.md said Swarm SSO creates the operator's forge account, which was not true until the previous commits. It now says how: sign in to the forge once through authelia, then `swarmctl forge make-admin `. sso.md says what that first login does and why ACCOUNT_LINKING is `login`. README, hivectl.md and forge.md drop `hivectl forge create-user`, and the swarmctl README gains `forge make-admin`. Refs #3782 --- README.md | 10 ++++++---- docs/getting-started/setup.md | 21 +++++++++++++++++++-- docs/integrations/forge.md | 13 ++++++------- docs/swarm/sso.md | 11 ++++++++++- docs/tools/hivectl.md | 26 +++++++------------------- swarmctl/README.md | 14 ++++++++++++++ 6 files changed, 62 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 8b5b5766..b6bb21af 100644 --- a/README.md +++ b/README.md @@ -114,16 +114,18 @@ doesn't go through the broker (built alongside `hive-c0re` when the host module is enabled): ```sh -sudo hivectl forge create-user mara # provisions a forge user -sudo hivectl forge create-user mara --password 'hunter2' # … with a fixed password sudo hivectl matrix create-user mara # provisions a matrix user sudo hivectl matrix create-user mara --password-stdin # … reading one line from stdin ``` For a name that's a managed agent, `hivectl` persists the resulting token to that agent's state dir, the same as the boot sweep does. For a -non-agent name (e.g. the operator's own forge/matrix account), it prints -the token to stdout and writes nothing. +non-agent name (for example the operator's own matrix account), it prints the +token to stdout and writes nothing. + +A human's first SSO login to the forge makes their forge account, not +`hivectl`; `swarmctl forge make-admin ` on the swarm-controller's +host makes it a site admin. ## Build / deploy diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index 22580928..7f0d0b4b 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -48,8 +48,9 @@ about ten minutes. `swarmctl agent mint-forge-token ruth` skips the wait for the pass. A hive without a swarm secret store has no path to a forge token for ruth at all. -Swarm SSO creates the human operator's own forge account instead of -a manual `hivectl` step — see _Swarm SSO_ below (`swarmctl user add`). +Swarm SSO creates the human operator's own forge account: the forge +makes it on their first login through authelia, and `swarmctl forge +make-admin ` then makes it a site admin — see _Swarm SSO_ below. ### 2 · Gateway (HTTP Basic auth) @@ -195,6 +196,22 @@ If an account already exists without it, `user add` refuses rather than amends — adding the group afterwards is `swarmctl user update mara --add-group admins`. +Then sign in to the forge once through authelia, with that account. That +first login creates your forge account, under the same username. Make it +a site admin: + +```bash +# On the swarm-controller's host. Fails until that first login has happened. +swarmctl forge make-admin mara +``` + +⚠️ **Keep `--email` too.** The forge won't create an account without an +email: a subject that has none gets the forge's link-account page and no +account. `swarmctl user update mara --email …` fixes it. + +If the forge already has a local account with your username, the first +SSO login asks for that account's forge password once, to link the two. + Detail, including what the password is and why this stays manual: [`swarm/sso.md`](../swarm/sso.md). diff --git a/docs/integrations/forge.md b/docs/integrations/forge.md index 83509234..3f98391c 100644 --- a/docs/integrations/forge.md +++ b/docs/integrations/forge.md @@ -15,11 +15,10 @@ each agent on relevant activity. ## Token scopes -Two scope sets live in `hive-c0re::forge`: +Two scope sets: -**`TOKEN_SCOPES`** (per-agent tokens, and `hivectl forge create-user` -accounts). swarm-controller mints agent tokens with a byte-identical copy, -`forge::agent_token::AGENT_TOKEN_SCOPES`, pinned by a test: +**`AGENT_TOKEN_SCOPES`** (swarm-controller's `forge::agent_token`, pinned +by a test). swarm-controller mints every agent token with it: | Scope | Why | | -------------------- | ------------------------------------------------------------------------------------------------------- | @@ -32,9 +31,9 @@ accounts). swarm-controller mints agent tokens with a byte-identical copy, | `read:notification` | Poll `GET /notifications` for unread events. | | `write:notification` | Mark notifications read via `PATCH /notifications/threads/{id}`. | -**`CORE_TOKEN_SCOPES`** (hive-c0re's own `core` user): everything in -`TOKEN_SCOPES` plus `read:admin` and `write:admin`. Site-admin -membership alone isn't sufficient — Forgejo's token scope gate runs +**`CORE_TOKEN_SCOPES`** (`hive-c0re::forge`, for its own `core` user): +everything in `AGENT_TOKEN_SCOPES` plus `read:admin` and `write:admin`. +Site-admin membership alone isn't sufficient — Forgejo's token scope gate runs before the user-permission check, so `/api/v1/admin/*` returns `403 Forbidden` for any token without the admin scope bits, even when the bearer is a site admin. diff --git a/docs/swarm/sso.md b/docs/swarm/sso.md index ca9033dc..05054f95 100644 --- a/docs/swarm/sso.md +++ b/docs/swarm/sso.md @@ -170,7 +170,16 @@ result isn't. | callback URL | `/user/oauth2//callback` | `/_matrix/client/unstable/login/sso/callback/`, a shape tuwunel fixes rather than accepts | | cost of a malformed entry | the login source is missing | the homeserver can refuse to start | -Two consequences worth stating plainly: +Three consequences worth stating plainly: + +- **A person's first forge login creates their forge account**, named + after authelia's `preferred_username` (`[oauth2_client]` in the forge + module), provided the subject has an email. It starts as an ordinary user; `swarmctl forge make-admin +` makes it a site admin. A name that already has a local forge + account doesn't get it handed over: forgejo asks for that account's + own password first (`ACCOUNT_LINKING = login`). Agents, `core` and + `swarm-controller` all have local accounts, and `swarmctl user add` + refuses none of those names. - **tuwunel re-reads its secret file on every OAuth exchange**, not only at startup, and its own sandboxing hides most paths from it. It gets the diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 47e56416..358ed60a 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -11,7 +11,7 @@ Available via the `hive-c0re` package in the host NixOS config. Unlike the `hive-c0re` daemon subcommands (which go through the broker), `hivectl` covers direct host-side administration: manual provisioning of -forge + matrix accounts, gateway htpasswd management, container +matrix accounts, gateway htpasswd management, container lifecycle shortcuts, and interactive agent shell access. This page is the curated guide. For the exhaustive flag-by-flag @@ -24,30 +24,18 @@ markdown-docs > docs/tools/hivectl-cli.md`. ## Forge -Manual entry to the same idempotent provisioning flow `hive-c0re` runs -at boot. Useful for recovery, ad-hoc re-provisioning, or fixing a single -agent without bouncing the daemon. +Reconciles an agent's config between this hive and the forge. `hivectl` +makes no forge accounts: a human's first SSO login to the forge makes +theirs, and `swarmctl forge make-admin ` makes it a site admin. +swarm-controller makes an agent's, and `swarmctl agent mint-forge-token +` checks and, if needed, re-mints its token. ```bash -hivectl forge create-user iris # refused: `iris` is an agent; use `swarmctl agent mint-forge-token iris` -hivectl forge create-user mara # create forge account for a human user; prints token to stdout -hivectl forge create-user mara --password hunter2 # set a web-login password -hivectl forge create-user mara --password-stdin # read password from stdin (safer for scripting) - hivectl forge reconcile-config iris # show local-applied <-> forge config divergence, then prompt hivectl forge reconcile-config iris --from forge # reset local applied checkout to forge main (effective next deploy) hivectl forge reconcile-config iris --verbose # include the full diff, not just --stat ``` -- For **agents** (name has a state dir under `/var/lib/hyperhive/agents/`): - `create-user` refuses. swarm-controller mints an agent's token into - the swarm secret store; `swarmctl agent mint-forge-token ` checks - and, if needed, re-mints it. -- For **non-agents** (humans): creates the account and prints the token to - stdout, creating no state dir. Re-running after account already exists - re-mints the token and prints it again — safe for password resets. -- Without `--password` / `--password-stdin` `create-user` uses a random - throwaway password (fine for agents — they auth by token). - `reconcile-config ` shows the divergence between the agent's local applied config checkout and its forge `agent-configs/` `main`, then reconciles. `--from forge` resets the local checkout to forge `main` (takes @@ -101,7 +89,7 @@ hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a spec Write an operator-supplied GitHub personal access token (PAT) into an agent's token file so its `gh` wrapper + git credential helper can act as -the bot account. Unlike forge/matrix there is no account creation — the PAT +the bot account. Unlike matrix there is no account creation — the PAT is for an existing GitHub account. A CLI alternative to the dashboard credentials tab; the [GitHub integration](../integrations/github.md) is on by default (`services.hyperhive.agent.github.enable`), so no per-agent config is needed. diff --git a/swarmctl/README.md b/swarmctl/README.md index 527e6ea0..e526fe7b 100644 --- a/swarmctl/README.md +++ b/swarmctl/README.md @@ -109,3 +109,17 @@ than shared: the controller's own types are private to its binary, this crate does not link it, and there is no wire-type crate between them. Two fields out, two in, both ends validating — a drift shows up as a 400 naming the field. + +## `forge make-admin` + +```console +# swarmctl forge make-admin mara +forge: "mara" is now a site admin +``` + +`POST /api/forge/users/{name}/admin` on the swarm-controller, over the same +socket as `agent create`. It never creates an account: the forge makes a +person's on their first login through authelia, and until then this fails +saying so. Running it on a site admin changes nothing, and an agent's name is +refused. The response shape is mirrored in `src/forge.rs`, for the same +reason as `agent create`'s.