//! Per-agent Forgejo user + access-token provisioning, account //! policy (email alignment, repo-creation lockdown), avatar uploads, //! and the bootstrap `core` admin user + token lifecycle. The typed //! API-client constructor + `forgejo admin` helpers live in the //! module root (`super`). use std::path::Path; use anyhow::{Context, Result}; use base64::Engine; use forgejo_api::structs::{EditUserOption, UpdateUserAvatarOption}; use forgejo_api::{ApiErrorKind, ForgejoError}; use reqwest::StatusCode; 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 to push the meta repo + drive admin API calls (org /// creation, future webhook setup, etc.). Root-only. const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token"; // Forge provisioning markers (`forge/core-avatar-set`, // `forge/agent-configs-avatar-set`, `forge/email-aligned-`) live // in `crate::paths` — one-shot guards: the upload/align runs once, the // marker is written, subsequent startups skip. Delete one to force its // step to re-run. // Avatar PNGs are loaded at runtime from // `$HIVE_ASSETS_DIR/branding/{hyperhive,agent-configs}.png` via the // helpers in `hive_sh4re::assets`. The `agent-configs.png` is // rendered from its SVG during the `hyperhive-assets` derivation's // build. /// Per-agent token scopes (broad-but-not-admin). See /// `docs/forge.md::Token scopes` for the per-scope rationale. 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. /// See `docs/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"; /// Pull the access token out of forgejo's success message. Format /// has shifted across versions (table form vs. "Access token was /// successfully created: "), so just hunt the output for the /// first long hex-looking word. fn extract_token(output: &str) -> Option { output .split(|c: char| c.is_whitespace() || c == ',' || c == ':') .find(|w| w.len() >= 32 && w.chars().all(|c| c.is_ascii_hexdigit())) .map(str::to_owned) } /// Canonical email address for a hive agent's Forgejo account. /// Must match the `user.email` set by `meta::render_flake` so commits /// by the agent link back to their Forgejo profile page. fn agent_email(name: &str) -> String { format!("{name}@hyperhive.local") } /// `EditUserOption` with every field unset except the ones Forgejo's /// validator effectively requires: `login_name` and `source_id = 0` /// (local auth, the default for users hive-c0re creates). Omitting /// `login_name` made Forgejo reset `use_custom_avatar` on every admin /// edit — the same reason the old raw JSON bodies always carried both /// fields. Callers set only the field(s) they mean to change on top. fn sparse_edit_user_option(name: &str) -> EditUserOption { EditUserOption { active: None, admin: None, 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: Some(name.to_owned()), max_repo_creation: None, must_change_password: None, password: None, prohibit_login: None, pronouns: None, restricted: None, source_id: Some(0), visibility: None, website: None, } } /// Whether a Forgejo API error is a definitive 403. The typed client /// maps a 403 response to `ApiErrorKind::Forbidden` when the endpoint /// spec lists it, or to `UnexpectedStatusCode(403)` otherwise — check /// both defensively. fn is_forbidden(e: &ForgejoError) -> bool { match e { ForgejoError::ApiError(api) => matches!(api.error_kind(), ApiErrorKind::Forbidden), ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::FORBIDDEN, _ => false, } } /// Ensure a forgejo user named `name` exists. Idempotent: forgejo /// returns a "user already exists" error which we treat as success. /// `admin` adds `--admin` (site admin) — used for the bootstrap /// `core` user that drives the API. `password` picks the initial /// account password: `None` uses `--random-password` (the existing /// agent provisioning shape — the password is never read, agents auth /// by token); `Some(pw)` uses `--password ` so the operator path /// in `hivectl` can set a real password for matrix-style web-UI login. async fn ensure_user_exists(name: &str, admin: bool, password: Option<&str>) -> Result<()> { 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"]), } if admin { args.push("--admin"); } let result = forge_admin(&args).await; match result { Ok(_) => { tracing::info!(%name, "forge: created user"); Ok(()) } Err(e) => { // Forgejo's "already exists" error wording varies; just // try the next step and let token issuance surface a // real failure if the user truly isn't there. let msg = format!("{e:#}"); if msg.contains("already exists") || msg.contains("user already") { tracing::debug!(%name, "forge: user already exists"); Ok(()) } else { tracing::warn!(%name, error = %msg, "forge: user create unclear; trying token anyway"); Ok(()) } } } } /// 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. /// Best-effort: failures are warned, not propagated. /// /// Marker-guarded: writes `EMAIL_ALIGNED_MARKER_PREFIX{name}` on first /// success and skips the PATCH on all subsequent calls. This prevents /// Forgejo's admin-user-edit endpoint from resetting `use_custom_avatar` /// on every `sync_agent` tick. Delete the marker to force re-alignment. /// /// Uses the admin REST API (`admin_edit_user`, i.e. `PATCH /// /api/v1/admin/users/{name}`) rather than `forgejo admin user edit` /// because the CLI dropped the `edit` subcommand somewhere between /// forgejo 8 and current. The edit body carries `login_name` + /// `source_id = 0` via [`sparse_edit_user_option`]. pub(super) async fn ensure_user_email(name: &str) { let marker = crate::paths::forge_email_aligned_marker(name); if marker.exists() { return; } let Some(token) = core_token() else { tracing::debug!(%name, "forge: skipping ensure_user_email — no core token yet"); return; }; let email = agent_email(name); let mut edit = sparse_edit_user_option(name); edit.email = Some(email.clone()); let client = match api(&token) { Ok(c) => c, Err(e) => { tracing::warn!(%name, error = %e, "forge: PATCH user email: client build failed"); return; } }; match client.admin_edit_user(name, edit).await { Ok(_) => { if let Some(parent) = marker.parent() { std::fs::create_dir_all(parent).ok(); } std::fs::write(&marker, "").ok(); tracing::info!(%name, %email, "forge: user email aligned"); } Err(e) if is_forbidden(&e) => { // Core token missing admin scope — see // `docs/forge.md::Token scopes` migration note. tracing::warn!( %name, %email, error = %e, "forge: PATCH user email forbidden — core token likely missing admin scope. \ Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes." ); } Err(e) => tracing::warn!(%name, %email, error = %e, "forge: PATCH user email failed"), } } /// Disable direct repo creation for agent `name` by setting /// `max_repo_creation = 0` on its Forgejo account. Agents must /// create repos *through hive-c0re* (which owns the perms), never with /// their own token — a write-scoped token can otherwise create + own /// repos and self-merge, bypassing the operator-only-merge policy. /// /// `max_repo_creation = 0` means `CanCreateRepo()` is false for any /// count (Forgejo: `MaxRepoCreation >= 0 && NumRepos >= MaxRepoCreation`), /// so creation is refused while push / PR / clone stay intact. **Existing /// repos are untouched** — this only blocks *new* direct creation. /// /// Marker-guarded like [`ensure_user_email`]: the edit runs once per /// agent (delete the marker to re-apply). The edit body carries /// `login_name` + `source_id` for the same reason `ensure_user_email` /// does — omitting `login_name` makes Forgejo's `EditUserOption` /// validator reset `use_custom_avatar`. Best-effort: failures warn, /// don't propagate. pub(super) async fn ensure_repo_creation_disabled(name: &str) { let marker = crate::paths::forge_repo_creation_disabled_marker(name); if marker.exists() { return; } let Some(token) = core_token() else { tracing::debug!(%name, "forge: skipping ensure_repo_creation_disabled — no core token yet"); return; }; let mut edit = sparse_edit_user_option(name); edit.max_repo_creation = Some(0); let client = match api(&token) { Ok(c) => c, Err(e) => { tracing::warn!(%name, error = %e, "forge: PATCH max_repo_creation: client build failed"); return; } }; match client.admin_edit_user(name, edit).await { Ok(_) => { if let Some(parent) = marker.parent() { std::fs::create_dir_all(parent).ok(); } std::fs::write(&marker, "").ok(); tracing::info!(%name, "forge: disabled direct repo creation (max_repo_creation=0)"); } Err(e) if is_forbidden(&e) => { tracing::warn!( %name, error = %e, "forge: PATCH max_repo_creation forbidden — core token likely missing admin scope. \ Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes." ); } Err(e) => { tracing::warn!(%name, error = %e, "forge: PATCH max_repo_creation failed"); } } } /// Mint a fresh access token for `name`. Token name is suffixed with /// 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 agents, `CORE_TOKEN_SCOPES` for the /// bootstrap `core` user. async fn mint_token(name: &str, scopes: &str) -> Result { let token_name = format!( "{TOKEN_NAME_PREFIX}-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |d| d.as_secs()) ); let stdout = forge_admin(&[ "user", "generate-access-token", "--username", name, "--token-name", &token_name, "--scopes", scopes, ]) .await?; let token = extract_token(&stdout) .with_context(|| format!("parse token from forgejo output: {stdout:?}"))?; tracing::debug!(%name, %token_name, "forge: minted access token"); Ok(token) } /// Mint a fresh Forgejo access token for an agent and write it to the /// agent's state dir via hive-priv. hive-c0re runs unprivileged and /// cannot write to agent-owned (0755) state directories directly. async fn mint_and_persist_agent_token(name: &str) -> Result<()> { let token = mint_token(name, TOKEN_SCOPES).await?; crate::priv_client::write_agent_forge_token(name, &token) .await .with_context(|| format!("write forge-token for {name} via hive-priv")) } /// Mint a fresh Forgejo access token for the `core` admin user and /// write it directly to `path`. Unlike agent tokens this path is owned /// by hive-c0re itself (under `/var/lib/hyperhive/`), so a direct /// write is both correct and necessary (no priv round-trip). async fn mint_and_persist_core_token(path: &Path) -> Result<()> { use std::os::unix::fs::PermissionsExt; let token = mint_token("core", CORE_TOKEN_SCOPES).await?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).ok(); } std::fs::write(path, format!("{token}\n")) .with_context(|| format!("write core token to {}", path.display()))?; let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); tracing::info!(path = %path.display(), "forge: persisted core access token"); Ok(()) } /// Ensure `name` has a forgejo user + token file. Always re-mints the /// token so the on-disk file always reflects the current `TOKEN_SCOPES`. /// Safe to call on every spawn and on every hive-c0re startup. pub async fn ensure_user_for(name: &str) -> Result<()> { if !is_present().await { return Ok(()); } ensure_user_exists(name, false, None).await?; ensure_user_email(name).await; mint_and_persist_agent_token(name).await } /// Provision a forgejo user for `name` and return the freshly-minted /// token. Unlike [`ensure_user_for`], the token is **not** persisted to /// disk — the caller is responsible for storing it. Used by `hivectl /// forge create-user` for human (non-agent) accounts so we don't create /// stray `/var/lib/hyperhive/agents//` directories for users that /// aren't agents. /// /// `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 /// with the default hash identicon. pub(super) async fn ensure_core_avatar(token: &str) -> Result<()> { let marker = crate::paths::forge_core_avatar_marker(); if marker.exists() { return Ok(()); } let png_path = hive_sh4re::assets::core_avatar_png(); let png_bytes = tokio::fs::read(&png_path) .await .with_context(|| format!("read core avatar PNG from {}", png_path.display()))?; // The raw-HTTP predecessor POSTed the admin endpoint // `/admin/users/core/avatar`, which forgejo-api has no method for. // `token` IS the core user's own token though, so updating "the // current user's avatar" (`POST /user/avatar`) is behaviorally // identical. api(token)? .user_update_avatar(UpdateUserAvatarOption { image: Some(base64::engine::general_purpose::STANDARD.encode(&png_bytes)), }) .await .context("set core avatar")?; if let Some(parent) = marker.parent() { std::fs::create_dir_all(parent).ok(); } std::fs::write(marker, "").ok(); tracing::info!("forge: set core user avatar to hyperhive logo"); Ok(()) } /// Set the `agent-configs` org's Forgejo avatar to the /// configs-stack glyph once. Sibling to `ensure_core_avatar`: /// one-shot, marker-guarded, best-effort. Uses `org_update_avatar` /// (`POST /api/v1/orgs/{org}/avatar`, base64-PNG payload). pub(super) async fn ensure_config_org_avatar(token: &str) -> Result<()> { let marker = crate::paths::forge_config_org_avatar_marker(); if marker.exists() { return Ok(()); } let png_path = hive_sh4re::assets::config_org_avatar_png(); let png_bytes = tokio::fs::read(&png_path) .await .with_context(|| format!("read {CONFIG_ORG} avatar PNG from {}", png_path.display()))?; api(token)? .org_update_avatar( CONFIG_ORG, UpdateUserAvatarOption { image: Some(base64::engine::general_purpose::STANDARD.encode(&png_bytes)), }, ) .await .with_context(|| format!("set {CONFIG_ORG} avatar"))?; if let Some(parent) = marker.parent() { std::fs::create_dir_all(parent).ok(); } std::fs::write(marker, "").ok(); tracing::info!( org = CONFIG_ORG, "forge: set org avatar to configs-stack logo" ); Ok(()) } /// Outcome of probing whether the persisted core token still works /// against the *current* forge. Existence on disk is not validity: a /// token minted before a forge rebuild / re-provision is unknown to the /// new forge's DB and 401s on every call — which silently breaks the /// hive-ci runner-registration prefetch (it reads this same token to /// fetch a runner registration token). #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CoreTokenCheck { /// Token authenticated successfully — keep using it. Valid, /// Forge explicitly rejected the token (401/403) — re-mint. Invalid, /// Couldn't determine (forge unreachable / 5xx). Don't re-mint on a /// transient: keep the existing token and let a later ensure pass /// re-check once the forge is responsive. Re-minting here would both /// fail (mint needs the forge too) and churn tokens needlessly. Indeterminate, } /// Map a failed token-probe call to a [`CoreTokenCheck`]. Only a /// definitive auth rejection (401/403 — surfaced by the typed client /// as `Unauthorized`/`Forbidden` API errors, or defensively as bare /// `UnexpectedStatusCode`s) is `Invalid`; anything else (transport, /// 5xx, unexpected shapes) is `Indeterminate`. Pure so the decision /// logic is unit-testable without a live forge. fn classify_core_token_error(e: &ForgejoError) -> CoreTokenCheck { match e { ForgejoError::ApiError(api) => match api.error_kind() { ApiErrorKind::Unauthorized | ApiErrorKind::Forbidden => CoreTokenCheck::Invalid, _ => CoreTokenCheck::Indeterminate, }, ForgejoError::UnexpectedStatusCode(s) if *s == StatusCode::UNAUTHORIZED || *s == StatusCode::FORBIDDEN => { CoreTokenCheck::Invalid } _ => CoreTokenCheck::Indeterminate, } } /// Probe whether `token` is still accepted by the current forge with a /// cheap authenticated `user_get_current` (`GET /api/v1/user`, covered /// by the core token's `read:user` scope). See [`CoreTokenCheck`] for /// how the outcome is interpreted. async fn check_core_token(token: &str) -> CoreTokenCheck { let Ok(client) = api(token) else { return CoreTokenCheck::Indeterminate; }; match client.user_get_current().await { Ok(_) => CoreTokenCheck::Valid, Err(e) => { let outcome = classify_core_token_error(&e); if outcome == CoreTokenCheck::Indeterminate { tracing::debug!( error = %e, "forge: core-token probe inconclusive (unreachable / unexpected response); \ treating as indeterminate" ); } outcome } } } /// Ensure the bootstrap `core` admin user + a token at /// `CORE_TOKEN_PATH`. The token is what hive-c0re uses for forgejo /// API calls (org creation, meta-repo push, and the hive-ci /// runner-registration prefetch). Returns the token. /// /// Idempotent, but validity-aware: when a token file is already present /// it is **probed against the current forge** before being trusted. A /// token persisted before a forge rebuild / re-provision is stale (the /// new forge DB doesn't know it) and would 401 every caller — so on a /// definitive rejection the token is re-minted. A merely-unreachable /// forge leaves the existing token in place (a later ensure pass /// re-checks) rather than churning tokens on a transient. pub(super) async fn ensure_core_user_and_token() -> Result { let path = std::path::Path::new(CORE_TOKEN_PATH); if let Ok(existing) = std::fs::read_to_string(path) { let trimmed = existing.trim().to_owned(); if !trimmed.is_empty() { match check_core_token(&trimmed).await { CoreTokenCheck::Valid | CoreTokenCheck::Indeterminate => return Ok(trimmed), CoreTokenCheck::Invalid => { tracing::warn!( path = %path.display(), "forge: persisted core token rejected by forge (stale after rebuild?); \ re-minting" ); } } } } 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"))?; Ok(raw.trim().to_owned()) } /// Read the persisted core token, or None when the forge isn't /// seeded yet. Cheap — just a file read. pub fn core_token() -> Option { std::fs::read_to_string(CORE_TOKEN_PATH) .ok() .map(|s| s.trim().to_owned()) .filter(|s| !s.is_empty()) } #[cfg(test)] mod tests { use super::{CoreTokenCheck, classify_core_token_error}; use forgejo_api::{ApiError, ApiErrorKind, ForgejoError}; use reqwest::StatusCode; fn api_err(kind: ApiErrorKind) -> ForgejoError { ForgejoError::ApiError(ApiError { message: None, kind, }) } #[test] fn auth_rejection_errors_are_invalid() { // The whole point: a stale token (forge rebuilt out from under it) // 401s, and 401/403 are the only outcomes that trigger a re-mint. assert_eq!( classify_core_token_error(&api_err(ApiErrorKind::Unauthorized)), CoreTokenCheck::Invalid ); assert_eq!( classify_core_token_error(&api_err(ApiErrorKind::Forbidden)), CoreTokenCheck::Invalid ); // Defensive: the same statuses arriving as bare status codes // (endpoint spec didn't list them) must classify identically. for s in [StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN] { assert_eq!( classify_core_token_error(&ForgejoError::UnexpectedStatusCode(s)), CoreTokenCheck::Invalid, "status {s} should be invalid" ); } } #[test] fn transient_and_unexpected_errors_are_indeterminate() { // Never re-mint on a transient — minting needs the forge too, and // churning tokens on a blip is worse than keeping the existing one. for s in [ StatusCode::INTERNAL_SERVER_ERROR, StatusCode::BAD_GATEWAY, StatusCode::SERVICE_UNAVAILABLE, StatusCode::GATEWAY_TIMEOUT, StatusCode::NOT_FOUND, ] { assert_eq!( classify_core_token_error(&ForgejoError::UnexpectedStatusCode(s)), CoreTokenCheck::Indeterminate, "status {s} should be indeterminate" ); } assert_eq!( classify_core_token_error(&api_err(ApiErrorKind::NotFound { errors: None })), CoreTokenCheck::Indeterminate ); assert_eq!( classify_core_token_error(&api_err(ApiErrorKind::Generic)), CoreTokenCheck::Indeterminate ); } }