Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
Author SHA1 Message Date
atlas
0cbb7db2c0 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 <you>`.
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
2026-09-25 08:29:56 +02:00
atlas
d56d8f2b36 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
2026-09-25 08:29:56 +02:00
atlas
f1f59ea165 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
2026-09-25 08:29:56 +02:00
atlas
ef494af188 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
2026-09-25 08:29:56 +02:00
atlas
113f3fe6e2 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
2026-09-25 08:29:56 +02:00
25 changed files with 715 additions and 270 deletions

View file

@ -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 <name>` on the swarm-controller's
host makes it a site admin.
## Build / deploy

View file

@ -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 <you>` 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).

View file

@ -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.

View file

@ -170,7 +170,16 @@ result isn't.
| callback URL | `<root>/user/oauth2/<source>/callback` | `<homeserver>/_matrix/client/unstable/login/sso/callback/<client_id>`, 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
<name>` 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

View file

@ -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] <COMMAND>`
###### **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 <COMMAND>`
###### **Subcommands:**
* `create-user` — Create or refresh a non-agent Forgejo account + token for `<name>`
* `reconcile-config` — Show + reconcile the divergence between an agent's local applied config checkout and its forge `agent-configs/<agent>` main
## `hivectl forge create-user`
Create or refresh a non-agent Forgejo account + token for `<name>`.
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 <agent>`).
**Usage:** `hivectl forge create-user [OPTIONS] <NAME>`
###### **Arguments:**
* `<NAME>` — Forgejo username of a human/other account — `mara`, `damocles`, etc
###### **Options:**
* `--password <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/<agent>` main.

View file

@ -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 <name>` makes it a site admin.
swarm-controller makes an agent's, and `swarmctl agent mint-forge-token
<agent>` 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 <agent>` 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 <agent>` shows the divergence between the agent's local
applied config checkout and its forge `agent-configs/<agent>` `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.

View file

@ -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 <COMMAND>`
###### **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] <NAME>`
###### **Arguments:**
* `<NAME>` — Forge username, the same as the user's SSO username
###### **Options:**
* `--controller-socket <PATH>` — 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.

View file

@ -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};

View file

@ -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 <pw>` 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<String> {
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<String> {
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<String> {
}
}
}
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"))?;

View file

@ -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(

View file

@ -233,9 +233,6 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> 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<HostResponse> {
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<HostResponse> {
crate::priv_client::write_agent_github_token(agent, token)
.await

View file

@ -315,17 +315,6 @@ pub enum HostRequest {
#[serde(default)]
room: Option<String>,
},
/// 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<String>,
},
/// Report the divergence between agent `agent`'s local applied config
/// checkout and its forge `agent-configs/<agent>` `main`. The daemon
/// fetches forge `main` read-only and returns a human-readable report

View file

@ -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 `<name>`.
///
/// 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 <agent>`).
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<String>,
/// 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/<agent>` main.
///

View file

@ -1,6 +1,5 @@
//! `hivectl forge create-user <name>` — 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 <agent>` — 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 <agent> [--from <forge|local>] [--verbose]`.
/// Always shows the divergence first (daemon computes it read-only), then

View file

@ -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,

View file

@ -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";

View file

@ -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

View file

@ -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<serde_json::Value> {
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<String> = seen
.lock()
.unwrap()
.iter()
.map(|(m, _, _)| m.clone())
.collect();
assert_eq!(methods, ["GET"]);
}
}

View file

@ -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.
@ -155,7 +153,7 @@ pub fn plan(observed: &[(String, Observed)]) -> Vec<String> {
}
/// 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 { .. })

View file

@ -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<Plan> {
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<bool>) -> 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);
}
}

View file

@ -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<Arc<auth::AuthBridge>>,
/// 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<Arc<str>>,
/// 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<bool, String> {
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<AppState>,
Path(name): Path<String>,
) -> Result<Json<MakeForgeAdminResponse>, 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<F, Fut>(
name: &str,
is_agent: Result<bool, String>,
promote: F,
) -> Result<Json<MakeForgeAdminResponse>, problem_details::ProblemDetails>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<forge::site_admin::Plan>>,
{
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<bool, String>,
plan: crate::forge::site_admin::Plan,
) -> (
Result<axum::Json<super::MakeForgeAdminResponse>, 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

View file

@ -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.

View file

@ -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<String> {
pub(crate) fn parse_ident(value: &str, what: &str) -> Result<String> {
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<CreateAgen
/// `what` names the request in error messages — the operator needs to know
/// which call failed, and every other part of this function is identical
/// between the two verbs.
async fn post<Req: Serialize, Resp: serde::de::DeserializeOwned>(
pub(crate) async fn post<Req: Serialize, Resp: serde::de::DeserializeOwned>(
socket: &Path,
uri: &str,
request: &Req,

66
swarmctl/src/forge.rs Normal file
View file

@ -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);
}
}
}

View file

@ -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<PathBuf>,
}
#[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<PathBuf>,
}
#[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";