From 23346adccf7aa23d26bae1f2787b5b543020519c Mon Sep 17 00:00:00 2001 From: Nathael Bonnal Date: Wed, 2 Sep 2026 22:47:46 +0200 Subject: [PATCH 1/3] feat(user): add set-password, remove-role and roles commands Refs #23 --- libs/ferriskey-cli-client/src/lib.rs | 59 ++++++ libs/ferriskey-cli-commands/src/lib.rs | 2 +- libs/ferriskey-cli-commands/src/user.rs | 55 ++++++ libs/ferriskey-cli-core/src/user.rs | 231 +++++++++++++++++++++++- 4 files changed, 339 insertions(+), 8 deletions(-) diff --git a/libs/ferriskey-cli-client/src/lib.rs b/libs/ferriskey-cli-client/src/lib.rs index 867f5d4..04fe425 100644 --- a/libs/ferriskey-cli-client/src/lib.rs +++ b/libs/ferriskey-cli-client/src/lib.rs @@ -231,6 +231,12 @@ pub struct CreateRedirectUriRequest { pub enabled: bool, } +#[derive(Debug, Clone, Serialize)] +pub struct SetPasswordRequest { + pub value: String, + pub temporary: bool, +} + impl FerriskeyClient { pub fn new( base_url: impl Into, @@ -613,6 +619,59 @@ impl FerriskeyClient { Ok(()) } + pub fn remove_user_role( + &self, + realm: &str, + user_id: &str, + role_id: &str, + ) -> Result<(), FerriskeyClientError> { + let response = self + .http + .delete(self.endpoint(&format!( + "realms/{realm}/users/{user_id}/roles/{role_id}" + ))) + .bearer_auth(&self.token) + .send()?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + return Err(FerriskeyClientError::Api { status, body }); + } + + Ok(()) + } + + pub fn list_user_roles( + &self, + realm: &str, + user_id: &str, + ) -> Result, FerriskeyClientError> { + self.get_list(&self.endpoint(&format!("realms/{realm}/users/{user_id}/roles"))) + } + + pub fn set_user_password( + &self, + realm: &str, + user_id: &str, + request: &SetPasswordRequest, + ) -> Result<(), FerriskeyClientError> { + let response = self + .http + .put(self.endpoint(&format!("realms/{realm}/users/{user_id}/password"))) + .bearer_auth(&self.token) + .json(request) + .send()?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + return Err(FerriskeyClientError::Api { status, body }); + } + + Ok(()) + } + /// RFC 8628 ยง3.1 โ€” start a device authorization flow. pub fn device_authorization( &self, diff --git a/libs/ferriskey-cli-commands/src/lib.rs b/libs/ferriskey-cli-commands/src/lib.rs index 4a40391..3503703 100644 --- a/libs/ferriskey-cli-commands/src/lib.rs +++ b/libs/ferriskey-cli-commands/src/lib.rs @@ -23,7 +23,7 @@ pub use self::source::{ }; pub use self::user::{ UserAssignRoleArgs, UserCommand, UserCreateArgs, UserDeleteArgs, UserGetArgs, UserListArgs, - UserSubcommand, + UserRemoveRoleArgs, UserRolesArgs, UserSetPasswordArgs, UserSubcommand, }; use clap::{Parser, Subcommand}; diff --git a/libs/ferriskey-cli-commands/src/user.rs b/libs/ferriskey-cli-commands/src/user.rs index b19284c..f7beeab 100644 --- a/libs/ferriskey-cli-commands/src/user.rs +++ b/libs/ferriskey-cli-commands/src/user.rs @@ -22,6 +22,12 @@ pub enum UserSubcommand { Delete(UserDeleteArgs), /// Assign a realm role to a user. AssignRole(UserAssignRoleArgs), + /// Remove a realm role from a user. + RemoveRole(UserRemoveRoleArgs), + /// List the realm roles assigned to a user. + Roles(UserRolesArgs), + /// Set a user's password. + SetPassword(UserSetPasswordArgs), } /// Arguments for assigning a realm role to a user. @@ -38,6 +44,55 @@ pub struct UserAssignRoleArgs { pub realm: Option, } +/// Arguments for removing a realm role from a user. +#[derive(Debug, Args)] +pub struct UserRemoveRoleArgs { + /// Username. + pub username: String, + + /// Realm role name to remove. + pub role: String, + + /// Realm name. Defaults to the selected context realm. + #[arg(long)] + pub realm: Option, +} + +/// Arguments for listing a user's realm roles. +#[derive(Debug, Args)] +pub struct UserRolesArgs { + /// Username. + pub username: String, + + /// Realm name. Defaults to the selected context realm. + #[arg(long)] + pub realm: Option, +} + +/// Arguments for setting a user's password. +#[derive(Debug, Args)] +pub struct UserSetPasswordArgs { + /// Username. + pub username: String, + + /// Realm name. Defaults to the selected context realm. + #[arg(long)] + pub realm: Option, + + /// New password. Prefer `--stdin` โ€” a value here lands in shell history + /// and the process list. + #[arg(long, conflicts_with = "stdin")] + pub password: Option, + + /// Read the new password from stdin (trailing newline trimmed). + #[arg(long, default_value_t = false)] + pub stdin: bool, + + /// Require the user to change this password on next login. + #[arg(long, default_value_t = false)] + pub temporary: bool, +} + /// Arguments for listing users. #[derive(Debug, Args)] pub struct UserListArgs { diff --git a/libs/ferriskey-cli-core/src/user.rs b/libs/ferriskey-cli-core/src/user.rs index a5d266b..20f25fc 100644 --- a/libs/ferriskey-cli-core/src/user.rs +++ b/libs/ferriskey-cli-core/src/user.rs @@ -1,9 +1,12 @@ +use std::io::Read; + use ferriskey_cli_client::{ - CreateUserRequest, FerriskeyClient, FerriskeyClientError, UserRepresentation, + CreateUserRequest, CreatedRole, FerriskeyClient, FerriskeyClientError, SetPasswordRequest, + UserRepresentation, }; use ferriskey_cli_commands::{ UserAssignRoleArgs, UserCommand, UserCreateArgs, UserDeleteArgs, UserGetArgs, UserListArgs, - UserSubcommand, + UserRemoveRoleArgs, UserRolesArgs, UserSetPasswordArgs, UserSubcommand, }; use serde::Serialize; use thiserror::Error; @@ -36,6 +39,15 @@ pub fn run( UserSubcommand::AssignRole(args) => { assign_role(output_format, context_override, inline_context, args) } + UserSubcommand::RemoveRole(args) => { + remove_role(output_format, context_override, inline_context, args) + } + UserSubcommand::Roles(args) => { + list_user_roles(output_format, context_override, inline_context, args) + } + UserSubcommand::SetPassword(args) => { + set_password(output_format, context_override, inline_context, args) + } } } @@ -65,6 +77,13 @@ pub enum UserCommandError { UserNotFound(String), #[error("role '{0}' not found in realm")] RoleNotFound(String), + #[error("pass exactly one of '--password' or '--stdin'")] + InvalidPasswordSource, + #[error("failed to read password from stdin")] + ReadStdin { + #[source] + source: std::io::Error, + }, #[error("unsupported output format: {0}")] UnsupportedOutputFormat(String), #[error("failed to serialize JSON output")] @@ -89,6 +108,19 @@ struct UserView { enabled: bool, } +#[derive(Debug, Serialize)] +struct RoleView { + id: String, + name: String, +} + +fn to_role_view(role: CreatedRole) -> RoleView { + RoleView { + id: role.id, + name: role.name, + } +} + fn resolve_context( context_override: Option<&str>, inline_context: Option, @@ -214,6 +246,14 @@ fn delete_user( render_message(output_format, &format!("user '{}' deleted", args.username)) } +fn resolve_role(client: &FerriskeyClient, realm: &str, role_name: &str) -> Result { + client + .list_realm_roles(realm)? + .into_iter() + .find(|r| r.name == role_name) + .ok_or_else(|| UserCommandError::RoleNotFound(role_name.to_owned())) +} + fn assign_role( output_format: &str, context_override: Option<&str>, @@ -224,11 +264,7 @@ fn assign_role( let realm = resolve_realm(&context, args.realm)?; let client = auth_client(&context)?; let user = find_user(&client, &realm, &args.username)?; - let role = client - .list_realm_roles(&realm)? - .into_iter() - .find(|r| r.name == args.role) - .ok_or_else(|| UserCommandError::RoleNotFound(args.role.clone()))?; + let role = resolve_role(&client, &realm, &args.role)?; client.assign_user_role(&realm, &user.id, &role.id)?; render_message( output_format, @@ -239,6 +275,96 @@ fn assign_role( ) } +fn remove_role( + output_format: &str, + context_override: Option<&str>, + inline_context: Option, + args: UserRemoveRoleArgs, +) -> Result<()> { + let context = resolve_context(context_override, inline_context)?; + let realm = resolve_realm(&context, args.realm)?; + let client = authenticate(&context, &realm)?; + let user = find_user(&client, &realm, &args.username)?; + let role = resolve_role(&client, &realm, &args.role)?; + client.remove_user_role(&realm, &user.id, &role.id)?; + render_message( + output_format, + &format!( + "role '{}' removed from user '{}'", + args.role, args.username + ), + ) +} + +fn list_user_roles( + output_format: &str, + context_override: Option<&str>, + inline_context: Option, + args: UserRolesArgs, +) -> Result<()> { + let context = resolve_context(context_override, inline_context)?; + let realm = resolve_realm(&context, args.realm)?; + let client = authenticate(&context, &realm)?; + let user = find_user(&client, &realm, &args.username)?; + let roles = client.list_user_roles(&realm, &user.id)?; + let views: Vec = roles.into_iter().map(to_role_view).collect(); + render_role_list(output_format, &views) +} + +/// Where `set-password` reads the new password from โ€” resolved before any +/// I/O so the "exactly one source" rule stays pure and testable. +#[derive(Debug, PartialEq, Eq)] +enum PasswordSource { + Literal(String), + Stdin, +} + +fn resolve_password_source( + password: Option, + stdin: bool, +) -> Result { + match (password, stdin) { + (Some(password), false) => Ok(PasswordSource::Literal(password)), + (None, true) => Ok(PasswordSource::Stdin), + _ => Err(UserCommandError::InvalidPasswordSource), + } +} + +fn set_password( + output_format: &str, + context_override: Option<&str>, + inline_context: Option, + args: UserSetPasswordArgs, +) -> Result<()> { + let value = match resolve_password_source(args.password, args.stdin)? { + PasswordSource::Literal(password) => password, + PasswordSource::Stdin => { + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .map_err(|source| UserCommandError::ReadStdin { source })?; + buf.trim_end_matches(['\n', '\r']).to_owned() + } + }; + + let context = resolve_context(context_override, inline_context)?; + let realm = resolve_realm(&context, args.realm)?; + let client = authenticate(&context, &realm)?; + let user = find_user(&client, &realm, &args.username)?; + client.set_user_password( + &realm, + &user.id, + &SetPasswordRequest { + value, + temporary: args.temporary, + }, + )?; + render_message( + output_format, + &format!("password set for user '{}'", args.username), + ) +} + fn render_user_list(output_format: &str, users: &[UserView]) -> Result<()> { match output_format { "table" => { @@ -328,6 +454,50 @@ fn render_user(output_format: &str, user: UserView) -> Result<()> { } } +fn render_role_list(output_format: &str, roles: &[RoleView]) -> Result<()> { + match output_format { + "table" => { + let name_width = roles + .iter() + .map(|r| r.name.len()) + .max() + .unwrap_or(0) + .max("NAME".len()); + let id_width = roles + .iter() + .map(|r| r.id.len()) + .max() + .unwrap_or(0) + .max("ID".len()); + + println!("{: { + println!( + "{}", + serde_json::to_string_pretty(roles) + .map_err(|source| UserCommandError::SerializeJson { source })? + ); + Ok(()) + } + "yaml" => { + println!( + "{}", + serde_yaml::to_string(roles) + .map_err(|source| UserCommandError::SerializeYaml { source })? + ); + Ok(()) + } + _ => Err(UserCommandError::UnsupportedOutputFormat( + output_format.to_owned(), + )), + } +} + fn render_message(output_format: &str, message: &str) -> Result<()> { match output_format { "table" => { @@ -459,4 +629,51 @@ mod tests { let err = render_user_list("xml", &[]).expect_err("unknown format should error"); assert!(matches!(err, UserCommandError::UnsupportedOutputFormat(_))); } + + #[test] + fn to_role_view_maps_id_and_name() { + let role = CreatedRole { + id: "r-1".to_owned(), + name: "admin".to_owned(), + }; + let view = to_role_view(role); + assert_eq!(view.id, "r-1"); + assert_eq!(view.name, "admin"); + } + + #[test] + fn render_role_list_table_and_json_succeed() { + let roles = vec![RoleView { + id: "r-1".to_owned(), + name: "admin".to_owned(), + }]; + assert!(render_role_list("table", &roles).is_ok()); + assert!(render_role_list("json", &roles).is_ok()); + assert!(render_role_list("table", &[]).is_ok()); + } + + #[test] + fn resolve_password_source_accepts_literal_password() { + let source = resolve_password_source(Some("secret".to_owned()), false).expect("resolved"); + assert_eq!(source, PasswordSource::Literal("secret".to_owned())); + } + + #[test] + fn resolve_password_source_accepts_stdin() { + let source = resolve_password_source(None, true).expect("resolved"); + assert_eq!(source, PasswordSource::Stdin); + } + + #[test] + fn resolve_password_source_rejects_neither() { + let err = resolve_password_source(None, false).expect_err("should require a source"); + assert!(matches!(err, UserCommandError::InvalidPasswordSource)); + } + + #[test] + fn resolve_password_source_rejects_both() { + let err = resolve_password_source(Some("secret".to_owned()), true) + .expect_err("should reject both sources"); + assert!(matches!(err, UserCommandError::InvalidPasswordSource)); + } } From 0290c06831a9fab741d3ece880df59487db0e786 Mon Sep 17 00:00:00 2001 From: Nathael Bonnal Date: Wed, 2 Sep 2026 23:37:10 +0200 Subject: [PATCH 2/3] fix(user): correct set-password endpoint (verified against a live server) set_user_password guessed PUT realms/{realm}/users/{id}/password (404). The real endpoint is PUT realms/{realm}/users/{id}/reset-password - found via the 405 Allow header while probing against a running FerrisKey server. --- libs/ferriskey-cli-client/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/ferriskey-cli-client/src/lib.rs b/libs/ferriskey-cli-client/src/lib.rs index 04fe425..5a8ee28 100644 --- a/libs/ferriskey-cli-client/src/lib.rs +++ b/libs/ferriskey-cli-client/src/lib.rs @@ -658,7 +658,7 @@ impl FerriskeyClient { ) -> Result<(), FerriskeyClientError> { let response = self .http - .put(self.endpoint(&format!("realms/{realm}/users/{user_id}/password"))) + .put(self.endpoint(&format!("realms/{realm}/users/{user_id}/reset-password"))) .bearer_auth(&self.token) .json(request) .send()?; From 0a19464dcb97b9f284b1f7dba1cd4ab21dd4d6c4 Mon Sep 17 00:00:00 2001 From: Nathael Bonnal Date: Thu, 3 Sep 2026 00:24:30 +0200 Subject: [PATCH 3/3] fix(user): use auth_client after rebase reintroduced authenticate calls GitHub's branch-update rebase of this PR onto main (post-#32) replayed this branch's additive commit without semantic conflict: the new remove-role/roles/set-password functions still called the now-removed authenticate(context, realm) helper instead of #32's auth_client(context). --- libs/ferriskey-cli-core/src/user.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/ferriskey-cli-core/src/user.rs b/libs/ferriskey-cli-core/src/user.rs index 20f25fc..f020955 100644 --- a/libs/ferriskey-cli-core/src/user.rs +++ b/libs/ferriskey-cli-core/src/user.rs @@ -283,7 +283,7 @@ fn remove_role( ) -> Result<()> { let context = resolve_context(context_override, inline_context)?; let realm = resolve_realm(&context, args.realm)?; - let client = authenticate(&context, &realm)?; + let client = auth_client(&context)?; let user = find_user(&client, &realm, &args.username)?; let role = resolve_role(&client, &realm, &args.role)?; client.remove_user_role(&realm, &user.id, &role.id)?; @@ -304,7 +304,7 @@ fn list_user_roles( ) -> Result<()> { let context = resolve_context(context_override, inline_context)?; let realm = resolve_realm(&context, args.realm)?; - let client = authenticate(&context, &realm)?; + let client = auth_client(&context)?; let user = find_user(&client, &realm, &args.username)?; let roles = client.list_user_roles(&realm, &user.id)?; let views: Vec = roles.into_iter().map(to_role_view).collect(); @@ -349,7 +349,7 @@ fn set_password( let context = resolve_context(context_override, inline_context)?; let realm = resolve_realm(&context, args.realm)?; - let client = authenticate(&context, &realm)?; + let client = auth_client(&context)?; let user = find_user(&client, &realm, &args.username)?; client.set_user_password( &realm,