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
This commit is contained in:
atlas 2026-09-24 23:59:13 +02:00 • committed by mara
commit ef494af188
10 changed files with 40 additions and 227 deletions

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