Compare commits

...

3 Commits

5 changed files with 201 additions and 20 deletions

View File

@ -2,6 +2,17 @@
name = "mysqladm-rs" name = "mysqladm-rs"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
# TODO: change licensing?
license = "GPL-2.0-only"
authors = ["oysteikt@pvv.ntnu.no"]
repository = "https://git.pvv.ntnu.no/Projects/mysqladm-rs"
# TODO: write a proper description
description = """\
asdf
"""
categories = ["command-line-interface", "command-line-utilities"]
keywords = ["mysql", "cli", "administration"]
readme = "README.md"
[dependencies] [dependencies]
anyhow = "1.0.82" anyhow = "1.0.82"
@ -37,3 +48,21 @@ codegen-units = 1
[build-dependencies] [build-dependencies]
anyhow = "1.0.82" anyhow = "1.0.82"
# TODO: package shell completions
[package.metadata.deb]
maintainer = "Programvareverkstedet <projects@pvv.ntnu.no>"
section = "admin"
assets = [
[
"target/release/mysqladm",
"usr/bin/",
"4755",
],
[
"example-config.toml",
"etc/mysqladm/config.toml",
"644",
],
]
conf-files = ["etc/mysqladm/config.toml"]

View File

@ -1,8 +1,6 @@
# This should go to `/etc/mysqladm/config.toml`
[mysql] [mysql]
host = "localhost" host = "localhost"
port = 3306 port = 3306
username = "root" username = "root"
password = "secret" password = "secret"
timeout = 2 # seconds timeout = 2 # seconds

View File

@ -178,6 +178,8 @@ async fn create_databases(
eprintln!("Failed to create database '{}': {}", name, e); eprintln!("Failed to create database '{}': {}", name, e);
eprintln!("Skipping..."); eprintln!("Skipping...");
result = CommandStatus::PartiallySuccessfullyModified; result = CommandStatus::PartiallySuccessfullyModified;
} else {
println!("Database '{}' created.", name);
} }
} }
@ -200,6 +202,8 @@ async fn drop_databases(
eprintln!("Failed to drop database '{}': {}", name, e); eprintln!("Failed to drop database '{}': {}", name, e);
eprintln!("Skipping..."); eprintln!("Skipping...");
result = CommandStatus::PartiallySuccessfullyModified; result = CommandStatus::PartiallySuccessfullyModified;
} else {
println!("Database '{}' dropped.", name);
} }
} }

View File

@ -40,6 +40,14 @@ pub enum UserCommand {
/// If no username is provided, all users you have access will be shown. /// If no username is provided, all users you have access will be shown.
#[command()] #[command()]
ShowUser(UserShowArgs), ShowUser(UserShowArgs),
/// Lock account for one or more users
#[command()]
LockUser(UserLockArgs),
/// Unlock account for one or more users
#[command()]
UnlockUser(UserUnlockArgs),
} }
#[derive(Parser)] #[derive(Parser)]
@ -75,6 +83,18 @@ pub struct UserShowArgs {
json: bool, json: bool,
} }
#[derive(Parser)]
pub struct UserLockArgs {
#[arg(num_args = 1..)]
username: Vec<String>,
}
#[derive(Parser)]
pub struct UserUnlockArgs {
#[arg(num_args = 1..)]
username: Vec<String>,
}
pub async fn handle_command( pub async fn handle_command(
command: UserCommand, command: UserCommand,
mut connection: MySqlConnection, mut connection: MySqlConnection,
@ -87,6 +107,8 @@ pub async fn handle_command(
UserCommand::DropUser(args) => drop_users(args, txn).await, UserCommand::DropUser(args) => drop_users(args, txn).await,
UserCommand::PasswdUser(args) => change_password_for_user(args, txn).await, UserCommand::PasswdUser(args) => change_password_for_user(args, txn).await,
UserCommand::ShowUser(args) => show_users(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,
} }
}) })
}) })
@ -154,6 +176,8 @@ async fn drop_users(
eprintln!("{}", e); eprintln!("{}", e);
eprintln!("Skipping..."); eprintln!("Skipping...");
result = CommandStatus::PartiallySuccessfullyModified; result = CommandStatus::PartiallySuccessfullyModified;
} else {
println!("User '{}' dropped.", username);
} }
} }
@ -236,6 +260,7 @@ async fn show_users(
json!({ json!({
"user": user.user, "user": user.user,
"has_password": user.has_password, "has_password": user.has_password,
"is_locked": user.is_locked,
"databases": user_databases.get(&user.user).unwrap_or(&vec![]), "databases": user_databases.get(&user.user).unwrap_or(&vec![]),
}) })
}) })
@ -252,12 +277,14 @@ async fn show_users(
table.add_row(row![ table.add_row(row![
"User", "User",
"Password is set", "Password is set",
"Locked",
"Databases where user has privileges" "Databases where user has privileges"
]); ]);
for user in users { for user in users {
table.add_row(row![ table.add_row(row![
user.user, user.user,
user.has_password, user.has_password,
user.is_locked,
user_databases.get(&user.user).unwrap_or(&vec![]).join("\n") user_databases.get(&user.user).unwrap_or(&vec![]).join("\n")
]); ]);
} }
@ -266,3 +293,49 @@ async fn show_users(
Ok(CommandStatus::NoModificationsIntended) Ok(CommandStatus::NoModificationsIntended)
} }
async fn lock_users(
args: UserLockArgs,
connection: &mut MySqlConnection,
) -> anyhow::Result<CommandStatus> {
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<CommandStatus> {
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)
}

View File

@ -99,6 +99,79 @@ pub async fn set_password_for_database_user(
Ok(()) Ok(())
} }
async fn user_is_locked(db_user: &str, connection: &mut MySqlConnection) -> anyhow::Result<bool> {
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::<bool, _>(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 struct contains information about a database user.
/// This can be extended if we need more information in the future. /// This can be extended if we need more information in the future.
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] #[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
@ -111,10 +184,28 @@ pub struct DatabaseUser {
#[sqlx(rename = "Host")] #[sqlx(rename = "Host")]
pub host: String, pub host: String,
#[sqlx(rename = "`Password` != '' OR `authentication_string` != ''")] #[sqlx(rename = "has_password")]
pub has_password: bool, 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 /// This function fetches all database users that have a prefix matching the
/// unix username and group names of the given unix user. /// unix username and group names of the given unix user.
pub async fn get_all_database_users_for_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, connection: &mut MySqlConnection,
) -> anyhow::Result<Vec<DatabaseUser>> { ) -> anyhow::Result<Vec<DatabaseUser>> {
let users = sqlx::query_as::<_, DatabaseUser>( let users = sqlx::query_as::<_, DatabaseUser>(
r#" &(DB_USER_SELECT_STATEMENT.to_string() + "WHERE `mysql`.`user`.`User` REGEXP ?"),
SELECT
`User`,
`Host`,
`Password` != '' OR `authentication_string` != ''
FROM `mysql`.`user`
WHERE `User` REGEXP ?
"#,
) )
.bind(create_user_group_matching_regex(unix_user)) .bind(create_user_group_matching_regex(unix_user))
.fetch_all(connection) .fetch_all(connection)
@ -144,14 +228,7 @@ pub async fn get_database_user_for_user(
connection: &mut MySqlConnection, connection: &mut MySqlConnection,
) -> anyhow::Result<Option<DatabaseUser>> { ) -> anyhow::Result<Option<DatabaseUser>> {
let user = sqlx::query_as::<_, DatabaseUser>( let user = sqlx::query_as::<_, DatabaseUser>(
r#" &(DB_USER_SELECT_STATEMENT.to_string() + "WHERE `mysql`.`user`.`User` = ?"),
SELECT
`User`,
`Host`,
`Password` != '' OR `authentication_string` != ''
FROM `mysql`.`user`
WHERE `User` = ?
"#,
) )
.bind(username) .bind(username)
.fetch_optional(connection) .fetch_optional(connection)