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

@ -34,6 +34,7 @@ use utoipa::ToSchema;
use crate::webhook::DeliveryKind; use crate::webhook::DeliveryKind;
pub mod agent_token; pub mod agent_token;
pub mod site_admin;
/// An agent's open config-PR, as [`Client::list_open_config_prs`] reports it /// An agent's open config-PR, as [`Client::list_open_config_prs`] reports it
/// and `GET /api/agents/{name}/config-pr` serves it. /// and `GET /api/agents/{name}/config-pr` serves it.
@ -1659,4 +1660,78 @@ mod tests {
let body = lockdown_patch(&seen, "alice").expect("no lockdown PATCH sent"); let body = lockdown_patch(&seen, "alice").expect("no lockdown PATCH sent");
assert_eq!(body["max_repo_creation"], 0); assert_eq!(body["max_repo_creation"], 0);
} }
/// Every `PATCH` body the stub saw.
fn patches(seen: &Seen) -> Vec<serde_json::Value> {
seen.lock()
.unwrap()
.iter()
.filter(|(method, _, _)| method == "PATCH")
.map(|(_, _, body)| body.clone())
.collect()
}
/// Only `admin` goes out: a `source_id` would reset an SSO-made
/// account's login type, and the other fields would overwrite the user's
/// own settings.
#[tokio::test]
async fn make_site_admin_sets_admin_and_nothing_else() {
let (client, seen) = stub_forge(
(404, "{}"),
(200, r#"{"login":"mara","is_admin":false}"#),
(200, "{}"),
)
.await;
let plan = client.make_site_admin("mara").await.unwrap();
assert_eq!(plan, site_admin::Plan::Promote);
let bodies = patches(&seen);
assert_eq!(bodies.len(), 1, "{bodies:?}");
let set: Vec<(&String, &serde_json::Value)> = bodies[0]
.as_object()
.unwrap()
.iter()
.filter(|(_, v)| !v.is_null())
.collect();
assert_eq!(set, [(&"admin".to_owned(), &serde_json::Value::Bool(true))]);
}
#[tokio::test]
async fn make_site_admin_leaves_an_admin_alone() {
let (client, seen) = stub_forge(
(404, "{}"),
(200, r#"{"login":"mara","is_admin":true}"#),
(200, "{}"),
)
.await;
let plan = client.make_site_admin("mara").await.unwrap();
assert_eq!(plan, site_admin::Plan::AlreadyAdmin);
assert!(patches(&seen).is_empty());
}
/// A user who has not logged in yet is reported, not created: nothing is
/// sent but the read.
#[tokio::test]
async fn make_site_admin_does_not_create_a_missing_user() {
let (client, seen) = stub_forge(
(201, "{}"),
(404, r#"{"message":"user does not exist"}"#),
(200, "{}"),
)
.await;
let plan = client.make_site_admin("mara").await.unwrap();
assert_eq!(plan, site_admin::Plan::NoSuchUser);
let methods: Vec<String> = seen
.lock()
.unwrap()
.iter()
.map(|(m, _, _)| m.clone())
.collect();
assert_eq!(methods, ["GET"]);
}
} }

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. /// 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 { match e {
ForgejoError::ApiError(api) => { ForgejoError::ApiError(api) => {
matches!(api.error_kind(), ApiErrorKind::NotFound { .. }) 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);
}
}

View file

@ -525,6 +525,7 @@ fn socket_path() -> PathBuf {
(name = "agents", description = "creating agent identities at swarm level"), (name = "agents", description = "creating agent identities at swarm level"),
(name = "webhook", description = "swarm-wide forge webhook receipt"), (name = "webhook", description = "swarm-wide forge webhook receipt"),
(name = "repos", description = "forge repo browsing (swarm-ui's issue-report page)"), (name = "repos", description = "forge repo browsing (swarm-ui's issue-report page)"),
(name = "forge", description = "forge site admins (`swarmctl forge make-admin`)"),
) )
)] )]
struct ApiDoc; struct ApiDoc;
@ -599,7 +600,8 @@ struct AppState {
/// it only for a name that breaks a naming rule, to tell a new agent /// it only for a name that breaks a naming rule, to tell a new agent
/// from an existing one, and warns when it cannot (`name_verdict`). **GET /// from an existing one, and warns when it cannot (`name_verdict`). **GET
/// has nowhere to defer to** — there is no job, only an answer it /// has nowhere to defer to** — there is no job, only an answer it
/// either has or does not. /// either has or does not. `POST /api/forge/users/{name}/admin` reads
/// it for every name and refuses when it cannot (`admin_unless_agent`).
auth: Option<Arc<auth::AuthBridge>>, auth: Option<Arc<auth::AuthBridge>>,
/// HMAC secret for swarm-wide forge webhooks, loaded once at startup. /// HMAC secret for swarm-wide forge webhooks, loaded once at startup.
/// `None` when it could not be read or created — the webhook endpoint /// `None` when it could not be read or created — the webhook endpoint
@ -626,7 +628,7 @@ struct AppState {
swarm_name: Option<Arc<str>>, swarm_name: Option<Arc<str>>,
/// The forge client itself, for the read-only repo/issue-report /// The forge client itself, for the read-only repo/issue-report
/// routes (`GET /api/repos`, `GET /api/repos/{org}/{repo}/issue-report`) /// routes (`GET /api/repos`, `GET /api/repos/{org}/{repo}/issue-report`)
/// — distinct from `config_prs`, which holds a *cache* built off this /// and `POST /api/forge/users/{name}/admin` — distinct from `config_prs`, which holds a *cache* built off this
/// same client rather than the client. `None` under the same /// same client rather than the client. `None` under the same
/// "no forge configured on this host" shape every other /// "no forge configured on this host" shape every other
/// forge-backed field here uses. /// forge-backed field here uses.
@ -1370,8 +1372,9 @@ fn broken_name_rules(
} }
/// Whether `agent` is already in the swarm roster, or why that could not be /// Whether `agent` is already in the swarm roster, or why that could not be
/// told. Asked only of a name that breaks a rule, so an ordinary creation /// told. `create_agent` asks only of a name that breaks a rule, so an
/// still never waits on the bridge. /// ordinary creation still never waits on the bridge; `make_forge_admin`
/// asks of every name.
async fn in_roster(auth: Option<&auth::AuthBridge>, agent: &str) -> Result<bool, String> { async fn in_roster(auth: Option<&auth::AuthBridge>, agent: &str) -> Result<bool, String> {
let Some(auth) = auth else { let Some(auth) = auth else {
return Err("no identity bridge is configured".to_owned()); return Err("no identity bridge is configured".to_owned());
@ -1828,6 +1831,113 @@ async fn mint_agent_forge_token(
Ok(Json(MintAgentForgeTokenResponse { node_id: id.get() })) Ok(Json(MintAgentForgeTokenResponse { node_id: id.get() }))
} }
/// What `POST /api/forge/users/{name}/admin` found.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
enum ForgeAdminChange {
Promoted,
AlreadyAdmin,
}
/// Success body of `POST /api/forge/users/{name}/admin`.
#[derive(Serialize, Deserialize, ToSchema)]
struct MakeForgeAdminResponse {
change: ForgeAdminChange,
}
/// Make an existing human forge account a site admin. Idempotent.
///
/// Never creates the account: a human's is made by their first SSO login to
/// the forge, and until then this answers 404. Refuses an agent's name: a
/// site admin can create repos whatever its `max_repo_creation`, and an
/// agent's account is locked to 0 so its token cannot create a repo and
/// self-merge in it.
#[utoipa::path(
post,
path = "/api/forge/users/{name}/admin",
params(("name" = String, Path, description = "forge username")),
responses(
(status = 200, description = "the account is a site admin", body = MakeForgeAdminResponse),
(status = 400, description = "`name` is not a valid identifier (problem+json)", body = String),
(status = 404, description = "no such account: the user has not logged in via SSO yet (problem+json)", body = String),
(status = 409, description = "`name` is an agent (problem+json)", body = String),
(status = 500, description = "the forge refused the read or the edit (problem+json)", body = String),
(status = 503, description = "no forge configured, or the agent roster could not be read (problem+json)", body = String),
),
tag = "forge"
)]
async fn make_forge_admin(
State(state): State<AppState>,
Path(name): Path<String>,
) -> Result<Json<MakeForgeAdminResponse>, problem_details::ProblemDetails> {
let name = hive_types::Ident::parse(&name)
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
.into_string();
let Some(forge) = state.forge.clone() else {
return Err(error_problem(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"no forge is configured on this host",
));
};
let is_agent = in_roster(state.auth.as_deref(), &name).await;
admin_unless_agent(&name, is_agent, || async {
forge.make_site_admin(&name).await
})
.await
}
/// Run `promote` only if `name` is known not to be an agent, and answer with
/// what it found.
///
/// An unreadable roster refuses too: a site admin is the one thing an agent's
/// account must never become, so "could not tell" is not "no".
async fn admin_unless_agent<F, Fut>(
name: &str,
is_agent: Result<bool, String>,
promote: F,
) -> Result<Json<MakeForgeAdminResponse>, problem_details::ProblemDetails>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<forge::site_admin::Plan>>,
{
match is_agent {
Ok(false) => {}
Ok(true) => {
return Err(error_problem(
axum::http::StatusCode::CONFLICT,
&format!("{name:?} is an agent; an agent's forge account is never a site admin"),
));
}
Err(e) => {
return Err(error_problem(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
&format!("cannot tell whether {name:?} is an agent, so refusing: {e}"),
));
}
}
let plan = promote().await.map_err(|e| {
error_problem(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
&format!("{e:#}"),
)
})?;
let change = match plan {
forge::site_admin::Plan::NoSuchUser => {
return Err(error_problem(
axum::http::StatusCode::NOT_FOUND,
&format!(
"the forge has no user {name:?}: the user has not logged in via SSO yet. \
The forge creates the account on their first authelia login; run this \
again after it"
),
));
}
forge::site_admin::Plan::AlreadyAdmin => ForgeAdminChange::AlreadyAdmin,
forge::site_admin::Plan::Promote => ForgeAdminChange::Promoted,
};
Ok(Json(MakeForgeAdminResponse { change }))
}
/// Every agent with an open config PR, in one response — the bulk /// Every agent with an open config PR, in one response — the bulk
/// counterpart to [`get_agent_config_pr`]. swarm-ui's config-PR table needs /// counterpart to [`get_agent_config_pr`]. swarm-ui's config-PR table needs
/// every agent's status to render, and fetching them one at a time doesn't /// every agent's status to render, and fetching them one at a time doesn't
@ -2175,6 +2285,7 @@ fn build_app(state: AppState) -> axum::Router {
.routes(routes!(create_agent)) .routes(routes!(create_agent))
.routes(routes!(mint_agent_identity)) .routes(routes!(mint_agent_identity))
.routes(routes!(mint_agent_forge_token)) .routes(routes!(mint_agent_forge_token))
.routes(routes!(make_forge_admin))
.routes(routes!(get_agents)) .routes(routes!(get_agents))
.routes(routes!(get_agents_status)) .routes(routes!(get_agents_status))
.routes(routes!(set_agent_state)) .routes(routes!(set_agent_state))
@ -2516,6 +2627,94 @@ mod tests {
assert!(warnings[0].contains("bridge down"), "{warnings:?}"); assert!(warnings[0].contains("bridge down"), "{warnings:?}");
} }
/// `admin_unless_agent` with a `promote` that records whether it ran and
/// answers `plan`.
async fn forge_admin_route(
is_agent: Result<bool, String>,
plan: crate::forge::site_admin::Plan,
) -> (
Result<axum::Json<super::MakeForgeAdminResponse>, problem_details::ProblemDetails>,
bool,
) {
let ran = std::sync::atomic::AtomicBool::new(false);
let result = super::admin_unless_agent("atlas", is_agent, || async {
ran.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(plan)
})
.await;
(result, ran.into_inner())
}
/// An agent's name never reaches the forge.
#[tokio::test]
async fn the_forge_admin_route_refuses_an_agent() {
let (result, ran) =
forge_admin_route(Ok(true), crate::forge::site_admin::Plan::Promote).await;
let Err(problem) = result else {
panic!("an agent's name must be refused");
};
assert!(!ran, "the account must not be touched");
assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT));
}
/// A roster that can't be read can't vouch for the name, so the route
/// refuses rather than guessing "not an agent".
#[tokio::test]
async fn the_forge_admin_route_refuses_on_an_unreadable_roster() {
let (result, ran) = forge_admin_route(
Err("bridge down".to_owned()),
crate::forge::site_admin::Plan::Promote,
)
.await;
let Err(problem) = result else {
panic!("an unreadable roster must refuse");
};
assert!(!ran, "the account must not be touched");
assert_eq!(
problem.status,
Some(axum::http::StatusCode::SERVICE_UNAVAILABLE)
);
}
/// A missing account is a 404 that tells the operator what to wait for.
#[tokio::test]
async fn the_forge_admin_route_names_the_missing_sso_login() {
let (result, ran) =
forge_admin_route(Ok(false), crate::forge::site_admin::Plan::NoSuchUser).await;
let Err(problem) = result else {
panic!("a missing account must not report success");
};
assert!(ran);
assert_eq!(problem.status, Some(axum::http::StatusCode::NOT_FOUND));
let rendered = format!("{problem:?}");
assert!(
rendered.contains("has not logged in via SSO yet"),
"{rendered}"
);
}
#[tokio::test]
async fn the_forge_admin_route_promotes_a_non_agent() {
let (result, ran) =
forge_admin_route(Ok(false), crate::forge::site_admin::Plan::Promote).await;
let Ok(axum::Json(resp)) = result else {
panic!("a non-agent must be promoted");
};
assert!(ran);
assert_eq!(resp.change, super::ForgeAdminChange::Promoted);
}
/// Idempotent: a second run succeeds and says nothing changed.
#[tokio::test]
async fn the_forge_admin_route_succeeds_for_an_admin() {
let (result, _) =
forge_admin_route(Ok(false), crate::forge::site_admin::Plan::AlreadyAdmin).await;
let Ok(axum::Json(resp)) = result else {
panic!("an existing admin must succeed");
};
assert_eq!(resp.change, super::ForgeAdminChange::AlreadyAdmin);
}
/// The backfill route's roster check, asserted by effect for the same /// The backfill route's roster check, asserted by effect for the same
/// reason its sibling above is: a refusal that queued first would still /// reason its sibling above is: a refusal that queued first would still
/// re-mint the agent's certificate, which every running agent on the /// re-mint the agent's certificate, which every running agent on the