Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/forge/site_admin.rs
atlas 6ba29c4d33 swarm-controller: re-read the forge token from the store when the forge rejects it
The controller read its forge token from the swarm secret store once, when
its client slot filled, and kept sending it until the daemon restarted. A
token the forge re-minted or revoked left every forge-backed path failing
with 401 until someone restarted the controller, and a restart drops the
runtime-only job graph.

Every forge request now goes through `forge::api::Api::call`. On a 401 it
asks a `StoredSecret` (the queue clients' debounced, single-flight re-read
with backoff) for a token to try instead, rebuilds the forgejo client with
it, and sends the request once more. A second 401, or a store still holding
the refused token, surfaces the error as before. Only 401 counts: unlike an
IdP, the forge answers 400 to a malformed request, and a re-read started by
one would put a real 401 that follows inside the backoff.

forgejo-api bakes the token into its own reqwest client's default headers
and takes no client or middleware from outside, so there is no single
request function to hook. Each `self.api.<endpoint>(..)` site becomes
`self.api.call(|api| api.<endpoint>(..).into_future())`; option structs a
site moved into the call are cloned, since the closure can run twice. The
`OnceLock<Arc<Client>>` slot and its `get()` callers are unchanged.

Refs #5017
2026-10-14 10:16:18 +02:00

128 lines
4.1 KiB
Rust

//! 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.call(|api| api.user_get(name).into_future()).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
.call(|api| api.admin_edit_user(name, admin_edit()).into_future())
.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);
}
}