Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
Author SHA1 Message Date
atlas
ddccdf2bab chore(#3422): drop swarmctl's now-unused serde_json dependency
Its last call sites were the JSON store's to_string_pretty/from_str,
which this branch replaced with serde_norway. Nothing in swarmctl/src
names it any more.

Caught by argus, and it is the second time: the same reviewer found a
dead reqwest in swarm-controller after a move earlier this week. No lint
in this workspace sees an unused dependency, so both were green locally
and in CI -- a move has two sides and my gate only looked at one. My
local gate now carries an advisory check for it.
2026-08-18 10:40:36 +02:00
atlas
6ca4887af4 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.
2026-08-18 10:34:00 +02:00
atlas
1885022d02 fix(#3422): give bridge-created identities an email too
#3414 fixed the missing-email defect in swarmctl only, so every identity
created by the bridge landed in users.yml with no `email`. 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 field one of them treats as
required is not a difference worth keeping, and with the file shared the
result also depended on which tool wrote last.

Mutation-checked: removing the fill turns the new test red and nothing
else.
2026-08-18 10:31:48 +02:00
atlas
af1203c78b fix(#3422): swarmctl reads and writes users.yml directly
The second half of making one file canonical. swarmctl kept its own
private JSON store and rendered users.yml from it, against the same
physical file the bridge wrote -- the seam that made `swarm agent create`
refuse to run.

- users.yml is read before it is written, so users another writer added
  are loaded rather than treated as a file to refuse or clobber. The
  overwrite guard and the seed check go with the second store: they
  existed to police two stores that could disagree.
- serde_norway replaces the hand-rolled emitter. Unknown top-level and
  per-user keys round-trip through `extra`, so two writers cannot delete
  each other's fields.
- The synthetic email moves from render time to the write path and is
  stored. With the file as the store, "rendered but not persisted" has
  nowhere left to live, and mara ruled the stored address correct.
- `--store` is removed rather than deprecated: a flag whose only
  remaining effect is nothing reads as accepted and does nothing.

Three tests asserted the emitter's exact bytes, and one asserted the
guard. Rewritten rather than deleted -- as round-trips for the former,
and inverted for the latter, since "a populated file is READ" is the
behaviour this change is for and deleting its test would leave it
unpinned.
2026-08-18 10:31:48 +02:00
atlas
4125d967ef fix(#3422): stop pointing the bridge at a second user store
The bridge no longer has a private canonical store, so the env var
naming one is gone rather than repointed. What is left is the single
users file it now reads and writes directly.

That line is what made `swarm agent create` fail on this hive: the
bridge found no store at its own JSON path, saw users already in
`users.yml`, and refused to overwrite a file it had not written --
correctly, given two things claimed to be canonical for one file.
2026-08-18 10:31:48 +02:00
atlas
24f4cd42a9 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.
2026-08-18 10:31:48 +02:00
15 changed files with 582 additions and 397 deletions

View file

@ -153,9 +153,9 @@ hand-maintained per-file tree drifts out of sync with the code.
**root and acts directly** — no socket, no HTTP route, no priv helper; **root and acts directly** — no socket, no HTTP route, no priv helper;
the crate's README records why the rootless shape was examined and the crate's README records why the rootless shape was examined and
rejected. Does not link `swarm-controller`, mirroring `hivectl` ÷ rejected. Does not link `swarm-controller`, mirroring `hivectl` ÷
`hive-c0re`. ⚠️ Its user store is **two files, one authoritative**: `hive-c0re`. ⚠️ Its user store is authelia's own **`users.yml`, read and
`users.json` is canonical, authelia's `users.yml` is a *rendered written in place** — one file, shared with `swarm-authelia-bridge`; see
artifact* that is written and never read back. Full, always-current that crate's README for what both writers must uphold. Full, always-current
verb reference (CI-enforced against the clap tree, same pattern as verb reference (CI-enforced against the clap tree, same pattern as
`hivectl`'s — see `docs/conventions.md`): `hivectl`'s — see `docs/conventions.md`):
[`docs/tools/swarmctl-cli.md`](docs/tools/swarmctl-cli.md). [`docs/tools/swarmctl-cli.md`](docs/tools/swarmctl-cli.md).

22
Cargo.lock generated
View file

@ -4324,6 +4324,19 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_norway"
version = "0.9.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e408f29489b5fd500fab51ff1484fc859bb655f32c671f307dcd733b72e8168c"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml-norway",
]
[[package]] [[package]]
name = "serde_path_to_error" name = "serde_path_to_error"
version = "0.1.20" version = "0.1.20"
@ -4564,6 +4577,7 @@ dependencies = [
"reqwest 0.13.1", "reqwest 0.13.1",
"serde", "serde",
"serde_json", "serde_json",
"serde_norway",
"swarm-authelia-bridge-sock", "swarm-authelia-bridge-sock",
"tokio", "tokio",
"tracing", "tracing",
@ -4648,7 +4662,7 @@ dependencies = [
"clap-markdown", "clap-markdown",
"clap_complete", "clap_complete",
"serde", "serde",
"serde_json", "serde_norway",
] ]
[[package]] [[package]]
@ -5236,6 +5250,12 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "unsafe-libyaml-norway"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39abd59bf32521c7f2301b52d05a6a2c975b6003521cbd0c6dc1582f0a22104"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"

View file

@ -47,7 +47,7 @@ Every row below is read against one of these.
| authelia OIDC issuer key (RSA) | same unit | `/var/lib/authelia-swarm/oidc-issuer.key` | same — relying parties verify against the **public** half at `/jwks.json` | | authelia OIDC issuer key (RSA) | same unit | `/var/lib/authelia-swarm/oidc-issuer.key` | same — relying parties verify against the **public** half at `/jwks.json` |
| OIDC client secret, plaintext half | `authelia crypto hash generate --random` | `/var/lib/authelia-swarm/oidc-clients/<id>.secret` | operator provides the file and names it in the service's `sso.clientSecretFile` | | OIDC client secret, plaintext half | `authelia crypto hash generate --random` | `/var/lib/authelia-swarm/oidc-clients/<id>.secret` | operator provides the file and names it in the service's `sso.clientSecretFile` |
| OIDC client secret, digest half | the same mint | `oidc-clients/<id>.digest` | authelia's own half; merged at runtime via `settingsFiles` | | OIDC client secret, digest half | the same mint | `oidc-clients/<id>.digest` | authelia's own half; merged at runtime via `settingsFiles` |
| authelia subject store | `swarmctl` | `users.json` (canonical) → `users.yml` (rendered) | `swarmctl`, on the host that runs authelia | | authelia subject store | `swarmctl` and `swarm-authelia-bridge` | `users.yml` — one file, read and written by both | `swarmctl`, on the host that runs authelia |
| wireguard private key | **the operator** — `wg genkey` | whatever `swarm.wireguard.privateKeyFile` names | always operator-provided; nothing generates this for you | | wireguard private key | **the operator** — `wg genkey` | whatever `swarm.wireguard.privateKeyFile` names | always operator-provided; nothing generates this for you |
| queue auth-callout nkeys (user seed + account seed) | `swarm-nats-callout-keys` first-boot unit, when `nats.autoGenerateCallout` is set | `/var/lib/swarm-nats-callout/{callout-user,issuer}.seed`, `0600` | operator mints both with `nk` and names them in `nats.calloutUserSeedFile` / `nats.calloutIssuerSeedFile` | | queue auth-callout nkeys (user seed + account seed) | `swarm-nats-callout-keys` first-boot unit, when `nats.autoGenerateCallout` is set | `/var/lib/swarm-nats-callout/{callout-user,issuer}.seed`, `0600` | operator mints both with `nk` and names them in `nats.calloutUserSeedFile` / `nats.calloutIssuerSeedFile` |

View file

@ -48,9 +48,14 @@ this password is stored nowhere — record it now
``` ```
The password is generated, hashed, and printed once; only the hash is The password is generated, hashed, and printed once; only the hash is
kept. `swarmctl` writes its canonical `users.json`, re-renders authelia's kept. `swarmctl` reads and writes authelia's `users.yml` directly — it is
`users.yml` from it, and restarts authelia. Full reference: the one user store, shared with `swarm-authelia-bridge`, which creates
[`../tools/swarmctl-cli.md`](../tools/swarmctl-cli.md). agent identities in the same file. No restart: authelia watches it. Full
reference: [`../tools/swarmctl-cli.md`](../tools/swarmctl-cli.md).
You can edit `users.yml` by hand, and `swarmctl` will read what you
wrote. ⚠️ It rewrites the whole file on every change, so **comments and
formatting do not survive**; values and unrecognised keys do.
This step stays manual on purpose. Bootstrapping an identity provider This step stays manual on purpose. Bootstrapping an identity provider
non-interactively means a secret arriving from somewhere — a file, an non-interactively means a secret arriving from somewhere — a file, an
@ -125,8 +130,8 @@ presenting as "the forge is broken", several layers from its cause.
### 2. Swarm-managed services ### 2. Swarm-managed services
The controller side owns provisioning: `swarmctl` writes both halves, the The controller side owns provisioning: `swarmctl` writes both halves, the
same way it already owns authelia's user store (`users.json` canonical, same way it already owns authelia's user store (`users.yml`, read and
`users.yml` a rendered artifact). written in place).
### 3. A hive elsewhere ### 3. A hive elsewhere

View file

@ -45,8 +45,8 @@ would have meant those accounts silently failing a check they were
supposed to pass. supposed to pass.
An account created without any group needs re-adding with the flag — An account created without any group needs re-adding with the flag —
`swarmctl` treats the existing entry as the canonical store, so the group `swarmctl` reads the existing entry out of `users.yml`, so the group is
is what changes. what changes.
Why a group and not a list of usernames: agents are getting authelia Why a group and not a list of usernames: agents are getting authelia
accounts of their own (matrix SSO), and *authenticated* would then accounts of their own (matrix SSO), and *authenticated* would then

View file

@ -25,8 +25,9 @@ swarm-level operator CLI
###### **Options:** ###### **Options:**
* `--authelia-bin <PATH>` — authelia binary used to hash passwords. The argon2 parameters must match the verifier's, so this has to be the *configured* package rather than whatever is on `PATH` * `--authelia-bin <PATH>` — authelia binary used to hash passwords. The argon2 parameters must match the verifier's, so this has to be the *configured* package rather than whatever is on `PATH`
* `--users-file <PATH>` — Host-side path of authelia's users database — i.e. the path inside the container, prefixed with the container's root * `--users-file <PATH>` — Host-side path of authelia's users database — i.e. the path inside the container, prefixed with the container's root.
* `--store <PATH>` — Canonical user store
This is the only user store: it is read before every change and written in place, and `swarm-authelia-bridge` writes the same file.
@ -40,7 +41,7 @@ Manage subjects in the swarm's SSO provider
* `add` — Add a user, generating a password for them * `add` — Add a user, generating a password for them
* `update` — Change an existing user's attributes * `update` — Change an existing user's attributes
* `list` — List every user in the canonical store * `list` — List every user in authelia's users database
@ -85,9 +86,9 @@ Every flag is optional and they compose, so one call can set several things at o
## `swarmctl user list` ## `swarmctl user list`
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).
**Usage:** `swarmctl user list` **Usage:** `swarmctl user list`

View file

@ -920,14 +920,12 @@ in
}; };
environment = { environment = {
SWARM_AUTHELIA_BRIDGE_BIND = "127.0.0.1:${toString cfg.bridgePort}"; SWARM_AUTHELIA_BRIDGE_BIND = "127.0.0.1:${toString cfg.bridgePort}";
# Own canonical store, alongside authelia's own state — # ONE file, and this is it. The bridge used to carry a
# NOT `swarm-controller`'s state dir: the two processes # second, private `…-users.json` it treated as canonical
# aren't guaranteed to be on the same host, and this store # while writing `users.yml` as a rendering of it — two
# has to live wherever its writer (this bridge) does. See # canonical stores for one physical file, which is what
# `swarm-authelia-bridge/README.md`'s "known limitation" # made `swarm agent create` refuse to start on a hive whose
# section for the resulting `swarmctl`-owns-a-second-store # `users.yml` already held users.
# seam.
SWARM_AUTHELIA_BRIDGE_STORE = "${stateDir}/swarm-authelia-bridge-users.json";
SWARM_AUTHELIA_BRIDGE_USERS_FILE = cfg.usersFile; SWARM_AUTHELIA_BRIDGE_USERS_FILE = cfg.usersFile;
# The CONFIGURED authelia, not whatever is on `PATH`: the # The CONFIGURED authelia, not whatever is on `PATH`: the
# argon2 parameters baked into a hash have to match the # argon2 parameters baked into a hash have to match the

View file

@ -14,6 +14,7 @@ axum.workspace = true
reqwest.workspace = true reqwest.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
serde_norway = "0.9.42"
swarm-authelia-bridge-sock.workspace = true swarm-authelia-bridge-sock.workspace = true
tokio.workspace = true tokio.workspace = true
tracing.workspace = true tracing.workspace = true

View file

@ -32,10 +32,29 @@ simply owns the file it writes. No elevated privilege anywhere.
`swarm-controller` (loopback) and a split-host one (bind wider + firewall) `swarm-controller` (loopback) and a split-host one (bind wider + firewall)
with no separate transport. with no separate transport.
## Known limitation ## One store, two writers
`swarmctl` still writes an independent `users.json`/`users.yml` for human This bridge and `swarmctl` both read and write authelia's `users.yml`
accounts, assuming co-location with `swarm-controller`'s host. Two directly. That is the whole arrangement — neither keeps a private copy it
canonical stores for the same physical file is a real seam, not solved considers canonical.
here — routing `swarmctl` through this bridge too is a plausible follow-up,
out of scope for the agent-identity slice this crate shipped with. It used to be otherwise, and the seam was real: each side had its own
`users.json` treated as authoritative, rendering the *same* physical
`users.yml`. A writer whose own JSON was missing could not tell "nothing
here yet" from "someone else's users", so it refused to write at all —
which is exactly what a hive with existing users hit (#3422).
What still has to hold, since two processes share the file:
- **Read before write.** Both do, so neither can drop a user the other
added between operations.
- **Unknown keys survive.** Both carry unmodelled top-level and per-user
fields through a round-trip, or whichever writes second would silently
delete what the first added.
- **Every user gets an email.** A relying party asking for the claim fails
rather than degrades, so both write paths fill in a synthetic address
when none was supplied.
Neither restarts authelia: it watches the file. `swarmctl` *could* (it is
root); this bridge deliberately cannot, and a reload that depends on which
process wrote is not a reload.

View file

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

View file

@ -1,27 +1,22 @@
//! The canonical user store this bridge owns, and the authelia users //! Authelia's users database — **the** store, not a rendering of one.
//! database rendered from it.
//! //!
//! Deliberately its own copy, not shared code with `swarmctl::users` even //! There used to be a private `users.json` here, canonical, with `users.yml`
//! though the YAML shape is identical (same "factor out later if //! rendered from it, while `swarmctl` kept its own pair against the *same*
//! duplication actually bites" reasoning as `swarm-controller::forge` //! physical file. Two canonical stores for one file is a seam, and it bit: a
//! mirroring `hive-c0re::forge` — see the agent-identity-at-swarm-level //! writer whose own JSON was absent refused to write at all, unable to tell
//! design thread). One real difference from `swarmctl`'s copy: this //! "nothing here yet" from "someone else's users". The JSON bought nothing —
//! store lives **wherever this //! it was read back on every load, so the round-trip it seemed to avoid was
//! bridge runs** (inside the `swarm-authelia` container, alongside //! already being paid.
//! `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.
//! //!
//! ⚠️ **Known limitation, not solved here**: `swarmctl` still writes its //! ⚠️ The file is **round-tripped**, so comments and hand-formatting do not
//! own independent `users.json`/`users.yml` for human accounts, assuming //! survive a write; values and unmodelled keys do (see `extra`).
//! co-location with `swarm-controller`'s host. Two independent canonical //!
//! stores for the same physical `users.yml` is a real seam — tracked as a //! Deliberately its own copy of the shape rather than code shared with
//! follow-up (route `swarmctl` through this bridge too), not attempted in //! `swarmctl::users` — same "factor out if the duplication actually bites"
//! this slice, whose scope is agent identities only. //! reasoning as `swarm-controller::forge` mirroring `hive-c0re::forge`. What
//! both copies must uphold is in `../README.md`.
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::fs::{self, File, Permissions}; use std::fs::{self, File, Permissions};
use std::io::Write as _; use std::io::Write as _;
use std::os::unix::fs::PermissionsExt as _; use std::os::unix::fs::PermissionsExt as _;
@ -30,17 +25,21 @@ use std::path::Path;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// What the `swarm-authelia` module's first-boot unit writes into /// The canonical store — this *is* `users.yml`, deserialised. `BTreeMap`
/// `users.yml` when there is no database yet — same constant/shape /// for a stable, diffable file: authelia does not care about key order, but
/// `swarmctl::users::SEED_USERS_FILE` uses, since both write the same /// a human reading `git diff` does.
/// physical file format.
pub const SEED_USERS_FILE: &str = "users: {}";
/// The canonical store, serialised as JSON. `BTreeMap` for a stable,
/// diffable render — same rationale as `swarmctl::users::UserStore`.
#[derive(Debug, Default, Serialize, Deserialize)] #[derive(Debug, Default, Serialize, Deserialize)]
pub struct UserStore { pub struct UserStore {
pub users: BTreeMap<String, User>, pub users: BTreeMap<String, User>,
/// Top-level keys this binary does not model, carried through a
/// round-trip untouched.
///
/// Two processes write this file and neither is authoritative about the
/// other's fields. Without this, whichever writes second silently
/// deletes anything the first added — the same shape as the bug that
/// made one file canonical in the first place, one level down.
#[serde(flatten)]
pub extra: BTreeMap<String, serde_norway::Value>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@ -52,6 +51,10 @@ pub struct User {
pub email: Option<String>, pub email: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<String>, pub groups: Vec<String>,
/// Per-user keys this binary does not model (authelia's `disabled`, for
/// one). Same preservation rule as [`UserStore::extra`].
#[serde(flatten)]
pub extra: BTreeMap<String, serde_norway::Value>,
} }
/// Same conservative charset `swarmctl::users::validate_username` already /// Same conservative charset `swarmctl::users::validate_username` already
@ -87,86 +90,61 @@ fn reject_control_chars(field: &str, value: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// A double-quoted YAML scalar — everything is quoted, including values /// Render the store as authelia's YAML users database.
/// that would be fine bare, since an argon2 digest alone contains `$`, ///
/// `=`, `,` and `/`. Control characters are excluded upstream, so `"` and /// Serialisation is `serde_norway`'s — quoting an argon2 digest (`$`, `=`,
/// `\` are the complete escape set. /// `,`, `/`) is exactly the kind of thing a real emitter gets right and a
fn quote(s: &str) -> String { /// hand-rolled one gets right until it doesn't.
let mut out = String::with_capacity(s.len() + 2); ///
out.push('"'); /// ⚠️ **Still fallible, and that is the load-bearing part.** It re-validates
for c in s.chars() { /// every value it is about to emit, so *"no control character ever reaches
match c { /// this file"* stays a property of the **one path that writes it** rather
'"' => out.push_str("\\\""), /// than a rule every caller has to remember. A bare `to_string(&store)`
'\\' => out.push_str("\\\\"), /// would serialise perfectly and drop that guarantee silently.
_ => out.push(c), pub fn render_yaml(store: &UserStore) -> Result<String> {
} validate(store)?;
} serde_norway::to_string(store).context("serialising the users database")
out.push('"');
out
} }
/// Render the store as authelia's block-style YAML users database. /// Everything [`render_yaml`] refuses to write. Separate so the rule can be
/// Fallible for the same reason `swarmctl::users::render_yaml` is: it /// tested directly, and so a second writer can call it without going
/// re-validates every value it is about to emit, so "no control character /// through serialisation.
/// ever reaches the file" is a property of the one path that writes it. fn validate(store: &UserStore) -> Result<()> {
pub fn render_yaml(store: &UserStore) -> Result<String> {
if store.users.is_empty() {
return Ok(format!("{SEED_USERS_FILE}\n"));
}
let mut out = String::from("users:\n");
for (name, user) in &store.users { for (name, user) in &store.users {
validate_username(name)?; validate_username(name)?;
reject_control_chars("displayname", &user.displayname)?; reject_control_chars("displayname", &user.displayname)?;
reject_control_chars("password digest", &user.password)?; reject_control_chars("password digest", &user.password)?;
writeln!(out, " {name}:")?;
writeln!(out, " displayname: {}", quote(&user.displayname))?;
writeln!(out, " password: {}", quote(&user.password))?;
if let Some(email) = &user.email { if let Some(email) = &user.email {
reject_control_chars("email", email)?; reject_control_chars("email", email)?;
writeln!(out, " email: {}", quote(email))?;
} }
if !user.groups.is_empty() {
writeln!(out, " groups:")?;
for group in &user.groups { for group in &user.groups {
reject_control_chars("group", group)?; reject_control_chars("group", group)?;
writeln!(out, " - {}", quote(group))?;
} }
} }
} Ok(())
Ok(out)
} }
/// Load the canonical store, or start an empty one if this bridge has /// Load the users database, or start empty when it does not exist yet.
/// never written a user. Same overwrite guard as ///
/// `swarmctl::load_store`: starting empty means the next write /// ⚠️ **There is deliberately no overwrite guard here any more.** The old
/// **overwrites** `users_file`, which is only safe when that file is /// one refused to write when a private JSON store was missing but
/// still the untouched first-boot seed. /// `users.yml` held users — it existed to police *two* stores that could
pub fn load_store(store_path: &Path, users_file: &Path) -> Result<UserStore> { /// disagree, and with one store there is nothing to disagree with. Reading
match fs::read_to_string(store_path) { /// the same file we are about to write means we cannot clobber users we did
Ok(raw) => serde_json::from_str(&raw) /// not know about: we just read them.
.with_context(|| format!("parsing the user store at {}", store_path.display())), ///
Err(e) if e.kind() == std::io::ErrorKind::NotFound => { /// An absent or empty file is an empty store, not an error: first boot is a
/// legitimate state, and the seed document authelia's own unit writes
/// (`users: {}`) deserialises to exactly that with no special case.
pub fn load_store(users_file: &Path) -> Result<UserStore> {
match fs::read_to_string(users_file) { match fs::read_to_string(users_file) {
Ok(existing) if !is_untouched_seed(&existing) => bail!( Ok(raw) if raw.trim().is_empty() => Ok(UserStore::default()),
"no user store at {} but {} already holds users — refusing to \ Ok(raw) => serde_norway::from_str(&raw)
overwrite it", .with_context(|| format!("parsing the users database at {}", users_file.display())),
store_path.display(), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(UserStore::default()),
users_file.display() Err(e) => Err(e).with_context(|| format!("reading {}", users_file.display())),
),
Ok(_) | Err(_) => Ok(UserStore::default()),
} }
} }
Err(e) => Err(e).with_context(|| format!("reading {}", store_path.display())),
}
}
/// Whether an existing `users.yml` is safe to take over — i.e. it is the
/// untouched first-boot seed. Empty counts too: a zero-byte file holds
/// nothing to lose.
fn is_untouched_seed(contents: &str) -> bool {
let trimmed = contents.trim();
trimmed.is_empty() || trimmed == SEED_USERS_FILE
}
/// Write the store + the rendered users file. **No restart** (the load- /// Write the store + the rendered users file. **No restart** (the load-
/// bearing difference from `swarmctl::publish`): this bridge relies on /// bearing difference from `swarmctl::publish`): this bridge relies on
@ -174,15 +152,41 @@ fn is_untouched_seed(contents: &str) -> bool {
/// against the pinned 4.39.20 build during this design work — a /// against the pinned 4.39.20 build during this design work — a
/// restart would drop every active SSO session, which mara ruled out for /// restart would drop every active SSO session, which mara ruled out for
/// agent creation specifically (not a rare, human-initiated event). /// agent creation specifically (not a rare, human-initiated event).
pub fn publish(store_path: &Path, users_file: &Path, store: &UserStore) -> Result<()> { pub fn publish(users_file: &Path, store: &mut UserStore) -> Result<()> {
fill_missing_emails(store);
// One file, so there is no longer an ordering question between two
// writes — the old version wrote the JSON store first so a crash
// between them left something to repair from. That whole failure mode
// belonged to having two files.
let rendered = render_yaml(store)?; let rendered = render_yaml(store)?;
let store_json = serde_json::to_string_pretty(store).context("serialising the user store")?;
// Store first — if the store lands and the users file doesn't, the
// next run re-renders and repairs it. The other order loses a user.
write_atomic(store_path, &format!("{store_json}\n"))?;
write_atomic(users_file, &rendered) write_atomic(users_file, &rendered)
} }
/// Domain for an address this bridge invents — the same one
/// `swarmctl::users` uses, and the same one `hive-c0re` already gives every
/// agent's forge account. Never routable, deliberately: nothing sends mail
/// here, the address exists so a relying party asking for an `email` claim
/// gets one.
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.** 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() {
user.email = Some(format!("{name}@{SYNTHETIC_EMAIL_DOMAIN}"));
}
}
}
/// Replace `path`'s contents atomically. Unlike `swarmctl::write_atomic`, /// Replace `path`'s contents atomically. Unlike `swarmctl::write_atomic`,
/// this does **not** need to preserve a foreign owner via `chown` — this /// this does **not** need to preserve a foreign owner via `chown` — this
/// process runs as `users_file`'s own owning user (see the module doc: /// process runs as `users_file`'s own owning user (see the module doc:
@ -225,29 +229,76 @@ fn write_atomic(path: &Path, contents: &str) -> Result<()> {
mod tests { mod tests {
use super::*; use super::*;
/// What the `swarm-authelia` module's first-boot unit writes into
/// `users.yml` when there is no database yet.
///
/// A test fixture rather than a production constant: nothing in this
/// binary writes a seed any more — the store deserialises whatever is
/// there and an absent file is an empty store. Keeping it `pub` in the
/// module would be a constant nothing reads, which is exactly the kind
/// of leftover that makes the next reader think the seed dance is still
/// load-bearing.
const SEED_USERS_FILE: &str = "users: {}";
fn user(password: &str) -> User { fn user(password: &str) -> User {
User { User {
displayname: "atlas".to_owned(), displayname: "atlas".to_owned(),
password: password.to_owned(), password: password.to_owned(),
email: None, email: None,
groups: Vec::new(), groups: Vec::new(),
extra: BTreeMap::new(),
} }
} }
/// The document `swarm-authelia`'s first-boot unit writes must load as
/// an empty store with no special case — otherwise a fresh hive's very
/// first `EnsureAgentIdentity` fails on a file we wrote ourselves.
#[test] #[test]
fn an_empty_store_renders_the_seed_document() { fn the_first_boot_seed_loads_as_an_empty_store() {
let out = render_yaml(&UserStore::default()).expect("renders"); let store: UserStore = serde_norway::from_str(SEED_USERS_FILE).expect("seed parses");
assert_eq!(out, "users: {}\n"); assert!(store.users.is_empty());
assert!(is_untouched_seed(&out)); assert!(store.extra.is_empty());
} }
/// An argon2 digest is `$`, `=`, `,` and `/` — the reason a real
/// emitter replaced the hand-rolled one. Asserted by **round-trip**,
/// not by looking for quotes: how the emitter chooses to quote is its
/// business, that the value survives is ours.
#[test] #[test]
fn an_argon2_digest_survives_quoting() { fn an_argon2_digest_survives_a_round_trip() {
let digest = "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$aGFzaA+/w=="; let digest = "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$aGFzaA+/w==";
let mut store = UserStore::default(); let mut store = UserStore::default();
store.users.insert("atlas".to_owned(), user(digest)); store.users.insert("atlas".to_owned(), user(digest));
let out = render_yaml(&store).expect("renders"); let out = render_yaml(&store).expect("renders");
assert!(out.contains(&format!("password: \"{digest}\""))); let back: UserStore = serde_norway::from_str(&out).expect("reparses");
assert_eq!(back.users["atlas"].password, digest);
}
/// A key neither writer models must survive the other's write. Without
/// this, two processes sharing one file silently delete each other's
/// fields — the same class of bug as the two stores this replaced.
#[test]
fn unknown_keys_survive_a_round_trip() {
let source = "\
users:
atlas:
displayname: atlas
password: \"$argon2id$x\"
disabled: true
some_future_top_level_key: 7
";
let store: UserStore = serde_norway::from_str(source).expect("parses");
let out = render_yaml(&store).expect("renders");
assert!(
out.contains("disabled"),
"a per-user key this binary does not model was dropped: {out}"
);
assert!(
out.contains("some_future_top_level_key"),
"a top-level key this binary does not model was dropped: {out}"
);
} }
#[test] #[test]
@ -275,21 +326,78 @@ mod tests {
/// `write_atomic` round-trips through a real temp dir — the property /// `write_atomic` round-trips through a real temp dir — the property
/// under test is the rename-into-place, not just the render. /// under test is the rename-into-place, not just the render.
/// 🩸 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
/// there and the file would notice.
#[test] #[test]
fn publish_writes_both_files_and_the_store_reloads() { fn an_agent_identity_reaches_the_file_with_an_email() {
let dir = tempdir();
let users_file = dir.join("users.yml");
let mut store = UserStore::default();
store.users.insert("atlas".to_owned(), user("$argon2id$x"));
assert!(
store.users["atlas"].email.is_none(),
"the fixture must start with the state `handle` creates"
);
publish(&users_file, &mut store).expect("publish");
let reloaded = load_store(&users_file).expect("reload");
assert_eq!(
reloaded.users["atlas"].email.as_deref(),
Some("atlas@hyperhive.local"),
"an agent must not land in the file without an email"
);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn publish_then_load_round_trips_through_the_real_file() {
let dir = tempdir(); let dir = tempdir();
let store_path = dir.join("users.json");
let users_file = dir.join("users.yml"); let users_file = dir.join("users.yml");
fs::write(&users_file, SEED_USERS_FILE).unwrap(); fs::write(&users_file, SEED_USERS_FILE).unwrap();
let mut store = UserStore::default(); let mut store = UserStore::default();
store.users.insert("atlas".to_owned(), user("$argon2id$x")); store.users.insert("atlas".to_owned(), user("$argon2id$x"));
publish(&store_path, &users_file, &store).expect("publish"); publish(&users_file, &mut store).expect("publish");
let reloaded = load_store(&store_path, &users_file).expect("reload"); let reloaded = load_store(&users_file).expect("reload");
assert!(reloaded.users.contains_key("atlas"));
fs::remove_dir_all(&dir).ok();
}
/// The bug this change closes: a `users.yml` holding users that this
/// binary did not write is **loaded**, not refused. The old code kept a
/// private store alongside it and bailed when the two disagreed.
#[test]
fn a_users_file_written_by_someone_else_is_read_not_refused() {
let dir = tempdir();
let users_file = dir.join("users.yml");
fs::write(
&users_file,
"users:\n mara:\n displayname: mara\n password: \"$argon2id$x\"\n",
)
.unwrap();
let mut store = load_store(&users_file).expect("must load a foreign users file");
assert!(store.users.contains_key("mara"));
// …and adding to it keeps the existing user rather than replacing.
store
.users
.insert("atlas".to_owned(), user("$argon2id$second"));
publish(&users_file, &mut store).expect("publish");
let reloaded = load_store(&users_file).expect("reload");
assert!(reloaded.users.contains_key("mara"), "existing user lost");
assert!(reloaded.users.contains_key("atlas")); assert!(reloaded.users.contains_key("atlas"));
let yaml = fs::read_to_string(&users_file).unwrap();
assert!(yaml.contains("atlas"));
fs::remove_dir_all(&dir).ok(); fs::remove_dir_all(&dir).ok();
} }

View file

@ -14,7 +14,7 @@ clap.workspace = true
clap-markdown = "0.1" clap-markdown = "0.1"
clap_complete.workspace = true clap_complete.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_norway = "0.9.42"
[lints] [lints]
workspace = true workspace = true

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` a **group-gated admin socket**, separate from the controller's `0666`
gateway-facing one — not a widening of what root does here. 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` — authelia's own users database — is read and written
- `users.yml` — a **rendered artifact** for authelia. Written, never read directly. There is no second store.
back.
The split is what lets this crate work without a YAML parser: the There used to be: a private `users.json` here, canonical, with `users.yml`
workspace has none, and adding one costs a crates.io fetch, a lock update rendered from it, while `swarm-authelia-bridge` kept its own pair against
and a vendor hash for a schema we fully control and only ever emit. 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 The argument for the split was that it let this crate work without a YAML
YAML) is deliberately not taken: authelia refuses to start on a users file parser. It didn't: the JSON was read back on every run, so the round-trip
it cannot parse, so that file fronts the whole SSO provider's boot, and was already being paid — the two files differed only in *format*.
"almost certainly parses" is not a claim worth betting a boot on without
running it. ⚠️ 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 ## Configuration
@ -53,7 +56,6 @@ an error.
| `SWARMCTL_AUTHELIA_USERS_FILE` | host-side path of the users database | | `SWARMCTL_AUTHELIA_USERS_FILE` | host-side path of the users database |
| `SWARMCTL_AUTHELIA_MACHINE` | container name, for `systemctl -M` | | `SWARMCTL_AUTHELIA_MACHINE` | container name, for `systemctl -M` |
| `SWARMCTL_AUTHELIA_UNIT` | authelia's unit inside that container | | `SWARMCTL_AUTHELIA_UNIT` | authelia's unit inside that container |
| `SWARMCTL_STORE` | canonical store (defaults to the controller's state dir) |
## Usage ## Usage

View file

@ -34,13 +34,6 @@ use clap::{Args, Parser, Subcommand};
use users::{User, UserStore}; use users::{User, UserStore};
/// Canonical user store. A compiled-in default is legitimate here for the
/// same reason it is on the daemon's socket path: this is a path this
/// process **creates**, not an address it hopes to find something at. It
/// lives under the controller's state directory because the controller is
/// this store's eventual reader.
const DEFAULT_STORE: &str = "/var/lib/swarm-controller/users.json";
#[derive(Parser)] #[derive(Parser)]
#[command(name = "swarmctl", version, about = "swarm-level operator CLI")] #[command(name = "swarmctl", version, about = "swarm-level operator CLI")]
struct Cli { struct Cli {
@ -67,17 +60,22 @@ struct PathArgs {
authelia_bin: Option<PathBuf>, authelia_bin: Option<PathBuf>,
/// Host-side path of authelia's users database — i.e. the path inside /// Host-side path of authelia's users database — i.e. the path inside
/// the container, prefixed with the container's root. /// the container, prefixed with the container's root.
///
/// 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")] #[arg(long, value_name = "PATH")]
users_file: Option<PathBuf>, users_file: Option<PathBuf>,
/// Canonical user store.
#[arg(long, value_name = "PATH")]
store: Option<PathBuf>,
} }
struct Paths { struct Paths {
authelia_bin: PathBuf, authelia_bin: PathBuf,
users_file: PathBuf, users_file: PathBuf,
store: PathBuf,
} }
impl PathArgs { impl PathArgs {
@ -85,10 +83,6 @@ impl PathArgs {
Ok(Paths { Ok(Paths {
authelia_bin: path_from(self.authelia_bin, "SWARMCTL_AUTHELIA_BIN")?, authelia_bin: path_from(self.authelia_bin, "SWARMCTL_AUTHELIA_BIN")?,
users_file: path_from(self.users_file, "SWARMCTL_AUTHELIA_USERS_FILE")?, users_file: path_from(self.users_file, "SWARMCTL_AUTHELIA_USERS_FILE")?,
store: self
.store
.or_else(|| std::env::var_os("SWARMCTL_STORE").map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from(DEFAULT_STORE)),
}) })
} }
} }
@ -157,12 +151,12 @@ enum UserVerb {
/// editing an attribute, and folded together an attribute edit can /// editing an attribute, and folded together an attribute edit can
/// invalidate a login by accident. /// invalidate a login by accident.
Update(UpdateArgs), 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 /// Read-only: it never writes the file. Shows every subject in it,
/// is a derived artifact this crate writes and never reads back (see /// including agent identities `swarm-authelia-bridge` created — one
/// the crate doc comment). One line per user: username, display name, /// line per user: username, display name, email (if set), groups (if
/// email (if set), groups (if any). /// any).
List, List,
} }
@ -238,12 +232,12 @@ fn main() -> Result<()> {
fn user_add(paths: &Paths, args: AddArgs) -> Result<()> { fn user_add(paths: &Paths, args: AddArgs) -> Result<()> {
users::validate_username(&args.username)?; users::validate_username(&args.username)?;
let mut store = load_store(&paths.store, &paths.users_file)?; let mut store = load_store(&paths.users_file)?;
if store.users.contains_key(&args.username) { if store.users.contains_key(&args.username) {
bail!( bail!(
"user {:?} already exists in {}", "user {:?} already exists in {}",
args.username, args.username,
paths.store.display() paths.users_file.display()
); );
} }
@ -256,10 +250,11 @@ fn user_add(paths: &Paths, args: AddArgs) -> Result<()> {
password: generated.digest, password: generated.digest,
email: args.email, email: args.email,
groups: args.groups, groups: args.groups,
extra: std::collections::BTreeMap::new(),
}, },
); );
publish(paths, &store)?; publish(paths, &mut store)?;
println!("added {} to {}", args.username, paths.users_file.display()); println!("added {} to {}", args.username, paths.users_file.display());
println!("password: {}", generated.password); println!("password: {}", generated.password);
@ -268,12 +263,12 @@ fn user_add(paths: &Paths, args: AddArgs) -> Result<()> {
} }
fn user_update(paths: &Paths, args: UpdateArgs) -> Result<()> { fn user_update(paths: &Paths, args: UpdateArgs) -> Result<()> {
let mut store = load_store(&paths.store, &paths.users_file)?; let mut store = load_store(&paths.users_file)?;
let Some(user) = store.users.get_mut(&args.username) else { let Some(user) = store.users.get_mut(&args.username) else {
bail!( bail!(
"no user {:?} in {} — `swarmctl user add` creates one", "no user {:?} in {} — `swarmctl user add` creates one",
args.username, args.username,
paths.store.display() paths.users_file.display()
); );
}; };
@ -292,7 +287,7 @@ fn user_update(paths: &Paths, args: UpdateArgs) -> Result<()> {
// list is the only way to notice. // list is the only way to notice.
let groups = users::fmt_groups(&user.groups); let groups = users::fmt_groups(&user.groups);
publish(paths, &store)?; publish(paths, &mut store)?;
for change in &changes { for change in &changes {
println!("{change}"); println!("{change}");
@ -301,14 +296,14 @@ fn user_update(paths: &Paths, args: UpdateArgs) -> Result<()> {
Ok(()) Ok(())
} }
/// `swarmctl user list` — read-only, never touches authelia's users file /// `swarmctl user list` — read-only: it loads authelia's users file and
/// or restarts it. Prints one line per user from the canonical store. /// writes nothing. One line per user, agent identities included.
fn user_list(paths: &Paths) -> Result<()> { fn user_list(paths: &Paths) -> Result<()> {
use std::fmt::Write as _; use std::fmt::Write as _;
let store = load_store(&paths.store, &paths.users_file)?; let store = load_store(&paths.users_file)?;
if store.users.is_empty() { if store.users.is_empty() {
println!("no users in {}", paths.store.display()); println!("no users in {}", paths.users_file.display());
return Ok(()); return Ok(());
} }
for (username, user) in &store.users { for (username, user) in &store.users {
@ -324,23 +319,24 @@ fn user_list(paths: &Paths) -> Result<()> {
Ok(()) Ok(())
} }
/// Write the store + the rendered users file, then restart authelia. /// Write the users database.
/// ///
/// Shared by every verb that mutates the store, so the ordering rules /// Shared by every verb that mutates it, so the rules below hold for all of
/// below hold for all of them rather than for whichever one was written /// them rather than for whichever one was written first.
/// first. fn publish(paths: &Paths, store: &mut UserStore) -> Result<()> {
fn publish(paths: &Paths, store: &UserStore) -> Result<()> { // Before rendering, not at creation: a user can also arrive by being
// Render before writing anything: a value this refuses to emit should // *read* — from a file the bridge wrote, or one an operator edited —
// stop the whole operation, not leave the canonical store one user // and an authelia subject with no `email` breaks any relying party that
// ahead of the file authelia reads. // asks for the claim (grafana's OIDC login is the measured case: it
let rendered = users::render_yaml(store)?; // fails outright rather than degrading). Filling it here is the only
let store_json = serde_json::to_string_pretty(store).context("serialising the user store")?; // 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");
}
// Store first, and the order matters. If the store lands and the // Render before writing: a value this refuses to emit should stop the
// users file does not, the next run re-renders and repairs it. The // whole operation rather than land a partial file.
// other order loses a user: the store would not know about someone let rendered = users::render_yaml(store)?;
// authelia does, and the next render would silently drop them.
write_atomic(&paths.store, &format!("{store_json}\n"))?;
write_atomic(&paths.users_file, &rendered)?; write_atomic(&paths.users_file, &rendered)?;
// No restart: authelia watches this file // No restart: authelia watches this file
@ -351,38 +347,25 @@ fn publish(paths: &Paths, store: &UserStore) -> Result<()> {
Ok(()) Ok(())
} }
/// Load the canonical store, or start an empty one if this deployment has /// Load the users database, or start empty when it does not exist yet.
/// never had a user added.
/// ///
/// The guard exists because starting empty means the next write /// ⚠️ **The overwrite guard that used to live here is gone, and its absence
/// **overwrites** authelia's users file. That is only safe when the file /// is the fix rather than a regression.** It refused to write when this
/// is the untouched first-boot seed; anything else is a user database /// crate's own JSON store was missing but `users.yml` held users — it
/// somebody meant to be there, and losing it is the one unrecoverable /// existed to police *two* stores that could disagree, and there is now one.
/// mistake this tool can make. /// Reading the same file we are about to write means we cannot clobber users
fn load_store(store_path: &Path, users_file: &Path) -> Result<UserStore> { /// we did not know about: we just read them.
match fs::read_to_string(store_path) { ///
Ok(raw) => serde_json::from_str(&raw) /// An absent or empty file is an empty store, not an error. First boot is a
.with_context(|| format!("parsing the user store at {}", store_path.display())), /// legitimate state, and the seed document authelia's own unit writes
Err(e) if e.kind() == std::io::ErrorKind::NotFound => { /// (`users: {}`) deserialises to exactly that with no special case.
fn load_store(users_file: &Path) -> Result<UserStore> {
match fs::read_to_string(users_file) { match fs::read_to_string(users_file) {
Ok(existing) if !users::is_untouched_seed(&existing) => bail!( Ok(raw) if raw.trim().is_empty() => Ok(UserStore::default()),
"no user store at {} but {} already holds users — refusing to \ Ok(raw) => serde_norway::from_str(&raw)
overwrite it. Reconstruct the store, or move the file aside if \ .with_context(|| format!("parsing the users database at {}", users_file.display())),
it is disposable.",
store_path.display(),
users_file.display()
),
Ok(_) => Ok(UserStore::default()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(UserStore::default()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(UserStore::default()),
Err(e) => Err(e).with_context(|| { Err(e) => Err(e).with_context(|| format!("reading {}", users_file.display())),
format!(
"reading {} to check it is safe to take over",
users_file.display()
)
}),
}
}
Err(e) => Err(e).with_context(|| format!("reading {}", store_path.display())),
} }
} }
@ -562,25 +545,40 @@ mod tests {
fs::remove_dir_all(&dir).ok(); fs::remove_dir_all(&dir).ok();
} }
/// The guard that stands between a missing store and an overwritten /// Replaces `load_store_refuses_to_take_over_a_populated_users_file`,
/// user database. /// which pinned the guard this change removes.
///
/// The guard refused to write when this crate's own JSON store was
/// missing but `users.yml` held users — the state every real hive is in,
/// and the reason `swarm agent create` failed. Deleting the test with
/// the guard would have left the *new* behaviour unpinned, so it is
/// inverted rather than dropped: a populated file must now be **read**,
/// and users somebody else wrote must survive.
#[test] #[test]
fn load_store_refuses_to_take_over_a_populated_users_file() { fn a_populated_users_file_is_read_rather_than_refused() {
let dir = std::env::temp_dir().join(format!("swarmctl-guard-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("swarmctl-takeover-{}", std::process::id()));
fs::create_dir_all(&dir).expect("temp dir"); fs::create_dir_all(&dir).expect("temp dir");
let store = dir.join("absent.json");
let users_file = dir.join("users.yml"); let users_file = dir.join("users.yml");
fs::write(&users_file, "users:\n mara:\n password: \"x\"\n").expect("write"); fs::write(
let err = load_store(&store, &users_file).expect_err("must refuse"); &users_file,
"users:\n mara:\n displayname: \"mara\"\n password: \"x\"\n",
)
.expect("write");
let store = load_store(&users_file).expect("a populated file is readable");
assert!( assert!(
err.to_string().contains("refusing to overwrite"), store.users.contains_key("mara"),
"unexpected error: {err}" "a user this process did not write must be read, not refused"
); );
fs::write(&users_file, "users: {}\n").expect("seed"); fs::write(&users_file, "users: {}\n").expect("seed");
let taken = load_store(&store, &users_file).expect("the seed is takeable"); assert!(
assert!(taken.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"
);
fs::remove_dir_all(&dir).ok(); fs::remove_dir_all(&dir).ok();
} }

View file

@ -1,49 +1,46 @@
//! The swarm's SSO user store, and the authelia users database rendered //! The swarm's SSO user store — authelia's own `users.yml`, read and
//! from it. //! written directly.
//! //!
//! **Two files, and only one of them is authoritative.** `users.json` is //! **One file.** There used to be a private `users.json` here, canonical,
//! ours and canonical; `users.yml` is a rendered artifact for authelia, //! with `users.yml` rendered from it — and `swarm-authelia-bridge` kept its
//! written and never read back. //! own pair against the *same* physical `users.yml`. Two canonical stores
//! for one file is a seam, and it bit: a writer whose own store was missing
//! could not tell *"nothing here yet"* from *"someone else's users"*, and
//! refused to write at all.
//! //!
//! That split is not a preference, it is what lets this crate exist //! The JSON bought nothing. The old module doc argued it let this crate
//! without a YAML parser: the workspace has none, and adding one costs a //! exist without a YAML parser — but `load_store` read the JSON back on
//! crates.io fetch, a lock update and a vendor hash — for a schema we //! every run, so the round-trip was already being paid; the two files
//! fully control and only ever emit. Rendering in one direction needs no //! differed only in *format*.
//! parser at all.
//! //!
//! The tempting shortcut — *"JSON is a subset of YAML, so just write JSON //! ⚠️ Consequence, deliberate: this file is **round-tripped**, so comments
//! into the `.yml` and read it back with `serde_json`"* — is rejected on //! and hand-formatting do not survive a write. An operator editing it
//! purpose. authelia **refuses to start** on a users file it cannot //! directly keeps their *values* and loses their *comments*. Unknown keys
//! parse, so it fronts the whole SSO provider's boot, and "go-yaml //! survive (see `extra`), so a field this binary does not model is not
//! almost certainly accepts flow style" is not a claim worth betting that //! deleted by it.
//! on without running it. Block style is what upstream's own examples
//! show.
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fmt::Write as _;
use anyhow::{Result, bail}; use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// What the swarm-authelia module's first-boot unit writes into /// The canonical store — this *is* `users.yml`, deserialised.
/// `users.yml` when there is no database yet.
/// ///
/// Matched (after trimming) so a first `user add` can take the file over /// `BTreeMap` rather than `HashMap` so the file is stable between runs: a
/// without a flag, while anything *else* already in place is treated as /// diffable artifact is worth more than the ordering being meaningless, and
/// content somebody meant to be there. Overwriting a live SSO user /// a random-order rewrite makes every change look like a whole-file change.
/// database because a canonical store happened to be missing is the one
/// unrecoverable mistake available here.
pub const SEED_USERS_FILE: &str = "users: {}";
/// The canonical store, serialised as JSON.
///
/// `BTreeMap` rather than `HashMap` so the rendered YAML is stable
/// between runs: a diffable artifact is worth more than the ordering
/// being meaningless, and a random-order rewrite makes every change look
/// like a whole-file change.
#[derive(Debug, Default, Serialize, Deserialize)] #[derive(Debug, Default, Serialize, Deserialize)]
pub struct UserStore { pub struct UserStore {
pub users: BTreeMap<String, User>, pub users: BTreeMap<String, User>,
/// Top-level keys this binary does not model, carried through a
/// round-trip untouched.
///
/// Two processes write this file and neither is authoritative about the
/// other's fields. Without this, whichever writes second silently
/// deletes whatever the first added — the same shape as the bug that
/// made one file canonical in the first place, one level down.
#[serde(flatten)]
pub extra: BTreeMap<String, serde_norway::Value>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@ -57,6 +54,10 @@ pub struct User {
pub email: Option<String>, pub email: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<String>, pub groups: Vec<String>,
/// Per-user keys this binary does not model (authelia's `disabled`, for
/// one). Same preservation rule as [`UserStore::extra`].
#[serde(flatten)]
pub extra: BTreeMap<String, serde_norway::Value>,
} }
/// Usernames are map **keys** in a YAML document, and they also reach /// Usernames are map **keys** in a YAML document, and they also reach
@ -100,28 +101,6 @@ fn reject_control_chars(field: &str, value: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// A double-quoted YAML scalar.
///
/// Everything is quoted, including values that would be fine bare: the
/// argon2 digest alone contains `$`, `=`, `,` and `/`, and deciding
/// per-value which of those need quoting is exactly the kind of judgement
/// that is right until one day it isn't. Control characters are excluded
/// upstream by [`reject_control_chars`], so `"` and `\` are the complete
/// escape set.
fn quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
_ => out.push(c),
}
}
out.push('"');
out
}
/// Domain for an address this crate invents. /// Domain for an address this crate invents.
/// ///
/// The same one `hive-c0re` already gives every agent's forge account /// The same one `hive-c0re` already gives every agent's forge account
@ -145,52 +124,66 @@ const SYNTHETIC_EMAIL_DOMAIN: &str = "hyperhive.local";
/// implement, and the login dies with `InternalError` rather than anything /// implement, and the login dies with `InternalError` rather than anything
/// naming the missing field. /// naming the missing field.
/// ///
/// Synthesised in the **renderer**, never written to the store: `users.json` /// Synthesised on the **write path** and stored, because the file *is* the
/// stays honest that no address was supplied, so an operator who later sets a /// store now: "rendered but not persisted" no longer has anywhere to live.
/// real one is not fighting a value swarmctl invented, and every existing user /// mara ruled it directly — *"email in yml is what is already there and
/// is fixed by the next render with no migration step. /// correct"* — so a synthesised address is simply the user's address, and
/// the next `user update --email` overwrites it like any other value.
fn synthetic_email(username: &str) -> String { fn synthetic_email(username: &str) -> String {
format!("{username}@{SYNTHETIC_EMAIL_DOMAIN}") format!("{username}@{SYNTHETIC_EMAIL_DOMAIN}")
} }
/// Render the store as authelia's block-style YAML users database. /// Give every user an address before the store is written.
/// ///
/// Fallible because it re-runs validation over every value it is about to /// Returns the names it filled, so a caller can say what it did rather than
/// emit. That is not belt-and-braces: it makes "no control character ever /// changing the file silently.
/// reaches the file" a property of the *only* path that writes it, rather ///
/// than a rule the call sites have to remember. /// On the write path rather than at creation, because users also arrive by
pub fn render_yaml(store: &UserStore) -> Result<String> { /// being *read* — from a file another writer produced, or one an operator
if store.users.is_empty() { /// edited. Fixing them where they enter would need every entry point to
return Ok(format!("{SEED_USERS_FILE}\n")); /// remember; fixing them where they leave cannot be forgotten.
pub fn fill_missing_emails(store: &mut UserStore) -> Vec<String> {
let mut filled = Vec::new();
for (name, user) in &mut store.users {
if user.email.is_none() {
user.email = Some(synthetic_email(name));
filled.push(name.clone());
}
}
filled
} }
let mut out = String::from("users:\n"); /// Serialise the store as authelia's YAML users database.
///
/// Serialisation is `serde_norway`'s: quoting an argon2 digest (`$`, `=`,
/// `,`, `/`) is exactly the kind of thing a real emitter gets right and a
/// hand-rolled one gets right until it doesn't.
///
/// ⚠️ **Still fallible, and that is the load-bearing part.** It re-runs
/// validation over every value it is about to emit, which is what makes
/// *"no control character ever reaches this file"* a property of the **one
/// path that writes it** rather than a rule every call site must remember.
/// A bare `to_string(&store)` serialises perfectly and drops that silently.
pub fn render_yaml(store: &UserStore) -> Result<String> {
validate(store)?;
serde_norway::to_string(store).context("serialising the users database")
}
/// Everything [`render_yaml`] refuses to write. Separate so the rule can be
/// tested directly, without going through serialisation.
fn validate(store: &UserStore) -> Result<()> {
for (name, user) in &store.users { for (name, user) in &store.users {
validate_username(name)?; validate_username(name)?;
reject_control_chars("displayname", &user.displayname)?; reject_control_chars("displayname", &user.displayname)?;
reject_control_chars("password digest", &user.password)?; reject_control_chars("password digest", &user.password)?;
if let Some(email) = &user.email {
writeln!(out, " {name}:")?; reject_control_chars("email", email)?;
writeln!(out, " displayname: {}", quote(&user.displayname))?; }
writeln!(out, " password: {}", quote(&user.password))?;
// Unconditional: an absent email is the failure mode, not a valid
// rendering. The synthetic address is validated on the same path as
// a supplied one so neither can smuggle a control character.
let email = match &user.email {
Some(supplied) => supplied.clone(),
None => synthetic_email(name),
};
reject_control_chars("email", &email)?;
writeln!(out, " email: {}", quote(&email))?;
if !user.groups.is_empty() {
writeln!(out, " groups:")?;
for group in &user.groups { for group in &user.groups {
reject_control_chars("group", group)?; reject_control_chars("group", group)?;
writeln!(out, " - {}", quote(group))?;
} }
} }
} Ok(())
Ok(out)
} }
/// A requested change to an existing user. /// A requested change to an existing user.
@ -304,17 +297,6 @@ pub fn fmt_groups(groups: &[String]) -> String {
} }
} }
/// Whether an existing `users.yml` is safe to take over when no canonical
/// store exists yet — i.e. it is the untouched first-boot seed.
///
/// Empty counts: a zero-byte file holds no users to destroy, and authelia
/// would refuse to start on it anyway, so treating it as untouched turns
/// a dead deployment into a working one instead of demanding a flag.
pub fn is_untouched_seed(contents: &str) -> bool {
let trimmed = contents.trim();
trimmed.is_empty() || trimmed == SEED_USERS_FILE
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -325,39 +307,91 @@ mod tests {
password: password.to_owned(), password: password.to_owned(),
email: None, email: None,
groups: Vec::new(), groups: Vec::new(),
extra: BTreeMap::new(),
} }
} }
#[test] #[test]
fn an_empty_store_renders_the_seed_document() { fn an_empty_store_round_trips_as_an_empty_store() {
let out = render_yaml(&UserStore::default()).expect("empty store renders"); let out = render_yaml(&UserStore::default()).expect("empty store renders");
assert_eq!(out, "users: {}\n"); let back: UserStore = serde_norway::from_str(&out).expect("re-reads");
assert!( assert!(
is_untouched_seed(&out), back.users.is_empty(),
"the empty rendering must itself read as the seed, or removing the \ "removing the last user must leave a file the next run can load, got:\n{out}"
last user would produce a file the next run refuses to take over"
); );
} }
/// The digest is the value most likely to break a naive emitter: it /// The digest is the value most likely to break a naive emitter: it
/// carries `$`, `=`, `,` and `/`, and `,` in particular terminates a /// carries `$`, `=`, `,` and `/`, and `,` in particular terminates a
/// YAML flow scalar. /// YAML flow scalar. Asserted through a **round-trip** rather than
/// against a literal quoting style: which scalars a real emitter chooses
/// to quote is its business, and pinning the spelling would make this
/// test fail on a serializer upgrade that broke nothing.
#[test] #[test]
fn an_argon2_digest_survives_quoting() { fn an_argon2_digest_survives_a_round_trip() {
let digest = "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$aGFzaA+/w=="; let digest = "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$aGFzaA+/w==";
let mut store = UserStore::default(); let mut store = UserStore::default();
store.users.insert("mara".to_owned(), user(digest)); store.users.insert("mara".to_owned(), user(digest));
let out = render_yaml(&store).expect("renders"); let out = render_yaml(&store).expect("renders");
assert!( let back: UserStore = serde_norway::from_str(&out).expect("re-reads");
out.contains(&format!("password: \"{digest}\"")), assert_eq!(
"digest must be emitted verbatim inside double quotes, got:\n{out}" back.users["mara"].password, digest,
"digest must survive verbatim, got:\n{out}"
); );
} }
/// Two processes write this file and neither knows the other's fields,
/// so anything unmodelled has to survive being read and written back —
/// otherwise the second writer silently deletes the first's work, which
/// is the same class of bug as the two-canonical-stores seam this change
/// removed, one level down.
#[test] #[test]
fn quoting_escapes_backslash_and_quote() { fn unknown_keys_survive_a_round_trip() {
assert_eq!(quote(r#"a"b\c"#), r#""a\"b\\c""#); let raw = "\
theme: dark
users:
mara:
displayname: \"mara\"
password: \"$argon2id$x\"
disabled: true
";
let mut store: UserStore = serde_norway::from_str(raw).expect("parses");
let out = render_yaml(&store).expect("renders");
assert!(
out.contains("theme"),
"a top-level key this binary does not model was dropped:\n{out}"
);
assert!(
out.contains("disabled"),
"a per-user key this binary does not model was dropped:\n{out}"
);
// ...and it must still survive once we have touched the entry the
// way a real verb does.
fill_missing_emails(&mut store);
let out = render_yaml(&store).expect("renders");
assert!(out.contains("disabled"), "dropped after a write:\n{out}");
}
/// mara: *"email in yml is what is already there and correct"* — with the
/// file as the store, an address is filled in on the write path and kept,
/// rather than invented afresh by each render.
#[test]
fn a_user_with_no_email_gets_one_stored() {
let mut store = UserStore::default();
store.users.insert("mara".to_owned(), user("$argon2id$x"));
let filled = fill_missing_emails(&mut store);
assert_eq!(filled, vec!["mara".to_owned()]);
assert_eq!(
store.users["mara"].email.as_deref(),
Some("mara@hyperhive.local")
);
// Idempotent: a second pass must not report a change, or every
// publish would print a note about a user it did not touch.
assert!(fill_missing_emails(&mut store).is_empty());
} }
#[test] #[test]
@ -372,22 +406,9 @@ mod tests {
); );
} }
/// Email is deliberately NOT in the test above any more. It used to /// The earlier missing-email property, moved from the renderer to the
/// assert that an absent one is omitted, which pinned the behaviour that /// write path along with the synthesis itself: a user who supplied an
/// broke grafana's login: authelia serves no `email` claim, and a relying /// address keeps it, and no invented one appears beside it.
/// party that wants one fails rather than degrading.
#[test]
fn a_user_with_no_email_still_renders_one_from_the_shared_domain() {
let mut store = UserStore::default();
store.users.insert("mara".to_owned(), user("$argon2id$x"));
let out = render_yaml(&store).expect("renders");
assert!(
out.contains(r#"email: "mara@hyperhive.local""#),
"a user with no email must still render one:\n{out}"
);
}
#[test] #[test]
fn a_supplied_email_is_never_replaced_by_the_synthetic_one() { fn a_supplied_email_is_never_replaced_by_the_synthetic_one() {
let mut u = user("$argon2id$x"); let mut u = user("$argon2id$x");
@ -395,11 +416,16 @@ mod tests {
let mut store = UserStore::default(); let mut store = UserStore::default();
store.users.insert("mara".to_owned(), u); store.users.insert("mara".to_owned(), u);
let out = render_yaml(&store).expect("renders");
assert!( assert!(
out.contains(r#"email: "real@elsewhere.example""#), fill_missing_emails(&mut store).is_empty(),
"the supplied address must win:\n{out}" "a user with an address is not missing one"
); );
assert_eq!(
store.users["mara"].email.as_deref(),
Some("real@elsewhere.example"),
"the supplied address must win"
);
let out = render_yaml(&store).expect("renders");
assert!( assert!(
!out.contains("mara@hyperhive.local"), !out.contains("mara@hyperhive.local"),
"the synthetic address must not also appear:\n{out}" "the synthetic address must not also appear:\n{out}"
@ -416,8 +442,13 @@ mod tests {
assert_eq!(synthetic_email("mara"), "mara@hyperhive.local"); assert_eq!(synthetic_email("mara"), "mara@hyperhive.local");
} }
/// Was `groups_render_as_a_block_sequence`, a byte-for-byte assertion on
/// the hand-rolled emitter's output — including which scalars it chose to
/// quote. That is the serializer's business now, and pinning it would
/// fail on an upgrade that broke nothing. What has to hold is that the
/// values come back, in order.
#[test] #[test]
fn groups_render_as_a_block_sequence() { fn a_users_groups_survive_a_round_trip_in_order() {
let mut u = user("$argon2id$x"); let mut u = user("$argon2id$x");
u.email = Some("mara@example.com".to_owned()); u.email = Some("mara@example.com".to_owned());
u.groups = vec!["admins".to_owned(), "operators".to_owned()]; u.groups = vec!["admins".to_owned(), "operators".to_owned()];
@ -425,19 +456,12 @@ mod tests {
store.users.insert("mara".to_owned(), u); store.users.insert("mara".to_owned(), u);
let out = render_yaml(&store).expect("renders"); let out = render_yaml(&store).expect("renders");
assert_eq!( let back: UserStore = serde_norway::from_str(&out).expect("re-reads");
out, let mara = &back.users["mara"];
concat!( assert_eq!(mara.displayname, "Test User");
"users:\n", assert_eq!(mara.password, "$argon2id$x");
" mara:\n", assert_eq!(mara.email.as_deref(), Some("mara@example.com"));
" displayname: \"Test User\"\n", assert_eq!(mara.groups, ["admins", "operators"], "order is meaningful");
" password: \"$argon2id$x\"\n",
" email: \"mara@example.com\"\n",
" groups:\n",
" - \"admins\"\n",
" - \"operators\"\n",
)
);
} }
/// Ordering is a property of the artifact, not an accident: an /// Ordering is a property of the artifact, not an accident: an
@ -608,17 +632,27 @@ mod tests {
assert_eq!(u.displayname, "Test User", "the user must be untouched"); assert_eq!(u.displayname, "Test User", "the user must be untouched");
} }
/// The seed check gates an irreversible overwrite, so it has to be /// Replaces `only_the_untouched_seed_reads_as_takeable`. That test
/// tolerant of trailing whitespace and intolerant of everything else. /// pinned the seed check, which existed to decide whether overwriting
/// `users.yml` was safe — a question that only arose because a *second*
/// store claimed to be canonical. There is no overwrite to gate now: the
/// file is read before it is written.
///
/// What still has to hold is that the first-boot seed and a zero-byte
/// file both mean "no users yet" rather than an error, so a fresh
/// deployment is not stranded.
#[test] #[test]
fn only_the_untouched_seed_reads_as_takeable() { fn the_first_boot_seed_and_an_empty_file_both_mean_no_users() {
assert!(is_untouched_seed("users: {}")); let seeded: UserStore = serde_norway::from_str("users: {}").expect("the seed parses");
assert!(is_untouched_seed("users: {}\n")); assert!(seeded.users.is_empty());
assert!(!is_untouched_seed("users:\n mara:\n"));
// The empty-file case is handled before deserialisation (an empty
// document is not valid YAML for this type), so it is asserted where
// it lives — `main::load_store` — rather than reproduced here.
assert!( assert!(
is_untouched_seed(""), serde_norway::from_str::<UserStore>("").is_err(),
"a zero-byte file has no users to lose, and authelia will not start \ "if this ever starts parsing, load_store's empty-file arm is \
on it — refusing here would strand the deployment" redundant rather than load-bearing, and should be revisited"
); );
} }
} }