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:
atlas 2026-08-19 21:13:47 +02:00
commit 3700167279
5 changed files with 240 additions and 22 deletions

View file

@ -27,8 +27,8 @@
use serde::{Deserialize, Serialize};
/// A request to the bridge. One variant today — see the module doc for why
/// this isn't a file-replace API.
/// A request to the bridge — see the module doc for why this isn't a
/// file-replace API.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum BridgeRequest {
/// Idempotently ensure `name` exists as an authelia subject —
@ -46,6 +46,30 @@ pub enum BridgeRequest {
/// crate carries the wire shape only, not the validation rule.
name: String,
},
/// Every agent identity the swarm holds — the roster.
///
/// Reads the same file [`BridgeRequest::EnsureAgentIdentity`] writes,
/// through the same process, for the same reason: the store is owned by
/// a uid `swarm-controller` does not have. A reader that opened the file
/// itself would also be a second parser of a format this bridge owns.
///
/// Answers *"which agents exist"*, never *"which are healthy"* — those
/// are separate sets on purpose, and an agent in the roster that has
/// never reported is not the same as one that does not exist.
ListAgentIdentities,
}
/// One roster entry.
///
/// A record rather than a bare name because the roster is the set other
/// swarm-level views are *complete against*, so an entry is a thing later
/// facts attach to. It carries what identifies an agent and nothing else —
/// notably not the user's groups (an authorisation detail this answer has no
/// reason to publish) and never the password digest.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentIdentity {
/// The authelia username, which is the agent's name.
pub name: String,
}
/// The bridge's answer to a [`BridgeRequest`].
@ -60,9 +84,20 @@ pub enum BridgeResponse {
/// The agent had no identity yet; one was minted and `users.yml` was
/// rewritten.
Created,
/// The agent already had an identity. No write happened — the
/// idempotent no-op path.
/// The agent already had an identity, so no password was minted.
///
/// ⚠️ Says nothing about whether the file was written. An identity that
/// exists but is missing the marker group gains it here, because
/// re-running agent creation is the whole of the migration story for
/// identities that predate that marker — so "already exists" has to mean
/// *the subject was there*, not *nothing changed on disk*.
AlreadyExists,
/// The roster, in answer to [`BridgeRequest::ListAgentIdentities`].
Agents {
/// Sorted by name — the store is a `BTreeMap`, and a stable order
/// means a consumer diffing two answers sees real changes only.
agents: Vec<AgentIdentity>,
},
/// The request was rejected or the write failed. Carries a message
/// for the caller to log/propagate, not a typed error enum: the
/// failure modes here (bad username, authelia binary failed, disk
@ -73,7 +108,7 @@ pub enum BridgeResponse {
#[cfg(test)]
mod tests {
use super::{BridgeRequest, BridgeResponse};
use super::{AgentIdentity, BridgeRequest, BridgeResponse};
/// Pins the external-tag wire shape — a reader off the wire (or a log
/// line) should be able to tell the three outcomes apart without
@ -103,7 +138,36 @@ mod tests {
};
let json = serde_json::to_string(&req).unwrap();
let back: BridgeRequest = serde_json::from_str(&json).unwrap();
let BridgeRequest::EnsureAgentIdentity { name } = back;
let BridgeRequest::EnsureAgentIdentity { name } = back else {
panic!("round-tripped into a different variant: {back:?}");
};
assert_eq!(name, "atlas");
let json = serde_json::to_string(&BridgeRequest::ListAgentIdentities).unwrap();
let back: BridgeRequest = serde_json::from_str(&json).unwrap();
assert!(matches!(back, BridgeRequest::ListAgentIdentities));
}
/// An empty roster must be a roster, not an absence: `{"agents":[]}`
/// says "no agents exist", and a consumer that cannot tell that from a
/// missing field will render a swarm it failed to read as an empty one.
#[test]
fn an_empty_roster_still_serialises_its_list() {
let empty = serde_json::to_value(BridgeResponse::Agents { agents: vec![] }).unwrap();
assert_eq!(empty, serde_json::json!({"status": "agents", "agents": []}));
}
/// The roster entry carries the name and nothing else. Asserted on the
/// serialised keys rather than on the struct, because the risk is a
/// future field being added here and reaching the wire unnoticed — this
/// answer is derived from a file of password digests.
#[test]
fn a_roster_entry_carries_only_the_name() {
let entry = serde_json::to_value(AgentIdentity {
name: "atlas".to_owned(),
})
.unwrap();
let keys: Vec<&String> = entry.as_object().expect("an object").keys().collect();
assert_eq!(keys, ["name"], "the roster names agents, nothing more");
}
}