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
|
|
@ -525,6 +525,7 @@ fn socket_path() -> PathBuf {
|
|||
(name = "agents", description = "creating agent identities at swarm level"),
|
||||
(name = "webhook", description = "swarm-wide forge webhook receipt"),
|
||||
(name = "repos", description = "forge repo browsing (swarm-ui's issue-report page)"),
|
||||
(name = "forge", description = "forge site admins (`swarmctl forge make-admin`)"),
|
||||
)
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
|
@ -599,7 +600,8 @@ struct AppState {
|
|||
/// 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
|
||||
/// 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>>,
|
||||
/// HMAC secret for swarm-wide forge webhooks, loaded once at startup.
|
||||
/// `None` when it could not be read or created — the webhook endpoint
|
||||
|
|
@ -626,7 +628,7 @@ struct AppState {
|
|||
swarm_name: Option<Arc<str>>,
|
||||
/// The forge client itself, for the read-only 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
|
||||
/// "no forge configured on this host" shape every other
|
||||
/// 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
|
||||
/// told. Asked only of a name that breaks a rule, so an ordinary creation
|
||||
/// still never waits on the bridge.
|
||||
/// told. `create_agent` asks only of a name that breaks a rule, so an
|
||||
/// 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> {
|
||||
let Some(auth) = auth else {
|
||||
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() }))
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
|
|
@ -2175,6 +2285,7 @@ fn build_app(state: AppState) -> axum::Router {
|
|||
.routes(routes!(create_agent))
|
||||
.routes(routes!(mint_agent_identity))
|
||||
.routes(routes!(mint_agent_forge_token))
|
||||
.routes(routes!(make_forge_admin))
|
||||
.routes(routes!(get_agents))
|
||||
.routes(routes!(get_agents_status))
|
||||
.routes(routes!(set_agent_state))
|
||||
|
|
@ -2516,6 +2627,94 @@ mod tests {
|
|||
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
|
||||
/// reason its sibling above is: a refusal that queued first would still
|
||||
/// re-mint the agent's certificate, which every running agent on the
|
||||
|
|
|
|||
Loading…
Reference in a new issue