docs(#3422): the user store is one file, not two

Six places asserted the old design as fact, and none of them mention the
change by name -- the class of doc breakage that is found by asking what
a diff made untrue, not by grepping for a feature:

- swarmctl/README.md and swarm-authelia-bridge/README.md both described
  their own private canonical store. The bridge's "known limitation"
  section described the seam as unsolved; it is what this fixes, so it
  becomes what both writers must uphold instead.
- docs/swarm/{sso,ui,secrets}.md described a rendered artifact.
- The repo CLAUDE.md entry for swarmctl said the same.
- docs/tools/swarmctl-cli.md is regenerated (CI diffs it against the
  clap tree), picking up the removed --store flag.

Operator-facing where it is read: the hand-editing consequence (values
survive a rewrite, comments do not) is stated in sso.md, where an
operator is being told to edit the file, rather than only in a module doc.
This commit is contained in:
atlas 2026-08-18 10:29:35 +02:00
commit 6ca4887af4
10 changed files with 114 additions and 97 deletions

View file

@ -1,37 +1,20 @@
//! The canonical user store this bridge owns, and the authelia users
//! database rendered from it.
//!
//! Deliberately its own copy, not shared code with `swarmctl::users` even
//! though the YAML shape is identical (same "factor out later if
//! duplication actually bites" reasoning as `swarm-controller::forge`
//! mirroring `hive-c0re::forge` — see the agent-identity-at-swarm-level
//! design thread). One real difference from `swarmctl`'s copy: this
//! store lives **wherever this
//! bridge runs** (inside the `swarm-authelia` container, alongside
//! `authelia-swarm`'s own state), not under `swarm-controller`'s state
//! dir — the two are not guaranteed to be the same host once
//! `swarm-authelia` and `swarm-controller` split across hosts, and this
//! bridge only ever runs where `swarm-authelia` does.
//!
//! # `users.yml` is the store, not a rendering of one
//! Authelia's users database — **the** store, not a rendering of one.
//!
//! There used to be a private `users.json` here, canonical, with `users.yml`
//! rendered from it — and `swarmctl` had its own pair against the *same*
//! physical `users.yml`. Two canonical stores for one file is a seam, and it
//! bit: a bridge whose own JSON was absent refused to write at all, because
//! it could not tell "nothing here yet" from "someone else's users".
//! rendered from it, while `swarmctl` kept its own pair against the *same*
//! physical file. Two canonical stores for one file is a seam, and it bit: a
//! writer whose own JSON was absent refused to write at all, unable to tell
//! "nothing here yet" from "someone else's users". The JSON bought nothing —
//! it was read back on every load, so the round-trip it seemed to avoid was
//! already being paid.
//!
//! The JSON bought nothing. It was read back on every load, so the
//! round-trip it seemed to avoid was already being paid — the two files
//! differed only in *format*. One file removes the class: there is no second
//! store to disagree with, and the overwrite guard that policed them has
//! nothing left to do.
//! ⚠️ The file is **round-tripped**, so comments and hand-formatting do not
//! survive a write; values and unmodelled keys do (see `extra`).
//!
//! ⚠️ Consequence, deliberate: this file is now **round-tripped**, so
//! comments and hand-formatting in it do not survive a write. An operator
//! editing it directly gets their *values* kept and their *comments* dropped.
//! Unknown keys are preserved (see `extra` below) so a field this binary does
//! not know about is not deleted by it.
//! Deliberately its own copy of the shape rather than code shared with
//! `swarmctl::users` — same "factor out if the duplication actually bites"
//! reasoning as `swarm-controller::forge` mirroring `hive-c0re::forge`. What
//! both copies must uphold is in `../README.md`.
use std::collections::BTreeMap;
use std::fs::{self, File, Permissions};
@ -188,13 +171,14 @@ const SYNTHETIC_EMAIL_DOMAIN: &str = "hyperhive.local";
/// Give every user an address before the file is written.
///
/// 🩸 **This bridge did not do it, and `swarmctl` did.** #3414 fixed the
/// missing-email defect in `swarmctl` only, so every identity created *here*
/// landed in `users.yml` with no `email` — and a relying party that asks for
/// the claim does not degrade, it fails (grafana's OIDC login is the
/// measured case, #3393). Two writers of one file disagreeing about a
/// required field is not a difference worth keeping, so the rule now lives
/// on both write paths.
/// 🩸 **This bridge did not do it, and `swarmctl` did.** The fix for the
/// missing-email defect landed in `swarmctl` only, so every identity created
/// *here* landed in `users.yml` with no `email` — and a relying party that
/// asks for the claim does not degrade, it fails (grafana's OIDC login is
/// the measured case: it falls through to an endpoint authelia does not
/// implement and dies with an internal error). Two writers of one file
/// disagreeing about a required field is not a difference worth keeping, so
/// the rule now lives on both write paths.
fn fill_missing_emails(store: &mut UserStore) {
for (name, user) in &mut store.users {
if user.email.is_none() {
@ -342,11 +326,11 @@ some_future_top_level_key: 7
/// `write_atomic` round-trips through a real temp dir — the property
/// under test is the rename-into-place, not just the render.
/// 🩸 The asymmetry this fixes: #3414 gave `swarmctl`-created humans a
/// synthetic address and left bridge-created **agents** with none, so
/// two writers of one file disagreed about a field a relying party
/// treats as required — grafana's OIDC login fails outright without it
/// (#3393) rather than degrading.
/// 🩸 The asymmetry this fixes: the earlier missing-email fix gave
/// `swarmctl`-created humans a synthetic address and left bridge-created
/// **agents** with none, so two writers of one file disagreed about a
/// field a relying party treats as required — grafana's OIDC login fails
/// outright without it rather than degrading.
///
/// Asserted through the real write path, because that is where the rule
/// lives: `handle` inserts a user with `email: None` and nothing between