create compatibility layer for mysql-admutils commands #32

Merged
oysteikt merged 1 commits from old-cli-compatibility-layer into main 2024-08-06 23:31:48 +02:00
14 changed files with 688 additions and 79 deletions

View File

@ -22,7 +22,9 @@ tokio = { version = "1.37.0", features = ["rt-multi-thread", "macros"] }
toml = "0.8.12" toml = "0.8.12"
[features] [features]
default = ["mysql-admutils-compatibility"]
tui = ["dep:ratatui"] tui = ["dep:ratatui"]
mysql-admutils-compatibility = []
[[bin]] [[bin]]
name = "mysqladm" name = "mysqladm"
@ -33,3 +35,6 @@ path = "src/main.rs"
strip = true strip = true
lto = true lto = true
codegen-units = 1 codegen-units = 1
[build-dependencies]
anyhow = "1.0.82"

View File

@ -47,3 +47,28 @@ To stop and remove the container, run the following command:
```bash ```bash
docker stop mariadb docker stop mariadb
``` ```
## Compatibility mode with [mysql-admutils](https://git.pvv.ntnu.no/Projects/mysql-admutils)
If you enable the feature flag `mysql-admutils-compatibility` (enabled by default), the output directory will contain two symlinks to the binary, `mysql-dbadm` and `mysql-useradm`. In the same fashion as busybox, the binary will react to its `argv[0]` and behave as if it was called with the corresponding name. While the internal functionality is written in rust, these modes strive to behave as similar as possible to the original programs.
```bash
cargo build
./target/debug/mysql-dbadm --help
./target/debug/mysql-useradm --help
```
### Known deviations from the original programs
- Added flags for database configuration, not present in the original programs
- `--help` output is formatted by clap in a modern style.
- `mysql-dbadm edit-perm` uses the new implementation. The idea was that the parsing
logic was too complex to be worth porting, and there wouldn't be any scripts depending
on this command anyway. As such, the new implementation is more user-friendly and only
brings positive changes.
- The new tools use the modern implementation to find it's configuration. If you compiled
the old programs with `--sysconfdir=<somewhere>`, you might have to provide `--config-file`
where the old program would just work by itself.
- The order in which some things are validated (e.g. whether you own a user, whether the
contains illegal characters, whether the user does or does not exist) might be different
from the original program, leading to the same command giving the errors in a different order.

38
build.rs Normal file
View File

@ -0,0 +1,38 @@
#[cfg(feature = "mysql-admutils-compatibility")]
use anyhow::anyhow;
#[cfg(feature = "mysql-admutils-compatibility")]
use std::{env, os::unix::fs::symlink, path::PathBuf};
fn main() -> anyhow::Result<()> {
#[cfg(feature = "mysql-admutils-compatibility")]
{
// NOTE: This is slightly illegal, and depends on implementation details.
// But it is only here for ease of testing the compatibility layer,
// and not critical in any way. Considering the code is never going
// to be used as a library, it should be fine.
let target_profile_dir: PathBuf = PathBuf::from(env::var("OUT_DIR")?)
.parent()
.and_then(|p| p.parent())
.and_then(|p| p.parent())
.ok_or(anyhow!("Could not resolve target profile directory"))?
.to_path_buf();
dbg!(&target_profile_dir);
if !target_profile_dir.join("mysql-useradm").exists() {
symlink(
target_profile_dir.join("mysqladm"),
target_profile_dir.join("mysql-useradm"),
)?;
}
if !target_profile_dir.join("mysql-dbadm").exists() {
symlink(
target_profile_dir.join("mysqladm"),
target_profile_dir.join("mysql-dbadm"),
)?;
}
}
Ok(())
}

View File

@ -1,2 +1,3 @@
pub mod database_command; pub mod database_command;
pub mod user_command; pub mod user_command;
pub mod mysql_admutils_compatibility;

View File

@ -124,22 +124,22 @@ pub struct DatabaseShowPermArgs {
#[derive(Parser)] #[derive(Parser)]
pub struct DatabaseEditPermArgs { pub struct DatabaseEditPermArgs {
/// The name of the database to edit permissions for. /// The name of the database to edit permissions for.
name: Option<String>, pub name: Option<String>,
#[arg(short, long, value_name = "[DATABASE:]USER:PERMISSIONS", num_args = 0..)] #[arg(short, long, value_name = "[DATABASE:]USER:PERMISSIONS", num_args = 0..)]
perm: Vec<String>, pub perm: Vec<String>,
/// Whether to output the information in JSON format. /// Whether to output the information in JSON format.
#[arg(short, long)] #[arg(short, long)]
json: bool, pub json: bool,
/// Specify the text editor to use for editing permissions. /// Specify the text editor to use for editing permissions.
#[arg(short, long)] #[arg(short, long)]
editor: Option<String>, pub editor: Option<String>,
/// Disable interactive confirmation before saving changes. /// Disable interactive confirmation before saving changes.
#[arg(short, long)] #[arg(short, long)]
yes: bool, pub yes: bool,
} }
pub async fn handle_command( pub async fn handle_command(
@ -410,7 +410,7 @@ fn format_privileges_line(
.to_string() .to_string()
} }
async fn edit_permissions( pub async fn edit_permissions(
args: DatabaseEditPermArgs, args: DatabaseEditPermArgs,
conn: &mut MySqlConnection, conn: &mut MySqlConnection,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {

View File

@ -0,0 +1,3 @@
pub mod common;
pub mod mysql_dbadm;
pub mod mysql_useradm;

View File

@ -0,0 +1,80 @@
use crate::core::common::{
get_current_unix_user, validate_name_token, validate_ownership_by_user_prefix,
};
/// This enum is used to differentiate between database and user operations.
/// Their output are very similar, but there are slight differences in the words used.
pub enum DbOrUser {
Database,
User,
}
impl DbOrUser {
pub fn lowercased(&self) -> String {
match self {
DbOrUser::Database => "database".to_string(),
DbOrUser::User => "user".to_string(),
}
}
pub fn capitalized(&self) -> String {
match self {
DbOrUser::Database => "Database".to_string(),
DbOrUser::User => "User".to_string(),
}
}
}
/// In contrast to the new implementation which reports errors on any invalid name
/// for any reason, mysql-admutils would only log the error and skip that particular
/// name. This function replicates that behavior.
pub fn filter_db_or_user_names(
names: Vec<String>,
db_or_user: DbOrUser,
) -> anyhow::Result<Vec<String>> {
let unix_user = get_current_unix_user()?;
let argv0 = std::env::args().next().unwrap_or_else(|| match db_or_user {
DbOrUser::Database => "mysql-dbadm".to_string(),
DbOrUser::User => "mysql-useradm".to_string(),
});
let filtered_names = names
.into_iter()
// NOTE: The original implementation would only copy the first 32 characters
// of the argument into it's internal buffer. We replicate that behavior
// here.
.map(|name| name.chars().take(32).collect::<String>())
.filter(|name| {
if let Err(_err) = validate_ownership_by_user_prefix(name, &unix_user) {
println!(
"You are not in charge of mysql-{}: '{}'. Skipping.",
db_or_user.lowercased(),
name
);
return false;
}
true
})
.filter(|name| {
// NOTE: while this also checks for the length of the name,
// the name is already truncated to 32 characters. So
// if there is an error, it's guaranteed to be due to
// invalid characters.
if let Err(_err) = validate_name_token(name) {
println!(
concat!(
"{}: {} name '{}' contains invalid characters.\n",
"Only A-Z, a-z, 0-9, _ (underscore) and - (dash) permitted. Skipping.",
),
argv0,
db_or_user.capitalized(),
name
);
return false;
}
true
})
.collect();
Ok(filtered_names)
}

View File

@ -0,0 +1,220 @@
use clap::Parser;
use sqlx::MySqlConnection;
use crate::{
cli::{
database_command,
mysql_admutils_compatibility::common::{filter_db_or_user_names, DbOrUser},
},
core::{
config::{get_config, mysql_connection_from_config, GlobalConfigArgs},
database_operations::{self, yn},
},
};
const HELP_DB_PERM: &str = r#"
Edit permissions for the DATABASE(s). Running this command will
spawn the editor stored in the $EDITOR environment variable.
(pico will be used if the variable is unset)
The file should contain one line per user, starting with the
username and followed by ten Y/N-values seperated by whitespace.
Lines starting with # are ignored.
The Y/N-values corresponds to the following mysql privileges:
Select - Enables use of SELECT
Insert - Enables use of INSERT
Update - Enables use of UPDATE
Delete - Enables use of DELETE
Create - Enables use of CREATE TABLE
Drop - Enables use of DROP TABLE
Alter - Enables use of ALTER TABLE
Index - Enables use of CREATE INDEX and DROP INDEX
Temp - Enables use of CREATE TEMPORARY TABLE
Lock - Enables use of LOCK TABLE
References - Enables use of REFERENCES
"#;
#[derive(Parser)]
pub struct Args {
#[command(subcommand)]
pub command: Option<Command>,
#[command(flatten)]
config_overrides: GlobalConfigArgs,
/// Print help for the 'editperm' subcommand.
#[arg(long, global = true)]
pub help_editperm: bool,
}
/// Create, drop or edit permissions for the DATABASE(s),
/// as determined by the COMMAND.
///
/// This is a compatibility layer for the mysql-dbadm command.
/// Please consider using the newer mysqladm command instead.
#[derive(Parser)]
#[command(
version,
about,
disable_help_subcommand = true,
verbatim_doc_comment,
)]
pub enum Command {
/// create the DATABASE(s).
Create(CreateArgs),
/// delete the DATABASE(s).
Drop(DatabaseDropArgs),
/// give information about the DATABASE(s), or, if
/// none are given, all the ones you own.
Show(DatabaseShowArgs),
// TODO: make this output more verbatim_doc_comment-like,
// without messing up the indentation.
/// change permissions for the DATABASE(s). Your
/// favorite editor will be started, allowing you
/// to make changes to the permission table.
/// Run 'mysql-dbadm --help-editperm' for more
/// information.
EditPerm(EditPermArgs),
}
#[derive(Parser)]
pub struct CreateArgs {
/// The name of the DATABASE(s) to create.
#[arg(num_args = 1..)]
name: Vec<String>,
}
#[derive(Parser)]
pub struct DatabaseDropArgs {
/// The name of the DATABASE(s) to drop.
#[arg(num_args = 1..)]
name: Vec<String>,
}
#[derive(Parser)]
pub struct DatabaseShowArgs {
/// The name of the DATABASE(s) to show.
#[arg(num_args = 0..)]
name: Vec<String>,
}
#[derive(Parser)]
pub struct EditPermArgs {
/// The name of the DATABASE to edit permissions for.
pub database: String,
}
pub async fn main() -> anyhow::Result<()> {
let args: Args = Args::parse();
if args.help_editperm {
println!("{}", HELP_DB_PERM);
return Ok(());
}
let command = match args.command {
Some(command) => command,
None => {
println!(
"Try `{} --help' for more information.",
std::env::args().next().unwrap_or("mysql-dbadm".to_string())
);
return Ok(());
}
};
let config = get_config(args.config_overrides)?;
let mut connection = mysql_connection_from_config(config).await?;
match command {
Command::Create(args) => {
let filtered_names = filter_db_or_user_names(args.name, DbOrUser::Database)?;
for name in filtered_names {
database_operations::create_database(&name, &mut connection).await?;
println!("Database {} created.", name);
}
}
Command::Drop(args) => {
let filtered_names = filter_db_or_user_names(args.name, DbOrUser::Database)?;
for name in filtered_names {
database_operations::drop_database(&name, &mut connection).await?;
println!("Database {} dropped.", name);
}
}
Command::Show(args) => {
let names = if args.name.is_empty() {
database_operations::get_database_list(&mut connection).await?
} else {
filter_db_or_user_names(args.name, DbOrUser::Database)?
};
for name in names {
show_db(&name, &mut connection).await?;
}
}
Command::EditPerm(args) => {
// TODO: This does not accurately replicate the behavior of the old implementation.
// Hopefully, not many people rely on this in an automated fashion, as it
// is made to be interactive in nature. However, we should still try to
// replicate the old behavior as closely as possible.
let edit_permissions_args = database_command::DatabaseEditPermArgs {
name: Some(args.database),
perm: vec![],
json: false,
editor: None,
yes: false,
};
database_command::edit_permissions(edit_permissions_args, &mut connection).await?;
}
}
Ok(())
}
async fn show_db(name: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> {
// NOTE: mysql-dbadm show has a quirk where valid database names
// for non-existent databases will report with no users.
// This function should *not* check for db existence, only
// validate the names.
let permissions = database_operations::get_database_privileges(name, conn)
.await
.unwrap_or(vec![]);
println!(
concat!(
"Database '{}':\n",
"# User Select Insert Update Delete Create Drop Alter Index Temp Lock References\n",
"# ---------------- ------ ------ ------ ------ ------ ---- ----- ----- ---- ---- ----------"
),
name,
);
if permissions.is_empty() {
println!("# (no permissions currently granted to any users)");
} else {
for permission in permissions {
println!(
" {:<16} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {}",
permission.user,
yn(permission.select_priv),
yn(permission.insert_priv),
yn(permission.update_priv),
yn(permission.delete_priv),
yn(permission.create_priv),
yn(permission.drop_priv),
yn(permission.alter_priv),
yn(permission.index_priv),
yn(permission.create_tmp_table_priv),
yn(permission.lock_tables_priv),
yn(permission.references_priv)
);
}
}
Ok(())
}

View File

@ -0,0 +1,171 @@
use clap::Parser;
use sqlx::MySqlConnection;
use crate::{
cli::{
mysql_admutils_compatibility::common::{filter_db_or_user_names, DbOrUser},
user_command,
},
core::{
common::get_current_unix_user,
config::{get_config, mysql_connection_from_config, GlobalConfigArgs},
user_operations::{
create_database_user, delete_database_user, get_all_database_users_for_unix_user,
password_is_set_for_database_user, set_password_for_database_user, user_exists,
},
},
};
#[derive(Parser)]
pub struct Args {
#[command(subcommand)]
pub command: Option<Command>,
#[command(flatten)]
config_overrides: GlobalConfigArgs,
}
/// Create, delete or change password for the USER(s),
/// as determined by the COMMAND.
///
/// This is a compatibility layer for the mysql-useradm command.
/// Please consider using the newer mysqladm command instead.
#[derive(Parser)]
#[command(version, about, disable_help_subcommand = true, verbatim_doc_comment)]
pub enum Command {
/// create the USER(s).
Create(CreateArgs),
/// delete the USER(s).
Delete(DeleteArgs),
/// change the MySQL password for the USER(s).
Passwd(PasswdArgs),
/// give information about the USERS(s), or, if
/// none are given, all the users you have.
Show(ShowArgs),
}
#[derive(Parser)]
pub struct CreateArgs {
/// The name of the USER(s) to create.
#[arg(num_args = 1..)]
name: Vec<String>,
}
#[derive(Parser)]
pub struct DeleteArgs {
/// The name of the USER(s) to delete.
#[arg(num_args = 1..)]
name: Vec<String>,
}
#[derive(Parser)]
pub struct PasswdArgs {
/// The name of the USER(s) to change the password for.
#[arg(num_args = 1..)]
name: Vec<String>,
}
#[derive(Parser)]
pub struct ShowArgs {
/// The name of the USER(s) to show.
#[arg(num_args = 0..)]
name: Vec<String>,
}
pub async fn main() -> anyhow::Result<()> {
let args: Args = Args::parse();
let command = match args.command {
Some(command) => command,
None => {
println!(
"Try `{} --help' for more information.",
std::env::args()
.next()
.unwrap_or("mysql-useradm".to_string())
);
return Ok(());
}
};
let config = get_config(args.config_overrides)?;
let mut connection = mysql_connection_from_config(config).await?;
match command {
Command::Create(args) => {
let filtered_names = filter_db_or_user_names(args.name, DbOrUser::User)?;
for name in filtered_names {
create_database_user(&name, &mut connection).await?;
}
}
Command::Delete(args) => {
let filtered_names = filter_db_or_user_names(args.name, DbOrUser::User)?;
for name in filtered_names {
delete_database_user(&name, &mut connection).await?;
}
}
Command::Passwd(args) => passwd(args, connection).await?,
Command::Show(args) => show(args, connection).await?,
}
Ok(())
}
async fn passwd(args: PasswdArgs, mut connection: MySqlConnection) -> anyhow::Result<()> {
let filtered_names = filter_db_or_user_names(args.name, DbOrUser::User)?;
// NOTE: this gets doubly checked during the call to `set_password_for_database_user`.
// This is moving the check before asking the user for the password,
// to avoid having them figure out that the user does not exist after they
// have entered the password twice.
let mut better_filtered_names = Vec::with_capacity(filtered_names.len());
for name in filtered_names.into_iter() {
if !user_exists(&name, &mut connection).await? {
println!(
"{}: User '{}' does not exist. You must create it first.",
std::env::args()
.next()
.unwrap_or("mysql-useradm".to_string()),
name,
);
} else {
better_filtered_names.push(name);
}
}
for name in better_filtered_names {
let password = user_command::read_password_from_stdin_with_double_check(&name)?;
set_password_for_database_user(&name, &password, &mut connection).await?;
println!("Password updated for user '{}'.", name);
}
Ok(())
}
async fn show(args: ShowArgs, mut connection: MySqlConnection) -> anyhow::Result<()> {
let users = if args.name.is_empty() {
let unix_user = get_current_unix_user()?;
get_all_database_users_for_unix_user(&unix_user, &mut connection)
.await?
.into_iter()
.map(|u| u.user)
.collect()
} else {
filter_db_or_user_names(args.name, DbOrUser::User)?
};
for user in users {
let password_is_set = password_is_set_for_database_user(&user, &mut connection).await?;
match password_is_set {
Some(true) => println!("User '{}': password set.", user),
Some(false) => println!("User '{}': no password set.", user),
None => {}
}
}
Ok(())
}

View File

@ -4,7 +4,7 @@ use anyhow::Context;
use clap::Parser; use clap::Parser;
use sqlx::{Connection, MySqlConnection}; use sqlx::{Connection, MySqlConnection};
use crate::core::user_operations::validate_ownership_of_user_name; use crate::core::user_operations::validate_user_name;
#[derive(Parser)] #[derive(Parser)]
pub struct UserArgs { pub struct UserArgs {
@ -102,6 +102,23 @@ async fn drop_users(args: UserDeleteArgs, conn: &mut MySqlConnection) -> anyhow:
Ok(()) Ok(())
} }
pub fn read_password_from_stdin_with_double_check(username: &str) -> anyhow::Result<String> {
let pass1 = rpassword::prompt_password(format!("New MySQL password for user '{}': ", username))
.context("Failed to read password")?;
let pass2 = rpassword::prompt_password(format!(
"Retype new MySQL password for user '{}': ",
username
))
.context("Failed to read password")?;
if pass1 != pass2 {
anyhow::bail!("Passwords do not match");
}
Ok(pass1)
}
async fn change_password_for_user( async fn change_password_for_user(
args: UserPasswdArgs, args: UserPasswdArgs,
conn: &mut MySqlConnection, conn: &mut MySqlConnection,
@ -109,7 +126,7 @@ async fn change_password_for_user(
// NOTE: although this also is checked in `set_password_for_database_user`, we check it here // NOTE: although this also is checked in `set_password_for_database_user`, we check it here
// to provide a more natural order of error messages. // to provide a more natural order of error messages.
let unix_user = crate::core::common::get_current_unix_user()?; let unix_user = crate::core::common::get_current_unix_user()?;
validate_ownership_of_user_name(&args.username, &unix_user)?; validate_user_name(&args.username, &unix_user)?;
let password = if let Some(password_file) = args.password_file { let password = if let Some(password_file) = args.password_file {
std::fs::read_to_string(password_file) std::fs::read_to_string(password_file)
@ -117,17 +134,7 @@ async fn change_password_for_user(
.trim() .trim()
.to_string() .to_string()
} else { } else {
let pass1 = rpassword::prompt_password("Enter new password: ") read_password_from_stdin_with_double_check(&args.username)?
.context("Failed to read password")?;
let pass2 = rpassword::prompt_password("Re-enter new password: ")
.context("Failed to read password")?;
if pass1 != pass2 {
anyhow::bail!("Passwords do not match");
}
pass1
}; };
crate::core::user_operations::set_password_for_database_user(&args.username, &password, conn) crate::core::user_operations::set_password_for_database_user(&args.username, &password, conn)
@ -144,7 +151,7 @@ async fn show_users(args: UserShowArgs, conn: &mut MySqlConnection) -> anyhow::R
} else { } else {
let mut result = vec![]; let mut result = vec![];
for username in args.username { for username in args.username {
if let Err(e) = validate_ownership_of_user_name(&username, &unix_user) { if let Err(e) = validate_user_name(&username, &unix_user) {
eprintln!("{}", e); eprintln!("{}", e);
eprintln!("Skipping..."); eprintln!("Skipping...");
continue; continue;

View File

@ -62,7 +62,30 @@ pub fn create_user_group_matching_regex(user: &User) -> String {
} }
} }
pub fn validate_prefix_for_user<'a>(name: &'a str, user: &User) -> anyhow::Result<&'a str> { pub fn validate_name_token(name: &str) -> anyhow::Result<()> {
if name.is_empty() {
anyhow::bail!("Database name cannot be empty.");
}
if name.len() > 64 {
anyhow::bail!("Database name is too long. Maximum length is 64 characters.");
}
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
anyhow::bail!(
indoc! {r#"
Invalid characters in name: '{}'
Only A-Z, a-z, 0-9, _ (underscore) and - (dash) are permitted.
"#},
name
);
}
Ok(())
}
pub fn validate_ownership_by_user_prefix<'a>(name: &'a str, user: &User) -> anyhow::Result<&'a str> {
let user_groups = get_unix_groups(user)?; let user_groups = get_unix_groups(user)?;
let mut split_name = name.split('_'); let mut split_name = name.split('_');

View File

@ -8,13 +8,12 @@ use serde::{Deserialize, Serialize};
use sqlx::{mysql::MySqlRow, prelude::*, MySqlConnection}; use sqlx::{mysql::MySqlRow, prelude::*, MySqlConnection};
use super::common::{ use super::common::{
create_user_group_matching_regex, get_current_unix_user, quote_identifier, create_user_group_matching_regex, get_current_unix_user, quote_identifier, validate_name_token, validate_ownership_by_user_prefix
validate_prefix_for_user,
}; };
pub async fn create_database(name: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> { pub async fn create_database(name: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> {
let user = get_current_unix_user()?; let user = get_current_unix_user()?;
validate_ownership_of_database_name(name, &user)?; validate_database_name(name, &user)?;
// NOTE: see the note about SQL injections in `validate_owner_of_database_name` // NOTE: see the note about SQL injections in `validate_owner_of_database_name`
sqlx::query(&format!("CREATE DATABASE {}", quote_identifier(name))) sqlx::query(&format!("CREATE DATABASE {}", quote_identifier(name)))
@ -33,7 +32,7 @@ pub async fn create_database(name: &str, conn: &mut MySqlConnection) -> anyhow::
pub async fn drop_database(name: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> { pub async fn drop_database(name: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> {
let user = get_current_unix_user()?; let user = get_current_unix_user()?;
validate_ownership_of_database_name(name, &user)?; validate_database_name(name, &user)?;
// NOTE: see the note about SQL injections in `validate_owner_of_database_name` // NOTE: see the note about SQL injections in `validate_owner_of_database_name`
sqlx::query(&format!("DROP DATABASE {}", quote_identifier(name))) sqlx::query(&format!("DROP DATABASE {}", quote_identifier(name)))
@ -213,7 +212,7 @@ pub async fn get_database_privileges(
conn: &mut MySqlConnection, conn: &mut MySqlConnection,
) -> anyhow::Result<Vec<DatabasePrivileges>> { ) -> anyhow::Result<Vec<DatabasePrivileges>> {
let unix_user = get_current_unix_user()?; let unix_user = get_current_unix_user()?;
validate_ownership_of_database_name(database_name, &unix_user)?; validate_database_name(database_name, &unix_user)?;
let result = sqlx::query_as::<_, DatabasePrivileges>(&format!( let result = sqlx::query_as::<_, DatabasePrivileges>(&format!(
"SELECT {} FROM `db` WHERE `db` = ?", "SELECT {} FROM `db` WHERE `db` = ?",
@ -391,28 +390,9 @@ pub async fn apply_permission_diffs(
/// properly. MySQL does not seem to allow for prepared statements, binding /// properly. MySQL does not seem to allow for prepared statements, binding
/// the database name as a parameter to the query. This means that we have /// the database name as a parameter to the query. This means that we have
/// to validate the database name ourselves to prevent SQL injection. /// to validate the database name ourselves to prevent SQL injection.
pub fn validate_ownership_of_database_name(name: &str, user: &User) -> anyhow::Result<()> { pub fn validate_database_name(name: &str, user: &User) -> anyhow::Result<()> {
if name.contains(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-') { validate_name_token(name).context("Invalid database name")?;
anyhow::bail!( validate_ownership_by_user_prefix(name, user).context("Invalid database name")?;
indoc! {r#"
Database name '{}' contains invalid characters.
Only A-Z, a-z, 0-9, _ (underscore) and - (dash) permitted.
"#},
name
);
}
if name.len() > 64 {
anyhow::bail!(
indoc! {r#"
Database name '{}' is too long.
Maximum length is 64 characters.
"#},
name
);
}
validate_prefix_for_user(name, user).context("Invalid database name")?;
Ok(()) Ok(())
} }

View File

@ -1,19 +1,44 @@
use anyhow::Context; use anyhow::Context;
use indoc::indoc;
use nix::unistd::User; use nix::unistd::User;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{prelude::*, MySqlConnection}; use sqlx::{prelude::*, MySqlConnection};
use crate::core::common::quote_literal; use crate::core::common::quote_literal;
use super::common::{create_user_group_matching_regex, get_current_unix_user, validate_prefix_for_user}; use super::common::{create_user_group_matching_regex, get_current_unix_user, validate_name_token, validate_ownership_by_user_prefix};
pub async fn user_exists(db_user: &str, conn: &mut MySqlConnection) -> anyhow::Result<bool> {
let unix_user = get_current_unix_user()?;
validate_user_name(db_user, &unix_user)?;
let user_exists = sqlx::query(
r#"
SELECT EXISTS(
SELECT 1
FROM `mysql`.`user`
WHERE `User` = ?
)
"#,
)
.bind(db_user)
.fetch_one(conn)
.await?
.get::<bool, _>(0);
Ok(user_exists)
}
pub async fn create_database_user(db_user: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> { pub async fn create_database_user(db_user: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> {
let unix_user = get_current_unix_user()?; let unix_user = get_current_unix_user()?;
validate_ownership_of_user_name(db_user, &unix_user)?; validate_user_name(db_user, &unix_user)?;
// NOTE: see the note about SQL injections in `validate_ownershipt_of_user_name` if user_exists(db_user, conn).await? {
anyhow::bail!("User '{}' already exists", db_user);
}
// NOTE: see the note about SQL injections in `validate_ownership_of_user_name`
sqlx::query(format!("CREATE USER {}@'%'", quote_literal(db_user),).as_str()) sqlx::query(format!("CREATE USER {}@'%'", quote_literal(db_user),).as_str())
.execute(conn) .execute(conn)
.await?; .await?;
@ -24,9 +49,13 @@ pub async fn create_database_user(db_user: &str, conn: &mut MySqlConnection) ->
pub async fn delete_database_user(db_user: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> { pub async fn delete_database_user(db_user: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> {
let unix_user = get_current_unix_user()?; let unix_user = get_current_unix_user()?;
validate_ownership_of_user_name(db_user, &unix_user)?; validate_user_name(db_user, &unix_user)?;
// NOTE: see the note about SQL injections in `validate_ownershipt_of_user_name` if !user_exists(db_user, conn).await? {
anyhow::bail!("User '{}' does not exist", db_user);
}
// NOTE: see the note about SQL injections in `validate_ownership_of_user_name`
sqlx::query(format!("DROP USER {}@'%'", quote_literal(db_user),).as_str()) sqlx::query(format!("DROP USER {}@'%'", quote_literal(db_user),).as_str())
.execute(conn) .execute(conn)
.await?; .await?;
@ -40,9 +69,13 @@ pub async fn set_password_for_database_user(
conn: &mut MySqlConnection, conn: &mut MySqlConnection,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let unix_user = crate::core::common::get_current_unix_user()?; let unix_user = crate::core::common::get_current_unix_user()?;
validate_ownership_of_user_name(db_user, &unix_user)?; validate_user_name(db_user, &unix_user)?;
// NOTE: see the note about SQL injections in `validate_ownershipt_of_user_name` if !user_exists(db_user, conn).await? {
anyhow::bail!("User '{}' does not exist", db_user);
}
// NOTE: see the note about SQL injections in `validate_ownership_of_user_name`
sqlx::query( sqlx::query(
format!( format!(
"ALTER USER {}@'%' IDENTIFIED BY {}", "ALTER USER {}@'%' IDENTIFIED BY {}",
@ -57,6 +90,27 @@ pub async fn set_password_for_database_user(
Ok(()) Ok(())
} }
#[derive(sqlx::FromRow)]
#[sqlx(transparent)]
pub struct PasswordIsSet(bool);
pub async fn password_is_set_for_database_user(
db_user: &str,
conn: &mut MySqlConnection,
) -> anyhow::Result<Option<bool>> {
let unix_user = crate::core::common::get_current_unix_user()?;
validate_user_name(db_user, &unix_user)?;
let user_has_password = sqlx::query_as::<_, PasswordIsSet>(
"SELECT authentication_string != '' FROM mysql.user WHERE User = ?"
)
.bind(db_user)
.fetch_optional(conn)
.await?;
Ok(user_has_password.map(|PasswordIsSet(is_set)| is_set))
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] #[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct DatabaseUser { pub struct DatabaseUser {
#[sqlx(rename = "User")] #[sqlx(rename = "User")]
@ -111,29 +165,9 @@ pub async fn get_database_user_for_user(
/// properly. MySQL does not seem to allow for prepared statements, binding /// properly. MySQL does not seem to allow for prepared statements, binding
/// the database name as a parameter to the query. This means that we have /// the database name as a parameter to the query. This means that we have
/// to validate the database name ourselves to prevent SQL injection. /// to validate the database name ourselves to prevent SQL injection.
pub fn validate_ownership_of_user_name(name: &str, user: &User) -> anyhow::Result<()> { pub fn validate_user_name(name: &str, user: &User) -> anyhow::Result<()> {
if name.contains(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-') { validate_name_token(name).context(format!("Invalid username: '{}'", name))?;
anyhow::bail!( validate_ownership_by_user_prefix(name, user).context(format!("Invalid username: '{}'", name))?;
indoc! {r#"
Username '{}' contains invalid characters.
Only A-Z, a-z, 0-9, _ (underscore) and - (dash) permitted.
"#},
name
);
}
// TODO: does the name have a length limit?
// if name.len() > 48 {
// anyhow::bail!(
// indoc! {r#"
// Username '{}' is too long.
// Maximum length is 48 characters. Skipping.
// "#},
// name
// );
// }
validate_prefix_for_user(name, user).context(format!("Invalid username: '{}'", name))?;
Ok(()) Ok(())
} }

View File

@ -1,6 +1,12 @@
#[macro_use] #[macro_use]
extern crate prettytable; extern crate prettytable;
#[cfg(feature = "mysql-admutils-compatibility")]
use std::path::PathBuf;
#[cfg(feature = "mysql-admutils-compatibility")]
use crate::cli::mysql_admutils_compatibility::{mysql_dbadm, mysql_useradm};
use clap::Parser; use clap::Parser;
mod cli; mod cli;
@ -41,6 +47,22 @@ enum Command {
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
env_logger::init(); env_logger::init();
#[cfg(feature = "mysql-admutils-compatibility")]
{
let argv0 = std::env::args().next().and_then(|s| {
PathBuf::from(s)
.file_name()
.map(|s| s.to_string_lossy().to_string())
});
match argv0.as_deref() {
Some("mysql-dbadm") => return mysql_dbadm::main().await,
Some("mysql-useradm") => return mysql_useradm::main().await,
_ => { /* fall through */ }
}
}
let args: Args = Args::parse(); let args: Args = Args::parse();
let config = core::config::get_config(args.config_overrides)?; let config = core::config::get_config(args.config_overrides)?;
let connection = core::config::mysql_connection_from_config(config).await?; let connection = core::config::mysql_connection_from_config(config).await?;