hyperhive/hive-c0re/src/forge/users.rs
atlas 20419ccd41 swarm-controller: own the swarm-wide forge objects; hive-c0re stops creating them
The orgs agent-configs/internal/agents (plus mirror owners), the
operators team in agents and agent-configs, the pull-mirrors,
internal/docs, internal/knowledge (public, README-seeded) and the
agent-configs org avatar are one set per forge. hive-c0re ensured them in
its boot sweep, as the core admin, and only on the hive co-located with
the forge container.

swarm-controller now reconciles them at start and every 5 minutes
(forge/objects.rs: observe -> pure plan -> apply). A failed object logs
a warn line plus a pass summary and is retried next tick. create_repo
ensures the agent-configs org and its operators team first, so a config
repo's merge gate never depends on the periodic pass having run.

hive-c0re drops ensure_org, SEEDED_ORGS, ensure_mirrors/ensure_mirror_repo,
ensure_operators_team, ensure_shared_docs_repo, ensure_knowledge_repo/
set_repo_public, seed_readme, ensure_config_org_avatar and the one-shot
knowledge::remove_webhook cleanup, with their now-unused helpers.

nix: the mirror list moves from the hive-c0re unit
(HYPERHIVE_FORGE_MIRRORS) to the swarm-controller unit
(SWARM_CONTROLLER_FORGE_MIRRORS), with an eval warning when mirrors are
declared on a host that runs no controller. c0re.orgAvatarPng is renamed
to deploy.swarm-controller.configOrgAvatarPng.

Refs #3782
2026-09-25 08:36:05 +02:00

579 lines
24 KiB
Rust

//! 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::{api, forge_admin};
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. Root-only. Literal lives
// in `crate::paths`; aliased here under the long-standing name.
use crate::paths::FORGE_CORE_TOKEN as CORE_TOKEN_PATH;
// Forge provisioning markers (`forge/core-avatar-set`,
// `forge/email-aligned-<name>`) 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.
// The avatar PNG path is resolved by `core_avatar_png_path` below —
// only-used-here, so it lives in this module rather than the shared
// `hive_sh4re::assets` helpers (which now hold only `prompt_template`, the
// one asset path every crate needs — the agent icon, the last other one, moved off the
// shared-asset model entirely; see `hive-agent::web_ui::screen`).
/// `$HIVE_ASSETS_DIR/branding/hyperhive.png` — the core mark,
/// rasterised. Not independently configurable — override the whole
/// `services.hyperhive.c0re.assets` package to change it.
fn core_avatar_png_path() -> std::path::PathBuf {
let dir = std::env::var("HIVE_ASSETS_DIR").expect(
"HIVE_ASSETS_DIR is unset — the hyperhive NixOS module sets it from \
services.hyperhive.c0re.assets on the hive-c0re unit, so this \
process was started outside that unit",
);
std::path::PathBuf::from(dir).join("branding/hyperhive.png")
}
/// 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.
/// 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";
/// Pull the access token out of forgejo's success message. Format
/// has shifted across versions (table form vs. "Access token was
/// successfully created: `<hex>`"), so just hunt the output for the
/// first long hex-looking word.
fn extract_token(output: &str) -> Option<String> {
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. 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,
"--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(())
}
}
}
}
/// 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/integrations/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`;
/// 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}-{}",
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?;
// Deliberately does NOT include `stdout`: on this path forgejo has
// already minted a live token and printed it, and an error string
// propagates into logs the same way any other message does. The
// shape of the output is enough to diagnose a version drift.
let token = extract_token(&stdout).with_context(|| {
format!(
"no token-shaped word (>= 32 hex chars) in forgejo output \
({} bytes, {} lines); output withheld, it carries the token",
stdout.len(),
stdout.lines().count()
)
})?;
tracing::debug!(%name, %token_name, "forge: minted access token");
Ok(token)
}
/// 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(())
}
/// 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 = core_avatar_png_path();
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(())
}
/// 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<String> {
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).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<String> {
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, is_forbidden};
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
);
}
/// The typed client only produces `ApiErrorKind::Forbidden` when the
/// endpoint's spec lists 403; every other endpoint surfaces the same
/// response as a bare `UnexpectedStatusCode`. Both shapes must be
/// recognised, because the arms guarded by this predicate are what tell
/// the operator that `ensure_repo_creation_disabled` — the lockdown
/// stopping an agent from creating and self-merging in its own repo —
/// did not apply, and which credential to re-mint to make it apply.
/// Drop the second arm as "redundant" and that lockdown fails with a
/// generic warning that names no remedy.
#[test]
fn a_403_is_recognised_in_both_shapes_the_client_can_produce() {
assert!(is_forbidden(&api_err(ApiErrorKind::Forbidden)));
assert!(is_forbidden(&ForgejoError::UnexpectedStatusCode(
StatusCode::FORBIDDEN
)));
}
/// The other direction, and the more expensive one to get wrong: this
/// predicate gates advice to **delete the core token and restart
/// hive-c0re**. A widened match would hand that advice out for a 502
/// from a restarting forge or a client-side error that never reached
/// the network — destroying a working credential in response to a blip.
/// 401 is called out separately because it is the near miss: it *is* a
/// credential problem, but its remedy is [`classify_core_token_error`]'s
/// automatic re-mint, so classifying it as forbidden buries the cause
/// under a scope warning that does not apply.
#[test]
fn a_non_403_failure_is_not_reported_as_a_missing_admin_scope() {
for kind in [
ApiErrorKind::Unauthorized,
ApiErrorKind::NotFound { errors: None },
ApiErrorKind::ValidationFailed,
ApiErrorKind::Generic,
] {
let e = api_err(kind);
assert!(!is_forbidden(&e), "{e} must not read as forbidden");
}
for s in [
StatusCode::UNAUTHORIZED,
StatusCode::NOT_FOUND,
StatusCode::BAD_GATEWAY,
StatusCode::INTERNAL_SERVER_ERROR,
] {
assert!(
!is_forbidden(&ForgejoError::UnexpectedStatusCode(s)),
"status {s} must not read as forbidden"
);
}
// A failure with no HTTP status at all — the request never got an
// answer, so nothing has been said about the token's scopes.
assert!(!is_forbidden(&ForgejoError::KeyNotAscii));
assert!(!is_forbidden(&ForgejoError::HostRequired));
}
}