feat(3201): swarmctl user update — change an existing subject's attributes

`user add` refuses on an existing name, so the `--group` flag it takes at
creation time could not be added afterwards at all: repairing an account
meant hand-editing both users.json and the rendered users.yml as root.
mara, on #3167: "i will not edit those files by hand, we will have the
same issues elsewhere".

The merge rules live in users.rs as a pure function over a UserUpdate, so
they are testable without a command line, a container or a running
authelia — main.rs's arm only loads, applies, publishes and prints.

Removals are strict and everything else is idempotent, which is the one
asymmetry here and is deliberate: a --remove-group naming a group the
user does not have fails, because a revocation that reports success
without revoking is the outcome nobody re-checks; while refusing an
already-satisfied set would make the multi-attribute call this verb
exists for break whenever one of the values was already right.

A command that changes nothing at all still fails — it would otherwise
rewrite both files and restart the SSO provider to no effect.

Passwords are out of scope: regenerating a credential is a different
intent from editing an attribute, and folded together an attribute edit
can invalidate a login by accident.

Extracts publish() from user_add so both verbs share the
render -> store -> users.yml -> restart ordering and the comment that
explains why that order, rather than the second verb copying it.
This commit is contained in:
atlas 2026-08-12 18:32:33 +02:00 committed by mara
commit 24ee0990a2
4 changed files with 370 additions and 8 deletions

View file

@ -149,6 +149,14 @@ enum Verb {
enum UserVerb {
/// Add a user, generating a password for them.
Add(AddArgs),
/// Change an existing user's attributes.
///
/// Every flag is optional and they compose, so one call can set
/// several things at once. Deliberately does **not** touch the
/// password: regenerating a credential is a different intent from
/// editing an attribute, and folded together an attribute edit can
/// invalidate a login by accident.
Update(UpdateArgs),
}
#[derive(Args)]
@ -166,6 +174,25 @@ struct AddArgs {
groups: Vec<String>,
}
#[derive(Args)]
struct UpdateArgs {
/// Login name of an existing user.
username: String,
/// Name shown in the SSO UI.
#[arg(long, value_name = "TEXT")]
display_name: Option<String>,
#[arg(long, value_name = "ADDRESS")]
email: Option<String>,
/// Repeatable. Adding a group the user is already in is not an error.
#[arg(long = "add-group", value_name = "GROUP")]
add_groups: Vec<String>,
/// Repeatable. Fails if the user is not in the group — a revocation
/// that reports success without revoking is the failure nobody
/// re-checks.
#[arg(long = "remove-group", value_name = "GROUP")]
remove_groups: Vec<String>,
}
fn main() -> Result<()> {
let Cli { paths, command } = Cli::parse();
match command {
@ -175,6 +202,9 @@ fn main() -> Result<()> {
Verb::User {
command: UserVerb::Add(args),
} => user_add(&paths.resolve()?, args),
Verb::User {
command: UserVerb::Update(args),
} => user_update(&paths.resolve()?, args),
Verb::MarkdownDocs => {
print!("{}", clap_markdown::help_markdown::<Cli>());
Ok(())
@ -206,11 +236,59 @@ fn user_add(paths: &Paths, args: AddArgs) -> Result<()> {
},
);
publish(paths, &store)?;
println!("added {} to {}", args.username, paths.users_file.display());
println!("password: {}", generated.password);
println!("this password is stored nowhere — record it now");
Ok(())
}
fn user_update(paths: &Paths, args: UpdateArgs) -> Result<()> {
let mut store = load_store(&paths.store, &paths.users_file)?;
let Some(user) = store.users.get_mut(&args.username) else {
bail!(
"no user {:?} in {} — `swarmctl user add` creates one",
args.username,
paths.store.display()
);
};
let changes = users::apply_update(
user,
&users::UserUpdate {
displayname: args.display_name,
email: args.email,
add_groups: args.add_groups,
remove_groups: args.remove_groups,
},
)?;
// Read back before the borrow ends: this is what the operator gets
// instead of a group registry we don't have — a typo'd `--add-group`
// is a real group with nobody reading it, and seeing the resulting
// list is the only way to notice.
let groups = users::fmt_groups(&user.groups);
publish(paths, &store)?;
for change in &changes {
println!("{change}");
}
println!("{} is now in groups: {groups}", args.username);
Ok(())
}
/// Write the store + the rendered users file, then restart authelia.
///
/// Shared by every verb that mutates the store, so the ordering rules
/// below hold for all of them rather than for whichever one was written
/// first.
fn publish(paths: &Paths, store: &UserStore) -> Result<()> {
// Render before writing anything: a value this refuses to emit should
// stop the whole operation, not leave the canonical store one user
// ahead of the file authelia reads.
let rendered = users::render_yaml(&store)?;
let store_json = serde_json::to_string_pretty(&store).context("serialising the user store")?;
let rendered = users::render_yaml(store)?;
let store_json = serde_json::to_string_pretty(store).context("serialising the user store")?;
// Store first, and the order matters. If the store lands and the
// users file does not, the next run re-renders and repairs it. The
@ -219,12 +297,7 @@ fn user_add(paths: &Paths, args: AddArgs) -> Result<()> {
write_atomic(&paths.store, &format!("{store_json}\n"))?;
write_atomic(&paths.users_file, &rendered)?;
restart_authelia(&paths.machine, &paths.unit)?;
println!("added {} to {}", args.username, paths.users_file.display());
println!("password: {}", generated.password);
println!("this password is stored nowhere — record it now");
Ok(())
restart_authelia(&paths.machine, &paths.unit)
}
/// Load the canonical store, or start an empty one if this deployment has