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.
658 lines
26 KiB
Rust
658 lines
26 KiB
Rust
//! The swarm's SSO user store — authelia's own `users.yml`, read and
|
|
//! written directly.
|
|
//!
|
|
//! **One file.** There used to be a private `users.json` here, canonical,
|
|
//! with `users.yml` rendered from it — and `swarm-authelia-bridge` kept its
|
|
//! 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.
|
|
//!
|
|
//! The JSON bought nothing. The old module doc argued it let this crate
|
|
//! exist without a YAML parser — but `load_store` read the JSON back on
|
|
//! every run, so the round-trip was already being paid; the two files
|
|
//! differed only in *format*.
|
|
//!
|
|
//! ⚠️ Consequence, deliberate: this file is **round-tripped**, so comments
|
|
//! and hand-formatting do not survive a write. An operator editing it
|
|
//! directly keeps their *values* and loses their *comments*. Unknown keys
|
|
//! survive (see `extra`), so a field this binary does not model is not
|
|
//! deleted by it.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// The canonical store — this *is* `users.yml`, deserialised.
|
|
///
|
|
/// `BTreeMap` rather than `HashMap` so the file 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)]
|
|
pub struct UserStore {
|
|
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)]
|
|
pub struct User {
|
|
pub displayname: String,
|
|
/// The argon2 **digest** (`$argon2id$v=19$...`), never a plaintext
|
|
/// password. Nothing in this crate stores or logs a plaintext: the
|
|
/// one that exists is printed to the operator once and dropped.
|
|
pub password: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub email: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
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
|
|
/// logs, session cookies and access-control rules. Keeping them to a
|
|
/// conservative ASCII set means the emitter never has to reason about a
|
|
/// key that needs quoting, and an authelia-side surprise can't be
|
|
/// something we handed it.
|
|
///
|
|
/// Deliberately tighter than authelia allows. Loosening later is a
|
|
/// one-line change; discovering that a name broke an access-control rule
|
|
/// in production is not.
|
|
pub fn validate_username(name: &str) -> Result<()> {
|
|
if name.is_empty() || name.len() > 64 {
|
|
bail!("username must be 1..=64 characters, got {}", name.len());
|
|
}
|
|
if !name.starts_with(|c: char| c.is_ascii_alphanumeric()) {
|
|
bail!("username must start with an ASCII letter or digit: {name:?}");
|
|
}
|
|
if let Some(bad) = name
|
|
.chars()
|
|
.find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')))
|
|
{
|
|
bail!("username may only contain [A-Za-z0-9._-], found {bad:?} in {name:?}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Reject control characters in free text rather than escaping them.
|
|
///
|
|
/// The emitter below could encode them, but a display name or email
|
|
/// carrying a control character is a bug or an injection attempt in every
|
|
/// real case, and refusing is both simpler to reason about and impossible
|
|
/// to get subtly wrong.
|
|
fn reject_control_chars(field: &str, value: &str) -> Result<()> {
|
|
if let Some(c) = value.chars().find(|c| c.is_control()) {
|
|
bail!(
|
|
"{field} contains control character U+{:04X}; refusing to write it",
|
|
c as u32
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Domain for an address this crate invents.
|
|
///
|
|
/// The same one `hive-c0re` already gives every agent's forge account
|
|
/// (`forge::users::agent_email`) and every hyperhive-authored git commit. A
|
|
/// deployment-derived domain was considered and rejected: it would have to be
|
|
/// passed in from config, and an operator who is already supplying a domain
|
|
/// may as well supply the whole address — while a *second* convention for
|
|
/// synthetic identities is a thing to keep in sync forever.
|
|
///
|
|
/// Never routable, and that is correct rather than a compromise. Nothing
|
|
/// sends mail here; the address exists so that a relying party asking for an
|
|
/// `email` claim gets one.
|
|
const SYNTHETIC_EMAIL_DOMAIN: &str = "hyperhive.local";
|
|
|
|
/// The address a user with no explicit one is rendered as.
|
|
///
|
|
/// Every user needs an email in the rendered file, because a relying party
|
|
/// that asks for the `email` claim and gets nothing does not degrade — it
|
|
/// fails. Grafana's OIDC login is the measured case: with no email claim it
|
|
/// falls through to `<api_url>/emails`, a GitHub-ism authelia does not
|
|
/// implement, and the login dies with `InternalError` rather than anything
|
|
/// naming the missing field.
|
|
///
|
|
/// Synthesised on the **write path** and stored, because the file *is* the
|
|
/// store now: "rendered but not persisted" no longer has anywhere to live.
|
|
/// mara ruled it directly — *"email in yml is what is already there and
|
|
/// 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 {
|
|
format!("{username}@{SYNTHETIC_EMAIL_DOMAIN}")
|
|
}
|
|
|
|
/// Give every user an address before the store is written.
|
|
///
|
|
/// Returns the names it filled, so a caller can say what it did rather than
|
|
/// changing the file silently.
|
|
///
|
|
/// On the write path rather than at creation, because users also arrive by
|
|
/// being *read* — from a file another writer produced, or one an operator
|
|
/// edited. Fixing them where they enter would need every entry point to
|
|
/// 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
|
|
}
|
|
|
|
/// 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 {
|
|
validate_username(name)?;
|
|
reject_control_chars("displayname", &user.displayname)?;
|
|
reject_control_chars("password digest", &user.password)?;
|
|
if let Some(email) = &user.email {
|
|
reject_control_chars("email", email)?;
|
|
}
|
|
for group in &user.groups {
|
|
reject_control_chars("group", group)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// A requested change to an existing user.
|
|
///
|
|
/// A plain struct rather than the CLI's `Args` type, so the merge rules in
|
|
/// [`apply_update`] can be tested without building a command line — and so
|
|
/// the single place that decides what *update* means has no opinion about
|
|
/// how it is spelled.
|
|
#[derive(Debug, Default)]
|
|
pub struct UserUpdate {
|
|
pub displayname: Option<String>,
|
|
pub email: Option<String>,
|
|
pub add_groups: Vec<String>,
|
|
pub remove_groups: Vec<String>,
|
|
}
|
|
|
|
/// Apply `update` to `user`, returning one line per change actually made.
|
|
///
|
|
/// **Removals are strict; everything else is idempotent.** That asymmetry
|
|
/// is the whole safety argument of this function, so it is deliberate
|
|
/// rather than an oversight:
|
|
///
|
|
/// - `--remove-group` on a group the user does not have **fails**. A
|
|
/// revocation that reports success without revoking is the one outcome
|
|
/// here nobody re-checks — you typo the group, the command says ok, and
|
|
/// the account keeps the access you believe you took away.
|
|
/// - Setting an attribute to the value it already holds, or adding a group
|
|
/// the user is already in, is **not** an error: the end state matches the
|
|
/// intent, and refusing would make the multi-attribute call this verb
|
|
/// exists for brittle — "set these four things" should not fail because
|
|
/// one of them was already right.
|
|
///
|
|
/// A command that changes *nothing at all* still fails, because it would
|
|
/// otherwise rewrite both files and restart the SSO provider to no effect.
|
|
///
|
|
/// On failure the `user` it was handed may be **partially mutated** — the
|
|
/// guarantee is not in-place atomicity but that the caller publishes
|
|
/// nothing on an error, so neither file and neither process ever sees a
|
|
/// half-applied update. Say it plainly rather than implying a rollback
|
|
/// this doesn't do.
|
|
///
|
|
/// Note what cannot be validated here: group names are free-form strings
|
|
/// with no registry, so a typo'd `--add-group` creates a group nothing
|
|
/// references, and the user silently gains no access. The caller prints the
|
|
/// resulting group list for exactly that reason — it is the only signal
|
|
/// available.
|
|
pub fn apply_update(user: &mut User, update: &UserUpdate) -> Result<Vec<String>> {
|
|
if let Some(dup) = update
|
|
.add_groups
|
|
.iter()
|
|
.find(|g| update.remove_groups.contains(g))
|
|
{
|
|
bail!("group {dup:?} is both added and removed; refusing to guess an order");
|
|
}
|
|
|
|
let mut changes = Vec::new();
|
|
|
|
if let Some(name) = &update.displayname
|
|
&& *name != user.displayname
|
|
{
|
|
reject_control_chars("displayname", name)?;
|
|
changes.push(format!("displayname: {:?} -> {name:?}", user.displayname));
|
|
user.displayname.clone_from(name);
|
|
}
|
|
|
|
if let Some(email) = &update.email
|
|
&& user.email.as_deref() != Some(email.as_str())
|
|
{
|
|
reject_control_chars("email", email)?;
|
|
changes.push(match &user.email {
|
|
Some(old) => format!("email: {old:?} -> {email:?}"),
|
|
None => format!("email: unset -> {email:?}"),
|
|
});
|
|
user.email = Some(email.clone());
|
|
}
|
|
|
|
for group in &update.remove_groups {
|
|
let Some(at) = user.groups.iter().position(|g| g == group) else {
|
|
bail!(
|
|
"user is not in group {group:?}, so there is nothing to revoke \
|
|
(groups: {})",
|
|
fmt_groups(&user.groups)
|
|
);
|
|
};
|
|
user.groups.remove(at);
|
|
changes.push(format!("removed from group {group:?}"));
|
|
}
|
|
|
|
for group in &update.add_groups {
|
|
if user.groups.iter().any(|g| g == group) {
|
|
continue;
|
|
}
|
|
reject_control_chars("group", group)?;
|
|
user.groups.push(group.clone());
|
|
changes.push(format!("added to group {group:?}"));
|
|
}
|
|
|
|
if changes.is_empty() {
|
|
bail!("nothing to change — every requested value is already set");
|
|
}
|
|
Ok(changes)
|
|
}
|
|
|
|
/// Group list for a message, so an empty one reads as a word rather than
|
|
/// as a missing value.
|
|
pub fn fmt_groups(groups: &[String]) -> String {
|
|
if groups.is_empty() {
|
|
"none".to_owned()
|
|
} else {
|
|
groups.join(", ")
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn user(password: &str) -> User {
|
|
User {
|
|
displayname: "Test User".to_owned(),
|
|
password: password.to_owned(),
|
|
email: None,
|
|
groups: Vec::new(),
|
|
extra: BTreeMap::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_store_round_trips_as_an_empty_store() {
|
|
let out = render_yaml(&UserStore::default()).expect("empty store renders");
|
|
let back: UserStore = serde_norway::from_str(&out).expect("re-reads");
|
|
assert!(
|
|
back.users.is_empty(),
|
|
"removing the last user must leave a file the next run can load, got:\n{out}"
|
|
);
|
|
}
|
|
|
|
/// The digest is the value most likely to break a naive emitter: it
|
|
/// carries `$`, `=`, `,` and `/`, and `,` in particular terminates a
|
|
/// 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]
|
|
fn an_argon2_digest_survives_a_round_trip() {
|
|
let digest = "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$aGFzaA+/w==";
|
|
let mut store = UserStore::default();
|
|
store.users.insert("mara".to_owned(), user(digest));
|
|
|
|
let out = render_yaml(&store).expect("renders");
|
|
let back: UserStore = serde_norway::from_str(&out).expect("re-reads");
|
|
assert_eq!(
|
|
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]
|
|
fn unknown_keys_survive_a_round_trip() {
|
|
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]
|
|
fn an_empty_group_list_is_omitted_rather_than_emitted_empty() {
|
|
let mut store = UserStore::default();
|
|
store.users.insert("mara".to_owned(), user("$argon2id$x"));
|
|
|
|
let out = render_yaml(&store).expect("renders");
|
|
assert!(
|
|
!out.contains("groups"),
|
|
"an empty group list must not appear:\n{out}"
|
|
);
|
|
}
|
|
|
|
/// 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");
|
|
u.email = Some("real@elsewhere.example".to_owned());
|
|
let mut store = UserStore::default();
|
|
store.users.insert("mara".to_owned(), u);
|
|
|
|
assert!(
|
|
fill_missing_emails(&mut store).is_empty(),
|
|
"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!(
|
|
!out.contains("mara@hyperhive.local"),
|
|
"the synthetic address must not also appear:\n{out}"
|
|
);
|
|
}
|
|
|
|
/// The synthetic address goes through the same validation as a supplied
|
|
/// one. A username is already constrained to `[A-Za-z0-9._-]`, so this
|
|
/// cannot currently fail — which is exactly why it is worth pinning: the
|
|
/// day username rules loosen, the renderer must still refuse rather than
|
|
/// quietly emit whatever it built.
|
|
#[test]
|
|
fn the_synthetic_address_is_built_from_the_username_and_domain() {
|
|
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]
|
|
fn a_users_groups_survive_a_round_trip_in_order() {
|
|
let mut u = user("$argon2id$x");
|
|
u.email = Some("mara@example.com".to_owned());
|
|
u.groups = vec!["admins".to_owned(), "operators".to_owned()];
|
|
let mut store = UserStore::default();
|
|
store.users.insert("mara".to_owned(), u);
|
|
|
|
let out = render_yaml(&store).expect("renders");
|
|
let back: UserStore = serde_norway::from_str(&out).expect("re-reads");
|
|
let mara = &back.users["mara"];
|
|
assert_eq!(mara.displayname, "Test User");
|
|
assert_eq!(mara.password, "$argon2id$x");
|
|
assert_eq!(mara.email.as_deref(), Some("mara@example.com"));
|
|
assert_eq!(mara.groups, ["admins", "operators"], "order is meaningful");
|
|
}
|
|
|
|
/// Ordering is a property of the artifact, not an accident: an
|
|
/// unordered rewrite makes every one-user change look like a
|
|
/// whole-file change to anyone diffing it.
|
|
#[test]
|
|
fn users_render_in_a_stable_order() {
|
|
let mut store = UserStore::default();
|
|
for name in ["zoe", "atlas", "mara"] {
|
|
store.users.insert(name.to_owned(), user("$argon2id$x"));
|
|
}
|
|
let out = render_yaml(&store).expect("renders");
|
|
let order: Vec<&str> = out
|
|
.lines()
|
|
.filter_map(|l| l.strip_prefix(" ").and_then(|l| l.strip_suffix(':')))
|
|
.collect();
|
|
assert_eq!(order, ["atlas", "mara", "zoe"]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_control_character_is_refused_not_escaped() {
|
|
let mut u = user("$argon2id$x");
|
|
u.displayname = "bad\nname".to_owned();
|
|
let mut store = UserStore::default();
|
|
store.users.insert("mara".to_owned(), u);
|
|
|
|
let err = render_yaml(&store).expect_err("a control character must not render");
|
|
assert!(
|
|
err.to_string().contains("control character"),
|
|
"unexpected error: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn usernames_outside_the_conservative_set_are_refused() {
|
|
for bad in ["", "-leading", "has space", "quote\"d", "sla/sh", "üni"] {
|
|
assert!(
|
|
validate_username(bad).is_err(),
|
|
"{bad:?} should have been rejected"
|
|
);
|
|
}
|
|
for good in ["mara", "atlas", "svc-agent_1", "a.b"] {
|
|
validate_username(good).unwrap_or_else(|e| panic!("{good:?} rejected: {e}"));
|
|
}
|
|
}
|
|
|
|
fn with_groups(groups: &[&str]) -> User {
|
|
let mut u = user("$argon2id$x");
|
|
u.groups = groups.iter().map(|g| (*g).to_owned()).collect();
|
|
u
|
|
}
|
|
|
|
/// The verb exists to change several things in one call, so the
|
|
/// composed case is the one that has to work.
|
|
#[test]
|
|
fn one_update_can_change_several_attributes() {
|
|
let mut u = with_groups(&["users"]);
|
|
let changes = apply_update(
|
|
&mut u,
|
|
&UserUpdate {
|
|
displayname: Some("Mara".to_owned()),
|
|
email: Some("mara@example.com".to_owned()),
|
|
add_groups: vec!["admins".to_owned()],
|
|
remove_groups: vec!["users".to_owned()],
|
|
},
|
|
)
|
|
.expect("applies");
|
|
|
|
assert_eq!(u.displayname, "Mara");
|
|
assert_eq!(u.email.as_deref(), Some("mara@example.com"));
|
|
assert_eq!(u.groups, ["admins"]);
|
|
assert_eq!(changes.len(), 4, "every change is reported: {changes:?}");
|
|
}
|
|
|
|
/// ⭐ The asymmetry that is the point of this function. A revocation
|
|
/// that reports success without revoking is the failure nobody
|
|
/// re-checks — so a `--remove-group` naming a group the user is not
|
|
/// in must fail, and must leave the user untouched.
|
|
#[test]
|
|
fn removing_a_group_the_user_lacks_fails_and_changes_nothing() {
|
|
let mut u = with_groups(&["admins"]);
|
|
let err = apply_update(
|
|
&mut u,
|
|
&UserUpdate {
|
|
// The realistic shape: a typo for `admins`.
|
|
remove_groups: vec!["admin".to_owned()],
|
|
..UserUpdate::default()
|
|
},
|
|
)
|
|
.expect_err("a no-op revocation must not report success");
|
|
|
|
assert!(err.to_string().contains("nothing to revoke"), "{err}");
|
|
assert_eq!(u.groups, ["admins"], "the user must be untouched");
|
|
}
|
|
|
|
/// The other half of the asymmetry: setting what is already set is
|
|
/// fine, because the end state matches the intent. Refusing would
|
|
/// make "set these four things" fail when one was already right.
|
|
#[test]
|
|
fn already_satisfied_additions_are_not_errors() {
|
|
let mut u = with_groups(&["admins"]);
|
|
u.displayname = "Mara".to_owned();
|
|
|
|
let changes = apply_update(
|
|
&mut u,
|
|
&UserUpdate {
|
|
displayname: Some("Mara".to_owned()),
|
|
add_groups: vec!["admins".to_owned(), "ops".to_owned()],
|
|
..UserUpdate::default()
|
|
},
|
|
)
|
|
.expect("a partially-satisfied update still applies the rest");
|
|
|
|
assert_eq!(u.groups, ["admins", "ops"], "no duplicate `admins`");
|
|
assert_eq!(
|
|
changes.len(),
|
|
1,
|
|
"only the real change reports: {changes:?}"
|
|
);
|
|
}
|
|
|
|
/// A command that changes nothing would still rewrite both files and
|
|
/// restart the SSO provider, so it is an error rather than a no-op.
|
|
#[test]
|
|
fn an_update_that_changes_nothing_fails() {
|
|
let mut u = with_groups(&["admins"]);
|
|
let err = apply_update(
|
|
&mut u,
|
|
&UserUpdate {
|
|
add_groups: vec!["admins".to_owned()],
|
|
..UserUpdate::default()
|
|
},
|
|
)
|
|
.expect_err("a no-op must not rewrite the user database");
|
|
assert!(err.to_string().contains("nothing to change"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn adding_and_removing_the_same_group_is_refused() {
|
|
let mut u = with_groups(&["admins"]);
|
|
let err = apply_update(
|
|
&mut u,
|
|
&UserUpdate {
|
|
add_groups: vec!["admins".to_owned()],
|
|
remove_groups: vec!["admins".to_owned()],
|
|
..UserUpdate::default()
|
|
},
|
|
)
|
|
.expect_err("contradictory flags must not pick a winner silently");
|
|
assert!(err.to_string().contains("both added and removed"), "{err}");
|
|
}
|
|
|
|
/// The update path writes the same file `render_yaml` validates, so a
|
|
/// control character has to be refused *before* it reaches the store —
|
|
/// not at render time, with the store already mutated.
|
|
#[test]
|
|
fn a_control_character_is_refused_by_the_update_path_too() {
|
|
let mut u = with_groups(&[]);
|
|
let err = apply_update(
|
|
&mut u,
|
|
&UserUpdate {
|
|
displayname: Some("bad\nname".to_owned()),
|
|
..UserUpdate::default()
|
|
},
|
|
)
|
|
.expect_err("must refuse");
|
|
assert!(err.to_string().contains("control character"), "{err}");
|
|
assert_eq!(u.displayname, "Test User", "the user must be untouched");
|
|
}
|
|
|
|
/// Replaces `only_the_untouched_seed_reads_as_takeable`. That test
|
|
/// 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]
|
|
fn the_first_boot_seed_and_an_empty_file_both_mean_no_users() {
|
|
let seeded: UserStore = serde_norway::from_str("users: {}").expect("the seed parses");
|
|
assert!(seeded.users.is_empty());
|
|
|
|
// 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!(
|
|
serde_norway::from_str::<UserStore>("").is_err(),
|
|
"if this ever starts parsing, load_store's empty-file arm is \
|
|
redundant rather than load-bearing, and should be revisited"
|
|
);
|
|
}
|
|
}
|