use indoc::formatdoc; use itertools::Itertools; use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use sqlx::MySqlConnection; use sqlx::prelude::*; use crate::core::protocol::request_validation::GroupDenylist; use crate::core::protocol::request_validation::validate_db_or_user_request; use crate::core::types::DbOrUser; use crate::{ core::{ common::UnixUser, database_privileges::DATABASE_PRIVILEGE_FIELDS, protocol::{ CreateUserError, CreateUsersResponse, DropUserError, DropUsersResponse, ListAllUsersError, ListAllUsersResponse, ListUsersError, ListUsersResponse, LockUserError, LockUsersResponse, SetPasswordError, SetUserPasswordResponse, UnlockUserError, UnlockUsersResponse, }, types::MySQLUser, }, server::{ common::{create_user_group_matching_regex, try_get_with_binary_fallback}, sql::quote_literal, }, }; // NOTE: this function is unsafe because it does no input validation. pub(super) async fn unsafe_user_exists( db_user: &str, connection: &mut MySqlConnection, ) -> Result { let result = sqlx::query( r" SELECT EXISTS( SELECT 1 FROM `mysql`.`user` WHERE `User` = ? ) ", ) .bind(db_user) .fetch_one(connection) .await .map(|row| row.get::(0)); if let Err(err) = &result { tracing::error!("Failed to check if database user exists: {:?}", err); } result } pub async fn complete_user_name( user_prefix: String, unix_user: &UnixUser, connection: &mut MySqlConnection, _db_is_mariadb: bool, group_denylist: &GroupDenylist, ) -> Vec { let result = sqlx::query( r" SELECT `User` AS `user` FROM `mysql`.`user` WHERE `User` REGEXP ? AND `User` LIKE ? ", ) .bind(create_user_group_matching_regex(unix_user, group_denylist)) .bind(format!("{user_prefix}%")) .fetch_all(connection) .await; match result { Ok(rows) => rows .into_iter() .filter_map(|row| { let user: String = try_get_with_binary_fallback(&row, "user").ok()?; Some(user.into()) }) .collect(), Err(err) => { tracing::error!( "Failed to complete user name for prefix '{}' and user '{}': {:?}", user_prefix, unix_user.username, err ); vec![] } } } pub async fn create_database_users( db_users: Vec, unix_user: &UnixUser, connection: &mut MySqlConnection, _db_is_mariadb: bool, group_denylist: &GroupDenylist, ) -> CreateUsersResponse { let mut results = BTreeMap::new(); for db_user in db_users { if let Err(err) = validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist) .map_err(CreateUserError::ValidationError) { results.insert(db_user, Err(err)); continue; } match unsafe_user_exists(&db_user, &mut *connection).await { Ok(true) => { results.insert(db_user, Err(CreateUserError::UserAlreadyExists)); continue; } Err(err) => { results.insert(db_user, Err(CreateUserError::MySqlError(err.to_string()))); continue; } _ => {} } let result = sqlx::query(format!("CREATE USER {}@'%'", quote_literal(&db_user),).as_str()) .execute(&mut *connection) .await .map(|_| ()) .map_err(|err| CreateUserError::MySqlError(err.to_string())); if let Err(err) = &result { tracing::error!("Failed to create database user '{}': {:?}", &db_user, err); } results.insert(db_user, result); } results } pub async fn drop_database_users( db_users: Vec, unix_user: &UnixUser, connection: &mut MySqlConnection, _db_is_mariadb: bool, group_denylist: &GroupDenylist, ) -> DropUsersResponse { let mut results = BTreeMap::new(); for db_user in db_users { if let Err(err) = validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist) .map_err(DropUserError::ValidationError) { results.insert(db_user, Err(err)); continue; } match unsafe_user_exists(&db_user, &mut *connection).await { Ok(false) => { results.insert(db_user, Err(DropUserError::UserDoesNotExist)); continue; } Err(err) => { results.insert(db_user, Err(DropUserError::MySqlError(err.to_string()))); continue; } _ => {} } let result = sqlx::query(format!("DROP USER {}@'%'", quote_literal(&db_user),).as_str()) .execute(&mut *connection) .await .map(|_| ()) .map_err(|err| DropUserError::MySqlError(err.to_string())); if let Err(err) = &result { tracing::error!("Failed to drop database user '{}': {:?}", &db_user, err); } results.insert(db_user, result); } results } pub async fn set_password_for_database_user( db_user: &MySQLUser, password: &str, unix_user: &UnixUser, connection: &mut MySqlConnection, _db_is_mariadb: bool, group_denylist: &GroupDenylist, ) -> SetUserPasswordResponse { validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist) .map_err(SetPasswordError::ValidationError)?; match unsafe_user_exists(db_user, &mut *connection).await { Ok(false) => return Err(SetPasswordError::UserDoesNotExist), Err(err) => return Err(SetPasswordError::MySqlError(err.to_string())), _ => {} } let result = sqlx::query( format!( "ALTER USER {}@'%' IDENTIFIED BY {}", quote_literal(db_user), quote_literal(password).as_str(), ) .as_str(), ) .execute(&mut *connection) .await .map(|_| ()) .map_err(|err| SetPasswordError::MySqlError(err.to_string())); if result.is_err() { tracing::error!( "Failed to set password for database user '{}': ", &db_user, ); } result } const DATABASE_USER_LOCK_STATUS_QUERY_MARIADB: &str = r#" SELECT COALESCE( JSON_EXTRACT(`mysql`.`global_priv`.`priv`, "$.account_locked"), 'false' ) != 'false' FROM `mysql`.`global_priv` WHERE `User` = ? AND `Host` = '%' "#; const DATABASE_USER_LOCK_STATUS_QUERY_MYSQL: &str = r" SELECT `mysql`.`user`.`account_locked` = 'Y' FROM `mysql`.`user` WHERE `User` = ? AND `Host` = '%' "; // NOTE: this function is unsafe because it does no input validation. async fn database_user_is_locked_unsafe( db_user: &str, connection: &mut MySqlConnection, db_is_mariadb: bool, ) -> Result { let result = sqlx::query(if db_is_mariadb { DATABASE_USER_LOCK_STATUS_QUERY_MARIADB } else { DATABASE_USER_LOCK_STATUS_QUERY_MYSQL }) .bind(db_user) .fetch_one(connection) .await .map(|row| row.try_get(0)) .and_then(|res| res); if let Err(err) = &result { tracing::error!( "Failed to check if database user is locked '{}': {:?}", &db_user, err ); } result } pub async fn lock_database_users( db_users: Vec, unix_user: &UnixUser, connection: &mut MySqlConnection, db_is_mariadb: bool, group_denylist: &GroupDenylist, ) -> LockUsersResponse { let mut results = BTreeMap::new(); for db_user in db_users { if let Err(err) = validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist) .map_err(LockUserError::ValidationError) { results.insert(db_user, Err(err)); continue; } match unsafe_user_exists(&db_user, &mut *connection).await { Ok(true) => {} Ok(false) => { results.insert(db_user, Err(LockUserError::UserDoesNotExist)); continue; } Err(err) => { results.insert(db_user, Err(LockUserError::MySqlError(err.to_string()))); continue; } } match database_user_is_locked_unsafe(&db_user, &mut *connection, db_is_mariadb).await { Ok(false) => {} Ok(true) => { results.insert(db_user, Err(LockUserError::UserIsAlreadyLocked)); continue; } Err(err) => { results.insert(db_user, Err(LockUserError::MySqlError(err.to_string()))); continue; } } let result = sqlx::query( format!("ALTER USER {}@'%' ACCOUNT LOCK", quote_literal(&db_user),).as_str(), ) .execute(&mut *connection) .await .map(|_| ()) .map_err(|err| LockUserError::MySqlError(err.to_string())); if let Err(err) = &result { tracing::error!("Failed to lock database user '{}': {:?}", &db_user, err); } results.insert(db_user, result); } results } pub async fn unlock_database_users( db_users: Vec, unix_user: &UnixUser, connection: &mut MySqlConnection, db_is_mariadb: bool, group_denylist: &GroupDenylist, ) -> UnlockUsersResponse { let mut results = BTreeMap::new(); for db_user in db_users { if let Err(err) = validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist) .map_err(UnlockUserError::ValidationError) { results.insert(db_user, Err(err)); continue; } match unsafe_user_exists(&db_user, &mut *connection).await { Ok(false) => { results.insert(db_user, Err(UnlockUserError::UserDoesNotExist)); continue; } Err(err) => { results.insert(db_user, Err(UnlockUserError::MySqlError(err.to_string()))); continue; } _ => {} } match database_user_is_locked_unsafe(&db_user, &mut *connection, db_is_mariadb).await { Ok(false) => { results.insert(db_user, Err(UnlockUserError::UserIsAlreadyUnlocked)); continue; } Err(err) => { results.insert(db_user, Err(UnlockUserError::MySqlError(err.to_string()))); continue; } _ => {} } let result = sqlx::query( format!("ALTER USER {}@'%' ACCOUNT UNLOCK", quote_literal(&db_user),).as_str(), ) .execute(&mut *connection) .await .map(|_| ()) .map_err(|err| UnlockUserError::MySqlError(err.to_string())); if let Err(err) = &result { tracing::error!("Failed to unlock database user '{}': {:?}", &db_user, err); } results.insert(db_user, result); } results } /// This struct contains information about a database user. /// This can be extended if we need more information in the future. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DatabaseUser { pub user: MySQLUser, #[serde(skip)] pub host: String, pub has_password: bool, pub is_locked: bool, pub databases: Vec, } impl FromRow<'_, sqlx::mysql::MySqlRow> for DatabaseUser { fn from_row(row: &sqlx::mysql::MySqlRow) -> Result { Ok(Self { user: try_get_with_binary_fallback(row, "User")?.into(), host: try_get_with_binary_fallback(row, "Host")?, has_password: row.try_get("has_password")?, is_locked: row.try_get("account_locked")?, databases: Vec::new(), }) } } const DB_USER_SELECT_STATEMENT_MARIADB: &str = r#" SELECT `user`.`User`, `user`.`Host`, `user`.`Password` != '' OR `user`.`authentication_string` != '' AS `has_password`, COALESCE( JSON_EXTRACT(`global_priv`.`priv`, "$.account_locked"), 'false' ) != 'false' AS `account_locked` FROM `user` JOIN `global_priv` ON `user`.`User` = `global_priv`.`User` AND `user`.`Host` = `global_priv`.`Host` "#; const DB_USER_SELECT_STATEMENT_MYSQL: &str = r" SELECT `user`.`User`, `user`.`Host`, `user`.`authentication_string` != '' AS `has_password`, `user`.`account_locked` = 'Y' AS `account_locked` FROM `user` "; pub async fn list_database_users( db_users: Vec, unix_user: &UnixUser, connection: &mut MySqlConnection, db_is_mariadb: bool, group_denylist: &GroupDenylist, ) -> ListUsersResponse { let mut results = BTreeMap::new(); for db_user in db_users { if let Err(err) = validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist) .map_err(ListUsersError::ValidationError) { results.insert(db_user, Err(err)); continue; } let mut result = sqlx::query_as::<_, DatabaseUser>( &(if db_is_mariadb { DB_USER_SELECT_STATEMENT_MARIADB.to_string() } else { DB_USER_SELECT_STATEMENT_MYSQL.to_string() } + "WHERE `mysql`.`user`.`User` = ?"), ) .bind(db_user.as_str()) .fetch_optional(&mut *connection) .await; if let Err(err) = &result { tracing::error!("Failed to list database user '{}': {:?}", &db_user, err); } if let Ok(Some(user)) = result.as_mut() && let Err(err) = set_databases_where_user_has_privileges(user, &mut *connection).await { result = Err(err); } match result { Ok(Some(user)) => results.insert(db_user, Ok(user)), Ok(None) => results.insert(db_user, Err(ListUsersError::UserDoesNotExist)), Err(err) => results.insert(db_user, Err(ListUsersError::MySqlError(err.to_string()))), }; } results } pub async fn list_all_database_users_for_unix_user( unix_user: &UnixUser, connection: &mut MySqlConnection, db_is_mariadb: bool, group_denylist: &GroupDenylist, ) -> ListAllUsersResponse { let mut result = sqlx::query_as::<_, DatabaseUser>( &(if db_is_mariadb { DB_USER_SELECT_STATEMENT_MARIADB.to_string() } else { DB_USER_SELECT_STATEMENT_MYSQL.to_string() } + "WHERE `user`.`User` REGEXP ?"), ) .bind(create_user_group_matching_regex(unix_user, group_denylist)) .fetch_all(&mut *connection) .await .map_err(|err| ListAllUsersError::MySqlError(err.to_string())); if let Err(err) = &result { tracing::error!("Failed to list all database users: {:?}", err); } if let Ok(users) = result.as_mut() { for user in users { if let Err(mysql_error) = set_databases_where_user_has_privileges(user, &mut *connection).await { return Err(ListAllUsersError::MySqlError(mysql_error.to_string())); } } } result } /// This function sets the `databases` field of the given `DatabaseUser` /// where the user has any privileges. pub async fn set_databases_where_user_has_privileges( db_user: &mut DatabaseUser, connection: &mut MySqlConnection, ) -> Result<(), sqlx::Error> { let database_list = sqlx::query( formatdoc!( r" SELECT `Db` AS `database` FROM `db` WHERE `User` = ? AND ({}) ", DATABASE_PRIVILEGE_FIELDS .iter() .map(|field| format!("`{field}` = 'Y'")) .join(" OR "), ) .as_str(), ) .bind(db_user.user.as_str()) .fetch_all(&mut *connection) .await; if let Err(err) = &database_list { tracing::error!( "Failed to list databases for user '{}': {:?}", &db_user.user, err ); } db_user.databases = database_list.and_then(|rows| { rows.into_iter() .map(|row| try_get_with_binary_fallback(&row, "database")) .collect::, sqlx::Error>>() })?; Ok(()) }