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:
parent
4a50d29a64
commit
8ad2af735e
10 changed files with 303 additions and 63 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue