diff --git a/src/cli/user_command.rs b/src/cli/user_command.rs index 4412966..512b9fe 100644 --- a/src/cli/user_command.rs +++ b/src/cli/user_command.rs @@ -40,6 +40,14 @@ pub enum UserCommand { /// If no username is provided, all users you have access will be shown. #[command()] ShowUser(UserShowArgs), + + /// Lock account for one or more users + #[command()] + LockUser(UserLockArgs), + + /// Unlock account for one or more users + #[command()] + UnlockUser(UserUnlockArgs), } #[derive(Parser)] @@ -75,6 +83,18 @@ pub struct UserShowArgs { json: bool, } +#[derive(Parser)] +pub struct UserLockArgs { + #[arg(num_args = 1..)] + username: Vec, +} + +#[derive(Parser)] +pub struct UserUnlockArgs { + #[arg(num_args = 1..)] + username: Vec, +} + pub async fn handle_command( command: UserCommand, mut connection: MySqlConnection, @@ -87,6 +107,8 @@ pub async fn handle_command( UserCommand::DropUser(args) => drop_users(args, txn).await, UserCommand::PasswdUser(args) => change_password_for_user(args, txn).await, UserCommand::ShowUser(args) => show_users(args, txn).await, + UserCommand::LockUser(args) => lock_users(args, txn).await, + UserCommand::UnlockUser(args) => unlock_users(args, txn).await, } }) }) @@ -236,6 +258,7 @@ async fn show_users( json!({ "user": user.user, "has_password": user.has_password, + "is_locked": user.is_locked, "databases": user_databases.get(&user.user).unwrap_or(&vec![]), }) }) @@ -252,12 +275,14 @@ async fn show_users( table.add_row(row![ "User", "Password is set", + "Locked", "Databases where user has privileges" ]); for user in users { table.add_row(row![ user.user, user.has_password, + user.is_locked, user_databases.get(&user.user).unwrap_or(&vec![]).join("\n") ]); } @@ -266,3 +291,49 @@ async fn show_users( Ok(CommandStatus::NoModificationsIntended) } + +async fn lock_users( + args: UserLockArgs, + connection: &mut MySqlConnection, +) -> anyhow::Result { + if args.username.is_empty() { + anyhow::bail!("No usernames provided"); + } + + let mut result = CommandStatus::SuccessfullyModified; + + for username in args.username { + if let Err(e) = lock_database_user(&username, connection).await { + eprintln!("{}", e); + eprintln!("Skipping..."); + result = CommandStatus::PartiallySuccessfullyModified; + } else { + println!("User '{}' locked.", username); + } + } + + Ok(result) +} + +async fn unlock_users( + args: UserUnlockArgs, + connection: &mut MySqlConnection, +) -> anyhow::Result { + if args.username.is_empty() { + anyhow::bail!("No usernames provided"); + } + + let mut result = CommandStatus::SuccessfullyModified; + + for username in args.username { + if let Err(e) = unlock_database_user(&username, connection).await { + eprintln!("{}", e); + eprintln!("Skipping..."); + result = CommandStatus::PartiallySuccessfullyModified; + } else { + println!("User '{}' unlocked.", username); + } + } + + Ok(result) +} \ No newline at end of file diff --git a/src/core/user_operations.rs b/src/core/user_operations.rs index 1916982..d567f66 100644 --- a/src/core/user_operations.rs +++ b/src/core/user_operations.rs @@ -99,6 +99,79 @@ pub async fn set_password_for_database_user( Ok(()) } +async fn user_is_locked(db_user: &str, connection: &mut MySqlConnection) -> anyhow::Result { + let unix_user = get_current_unix_user()?; + + validate_user_name(db_user, &unix_user)?; + + if !user_exists(db_user, connection).await? { + anyhow::bail!("User '{}' does not exist", db_user); + } + + let is_locked = sqlx::query( + r#" + SELECT JSON_EXTRACT(`mysql`.`global_priv`.`priv`, "$.account_locked") = 'true' + FROM `mysql`.`global_priv` + WHERE `User` = ? + AND `Host` = '%' + "#, + ) + .bind(db_user) + .fetch_one(connection) + .await? + .get::(0); + + Ok(is_locked) +} + +pub async fn lock_database_user( + db_user: &str, + connection: &mut MySqlConnection, +) -> anyhow::Result<()> { + let unix_user = get_current_unix_user()?; + + validate_user_name(db_user, &unix_user)?; + + if !user_exists(db_user, connection).await? { + anyhow::bail!("User '{}' does not exist", db_user); + } + + if user_is_locked(db_user, connection).await? { + anyhow::bail!("User '{}' is already locked", db_user); + } + + // NOTE: see the note about SQL injections in `validate_ownership_of_user_name` + sqlx::query(format!("ALTER USER {}@'%' ACCOUNT LOCK", quote_literal(db_user),).as_str()) + .execute(connection) + .await?; + + Ok(()) +} + +pub async fn unlock_database_user( + db_user: &str, + connection: &mut MySqlConnection, +) -> anyhow::Result<()> { + let unix_user = get_current_unix_user()?; + + validate_user_name(db_user, &unix_user)?; + + if !user_exists(db_user, connection).await? { + anyhow::bail!("User '{}' does not exist", db_user); + } + + if !user_is_locked(db_user, connection).await? { + anyhow::bail!("User '{}' is already unlocked", db_user); + } + + // NOTE: see the note about SQL injections in `validate_ownership_of_user_name` + sqlx::query(format!("ALTER USER {}@'%' ACCOUNT UNLOCK", quote_literal(db_user),).as_str()) + .execute(connection) + .await?; + + Ok(()) +} + /// This struct contains information about a database user. /// This can be extended if we need more information in the future. #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] @@ -111,10 +184,28 @@ pub struct DatabaseUser { #[sqlx(rename = "Host")] pub host: String, - #[sqlx(rename = "`Password` != '' OR `authentication_string` != ''")] + #[sqlx(rename = "has_password")] pub has_password: bool, + + #[sqlx(rename = "is_locked")] + pub is_locked: bool, } +const DB_USER_SELECT_STATEMENT: &str = r#" +SELECT + `mysql`.`user`.`User`, + `mysql`.`user`.`Host`, + `mysql`.`user`.`Password` != '' OR `mysql`.`user`.`authentication_string` != '' AS `has_password`, + COALESCE( + JSON_EXTRACT(`mysql`.`global_priv`.`priv`, "$.account_locked"), + 'false' + ) != 'false' AS `is_locked` +FROM `mysql`.`user` +JOIN `mysql`.`global_priv` ON + `mysql`.`user`.`User` = `mysql`.`global_priv`.`User` + AND `mysql`.`user`.`Host` = `mysql`.`global_priv`.`Host` +"#; + /// This function fetches all database users that have a prefix matching the /// unix username and group names of the given unix user. pub async fn get_all_database_users_for_unix_user( @@ -122,14 +213,7 @@ pub async fn get_all_database_users_for_unix_user( connection: &mut MySqlConnection, ) -> anyhow::Result> { let users = sqlx::query_as::<_, DatabaseUser>( - r#" - SELECT - `User`, - `Host`, - `Password` != '' OR `authentication_string` != '' - FROM `mysql`.`user` - WHERE `User` REGEXP ? - "#, + &(DB_USER_SELECT_STATEMENT.to_string() + "WHERE `mysql`.`user`.`User` REGEXP ?"), ) .bind(create_user_group_matching_regex(unix_user)) .fetch_all(connection) @@ -144,14 +228,7 @@ pub async fn get_database_user_for_user( connection: &mut MySqlConnection, ) -> anyhow::Result> { let user = sqlx::query_as::<_, DatabaseUser>( - r#" - SELECT - `User`, - `Host`, - `Password` != '' OR `authentication_string` != '' - FROM `mysql`.`user` - WHERE `User` = ? - "#, + &(DB_USER_SELECT_STATEMENT.to_string() + "WHERE `mysql`.`user`.`User` = ?"), ) .bind(username) .fetch_optional(connection)