Watch
0
0
Fork
You've already forked hyperhive
0

swarm UI: delete linked accounts

Each row of an agent's linked accounts, except its own `main` matrix
account, gets a delete action. swarm-controller serves DELETE beside each
PUT (matrix-accounts/{account}, forge-accounts/{label}, github-account),
answers 404 for an account the store does not hold, refuses `main`, and
removes every version through `delete_all_versions`.

The matrix confirmation has a revoke checkbox, off by default: the
controller logs the stored token out at its homeserver first, and keeps
the account when that fails or no homeserver is stored.

The controller's policy gains `delete` on each agent's
`metadata/.../matrix/+`, `forge/+` and `github-token`, pinned in
bao-grants.nix.

Refs #4855
This commit is contained in:
atlas 2026-10-02 23:31:39 +02:00
commit bbf931207f
7 changed files with 705 additions and 35 deletions

View file

@ -1,5 +1,5 @@
//! The accounts linked to one agent, as kind, name and host: what the swarm
//! UI's agent panel lists.
//! UI's agent panel lists, and deletes.
//!
//! Read from the paths [`crate::matrix_account`], [`crate::forge_account`] and
//! [`crate::github_account`] write, plus the agent's own `main` matrix account,
@ -8,18 +8,23 @@
//! [`LinkedAccount`] has no field a credential could land in.
//!
//! Naming the matrix accounts and forge labels takes `list` on each agent's
//! `matrix` and `forge` metadata directories, which `controllerPolicyText` in
//! `nix/host-modules/swarm-bao.nix` grants on those two directories alone.
//! `matrix` and `forge` metadata directories, and deleting an account takes
//! `delete` on its metadata path. `controllerPolicyText` in
//! `nix/host-modules/swarm-bao.nix` grants both on those paths alone.
//!
//! A delete removes the store entry only, and the agent is not told. Its
//! GitHub and forge token files stay in place, since the units that write them
//! never delete one; its matrix daemon drops the account when it next re-lists.
use std::future::Future;
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use swarm_secret_client::{Error, SecretStore, forge, github, matrix};
use utoipa::ToSchema;
use utoipa::{IntoParams, ToSchema};
use super::{AppState, error_problem, swarm_hive};
@ -52,7 +57,8 @@ pub struct LinkedAccount {
reserved: bool,
}
/// The store reads a listing makes, so a test can stand in for the store.
/// The store calls a listing or a delete makes, so a test can stand in for the
/// store.
pub(crate) trait AccountStore {
/// As [`SecretStore::list`].
fn list(&self, dir: &str) -> impl Future<Output = Result<Vec<String>, Error>> + Send;
@ -62,6 +68,9 @@ pub(crate) trait AccountStore {
&self,
path: &str,
) -> impl Future<Output = Result<Option<T>, Error>> + Send;
/// As [`SecretStore::delete_all_versions`].
fn delete_all_versions(&self, path: &str) -> impl Future<Output = Result<(), Error>> + Send;
}
impl AccountStore for SecretStore {
@ -75,6 +84,10 @@ impl AccountStore for SecretStore {
) -> Result<Option<T>, Error> {
SecretStore::read_optional(self, path).await
}
async fn delete_all_versions(&self, path: &str) -> Result<(), Error> {
SecretStore::delete_all_versions(self, path).await
}
}
/// The object names directly under `dir`. A key ending in `/` is a directory
@ -176,23 +189,270 @@ pub async fn get_linked_accounts(
Ok(Json(accounts))
}
/// Why an account was not deleted.
#[derive(Debug)]
pub(crate) enum Removal {
/// Nothing is stored at the account's path.
NotFound,
/// Revoking was asked for, and the account has no homeserver to revoke at.
NoHomeserver,
/// The homeserver did not revoke the token.
Revoke(String),
/// The store refused or could not be reached.
Store(Error),
}
impl Removal {
fn problem(&self) -> problem_details::ProblemDetails {
match self {
Self::NotFound => error_problem(StatusCode::NOT_FOUND, "no such account is stored"),
Self::NoHomeserver => error_problem(
StatusCode::CONFLICT,
"no homeserver is stored for this account, so its token cannot be revoked; \
the account was not deleted",
),
Self::Revoke(e) => error_problem(
StatusCode::BAD_GATEWAY,
&format!(
"the homeserver did not revoke the token, so the account was not deleted: {e}"
),
),
Self::Store(e) => error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
}
/// The object at `path`, or [`Removal::NotFound`] when nothing is stored there.
async fn stored<T: DeserializeOwned + Send>(
store: &impl AccountStore,
path: &str,
) -> Result<T, Removal> {
store
.read_optional(path)
.await
.map_err(Removal::Store)?
.ok_or(Removal::NotFound)
}
/// Delete every version of `path`.
async fn remove(store: &impl AccountStore, path: &str) -> Result<(), Removal> {
store
.delete_all_versions(path)
.await
.map_err(Removal::Store)
}
/// Delete the matrix account at `path`. With `revoke`, its token is first
/// handed to `revoke` with the stored homeserver, and the entry stays when that
/// fails.
async fn remove_matrix<F, Fut>(
store: &impl AccountStore,
path: &str,
revoke: Option<F>,
) -> Result<(), Removal>
where
F: FnOnce(String, String) -> Fut,
Fut: Future<Output = Result<(), String>>,
{
let credential: matrix::Credential = stored(store, path).await?;
if let Some(revoke) = revoke {
let homeserver = credential.homeserver.ok_or(Removal::NoHomeserver)?;
revoke(homeserver, credential.value)
.await
.map_err(Removal::Revoke)?;
}
remove(store, path).await
}
/// Delete the forge account or GitHub token at `path`.
async fn remove_plain<T: DeserializeOwned + Send>(
store: &impl AccountStore,
path: &str,
) -> Result<(), Removal> {
stored::<T>(store, path).await?;
remove(store, path).await
}
/// `delete_matrix_account`'s query.
#[derive(Deserialize, IntoParams)]
pub struct DeleteMatrixAccountQuery {
/// Log the stored token out at its homeserver before deleting. When that
/// fails, the account is not deleted.
#[serde(default)]
revoke: bool,
}
/// Delete an agent's external matrix account from the store, every version of
/// it.
#[utoipa::path(
delete,
path = "/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}",
params(
("hive" = String, Path, description = "hive the agent runs on"),
("agent" = String, Path, description = "agent the account belongs to"),
("account" = String, Path, description = "the account to delete"),
DeleteMatrixAccountQuery,
),
responses(
(status = 204, description = "deleted"),
(status = 400, description = "a name is not an identifier, the account name is not a single path segment, the account is 'main' (reserved), or the hive is not in this swarm (problem+json)", body = String),
(status = 404, description = "no such account is stored (problem+json)", body = String),
(status = 409, description = "revoke was asked for and the account has no homeserver stored; not deleted (problem+json)", body = String),
(status = 502, description = "revoke was asked for and the homeserver did not revoke the token; not deleted (problem+json)", body = String),
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
),
tag = "agents"
)]
pub async fn delete_matrix_account(
State(state): State<AppState>,
axum::extract::Path((hive, agent, account)): axum::extract::Path<(String, String, String)>,
axum::extract::Query(query): axum::extract::Query<DeleteMatrixAccountQuery>,
) -> Result<StatusCode, problem_details::ProblemDetails> {
let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?;
let agent = hive_types::Ident::parse(&agent)
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
.into_string();
let path = matrix::account_path(&agent, &account)
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
// `agent_token` re-mints `main` on its next reconcile, so deleting it
// would only rotate the agent's own token.
if crate::matrix_account::is_reserved_account(&account) {
return Err(error_problem(
StatusCode::BAD_REQUEST,
"'main' is the agent's own account, which the swarm mints — it cannot be deleted.",
));
}
let store = crate::store::connect().await.map_err(|e| {
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
})?;
let revoke = query
.revoke
.then_some(|homeserver: String, token: String| async move {
crate::matrix_account::matrix_logout(&homeserver, &token).await
});
remove_matrix(&store, &path, revoke).await.map_err(|e| {
tracing::warn!(%hive, %agent, %account, error = ?e, "deleting the matrix account failed");
e.problem()
})?;
tracing::info!(%hive, %agent, %account, revoked = query.revoke, "matrix account deleted");
Ok(StatusCode::NO_CONTENT)
}
/// Delete an agent's external forge account from the store, every version of
/// it.
#[utoipa::path(
delete,
path = "/api/hives/{hive}/agents/{agent}/forge-accounts/{label}",
params(
("hive" = String, Path, description = "hive the agent runs on"),
("agent" = String, Path, description = "agent the account belongs to"),
("label" = String, Path, description = "the account's label"),
),
responses(
(status = 204, description = "deleted"),
(status = 400, description = "the agent or label is not an identifier, or the hive is not in this swarm (problem+json)", body = String),
(status = 404, description = "no such account is stored (problem+json)", body = String),
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
),
tag = "agents"
)]
pub async fn delete_forge_account(
State(state): State<AppState>,
axum::extract::Path((hive, agent, label)): axum::extract::Path<(String, String, String)>,
) -> Result<StatusCode, problem_details::ProblemDetails> {
let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?;
let agent = hive_types::Ident::parse(&agent)
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
.into_string();
let label = hive_types::Ident::parse(&label)
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
.into_string();
let path = forge::account_path(&agent, &label)
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
let store = crate::store::connect().await.map_err(|e| {
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
})?;
remove_plain::<forge::Account>(&store, &path)
.await
.map_err(|e| {
tracing::warn!(%hive, %agent, %label, error = ?e, "deleting the forge account failed");
e.problem()
})?;
tracing::info!(%hive, %agent, %label, "forge account deleted");
Ok(StatusCode::NO_CONTENT)
}
/// Delete an agent's GitHub token from the store, every version of it.
#[utoipa::path(
delete,
path = "/api/hives/{hive}/agents/{agent}/github-account",
params(
("hive" = String, Path, description = "hive the agent runs on"),
("agent" = String, Path, description = "agent the token belongs to"),
),
responses(
(status = 204, description = "deleted"),
(status = 400, description = "the agent is not an identifier, or the hive is not in this swarm (problem+json)", body = String),
(status = 404, description = "no token is stored (problem+json)", body = String),
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
),
tag = "agents"
)]
pub async fn delete_github_account(
State(state): State<AppState>,
axum::extract::Path((hive, agent)): axum::extract::Path<(String, String)>,
) -> Result<StatusCode, problem_details::ProblemDetails> {
let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?;
let agent = hive_types::Ident::parse(&agent)
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
.into_string();
let path = github::account_path(&agent)
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
let store = crate::store::connect().await.map_err(|e| {
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
})?;
remove_plain::<github::Credential>(&store, &path)
.await
.map_err(|e| {
tracing::warn!(%hive, %agent, error = ?e, "deleting the github token failed");
e.problem()
})?;
tracing::info!(%hive, %agent, "github token deleted");
Ok(StatusCode::NO_CONTENT)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::sync::Mutex;
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use swarm_secret_client::Error;
use super::{AccountKind, AccountStore, LinkedAccount, linked_accounts};
use super::{
AccountKind, AccountStore, LinkedAccount, Removal, linked_accounts, remove_matrix,
remove_plain,
};
use swarm_secret_client::{forge, github};
/// Objects by path. A list answers the next segment of every path under
/// the directory, with a trailing `/` when it goes deeper, as the store
/// does.
/// does. A delete is recorded in `deleted` and leaves `objects` as it is.
#[derive(Default)]
struct FakeStore {
objects: BTreeMap<String, Value>,
denied: bool,
deleted: Mutex<Vec<String>>,
}
impl FakeStore {
@ -233,6 +493,17 @@ mod tests {
.get(path)
.map(|v| serde_json::from_value(v.clone()).expect("fixture decodes")))
}
async fn delete_all_versions(&self, path: &str) -> Result<(), Error> {
if self.denied {
return Err(Error::MissingEnv("BAO_ADDR"));
}
self.deleted
.lock()
.expect("not poisoned")
.push(path.to_owned());
Ok(())
}
}
fn row(kind: AccountKind, name: &str, host: Option<&str>, reserved: bool) -> LinkedAccount {
@ -350,4 +621,199 @@ mod tests {
};
assert!(linked_accounts(&store, "atlas").await.is_err());
}
/// The `revoke` a delete without the checkbox passes.
type NoRevoke = fn(String, String) -> std::future::Ready<Result<(), String>>;
fn deleted(store: &FakeStore) -> Vec<String> {
store.deleted.lock().expect("not poisoned").clone()
}
#[tokio::test]
async fn each_kind_deletes_the_stored_account() {
let store = every_kind();
remove_matrix(
&store,
"swarm/agents/atlas/matrix/catgirl",
None::<NoRevoke>,
)
.await
.expect("stored");
remove_plain::<forge::Account>(&store, "swarm/agents/atlas/forge/codeberg")
.await
.expect("stored");
remove_plain::<github::Credential>(&store, "swarm/agents/atlas/github-token")
.await
.expect("stored");
assert_eq!(
deleted(&store),
[
"swarm/agents/atlas/matrix/catgirl",
"swarm/agents/atlas/forge/codeberg",
"swarm/agents/atlas/github-token",
]
);
}
#[tokio::test]
async fn an_unknown_account_is_a_404_and_nothing_is_deleted() {
let store = FakeStore::default();
let results = [
remove_matrix(
&store,
"swarm/agents/atlas/matrix/catgirl",
None::<NoRevoke>,
)
.await,
remove_plain::<forge::Account>(&store, "swarm/agents/atlas/forge/codeberg").await,
remove_plain::<github::Credential>(&store, "swarm/agents/atlas/github-token").await,
];
for result in results {
let removal = result.expect_err("nothing is stored");
assert!(matches!(removal, Removal::NotFound), "{removal:?}");
assert_eq!(
removal.problem().status,
Some(axum::http::StatusCode::NOT_FOUND)
);
}
assert!(deleted(&store).is_empty());
}
#[tokio::test]
async fn revoking_logs_out_the_stored_token_at_its_homeserver_then_deletes() {
let store = every_kind();
let asked = Mutex::new(None);
remove_matrix(
&store,
"swarm/agents/atlas/matrix/catgirl",
Some(|homeserver: String, token: String| {
*asked.lock().expect("not poisoned") = Some((homeserver, token));
std::future::ready(Ok(()))
}),
)
.await
.expect("revoked");
assert_eq!(
asked.into_inner().expect("not poisoned"),
Some((
"https://matrix.example.org".to_owned(),
"t0k3n-cat".to_owned()
))
);
assert_eq!(deleted(&store), ["swarm/agents/atlas/matrix/catgirl"]);
}
#[tokio::test]
async fn a_failed_revoke_keeps_the_account_and_says_so() {
let store = every_kind();
let removal = remove_matrix(
&store,
"swarm/agents/atlas/matrix/catgirl",
Some(|_: String, _: String| {
std::future::ready(Err("/logout HTTP 502 Bad Gateway: down".to_owned()))
}),
)
.await
.expect_err("the homeserver refused");
assert!(deleted(&store).is_empty());
let problem = removal.problem();
assert_eq!(problem.status, Some(axum::http::StatusCode::BAD_GATEWAY));
let detail = problem.detail.expect("a detail");
assert!(detail.contains("not deleted"), "{detail}");
assert!(detail.contains("down"), "{detail}");
}
#[tokio::test]
async fn revoking_an_account_with_no_homeserver_keeps_it() {
let store = every_kind();
let removal = remove_matrix(
&store,
"swarm/agents/atlas/matrix/old",
Some(
|_: String, _: String| -> std::future::Ready<Result<(), String>> {
panic!("there is no homeserver to call")
},
),
)
.await
.expect_err("no homeserver");
assert!(matches!(removal, Removal::NoHomeserver), "{removal:?}");
assert!(deleted(&store).is_empty());
}
#[tokio::test]
async fn a_store_that_refuses_a_delete_is_an_error() {
let store = FakeStore {
denied: true,
..every_kind()
};
let removal = remove_plain::<github::Credential>(&store, "swarm/agents/atlas/github-token")
.await
.expect_err("denied");
assert!(matches!(removal, Removal::Store(_)), "{removal:?}");
}
/// Bare-minimum `AppState`, as `matrix_account`'s tests build it.
fn state() -> super::super::AppState {
super::super::AppState {
hives: std::sync::Arc::new(vec![super::super::HiveEntry {
name: "pr1ma".to_owned(),
domain: "pr1ma.example".to_owned(),
}]),
links: std::sync::Arc::new(Vec::new()),
status: None,
wanted: None,
agent_status: None,
agent_icons: None,
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),
))),
webhook_secret: None,
config_prs: None,
swarm_name: None,
auth: None,
forge: None,
create_gate: std::sync::Arc::default(),
}
}
async fn delete_matrix(account: &str) -> problem_details::ProblemDetails {
super::delete_matrix_account(
axum::extract::State(state()),
axum::extract::Path(("pr1ma".to_owned(), "atlas".to_owned(), account.to_owned())),
axum::extract::Query(super::DeleteMatrixAccountQuery { revoke: true }),
)
.await
.expect_err("no store is configured in a test")
}
/// Refused before the store, and before any revoke: with `BAO_*` unset a
/// store connect would answer 500.
#[tokio::test]
async fn the_agents_own_matrix_account_cannot_be_deleted() {
for var in ["BAO_ADDR", "BAO_CLIENT_CERT", "BAO_CLIENT_KEY"] {
assert!(
std::env::var(var).is_err(),
"{var} must be unset for this test to prove anything"
);
}
let problem = delete_matrix("main").await;
assert_eq!(
problem.status,
Some(axum::http::StatusCode::BAD_REQUEST),
"{problem:?}"
);
}
/// The control: any other account name reaches the store connect.
#[tokio::test]
async fn another_matrix_account_reaches_the_store() {
let problem = delete_matrix("catgirl").await;
assert_eq!(
problem.status,
Some(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
"{problem:?}"
);
}
}

View file

@ -2977,9 +2977,18 @@ fn build_app(state: AppState) -> axum::Router {
.routes(routes!(get_agents))
.routes(routes!(get_agents_status))
.routes(routes!(set_agent_state))
.routes(routes!(matrix_account::put_matrix_account))
.routes(routes!(forge_account::put_forge_account))
.routes(routes!(github_account::put_github_account))
.routes(routes!(
matrix_account::put_matrix_account,
linked_accounts::delete_matrix_account
))
.routes(routes!(
forge_account::put_forge_account,
linked_accounts::delete_forge_account
))
.routes(routes!(
github_account::put_github_account,
linked_accounts::delete_github_account
))
.routes(routes!(linked_accounts::get_linked_accounts))
.routes(routes!(get_hive_wanted))
.routes(routes!(term_stream::stream_agent_term))

View file

@ -196,7 +196,7 @@ pub async fn put_matrix_account(
/// Whether `account` is the agent's own account, which [`agent_token`] mints
/// and `nix/agent-modules/matrix.nix` declares per agent — see
/// [`put_matrix_account`]'s comment on it for why that route must never write
/// one.
/// one. `linked_accounts::delete_matrix_account` refuses to delete it too.
pub(crate) fn is_reserved_account(account: &str) -> bool {
account == agent_token::ACCOUNT
}
@ -356,6 +356,40 @@ async fn matrix_password_login(
Ok((token.to_owned(), uid.to_owned()))
}
/// POST `<homeserver>/_matrix/client/v3/logout` with `token`, which
/// invalidates that token at the homeserver.
///
/// The error names the status and the homeserver's `error` text, never the
/// token.
pub(crate) async fn matrix_logout(homeserver: &str, token: &str) -> Result<(), String> {
let url = format!(
"{}/_matrix/client/v3/logout",
homeserver.trim_end_matches('/')
);
let resp = http_client()?
.post(&url)
.bearer_auth(token)
.json(&serde_json::json!({}))
.send()
.await
.map_err(|e| http_error("POST /logout", &e))?;
let status = resp.status();
if status.is_success() {
return Ok(());
}
let err = resp
.json::<serde_json::Value>()
.await
.ok()
.and_then(|j| {
j.get("error")
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
})
.unwrap_or_else(|| "logout failed".to_owned());
Err(format!("/logout HTTP {status}: {err}"))
}
#[cfg(test)]
mod tests {
use super::{