Reimplement most of the tool:

Most of the tool has been reimplemented, with the exception of the
permission editing feature, which is currently half implemented. There
are also several TODOs spread around that would benefit from some action
This commit is contained in:
Oystein Kristoffer Tveit 2024-04-21 06:03:25 +02:00
parent 77f7085d2b
commit ccf1b78ce8
Signed by: oysteikt
GPG Key ID: 9F2F7D8250F35146
14 changed files with 2173 additions and 915 deletions

1836
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -6,11 +6,26 @@ edition = "2021"
[dependencies] [dependencies]
anyhow = "1.0.82" anyhow = "1.0.82"
clap = { version = "4.5.4", features = ["derive"] } clap = { version = "4.5.4", features = ["derive"] }
mysql = "25.0.0" edit = "0.1.5"
env_logger = "0.11.3"
indoc = "2.0.5"
itertools = "0.12.1"
log = "0.4.21"
nix = { version = "0.28.0", features = ["user"] }
prettytable = "0.10.0"
rpassword = "7.3.1"
serde = "1.0.198" serde = "1.0.198"
serde_json = "1.0.116"
sqlx = { version = "0.7.4", features = ["runtime-tokio", "mysql", "tls-rustls"] }
tokio = { version = "1.37.0", features = ["rt-multi-thread", "macros"] }
toml = "0.8.12" toml = "0.8.12"
[[bin]] [[bin]]
name = "mysqladm" name = "mysqladm"
bench = false bench = false
path = "src/main.rs" path = "src/main.rs"
[profile.release]
strip = true
lto = true
codegen-units = 1

7
example-config.toml Normal file
View File

@ -0,0 +1,7 @@
# This should go to `/etc/mysqladm/config.toml`
[mysql]
host = "localhost"
posrt = 3306
username = "root"
password = "secret"

2
src/cli.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod database_command;
pub mod user_command;

343
src/cli/database_command.rs Normal file
View File

@ -0,0 +1,343 @@
use anyhow::Context;
use clap::Parser;
use prettytable::{Cell, Row, Table};
use sqlx::MySqlConnection;
use crate::core::{self, database_operations::DatabasePrivileges};
#[derive(Parser)]
pub struct DatabaseArgs {
#[clap(subcommand)]
subcmd: DatabaseCommand,
}
// TODO: Support batch creation/dropping,showing of databases,
// using a comma-separated list of database names.
#[derive(Parser)]
enum DatabaseCommand {
/// Create the DATABASE(S).
#[command(alias = "add", alias = "c")]
Create(DatabaseCreateArgs),
/// Delete the DATABASE(S).
#[command(alias = "remove", alias = "delete", alias = "rm", alias = "d")]
Drop(DatabaseDropArgs),
/// List the DATABASE(S) you own.
#[command()]
List(DatabaseListArgs),
/// Give information about the DATABASE(S), or if none are given, all the ones you own.
///
/// In particular, this will show the permissions for the database(s) owned by the current user.
#[command(alias = "s")]
ShowPerm(DatabaseShowPermArgs),
/// Change permissions for the DATABASE(S). Run `edit-perm --help` for more information.
///
/// TODO: fix this help message.
///
/// This command has two modes of operation:
/// 1. Interactive mode: If the `-t` flag is used, the user will be prompted to edit the permissions using a text editor.
/// 2. Non-interactive mode: If the `-t` flag is not used, the user can specify the permissions to change using the `-p` flag.
///
/// In non-interactive mode, the `-p` flag should be followed by strings, each representing a single permission change.
///
/// The permission arguments should be a string, formatted as `db:user:privileges`
/// where privs are a string of characters, each representing a single permissions,
/// with the exception of `A` which represents all permissions.
///
/// The permission to character mapping is as follows:
///
/// - `s` - SELECT
/// - `i` - INSERT
/// - `u` - UPDATE
/// - `d` - DELETE
/// - `c` - CREATE
/// - `D` - DROP
/// - `a` - ALTER
/// - `I` - INDEX
/// - `t` - CREATE TEMPORARY TABLES
/// - `l` - LOCK TABLES
/// - `r` - REFERENCES
/// - `A` - ALL PRIVILEGES
///
#[command(display_name = "edit-perm", alias = "e", verbatim_doc_comment)]
EditPerm(DatabaseEditPermArgs),
}
#[derive(Parser)]
struct DatabaseCreateArgs {
/// The name of the database(s) to create.
#[arg(num_args = 1..)]
name: Vec<String>,
}
#[derive(Parser)]
struct DatabaseDropArgs {
/// The name of the database(s) to drop.
#[arg(num_args = 1..)]
name: Vec<String>,
}
#[derive(Parser)]
struct DatabaseListArgs {
/// Whether to output the information in JSON format.
#[arg(short, long)]
json: bool,
}
#[derive(Parser)]
struct DatabaseShowPermArgs {
/// The name of the database(s) to show.
#[arg(num_args = 0..)]
name: Vec<String>,
/// Whether to output the information in JSON format.
#[arg(short, long)]
json: bool,
}
#[derive(Parser)]
struct DatabaseEditPermArgs {
/// The name of the database to edit permissions for.
name: Option<String>,
#[arg(short, long, value_name = "[DATABASE:]USER:PERMISSIONS", num_args = 0..)]
perm: Vec<String>,
/// Whether to output the information in JSON format.
#[arg(short, long)]
json: bool,
/// Whether to edit the permissions using a text editor.
#[arg(short, long)]
text: bool,
/// Specify the text editor to use for editing permissions.
#[arg(short, long)]
editor: Option<String>,
/// Disable confirmation before saving changes.
#[arg(short, long)]
yes: bool,
}
pub async fn handle_command(args: DatabaseArgs, conn: MySqlConnection) -> anyhow::Result<()> {
match args.subcmd {
DatabaseCommand::Create(args) => create_databases(args, conn).await,
DatabaseCommand::Drop(args) => drop_databases(args, conn).await,
DatabaseCommand::List(args) => list_databases(args, conn).await,
DatabaseCommand::ShowPerm(args) => show_databases(args, conn).await,
DatabaseCommand::EditPerm(args) => edit_permissions(args, conn).await,
}
}
async fn create_databases(
args: DatabaseCreateArgs,
mut conn: MySqlConnection,
) -> anyhow::Result<()> {
if args.name.is_empty() {
anyhow::bail!("No database names provided");
}
for name in args.name {
// TODO: This can be optimized by fetching all the database privileges in one query.
if let Err(e) = core::database_operations::create_database(&name, &mut conn).await {
eprintln!("Failed to create database '{}': {}", name, e);
eprintln!("Skipping...");
}
}
Ok(())
}
async fn drop_databases(args: DatabaseDropArgs, mut conn: MySqlConnection) -> anyhow::Result<()> {
if args.name.is_empty() {
anyhow::bail!("No database names provided");
}
for name in args.name {
// TODO: This can be optimized by fetching all the database privileges in one query.
if let Err(e) = core::database_operations::drop_database(&name, &mut conn).await {
eprintln!("Failed to drop database '{}': {}", name, e);
eprintln!("Skipping...");
}
}
Ok(())
}
async fn list_databases(args: DatabaseListArgs, mut conn: MySqlConnection) -> anyhow::Result<()> {
let databases = core::database_operations::get_database_list(&mut conn).await?;
if databases.is_empty() {
println!("No databases to show.");
return Ok(());
}
if args.json {
println!("{}", serde_json::to_string_pretty(&databases)?);
} else {
for db in databases {
println!("{}", db);
}
}
Ok(())
}
async fn show_databases(args: DatabaseShowPermArgs, mut conn: MySqlConnection) -> anyhow::Result<()> {
let database_users_to_show = if args.name.is_empty() {
core::database_operations::get_all_database_privileges(&mut conn).await?
} else {
// TODO: This can be optimized by fetching all the database privileges in one query.
let mut result = Vec::with_capacity(args.name.len());
for name in args.name {
match core::database_operations::get_database_privileges(&name, &mut conn).await {
Ok(db) => result.extend(db),
Err(e) => {
eprintln!("Failed to show database '{}': {}", name, e);
eprintln!("Skipping...");
}
}
}
result
};
if database_users_to_show.is_empty() {
println!("No database users to show.");
return Ok(());
}
if args.json {
println!("{}", serde_json::to_string_pretty(&database_users_to_show)?);
} else {
let mut table = Table::new();
table.add_row(Row::new(
core::database_operations::HUMAN_READABLE_DATABASE_PRIVILEGE_NAMES
.iter()
.map(|(name, _)| Cell::new(name))
.collect(),
));
for row in database_users_to_show {
table.add_row(row![
row.db,
row.user,
row.select_priv,
row.insert_priv,
row.update_priv,
row.delete_priv,
row.create_priv,
row.drop_priv,
row.alter_priv,
row.index_priv,
row.create_tmp_table_priv,
row.lock_tables_priv,
row.references_priv
]);
}
table.printstd();
}
Ok(())
}
/// See documentation for `DatabaseCommand::EditPerm`.
fn parse_permission_table_cli_arg(arg: &str) -> anyhow::Result<DatabasePrivileges> {
let parts: Vec<&str> = arg.split(':').collect();
if parts.len() != 3 {
anyhow::bail!("Invalid argument format. See `edit-perm --help` for more information.");
}
let db = parts[0].to_string();
let user = parts[1].to_string();
let privs = parts[2].to_string();
let mut result = DatabasePrivileges {
db,
user,
select_priv: "N".to_string(),
insert_priv: "N".to_string(),
update_priv: "N".to_string(),
delete_priv: "N".to_string(),
create_priv: "N".to_string(),
drop_priv: "N".to_string(),
alter_priv: "N".to_string(),
index_priv: "N".to_string(),
create_tmp_table_priv: "N".to_string(),
lock_tables_priv: "N".to_string(),
references_priv: "N".to_string(),
};
for char in privs.chars() {
match char {
's' => result.select_priv = "Y".to_string(),
'i' => result.insert_priv = "Y".to_string(),
'u' => result.update_priv = "Y".to_string(),
'd' => result.delete_priv = "Y".to_string(),
'c' => result.create_priv = "Y".to_string(),
'D' => result.drop_priv = "Y".to_string(),
'a' => result.alter_priv = "Y".to_string(),
'I' => result.index_priv = "Y".to_string(),
't' => result.create_tmp_table_priv = "Y".to_string(),
'l' => result.lock_tables_priv = "Y".to_string(),
'r' => result.references_priv = "Y".to_string(),
'A' => {
result.select_priv = "Y".to_string();
result.insert_priv = "Y".to_string();
result.update_priv = "Y".to_string();
result.delete_priv = "Y".to_string();
result.create_priv = "Y".to_string();
result.drop_priv = "Y".to_string();
result.alter_priv = "Y".to_string();
result.index_priv = "Y".to_string();
result.create_tmp_table_priv = "Y".to_string();
result.lock_tables_priv = "Y".to_string();
result.references_priv = "Y".to_string();
}
_ => anyhow::bail!("Invalid permission character: {}", char),
}
}
Ok(result)
}
async fn edit_permissions(args: DatabaseEditPermArgs, mut conn: MySqlConnection) -> anyhow::Result<()> {
let _data = if let Some(name) = &args.name {
core::database_operations::get_database_privileges(name, &mut conn).await?
} else {
core::database_operations::get_all_database_privileges(&mut conn).await?
};
if !args.text {
let permissions_to_change: Vec<DatabasePrivileges> = if let Some(name) = args.name {
args.perm
.iter()
.map(|perm| {
parse_permission_table_cli_arg(&format!("{}:{}", name, &perm))
.context(format!("Failed parsing database permissions: `{}`", &perm))
})
.collect::<anyhow::Result<Vec<DatabasePrivileges>>>()?
} else {
args.perm
.iter()
.map(|perm| {
parse_permission_table_cli_arg(perm)
.context(format!("Failed parsing database permissions: `{}`", &perm))
})
.collect::<anyhow::Result<Vec<DatabasePrivileges>>>()?
};
println!("{:#?}", permissions_to_change);
} else {
// TODO: debug assert that -p is not used with -t
}
// TODO: find the difference between the two vectors, and ask for confirmation before applying the changes.
// TODO: apply the changes to the database.
unimplemented!();
}

180
src/cli/user_command.rs Normal file
View File

@ -0,0 +1,180 @@
use std::vec;
use anyhow::Context;
use clap::Parser;
use sqlx::MySqlConnection;
use crate::core::user_operations::validate_ownership_of_user_name;
#[derive(Parser)]
pub struct UserArgs {
#[clap(subcommand)]
subcmd: UserCommand,
}
#[derive(Parser)]
enum UserCommand {
/// Create the USER(s).
#[command(alias = "add", alias = "c")]
Create(UserCreateArgs),
/// Delete the USER(s).
#[command(alias = "remove", alias = "delete", alias = "rm", alias = "d")]
Drop(UserDeleteArgs),
/// Change the MySQL password for the USER.
#[command(alias = "password", alias = "p")]
Passwd(UserPasswdArgs),
/// Give information about the USER(s), or if no USER is given, all USERs you have access to.
#[command(alias = "list", alias = "ls", alias = "s")]
Show(UserShowArgs),
}
#[derive(Parser)]
struct UserCreateArgs {
#[arg(num_args = 1..)]
username: Vec<String>,
}
#[derive(Parser)]
struct UserDeleteArgs {
#[arg(num_args = 1..)]
username: Vec<String>,
}
#[derive(Parser)]
struct UserPasswdArgs {
username: String,
#[clap(short, long)]
password_file: Option<String>,
}
#[derive(Parser)]
struct UserShowArgs {
#[arg(num_args = 0..)]
username: Vec<String>,
}
pub async fn handle_command(args: UserArgs, conn: MySqlConnection) -> anyhow::Result<()> {
match args.subcmd {
UserCommand::Create(args) => create_users(args, conn).await,
UserCommand::Drop(args) => drop_users(args, conn).await,
UserCommand::Passwd(args) => change_password_for_user(args, conn).await,
UserCommand::Show(args) => show_users(args, conn).await,
}
}
// TODO: provide a better error message when the user already exists
async fn create_users(args: UserCreateArgs, mut conn: MySqlConnection) -> anyhow::Result<()> {
if args.username.is_empty() {
anyhow::bail!("No usernames provided");
}
for username in args.username {
if let Err(e) =
crate::core::user_operations::create_database_user(&username, &mut conn).await
{
eprintln!("{}", e);
eprintln!("Skipping...");
}
}
Ok(())
}
// TODO: provide a better error message when the user does not exist
async fn drop_users(args: UserDeleteArgs, mut conn: MySqlConnection) -> anyhow::Result<()> {
if args.username.is_empty() {
anyhow::bail!("No usernames provided");
}
for username in args.username {
if let Err(e) =
crate::core::user_operations::delete_database_user(&username, &mut conn).await
{
eprintln!("{}", e);
eprintln!("Skipping...");
}
}
Ok(())
}
async fn change_password_for_user(
args: UserPasswdArgs,
mut conn: MySqlConnection,
) -> anyhow::Result<()> {
// 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.
let unix_user = crate::core::common::get_current_unix_user()?;
validate_ownership_of_user_name(&args.username, &unix_user)?;
let password = if let Some(password_file) = args.password_file {
std::fs::read_to_string(password_file)
.context("Failed to read password file")?
.trim()
.to_string()
} else {
let pass1 = rpassword::prompt_password("Enter new password: ")
.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,
&mut conn,
)
.await?;
Ok(())
}
async fn show_users(args: UserShowArgs, mut conn: MySqlConnection) -> anyhow::Result<()> {
let user = crate::core::common::get_current_unix_user()?;
let users = if args.username.is_empty() {
crate::core::user_operations::get_all_database_users_for_user(&user, &mut conn).await?
} else {
let mut result = vec![];
for username in args.username {
if let Err(e) = validate_ownership_of_user_name(&username, &user) {
eprintln!("{}", e);
eprintln!("Skipping...");
continue;
}
let user =
crate::core::user_operations::get_database_user_for_user(&username, &mut conn)
.await?;
if let Some(user) = user {
result.push(user);
} else {
eprintln!("User not found: {}", username);
}
}
result
};
for user in users {
println!(
"User '{}': {}",
&user.user,
if !(user.authentication_string.is_empty() && user.password.is_empty()) {
"password set."
} else {
"no password set."
}
);
}
Ok(())
}

4
src/core.rs Normal file
View File

@ -0,0 +1,4 @@
pub mod common;
pub mod config;
pub mod database_operations;
pub mod user_operations;

91
src/core/common.rs Normal file
View File

@ -0,0 +1,91 @@
use anyhow::Context;
use indoc::indoc;
use itertools::Itertools;
use nix::unistd::{getuid, Group, User};
use std::ffi::CString;
pub fn get_current_unix_user() -> anyhow::Result<User> {
User::from_uid(getuid())
.context("Failed to look up your UNIX username")
.and_then(|u| u.ok_or(anyhow::anyhow!("Failed to look up your UNIX username")))
}
pub fn get_unix_groups(user: &User) -> anyhow::Result<Vec<Group>> {
let user_cstr =
CString::new(user.name.as_bytes()).context("Failed to convert username to CStr")?;
let groups = nix::unistd::getgrouplist(&user_cstr, user.gid)?
.iter()
.filter_map(|gid| {
match Group::from_gid(*gid).map_err(|e| {
log::trace!(
"Failed to look up group with GID {}: {}\nIgnoring...",
gid,
e
);
e
}) {
Ok(Some(group)) => Some(group),
_ => None,
}
})
.collect::<Vec<Group>>();
Ok(groups)
}
pub fn validate_prefix_for_user<'a>(name: &'a str, user: &User) -> anyhow::Result<&'a str> {
let user_groups = get_unix_groups(user)?;
let mut split_name = name.split('_');
let prefix = split_name
.next()
.ok_or(anyhow::anyhow!(indoc! {r#"
Failed to find prefix.
"#},))
.and_then(|prefix| {
if user.name == prefix || user_groups.iter().any(|g| g.name == prefix) {
Ok(prefix)
} else {
anyhow::bail!(
indoc! {r#"
Invalid prefix: '{}' does not match your username or any of your groups.
Are you sure you are allowed to create databases or users with this prefix?
Allowed prefixes:
- {}
{}
"#},
prefix,
user.name,
user_groups
.iter()
.filter(|g| g.name != user.name)
.map(|g| format!(" - {}", g.name))
.sorted()
.join("\n"),
);
}
})?;
if !split_name.next().is_some_and(|s| !s.is_empty()) {
anyhow::bail!(
indoc! {r#"
Missing the rest of the name after the user/group prefix.
The name should be in the format: '{}_<name>'
"#},
prefix
);
}
Ok(prefix)
}
pub fn quote_literal(s: &str) -> String {
format!("'{}'", s.replace('\'', r"\'"))
}
pub fn quote_identifier(s: &str) -> String {
format!("`{}`", s.replace('`', r"\`"))
}

122
src/core/config.rs Normal file
View File

@ -0,0 +1,122 @@
use std::{fs, path::PathBuf};
use anyhow::Context;
use clap::Parser;
use serde::{Deserialize, Serialize};
use sqlx::{mysql::MySqlConnectOptions, ConnectOptions, MySqlConnection};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
pub mysql: MysqlConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename = "mysql")]
pub struct MysqlConfig {
pub host: String,
pub port: Option<u16>,
pub username: String,
pub password: String,
}
#[derive(Parser)]
pub struct ConfigOverrideArgs {
#[arg(
long,
value_name = "PATH",
global = true,
help_heading = Some("Configuration overrides"),
hide_short_help = true,
alias = "config",
alias = "conf",
)]
config_file: Option<String>,
#[arg(
long,
value_name = "HOST",
global = true,
help_heading = Some("Configuration overrides"),
hide_short_help = true,
)]
mysql_host: Option<String>,
#[arg(
long,
value_name = "PORT",
global = true,
help_heading = Some("Configuration overrides"),
hide_short_help = true,
)]
mysql_port: Option<u16>,
#[arg(
long,
value_name = "USER",
global = true,
help_heading = Some("Configuration overrides"),
hide_short_help = true,
)]
mysql_user: Option<String>,
#[arg(
long,
value_name = "PATH",
global = true,
help_heading = Some("Configuration overrides"),
hide_short_help = true,
)]
mysql_password_file: Option<String>,
}
pub fn get_config(args: ConfigOverrideArgs) -> anyhow::Result<Config> {
let config_path = args
.config_file
.unwrap_or("/etc/mysqladm/config.toml".to_string());
let config_path = PathBuf::from(config_path);
let config: Config = fs::read_to_string(&config_path)
.context(format!(
"Failed to read config file from {:?}",
&config_path
))
.and_then(|c| toml::from_str(&c).context("Failed to parse config file"))
.context(format!(
"Failed to parse config file from {:?}",
&config_path
))?;
let mysql = &config.mysql;
let password = if let Some(path) = args.mysql_password_file {
fs::read_to_string(path)
.context("Failed to read MySQL password file")
.map(|s| s.trim().to_owned())?
} else {
mysql.password.to_owned()
};
let mysql_config = MysqlConfig {
host: args.mysql_host.unwrap_or(mysql.host.to_owned()),
port: args.mysql_port.or(mysql.port),
username: args.mysql_user.unwrap_or(mysql.username.to_owned()),
password,
};
Ok(Config {
mysql: mysql_config,
})
}
/// TODO: Add timeout.
pub async fn mysql_connection_from_config(config: Config) -> anyhow::Result<MySqlConnection> {
MySqlConnectOptions::new()
.host(&config.mysql.host)
.username(&config.mysql.username)
.password(&config.mysql.password)
.port(config.mysql.port.unwrap_or(3306))
.database("mysql")
.connect()
.await
.context("Failed to connect to MySQL")
}

View File

@ -0,0 +1,201 @@
use anyhow::Context;
use indoc::indoc;
use itertools::Itertools;
use nix::unistd::User;
use serde::{Deserialize, Serialize};
use sqlx::{prelude::*, MySqlConnection};
use super::common::{
get_current_unix_user, get_unix_groups, quote_identifier, validate_prefix_for_user,
};
pub async fn create_database(name: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> {
let user = get_current_unix_user()?;
validate_ownership_of_database_name(name, &user)?;
// NOTE: see the note about SQL injections in `validate_owner_of_database_name`
sqlx::query(&format!("CREATE DATABASE {}", quote_identifier(name)))
.execute(conn)
.await
.map_err(|e| {
if e.to_string().contains("database exists") {
anyhow::anyhow!("Database '{}' already exists", name)
} else {
e.into()
}
})?;
Ok(())
}
pub async fn drop_database(name: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> {
let user = get_current_unix_user()?;
validate_ownership_of_database_name(name, &user)?;
// NOTE: see the note about SQL injections in `validate_owner_of_database_name`
sqlx::query(&format!("DROP DATABASE {}", quote_identifier(name)))
.execute(conn)
.await
.map_err(|e| {
if e.to_string().contains("doesn't exist") {
anyhow::anyhow!("Database '{}' does not exist", name)
} else {
e.into()
}
})?;
Ok(())
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
struct DatabaseName {
database: String,
}
pub async fn get_database_list(conn: &mut MySqlConnection) -> anyhow::Result<Vec<String>> {
let unix_user = get_current_unix_user()?;
let unix_groups = get_unix_groups(&unix_user)?
.into_iter()
.map(|g| g.name)
.collect::<Vec<_>>();
let databases = sqlx::query_as::<_, DatabaseName>(
r#"
SELECT `SCHEMA_NAME` AS `database`
FROM `information_schema`.`SCHEMATA`
WHERE `SCHEMA_NAME` NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')
AND `SCHEMA_NAME` REGEXP ?
"#,
)
.bind(format!(
"({}|{})_.+",
unix_user.name,
unix_groups.iter().map(|g| g.to_string()).join("|")
))
.fetch_all(conn)
.await
.context(format!(
"Failed to get databases for user '{}'",
unix_user.name
))?;
Ok(databases.into_iter().map(|d| d.database).collect())
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct DatabasePrivileges {
pub db: String,
pub user: String,
pub select_priv: String,
pub insert_priv: String,
pub update_priv: String,
pub delete_priv: String,
pub create_priv: String,
pub drop_priv: String,
pub alter_priv: String,
pub index_priv: String,
pub create_tmp_table_priv: String,
pub lock_tables_priv: String,
pub references_priv: String,
}
pub const HUMAN_READABLE_DATABASE_PRIVILEGE_NAMES: [(&str, &str); 13] = [
("Database", "db"),
("User", "user"),
("Select", "select_priv"),
("Insert", "insert_priv"),
("Update", "update_priv"),
("Delete", "delete_priv"),
("Create", "create_priv"),
("Drop", "drop_priv"),
("Alter", "alter_priv"),
("Index", "index_priv"),
("Temp", "create_tmp_table_priv"),
("Lock", "lock_tables_priv"),
("References", "references_priv"),
];
pub async fn get_database_privileges(
database_name: &str,
conn: &mut MySqlConnection,
) -> anyhow::Result<Vec<DatabasePrivileges>> {
let unix_user = get_current_unix_user()?;
validate_ownership_of_database_name(database_name, &unix_user)?;
let result = sqlx::query_as::<_, DatabasePrivileges>(&format!(
"SELECT {} FROM `db` WHERE `db` = ?",
HUMAN_READABLE_DATABASE_PRIVILEGE_NAMES
.iter()
.map(|(_, prop)| quote_identifier(prop))
.join(","),
))
.bind(database_name)
.fetch_all(conn)
.await
.context("Failed to show database")?;
Ok(result)
}
pub async fn get_all_database_privileges(
conn: &mut MySqlConnection,
) -> anyhow::Result<Vec<DatabasePrivileges>> {
let unix_user = get_current_unix_user()?;
let unix_groups = get_unix_groups(&unix_user)?
.into_iter()
.map(|g| g.name)
.collect::<Vec<_>>();
let result = sqlx::query_as::<_, DatabasePrivileges>(&format!(
indoc! {r#"
SELECT {} FROM `db` WHERE `db` IN
(SELECT DISTINCT `SCHEMA_NAME` AS `database`
FROM `information_schema`.`SCHEMATA`
WHERE `SCHEMA_NAME` NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')
AND `SCHEMA_NAME` REGEXP ?)
"#},
HUMAN_READABLE_DATABASE_PRIVILEGE_NAMES
.iter()
.map(|(_, prop)| format!("`{}`", prop))
.join(","),
))
.bind(format!(
"({}|{})_.+",
unix_user.name,
unix_groups.iter().map(|g| g.to_string()).join("|")
))
.fetch_all(conn)
.await
.context("Failed to show databases")?;
Ok(result)
}
/// NOTE: It is very critical that this function validates the database name
/// 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
/// to validate the database name ourselves to prevent SQL injection.
pub fn validate_ownership_of_database_name(name: &str, user: &User) -> anyhow::Result<()> {
if name.contains(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-') {
anyhow::bail!(
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(())
}

151
src/core/user_operations.rs Normal file
View File

@ -0,0 +1,151 @@
use anyhow::Context;
use indoc::indoc;
use nix::unistd::User;
use serde::{Deserialize, Serialize};
use sqlx::{prelude::*, MySqlConnection};
use crate::core::common::quote_literal;
use super::common::{get_current_unix_user, get_unix_groups, validate_prefix_for_user};
pub async fn create_database_user(db_user: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> {
let unix_user = get_current_unix_user()?;
validate_ownership_of_user_name(db_user, &unix_user)?;
// NOTE: see the note about SQL injections in `validate_ownershipt_of_user_name`
sqlx::query(format!("CREATE USER {}@'%'", quote_literal(db_user),).as_str())
.execute(conn)
.await?;
Ok(())
}
pub async fn delete_database_user(db_user: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> {
let unix_user = get_current_unix_user()?;
validate_ownership_of_user_name(db_user, &unix_user)?;
// NOTE: see the note about SQL injections in `validate_ownershipt_of_user_name`
sqlx::query(format!("DROP USER {}@'%'", quote_literal(db_user),).as_str())
.execute(conn)
.await?;
Ok(())
}
pub async fn set_password_for_database_user(
db_user: &str,
password: &str,
conn: &mut MySqlConnection,
) -> anyhow::Result<()> {
let unix_user = crate::core::common::get_current_unix_user()?;
validate_ownership_of_user_name(db_user, &unix_user)?;
// NOTE: see the note about SQL injections in `validate_ownershipt_of_user_name`
sqlx::query(
format!(
"ALTER USER {}@'%' IDENTIFIED BY {}",
quote_literal(db_user),
quote_literal(password).as_str()
)
.as_str(),
)
.execute(conn)
.await?;
Ok(())
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct DatabaseUser {
#[sqlx(rename = "User")]
pub user: String,
#[sqlx(rename = "Host")]
pub host: String,
#[sqlx(rename = "Password")]
pub password: String,
pub authentication_string: String,
}
pub async fn get_all_database_users_for_user(
user: &User,
conn: &mut MySqlConnection,
) -> anyhow::Result<Vec<DatabaseUser>> {
let groups = get_unix_groups(user)?;
let regex = format!(
"({}|{})(_.+)?",
user.name,
groups
.iter()
.map(|g| g.name.as_str())
.collect::<Vec<_>>()
.join("|")
);
let users = sqlx::query_as::<_, DatabaseUser>(
r#"
SELECT `User`, `Host`, `Password`, `authentication_string`
FROM `mysql`.`user`
WHERE `User` REGEXP ?
"#,
)
.bind(regex)
.fetch_all(conn)
.await?;
Ok(users)
}
pub async fn get_database_user_for_user(
username: &str,
conn: &mut MySqlConnection,
) -> anyhow::Result<Option<DatabaseUser>> {
let user = sqlx::query_as::<_, DatabaseUser>(
r#"
SELECT `User`, `Host`, `Password`, `authentication_string`
FROM `mysql`.`user`
WHERE `User` = ?
"#,
)
.bind(username)
.fetch_optional(conn)
.await?;
Ok(user)
}
/// NOTE: It is very critical that this function validates the database name
/// 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
/// to validate the database name ourselves to prevent SQL injection.
pub fn validate_ownership_of_user_name(name: &str, user: &User) -> anyhow::Result<()> {
if name.contains(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-') {
anyhow::bail!(
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(())
}

View File

@ -1,33 +0,0 @@
use clap::Parser;
#[derive(Parser)]
pub struct DatabaseArgs {
#[clap(subcommand)]
subcmd: DatabaseCommand,
}
#[derive(Parser)]
enum DatabaseCommand {
/// Create the DATABASE(S).
Create,
/// Delete the DATABASE(S).
Drop,
/// Give information about the DATABASE(S), or, if none are given, all the ones you own.
Show,
/// 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,
}
pub fn handle_command(args: DatabaseArgs) {
match args.subcmd {
DatabaseCommand::Create => println!("Creating database"),
DatabaseCommand::Drop => println!("Dropping database"),
DatabaseCommand::Show => println!("Showing database"),
DatabaseCommand::EditPerm => println!("Editing permissions"),
}
}

View File

@ -1,61 +1,48 @@
#[macro_use]
extern crate prettytable;
use clap::Parser; use clap::Parser;
mod database_command; mod cli;
mod user_command; mod core;
#[derive(Parser)] #[derive(Parser)]
struct Args { struct Args {
#[clap(subcommand)] #[command(subcommand)]
command: Command, command: Command,
#[command(flatten)]
config_overrides: core::config::ConfigOverrideArgs,
} }
/// Database administration tool designed for non-admin users to manage their own MySQL databases and users.
/// Use `--help` for advanced usage.
///
/// This tool allows you to manage users and databases in MySQL that are prefixed with your username.
#[derive(Parser)] #[derive(Parser)]
#[command(version, about, disable_help_subcommand = true)]
enum Command { enum Command {
/// Create, drop or edit permission for the DATABASE(s), /// Create, drop or show/edit permissions for DATABASE(s),
#[clap(name = "db")] #[command(alias = "database")]
Database(database_command::DatabaseArgs), Db(cli::database_command::DatabaseArgs),
/// Create, delete or change password for your USER, // Database(cli::database_command::DatabaseArgs),
#[clap(name = "user")] /// Create, drop, change password for, or show your USER(s),
User(user_command::UserArgs), #[command(name = "user")]
User(cli::user_command::UserArgs),
} }
fn main() { #[tokio::main]
async fn main() -> anyhow::Result<()> {
env_logger::init();
let args: Args = Args::parse(); let args: Args = Args::parse();
let config = core::config::get_config(args.config_overrides)?;
let connection = core::config::mysql_connection_from_config(config).await?;
match args.command { match args.command {
Command::Database(database_args) => database_command::handle_command(database_args), Command::Db(database_args) => {
Command::User(user_args) => user_command::handle_command(user_args), cli::database_command::handle_command(database_args, connection).await
}
Command::User(user_args) => cli::user_command::handle_command(user_args, connection).await,
} }
} }
// loginstud03% mysql-dbadm --help
// Usage: mysql-dbadm COMMAND [DATABASE]...
// Create, drop og edit permission for the DATABASE(s),
// as determined by the COMMAND. Valid COMMANDs:
// create create the DATABASE(s).
// drop delete the DATABASE(s).
// show give information about the DATABASE(s), or, if
// none are given, all the ones you own.
// editperm 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.
// Report bugs to orakel@ntnu.no
// loginstud03% mysql-useradm --help
// Usage: mysql-useradm COMMAND [USER]...
// Create, delete or change password for the USER(s),
// as determined by the COMMAND. Valid COMMANDs:
// create create the USER(s).
// delete delete the USER(s).
// passwd change the MySQL password for the USER(s).
// show give information about the USERS(s), or, if
// none are given, all the users you have.
// Report bugs to orakel@ntnu.no

View File

@ -1,24 +0,0 @@
use clap::Parser;
#[derive(Parser)]
pub struct UserArgs {
#[clap(subcommand)]
subcmd: UserCommand,
}
#[derive(Parser)]
enum UserCommand {
Create,
Delete,
Passwd,
Show,
}
pub fn handle_command(args: UserArgs) {
match args.subcmd {
UserCommand::Create => println!("Creating user"),
UserCommand::Delete => println!("Deleting user"),
UserCommand::Passwd => println!("Changing password"),
UserCommand::Show => println!("Showing user"),
}
}