From 4f9e9bd680162880f5eb50b2372ae588898b0a0f Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 15 Oct 2026 10:27:35 +0200 Subject: [PATCH] agent permissions: a destroyed agent's key goes with it Declaring an agent Destroyed now deletes its `agent-permissions` key, next to the queue-credential revoke and with the same failure handling: logged at error, the destroy goes ahead, and declaring it destroyed again re-runs it. Agent names are reusable after a destroy, and a create left on the default stores no key, so the old key handed a new agent of the same name the destroyed agent's grant (argus on #5293). Also: migrate.rs's create-time-setup note named ruth's tool-group seed in `ensure_root_agent`, which this PR deletes; it now says she holds the code-constant default when the swarm sets nothing. Part of #5170. --- hive-c0re/src/migrate.rs | 9 ++-- swarm-controller/src/agent_permissions.rs | 64 +++++++++++++++++++++++ swarm-controller/src/main.rs | 7 +-- 3 files changed, 73 insertions(+), 7 deletions(-) diff --git a/hive-c0re/src/migrate.rs b/hive-c0re/src/migrate.rs index b47171b4..a6f926fd 100644 --- a/hive-c0re/src/migrate.rs +++ b/hive-c0re/src/migrate.rs @@ -10,10 +10,11 @@ //! `h-root`); both targeted layouts no live hive still has. Add one //! only if it cannot be expressed as convergence, and expect to delete //! it once every hive has passed it. -//! - **Create-time setup.** Ruth's tool groups were backfilled here on -//! every boot; they are now seeded where she is created -//! (`workers::auto_update::ensure_root_agent`). A thing that is true -//! from birth does not need re-asserting each morning. +//! - **Create-time setup.** A thing that is true from birth is set where +//! the agent is created, not re-asserted each morning. Ruth's +//! permissions need neither: with no key in the swarm's +//! `agent-permissions` bucket she holds the code-constant default +//! (`Permission::default_for`). //! //! Kill-switch: `HIVE_SKIP_META_MIGRATION=1`. Full sequence and phase //! details: `docs/agent-lifecycle/config-changes.md::Startup migrations (older hosts)`. diff --git a/swarm-controller/src/agent_permissions.rs b/swarm-controller/src/agent_permissions.rs index 93586065..ba2d186f 100644 --- a/swarm-controller/src/agent_permissions.rs +++ b/swarm-controller/src/agent_permissions.rs @@ -206,6 +206,35 @@ pub(crate) async fn delete_agent_permissions( Ok(StatusCode::NO_CONTENT) } +/// Delete a destroyed agent's key. A name is free again once its agent is +/// destroyed, and a create left on the default stores nothing, so a key left +/// here would hand the next agent of that name this one's grant. +/// +/// Like the credential revoke it runs beside, a failure is logged and does +/// not stop the destroy; declaring the agent destroyed again re-runs it. +pub(crate) async fn clear_on_destroy(state: &AppState, hive: &str, agent: &str) { + let cleared = async { + let client = queue(state).map_err(|(_, why)| anyhow::anyhow!(why))?; + agent_permissions::open_or_create(&client) + .await? + .delete(agent) + .await?; + anyhow::Ok(()) + } + .await; + match cleared { + Ok(()) => tracing::info!(hive, agent, "destroyed agent's permissions cleared"), + Err(e) => tracing::error!( + hive, + agent, + error = %format!("{e:#}"), + "clearing this agent's permissions FAILED; it was destroyed anyway, so a new \ + agent of the same name created on the default gets its grant. Declaring the \ + agent destroyed again re-runs this." + ), + } +} + /// Every permission name, and the set an agent the swarm sets nothing for /// holds — so a client lists them without a copy of its own. #[derive(Debug, Serialize, ToSchema)] @@ -658,6 +687,41 @@ mod tests { assert_eq!(published(&client, "damocles").await, None); } + /// Destroying an agent deletes its key, so a new agent of the same name + /// created on the default gets the default, not the old grant. + #[tokio::test] + async fn a_recreated_agent_does_not_inherit_the_destroyed_ones_grant() { + let nats = TestNats::start(); + let client = nats.client().await; + let state = crate::agent_limits::tests::declared(&client).await; + let _ = put(&state, "atlas", granted()).await.expect("set"); + + let _ = crate::set_agent_state( + State(state.clone()), + Path(("pr1ma".to_owned(), "atlas".to_owned())), + axum::Json(crate::SetAgentStateRequest { + state: swarm_queue_client::wanted::AgentState::Destroyed, + }), + ) + .await + .expect("destroyed"); + let _ = crate::create_agent( + State(state.clone()), + axum::Json(crate::CreateAgentRequest { + name: "atlas".to_owned(), + hive: "pr1ma".to_owned(), + limits: None, + inference: None, + permissions: None, + }), + ) + .await + .expect("recreated"); + + assert_eq!(published(&client, "atlas").await, None); + assert_eq!(get(&state, "atlas").await, Permissions::default()); + } + #[tokio::test] async fn the_catalogue_lists_every_permission_and_the_default() { let json = serde_json::to_value(super::get_permission_catalogue().await.0).unwrap(); diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 714b0f82..78f1ed08 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -1410,11 +1410,12 @@ async fn set_agent_state( // After the declaration and never before it: the write above is the // destroy order, and until it lands the agent is not being torn down, so - // its credential has to keep working. Sequenced rather than spawned so - // the revocation attempt is finished — and its outcome logged — by the - // time the operator's call returns. + // its credential and permissions have to keep working. Sequenced rather + // than spawned so both attempts are finished — and their outcomes + // logged — by the time the operator's call returns. if revokes_queue_credential(req.state) { revoke_or_complain(&hive, &agent).await; + agent_permissions::clear_on_destroy(&state, &hive, &agent).await; } Ok(Json(render(&declaration))) }