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:
parent
086f3f43d6
commit
24ee0990a2
4 changed files with 370 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue