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
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue