diff --git a/README.md b/README.md index b6bb21af..8b5b5766 100644 --- a/README.md +++ b/README.md @@ -114,18 +114,16 @@ 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 (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. +non-agent name (e.g. the operator's own forge/matrix account), it prints +the token to stdout and writes nothing. ## Build / deploy diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index 7f0d0b4b..22580928 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -48,9 +48,8 @@ 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: 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. +Swarm SSO creates the human operator's own forge account instead of +a manual `hivectl` step — see _Swarm SSO_ below (`swarmctl user add`). ### 2 · Gateway (HTTP Basic auth) @@ -196,22 +195,6 @@ 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 3f98391c..83509234 100644 --- a/docs/integrations/forge.md +++ b/docs/integrations/forge.md @@ -15,10 +15,11 @@ each agent on relevant activity. ## Token scopes -Two scope sets: +Two scope sets live in `hive-c0re::forge`: -**`AGENT_TOKEN_SCOPES`** (swarm-controller's `forge::agent_token`, pinned -by a test). swarm-controller mints every agent token with it: +**`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: | Scope | Why | | -------------------- | ------------------------------------------------------------------------------------------------------- | @@ -31,9 +32,9 @@ by a test). swarm-controller mints every agent token with it: | `read:notification` | Poll `GET /notifications` for unread events. | | `write:notification` | Mark notifications read via `PATCH /notifications/threads/{id}`. | -**`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 +**`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 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 05054f95..ca9033dc 100644 --- a/docs/swarm/sso.md +++ b/docs/swarm/sso.md @@ -170,16 +170,7 @@ 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 | -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. +Two consequences worth stating plainly: - **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-cli.md b/docs/tools/hivectl-cli.md index 06ce86e9..cf635f73 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -6,6 +6,7 @@ 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) @@ -62,13 +63,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 matrix container, 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 forge + matrix containers, plus future recovery / debugging verbs. **Usage:** `hivectl [OPTIONS] ` ###### **Subcommands:** -* `forge` — Reconcile an agent's config between this hive and the forge +* `forge` — Forgejo user provisioning * `matrix` — matrix-tuwunel user provisioning * `github` — GitHub account provisioning * `gateway` — Gateway htpasswd user management @@ -94,18 +95,38 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that ## `hivectl forge` -Reconcile an agent's config between this hive and the forge. +Forgejo user provisioning. -A human's first SSO login to the forge makes their forge account. +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. **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/docs/tools/hivectl.md b/docs/tools/hivectl.md index 358ed60a..47e56416 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 -matrix accounts, gateway htpasswd management, container +forge + 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,18 +24,30 @@ markdown-docs > docs/tools/hivectl-cli.md`. ## Forge -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. +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. ```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 @@ -89,7 +101,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 matrix there is no account creation — the PAT +the bot account. Unlike forge/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/docs/tools/swarmctl-cli.md b/docs/tools/swarmctl-cli.md index 031d536f..1810d02d 100644 --- a/docs/tools/swarmctl-cli.md +++ b/docs/tools/swarmctl-cli.md @@ -13,8 +13,6 @@ 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` @@ -27,7 +25,6 @@ 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:** @@ -194,38 +191,6 @@ 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/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index 292aebe8..e79e1d65 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; +pub use users::{core_token, provision_user_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 b5a4af57..fc36a113 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}; +use super::{CONFIG_ORG, api, forge_admin, is_present}; const TOKEN_NAME_PREFIX: &str = "hyperhive"; // Where the host-side `core` admin token lives. Used by hive-c0re itself @@ -58,12 +58,18 @@ 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 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. +/// 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. /// 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"; @@ -131,20 +137,18 @@ 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. The password is random and never -/// read: the account authenticates by token. -async fn ensure_user_exists(name: &str, admin: bool) -> Result<()> { +/// `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<()> { let email = agent_email(name); - let mut args = vec![ - "user", - "create", - "--username", - name, - "--email", - &email, - "--random-password", - "--must-change-password=false", - ]; + 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"]), + } if admin { args.push("--admin"); } @@ -170,6 +174,30 @@ async fn ensure_user_exists(name: &str, admin: bool) -> Result<()> { } } +/// 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. @@ -285,7 +313,8 @@ 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`; -/// the bootstrap `core` user, its one caller, passes `CORE_TOKEN_SCOPES`. +/// use `TOKEN_SCOPES` for `hivectl forge create-user` accounts and +/// `CORE_TOKEN_SCOPES` for the bootstrap `core` user. async fn mint_token(name: &str, scopes: &str) -> Result { let token_name = format!( "{TOKEN_NAME_PREFIX}-{}", @@ -337,6 +366,37 @@ 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 @@ -495,7 +555,7 @@ pub(super) async fn ensure_core_user_and_token() -> Result { } } } - ensure_user_exists("core", true).await?; + ensure_user_exists("core", true, None).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 59f222ab..224ea86a 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**: 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** (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. /// 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 8b1ae675..f2c4b389 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -233,6 +233,9 @@ 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? } @@ -588,6 +591,43 @@ 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 40cfa32e..d7263bfb 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -315,6 +315,17 @@ 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 42c2b6fd..35bc83d0 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 matrix container, plus future \ +provisioning on the bundled forge + matrix containers, plus future \ recovery / debugging verbs.\ " )] @@ -28,10 +28,11 @@ pub struct Cli { #[derive(Subcommand)] pub enum Cmd { - /// Reconcile an agent's config between this hive and the forge. + /// Forgejo user provisioning. /// - /// A human's first SSO login to the forge makes their forge - /// account. + /// 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. Forge { #[command(subcommand)] cmd: ForgeCmd, @@ -247,6 +248,30 @@ 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 15fdb33e..12ca8964 100644 --- a/hivectl/src/forge.rs +++ b/hivectl/src/forge.rs @@ -1,5 +1,6 @@ -//! `hivectl forge reconcile-config ` — show and reconcile an -//! agent's config divergence via the daemon. +//! `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. use std::io::{self, Write}; use std::path::Path; @@ -8,7 +9,29 @@ use anyhow::Result; use hive_host_sock::{HostRequest, ReconcileDirection}; use crate::cli::ReconcileFrom; -use crate::util::daemon_request; +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 +} /// `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 8bb066dc..3182deb7 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_reconcile_config; +use forge::{forge_create_user, forge_reconcile_config}; mod agents; use agents::{agents_list, run_agent}; mod power; @@ -66,6 +66,11 @@ 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/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 732e730c..76bc63e4 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -775,33 +775,13 @@ in # the custom theme CSS baked straight into the nix store. STATIC_ROOT_PATH = staticRootWithTheme; }; - # 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). + # Registration off — operator seeds agent users via + # `nixos-container run hive-forge -- forgejo admin + # user create …`. 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 02c75288..7776bd2a 100644 --- a/nix/module-eval/forge-placement.nix +++ b/nix/module-eval/forge-placement.nix @@ -70,8 +70,6 @@ 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 @@ -141,30 +139,6 @@ 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 diff --git a/swarm-controller/src/forge.rs b/swarm-controller/src/forge.rs index e6f7a544..ec4cf3d9 100644 --- a/swarm-controller/src/forge.rs +++ b/swarm-controller/src/forge.rs @@ -34,7 +34,6 @@ 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. @@ -1660,78 +1659,4 @@ 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 5932bce8..19a322d8 100644 --- a/swarm-controller/src/forge/agent_token.rs +++ b/swarm-controller/src/forge/agent_token.rs @@ -30,10 +30,12 @@ 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 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. +/// 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. 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. @@ -153,7 +155,7 @@ pub fn plan(observed: &[(String, Observed)]) -> Vec { } /// Whether a forge error is a 404, whichever of its two shapes it came in. -pub(super) fn is_not_found(e: &ForgejoError) -> bool { +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 deleted file mode 100644 index 7d4de581..00000000 --- a/swarm-controller/src/forge/site_admin.rs +++ /dev/null @@ -1,128 +0,0 @@ -//! 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 3af90807..e43fcb0e 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -525,7 +525,6 @@ 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; @@ -600,8 +599,7 @@ 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. `POST /api/forge/users/{name}/admin` reads - /// it for every name and refuses when it cannot (`admin_unless_agent`). + /// either has or does not. 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 @@ -628,7 +626,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`) - /// and `POST /api/forge/users/{name}/admin` — distinct from `config_prs`, which holds a *cache* built off this + /// — 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. @@ -1372,9 +1370,8 @@ fn broken_name_rules( } /// Whether `agent` is already in the swarm roster, or why that could not be -/// 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. +/// told. Asked only of a name that breaks a rule, so an ordinary creation +/// still never waits on the bridge. 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()); @@ -1831,113 +1828,6 @@ 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 @@ -2285,7 +2175,6 @@ 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)) @@ -2627,94 +2516,6 @@ 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 diff --git a/swarmctl/README.md b/swarmctl/README.md index e526fe7b..527e6ea0 100644 --- a/swarmctl/README.md +++ b/swarmctl/README.md @@ -109,17 +109,3 @@ 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. diff --git a/swarmctl/src/agent.rs b/swarmctl/src/agent.rs index d22a4aab..e6b57bf5 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. -pub(crate) fn parse_ident(value: &str, what: &str) -> Result { +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( +async fn post( socket: &Path, uri: &str, request: &Req, diff --git a/swarmctl/src/forge.rs b/swarmctl/src/forge.rs deleted file mode 100644 index c7f3f1f6..00000000 --- a/swarmctl/src/forge.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! `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 d1c032eb..7832e335 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -28,7 +28,6 @@ //! for the same reason `hivectl` does not link `hive-c0re`. mod agent; -mod forge; mod users; use std::fs::{self, File, Permissions}; @@ -124,11 +123,6 @@ 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 @@ -261,29 +255,6 @@ 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. @@ -366,13 +337,6 @@ 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. @@ -787,24 +751,6 @@ 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";