Watch
0
0
Fork
You've already forked hyperhive
0

swarm-controller: make an existing forge user a site admin

POST /api/forge/users/{name}/admin reads the account and, when it is not
already a site admin, sets `admin` with admin_edit_user. It never
creates one: a human's account is made by their first authelia login,
so a missing one answers 404, saying the user has not logged in via SSO
yet. An existing admin is a success with nothing sent.

The edit carries `admin` alone. repo_creation_lockdown's login_name +
source_id = 0 would turn an SSO-made account into a local one: in
Forgejo 16 a source_id sets the login type.

An agent's name is refused, and so is any name when the roster can't be
read: a site admin ignores max_repo_creation, the lockdown that keeps an
agent's token from creating a repo and self-merging in it.

Refs #3782
This commit is contained in:
atlas 2026-09-25 00:04:44 +02:00 • committed by mara
commit f1f59ea165
4 changed files with 407 additions and 5 deletions

View file

@ -153,7 +153,7 @@ pub fn plan(observed: &[(String, Observed)]) -> Vec<String> {
}
/// Whether a forge error is a 404, whichever of its two shapes it came in.
fn is_not_found(e: &ForgejoError) -> bool {
pub(super) fn is_not_found(e: &ForgejoError) -> bool {
match e {
ForgejoError::ApiError(api) => {
matches!(api.error_kind(), ApiErrorKind::NotFound { .. })

View file

@ -0,0 +1,128 @@
//! 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.user_get(name).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
.admin_edit_user(name, admin_edit())
.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);
}
}