Watch
0
0
Fork
You've already forked hyperhive
0

swarm-controller: refuse linking over an existing account

The matrix, forge and github link routes wrote their credential
unconditionally, so linking a name that was already linked replaced the
working account. For matrix that lost the device the agent's crypto store
belongs to (#4838).

Each route now reads the account's store path first and answers 409,
naming the existing account, when something is stored there. Nothing is
written. Replacing an account takes the delete from #4899, then a link.

The matrix route checks before password mode's login, so a refused link
mints no new device at the homeserver.

The check is a read then a write, not an atomic step; two concurrent
links to one name can still both pass it.

Closes #4856
This commit is contained in:
atlas 2026-10-03 13:29:26 +02:00
commit 8ad2af735e
10 changed files with 303 additions and 63 deletions

View file

@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};
use swarm_secret_client::forge;
use utoipa::ToSchema;
use super::linked_accounts::link;
use super::{AppState, error_problem, swarm_hive};
/// The account to store for one agent's external forge.
@ -34,10 +35,8 @@ pub struct PutForgeAccountResponse {
url: String,
}
/// Store an agent's external forge account.
///
/// Idempotent: the store keeps versions, so repeating a call replaces the
/// account the agent will next read rather than adding a second one.
/// Store an agent's external forge account, unless one is stored under the
/// label already.
#[utoipa::path(
put,
path = "/api/hives/{hive}/agents/{agent}/forge-accounts/{label}",
@ -50,7 +49,8 @@ pub struct PutForgeAccountResponse {
responses(
(status = 200, description = "stored", body = PutForgeAccountResponse),
(status = 400, description = "the agent or label is not an identifier, the URL is not http(s), the token is empty, or the hive is not in this swarm (problem+json)", body = String),
(status = 500, description = "the store write failed (problem+json)", body = String),
(status = 409, description = "an account is stored under the label already; nothing was written (problem+json)", body = String),
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
),
tag = "agents"
)]
@ -76,16 +76,21 @@ pub async fn put_forge_account(
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
})?;
store.write(&secret_path, &account).await.map_err(|e| {
link(&store, &secret_path, &account).await.map_err(|e| {
// The path names the agent and the label; the value is not in it.
tracing::warn!(path = %secret_path, error = %e, "writing the forge account failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
tracing::warn!(path = %secret_path, error = ?e, "linking the forge account failed");
e.problem(&existing(&agent, &label))
})?;
tracing::info!(%hive, %agent, %label, url = %account.url, "forge account stored");
Ok(Json(PutForgeAccountResponse { url: account.url }))
}
/// The account a refused link names.
fn existing(agent: &str, label: &str) -> String {
format!("agent {agent} already has forge account {label:?}")
}
/// The request as it is stored, or why it cannot be.
fn account(req: PutForgeAccountRequest) -> Result<forge::Account, &'static str> {
let url = req.url.trim().trim_end_matches('/');
@ -197,4 +202,38 @@ mod tests {
"{problem:?}"
);
}
#[tokio::test]
async fn linking_a_label_twice_is_a_409_and_the_first_account_stays() {
use super::super::linked_accounts::{AccountStore, link, tests::FakeStore};
use swarm_secret_client::forge;
let store = FakeStore::default();
let path = forge::account_path("atlas", "codeberg").expect("a valid path");
let first = account(request("https://codeberg.org", "t0k3n-first")).expect("valid");
let second = account(request("https://forge.lan", "t0k3n-second")).expect("valid");
link(&store, &path, &first)
.await
.expect("nothing is stored");
let problem = link(&store, &path, &second)
.await
.expect_err("an account is stored")
.problem(&super::existing("atlas", "codeberg"));
assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT));
let detail = problem.detail.expect("a detail");
assert!(
detail.contains(r#"agent atlas already has forge account "codeberg""#),
"{detail}"
);
assert_eq!(store.written(), std::slice::from_ref(&path));
let kept: forge::Account = store
.read_optional(&path)
.await
.expect("store answers")
.expect("still stored");
assert_eq!(kept.url, "https://codeberg.org");
assert_eq!(kept.value, "t0k3n-first");
}
}

View file

@ -12,6 +12,7 @@ use serde::Deserialize;
use swarm_secret_client::github;
use utoipa::ToSchema;
use super::linked_accounts::link;
use super::{AppState, error_problem, swarm_hive};
/// The token to store for one agent.
@ -24,10 +25,7 @@ pub struct PutGithubAccountRequest {
token: String,
}
/// Store an agent's GitHub token.
///
/// Idempotent: the store keeps versions, so repeating a call replaces the
/// token the agent will next read.
/// Store an agent's GitHub token, unless it has one stored already.
#[utoipa::path(
put,
path = "/api/hives/{hive}/agents/{agent}/github-account",
@ -39,7 +37,8 @@ pub struct PutGithubAccountRequest {
responses(
(status = 204, description = "stored"),
(status = 400, description = "the agent is not an identifier, the token is empty, or the hive is not in this swarm (problem+json)", body = String),
(status = 500, description = "the store write failed (problem+json)", body = String),
(status = 409, description = "the agent has a token stored already; nothing was written (problem+json)", body = String),
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
),
tag = "agents"
)]
@ -60,16 +59,21 @@ pub async fn put_github_account(
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
})?;
store.write(&secret_path, &credential).await.map_err(|e| {
link(&store, &secret_path, &credential).await.map_err(|e| {
// The path names the agent; the value is not in it.
tracing::warn!(path = %secret_path, error = %e, "writing the github token failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
tracing::warn!(path = %secret_path, error = ?e, "linking the github token failed");
e.problem(&existing(&agent))
})?;
tracing::info!(%hive, %agent, "github token stored");
Ok(StatusCode::NO_CONTENT)
}
/// The token a refused link names.
fn existing(agent: &str) -> String {
format!("agent {agent} already has a github token")
}
/// The request as it is stored, or why it cannot be.
fn credential(req: &PutGithubAccountRequest) -> Result<github::Credential, &'static str> {
let token = req.token.trim();
@ -173,4 +177,37 @@ mod tests {
"{problem:?}"
);
}
#[tokio::test]
async fn linking_a_second_token_is_a_409_and_the_first_stays() {
use super::super::linked_accounts::{AccountStore, link, tests::FakeStore};
use swarm_secret_client::github;
let store = FakeStore::default();
let path = github::account_path("atlas").expect("a valid path");
let first = credential(&request("t0k3n-first")).expect("valid");
let second = credential(&request("t0k3n-second")).expect("valid");
link(&store, &path, &first)
.await
.expect("nothing is stored");
let problem = link(&store, &path, &second)
.await
.expect_err("a token is stored")
.problem(&super::existing("atlas"));
assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT));
let detail = problem.detail.expect("a detail");
assert!(
detail.contains("agent atlas already has a github token"),
"{detail}"
);
assert_eq!(store.written(), std::slice::from_ref(&path));
let kept: github::Credential = store
.read_optional(&path)
.await
.expect("store answers")
.expect("still stored");
assert_eq!(kept.value, "t0k3n-first");
}
}

View file

@ -1,6 +1,9 @@
//! The accounts linked to one agent, as kind, name and host: what the swarm
//! UI's agent panel lists, and deletes.
//!
//! [`link`] is the write the three link routes share: it refuses a path that
//! already holds an account, so replacing one takes a delete first.
//!
//! Read from the paths [`crate::matrix_account`], [`crate::forge_account`] and
//! [`crate::github_account`] write, plus the agent's own `main` matrix account,
//! which `matrix_account::agent_token` mints into the same directory. Each
@ -57,8 +60,8 @@ pub struct LinkedAccount {
reserved: bool,
}
/// The store calls a listing or a delete makes, so a test can stand in for the
/// store.
/// The store calls a listing, a link 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;
@ -69,6 +72,13 @@ pub(crate) trait AccountStore {
path: &str,
) -> impl Future<Output = Result<Option<T>, Error>> + Send;
/// As [`SecretStore::write`].
fn write<T: Serialize + Sync>(
&self,
path: &str,
value: &T,
) -> impl Future<Output = Result<(), Error>> + Send;
/// As [`SecretStore::delete_all_versions`].
fn delete_all_versions(&self, path: &str) -> impl Future<Output = Result<(), Error>> + Send;
}
@ -85,6 +95,10 @@ impl AccountStore for SecretStore {
SecretStore::read_optional(self, path).await
}
async fn write<T: Serialize + Sync>(&self, path: &str, value: &T) -> Result<(), Error> {
SecretStore::write(self, path, value).await
}
async fn delete_all_versions(&self, path: &str) -> Result<(), Error> {
SecretStore::delete_all_versions(self, path).await
}
@ -189,6 +203,54 @@ pub async fn get_linked_accounts(
Ok(Json(accounts))
}
/// Why an account was not linked.
#[derive(Debug)]
pub(crate) enum Linking {
/// An account is stored at the path already. Nothing was written.
Exists,
/// The store refused or could not be reached.
Store(Error),
}
impl Linking {
/// `existing` names the stored account, as in
/// `agent janet already has matrix account "work"`.
pub(crate) fn problem(&self, existing: &str) -> problem_details::ProblemDetails {
match self {
Self::Exists => error_problem(
StatusCode::CONFLICT,
&format!("{existing} linked; delete it first"),
),
Self::Store(e) => error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
}
/// [`Linking::Exists`] when an account is stored at `path`.
pub(crate) async fn refuse_linked<T: DeserializeOwned + Send>(
store: &impl AccountStore,
path: &str,
) -> Result<(), Linking> {
match store.read_optional::<T>(path).await {
Ok(None) => Ok(()),
Ok(Some(_)) => Err(Linking::Exists),
Err(e) => Err(Linking::Store(e)),
}
}
/// Write `value` at `path`, unless an account is stored there already.
///
/// A read then a write, not one atomic step: two links racing for one path can
/// both pass the check, and the later write wins.
pub(crate) async fn link<T: Serialize + DeserializeOwned + Send + Sync>(
store: &impl AccountStore,
path: &str,
value: &T,
) -> Result<(), Linking> {
refuse_linked::<T>(store, path).await?;
store.write(path, value).await.map_err(Linking::Store)
}
/// Why an account was not deleted.
#[derive(Debug)]
pub(crate) enum Removal {
@ -431,10 +493,11 @@ pub async fn delete_github_account(
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use std::collections::BTreeMap;
use std::sync::Mutex;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use swarm_secret_client::Error;
@ -447,19 +510,29 @@ mod tests {
/// 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. A delete is recorded in `deleted` and leaves `objects` as it is.
/// does. A write replaces the object and is recorded in `written`; a
/// delete is recorded in `deleted` and leaves `objects` as it is.
#[derive(Default)]
struct FakeStore {
objects: BTreeMap<String, Value>,
pub(crate) struct FakeStore {
objects: Mutex<BTreeMap<String, Value>>,
denied: bool,
written: Mutex<Vec<String>>,
deleted: Mutex<Vec<String>>,
}
impl FakeStore {
fn with(mut self, path: &str, object: Value) -> Self {
self.objects.insert(path.to_owned(), object);
fn with(self, path: &str, object: Value) -> Self {
self.objects
.lock()
.expect("not poisoned")
.insert(path.to_owned(), object);
self
}
/// The paths written, in order.
pub(crate) fn written(&self) -> Vec<String> {
self.written.lock().expect("not poisoned").clone()
}
}
impl AccountStore for FakeStore {
@ -470,6 +543,8 @@ mod tests {
let prefix = format!("{dir}/");
let mut keys: Vec<String> = self
.objects
.lock()
.expect("not poisoned")
.keys()
.filter_map(|p| p.strip_prefix(&prefix))
.map(|rest| match rest.split_once('/') {
@ -490,10 +565,28 @@ mod tests {
}
Ok(self
.objects
.lock()
.expect("not poisoned")
.get(path)
.map(|v| serde_json::from_value(v.clone()).expect("fixture decodes")))
}
async fn write<T: Serialize + Sync>(&self, path: &str, value: &T) -> Result<(), Error> {
if self.denied {
return Err(Error::MissingEnv("BAO_ADDR"));
}
let value = serde_json::to_value(value).expect("serialises");
self.objects
.lock()
.expect("not poisoned")
.insert(path.to_owned(), value);
self.written
.lock()
.expect("not poisoned")
.push(path.to_owned());
Ok(())
}
async fn delete_all_versions(&self, path: &str) -> Result<(), Error> {
if self.denied {
return Err(Error::MissingEnv("BAO_ADDR"));

View file

@ -29,6 +29,7 @@ use serde::{Deserialize, Serialize};
use swarm_secret_client::matrix;
use utoipa::ToSchema;
use super::linked_accounts::{AccountStore, Linking, link, refuse_linked};
use super::{AppState, error_problem, swarm_hive};
pub mod agent_token;
@ -117,10 +118,8 @@ pub struct PutMatrixAccountResponse {
user_id: Option<String>,
}
/// Store an agent's external matrix account credential.
///
/// Idempotent: the store keeps versions, so repeating a call replaces the
/// value the agent will next read rather than adding a second account.
/// Store an agent's external matrix account credential, unless an account is
/// stored under the name already.
#[utoipa::path(
put,
path = "/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}",
@ -133,7 +132,8 @@ pub struct PutMatrixAccountResponse {
responses(
(status = 200, description = "stored", body = PutMatrixAccountResponse),
(status = 400, description = "a name is not an identifier, the account name is not a single path segment, the account is 'main' (reserved), the mode is unrecognized, a mode's required fields are missing, or the hive is not in this swarm (problem+json)", body = String),
(status = 500, description = "the store write failed (problem+json)", body = String),
(status = 409, description = "an account is stored under the name already; nothing was written and no login was made (problem+json)", body = String),
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
),
tag = "agents"
)]
@ -166,33 +166,57 @@ pub async fn put_matrix_account(
));
}
// Password mode's network call happens here, before the store is
// touched, so a failed login leaves no partial state behind.
let (token, homeserver, user_id) = resolve_credential(&req).await.map_err(|b| *b)?;
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())
})?;
store
.write(
&secret_path,
&matrix::Credential {
value: token,
homeserver,
},
)
let user_id = link_matrix(&store, &secret_path, &existing(&agent, &account), &req)
.await
.map_err(|e| {
// The path names the agent and the account; the value is not in it.
tracing::warn!(path = %secret_path, error = %e, "writing the credential failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
})?;
.map_err(|b| *b)?;
tracing::info!(%hive, %agent, %account, "credential stored");
Ok(Json(PutMatrixAccountResponse { user_id }))
}
/// The account a refused link names.
fn existing(agent: &str, account: &str) -> String {
format!("agent {agent} already has matrix account {account:?}")
}
/// Resolve `req`'s credential and write it at `path`, unless an account is
/// stored there already; `existing` names that account in the 409.
///
/// The check comes before password mode's login, so a refused link makes no
/// login and so mints no device at the homeserver. A failed login writes
/// nothing.
async fn link_matrix(
store: &impl AccountStore,
path: &str,
existing: &str,
req: &PutMatrixAccountRequest,
) -> Result<Option<String>, Box<problem_details::ProblemDetails>> {
let refused = |e: Linking| {
// The path names the agent and the account; the value is not in it.
tracing::warn!(path, error = ?e, "linking the matrix account failed");
Box::new(e.problem(existing))
};
refuse_linked::<matrix::Credential>(store, path)
.await
.map_err(refused)?;
let (token, homeserver, user_id) = resolve_credential(req).await?;
link(
store,
path,
&matrix::Credential {
value: token,
homeserver,
},
)
.await
.map_err(refused)?;
Ok(user_id)
}
/// 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
@ -252,8 +276,8 @@ fn password_fields(req: &PutMatrixAccountRequest) -> Result<PasswordFields<'_>,
///
/// `Box`ed error for the same `result_large_err` reason `token_credential`'s
/// doc explains — this fn is private too, so it does not get `put_matrix_account`'s
/// exported-API exemption. Unboxed at the one call site instead of changing
/// `put_matrix_account`'s own (exempt, and part of the route's documented
/// exported-API exemption. Unboxed in `put_matrix_account` instead of
/// changing that fn's own (exempt, and part of the route's documented
/// contract) return type.
async fn resolve_credential(
req: &PutMatrixAccountRequest,
@ -612,6 +636,50 @@ mod tests {
);
}
/// The second link is password mode against a port nothing listens on:
/// a login attempt would answer 400, so the 409 is the check running first.
#[tokio::test]
async fn linking_a_name_twice_is_a_409_and_the_first_account_stays() {
use super::super::linked_accounts::{AccountStore, tests::FakeStore};
use swarm_secret_client::matrix;
let store = FakeStore::default();
let path = matrix::account_path("atlas", "workaccount").expect("a valid path");
let existing = &super::existing("atlas", "workaccount");
let mut first = request("token");
first.token = Some("t0k3n-first".to_owned());
first.homeserver = Some("https://matrix.example.org".to_owned());
let mut second = request("password");
second.homeserver = Some("http://127.0.0.1:9".to_owned());
second.user_id = Some("@a:matrix.example.org".to_owned());
second.password = Some("hunter2".to_owned());
super::link_matrix(&store, &path, existing, &first)
.await
.expect("nothing is stored");
let problem = super::link_matrix(&store, &path, existing, &second)
.await
.expect_err("an account is stored");
assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT));
let detail = problem.detail.expect("a detail");
assert_eq!(
detail,
r#"agent atlas already has matrix account "workaccount" linked; delete it first"#
);
assert_eq!(store.written(), std::slice::from_ref(&path));
let kept: matrix::Credential = store
.read_optional(&path)
.await
.expect("store answers")
.expect("still stored");
assert_eq!(kept.value, "t0k3n-first");
assert_eq!(
kept.homeserver.as_deref(),
Some("https://matrix.example.org")
);
}
/// A stand-in homeserver on a loopback port, answering every
/// `/_matrix/client/v3/logout` with `status` and `body`.
async fn stub_homeserver(status: u16, body: &'static str) -> String {