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:
2024-04-21 06:03:25 +02:00
parent 77f7085d2b
commit ccf1b78ce8
14 changed files with 2173 additions and 915 deletions

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(())
}