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

@ -23,21 +23,24 @@ a verb has to run as a non-root user or from another host, the answer is
a **group-gated admin socket**, separate from the controller's `0666`
gateway-facing one — not a widening of what root does here.
## Two files, one of them authoritative
## One file, two writers
- `users.json` — canonical, ours, JSON.
- `users.yml` — a **rendered artifact** for authelia. Written, never read
back.
`users.yml` — authelia's own users database — is read and written
directly. There is no second store.
The split is what lets this crate work without a YAML parser: the
workspace has none, and adding one costs a crates.io fetch, a lock update
and a vendor hash for a schema we fully control and only ever emit.
There used to be: a private `users.json` here, canonical, with `users.yml`
rendered from it, while `swarm-authelia-bridge` 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 missing could not tell "nothing here
yet" from "someone else's users", and refused to write (#3422).
The shortcut of writing JSON into the `.yml` (JSON being a subset of
YAML) is deliberately not taken: authelia refuses to start on a users file
it cannot parse, so that file fronts the whole SSO provider's boot, and
"almost certainly parses" is not a claim worth betting a boot on without
running it.
The argument for the split was that it let this crate work without a YAML
parser. It didn't: the JSON was read back on every run, so the round-trip
was already being paid — the two files differed only in *format*.
⚠️ The file is round-tripped, so **comments and hand-formatting do not
survive a write**. Values do, and so do keys this binary does not model.
See `swarm-authelia-bridge/README.md` for what both writers must uphold.
## Configuration
@ -53,7 +56,6 @@ an error.
| `SWARMCTL_AUTHELIA_USERS_FILE` | host-side path of the users database |
| `SWARMCTL_AUTHELIA_MACHINE` | container name, for `systemctl -M` |
| `SWARMCTL_AUTHELIA_UNIT` | authelia's unit inside that container |
| `SWARMCTL_STORE` | canonical store (defaults to the controller's state dir) |
## Usage

View file

@ -61,12 +61,14 @@ struct PathArgs {
/// Host-side path of authelia's users database — i.e. the path inside
/// the container, prefixed with the container's root.
///
/// ⚠️ This is the **only** user store. There used to be a `--store`
/// flag naming a private canonical JSON that this file was rendered
/// from; it is gone rather than deprecated, because a flag whose only
/// remaining effect would be nothing is worse than an unknown-argument
/// error — the operator sets it, sees success, and gets none of what
/// they asked for.
/// This is the only user store: it is read before every change and
/// written in place, and `swarm-authelia-bridge` writes the same file.
//
// The `--store` flag that named a second, private JSON store is gone
// rather than deprecated — a flag whose only remaining effect would be
// nothing reads as accepted and does nothing, where an unknown-argument
// error is loud. Not in the doc comment: `--help` is an operator
// surface, and the removal's reasoning belongs in the README.
#[arg(long, value_name = "PATH")]
users_file: Option<PathBuf>,
}
@ -149,12 +151,12 @@ enum UserVerb {
/// editing an attribute, and folded together an attribute edit can
/// invalidate a login by accident.
Update(UpdateArgs),
/// List every user in the canonical store.
/// List every user in authelia's users database.
///
/// Reads `store` only — never authelia's rendered `users.yml`, which
/// is a derived artifact this crate writes and never reads back (see
/// the crate doc comment). One line per user: username, display name,
/// email (if set), groups (if any).
/// Read-only: it never writes the file. Shows every subject in it,
/// including agent identities `swarm-authelia-bridge` created — one
/// line per user: username, display name, email (if set), groups (if
/// any).
List,
}
@ -294,8 +296,8 @@ fn user_update(paths: &Paths, args: UpdateArgs) -> Result<()> {
Ok(())
}
/// `swarmctl user list` — read-only, never touches authelia's users file
/// or restarts it. Prints one line per user from the canonical store.
/// `swarmctl user list` — read-only: it loads authelia's users file and
/// writes nothing. One line per user, agent identities included.
fn user_list(paths: &Paths) -> Result<()> {
use std::fmt::Write as _;
@ -325,8 +327,9 @@ fn publish(paths: &Paths, store: &mut UserStore) -> Result<()> {
// Before rendering, not at creation: a user can also arrive by being
// *read* — from a file the bridge wrote, or one an operator edited —
// and an authelia subject with no `email` breaks any relying party that
// asks for the claim (grafana's OIDC login is the measured case,
// #3393). Filling it here is the only place no entry point can skip.
// asks for the claim (grafana's OIDC login is the measured case: it
// fails outright rather than degrading). Filling it here is the only
// place no entry point can skip.
for name in users::fill_missing_emails(store) {
println!("note: {name} had no email; set to a synthetic address");
}
@ -570,7 +573,10 @@ mod tests {
fs::write(&users_file, "users: {}\n").expect("seed");
assert!(
load_store(&users_file).expect("the seed loads").users.is_empty(),
load_store(&users_file)
.expect("the seed loads")
.users
.is_empty(),
"the first-boot seed is an empty store, with no special case"
);

View file

@ -406,9 +406,9 @@ users:
);
}
/// The #3414 property, moved from the renderer to the write path along
/// with the synthesis itself: a user who supplied an address keeps it,
/// and no invented one appears beside it.
/// The earlier missing-email property, moved from the renderer to the
/// write path along with the synthesis itself: a user who supplied an
/// address keeps it, and no invented one appears beside it.
#[test]
fn a_supplied_email_is_never_replaced_by_the_synthetic_one() {
let mut u = user("$argon2id$x");