fix(#3422): the bridge reads and writes users.yml directly

`swarm agent create` failed with "no user store at
swarm-authelia-bridge-users.json but users.yml already holds users" on
any hive that had users. The guard was correct; what was wrong is that
two files both claimed to be canonical for one physical file.

The bridge kept a private users.json and rendered users.yml from it,
while swarmctl kept its own pair against the same users.yml. A writer
whose own JSON was absent could not tell "nothing here yet" from
"someone else's users", so it refused to write at all.

users.yml becomes the store: read before write, through serde_norway
rather than a hand-rolled emitter. Unknown top-level and per-user keys
round-trip through `extra`, or whichever process writes second would
silently delete what the first added. Validation stays on the write path
-- a bare to_string(&store) serialises perfectly and drops the "no
control character ever reaches this file" guarantee silently.

The seed constant goes with the guard: nothing writes a seed now, an
absent file is an empty store, and left as a pub constant it read as if
the seed dance were still load-bearing.
This commit is contained in:
atlas 2026-08-18 10:31:48 +02:00
commit 24f4cd42a9
5 changed files with 190 additions and 101 deletions

View file

@ -47,7 +47,6 @@ use swarm_authelia_bridge_sock::{BridgeRequest, BridgeResponse};
/// that provisions this bridge's own client alongside authelia.
struct Config {
bind: String,
store_path: std::path::PathBuf,
users_file: std::path::PathBuf,
authelia_bin: std::path::PathBuf,
introspection_url: String,
@ -59,7 +58,6 @@ impl Config {
fn from_env() -> Result<Self> {
Ok(Self {
bind: env_var("SWARM_AUTHELIA_BRIDGE_BIND")?,
store_path: env_var("SWARM_AUTHELIA_BRIDGE_STORE")?.into(),
users_file: env_var("SWARM_AUTHELIA_BRIDGE_USERS_FILE")?.into(),
authelia_bin: env_var("SWARM_AUTHELIA_BRIDGE_AUTHELIA_BIN")?.into(),
introspection_url: env_var("SWARM_AUTHELIA_BRIDGE_INTROSPECTION_URL")?,
@ -265,7 +263,7 @@ async fn handle(state: &AppState, name: String) -> Result<BridgeResponse> {
// practice, not just theoretically.
let _write_guard = state.write_lock.lock().await;
let mut user_store = store::load_store(&cfg.store_path, &cfg.users_file)?;
let mut user_store = store::load_store(&cfg.users_file)?;
if user_store.users.contains_key(&name) {
return Ok(BridgeResponse::AlreadyExists);
}
@ -277,9 +275,10 @@ async fn handle(state: &AppState, name: String) -> Result<BridgeResponse> {
password: generated,
email: None,
groups: Vec::new(),
extra: std::collections::BTreeMap::new(),
},
);
store::publish(&cfg.store_path, &cfg.users_file, &user_store)?;
store::publish(&cfg.users_file, &user_store)?;
tracing::info!(agent = %name, "created authelia identity");
Ok(BridgeResponse::Created)
}