//! 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 { 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) -> 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); } }