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:
parent
ef494af188
commit
f1f59ea165
4 changed files with 407 additions and 5 deletions
|
|
@ -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 { .. })
|
||||
|
|
|
|||
128
swarm-controller/src/forge/site_admin.rs
Normal file
128
swarm-controller/src/forge/site_admin.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue