feat(swarm-authelia-bridge): mark agent identities with a group, and answer for the set
The users database holds humans and agents in one namespace and nothing in it said which was which, so a roster read had no predicate to read with. Marks positively, at creation. The alternative — everyone who is not an operator — fails in the direction that matters: an account created without a group is an operator who cannot log in, a mistake the swarm UI docs already warn about, and it would have rendered as an agent. The group is a constant for the same reason the operator group it mirrors is one. An identity that predates the marker gains it when agent creation runs again, which is already the agreed migration for those; AlreadyExists therefore reports that the subject was there, not that nothing was written. ListAgentIdentities reads through this process because the store is owned by a uid swarm-controller does not have — the same reason the write goes through here — and answers with names alone, never the digests it sits next to.
This commit is contained in:
parent
78217bc10f
commit
3700167279
5 changed files with 240 additions and 22 deletions
|
|
@ -23,10 +23,15 @@
|
|||
//! this process runs right next to the authelia it asks.
|
||||
//!
|
||||
//! One endpoint, `POST /requests`, body =
|
||||
//! [`swarm_authelia_bridge_sock::BridgeRequest`] verbatim — one variant
|
||||
//! today (`EnsureAgentIdentity`, idempotently ensure an agent exists as
|
||||
//! an authelia subject). Not a wholesale-replace-the-file API — this
|
||||
//! process owns rendering `users.yml` internally; see `store` module.
|
||||
//! [`swarm_authelia_bridge_sock::BridgeRequest`] verbatim: ensure an agent
|
||||
//! exists as an authelia subject, and list the ones that do. Not a
|
||||
//! wholesale-replace-the-file API — this process owns rendering
|
||||
//! `users.yml` internally; see `store` module.
|
||||
//!
|
||||
//! The read is here rather than in its caller for the same reason the
|
||||
//! write is: the store is owned by a uid `swarm-controller` does not have,
|
||||
//! and a caller that opened the file itself would be a second parser of a
|
||||
//! format this process owns.
|
||||
|
||||
mod introspect;
|
||||
mod store;
|
||||
|
|
@ -39,7 +44,7 @@ use axum::http::{HeaderMap, StatusCode};
|
|||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use swarm_authelia_bridge_sock::{BridgeRequest, BridgeResponse};
|
||||
use swarm_authelia_bridge_sock::{AgentIdentity, BridgeRequest, BridgeResponse};
|
||||
|
||||
/// Where the introspecting bearer token is authenticated *to* — this
|
||||
/// bridge's own authelia machine client, distinct from
|
||||
|
|
@ -127,9 +132,9 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
|
||||
/// The single endpoint — body is the wire-crate's [`BridgeRequest`]
|
||||
/// verbatim (JSON), not a per-operation REST route. One variant today,
|
||||
/// but this is what keeps a second operation from needing a new route +
|
||||
/// a new extractor shape: it just becomes a new match arm below.
|
||||
/// verbatim (JSON), not a per-operation REST route. That is what let the
|
||||
/// second operation arrive as a match arm rather than as another route
|
||||
/// with its own extractor and its own copy of the authorize call.
|
||||
async fn handle_request(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
|
|
@ -138,12 +143,17 @@ async fn handle_request(
|
|||
if let Err(resp) = authorize(&state, &headers).await {
|
||||
return resp;
|
||||
}
|
||||
let BridgeRequest::EnsureAgentIdentity { name } = req;
|
||||
match handle(&state, name).await {
|
||||
let (op, result) = match req {
|
||||
BridgeRequest::EnsureAgentIdentity { name } => {
|
||||
("ensure_identity", handle(&state, name).await)
|
||||
}
|
||||
BridgeRequest::ListAgentIdentities => ("list_identities", list(&state)),
|
||||
};
|
||||
match result {
|
||||
Ok(body) => (StatusCode::OK, Json(body)).into_response(),
|
||||
Err(e) => {
|
||||
let detail = format!("{e:#}");
|
||||
tracing::warn!(error = %detail, "ensure_identity failed");
|
||||
tracing::warn!(op, error = %detail, "request failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BridgeResponse::Error { message: detail }),
|
||||
|
|
@ -269,6 +279,15 @@ async fn handle(state: &AppState, name: String) -> Result<BridgeResponse> {
|
|||
|
||||
let mut user_store = store::load_store(&cfg.users_file)?;
|
||||
if user_store.users.contains_key(&name) {
|
||||
// The subject is there, so no password is minted — but it may
|
||||
// predate the marker that puts it in the roster, and running agent
|
||||
// creation again is the whole of the migration for those. Reported
|
||||
// as `AlreadyExists` either way: the wire answer is about the
|
||||
// subject existing, not about whether bytes moved.
|
||||
if store::mark_as_agent(&mut user_store, &name) {
|
||||
store::publish(&cfg.users_file, &mut user_store)?;
|
||||
tracing::info!(agent = %name, "marked an existing identity as an agent");
|
||||
}
|
||||
return Ok(BridgeResponse::AlreadyExists);
|
||||
}
|
||||
let generated = generate_password(&cfg.authelia_bin).await?;
|
||||
|
|
@ -278,7 +297,7 @@ async fn handle(state: &AppState, name: String) -> Result<BridgeResponse> {
|
|||
displayname: name.clone(),
|
||||
password: generated,
|
||||
email: None,
|
||||
groups: Vec::new(),
|
||||
groups: vec![store::AGENT_GROUP.to_owned()],
|
||||
extra: std::collections::BTreeMap::new(),
|
||||
},
|
||||
);
|
||||
|
|
@ -287,6 +306,22 @@ async fn handle(state: &AppState, name: String) -> Result<BridgeResponse> {
|
|||
Ok(BridgeResponse::Created)
|
||||
}
|
||||
|
||||
/// The roster: every subject carrying the agent marker.
|
||||
///
|
||||
/// Takes no write lock. The store is replaced by an atomic rename, so a
|
||||
/// reader sees either the whole old file or the whole new one — and holding
|
||||
/// the lock would make a read wait behind a password mint, which is an
|
||||
/// external process.
|
||||
fn list(state: &AppState) -> Result<BridgeResponse> {
|
||||
let user_store = store::load_store(&state.config.users_file)?;
|
||||
Ok(BridgeResponse::Agents {
|
||||
agents: store::agent_names(&user_store)
|
||||
.into_iter()
|
||||
.map(|name| AgentIdentity { name })
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Mint a password digest via the **configured** authelia — the argon2
|
||||
/// parameters baked into a hash have to match the verifier's, same
|
||||
/// reasoning `swarmctl::generate_password` documents (this is
|
||||
|
|
|
|||
Loading…
Reference in a new issue