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
128 lines
4.1 KiB
Rust
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);
|
|
}
|
|
}
|