Every run is recorded when it ends: a try-out when its reply settles it, a requested review once its review is posted or the post fails, and a request turned away by a full queue once its comment is posted. A duplicate request that starts nothing is not recorded. The newest 100 are one JSON array under `recent` in a new controller-owned KV bucket, `review-classifier-runs`, written with compare-and-set as `schedule-definitions` is (five attempts). A failed write is logged; the review is already posted by then. Each recorded run is also one info event, "review classifier run finished", with the record's fields as tracing fields. The controller logs natively to the journal, so they reach VictoriaLogs as F_<NAME>. GET /api/review-classifier/runs returns the list, newest first, gated by the gateway like the try-out GET. post_classifier_review now returns the posted review's html_url, which the record keeps as review_url. Closes #5153, part of #5096.
1016 lines
36 KiB
Rust
1016 lines
36 KiB
Rust
//! 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
|
|
//! entry is read for its host and nothing else leaves this module:
|
|
//! [`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, 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::de::{DeserializeOwned, IgnoredAny};
|
|
use serde::{Deserialize, Serialize};
|
|
use swarm_secret_client::{Error, SecretStore, forge, github, matrix};
|
|
use utoipa::{IntoParams, ToSchema};
|
|
|
|
use super::{AppState, error_problem, swarm_hive};
|
|
|
|
/// The host shown for a GitHub token. The store keeps none: a token is only
|
|
/// ever used against github.com.
|
|
const GITHUB_HOST: &str = "github.com";
|
|
|
|
/// Which kind of account a [`LinkedAccount`] is.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, ToSchema)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum AccountKind {
|
|
Matrix,
|
|
Forgejo,
|
|
Github,
|
|
}
|
|
|
|
/// One account linked to an agent. Never carries the credential.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, ToSchema)]
|
|
pub struct LinkedAccount {
|
|
kind: AccountKind,
|
|
/// The matrix account name, the forge label, or `github`.
|
|
#[schema(example = "main")]
|
|
name: String,
|
|
/// The matrix homeserver, the forge base URL, or `github.com`. `None` for a
|
|
/// matrix account stored without a homeserver.
|
|
#[schema(example = "https://matrix.example.org")]
|
|
host: Option<String>,
|
|
/// The agent's own matrix account, which the swarm mints and an operator
|
|
/// does not link.
|
|
reserved: bool,
|
|
}
|
|
|
|
/// 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;
|
|
|
|
/// As [`SecretStore::read_optional`].
|
|
fn read_optional<T: DeserializeOwned + Send>(
|
|
&self,
|
|
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;
|
|
}
|
|
|
|
impl AccountStore for SecretStore {
|
|
async fn list(&self, dir: &str) -> Result<Vec<String>, Error> {
|
|
SecretStore::list(self, dir).await
|
|
}
|
|
|
|
async fn read_optional<T: DeserializeOwned + Send>(
|
|
&self,
|
|
path: &str,
|
|
) -> Result<Option<T>, Error> {
|
|
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
|
|
}
|
|
}
|
|
|
|
/// The store the link, delete and list routes use, as [`AppState`] holds it.
|
|
#[derive(Clone)]
|
|
pub(crate) enum Accounts {
|
|
/// The swarm's secret store, logged in to afresh on every request: a
|
|
/// [`SecretStore`] holds a token it never renews, and a store that is down
|
|
/// fails only the requests made while it is down.
|
|
Live,
|
|
/// An in-memory store a test reads back.
|
|
#[cfg(test)]
|
|
Fake(std::sync::Arc<tests::FakeStore>),
|
|
}
|
|
|
|
impl Accounts {
|
|
/// A store to make one request's calls on.
|
|
///
|
|
/// # Errors
|
|
/// Whatever [`crate::store::connect`] raises.
|
|
pub(crate) async fn open(&self) -> Result<OpenAccounts, Error> {
|
|
match self {
|
|
Self::Live => crate::store::connect()
|
|
.await
|
|
.map(|s| OpenAccounts::Live(Box::new(s))),
|
|
#[cfg(test)]
|
|
Self::Fake(store) => Ok(OpenAccounts::Fake(store.clone())),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One request's store, from [`Accounts::open`].
|
|
pub(crate) enum OpenAccounts {
|
|
Live(Box<SecretStore>),
|
|
#[cfg(test)]
|
|
Fake(std::sync::Arc<tests::FakeStore>),
|
|
}
|
|
|
|
impl AccountStore for OpenAccounts {
|
|
async fn list(&self, dir: &str) -> Result<Vec<String>, Error> {
|
|
match self {
|
|
Self::Live(s) => s.list(dir).await,
|
|
#[cfg(test)]
|
|
Self::Fake(s) => s.list(dir).await,
|
|
}
|
|
}
|
|
|
|
async fn read_optional<T: DeserializeOwned + Send>(
|
|
&self,
|
|
path: &str,
|
|
) -> Result<Option<T>, Error> {
|
|
match self {
|
|
Self::Live(s) => s.read_optional(path).await,
|
|
#[cfg(test)]
|
|
Self::Fake(s) => s.read_optional(path).await,
|
|
}
|
|
}
|
|
|
|
async fn write<T: Serialize + Sync>(&self, path: &str, value: &T) -> Result<(), Error> {
|
|
match self {
|
|
Self::Live(s) => s.write(path, value).await,
|
|
#[cfg(test)]
|
|
Self::Fake(s) => s.write(path, value).await,
|
|
}
|
|
}
|
|
|
|
async fn delete_all_versions(&self, path: &str) -> Result<(), Error> {
|
|
match self {
|
|
Self::Live(s) => s.delete_all_versions(path).await,
|
|
#[cfg(test)]
|
|
Self::Fake(s) => s.delete_all_versions(path).await,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The object names directly under `dir`. A key ending in `/` is a directory
|
|
/// below it, not an account.
|
|
async fn objects(store: &impl AccountStore, dir: &str) -> Result<Vec<String>, Error> {
|
|
let keys = store.list(dir).await?;
|
|
Ok(keys.into_iter().filter(|k| !k.ends_with('/')).collect())
|
|
}
|
|
|
|
/// Every account the store holds for `agent`: matrix, then forgejo, then
|
|
/// github.
|
|
///
|
|
/// A missing directory lists nothing, and a listed name whose object is gone
|
|
/// gets no entry.
|
|
///
|
|
/// # Errors
|
|
/// Anything [`SecretStore::list`] or [`SecretStore::read_optional`] does not
|
|
/// treat as absence, such as a denial or an unreachable store, and an `agent`
|
|
/// or listed name that is not a single path segment.
|
|
pub(crate) async fn linked_accounts(
|
|
store: &impl AccountStore,
|
|
agent: &str,
|
|
) -> Result<Vec<LinkedAccount>, Error> {
|
|
let mut out = Vec::new();
|
|
|
|
for name in objects(store, &matrix::accounts_dir(agent)?).await? {
|
|
let path = matrix::account_path(agent, &name)?;
|
|
if let Some(c) = store.read_optional::<matrix::Credential>(&path).await? {
|
|
out.push(LinkedAccount {
|
|
kind: AccountKind::Matrix,
|
|
reserved: crate::matrix_account::is_reserved_account(&name),
|
|
host: c.homeserver,
|
|
name,
|
|
});
|
|
}
|
|
}
|
|
|
|
for label in objects(store, &forge::accounts_dir(agent)?).await? {
|
|
let path = forge::account_path(agent, &label)?;
|
|
if let Some(a) = store.read_optional::<forge::Account>(&path).await? {
|
|
out.push(LinkedAccount {
|
|
kind: AccountKind::Forgejo,
|
|
name: label,
|
|
host: Some(a.url),
|
|
reserved: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
let path = github::account_path(agent)?;
|
|
if store
|
|
.read_optional::<github::Credential>(&path)
|
|
.await?
|
|
.is_some()
|
|
{
|
|
out.push(LinkedAccount {
|
|
kind: AccountKind::Github,
|
|
name: "github".to_owned(),
|
|
host: Some(GITHUB_HOST.to_owned()),
|
|
reserved: false,
|
|
});
|
|
}
|
|
|
|
Ok(out)
|
|
}
|
|
|
|
/// List the accounts linked to an agent: names and hosts, never a credential.
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/hives/{hive}/agents/{agent}/linked-accounts",
|
|
params(
|
|
("hive" = String, Path, description = "hive the agent runs on"),
|
|
("agent" = String, Path, description = "agent whose accounts to list"),
|
|
),
|
|
responses(
|
|
(status = 200, description = "the agent's linked accounts, empty when it has none", body = Vec<LinkedAccount>),
|
|
(status = 400, description = "the agent is not an identifier, or the hive is not in this swarm (problem+json)", body = String),
|
|
(status = 500, description = "the store could not be read (problem+json)", body = String),
|
|
),
|
|
tag = "agents"
|
|
)]
|
|
pub async fn get_linked_accounts(
|
|
State(state): State<AppState>,
|
|
axum::extract::Path((hive, agent)): axum::extract::Path<(String, String)>,
|
|
) -> Result<Json<Vec<LinkedAccount>>, 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 store = state.accounts.open().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 accounts = linked_accounts(&store, &agent).await.map_err(|e| {
|
|
tracing::warn!(%hive, %agent, error = %e, "listing linked accounts failed");
|
|
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
|
})?;
|
|
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 any object is stored at `path`, whatever its
|
|
/// shape: one that no longer decodes as an account still holds the name.
|
|
pub(crate) async fn refuse_linked(store: &impl AccountStore, path: &str) -> Result<(), Linking> {
|
|
match store.read_optional::<IgnoredAny>(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 + Sync>(
|
|
store: &impl AccountStore,
|
|
path: &str,
|
|
value: &T,
|
|
) -> Result<(), Linking> {
|
|
refuse_linked(store, path).await?;
|
|
store.write(path, value).await.map_err(Linking::Store)
|
|
}
|
|
|
|
/// 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 = state.accounts.open().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 = state.accounts.open().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 = state.accounts.open().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)]
|
|
pub(crate) mod tests {
|
|
use std::collections::BTreeMap;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use serde::Serialize;
|
|
use serde::de::DeserializeOwned;
|
|
use serde_json::{Value, json};
|
|
use swarm_secret_client::Error;
|
|
|
|
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. A write replaces the object and is recorded in `written`; a
|
|
/// delete is recorded in `deleted` and leaves `objects` as it is.
|
|
#[derive(Default)]
|
|
pub(crate) struct FakeStore {
|
|
objects: Mutex<BTreeMap<String, Value>>,
|
|
denied: bool,
|
|
written: Mutex<Vec<String>>,
|
|
deleted: Mutex<Vec<String>>,
|
|
}
|
|
|
|
impl FakeStore {
|
|
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 {
|
|
async fn list(&self, dir: &str) -> Result<Vec<String>, Error> {
|
|
if self.denied {
|
|
return Err(Error::MissingEnv("BAO_ADDR"));
|
|
}
|
|
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('/') {
|
|
Some((head, _)) => format!("{head}/"),
|
|
None => rest.to_owned(),
|
|
})
|
|
.collect();
|
|
keys.dedup();
|
|
Ok(keys)
|
|
}
|
|
|
|
async fn read_optional<T: DeserializeOwned + Send>(
|
|
&self,
|
|
path: &str,
|
|
) -> Result<Option<T>, Error> {
|
|
if self.denied {
|
|
return Err(Error::MissingEnv("BAO_ADDR"));
|
|
}
|
|
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"));
|
|
}
|
|
self.deleted
|
|
.lock()
|
|
.expect("not poisoned")
|
|
.push(path.to_owned());
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn row(kind: AccountKind, name: &str, host: Option<&str>, reserved: bool) -> LinkedAccount {
|
|
LinkedAccount {
|
|
kind,
|
|
name: name.to_owned(),
|
|
host: host.map(str::to_owned),
|
|
reserved,
|
|
}
|
|
}
|
|
|
|
fn every_kind() -> FakeStore {
|
|
FakeStore::default()
|
|
.with(
|
|
"swarm/agents/atlas/matrix/main",
|
|
json!({"value": "t0k3n-main", "homeserver": "https://matrix.swarm"}),
|
|
)
|
|
.with(
|
|
"swarm/agents/atlas/matrix/catgirl",
|
|
json!({"value": "t0k3n-cat", "homeserver": "https://matrix.example.org"}),
|
|
)
|
|
.with(
|
|
"swarm/agents/atlas/matrix/old",
|
|
json!({"value": "t0k3n-old"}),
|
|
)
|
|
.with(
|
|
"swarm/agents/atlas/forge/myforge",
|
|
json!({"value": "t0k3n-forge", "url": "https://forge.example.org"}),
|
|
)
|
|
.with(
|
|
"swarm/agents/atlas/github-token",
|
|
json!({"value": "t0k3n-gh"}),
|
|
)
|
|
// Another agent's accounts, and the agent's own forge token beside
|
|
// its `forge/` directory: neither is a linked account of atlas.
|
|
.with(
|
|
"swarm/agents/red/matrix/catgirl",
|
|
json!({"value": "t0k3n-red", "homeserver": "https://matrix.example.org"}),
|
|
)
|
|
.with(
|
|
"swarm/agents/atlas/forge-token",
|
|
json!({"value": "t0k3n-own", "name": "atlas"}),
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn every_kind_is_listed_with_its_name_and_host() {
|
|
let got = linked_accounts(&every_kind(), "atlas")
|
|
.await
|
|
.expect("store answers");
|
|
assert_eq!(
|
|
got,
|
|
vec![
|
|
row(
|
|
AccountKind::Matrix,
|
|
"catgirl",
|
|
Some("https://matrix.example.org"),
|
|
false
|
|
),
|
|
row(
|
|
AccountKind::Matrix,
|
|
"main",
|
|
Some("https://matrix.swarm"),
|
|
true
|
|
),
|
|
row(AccountKind::Matrix, "old", None, false),
|
|
row(
|
|
AccountKind::Forgejo,
|
|
"myforge",
|
|
Some("https://forge.example.org"),
|
|
false
|
|
),
|
|
row(AccountKind::Github, "github", Some("github.com"), false),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_response_carries_no_credential() {
|
|
let got = linked_accounts(&every_kind(), "atlas")
|
|
.await
|
|
.expect("store answers");
|
|
let body = serde_json::to_value(&got).expect("serialises");
|
|
for entry in body.as_array().expect("an array") {
|
|
let mut keys: Vec<&str> = entry
|
|
.as_object()
|
|
.expect("an object")
|
|
.keys()
|
|
.map(String::as_str)
|
|
.collect();
|
|
keys.sort_unstable();
|
|
assert_eq!(keys, ["host", "kind", "name", "reserved"], "{entry}");
|
|
}
|
|
let text = body.to_string();
|
|
assert!(!text.contains("value"), "{text}");
|
|
assert!(!text.contains("t0k3n"), "{text}");
|
|
// The control: the same serialisation does carry the names, so the
|
|
// absence above is not an empty body.
|
|
assert!(text.contains("catgirl"), "{text}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_agent_with_nothing_stored_lists_nothing() {
|
|
let got = linked_accounts(&FakeStore::default(), "atlas")
|
|
.await
|
|
.expect("absence is not an error");
|
|
assert!(got.is_empty(), "{got:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_store_that_refuses_is_an_error_not_an_empty_list() {
|
|
let store = FakeStore {
|
|
denied: true,
|
|
..every_kind()
|
|
};
|
|
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/myforge")
|
|
.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/myforge",
|
|
"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/myforge").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:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_object_that_no_longer_decodes_still_refuses_a_link() {
|
|
let path = "swarm/agents/atlas/github-token";
|
|
let stale = json!({"token": 1});
|
|
// The control: a typed read of this object fails, so the 409 below is
|
|
// not a decode that happened to succeed.
|
|
assert!(serde_json::from_value::<github::Credential>(stale.clone()).is_err());
|
|
let store = FakeStore::default().with(path, stale);
|
|
|
|
let linking = super::link(
|
|
&store,
|
|
path,
|
|
&github::Credential {
|
|
value: "t0k3n-new".to_owned(),
|
|
},
|
|
)
|
|
.await
|
|
.expect_err("an object is stored");
|
|
|
|
assert!(matches!(linking, super::Linking::Exists), "{linking:?}");
|
|
assert_eq!(
|
|
linking
|
|
.problem("agent atlas already has a github token")
|
|
.status,
|
|
Some(axum::http::StatusCode::CONFLICT)
|
|
);
|
|
assert!(store.written().is_empty());
|
|
}
|
|
|
|
/// Bare-minimum `AppState`, as `matrix_account`'s tests build it.
|
|
fn state(store: &Arc<FakeStore>) -> 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,
|
|
reported_inference: 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: std::sync::Arc::default(),
|
|
swarm_name: None,
|
|
auth: None,
|
|
forge: std::sync::Arc::default(),
|
|
create_gate: std::sync::Arc::default(),
|
|
accounts: super::Accounts::Fake(Arc::clone(store)),
|
|
try_outs: super::super::review_classifier::TryOuts::default(),
|
|
reviews: std::sync::Arc::default(),
|
|
runs: crate::review_classifier::runs::Runs::Live(None),
|
|
}
|
|
}
|
|
|
|
async fn delete_matrix(
|
|
store: &Arc<FakeStore>,
|
|
account: &str,
|
|
revoke: bool,
|
|
) -> Result<axum::http::StatusCode, problem_details::ProblemDetails> {
|
|
super::delete_matrix_account(
|
|
axum::extract::State(state(store)),
|
|
axum::extract::Path(("pr1ma".to_owned(), "atlas".to_owned(), account.to_owned())),
|
|
axum::extract::Query(super::DeleteMatrixAccountQuery { revoke }),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Refused before the store: `main` is stored, and stays.
|
|
#[tokio::test]
|
|
async fn the_agents_own_matrix_account_cannot_be_deleted() {
|
|
let store = Arc::new(every_kind());
|
|
let problem = delete_matrix(&store, "main", false)
|
|
.await
|
|
.expect_err("'main' is reserved");
|
|
assert_eq!(
|
|
problem.status,
|
|
Some(axum::http::StatusCode::BAD_REQUEST),
|
|
"{problem:?}"
|
|
);
|
|
assert!(deleted(&store).is_empty());
|
|
}
|
|
|
|
/// The control: any other account name is deleted.
|
|
#[tokio::test]
|
|
async fn another_matrix_account_reaches_the_store() {
|
|
let store = Arc::new(every_kind());
|
|
let status = delete_matrix(&store, "catgirl", false)
|
|
.await
|
|
.expect("stored");
|
|
assert_eq!(status, axum::http::StatusCode::NO_CONTENT);
|
|
assert_eq!(deleted(&store), ["swarm/agents/atlas/matrix/catgirl"]);
|
|
}
|
|
}
|