This commit is contained in:
Oystein Kristoffer Tveit 2024-08-10 15:15:56 +02:00
parent 2c8c16e5bf
commit a04f6ae628
Signed by: oysteikt
GPG Key ID: 9F2F7D8250F35146
35 changed files with 2955 additions and 2926 deletions

29
Cargo.lock generated
View File

@ -418,6 +418,27 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "derive_more"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"unicode-xid",
]
[[package]] [[package]]
name = "dialoguer" name = "dialoguer"
version = "0.11.0" version = "0.11.0"
@ -993,6 +1014,7 @@ dependencies = [
"async-bincode", "async-bincode",
"bincode", "bincode",
"clap", "clap",
"derive_more",
"dialoguer", "dialoguer",
"env_logger", "env_logger",
"futures", "futures",
@ -1007,7 +1029,6 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
"thiserror",
"tokio", "tokio",
"tokio-serde", "tokio-serde",
"tokio-stream", "tokio-stream",
@ -2130,6 +2151,12 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85"
[[package]]
name = "unicode-xid"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c"
[[package]] [[package]]
name = "unicode_categories" name = "unicode_categories"
version = "0.1.1" version = "0.1.1"

View File

@ -8,6 +8,7 @@ anyhow = "1.0.82"
async-bincode = "0.7.2" async-bincode = "0.7.2"
bincode = "1.3.3" bincode = "1.3.3"
clap = { version = "4.5.4", features = ["derive"] } clap = { version = "4.5.4", features = ["derive"] }
derive_more = { version = "1.0.0", features = ["display", "error"] }
dialoguer = "0.11.0" dialoguer = "0.11.0"
env_logger = "0.11.3" env_logger = "0.11.3"
futures = "0.3.30" futures = "0.3.30"
@ -15,14 +16,13 @@ futures-util = "0.3.30"
indoc = "2.0.5" indoc = "2.0.5"
itertools = "0.12.1" itertools = "0.12.1"
log = "0.4.21" log = "0.4.21"
nix = { version = "0.28.0", features = ["fs", "user"] } nix = { version = "0.28.0", features = ["fs", "process", "user"] }
prettytable = "0.10.0" prettytable = "0.10.0"
rand = "0.8.5" rand = "0.8.5"
ratatui = { version = "0.26.2", optional = true } ratatui = { version = "0.26.2", optional = true }
serde = "1.0.198" serde = "1.0.198"
serde_json = { version = "1.0.116", features = ["preserve_order"] } serde_json = { version = "1.0.116", features = ["preserve_order"] }
sqlx = { version = "0.7.4", features = ["runtime-tokio", "mysql", "tls-rustls"] } sqlx = { version = "0.7.4", features = ["runtime-tokio", "mysql", "tls-rustls"] }
thiserror = "1.0.63"
tokio = { version = "1.37.0", features = ["rt", "macros"] } tokio = { version = "1.37.0", features = ["rt", "macros"] }
tokio-serde = { version = "0.9.0", features = ["bincode"] } tokio-serde = { version = "0.9.0", features = ["bincode"] }
tokio-stream = "0.1.15" tokio-stream = "0.1.15"

View File

@ -1,3 +1,6 @@
mod common;
pub mod database_command; pub mod database_command;
pub mod mysql_admutils_compatibility;
pub mod user_command; pub mod user_command;
#[cfg(feature = "mysql-admutils-compatibility")]
pub mod mysql_admutils_compatibility;

20
src/cli/common.rs Normal file
View File

@ -0,0 +1,20 @@
use crate::core::protocol::Response;
pub fn erroneous_server_response(
response: Option<Result<Response, std::io::Error>>,
) -> anyhow::Result<()> {
match response {
Some(Ok(Response::Error(e))) => {
anyhow::bail!("Server returned error: {}", e);
}
Some(Err(e)) => {
anyhow::bail!(e);
}
Some(response) => {
anyhow::bail!("Unexpected response from server: {:?}", response);
}
None => {
anyhow::bail!("No response from server");
}
}
}

View File

@ -1,17 +1,29 @@
use anyhow::Context; use anyhow::Context;
use clap::Parser; use clap::Parser;
use dialoguer::{Confirm, Editor}; use dialoguer::{Confirm, Editor};
use futures_util::{SinkExt, StreamExt};
use nix::unistd::{getuid, User};
use prettytable::{Cell, Row, Table}; use prettytable::{Cell, Row, Table};
use sqlx::{Connection, MySqlConnection};
use crate::core::{ use crate::{
common::{close_database_connection, get_current_unix_user, yn, CommandStatus}, cli::common::erroneous_server_response,
database_operations::*, core::{
database_privilege_operations::*, common::yn,
user_operations::user_exists, database_privileges::{
db_priv_field_human_readable_name, diff_privileges, display_privilege_diffs,
generate_editor_content_from_privilege_data, parse_privilege_data_from_editor_content,
parse_privilege_table_cli_arg,
},
protocol::{
print_create_databases_output_status, print_drop_databases_output_status,
print_modify_database_privileges_output_status, ClientToServerMessageStream, Request,
Response,
},
},
server::sql::database_privilege_operations::{DatabasePrivilegeRow, DATABASE_PRIVILEGE_FIELDS},
}; };
#[derive(Parser)] #[derive(Parser, Debug, Clone)]
// #[command(next_help_heading = Some(DATABASE_COMMAND_HEADER))] // #[command(next_help_heading = Some(DATABASE_COMMAND_HEADER))]
pub enum DatabaseCommand { pub enum DatabaseCommand {
/// Create one or more databases /// Create one or more databases
@ -86,28 +98,28 @@ pub enum DatabaseCommand {
EditDbPrivs(DatabaseEditPrivsArgs), EditDbPrivs(DatabaseEditPrivsArgs),
} }
#[derive(Parser)] #[derive(Parser, Debug, Clone)]
pub struct DatabaseCreateArgs { pub struct DatabaseCreateArgs {
/// The name of the database(s) to create. /// The name of the database(s) to create.
#[arg(num_args = 1..)] #[arg(num_args = 1..)]
name: Vec<String>, name: Vec<String>,
} }
#[derive(Parser)] #[derive(Parser, Debug, Clone)]
pub struct DatabaseDropArgs { pub struct DatabaseDropArgs {
/// The name of the database(s) to drop. /// The name of the database(s) to drop.
#[arg(num_args = 1..)] #[arg(num_args = 1..)]
name: Vec<String>, name: Vec<String>,
} }
#[derive(Parser)] #[derive(Parser, Debug, Clone)]
pub struct DatabaseListArgs { pub struct DatabaseListArgs {
/// Whether to output the information in JSON format. /// Whether to output the information in JSON format.
#[arg(short, long)] #[arg(short, long)]
json: bool, json: bool,
} }
#[derive(Parser)] #[derive(Parser, Debug, Clone)]
pub struct DatabaseShowPrivsArgs { pub struct DatabaseShowPrivsArgs {
/// The name of the database(s) to show. /// The name of the database(s) to show.
#[arg(num_args = 0..)] #[arg(num_args = 0..)]
@ -118,7 +130,7 @@ pub struct DatabaseShowPrivsArgs {
json: bool, json: bool,
} }
#[derive(Parser)] #[derive(Parser, Debug, Clone)]
pub struct DatabaseEditPrivsArgs { pub struct DatabaseEditPrivsArgs {
/// The name of the database to edit privileges for. /// The name of the database to edit privileges for.
pub name: Option<String>, pub name: Option<String>,
@ -141,125 +153,143 @@ pub struct DatabaseEditPrivsArgs {
pub async fn handle_command( pub async fn handle_command(
command: DatabaseCommand, command: DatabaseCommand,
mut connection: MySqlConnection, server_connection: ClientToServerMessageStream,
) -> anyhow::Result<CommandStatus> { ) -> anyhow::Result<()> {
let result = connection match command {
.transaction(|txn| { DatabaseCommand::CreateDb(args) => create_databases(args, server_connection).await,
Box::pin(async move { DatabaseCommand::DropDb(args) => drop_databases(args, server_connection).await,
match command { DatabaseCommand::ListDb(args) => list_databases(args, server_connection).await,
DatabaseCommand::CreateDb(args) => create_databases(args, txn).await, DatabaseCommand::ShowDbPrivs(args) => {
DatabaseCommand::DropDb(args) => drop_databases(args, txn).await, show_database_privileges(args, server_connection).await
DatabaseCommand::ListDb(args) => list_databases(args, txn).await, }
DatabaseCommand::ShowDbPrivs(args) => show_database_privileges(args, txn).await, DatabaseCommand::EditDbPrivs(args) => {
DatabaseCommand::EditDbPrivs(args) => edit_privileges(args, txn).await, edit_database_privileges(args, server_connection).await
} }
}) }
})
.await;
close_database_connection(connection).await;
result
} }
async fn create_databases( async fn create_databases(
args: DatabaseCreateArgs, args: DatabaseCreateArgs,
connection: &mut MySqlConnection, mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<CommandStatus> { ) -> anyhow::Result<()> {
if args.name.is_empty() { if args.name.is_empty() {
anyhow::bail!("No database names provided"); anyhow::bail!("No database names provided");
} }
let mut result = CommandStatus::SuccessfullyModified; let message = Request::CreateDatabases(args.name.clone());
server_connection.send(message).await?;
for name in args.name { let result = match server_connection.next().await {
// TODO: This can be optimized by fetching all the database privileges in one query. Some(Ok(Response::CreateDatabases(result))) => result,
if let Err(e) = create_database(&name, connection).await { response => return erroneous_server_response(response),
eprintln!("Failed to create database '{}': {}", name, e); };
eprintln!("Skipping...");
result = CommandStatus::PartiallySuccessfullyModified;
} else {
println!("Database '{}' created.", name);
}
}
Ok(result) server_connection.send(Request::Exit).await?;
print_create_databases_output_status(&result);
Ok(())
} }
async fn drop_databases( async fn drop_databases(
args: DatabaseDropArgs, args: DatabaseDropArgs,
connection: &mut MySqlConnection, mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<CommandStatus> { ) -> anyhow::Result<()> {
if args.name.is_empty() { if args.name.is_empty() {
anyhow::bail!("No database names provided"); anyhow::bail!("No database names provided");
} }
let mut result = CommandStatus::SuccessfullyModified; let message = Request::DropDatabases(args.name.clone());
server_connection.send(message).await?;
for name in args.name { let result = match server_connection.next().await {
// TODO: This can be optimized by fetching all the database privileges in one query. Some(Ok(Response::DropDatabases(result))) => result,
if let Err(e) = drop_database(&name, connection).await { response => return erroneous_server_response(response),
eprintln!("Failed to drop database '{}': {}", name, e); };
eprintln!("Skipping...");
result = CommandStatus::PartiallySuccessfullyModified;
} else {
println!("Database '{}' dropped.", name);
}
}
Ok(result) server_connection.send(Request::Exit).await?;
print_drop_databases_output_status(&result);
Ok(())
} }
async fn list_databases( async fn list_databases(
args: DatabaseListArgs, args: DatabaseListArgs,
connection: &mut MySqlConnection, mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<CommandStatus> { ) -> anyhow::Result<()> {
let databases = get_database_list(connection).await?; let message = Request::ListDatabases;
server_connection.send(message).await?;
let result = match server_connection.next().await {
Some(Ok(Response::ListAllDatabases(result))) => result,
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
let database_list = match result {
Ok(list) => list,
Err(err) => {
return Err(anyhow::anyhow!(err.to_error_message()).context("Failed to list databases"))
}
};
if args.json { if args.json {
println!("{}", serde_json::to_string_pretty(&databases)?); println!("{}", serde_json::to_string_pretty(&database_list)?);
return Ok(CommandStatus::NoModificationsIntended); } else if database_list.is_empty() {
}
if databases.is_empty() {
println!("No databases to show."); println!("No databases to show.");
} else { } else {
for db in databases { for db in database_list {
println!("{}", db); println!("{}", db);
} }
} }
Ok(CommandStatus::NoModificationsIntended) Ok(())
} }
async fn show_database_privileges( async fn show_database_privileges(
args: DatabaseShowPrivsArgs, args: DatabaseShowPrivsArgs,
connection: &mut MySqlConnection, mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<CommandStatus> { ) -> anyhow::Result<()> {
let database_users_to_show = if args.name.is_empty() { let message = if args.name.is_empty() {
get_all_database_privileges(connection).await? Request::ListPrivileges(None)
} else { } else {
// TODO: This can be optimized by fetching all the database privileges in one query. Request::ListPrivileges(Some(args.name.clone()))
let mut result = Vec::with_capacity(args.name.len()); };
for name in args.name { server_connection.send(message).await?;
match get_database_privileges(&name, connection).await {
Ok(db) => result.extend(db), let privilege_data = match server_connection.next().await {
Err(e) => { Some(Ok(Response::ListPrivileges(databases))) => databases
eprintln!("Failed to show database '{}': {}", name, e); .into_iter()
.filter_map(|(database_name, result)| match result {
Ok(privileges) => Some(privileges),
Err(err) => {
eprintln!("{}", err.to_error_message(&database_name));
eprintln!("Skipping..."); eprintln!("Skipping...");
println!();
None
} }
})
.flatten()
.collect::<Vec<_>>(),
Some(Ok(Response::ListAllPrivileges(privilege_rows))) => match privilege_rows {
Ok(list) => list,
Err(err) => {
server_connection.send(Request::Exit).await?;
return Err(anyhow::anyhow!(err.to_error_message())
.context("Failed to list database privileges"));
} }
} },
result response => return erroneous_server_response(response),
}; };
if args.json { server_connection.send(Request::Exit).await?;
println!("{}", serde_json::to_string_pretty(&database_users_to_show)?);
return Ok(CommandStatus::NoModificationsIntended);
}
if database_users_to_show.is_empty() { if args.json {
println!("No database users to show."); println!("{}", serde_json::to_string_pretty(&privilege_data)?);
} else if privilege_data.is_empty() {
println!("No database privileges to show.");
} else { } else {
let mut table = Table::new(); let mut table = Table::new();
table.add_row(Row::new( table.add_row(Row::new(
@ -270,7 +300,7 @@ async fn show_database_privileges(
.collect(), .collect(),
)); ));
for row in database_users_to_show { for row in privilege_data {
table.add_row(row![ table.add_row(row![
row.db, row.db,
row.user, row.user,
@ -290,17 +320,40 @@ async fn show_database_privileges(
table.printstd(); table.printstd();
} }
Ok(CommandStatus::NoModificationsIntended) Ok(())
} }
pub async fn edit_privileges( pub async fn edit_database_privileges(
args: DatabaseEditPrivsArgs, args: DatabaseEditPrivsArgs,
connection: &mut MySqlConnection, mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<CommandStatus> { ) -> anyhow::Result<()> {
let privilege_data = if let Some(name) = &args.name { let message = Request::ListPrivileges(args.name.clone().map(|name| vec![name]));
get_database_privileges(name, connection).await?
} else { server_connection.send(message).await?;
get_all_database_privileges(connection).await?
let privilege_data = match server_connection.next().await {
Some(Ok(Response::ListPrivileges(databases))) => databases
.into_iter()
.filter_map(|(database_name, result)| match result {
Ok(privileges) => Some(privileges),
Err(err) => {
eprintln!("{}", err.to_error_message(&database_name));
eprintln!("Skipping...");
println!();
None
}
})
.flatten()
.collect::<Vec<_>>(),
Some(Ok(Response::ListAllPrivileges(privilege_rows))) => match privilege_rows {
Ok(list) => list,
Err(err) => {
server_connection.send(Request::Exit).await?;
return Err(anyhow::anyhow!(err.to_error_message())
.context("Failed to list database privileges"));
}
},
response => return erroneous_server_response(response),
}; };
// TODO: The data from args should not be absolute. // TODO: The data from args should not be absolute.
@ -316,22 +369,16 @@ pub async fn edit_privileges(
edit_privileges_with_editor(&privilege_data)? edit_privileges_with_editor(&privilege_data)?
}; };
for row in privileges_to_change.iter() {
if !user_exists(&row.user, connection).await? {
// TODO: allow user to return and correct their mistake
anyhow::bail!("User {} does not exist", row.user);
}
}
let diffs = diff_privileges(&privilege_data, &privileges_to_change); let diffs = diff_privileges(&privilege_data, &privileges_to_change);
if diffs.is_empty() { if diffs.is_empty() {
println!("No changes to make."); println!("No changes to make.");
return Ok(CommandStatus::NoModificationsNeeded); return Ok(());
} }
println!("The following changes will be made:\n"); println!("The following changes will be made:\n");
println!("{}", display_privilege_diffs(&diffs)); println!("{}", display_privilege_diffs(&diffs));
if !args.yes if !args.yes
&& !Confirm::new() && !Confirm::new()
.with_prompt("Do you want to apply these changes?") .with_prompt("Do you want to apply these changes?")
@ -339,15 +386,27 @@ pub async fn edit_privileges(
.show_default(true) .show_default(true)
.interact()? .interact()?
{ {
return Ok(CommandStatus::Cancelled); server_connection.send(Request::Exit).await?;
return Ok(());
} }
apply_privilege_diffs(diffs, connection).await?; let message = Request::ModifyPrivileges(diffs);
server_connection.send(message).await?;
Ok(CommandStatus::SuccessfullyModified) let result = match server_connection.next().await {
Some(Ok(Response::ModifyPrivileges(result))) => result,
response => return erroneous_server_response(response),
};
// TODO: allow user to return and correct their mistake
print_modify_database_privileges_output_status(&result);
server_connection.send(Request::Exit).await?;
Ok(())
} }
pub fn parse_privilege_tables_from_args( fn parse_privilege_tables_from_args(
args: &DatabaseEditPrivsArgs, args: &DatabaseEditPrivsArgs,
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> { ) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
debug_assert!(!args.privs.is_empty()); debug_assert!(!args.privs.is_empty());
@ -371,10 +430,12 @@ pub fn parse_privilege_tables_from_args(
Ok(result) Ok(result)
} }
pub fn edit_privileges_with_editor( fn edit_privileges_with_editor(
privilege_data: &[DatabasePrivilegeRow], privilege_data: &[DatabasePrivilegeRow],
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> { ) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
let unix_user = get_current_unix_user()?; let unix_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")))?;
let editor_content = let editor_content =
generate_editor_content_from_privilege_data(privilege_data, &unix_user.name); generate_editor_content_from_privilege_data(privilege_data, &unix_user.name);

View File

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

View File

@ -1,57 +1,4 @@
use crate::core::common::{ #[inline]
get_current_unix_user, validate_name_or_error, validate_ownership_or_error, DbOrUser, pub fn trim_to_32_chars(name: &str) -> String {
}; name.chars().take(32).collect()
/// 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_or_error(name, &unix_user, db_or_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_or_error(name, db_or_user) {
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,155 @@
use crate::core::protocol::{
CreateDatabaseError, CreateUserError, DbOrUser, DropDatabaseError, DropUserError,
ListUsersError,
};
pub fn name_validation_error_to_error_message(name: &str, db_or_user: DbOrUser) -> String {
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(),
});
format!(
concat!(
"{}: {} name '{}' contains invalid characters.\n",
"Only A-Z, a-z, 0-9, _ (underscore) and - (dash) permitted. Skipping.",
),
argv0,
db_or_user.capitalized(),
name,
)
}
pub fn owner_validation_error_message(name: &str, db_or_user: DbOrUser) -> String {
format!(
"You are not in charge of mysql-{}: '{}'. Skipping.",
db_or_user.lowercased(),
name
)
}
pub fn handle_create_user_error(error: CreateUserError, name: &str) {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-useradm".to_string());
match error {
CreateUserError::SanitizationError(_) => {
eprintln!(
"{}",
name_validation_error_to_error_message(name, DbOrUser::User)
);
}
CreateUserError::OwnershipError(_) => {
eprintln!("{}", owner_validation_error_message(name, DbOrUser::User));
}
CreateUserError::MySqlError(err) => {
eprintln!("{}: Failed to create user '{}': {}", argv0, name, err);
}
CreateUserError::UserAlreadyExists => {
eprintln!("{}: User '{}' already exists. Skipping.", argv0, name);
}
}
}
pub fn handle_drop_user_error(error: DropUserError, name: &str) {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-useradm".to_string());
match error {
DropUserError::SanitizationError(_) => {
eprintln!(
"{}",
name_validation_error_to_error_message(name, DbOrUser::User)
);
}
DropUserError::OwnershipError(_) => {
eprintln!("{}", owner_validation_error_message(name, DbOrUser::User));
}
DropUserError::MySqlError(err) => {
eprintln!("{}: Failed to delete user '{}': {}", argv0, name, err);
}
DropUserError::UserDoesNotExist => {
eprintln!("{}: User '{}' does not exist. Skipping.", argv0, name);
}
}
}
pub fn handle_list_users_error(error: ListUsersError, name: &str) {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-useradm".to_string());
match error {
ListUsersError::SanitizationError(_) => {
eprintln!(
"{}",
name_validation_error_to_error_message(name, DbOrUser::User)
);
}
ListUsersError::OwnershipError(_) => {
eprintln!("{}", owner_validation_error_message(name, DbOrUser::User));
}
ListUsersError::UserDoesNotExist => {
eprintln!(
"{}: User '{}' does not exist. You must create it first.",
argv0, name,
);
}
ListUsersError::MySqlError(_) => {
eprintln!("{}: Failed to look up password for user '{}'", argv0, name);
}
}
}
// ----------------------------------------------------------------------------
pub fn handle_create_database_error(error: CreateDatabaseError, name: &str) {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-dbadm".to_string());
match error {
CreateDatabaseError::SanitizationError(_) => {
eprintln!(
"{}",
name_validation_error_to_error_message(name, DbOrUser::Database)
);
}
CreateDatabaseError::OwnershipError(_) => {
eprintln!(
"{}",
owner_validation_error_message(name, DbOrUser::Database)
);
}
CreateDatabaseError::MySqlError(err) => {
eprintln!("{}: Failed to create database '{}': {}", argv0, name, err);
}
CreateDatabaseError::DatabaseAlreadyExists => {
eprintln!("{}: Database '{}' already exists. Skipping.", argv0, name);
}
}
}
pub fn handle_drop_database_error(error: DropDatabaseError, name: &str) {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-dbadm".to_string());
match error {
DropDatabaseError::SanitizationError(_) => {
eprintln!(
"{}",
name_validation_error_to_error_message(name, DbOrUser::Database)
);
}
DropDatabaseError::OwnershipError(_) => {
eprintln!(
"{}",
owner_validation_error_message(name, DbOrUser::Database)
);
}
DropDatabaseError::MySqlError(err) => {
eprintln!("{}: Failed to delete database '{}': {}", argv0, name, err);
}
DropDatabaseError::DatabaseDoesNotExist => {
eprintln!("{}: Database '{}' does not exist. Skipping.", argv0, name);
}
}
}

View File

@ -1,14 +1,25 @@
use clap::Parser; use clap::Parser;
use sqlx::MySqlConnection; use futures_util::{SinkExt, StreamExt};
use std::os::unix::net::UnixStream as StdUnixStream;
use std::path::PathBuf;
use tokio::net::UnixStream as TokioUnixStream;
use crate::{ use crate::{
cli::{database_command, mysql_admutils_compatibility::common::filter_db_or_user_names}, cli::{
core::{ common::erroneous_server_response,
common::{yn, DbOrUser}, database_command,
config::{create_mysql_connection_from_config, get_config, GlobalConfigArgs}, mysql_admutils_compatibility::{
database_operations::{create_database, drop_database, get_database_list}, common::trim_to_32_chars,
database_privilege_operations, error_messages::{handle_create_database_error, handle_drop_database_error},
},
}, },
core::{
bootstrap::bootstrap_server_connection_and_drop_privileges,
protocol::{
create_client_to_server_message_stream, ClientToServerMessageStream, Request, Response,
},
},
server::sql::database_privilege_operations::DatabasePrivilegeRow,
}; };
const HELP_DB_PERM: &str = r#" const HELP_DB_PERM: &str = r#"
@ -39,8 +50,25 @@ pub struct Args {
#[command(subcommand)] #[command(subcommand)]
pub command: Option<Command>, pub command: Option<Command>,
#[command(flatten)] /// Path to the socket of the server, if it already exists.
config_overrides: GlobalConfigArgs, #[arg(
short,
long,
value_name = "PATH",
global = true,
hide_short_help = true
)]
server_socket_path: Option<PathBuf>,
/// Config file to use for the server.
#[arg(
short,
long,
value_name = "PATH",
global = true,
hide_short_help = true
)]
config: Option<PathBuf>,
/// Print help for the 'editperm' subcommand. /// Print help for the 'editperm' subcommand.
#[arg(long, global = true)] #[arg(long, global = true)]
@ -106,7 +134,7 @@ pub struct EditPermArgs {
pub database: String, pub database: String,
} }
pub async fn main() -> anyhow::Result<()> { pub fn main() -> anyhow::Result<()> {
let args: Args = Args::parse(); let args: Args = Args::parse();
if args.help_editperm { if args.help_editperm {
@ -114,6 +142,9 @@ pub async fn main() -> anyhow::Result<()> {
return Ok(()); return Ok(());
} }
let server_connection =
bootstrap_server_connection_and_drop_privileges(args.server_socket_path, args.config)?;
let command = match args.command { let command = match args.command {
Some(command) => command, Some(command) => command,
None => { None => {
@ -125,64 +156,162 @@ pub async fn main() -> anyhow::Result<()> {
} }
}; };
let config = get_config(args.config_overrides)?; tokio_run_command(command, server_connection)?;
let mut connection = create_mysql_connection_from_config(config.mysql).await?;
match command { Ok(())
Command::Create(args) => { }
let filtered_names = filter_db_or_user_names(args.name, DbOrUser::Database)?;
for name in filtered_names {
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 {
drop_database(&name, &mut connection).await?;
println!("Database {} dropped.", name);
}
}
Command::Show(args) => {
let names = if args.name.is_empty() {
get_database_list(&mut connection).await?
} else {
filter_db_or_user_names(args.name, DbOrUser::Database)?
};
for name in names { fn tokio_run_command(command: Command, server_connection: StdUnixStream) -> anyhow::Result<()> {
show_db(&name, &mut connection).await?; tokio::runtime::Builder::new_current_thread()
} .enable_all()
} .build()
Command::EditPerm(args) => { .unwrap()
// TODO: This does not accurately replicate the behavior of the old implementation. .block_on(async {
// Hopefully, not many people rely on this in an automated fashion, as it let tokio_socket = TokioUnixStream::from_std(server_connection)?;
// is made to be interactive in nature. However, we should still try to let message_stream = create_client_to_server_message_stream(tokio_socket);
// replicate the old behavior as closely as possible. match command {
let edit_privileges_args = database_command::DatabaseEditPrivsArgs { Command::Create(args) => create_databases(args, message_stream).await,
name: Some(args.database), Command::Drop(args) => drop_databases(args, message_stream).await,
privs: vec![], Command::Show(args) => show_databases(args, message_stream).await,
json: false, Command::EditPerm(args) => {
editor: None, let edit_privileges_args = database_command::DatabaseEditPrivsArgs {
yes: false, name: Some(args.database),
}; privs: vec![],
json: false,
// TODO: use this to mimic the old editor-finding logic
editor: None,
yes: false,
};
database_command::edit_privileges(edit_privileges_args, &mut connection).await?; database_command::edit_database_privileges(edit_privileges_args, message_stream)
.await
}
}
})
}
async fn create_databases(
args: CreateArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
let database_names = args
.name
.iter()
.map(|name| trim_to_32_chars(name))
.collect();
let message = Request::CreateDatabases(database_names);
server_connection.send(message).await?;
let result = match server_connection.next().await {
Some(Ok(Response::CreateDatabases(result))) => result,
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
for (name, result) in result {
match result {
Ok(()) => println!("Database {} created.", name),
Err(err) => handle_create_database_error(err, &name),
} }
} }
Ok(()) Ok(())
} }
async fn show_db(name: &str, connection: &mut MySqlConnection) -> anyhow::Result<()> { async fn drop_databases(
args: DatabaseDropArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
let database_names = args
.name
.iter()
.map(|name| trim_to_32_chars(name))
.collect();
let message = Request::DropDatabases(database_names);
server_connection.send(message).await?;
let result = match server_connection.next().await {
Some(Ok(Response::DropDatabases(result))) => result,
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
for (name, result) in result {
match result {
Ok(()) => println!("Database {} dropped.", name),
Err(err) => handle_drop_database_error(err, &name),
}
}
Ok(())
}
async fn show_databases(
args: DatabaseShowArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
let database_names: Vec<String> = args
.name
.iter()
.map(|name| trim_to_32_chars(name))
.collect();
let message = if database_names.is_empty() {
Request::ListPrivileges(None)
} else {
Request::ListPrivileges(Some(database_names))
};
server_connection.send(message).await?;
let response = server_connection.next().await;
server_connection.send(Request::Exit).await?;
let results: Vec<Result<(String, Vec<DatabasePrivilegeRow>), String>> = match response {
Some(Ok(Response::ListPrivileges(result))) => {
// TODO: All databases that does not exist should return ok empty vec
// instead of an error.
// result.into_iter().map(|(name, result)| {
// match result {
// Ok(rows) => Ok((name, rows)),
// Err(err) => Err(err),
// }
// }).collect()
todo!()
}
Some(Ok(Response::ListAllPrivileges(result))) => todo!(),
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
todo!();
Ok(())
}
#[inline]
fn yn(value: bool) -> &'static str {
if value {
"Y"
} else {
"N"
}
}
fn show_db(name: &str, rows: Vec<DatabasePrivilegeRow>) -> anyhow::Result<()> {
// NOTE: mysql-dbadm show has a quirk where valid database names // NOTE: mysql-dbadm show has a quirk where valid database names
// for non-existent databases will report with no users. // for non-existent databases will report with no users.
// This function should *not* check for db existence, only // This function should *not* check for db existence, only
// validate the names. // validate the names.
let privileges = database_privilege_operations::get_database_privileges(name, connection) // let privileges = database_privilege_operations::get_database_privileges(name, connection)
.await // .await
.unwrap_or(vec![]); // .unwrap_or(vec![]);
println!( println!(
concat!( concat!(
"Database '{}':\n", "Database '{}':\n",
@ -191,10 +320,10 @@ async fn show_db(name: &str, connection: &mut MySqlConnection) -> anyhow::Result
), ),
name, name,
); );
if privileges.is_empty() { if rows.is_empty() {
println!("# (no permissions currently granted to any users)"); println!("# (no permissions currently granted to any users)");
} else { } else {
for privilege in privileges { for privilege in rows {
println!( println!(
" {:<16} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {}", " {:<16} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {}",
privilege.user, privilege.user,

View File

@ -1,12 +1,26 @@
use clap::Parser; use clap::Parser;
use sqlx::MySqlConnection; use futures_util::{SinkExt, StreamExt};
use std::path::PathBuf;
use std::os::unix::net::UnixStream as StdUnixStream;
use tokio::net::UnixStream as TokioUnixStream;
use crate::{ use crate::{
cli::{mysql_admutils_compatibility::common::filter_db_or_user_names, user_command}, cli::{
common::erroneous_server_response,
mysql_admutils_compatibility::{
common::trim_to_32_chars,
error_messages::{
handle_create_user_error, handle_drop_user_error, handle_list_users_error,
},
},
user_command::read_password_from_stdin_with_double_check,
},
core::{ core::{
common::{close_database_connection, get_current_unix_user, DbOrUser}, bootstrap::bootstrap_server_connection_and_drop_privileges,
config::{create_mysql_connection_from_config, get_config, GlobalConfigArgs}, protocol::{
user_operations::*, create_client_to_server_message_stream, ClientToServerMessageStream, Request, Response,
},
}, },
}; };
@ -15,8 +29,25 @@ pub struct Args {
#[command(subcommand)] #[command(subcommand)]
pub command: Option<Command>, pub command: Option<Command>,
#[command(flatten)] /// Path to the socket of the server, if it already exists.
config_overrides: GlobalConfigArgs, #[arg(
short,
long,
value_name = "PATH",
global = true,
hide_short_help = true
)]
server_socket_path: Option<PathBuf>,
/// Config file to use for the server.
#[arg(
short,
long,
value_name = "PATH",
global = true,
hide_short_help = true
)]
config: Option<PathBuf>,
} }
/// Create, delete or change password for the USER(s), /// Create, delete or change password for the USER(s),
@ -69,7 +100,7 @@ pub struct ShowArgs {
name: Vec<String>, name: Vec<String>,
} }
pub async fn main() -> anyhow::Result<()> { pub fn main() -> anyhow::Result<()> {
let args: Args = Args::parse(); let args: Args = Args::parse();
let command = match args.command { let command = match args.command {
@ -85,78 +116,169 @@ pub async fn main() -> anyhow::Result<()> {
} }
}; };
let config = get_config(args.config_overrides)?; let server_connection =
let mut connection = create_mysql_connection_from_config(config.mysql).await?; bootstrap_server_connection_and_drop_privileges(args.server_socket_path, args.config)?;
match command { tokio_run_command(command, server_connection)?;
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, &mut connection).await?,
Command::Show(args) => show(args, &mut connection).await?,
}
close_database_connection(connection).await;
Ok(()) Ok(())
} }
async fn passwd(args: PasswdArgs, connection: &mut MySqlConnection) -> anyhow::Result<()> { fn tokio_run_command(command: Command, server_connection: StdUnixStream) -> anyhow::Result<()> {
let filtered_names = filter_db_or_user_names(args.name, DbOrUser::User)?; tokio::runtime::Builder::new_current_thread()
.enable_all()
// NOTE: this gets doubly checked during the call to `set_password_for_database_user`. .build()
// This is moving the check before asking the user for the password, .unwrap()
// to avoid having them figure out that the user does not exist after they .block_on(async {
// have entered the password twice. let tokio_socket = TokioUnixStream::from_std(server_connection)?;
let mut better_filtered_names = Vec::with_capacity(filtered_names.len()); let message_stream = create_client_to_server_message_stream(tokio_socket);
for name in filtered_names.into_iter() { match command {
if !user_exists(&name, connection).await? { Command::Create(args) => create_user(args, message_stream).await,
println!( Command::Delete(args) => drop_users(args, message_stream).await,
"{}: User '{}' does not exist. You must create it first.", Command::Passwd(args) => passwd_users(args, message_stream).await,
std::env::args() Command::Show(args) => show_users(args, message_stream).await,
.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, connection).await?;
println!("Password updated for user '{}'.", name);
}
Ok(())
} }
async fn show(args: ShowArgs, connection: &mut MySqlConnection) -> anyhow::Result<()> { async fn create_user(
let users = if args.name.is_empty() { args: CreateArgs,
let unix_user = get_current_unix_user()?; mut server_connection: ClientToServerMessageStream,
get_all_database_users_for_unix_user(&unix_user, connection).await? ) -> anyhow::Result<()> {
} else { let usernames = args
let filtered_usernames = filter_db_or_user_names(args.name, DbOrUser::User)?; .name
let mut result = Vec::with_capacity(filtered_usernames.len()); .iter()
for username in filtered_usernames.iter() { .map(|name| trim_to_32_chars(name))
// TODO: fetch all users in one query .collect();
if let Some(user) = get_database_user_for_user(username, connection).await? {
result.push(user) let message = Request::CreateUsers(usernames);
} server_connection.send(message).await?;
}
result let result = match server_connection.next().await {
Some(Ok(Response::CreateUsers(result))) => result,
response => return erroneous_server_response(response),
}; };
for (name, result) in result {
match result {
Ok(()) => println!("User '{}' created.", name),
Err(err) => handle_create_user_error(err, &name),
}
}
Ok(())
}
async fn drop_users(
args: DeleteArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
let usernames = args
.name
.iter()
.map(|name| trim_to_32_chars(name))
.collect();
let message = Request::DropUsers(usernames);
server_connection.send(message).await?;
let result = match server_connection.next().await {
Some(Ok(Response::DropUsers(result))) => result,
response => return erroneous_server_response(response),
};
for (name, result) in result {
match result {
Ok(()) => println!("User '{}' deleted.", name),
Err(err) => handle_drop_user_error(err, &name),
}
}
Ok(())
}
async fn passwd_users(
args: PasswdArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
let usernames = args
.name
.iter()
.map(|name| trim_to_32_chars(name))
.collect();
let message = Request::ListUsers(Some(usernames));
server_connection.send(message).await?;
let response = match server_connection.next().await {
Some(Ok(Response::ListUsers(result))) => result,
response => return erroneous_server_response(response),
};
let argv0 = std::env::args()
.next()
.unwrap_or("mysql-useradm".to_string());
let users = response
.into_iter()
.filter_map(|(name, result)| match result {
Ok(user) => Some(user),
Err(err) => {
handle_list_users_error(err, &name);
None
}
})
.collect::<Vec<_>>();
for user in users {
let password = read_password_from_stdin_with_double_check(&user.user)?;
let message = Request::PasswdUser(user.user.clone(), password);
server_connection.send(message).await?;
match server_connection.next().await {
Some(Ok(Response::PasswdUser(result))) => match result {
Ok(()) => println!("Password updated for user '{}'.", user.user),
// TODO: fix error printing
Err(err) => eprintln!(
"{}: Failed to update password for user '{}': {:?}",
argv0, user.user, err
),
},
response => return erroneous_server_response(response),
}
}
Ok(())
}
async fn show_users(
args: ShowArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
let usernames = args
.name
.iter()
.map(|name| trim_to_32_chars(name))
.collect();
let message = Request::ListUsers(Some(usernames));
server_connection.send(message).await?;
let response = match server_connection.next().await {
Some(Ok(Response::ListUsers(result))) => result,
response => return erroneous_server_response(response),
};
let users = response
.into_iter()
.filter_map(|(name, result)| match result {
Ok(user) => Some(user),
Err(err) => {
handle_list_users_error(err, &name);
None
}
})
.collect::<Vec<_>>();
for user in users { for user in users {
if user.has_password { if user.has_password {
println!("User '{}': password set.", user.user); println!("User '{}': password set.", user.user);

View File

@ -1,27 +1,24 @@
use std::collections::BTreeMap;
use std::vec;
use anyhow::Context; use anyhow::Context;
use clap::Parser; use clap::Parser;
use dialoguer::{Confirm, Password}; use dialoguer::{Confirm, Password};
use prettytable::Table; use futures_util::{SinkExt, StreamExt};
use serde_json::json;
use sqlx::{Connection, MySqlConnection};
use crate::core::{ use crate::core::protocol::{
common::{close_database_connection, get_current_unix_user, CommandStatus}, print_create_users_output_status, print_drop_users_output_status,
database_operations::*, print_lock_users_output_status, print_set_password_output_status,
user_operations::*, print_unlock_users_output_status, ClientToServerMessageStream, Request, Response,
}; };
#[derive(Parser)] use super::common::erroneous_server_response;
#[derive(Parser, Debug, Clone)]
pub struct UserArgs { pub struct UserArgs {
#[clap(subcommand)] #[clap(subcommand)]
subcmd: UserCommand, subcmd: UserCommand,
} }
#[allow(clippy::enum_variant_names)] #[allow(clippy::enum_variant_names)]
#[derive(Parser)] #[derive(Parser, Debug, Clone)]
pub enum UserCommand { pub enum UserCommand {
/// Create one or more users /// Create one or more users
#[command()] #[command()]
@ -50,7 +47,7 @@ pub enum UserCommand {
UnlockUser(UserUnlockArgs), UnlockUser(UserUnlockArgs),
} }
#[derive(Parser)] #[derive(Parser, Debug, Clone)]
pub struct UserCreateArgs { pub struct UserCreateArgs {
#[arg(num_args = 1..)] #[arg(num_args = 1..)]
username: Vec<String>, username: Vec<String>,
@ -60,13 +57,13 @@ pub struct UserCreateArgs {
no_password: bool, no_password: bool,
} }
#[derive(Parser)] #[derive(Parser, Debug, Clone)]
pub struct UserDeleteArgs { pub struct UserDeleteArgs {
#[arg(num_args = 1..)] #[arg(num_args = 1..)]
username: Vec<String>, username: Vec<String>,
} }
#[derive(Parser)] #[derive(Parser, Debug, Clone)]
pub struct UserPasswdArgs { pub struct UserPasswdArgs {
username: String, username: String,
@ -74,7 +71,7 @@ pub struct UserPasswdArgs {
password_file: Option<String>, password_file: Option<String>,
} }
#[derive(Parser)] #[derive(Parser, Debug, Clone)]
pub struct UserShowArgs { pub struct UserShowArgs {
#[arg(num_args = 0..)] #[arg(num_args = 0..)]
username: Vec<String>, username: Vec<String>,
@ -83,13 +80,13 @@ pub struct UserShowArgs {
json: bool, json: bool,
} }
#[derive(Parser)] #[derive(Parser, Debug, Clone)]
pub struct UserLockArgs { pub struct UserLockArgs {
#[arg(num_args = 1..)] #[arg(num_args = 1..)]
username: Vec<String>, username: Vec<String>,
} }
#[derive(Parser)] #[derive(Parser, Debug, Clone)]
pub struct UserUnlockArgs { pub struct UserUnlockArgs {
#[arg(num_args = 1..)] #[arg(num_args = 1..)]
username: Vec<String>, username: Vec<String>,
@ -97,48 +94,45 @@ pub struct UserUnlockArgs {
pub async fn handle_command( pub async fn handle_command(
command: UserCommand, command: UserCommand,
mut connection: MySqlConnection, server_connection: ClientToServerMessageStream,
) -> anyhow::Result<CommandStatus> { ) -> anyhow::Result<()> {
let result = connection match command {
.transaction(|txn| { UserCommand::CreateUser(args) => create_users(args, server_connection).await,
Box::pin(async move { UserCommand::DropUser(args) => drop_users(args, server_connection).await,
match command { UserCommand::PasswdUser(args) => passwd_user(args, server_connection).await,
UserCommand::CreateUser(args) => create_users(args, txn).await, UserCommand::ShowUser(args) => show_users(args, server_connection).await,
UserCommand::DropUser(args) => drop_users(args, txn).await, UserCommand::LockUser(args) => lock_users(args, server_connection).await,
UserCommand::PasswdUser(args) => change_password_for_user(args, txn).await, UserCommand::UnlockUser(args) => unlock_users(args, server_connection).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,
}
})
})
.await;
close_database_connection(connection).await;
result
} }
async fn create_users( async fn create_users(
args: UserCreateArgs, args: UserCreateArgs,
connection: &mut MySqlConnection, mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<CommandStatus> { ) -> anyhow::Result<()> {
if args.username.is_empty() { if args.username.is_empty() {
anyhow::bail!("No usernames provided"); anyhow::bail!("No usernames provided");
} }
let mut result = CommandStatus::SuccessfullyModified; let message = Request::CreateUsers(args.username.clone());
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
anyhow::bail!(anyhow::Error::from(err).context("Failed to communicate with server"));
}
for username in args.username { let result = match server_connection.next().await {
if let Err(e) = create_database_user(&username, connection).await { Some(Ok(Response::CreateUsers(result))) => result,
eprintln!("{}", e); response => return erroneous_server_response(response),
eprintln!("Skipping...\n"); };
result = CommandStatus::PartiallySuccessfullyModified;
continue;
} else {
println!("User '{}' created.", username);
}
print_create_users_output_status(&result);
let successfully_created_users = result
.iter()
.filter_map(|(username, result)| result.as_ref().ok().map(|_| username))
.collect::<Vec<_>>();
for username in successfully_created_users {
if !args.no_password if !args.no_password
&& Confirm::new() && Confirm::new()
.with_prompt(format!( .with_prompt(format!(
@ -147,41 +141,55 @@ async fn create_users(
)) ))
.interact()? .interact()?
{ {
change_password_for_user( let password = read_password_from_stdin_with_double_check(username)?;
UserPasswdArgs { let message = Request::PasswdUser(username.clone(), password);
username,
password_file: None, if let Err(err) = server_connection.send(message).await {
}, server_connection.close().await.ok();
connection, anyhow::bail!(err);
) }
.await?;
match server_connection.next().await {
Some(Ok(Response::PasswdUser(result))) => {
print_set_password_output_status(&result, username)
}
response => return erroneous_server_response(response),
}
println!();
} }
println!();
} }
Ok(result)
server_connection.send(Request::Exit).await?;
Ok(())
} }
async fn drop_users( async fn drop_users(
args: UserDeleteArgs, args: UserDeleteArgs,
connection: &mut MySqlConnection, mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<CommandStatus> { ) -> anyhow::Result<()> {
if args.username.is_empty() { if args.username.is_empty() {
anyhow::bail!("No usernames provided"); anyhow::bail!("No usernames provided");
} }
let mut result = CommandStatus::SuccessfullyModified; let message = Request::DropUsers(args.username.clone());
for username in args.username { if let Err(err) = server_connection.send(message).await {
if let Err(e) = delete_database_user(&username, connection).await { server_connection.close().await.ok();
eprintln!("{}", e); anyhow::bail!(err);
eprintln!("Skipping...");
result = CommandStatus::PartiallySuccessfullyModified;
} else {
println!("User '{}' dropped.", username);
}
} }
Ok(result) let result = match server_connection.next().await {
Some(Ok(Response::DropUsers(result))) => result,
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
print_drop_users_output_status(&result);
Ok(())
} }
pub fn read_password_from_stdin_with_double_check(username: &str) -> anyhow::Result<String> { pub fn read_password_from_stdin_with_double_check(username: &str) -> anyhow::Result<String> {
@ -195,15 +203,10 @@ pub fn read_password_from_stdin_with_double_check(username: &str) -> anyhow::Res
.map_err(Into::into) .map_err(Into::into)
} }
async fn change_password_for_user( async fn passwd_user(
args: UserPasswdArgs, args: UserPasswdArgs,
connection: &mut MySqlConnection, mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<CommandStatus> { ) -> 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 = get_current_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)
.context("Failed to read password file")? .context("Failed to read password file")?
@ -213,129 +216,146 @@ async fn change_password_for_user(
read_password_from_stdin_with_double_check(&args.username)? read_password_from_stdin_with_double_check(&args.username)?
}; };
set_password_for_database_user(&args.username, &password, connection).await?; let message = Request::PasswdUser(args.username.clone(), password);
Ok(CommandStatus::SuccessfullyModified) if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
anyhow::bail!(err);
}
let result = match server_connection.next().await {
Some(Ok(Response::PasswdUser(result))) => result,
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
print_set_password_output_status(&result, &args.username);
Ok(())
} }
async fn show_users( async fn show_users(
args: UserShowArgs, args: UserShowArgs,
connection: &mut MySqlConnection, mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<CommandStatus> { ) -> anyhow::Result<()> {
let unix_user = get_current_unix_user()?; let message = if args.username.is_empty() {
Request::ListUsers(None)
let users = if args.username.is_empty() {
get_all_database_users_for_unix_user(&unix_user, connection).await?
} else { } else {
let mut result = vec![]; Request::ListUsers(Some(args.username.clone()))
for username in args.username {
if let Err(e) = validate_user_name(&username, &unix_user) {
eprintln!("{}", e);
eprintln!("Skipping...");
continue;
}
let user = get_database_user_for_user(&username, connection).await?;
if let Some(user) = user {
result.push(user);
} else {
eprintln!("User not found: {}", username);
}
}
result
}; };
let mut user_databases: BTreeMap<String, Vec<String>> = BTreeMap::new(); if let Err(err) = server_connection.send(message).await {
for user in users.iter() { server_connection.close().await.ok();
user_databases.insert( anyhow::bail!(err);
user.user.clone(),
get_databases_where_user_has_privileges(&user.user, connection).await?,
);
} }
if args.json { let users = match server_connection.next().await {
let users_json = users Some(Ok(Response::ListUsers(users))) => users
.into_iter() .into_iter()
.map(|user| { .filter_map(|(username, result)| match result {
json!({ Ok(user) => Some(user),
"user": user.user, Err(err) => {
"has_password": user.has_password, eprintln!("{}", err.to_error_message(&username));
"is_locked": user.is_locked, eprintln!("Skipping...");
"databases": user_databases.get(&user.user).unwrap_or(&vec![]), None
}) }
}) })
.collect::<serde_json::Value>(); .collect::<Vec<_>>(),
Some(Ok(Response::ListAllUsers(users))) => match users {
Ok(users) => users,
Err(err) => {
server_connection.send(Request::Exit).await?;
return Err(
anyhow::anyhow!(err.to_error_message()).context("Failed to list all users")
);
}
},
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
// TODO: print databases where user has privileges
if args.json {
println!( println!(
"{}", "{}",
serde_json::to_string_pretty(&users_json) serde_json::to_string_pretty(&users).context("Failed to serialize users to JSON")?
.context("Failed to serialize users to JSON")?
); );
} else if users.is_empty() { } else if users.is_empty() {
println!("No users found."); println!("No users to show.");
} else { } else {
let mut table = Table::new(); let mut table = prettytable::Table::new();
table.add_row(row![ table.add_row(row![
"User", "User",
"Password is set", "Password is set",
"Locked", "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.is_locked,
user_databases.get(&user.user).unwrap_or(&vec![]).join("\n") // user.databases.join("\n")
]); ]);
} }
table.printstd(); table.printstd();
} }
Ok(CommandStatus::NoModificationsIntended) Ok(())
} }
async fn lock_users( async fn lock_users(
args: UserLockArgs, args: UserLockArgs,
connection: &mut MySqlConnection, mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<CommandStatus> { ) -> anyhow::Result<()> {
if args.username.is_empty() { if args.username.is_empty() {
anyhow::bail!("No usernames provided"); anyhow::bail!("No usernames provided");
} }
let mut result = CommandStatus::SuccessfullyModified; let message = Request::LockUsers(args.username.clone());
for username in args.username { if let Err(err) = server_connection.send(message).await {
if let Err(e) = lock_database_user(&username, connection).await { server_connection.close().await.ok();
eprintln!("{}", e); anyhow::bail!(err);
eprintln!("Skipping...");
result = CommandStatus::PartiallySuccessfullyModified;
} else {
println!("User '{}' locked.", username);
}
} }
Ok(result) let result = match server_connection.next().await {
Some(Ok(Response::LockUsers(result))) => result,
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
print_lock_users_output_status(&result);
Ok(())
} }
async fn unlock_users( async fn unlock_users(
args: UserUnlockArgs, args: UserUnlockArgs,
connection: &mut MySqlConnection, mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<CommandStatus> { ) -> anyhow::Result<()> {
if args.username.is_empty() { if args.username.is_empty() {
anyhow::bail!("No usernames provided"); anyhow::bail!("No usernames provided");
} }
let mut result = CommandStatus::SuccessfullyModified; let message = Request::UnlockUsers(args.username.clone());
for username in args.username { if let Err(err) = server_connection.send(message).await {
if let Err(e) = unlock_database_user(&username, connection).await { server_connection.close().await.ok();
eprintln!("{}", e); anyhow::bail!(err);
eprintln!("Skipping...");
result = CommandStatus::PartiallySuccessfullyModified;
} else {
println!("User '{}' unlocked.", username);
}
} }
Ok(result) let result = match server_connection.next().await {
Some(Ok(Response::UnlockUsers(result))) => result,
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
print_unlock_users_output_status(&result);
Ok(())
} }

View File

@ -1,5 +1,4 @@
pub mod bootstrap;
pub mod common; pub mod common;
pub mod config; pub mod database_privileges;
pub mod database_operations; pub mod protocol;
pub mod database_privilege_operations;
pub mod user_operations;

177
src/core/bootstrap.rs Normal file
View File

@ -0,0 +1,177 @@
use std::{fs, path::PathBuf};
use anyhow::Context;
use nix::libc::{exit, EXIT_SUCCESS};
use std::os::unix::net::UnixStream as StdUnixStream;
use tokio::net::UnixStream as TokioUnixStream;
use crate::{
core::{
bootstrap::authenticated_unix_socket::client_authenticate,
common::{UnixUser, DEFAULT_CONFIG_PATH, DEFAULT_SOCKET_PATH},
},
server::{config::read_config_form_path, server_loop::handle_requests_for_single_session},
};
pub mod authenticated_unix_socket;
// TODO: this function is security critical, it should be integration tested
// in isolation.
/// Drop privileges to the real user and group of the process.
/// If the process is not running with elevated privileges, this function
/// is a no-op.
pub fn drop_privs() -> anyhow::Result<()> {
log::debug!("Dropping privileges");
let real_uid = nix::unistd::getuid();
let real_gid = nix::unistd::getgid();
nix::unistd::setuid(real_uid).context("Failed to drop privileges")?;
nix::unistd::setgid(real_gid).context("Failed to drop privileges")?;
debug_assert_eq!(nix::unistd::getuid(), real_uid);
debug_assert_eq!(nix::unistd::getgid(), real_gid);
log::debug!("Privileges dropped successfully");
Ok(())
}
/// This function is used to bootstrap the connection to the server.
/// This can happen in two ways:
/// 1. If a socket path is provided, or exists in the default location,
/// the function will connect to the socket and authenticate with the
/// server to ensure that the server knows the uid of the client.
/// 2. If a config path is provided, or exists in the default location,
/// and the config is readable, the function will assume it is either
/// setuid or setgid, and will fork a child process to run the server
/// with the provided config. The server will exit silently by itself
/// when it is done, and this function will only return for the client
/// with the socket for the server.
/// If neither of these options are available, the function will fail.
pub fn bootstrap_server_connection_and_drop_privileges(
server_socket_path: Option<PathBuf>,
config_path: Option<PathBuf>,
) -> anyhow::Result<StdUnixStream> {
if server_socket_path.is_some() && config_path.is_some() {
anyhow::bail!("Cannot provide both a socket path and a config path");
}
log::debug!("Starting the server connection bootstrap process");
let (socket, do_authenticate) = bootstrap_server_connection(server_socket_path, config_path)?;
drop_privs()?;
let result: anyhow::Result<StdUnixStream> = if do_authenticate {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
let mut socket = TokioUnixStream::from_std(socket)?;
client_authenticate(&mut socket, None).await?;
Ok(socket.into_std()?)
})
} else {
Ok(socket)
};
result
}
/// Inner function for [`bootstrap_server_connection_and_drop_privileges`].
/// See that function for more information.
fn bootstrap_server_connection(
socket_path: Option<PathBuf>,
config_path: Option<PathBuf>,
) -> anyhow::Result<(StdUnixStream, bool)> {
// TODO: ensure this is both readable and writable
if let Some(socket_path) = socket_path {
log::debug!("Connecting to socket at {:?}", socket_path);
return match StdUnixStream::connect(socket_path) {
Ok(socket) => Ok((socket, true)),
Err(e) => match e.kind() {
std::io::ErrorKind::NotFound => Err(anyhow::anyhow!("Socket not found")),
std::io::ErrorKind::PermissionDenied => Err(anyhow::anyhow!("Permission denied")),
_ => Err(anyhow::anyhow!("Failed to connect to socket: {}", e)),
},
};
}
if let Some(config_path) = config_path {
// ensure config exists and is readable
if fs::metadata(&config_path).is_err() {
return Err(anyhow::anyhow!("Config file not found or not readable"));
}
log::debug!("Starting server with config at {:?}", config_path);
return invoke_server_with_config(config_path).map(|socket| (socket, false));
}
if fs::metadata(DEFAULT_SOCKET_PATH).is_ok() {
return match StdUnixStream::connect(DEFAULT_SOCKET_PATH) {
Ok(socket) => Ok((socket, true)),
Err(e) => match e.kind() {
std::io::ErrorKind::NotFound => Err(anyhow::anyhow!("Socket not found")),
std::io::ErrorKind::PermissionDenied => Err(anyhow::anyhow!("Permission denied")),
_ => Err(anyhow::anyhow!("Failed to connect to socket: {}", e)),
},
};
}
let config_path = PathBuf::from(DEFAULT_CONFIG_PATH);
if fs::metadata(&config_path).is_ok() {
return invoke_server_with_config(config_path).map(|socket| (socket, false));
}
anyhow::bail!("No socket path or config path provided, and no default socket or config found");
}
// TODO: we should somehow ensure that the forked process is killed on completion,
// just in case the client does not behave properly.
/// Fork a child process to run the server with the provided config.
/// The server will exit silently by itself when it is done, and this function
/// will only return for the client with the socket for the server.
fn invoke_server_with_config(config_path: PathBuf) -> anyhow::Result<StdUnixStream> {
let (server_socket, client_socket) = StdUnixStream::pair()?;
let unix_user = UnixUser::from_uid(nix::unistd::getuid().as_raw())?;
match (unsafe { nix::unistd::fork() }).context("Failed to fork")? {
nix::unistd::ForkResult::Parent { child } => {
log::debug!("Forked child process with PID {}", child);
Ok(client_socket)
}
nix::unistd::ForkResult::Child => {
log::debug!("Running server in child process");
match run_forked_server(config_path, server_socket, unix_user) {
Err(e) => Err(e),
Ok(_) => unreachable!(),
}
}
}
}
/// Run the server in the forked child process.
/// This function will not return, but will exit the process with a success code.
fn run_forked_server(
config_path: PathBuf,
server_socket: StdUnixStream,
unix_user: UnixUser,
) -> anyhow::Result<()> {
let config = read_config_form_path(Some(config_path))?;
let result: anyhow::Result<()> = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
let socket = TokioUnixStream::from_std(server_socket)?;
handle_requests_for_single_session(socket, &unix_user, &config).await?;
Ok(())
});
result?;
unsafe {
exit(EXIT_SUCCESS);
}
}

View File

@ -30,10 +30,13 @@
//! Also note that it is essential that the client does not send any sensitive information //! Also note that it is essential that the client does not send any sensitive information
//! over it's authentication socket, since it is readable by any user on the system. //! over it's authentication socket, since it is readable by any user on the system.
// TODO: rewrite this so that it can be used with a normal std::os::unix::net::UnixStream
use std::os::unix::io::AsRawFd; use std::os::unix::io::AsRawFd;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use async_bincode::{tokio::AsyncBincodeStream, AsyncDestination}; use async_bincode::{tokio::AsyncBincodeStream, AsyncDestination};
use derive_more::derive::{Display, Error};
use futures::{SinkExt, StreamExt}; use futures::{SinkExt, StreamExt};
use nix::{sys::stat, unistd::Uid}; use nix::{sys::stat, unistd::Uid};
use rand::distributions::Alphanumeric; use rand::distributions::Alphanumeric;
@ -52,7 +55,7 @@ pub enum ClientRequest {
Cancel, Cancel,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Display, Error)]
pub enum ServerResponse { pub enum ServerResponse {
Authenticated, Authenticated,
ChallengeDidNotMatch, ChallengeDidNotMatch,
@ -61,7 +64,7 @@ pub enum ServerResponse {
// TODO: wrap more data into the errors // TODO: wrap more data into the errors
#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[derive(Debug, Display, PartialEq, Serialize, Deserialize, Clone, Error)]
pub enum ServerError { pub enum ServerError {
InvalidRequest, InvalidRequest,
UnableToReadPermissionsFromAuthSocket, UnableToReadPermissionsFromAuthSocket,
@ -72,7 +75,7 @@ pub enum ServerError {
InvalidChallenge, InvalidChallenge,
} }
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq, Display, Error)]
pub enum ClientError { pub enum ClientError {
UnableToConnectToServer, UnableToConnectToServer,
UnableToOpenAuthSocket, UnableToOpenAuthSocket,
@ -80,13 +83,12 @@ pub enum ClientError {
AuthSocketClosedEarly, AuthSocketClosedEarly,
UnableToCloseAuthSocket, UnableToCloseAuthSocket,
AuthenticationError, AuthenticationError,
InvalidServerResponse(ServerResponse),
UnableToParseServerResponse, UnableToParseServerResponse,
NoServerResponse, NoServerResponse,
ServerError(ServerError), ServerError(ServerError),
} }
async fn create_auth_socket(socket_addr: &str) -> Result<UnixListener, ClientError> { async fn create_auth_socket(socket_addr: &PathBuf) -> Result<UnixListener, ClientError> {
let auth_socket = let auth_socket =
UnixListener::bind(socket_addr).map_err(|_err| ClientError::UnableToOpenAuthSocket)?; UnixListener::bind(socket_addr).map_err(|_err| ClientError::UnableToOpenAuthSocket)?;
@ -109,11 +111,13 @@ type AuthStream<'a> = AsyncBincodeStream<&'a mut UnixStream, u64, u64, AsyncDest
// TODO: add timeout // TODO: add timeout
// TODO: respect $XDG_RUNTIME_DIR and $TMPDIR
const AUTH_SOCKET_NAME: &str = "mysqladm-rs-cli-auth.sock"; const AUTH_SOCKET_NAME: &str = "mysqladm-rs-cli-auth.sock";
pub async fn client_authenticate( pub async fn client_authenticate(
normal_socket: &mut UnixStream, normal_socket: &mut UnixStream,
#[cfg(not(test))] auth_socket_dir: Option<PathBuf>, auth_socket_dir: Option<PathBuf>,
#[cfg(test)] auth_socket_file: Option<PathBuf>,
) -> Result<(), ClientError> { ) -> Result<(), ClientError> {
let random_prefix: String = rand::thread_rng() let random_prefix: String = rand::thread_rng()
.sample_iter(&Alphanumeric) .sample_iter(&Alphanumeric)
@ -123,32 +127,16 @@ pub async fn client_authenticate(
let socket_name = format!("{}-{}", random_prefix, AUTH_SOCKET_NAME); let socket_name = format!("{}-{}", random_prefix, AUTH_SOCKET_NAME);
#[cfg(not(test))] let auth_socket_address = auth_socket_dir
let auth_socket_address = match auth_socket_dir { .unwrap_or(std::env::temp_dir())
Some(dir) => dir.join(socket_name).to_str().unwrap().to_string(), .join(socket_name);
None => std::env::temp_dir()
.join(socket_name)
.to_str()
.unwrap()
.to_string(),
};
#[cfg(test)]
let auth_socket_address = match auth_socket_file {
Some(file) => file.to_str().unwrap().to_string(),
None => std::env::temp_dir()
.join(socket_name)
.to_str()
.unwrap()
.to_string(),
};
client_authenticate_with_auth_socket_address(normal_socket, &auth_socket_address).await client_authenticate_with_auth_socket_address(normal_socket, &auth_socket_address).await
} }
async fn client_authenticate_with_auth_socket_address( async fn client_authenticate_with_auth_socket_address(
normal_socket: &mut UnixStream, normal_socket: &mut UnixStream,
auth_socket_address: &str, auth_socket_address: &PathBuf,
) -> Result<(), ClientError> { ) -> Result<(), ClientError> {
let auth_socket = create_auth_socket(auth_socket_address).await?; let auth_socket = create_auth_socket(auth_socket_address).await?;
@ -164,7 +152,7 @@ async fn client_authenticate_with_auth_socket_address(
async fn client_authenticate_with_auth_socket( async fn client_authenticate_with_auth_socket(
normal_socket: &mut UnixStream, normal_socket: &mut UnixStream,
auth_socket: UnixListener, auth_socket: UnixListener,
auth_socket_address: &str, auth_socket_address: &Path,
) -> Result<(), ClientError> { ) -> Result<(), ClientError> {
let challenge = rand::random::<u64>(); let challenge = rand::random::<u64>();
let uid = nix::unistd::getuid(); let uid = nix::unistd::getuid();
@ -199,7 +187,10 @@ async fn client_authenticate_with_auth_socket(
let client_hello = ClientRequest::Initialize { let client_hello = ClientRequest::Initialize {
uid: uid.into(), uid: uid.into(),
challenge, challenge,
auth_socket: auth_socket_address.to_string(), auth_socket: auth_socket_address
.to_str()
.ok_or(ClientError::UnableToConfigureAuthSocket)?
.to_owned(),
}; };
normal_socket normal_socket
@ -239,9 +230,13 @@ macro_rules! report_server_error_and_return {
}}; }};
} }
async fn server_authenticate( pub async fn server_authenticate(normal_socket: &mut UnixStream) -> Result<Uid, ServerError> {
_server_authenticate(normal_socket, None).await
}
pub async fn _server_authenticate(
normal_socket: &mut UnixStream, normal_socket: &mut UnixStream,
#[cfg(test)] unix_user_uid: Option<u32>, unix_user_uid: Option<u32>,
) -> Result<Uid, ServerError> { ) -> Result<Uid, ServerError> {
let mut normal_socket: ServerToClientStream = let mut normal_socket: ServerToClientStream =
AsyncBincodeStream::from(normal_socket).for_async(); AsyncBincodeStream::from(normal_socket).for_async();
@ -256,22 +251,15 @@ async fn server_authenticate(
_ => report_server_error_and_return!(normal_socket, ServerError::InvalidRequest), _ => report_server_error_and_return!(normal_socket, ServerError::InvalidRequest),
}; };
#[cfg(test)]
let auth_socket_uid = match unix_user_uid { let auth_socket_uid = match unix_user_uid {
Some(uid) => uid, Some(uid) => uid,
None => report_server_error_and_return!( None => match stat::stat(auth_socket.as_str()) {
normal_socket, Ok(stat) => stat.st_uid,
ServerError::UnableToReadPermissionsFromAuthSocket Err(_err) => report_server_error_and_return!(
), normal_socket,
}; ServerError::UnableToReadPermissionsFromAuthSocket
),
#[cfg(not(test))] },
let auth_socket_uid = match stat::stat(auth_socket.as_str()) {
Ok(stat) => stat.st_uid,
Err(_err) => report_server_error_and_return!(
normal_socket,
ServerError::UnableToReadPermissionsFromAuthSocket
),
}; };
if uid != auth_socket_uid { if uid != auth_socket_uid {
@ -324,10 +312,7 @@ mod test {
let client_handle = let client_handle =
tokio::spawn(async move { client_authenticate(&mut client, None).await }); tokio::spawn(async move { client_authenticate(&mut client, None).await });
let server_handle = tokio::spawn(async move { let server_handle = tokio::spawn(async move { server_authenticate(&mut server).await });
let uid = nix::unistd::getuid().into();
server_authenticate(&mut server, Some(uid)).await
});
client_handle.await.unwrap().unwrap(); client_handle.await.unwrap().unwrap();
server_handle.await.unwrap().unwrap(); server_handle.await.unwrap().unwrap();
@ -340,15 +325,12 @@ mod test {
let client_handle = tokio::spawn(async move { let client_handle = tokio::spawn(async move {
client_authenticate_with_auth_socket_address( client_authenticate_with_auth_socket_address(
&mut client, &mut client,
"/tmp/test_auth_socket_does_not_exist.sock", &PathBuf::from("/tmp/test_auth_socket_does_not_exist.sock"),
) )
.await .await
}); });
let server_handle = tokio::spawn(async move { let server_handle = tokio::spawn(async move { server_authenticate(&mut server).await });
let uid = nix::unistd::getuid().into();
server_authenticate(&mut server, Some(uid)).await
});
client_handle.await.unwrap().unwrap(); client_handle.await.unwrap().unwrap();
server_handle.await.unwrap().unwrap(); server_handle.await.unwrap().unwrap();
@ -365,7 +347,7 @@ mod test {
let server_handle = tokio::spawn(async move { let server_handle = tokio::spawn(async move {
let uid: u32 = nix::unistd::getuid().into(); let uid: u32 = nix::unistd::getuid().into();
let err = server_authenticate(&mut server, Some(uid + 1)).await; let err = _server_authenticate(&mut server, Some(uid + 1)).await;
assert_eq!(err, Err(ServerError::UidMismatch)); assert_eq!(err, Err(ServerError::UidMismatch));
}); });
@ -379,13 +361,19 @@ mod test {
let socket_path = std::env::temp_dir().join("socket_to_snoop.sock"); let socket_path = std::env::temp_dir().join("socket_to_snoop.sock");
let socket_path_clone = socket_path.clone(); let socket_path_clone = socket_path.clone();
let client_handle = let client_handle = tokio::spawn(async move {
tokio::spawn( client_authenticate_with_auth_socket_address(&mut client, &socket_path_clone).await
async move { client_authenticate(&mut client, Some(socket_path_clone)).await }, });
);
while !socket_path.exists() { for i in 0..100 {
sleep(std::time::Duration::from_millis(10)).await; if socket_path.exists() {
break;
}
sleep(Duration::from_millis(10)).await;
if i == 99 {
panic!("Socket not created after 1 second, assuming test failure");
}
} }
let mut snooper = UnixStream::connect(socket_path.clone()).await.unwrap(); let mut snooper = UnixStream::connect(socket_path.clone()).await.unwrap();
@ -409,10 +397,7 @@ mod test {
sleep(Duration::from_millis(10)).await; sleep(Duration::from_millis(10)).await;
let server_handle = tokio::spawn(async move { let server_handle = tokio::spawn(async move { server_authenticate(&mut server).await });
let uid: u32 = nix::unistd::getuid().into();
server_authenticate(&mut server, Some(uid)).await
});
client_handle.await.unwrap().unwrap(); client_handle.await.unwrap().unwrap();
server_handle.await.unwrap().unwrap(); server_handle.await.unwrap().unwrap();

View File

@ -1,56 +1,32 @@
use anyhow::Context; use anyhow::Context;
use indoc::indoc; use nix::unistd::{Group as LibcGroup, User as LibcUser};
use itertools::Itertools;
use nix::unistd::{getuid, Group, User};
use sqlx::{Connection, MySqlConnection};
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
use std::ffi::CString; use std::ffi::CString;
/// Report the result status of a command. pub const DEFAULT_CONFIG_PATH: &str = "/etc/mysqladm/config.toml";
/// This is used to display a status message to the user. pub const DEFAULT_SOCKET_PATH: &str = "/run/mysqladm/mysqladm.sock";
pub enum CommandStatus {
/// The command was successful,
/// and made modification to the database.
SuccessfullyModified,
/// The command was mostly successful, pub struct UnixUser {
/// and modifications have been made to the database. pub username: String,
/// However, some of the requested modifications failed. pub groups: Vec<String>,
PartiallySuccessfullyModified,
/// The command was successful,
/// but no modifications were needed.
NoModificationsNeeded,
/// The command was successful,
/// and made no modification to the database.
NoModificationsIntended,
/// The command was cancelled, either through a dialog or a signal.
/// No modifications have been made to the database.
Cancelled,
} }
pub fn get_current_unix_user() -> anyhow::Result<User> { // TODO: these functions are somewhat critical, and should have integration tests
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")))
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub fn get_unix_groups(_user: &User) -> anyhow::Result<Vec<Group>> { fn get_unix_groups(_user: &User) -> anyhow::Result<Vec<Group>> {
// Return an empty list on macOS since there is no `getgrouplist` function // Return an empty list on macOS since there is no `getgrouplist` function
Ok(vec![]) Ok(vec![])
} }
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
pub fn get_unix_groups(user: &User) -> anyhow::Result<Vec<Group>> { fn get_unix_groups(user: &LibcUser) -> anyhow::Result<Vec<LibcGroup>> {
let user_cstr = let user_cstr =
CString::new(user.name.as_bytes()).context("Failed to convert username to CStr")?; CString::new(user.name.as_bytes()).context("Failed to convert username to CStr")?;
let groups = nix::unistd::getgrouplist(&user_cstr, user.gid)? let groups = nix::unistd::getgrouplist(&user_cstr, user.gid)?
.iter() .iter()
.filter_map(|gid| match Group::from_gid(*gid) { .filter_map(|gid| match LibcGroup::from_gid(*gid) {
Ok(Some(group)) => Some(group), Ok(Some(group)) => Some(group),
Ok(None) => None, Ok(None) => None,
Err(e) => { Err(e) => {
@ -62,211 +38,32 @@ pub fn get_unix_groups(user: &User) -> anyhow::Result<Vec<Group>> {
None None
} }
}) })
.collect::<Vec<Group>>(); .collect::<Vec<LibcGroup>>();
Ok(groups) Ok(groups)
} }
/// This function creates a regex that matches items (users, databases) impl UnixUser {
/// that belong to the user or any of the user's groups. pub fn from_uid(uid: u32) -> anyhow::Result<Self> {
pub fn create_user_group_matching_regex(user: &User) -> String { let libc_uid = nix::unistd::Uid::from_raw(uid);
let groups = get_unix_groups(user).unwrap_or_default(); let libc_user = LibcUser::from_uid(libc_uid)
.context("Failed to look up your UNIX username")?
.ok_or(anyhow::anyhow!("Failed to look up your UNIX username"))?;
if groups.is_empty() { let groups = get_unix_groups(&libc_user)?;
format!("{}(_.+)?", user.name)
} else {
format!(
"({}|{})(_.+)?",
user.name,
groups
.iter()
.map(|g| g.name.as_str())
.collect::<Vec<_>>()
.join("|")
)
}
}
/// This enum is used to differentiate between database and user operations. Ok(UnixUser {
/// Their output are very similar, but there are slight differences in the words used. username: libc_user.name,
#[derive(Debug, PartialEq, Eq, Clone, Copy)] groups: groups.iter().map(|g| g.name.clone()).collect(),
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 { pub fn from_enviroment() -> anyhow::Result<Self> {
match self { let libc_uid = nix::unistd::getuid();
DbOrUser::Database => "Database".to_string(), UnixUser::from_uid(libc_uid.as_raw())
DbOrUser::User => "User".to_string(),
}
} }
} }
#[derive(Debug, PartialEq, Eq)]
pub enum NameValidationResult {
Valid,
EmptyString,
InvalidCharacters,
TooLong,
}
pub fn validate_name(name: &str) -> NameValidationResult {
if name.is_empty() {
NameValidationResult::EmptyString
} else if name.len() > 64 {
NameValidationResult::TooLong
} else if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
NameValidationResult::InvalidCharacters
} else {
NameValidationResult::Valid
}
}
pub fn validate_name_or_error(name: &str, db_or_user: DbOrUser) -> anyhow::Result<()> {
match validate_name(name) {
NameValidationResult::Valid => Ok(()),
NameValidationResult::EmptyString => {
anyhow::bail!("{} name cannot be empty.", db_or_user.capitalized())
}
NameValidationResult::TooLong => anyhow::bail!(
"{} is too long. Maximum length is 64 characters.",
db_or_user.capitalized()
),
NameValidationResult::InvalidCharacters => anyhow::bail!(
indoc! {r#"
Invalid characters in {} name: '{}'
Only A-Z, a-z, 0-9, _ (underscore) and - (dash) are permitted.
"#},
db_or_user.lowercased(),
name
),
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum OwnerValidationResult {
// The name is valid and matches one of the given prefixes
Match,
// The name is valid, but none of the given prefixes matched the name
NoMatch,
// The name is empty, which is invalid
StringEmpty,
// The name is in the format "_<postfix>", which is invalid
MissingPrefix,
// The name is in the format "<prefix>_", which is invalid
MissingPostfix,
}
/// Core logic for validating the ownership of a database name.
/// This function checks if the given name matches any of the given prefixes.
/// These prefixes will in most cases be the user's unix username and any
/// unix groups the user is a member of.
pub fn validate_ownership_by_prefixes(name: &str, prefixes: &[String]) -> OwnerValidationResult {
if name.is_empty() {
return OwnerValidationResult::StringEmpty;
}
if name.starts_with('_') {
return OwnerValidationResult::MissingPrefix;
}
let (prefix, _) = match name.split_once('_') {
Some(pair) => pair,
None => return OwnerValidationResult::MissingPostfix,
};
if prefixes.iter().any(|g| g == prefix) {
OwnerValidationResult::Match
} else {
OwnerValidationResult::NoMatch
}
}
/// Validate the ownership of a database name or database user name.
/// This function takes the name of a database or user and a unix user,
/// for which it fetches the user's groups. It then checks if the name
/// is prefixed with the user's username or any of the user's groups.
pub fn validate_ownership_or_error<'a>(
name: &'a str,
user: &User,
db_or_user: DbOrUser,
) -> anyhow::Result<&'a str> {
let user_groups = get_unix_groups(user)?;
let prefixes = std::iter::once(user.name.clone())
.chain(user_groups.iter().map(|g| g.name.clone()))
.collect::<Vec<String>>();
match validate_ownership_by_prefixes(name, &prefixes) {
OwnerValidationResult::Match => Ok(name),
OwnerValidationResult::NoMatch => {
anyhow::bail!(
indoc! {r#"
Invalid {} name prefix: '{}' does not match your username or any of your groups.
Are you sure you are allowed to create {} names with this prefix?
Allowed prefixes:
- {}
{}
"#},
db_or_user.lowercased(),
name,
db_or_user.lowercased(),
user.name,
user_groups
.iter()
.filter(|g| g.name != user.name)
.map(|g| format!(" - {}", g.name))
.sorted()
.join("\n"),
);
}
_ => anyhow::bail!(
"'{}' is not a valid {} name.",
name,
db_or_user.lowercased()
),
}
}
/// Gracefully close a MySQL connection.
pub async fn close_database_connection(connection: MySqlConnection) {
if let Err(e) = connection
.close()
.await
.context("Failed to close connection properly")
{
eprintln!("{}", e);
eprintln!("Ignoring...");
}
}
#[inline]
pub fn quote_literal(s: &str) -> String {
format!("'{}'", s.replace('\'', r"\'"))
}
#[inline]
pub fn quote_identifier(s: &str) -> String {
format!("`{}`", s.replace('`', r"\`"))
}
#[inline] #[inline]
pub(crate) fn yn(b: bool) -> &'static str { pub(crate) fn yn(b: bool) -> &'static str {
if b { if b {
@ -303,94 +100,4 @@ mod test {
assert_eq!(rev_yn("n"), Some(false)); assert_eq!(rev_yn("n"), Some(false));
assert_eq!(rev_yn("X"), None); assert_eq!(rev_yn("X"), None);
} }
#[test]
fn test_quote_literal() {
let payload = "' OR 1=1 --";
assert_eq!(quote_literal(payload), r#"'\' OR 1=1 --'"#);
}
#[test]
fn test_quote_identifier() {
let payload = "` OR 1=1 --";
assert_eq!(quote_identifier(payload), r#"`\` OR 1=1 --`"#);
}
#[test]
fn test_validate_name() {
assert_eq!(validate_name(""), NameValidationResult::EmptyString);
assert_eq!(
validate_name("abcdefghijklmnopqrstuvwxyz"),
NameValidationResult::Valid
);
assert_eq!(
validate_name("ABCDEFGHIJKLMNOPQRSTUVWXYZ"),
NameValidationResult::Valid
);
assert_eq!(validate_name("0123456789_-"), NameValidationResult::Valid);
for c in "\n\t\r !@#$%^&*()+=[]{}|;:,.<>?/".chars() {
assert_eq!(
validate_name(&c.to_string()),
NameValidationResult::InvalidCharacters
);
}
assert_eq!(validate_name(&"a".repeat(64)), NameValidationResult::Valid);
assert_eq!(
validate_name(&"a".repeat(65)),
NameValidationResult::TooLong
);
}
#[test]
fn test_validate_owner_by_prefixes() {
let prefixes = vec!["user".to_string(), "group".to_string()];
assert_eq!(
validate_ownership_by_prefixes("", &prefixes),
OwnerValidationResult::StringEmpty
);
assert_eq!(
validate_ownership_by_prefixes("user", &prefixes),
OwnerValidationResult::MissingPostfix
);
assert_eq!(
validate_ownership_by_prefixes("something", &prefixes),
OwnerValidationResult::MissingPostfix
);
assert_eq!(
validate_ownership_by_prefixes("user-testdb", &prefixes),
OwnerValidationResult::MissingPostfix
);
assert_eq!(
validate_ownership_by_prefixes("_testdb", &prefixes),
OwnerValidationResult::MissingPrefix
);
assert_eq!(
validate_ownership_by_prefixes("user_testdb", &prefixes),
OwnerValidationResult::Match
);
assert_eq!(
validate_ownership_by_prefixes("group_testdb", &prefixes),
OwnerValidationResult::Match
);
assert_eq!(
validate_ownership_by_prefixes("group_test_db", &prefixes),
OwnerValidationResult::Match
);
assert_eq!(
validate_ownership_by_prefixes("group_test-db", &prefixes),
OwnerValidationResult::Match
);
assert_eq!(
validate_ownership_by_prefixes("nonexistent_testdb", &prefixes),
OwnerValidationResult::NoMatch
);
}
} }

View File

@ -1,120 +0,0 @@
use anyhow::Context;
use indoc::formatdoc;
use itertools::Itertools;
use nix::unistd::User;
use sqlx::{prelude::*, MySqlConnection};
use crate::core::{
common::{
create_user_group_matching_regex, get_current_unix_user, quote_identifier,
validate_name_or_error, validate_ownership_or_error, DbOrUser,
},
database_privilege_operations::DATABASE_PRIVILEGE_FIELDS,
};
pub async fn create_database(name: &str, connection: &mut MySqlConnection) -> anyhow::Result<()> {
let user = get_current_unix_user()?;
validate_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(connection)
.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, connection: &mut MySqlConnection) -> anyhow::Result<()> {
let user = get_current_unix_user()?;
validate_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(connection)
.await
.map_err(|e| {
if e.to_string().contains("doesn't exist") {
anyhow::anyhow!("Database '{}' does not exist", name)
} else {
e.into()
}
})?;
Ok(())
}
pub async fn get_database_list(connection: &mut MySqlConnection) -> anyhow::Result<Vec<String>> {
let unix_user = get_current_unix_user()?;
let databases: Vec<String> = sqlx::query(
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(create_user_group_matching_regex(&unix_user))
.fetch_all(connection)
.await
.and_then(|row| {
row.into_iter()
.map(|row| row.try_get::<String, _>("database"))
.collect::<Result<_, _>>()
})
.context(format!(
"Failed to get databases for user '{}'",
unix_user.name
))?;
Ok(databases)
}
pub async fn get_databases_where_user_has_privileges(
username: &str,
connection: &mut MySqlConnection,
) -> anyhow::Result<Vec<String>> {
let result = sqlx::query(
formatdoc!(
r#"
SELECT `db` AS `database`
FROM `db`
WHERE `user` = ?
AND ({})
"#,
DATABASE_PRIVILEGE_FIELDS
.iter()
.map(|field| format!("`{}` = 'Y'", field))
.join(" OR "),
)
.as_str(),
)
.bind(username)
.fetch_all(connection)
.await?
.into_iter()
.map(|databases| databases.try_get::<String, _>("database").unwrap())
.collect();
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_database_name(name: &str, user: &User) -> anyhow::Result<()> {
validate_name_or_error(name, DbOrUser::Database)
.context(format!("Invalid database name: '{}'", name))?;
validate_ownership_or_error(name, user, DbOrUser::Database)
.context(format!("Invalid database name: '{}'", name))?;
Ok(())
}

View File

@ -1,52 +1,16 @@
//! Database privilege operations
//!
//! This module contains functions for querying, modifying,
//! displaying and comparing database privileges.
//!
//! A lot of the complexity comes from two core components:
//!
//! - The privilege editor that needs to be able to print
//! an editable table of privileges and reparse the content
//! after the user has made manual changes.
//!
//! - The comparison functionality that tells the user what
//! changes will be made when applying a set of changes
//! to the list of database privileges.
use std::collections::{BTreeSet, HashMap};
use anyhow::{anyhow, Context}; use anyhow::{anyhow, Context};
use indoc::indoc;
use itertools::Itertools; use itertools::Itertools;
use prettytable::Table; use prettytable::Table;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{mysql::MySqlRow, prelude::*, MySqlConnection}; use std::{
cmp::max,
use crate::core::{ collections::{BTreeSet, HashMap},
common::{
create_user_group_matching_regex, get_current_unix_user, quote_identifier, rev_yn, yn,
},
database_operations::validate_database_name,
}; };
/// This is the list of fields that are used to fetch the db + user + privileges use super::common::{rev_yn, yn};
/// from the `db` table in the database. If you need to add or remove privilege use crate::server::sql::database_privilege_operations::{
/// fields, this is a good place to start. DatabasePrivilegeRow, DATABASE_PRIVILEGE_FIELDS,
pub const DATABASE_PRIVILEGE_FIELDS: [&str; 13] = [ };
"db",
"user",
"select_priv",
"insert_priv",
"update_priv",
"delete_priv",
"create_priv",
"drop_priv",
"alter_priv",
"index_priv",
"create_tmp_table_priv",
"lock_tables_priv",
"references_priv",
];
pub fn db_priv_field_human_readable_name(name: &str) -> String { pub fn db_priv_field_human_readable_name(name: &str) -> String {
match name { match name {
@ -67,162 +31,24 @@ pub fn db_priv_field_human_readable_name(name: &str) -> String {
} }
} }
/// This struct represents the set of privileges for a single user on a single database. pub fn diff(row1: &DatabasePrivilegeRow, row2: &DatabasePrivilegeRow) -> DatabasePrivilegeRowDiff {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)] debug_assert!(row1.db == row2.db && row1.user == row2.user);
pub struct DatabasePrivilegeRow {
pub db: String,
pub user: String,
pub select_priv: bool,
pub insert_priv: bool,
pub update_priv: bool,
pub delete_priv: bool,
pub create_priv: bool,
pub drop_priv: bool,
pub alter_priv: bool,
pub index_priv: bool,
pub create_tmp_table_priv: bool,
pub lock_tables_priv: bool,
pub references_priv: bool,
}
impl DatabasePrivilegeRow { DatabasePrivilegeRowDiff {
pub fn empty(db: &str, user: &str) -> Self { db: row1.db.clone(),
Self { user: row1.user.clone(),
db: db.to_owned(), diff: DATABASE_PRIVILEGE_FIELDS
user: user.to_owned(), .into_iter()
select_priv: false, .skip(2)
insert_priv: false, .filter_map(|field| {
update_priv: false, DatabasePrivilegeChange::new(
delete_priv: false, row1.get_privilege_by_name(field),
create_priv: false, row2.get_privilege_by_name(field),
drop_priv: false, field,
alter_priv: false, )
index_priv: false, })
create_tmp_table_priv: false, .collect(),
lock_tables_priv: false,
references_priv: false,
}
} }
pub fn get_privilege_by_name(&self, name: &str) -> bool {
match name {
"select_priv" => self.select_priv,
"insert_priv" => self.insert_priv,
"update_priv" => self.update_priv,
"delete_priv" => self.delete_priv,
"create_priv" => self.create_priv,
"drop_priv" => self.drop_priv,
"alter_priv" => self.alter_priv,
"index_priv" => self.index_priv,
"create_tmp_table_priv" => self.create_tmp_table_priv,
"lock_tables_priv" => self.lock_tables_priv,
"references_priv" => self.references_priv,
_ => false,
}
}
pub fn diff(&self, other: &DatabasePrivilegeRow) -> DatabasePrivilegeRowDiff {
debug_assert!(self.db == other.db && self.user == other.user);
DatabasePrivilegeRowDiff {
db: self.db.clone(),
user: self.user.clone(),
diff: DATABASE_PRIVILEGE_FIELDS
.into_iter()
.skip(2)
.filter_map(|field| {
DatabasePrivilegeChange::new(
self.get_privilege_by_name(field),
other.get_privilege_by_name(field),
field,
)
})
.collect(),
}
}
}
#[inline]
fn get_mysql_row_priv_field(row: &MySqlRow, position: usize) -> Result<bool, sqlx::Error> {
let field = DATABASE_PRIVILEGE_FIELDS[position];
let value = row.try_get(position)?;
match rev_yn(value) {
Some(val) => Ok(val),
_ => {
log::warn!(r#"Invalid value for privilege "{}": '{}'"#, field, value);
Ok(false)
}
}
}
impl FromRow<'_, MySqlRow> for DatabasePrivilegeRow {
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
Ok(Self {
db: row.try_get("db")?,
user: row.try_get("user")?,
select_priv: get_mysql_row_priv_field(row, 2)?,
insert_priv: get_mysql_row_priv_field(row, 3)?,
update_priv: get_mysql_row_priv_field(row, 4)?,
delete_priv: get_mysql_row_priv_field(row, 5)?,
create_priv: get_mysql_row_priv_field(row, 6)?,
drop_priv: get_mysql_row_priv_field(row, 7)?,
alter_priv: get_mysql_row_priv_field(row, 8)?,
index_priv: get_mysql_row_priv_field(row, 9)?,
create_tmp_table_priv: get_mysql_row_priv_field(row, 10)?,
lock_tables_priv: get_mysql_row_priv_field(row, 11)?,
references_priv: get_mysql_row_priv_field(row, 12)?,
})
}
}
/// Get all users + privileges for a single database.
pub async fn get_database_privileges(
database_name: &str,
connection: &mut MySqlConnection,
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
let unix_user = get_current_unix_user()?;
validate_database_name(database_name, &unix_user)?;
let result = sqlx::query_as::<_, DatabasePrivilegeRow>(&format!(
"SELECT {} FROM `db` WHERE `db` = ?",
DATABASE_PRIVILEGE_FIELDS
.iter()
.map(|field| quote_identifier(field))
.join(","),
))
.bind(database_name)
.fetch_all(connection)
.await
.context("Failed to show database")?;
Ok(result)
}
/// Get all database + user + privileges pairs that are owned by the current user.
pub async fn get_all_database_privileges(
connection: &mut MySqlConnection,
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
let unix_user = get_current_unix_user()?;
let result = sqlx::query_as::<_, DatabasePrivilegeRow>(&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 ?)
"#},
DATABASE_PRIVILEGE_FIELDS
.iter()
.map(|field| format!("`{field}`"))
.join(","),
))
.bind(create_user_group_matching_regex(&unix_user))
.fetch_all(connection)
.await
.context("Failed to show databases")?;
Ok(result)
} }
/*************************/ /*************************/
@ -340,17 +166,23 @@ pub fn generate_editor_content_from_privilege_data(
// editor will be the example user and example db name. // editor will be the example user and example db name.
// Hence, it's put as the fallback value, despite not really // Hence, it's put as the fallback value, despite not really
// being a "fallback" in the normal sense. // being a "fallback" in the normal sense.
let longest_username = privilege_data let longest_username = max(
.iter() privilege_data
.map(|p| p.user.len()) .iter()
.max() .map(|p| p.user.len())
.unwrap_or(example_user.len()); .max()
.unwrap_or(example_user.len()),
"User".len(),
);
let longest_database_name = privilege_data let longest_database_name = max(
.iter() privilege_data
.map(|p| p.db.len()) .iter()
.max() .map(|p| p.db.len())
.unwrap_or(example_db.len()); .max()
.unwrap_or(example_db.len()),
"Database".len(),
);
let mut header: Vec<_> = DATABASE_PRIVILEGE_FIELDS let mut header: Vec<_> = DATABASE_PRIVILEGE_FIELDS
.into_iter() .into_iter()
@ -578,7 +410,7 @@ pub fn parse_privilege_data_from_editor_content(
/// instances of privilege sets for a single user on a single database. /// instances of privilege sets for a single user on a single database.
/// ///
/// The `User` and `Database` are the same for both instances. /// The `User` and `Database` are the same for both instances.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, PartialOrd, Ord)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
pub struct DatabasePrivilegeRowDiff { pub struct DatabasePrivilegeRowDiff {
pub db: String, pub db: String,
pub user: String, pub user: String,
@ -586,7 +418,7 @@ pub struct DatabasePrivilegeRowDiff {
} }
/// This enum represents a change for a single privilege. /// This enum represents a change for a single privilege.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, PartialOrd, Ord)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
pub enum DatabasePrivilegeChange { pub enum DatabasePrivilegeChange {
YesToNo(String), YesToNo(String),
NoToYes(String), NoToYes(String),
@ -603,13 +435,31 @@ impl DatabasePrivilegeChange {
} }
/// This enum encapsulates whether a [`DatabasePrivilegeRow`] was intrduced, modified or deleted. /// This enum encapsulates whether a [`DatabasePrivilegeRow`] was intrduced, modified or deleted.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, PartialOrd, Ord)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
pub enum DatabasePrivilegesDiff { pub enum DatabasePrivilegesDiff {
New(DatabasePrivilegeRow), New(DatabasePrivilegeRow),
Modified(DatabasePrivilegeRowDiff), Modified(DatabasePrivilegeRowDiff),
Deleted(DatabasePrivilegeRow), Deleted(DatabasePrivilegeRow),
} }
impl DatabasePrivilegesDiff {
pub fn get_database_name(&self) -> &str {
match self {
DatabasePrivilegesDiff::New(p) => &p.db,
DatabasePrivilegesDiff::Modified(p) => &p.db,
DatabasePrivilegesDiff::Deleted(p) => &p.db,
}
}
pub fn get_user_name(&self) -> &str {
match self {
DatabasePrivilegesDiff::New(p) => &p.user,
DatabasePrivilegesDiff::Modified(p) => &p.user,
DatabasePrivilegesDiff::Deleted(p) => &p.user,
}
}
}
/// This function calculates the differences between two sets of database privileges. /// This function calculates the differences between two sets of database privileges.
/// It returns a set of [`DatabasePrivilegesDiff`] that can be used to display or /// It returns a set of [`DatabasePrivilegesDiff`] that can be used to display or
/// apply a set of privilege modifications to the database. /// apply a set of privilege modifications to the database.
@ -633,7 +483,7 @@ pub fn diff_privileges(
for p in to { for p in to {
if let Some(old_p) = from_lookup_table.get(&(p.db.clone(), p.user.clone())) { if let Some(old_p) = from_lookup_table.get(&(p.db.clone(), p.user.clone())) {
let diff = old_p.diff(p); let diff = diff(old_p, p);
if !diff.diff.is_empty() { if !diff.diff.is_empty() {
result.insert(DatabasePrivilegesDiff::Modified(diff)); result.insert(DatabasePrivilegesDiff::Modified(diff));
} }
@ -651,72 +501,6 @@ pub fn diff_privileges(
result result
} }
/// Uses the result of [`diff_privileges`] to modify privileges in the database.
pub async fn apply_privilege_diffs(
diffs: BTreeSet<DatabasePrivilegesDiff>,
connection: &mut MySqlConnection,
) -> anyhow::Result<()> {
for diff in diffs {
match diff {
DatabasePrivilegesDiff::New(p) => {
let tables = DATABASE_PRIVILEGE_FIELDS
.iter()
.map(|field| format!("`{field}`"))
.join(",");
let question_marks = std::iter::repeat("?")
.take(DATABASE_PRIVILEGE_FIELDS.len())
.join(",");
sqlx::query(
format!("INSERT INTO `db` ({}) VALUES ({})", tables, question_marks).as_str(),
)
.bind(p.db)
.bind(p.user)
.bind(yn(p.select_priv))
.bind(yn(p.insert_priv))
.bind(yn(p.update_priv))
.bind(yn(p.delete_priv))
.bind(yn(p.create_priv))
.bind(yn(p.drop_priv))
.bind(yn(p.alter_priv))
.bind(yn(p.index_priv))
.bind(yn(p.create_tmp_table_priv))
.bind(yn(p.lock_tables_priv))
.bind(yn(p.references_priv))
.execute(&mut *connection)
.await?;
}
DatabasePrivilegesDiff::Modified(p) => {
let tables = p
.diff
.iter()
.map(|diff| match diff {
DatabasePrivilegeChange::YesToNo(name) => format!("`{}` = 'N'", name),
DatabasePrivilegeChange::NoToYes(name) => format!("`{}` = 'Y'", name),
})
.join(",");
sqlx::query(
format!("UPDATE `db` SET {} WHERE `db` = ? AND `user` = ?", tables).as_str(),
)
.bind(p.db)
.bind(p.user)
.execute(&mut *connection)
.await?;
}
DatabasePrivilegesDiff::Deleted(p) => {
sqlx::query("DELETE FROM `db` WHERE `db` = ? AND `user` = ?")
.bind(p.db)
.bind(p.user)
.execute(&mut *connection)
.await?;
}
}
}
Ok(())
}
fn display_privilege_cell(diff: &DatabasePrivilegeRowDiff) -> String { fn display_privilege_cell(diff: &DatabasePrivilegeRowDiff) -> String {
diff.diff diff.diff
.iter() .iter()
@ -731,6 +515,20 @@ fn display_privilege_cell(diff: &DatabasePrivilegeRowDiff) -> String {
.join("\n") .join("\n")
} }
fn display_new_privileges_list(row: &DatabasePrivilegeRow) -> String {
DATABASE_PRIVILEGE_FIELDS
.into_iter()
.skip(2)
.map(|field| {
if row.get_privilege_by_name(field) {
format!("{}: Y", db_priv_field_human_readable_name(field))
} else {
format!("{}: N", db_priv_field_human_readable_name(field))
}
})
.join("\n")
}
/// Displays the difference between two sets of database privileges. /// Displays the difference between two sets of database privileges.
pub fn display_privilege_diffs(diffs: &BTreeSet<DatabasePrivilegesDiff>) -> String { pub fn display_privilege_diffs(diffs: &BTreeSet<DatabasePrivilegesDiff>) -> String {
let mut table = Table::new(); let mut table = Table::new();
@ -741,24 +539,14 @@ pub fn display_privilege_diffs(diffs: &BTreeSet<DatabasePrivilegesDiff>) -> Stri
table.add_row(row![ table.add_row(row![
p.db, p.db,
p.user, p.user,
"(New user)\n".to_string() "(New user)\n".to_string() + &display_new_privileges_list(p)
+ &display_privilege_cell(
&DatabasePrivilegeRow::empty(&p.db, &p.user).diff(p)
)
]); ]);
} }
DatabasePrivilegesDiff::Modified(p) => { DatabasePrivilegesDiff::Modified(p) => {
table.add_row(row![p.db, p.user, display_privilege_cell(p),]); table.add_row(row![p.db, p.user, display_privilege_cell(p),]);
} }
DatabasePrivilegesDiff::Deleted(p) => { DatabasePrivilegesDiff::Deleted(p) => {
table.add_row(row![ table.add_row(row![p.db, p.user, "Removed".to_string()]);
p.db,
p.user,
"(All privileges removed)\n".to_string()
+ &display_privilege_cell(
&p.diff(&DatabasePrivilegeRow::empty(&p.db, &p.user))
)
]);
} }
} }
} }

5
src/core/protocol.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod request_response;
pub mod server_responses;
pub use request_response::*;
pub use server_responses::*;

View File

@ -0,0 +1,79 @@
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use tokio::net::UnixStream;
use tokio_serde::{formats::Bincode, Framed as SerdeFramed};
use tokio_util::codec::{Framed, LengthDelimitedCodec};
use crate::core::{database_privileges::DatabasePrivilegesDiff, protocol::*};
pub type ServerToClientMessageStream = SerdeFramed<
Framed<UnixStream, LengthDelimitedCodec>,
Request,
Response,
Bincode<Request, Response>,
>;
pub type ClientToServerMessageStream = SerdeFramed<
Framed<UnixStream, LengthDelimitedCodec>,
Response,
Request,
Bincode<Response, Request>,
>;
pub fn create_server_to_client_message_stream(socket: UnixStream) -> ServerToClientMessageStream {
let length_delimited = Framed::new(socket, LengthDelimitedCodec::new());
tokio_serde::Framed::new(length_delimited, Bincode::default())
}
pub fn create_client_to_server_message_stream(socket: UnixStream) -> ClientToServerMessageStream {
let length_delimited = Framed::new(socket, LengthDelimitedCodec::new());
tokio_serde::Framed::new(length_delimited, Bincode::default())
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Request {
CreateDatabases(Vec<String>),
DropDatabases(Vec<String>),
ListDatabases,
ListPrivileges(Option<Vec<String>>),
ModifyPrivileges(BTreeSet<DatabasePrivilegesDiff>),
CreateUsers(Vec<String>),
DropUsers(Vec<String>),
PasswdUser(String, String),
ListUsers(Option<Vec<String>>),
LockUsers(Vec<String>),
UnlockUsers(Vec<String>),
// Commit,
Exit,
}
// TODO: include a generic "message" that will display a message to the user?
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Response {
// Specific data for specific commands
CreateDatabases(CreateDatabasesOutput),
DropDatabases(DropDatabasesOutput),
ListAllDatabases(ListAllDatabasesOutput),
ListPrivileges(GetDatabasesPrivilegeData),
ListAllPrivileges(GetAllDatabasesPrivilegeData),
ModifyPrivileges(ModifyDatabasePrivilegesOutput),
CreateUsers(CreateUsersOutput),
DropUsers(DropUsersOutput),
PasswdUser(SetPasswordOutput),
ListUsers(ListUsersOutput),
ListAllUsers(ListAllUsersOutput),
LockUsers(LockUsersOutput),
UnlockUsers(UnlockUsersOutput),
// Generic responses
OperationAborted,
Error(String),
Exit,
}

View File

@ -0,0 +1,603 @@
use std::collections::BTreeMap;
use indoc::indoc;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use crate::{
core::{common::UnixUser, database_privileges::DatabasePrivilegeRowDiff},
server::sql::{
database_privilege_operations::DatabasePrivilegeRow, user_operations::DatabaseUser,
},
};
/// 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.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
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(),
}
}
}
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
pub enum NameValidationError {
EmptyString,
InvalidCharacters,
TooLong,
}
impl NameValidationError {
pub fn to_error_message(self, name: &str, db_or_user: DbOrUser) -> String {
match self {
NameValidationError::EmptyString => {
format!("{} name cannot be empty.", db_or_user.capitalized()).to_owned()
}
NameValidationError::TooLong => format!(
"{} is too long. Maximum length is 64 characters.",
db_or_user.capitalized()
)
.to_owned(),
NameValidationError::InvalidCharacters => format!(
indoc! {r#"
Invalid characters in {} name: '{}'
Only A-Z, a-z, 0-9, _ (underscore) and - (dash) are permitted.
"#},
db_or_user.lowercased(),
name
)
.to_owned(),
}
}
}
impl OwnerValidationError {
pub fn to_error_message(self, name: &str, db_or_user: DbOrUser) -> String {
let user = UnixUser::from_enviroment();
match self {
OwnerValidationError::NoMatch => format!(
indoc! {r#"
Invalid {} name prefix: '{}' does not match your username or any of your groups.
Are you sure you are allowed to create {} names with this prefix?
Allowed prefixes:
- {}
{}
"#},
db_or_user.lowercased(),
name,
db_or_user.lowercased(),
user.as_ref()
.map(|u| u.username.clone())
.unwrap_or("???".to_string()),
user.map(|u| u.groups)
.unwrap_or_default()
.iter()
.map(|g| format!(" - {}", g))
.sorted()
.join("\n"),
)
.to_owned(),
_ => format!(
"'{}' is not a valid {} name.",
name,
db_or_user.lowercased()
)
.to_string(),
}
}
}
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
pub enum OwnerValidationError {
// The name is valid, but none of the given prefixes matched the name
NoMatch,
// The name is empty, which is invalid
StringEmpty,
// The name is in the format "_<postfix>", which is invalid
MissingPrefix,
// The name is in the format "<prefix>_", which is invalid
MissingPostfix,
}
pub type CreateDatabasesOutput = BTreeMap<String, Result<(), CreateDatabaseError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CreateDatabaseError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
DatabaseAlreadyExists,
MySqlError(String),
}
pub fn print_create_databases_output_status(output: &CreateDatabasesOutput) {
for (database_name, result) in output {
match result {
Ok(()) => {
println!("Database '{}' created successfully.", database_name);
}
Err(err) => {
println!("{}", err.to_error_message(database_name));
}
}
println!();
}
}
impl CreateDatabaseError {
pub fn to_error_message(&self, database_name: &str) -> String {
match self {
CreateDatabaseError::SanitizationError(err) => {
err.to_error_message(database_name, DbOrUser::Database)
}
CreateDatabaseError::OwnershipError(err) => {
err.to_error_message(database_name, DbOrUser::Database)
}
CreateDatabaseError::DatabaseAlreadyExists => {
format!("Database {} already exists.", database_name)
}
CreateDatabaseError::MySqlError(err) => {
format!("MySQL error: {}", err)
}
}
}
}
pub type DropDatabasesOutput = BTreeMap<String, Result<(), DropDatabaseError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DropDatabaseError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
DatabaseDoesNotExist,
MySqlError(String),
}
pub fn print_drop_databases_output_status(output: &DropDatabasesOutput) {
for (database_name, result) in output {
match result {
Ok(()) => {
println!("Database '{}' dropped successfully.", database_name);
}
Err(err) => {
println!("{}", err.to_error_message(database_name));
}
}
println!();
}
}
impl DropDatabaseError {
pub fn to_error_message(&self, database_name: &str) -> String {
match self {
DropDatabaseError::SanitizationError(err) => {
err.to_error_message(database_name, DbOrUser::Database)
}
DropDatabaseError::OwnershipError(err) => {
err.to_error_message(database_name, DbOrUser::Database)
}
DropDatabaseError::DatabaseDoesNotExist => {
format!("Database {} does not exist.", database_name)
}
DropDatabaseError::MySqlError(err) => {
format!("MySQL error: {}", err)
}
}
}
}
pub type ListAllDatabasesOutput = Result<Vec<String>, ListDatabasesError>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ListDatabasesError {
MySqlError(String),
}
impl ListDatabasesError {
pub fn to_error_message(&self) -> String {
match self {
ListDatabasesError::MySqlError(err) => format!("MySQL error: {}", err),
}
}
}
// TODO: merge all rows into a single collection.
// they already contain which database they belong to.
// no need to index by database name.
pub type GetDatabasesPrivilegeData =
BTreeMap<String, Result<Vec<DatabasePrivilegeRow>, GetDatabasesPrivilegeDataError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum GetDatabasesPrivilegeDataError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
DatabaseDoesNotExist,
MySqlError(String),
}
impl GetDatabasesPrivilegeDataError {
pub fn to_error_message(&self, database_name: &str) -> String {
match self {
GetDatabasesPrivilegeDataError::SanitizationError(err) => {
err.to_error_message(database_name, DbOrUser::Database)
}
GetDatabasesPrivilegeDataError::OwnershipError(err) => {
err.to_error_message(database_name, DbOrUser::Database)
}
GetDatabasesPrivilegeDataError::DatabaseDoesNotExist => {
format!("Database '{}' does not exist.", database_name)
}
GetDatabasesPrivilegeDataError::MySqlError(err) => {
format!("MySQL error: {}", err)
}
}
}
}
pub type GetAllDatabasesPrivilegeData =
Result<Vec<DatabasePrivilegeRow>, GetAllDatabasesPrivilegeDataError>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum GetAllDatabasesPrivilegeDataError {
MySqlError(String),
}
impl GetAllDatabasesPrivilegeDataError {
pub fn to_error_message(&self) -> String {
match self {
GetAllDatabasesPrivilegeDataError::MySqlError(err) => format!("MySQL error: {}", err),
}
}
}
pub type ModifyDatabasePrivilegesOutput =
BTreeMap<(String, String), Result<(), ModifyDatabasePrivilegesError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ModifyDatabasePrivilegesError {
DatabaseSanitizationError(NameValidationError),
DatabaseOwnershipError(OwnerValidationError),
UserSanitizationError(NameValidationError),
UserOwnershipError(OwnerValidationError),
DatabaseDoesNotExist,
DiffDoesNotApply(DiffDoesNotApplyError),
MySqlError(String),
}
#[allow(clippy::enum_variant_names)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DiffDoesNotApplyError {
RowAlreadyExists(String, String),
RowDoesNotExist(String, String),
RowPrivilegeChangeDoesNotApply(DatabasePrivilegeRowDiff, DatabasePrivilegeRow),
}
pub fn print_modify_database_privileges_output_status(output: &ModifyDatabasePrivilegesOutput) {
for ((database_name, username), result) in output {
match result {
Ok(()) => {
println!(
"Privileges for user '{}' on database '{}' modified successfully.",
username, database_name
);
}
Err(err) => {
println!("{}", err.to_error_message(database_name, username));
}
}
println!();
}
}
impl ModifyDatabasePrivilegesError {
pub fn to_error_message(&self, database_name: &str, username: &str) -> String {
match self {
ModifyDatabasePrivilegesError::DatabaseSanitizationError(err) => {
err.to_error_message(database_name, DbOrUser::Database)
}
ModifyDatabasePrivilegesError::DatabaseOwnershipError(err) => {
err.to_error_message(database_name, DbOrUser::Database)
}
ModifyDatabasePrivilegesError::UserSanitizationError(err) => {
err.to_error_message(username, DbOrUser::User)
}
ModifyDatabasePrivilegesError::UserOwnershipError(err) => {
err.to_error_message(username, DbOrUser::User)
}
ModifyDatabasePrivilegesError::DatabaseDoesNotExist => {
format!("Database '{}' does not exist.", database_name)
}
ModifyDatabasePrivilegesError::DiffDoesNotApply(diff) => {
format!(
"Could not apply privilege change:\n{}",
diff.to_error_message()
)
}
ModifyDatabasePrivilegesError::MySqlError(err) => {
format!("MySQL error: {}", err)
}
}
}
}
impl DiffDoesNotApplyError {
pub fn to_error_message(&self) -> String {
match self {
DiffDoesNotApplyError::RowAlreadyExists(database_name, username) => {
format!(
"Privileges for user '{}' on database '{}' already exist.",
username, database_name
)
}
DiffDoesNotApplyError::RowDoesNotExist(database_name, username) => {
format!(
"Privileges for user '{}' on database '{}' do not exist.",
username, database_name
)
}
DiffDoesNotApplyError::RowPrivilegeChangeDoesNotApply(diff, row) => {
format!(
"Could not apply privilege change {:?} to row {:?}",
diff, row
)
}
}
}
}
pub type CreateUsersOutput = BTreeMap<String, Result<(), CreateUserError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CreateUserError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
UserAlreadyExists,
MySqlError(String),
}
pub fn print_create_users_output_status(output: &CreateUsersOutput) {
for (username, result) in output {
match result {
Ok(()) => {
println!("User '{}' created successfully.", username);
}
Err(err) => {
println!("{}", err.to_error_message(username));
}
}
println!();
}
}
impl CreateUserError {
pub fn to_error_message(&self, username: &str) -> String {
match self {
CreateUserError::SanitizationError(err) => {
err.to_error_message(username, DbOrUser::User)
}
CreateUserError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
CreateUserError::UserAlreadyExists => {
format!("User '{}' already exists.", username)
}
CreateUserError::MySqlError(err) => {
format!("MySQL error: {}", err)
}
}
}
}
pub type DropUsersOutput = BTreeMap<String, Result<(), DropUserError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DropUserError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
UserDoesNotExist,
MySqlError(String),
}
pub fn print_drop_users_output_status(output: &DropUsersOutput) {
for (username, result) in output {
match result {
Ok(()) => {
println!("User '{}' dropped successfully.", username);
}
Err(err) => {
println!("{}", err.to_error_message(username));
}
}
println!();
}
}
impl DropUserError {
pub fn to_error_message(&self, username: &str) -> String {
match self {
DropUserError::SanitizationError(err) => err.to_error_message(username, DbOrUser::User),
DropUserError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
DropUserError::UserDoesNotExist => {
format!("User '{}' does not exist.", username)
}
DropUserError::MySqlError(err) => {
format!("MySQL error: {}", err)
}
}
}
}
pub type SetPasswordOutput = Result<(), SetPasswordError>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SetPasswordError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
UserDoesNotExist,
MySqlError(String),
}
pub fn print_set_password_output_status(output: &SetPasswordOutput, username: &str) {
match output {
Ok(()) => {
println!("Password for user '{}' set successfully.", username);
}
Err(err) => {
println!("{}", err.to_error_message(username));
}
}
}
impl SetPasswordError {
pub fn to_error_message(&self, username: &str) -> String {
match self {
SetPasswordError::SanitizationError(err) => {
err.to_error_message(username, DbOrUser::User)
}
SetPasswordError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
SetPasswordError::UserDoesNotExist => {
format!("User '{}' does not exist.", username)
}
SetPasswordError::MySqlError(err) => {
format!("MySQL error: {}", err)
}
}
}
}
pub type LockUsersOutput = BTreeMap<String, Result<(), LockUserError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum LockUserError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
UserDoesNotExist,
UserIsAlreadyLocked,
MySqlError(String),
}
pub fn print_lock_users_output_status(output: &LockUsersOutput) {
for (username, result) in output {
match result {
Ok(()) => {
println!("User '{}' locked successfully.", username);
}
Err(err) => {
println!("{}", err.to_error_message(username));
}
}
println!();
}
}
impl LockUserError {
pub fn to_error_message(&self, username: &str) -> String {
match self {
LockUserError::SanitizationError(err) => err.to_error_message(username, DbOrUser::User),
LockUserError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
LockUserError::UserDoesNotExist => {
format!("User '{}' does not exist.", username)
}
LockUserError::UserIsAlreadyLocked => {
format!("User '{}' is already locked.", username)
}
LockUserError::MySqlError(err) => {
format!("MySQL error: {}", err)
}
}
}
}
pub type UnlockUsersOutput = BTreeMap<String, Result<(), UnlockUserError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum UnlockUserError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
UserDoesNotExist,
UserIsAlreadyUnlocked,
MySqlError(String),
}
pub fn print_unlock_users_output_status(output: &UnlockUsersOutput) {
for (username, result) in output {
match result {
Ok(()) => {
println!("User '{}' unlocked successfully.", username);
}
Err(err) => {
println!("{}", err.to_error_message(username));
}
}
println!();
}
}
impl UnlockUserError {
pub fn to_error_message(&self, username: &str) -> String {
match self {
UnlockUserError::SanitizationError(err) => {
err.to_error_message(username, DbOrUser::User)
}
UnlockUserError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
UnlockUserError::UserDoesNotExist => {
format!("User '{}' does not exist.", username)
}
UnlockUserError::UserIsAlreadyUnlocked => {
format!("User '{}' is already unlocked.", username)
}
UnlockUserError::MySqlError(err) => {
format!("MySQL error: {}", err)
}
}
}
}
pub type ListUsersOutput = BTreeMap<String, Result<DatabaseUser, ListUsersError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ListUsersError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
UserDoesNotExist,
MySqlError(String),
}
impl ListUsersError {
pub fn to_error_message(&self, username: &str) -> String {
match self {
ListUsersError::SanitizationError(err) => {
err.to_error_message(username, DbOrUser::User)
}
ListUsersError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
ListUsersError::UserDoesNotExist => {
format!("User '{}' does not exist.", username)
}
ListUsersError::MySqlError(err) => {
format!("MySQL error: {}", err)
}
}
}
}
pub type ListAllUsersOutput = Result<Vec<DatabaseUser>, ListAllUsersError>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ListAllUsersError {
MySqlError(String),
}
impl ListAllUsersError {
pub fn to_error_message(&self) -> String {
match self {
ListAllUsersError::MySqlError(err) => format!("MySQL error: {}", err),
}
}
}

View File

@ -1,249 +0,0 @@
use anyhow::Context;
use nix::unistd::User;
use serde::{Deserialize, Serialize};
use sqlx::{prelude::*, MySqlConnection};
use crate::core::common::{
create_user_group_matching_regex, get_current_unix_user, quote_literal, validate_name_or_error,
validate_ownership_or_error, DbOrUser,
};
pub async fn user_exists(db_user: &str, connection: &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(connection)
.await?
.get::<bool, _>(0);
Ok(user_exists)
}
pub async fn create_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 '{}' 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())
.execute(connection)
.await?;
Ok(())
}
pub async fn delete_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);
}
// NOTE: see the note about SQL injections in `validate_ownership_of_user_name`
sqlx::query(format!("DROP USER {}@'%'", quote_literal(db_user),).as_str())
.execute(connection)
.await?;
Ok(())
}
pub async fn set_password_for_database_user(
db_user: &str,
password: &str,
connection: &mut MySqlConnection,
) -> anyhow::Result<()> {
let unix_user = crate::core::common::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);
}
// NOTE: see the note about SQL injections in `validate_ownership_of_user_name`
sqlx::query(
format!(
"ALTER USER {}@'%' IDENTIFIED BY {}",
quote_literal(db_user),
quote_literal(password).as_str()
)
.as_str(),
)
.execute(connection)
.await?;
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 can be extended if we need more information in the future.
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct DatabaseUser {
#[sqlx(rename = "User")]
pub user: String,
#[allow(dead_code)]
#[serde(skip)]
#[sqlx(rename = "Host")]
pub host: String,
#[sqlx(rename = "has_password")]
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
/// unix username and group names of the given unix user.
pub async fn get_all_database_users_for_unix_user(
unix_user: &User,
connection: &mut MySqlConnection,
) -> anyhow::Result<Vec<DatabaseUser>> {
let users = sqlx::query_as::<_, DatabaseUser>(
&(DB_USER_SELECT_STATEMENT.to_string() + "WHERE `mysql`.`user`.`User` REGEXP ?"),
)
.bind(create_user_group_matching_regex(unix_user))
.fetch_all(connection)
.await?;
Ok(users)
}
/// This function fetches a database user if it exists.
pub async fn get_database_user_for_user(
username: &str,
connection: &mut MySqlConnection,
) -> anyhow::Result<Option<DatabaseUser>> {
let user = sqlx::query_as::<_, DatabaseUser>(
&(DB_USER_SELECT_STATEMENT.to_string() + "WHERE `mysql`.`user`.`User` = ?"),
)
.bind(username)
.fetch_optional(connection)
.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_user_name(name: &str, user: &User) -> anyhow::Result<()> {
validate_name_or_error(name, DbOrUser::User)
.context(format!("Invalid username: '{}'", name))?;
validate_ownership_or_error(name, user, DbOrUser::User)
.context(format!("Invalid username: '{}'", name))?;
Ok(())
}

View File

@ -1,42 +1,69 @@
#[macro_use] #[macro_use]
extern crate prettytable; extern crate prettytable;
use core::common::CommandStatus; use clap::Parser;
#[cfg(feature = "mysql-admutils-compatibility")]
use std::path::PathBuf; use std::path::PathBuf;
use std::os::unix::net::UnixStream as StdUnixStream;
use tokio::net::UnixStream as TokioUnixStream;
use crate::{
core::{
bootstrap::{bootstrap_server_connection_and_drop_privileges, drop_privs},
protocol::create_client_to_server_message_stream,
},
server::command::ServerArgs,
};
#[cfg(feature = "mysql-admutils-compatibility")] #[cfg(feature = "mysql-admutils-compatibility")]
use crate::cli::mysql_admutils_compatibility::{mysql_dbadm, mysql_useradm}; use crate::cli::mysql_admutils_compatibility::{mysql_dbadm, mysql_useradm};
use clap::Parser; mod server;
mod authenticated_unix_socket;
mod cli; mod cli;
mod core; mod core;
#[cfg(feature = "tui")] #[cfg(feature = "tui")]
mod tui; mod tui;
#[derive(Parser)] #[derive(Parser, Debug)]
struct Args { struct Args {
#[command(subcommand)] #[command(subcommand)]
command: Command, command: Command,
#[command(flatten)] /// Path to the socket of the server, if it already exists.
config_overrides: core::config::GlobalConfigArgs, #[arg(
short,
long,
value_name = "PATH",
global = true,
hide_short_help = true
)]
server_socket_path: Option<PathBuf>,
/// Config file to use for the server.
#[arg(
short,
long,
value_name = "PATH",
global = true,
hide_short_help = true
)]
config: Option<PathBuf>,
#[cfg(feature = "tui")] #[cfg(feature = "tui")]
#[arg(short, long, alias = "tui", global = true)] #[arg(short, long, alias = "tui", global = true)]
interactive: bool, interactive: bool,
} }
/// Database administration tool for non-admin users to manage their own MySQL databases and users. // Database administration tool for non-admin users to manage their own MySQL databases and users.
/// //
/// This tool allows you to manage users and databases in MySQL. // This tool allows you to manage users and databases in MySQL.
/// //
/// You are only allowed to manage databases and users that are prefixed with // You are only allowed to manage databases and users that are prefixed with
/// either your username, or a group that you are a member of. // either your username, or a group that you are a member of.
#[derive(Parser)] #[derive(Parser, Debug, Clone)]
#[command(version, about, disable_help_subcommand = true)] #[command(version, about, disable_help_subcommand = true)]
enum Command { enum Command {
#[command(flatten)] #[command(flatten)]
@ -44,10 +71,18 @@ enum Command {
#[command(flatten)] #[command(flatten)]
User(cli::user_command::UserCommand), User(cli::user_command::UserCommand),
#[command(hide = true)]
Server(server::command::ServerArgs),
} }
#[tokio::main(flavor = "current_thread")] // TODO: tag all functions that are run with elevated privileges with
async fn main() -> anyhow::Result<()> { // comments emphasizing the need for caution.
fn main() -> anyhow::Result<()> {
// TODO: find out if there are any security risks of running
// env_logger and clap with elevated privileges.
env_logger::init(); env_logger::init();
#[cfg(feature = "mysql-admutils-compatibility")] #[cfg(feature = "mysql-admutils-compatibility")]
@ -59,42 +94,60 @@ async fn main() -> anyhow::Result<()> {
}); });
match argv0.as_deref() { match argv0.as_deref() {
Some("mysql-dbadm") => return mysql_dbadm::main().await, Some("mysql-dbadm") => return mysql_dbadm::main(),
Some("mysql-useradm") => return mysql_useradm::main().await, Some("mysql-useradm") => return mysql_useradm::main(),
_ => { /* fall through */ } _ => { /* fall through */ }
} }
} }
let args: Args = Args::parse(); let args: Args = Args::parse();
let config = core::config::get_config(args.config_overrides)?; match args.command {
let connection = core::config::create_mysql_connection_from_config(config.mysql).await?; Command::Server(ref command) => {
drop_privs()?;
let result = match args.command { tokio_start_server(args.server_socket_path, args.config, command.clone())?;
Command::Db(command) => cli::database_command::handle_command(command, connection).await, return Ok(());
Command::User(user_args) => cli::user_command::handle_command(user_args, connection).await,
};
match result {
Ok(CommandStatus::SuccessfullyModified) => {
println!("Modifications committed successfully");
Ok(())
} }
Ok(CommandStatus::PartiallySuccessfullyModified) => { _ => { /* fall through */ }
println!("Some modifications committed successfully");
Ok(())
}
Ok(CommandStatus::NoModificationsNeeded) => {
println!("No modifications made");
Ok(())
}
Ok(CommandStatus::NoModificationsIntended) => {
/* Don't report anything */
Ok(())
}
Ok(CommandStatus::Cancelled) => {
println!("Command cancelled successfully");
Ok(())
}
Err(e) => Err(e),
} }
let server_connection =
bootstrap_server_connection_and_drop_privileges(args.server_socket_path, args.config)?;
tokio_run_command(args.command, server_connection)?;
Ok(())
}
fn tokio_start_server(
server_socket_path: Option<PathBuf>,
config_path: Option<PathBuf>,
args: ServerArgs,
) -> anyhow::Result<()> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
server::command::handle_command(server_socket_path, config_path, args).await
})
}
fn tokio_run_command(command: Command, server_connection: StdUnixStream) -> anyhow::Result<()> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
let tokio_socket = TokioUnixStream::from_std(server_connection)?;
let message_stream = create_client_to_server_message_stream(tokio_socket);
match command {
Command::User(user_args) => {
cli::user_command::handle_command(user_args, message_stream).await
}
Command::Db(db_args) => {
cli::database_command::handle_command(db_args, message_stream).await
}
Command::Server(_) => unreachable!(),
}
})
} }

View File

@ -1,6 +1,6 @@
pub mod command;
mod common; mod common;
mod database_operations; pub mod config;
mod entrypoint; pub mod input_sanitization;
mod input_sanitization; pub mod server_loop;
mod protocol; pub mod sql;
mod user_operations;

77
src/server/command.rs Normal file
View File

@ -0,0 +1,77 @@
use std::os::fd::FromRawFd;
use std::path::PathBuf;
use anyhow::Context;
use clap::Parser;
use std::os::unix::net::UnixStream as StdUnixStream;
use tokio::net::UnixStream as TokioUnixStream;
use crate::core::bootstrap::authenticated_unix_socket;
use crate::core::common::UnixUser;
use crate::server::config::read_config_from_path_with_arg_overrides;
use crate::server::server_loop::listen_for_incoming_connections;
use crate::server::{
config::{ServerConfig, ServerConfigArgs},
server_loop::handle_requests_for_single_session,
};
#[derive(Parser, Debug, Clone)]
pub struct ServerArgs {
#[command(subcommand)]
subcmd: ServerCommand,
#[command(flatten)]
config_overrides: ServerConfigArgs,
}
#[derive(Parser, Debug, Clone)]
pub enum ServerCommand {
#[command()]
Listen,
#[command()]
SocketActivate,
}
pub async fn handle_command(
socket_path: Option<PathBuf>,
config_path: Option<PathBuf>,
args: ServerArgs,
) -> anyhow::Result<()> {
let config = read_config_from_path_with_arg_overrides(config_path, args.config_overrides)?;
// if let Err(e) = &result {
// eprintln!("{}", e);
// }
match args.subcmd {
ServerCommand::Listen => listen_for_incoming_connections(socket_path, config).await,
ServerCommand::SocketActivate => socket_activate(config).await,
}
}
async fn socket_activate(config: ServerConfig) -> anyhow::Result<()> {
// TODO: allow getting socket path from other socket activation sources
let mut conn = get_socket_from_systemd().await?;
let uid = authenticated_unix_socket::server_authenticate(&mut conn).await?;
let unix_user = UnixUser::from_uid(uid.into())?;
handle_requests_for_single_session(conn, &unix_user, &config).await?;
Ok(())
}
async fn get_socket_from_systemd() -> anyhow::Result<TokioUnixStream> {
let fd = std::env::var("LISTEN_FDS")
.context("LISTEN_FDS not set, not running under systemd?")?
.parse::<i32>()
.context("Failed to parse LISTEN_FDS")?;
if fd != 1 {
return Err(anyhow::anyhow!("Unexpected LISTEN_FDS value: {}", fd));
}
let std_unix_stream = unsafe { StdUnixStream::from_raw_fd(fd) };
let socket = TokioUnixStream::from_std(std_unix_stream)?;
Ok(socket)
}

View File

@ -1,94 +1,4 @@
use anyhow::Context; use crate::core::common::UnixUser;
use nix::unistd::{Group as LibcGroup, User as LibcUser};
use sqlx::{Connection, MySqlConnection};
#[cfg(not(target_os = "macos"))]
use std::ffi::CString;
/// Report the result status of a command.
/// This is used to display a status message to the user.
pub enum CommandStatus {
/// The command was successful,
/// and made modification to the database.
SuccessfullyModified,
/// The command was mostly successful,
/// and modifications have been made to the database.
/// However, some of the requested modifications failed.
PartiallySuccessfullyModified,
/// The command was successful,
/// but no modifications were needed.
NoModificationsNeeded,
/// The command was successful,
/// and made no modification to the database.
NoModificationsIntended,
/// The command was cancelled, either through a dialog or a signal.
/// No modifications have been made to the database.
Cancelled,
}
// 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 struct UnixUser {
pub username: String,
pub uid: u32,
pub gid: u32,
pub groups: Vec<String>,
}
#[cfg(target_os = "macos")]
fn get_unix_groups(_user: &User) -> anyhow::Result<Vec<Group>> {
// Return an empty list on macOS since there is no `getgrouplist` function
Ok(vec![])
}
#[cfg(not(target_os = "macos"))]
fn get_unix_groups(user: &LibcUser) -> anyhow::Result<Vec<LibcGroup>> {
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 LibcGroup::from_gid(*gid) {
Ok(Some(group)) => Some(group),
Ok(None) => None,
Err(e) => {
log::warn!(
"Failed to look up group with GID {}: {}\nIgnoring...",
gid,
e
);
None
}
})
.collect::<Vec<LibcGroup>>();
Ok(groups)
}
impl UnixUser {
pub fn from_uid(uid: u32) -> anyhow::Result<Self> {
let libc_uid = nix::unistd::Uid::from_raw(uid);
let libc_user = LibcUser::from_uid(libc_uid)
.context("Failed to look up your UNIX username")?
.ok_or(anyhow::anyhow!("Failed to look up your UNIX username"))?;
let groups = get_unix_groups(&libc_user)?;
Ok(UnixUser {
username: libc_user.name,
uid,
gid: libc_user.gid.into(),
groups: groups.iter().map(|g| g.name.clone()).collect(),
})
}
}
/// This function creates a regex that matches items (users, databases) /// This function creates a regex that matches items (users, databases)
/// that belong to the user or any of the user's groups. /// that belong to the user or any of the user's groups.
@ -99,15 +9,3 @@ pub fn create_user_group_matching_regex(user: &UnixUser) -> String {
format!("({}|{})(_.+)?", user.username, user.groups.join("|")) format!("({}|{})(_.+)?", user.username, user.groups.join("|"))
} }
} }
/// Gracefully close a MySQL connection.
pub async fn close_database_connection(connection: MySqlConnection) {
if let Err(e) = connection
.close()
.await
.context("Failed to close connection properly")
{
eprintln!("{}", e);
eprintln!("Ignoring...");
}
}

View File

@ -5,11 +5,16 @@ use clap::Parser;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{mysql::MySqlConnectOptions, ConnectOptions, MySqlConnection}; use sqlx::{mysql::MySqlConnectOptions, ConnectOptions, MySqlConnection};
use crate::core::common::DEFAULT_CONFIG_PATH;
pub const DEFAULT_PORT: u16 = 3306;
pub const DEFAULT_TIMEOUT: u64 = 2;
// NOTE: this might look empty now, and the extra wrapping for the mysql // NOTE: this might look empty now, and the extra wrapping for the mysql
// config seems unnecessary, but it will be useful later when we // config seems unnecessary, but it will be useful later when we
// add more configuration options. // add more configuration options.
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config { pub struct ServerConfig {
pub mysql: MysqlConfig, pub mysql: MysqlConfig,
} }
@ -23,58 +28,36 @@ pub struct MysqlConfig {
pub timeout: Option<u64>, pub timeout: Option<u64>,
} }
const DEFAULT_PORT: u16 = 3306; #[derive(Parser, Debug, Clone)]
const DEFAULT_TIMEOUT: u64 = 2; pub struct ServerConfigArgs {
#[derive(Parser)]
pub struct GlobalConfigArgs {
/// Path to the configuration file.
#[arg(
short,
long,
value_name = "PATH",
global = true,
hide_short_help = true,
default_value = "/etc/mysqladm/config.toml"
)]
config_file: String,
/// Hostname of the MySQL server. /// Hostname of the MySQL server.
#[arg(long, value_name = "HOST", global = true, hide_short_help = true)] #[arg(long, value_name = "HOST", global = true)]
mysql_host: Option<String>, mysql_host: Option<String>,
/// Port of the MySQL server. /// Port of the MySQL server.
#[arg(long, value_name = "PORT", global = true, hide_short_help = true)] #[arg(long, value_name = "PORT", global = true)]
mysql_port: Option<u16>, mysql_port: Option<u16>,
/// Username to use for the MySQL connection. /// Username to use for the MySQL connection.
#[arg(long, value_name = "USER", global = true, hide_short_help = true)] #[arg(long, value_name = "USER", global = true)]
mysql_user: Option<String>, mysql_user: Option<String>,
/// Path to a file containing the MySQL password. /// Path to a file containing the MySQL password.
#[arg(long, value_name = "PATH", global = true, hide_short_help = true)] #[arg(long, value_name = "PATH", global = true)]
mysql_password_file: Option<String>, mysql_password_file: Option<String>,
/// Seconds to wait for the MySQL connection to be established. /// Seconds to wait for the MySQL connection to be established.
#[arg(long, value_name = "SECONDS", global = true, hide_short_help = true)] #[arg(long, value_name = "SECONDS", global = true)]
mysql_connect_timeout: Option<u64>, mysql_connect_timeout: Option<u64>,
} }
/// Use the arguments and whichever configuration file which might or might not /// Use the arguments and whichever configuration file which might or might not
/// be found and default values to determine the configuration for the program. /// be found and default values to determine the configuration for the program.
pub fn get_config(args: GlobalConfigArgs) -> anyhow::Result<Config> { pub fn read_config_from_path_with_arg_overrides(
let config_path = PathBuf::from(args.config_file); config_path: Option<PathBuf>,
args: ServerConfigArgs,
let config: Config = fs::read_to_string(&config_path) ) -> anyhow::Result<ServerConfig> {
.context(format!( let config = read_config_form_path(config_path)?;
"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 mysql = &config.mysql;
@ -86,22 +69,35 @@ pub fn get_config(args: GlobalConfigArgs) -> anyhow::Result<Config> {
mysql.password.to_owned() mysql.password.to_owned()
}; };
let mysql_config = MysqlConfig { Ok(ServerConfig {
host: args.mysql_host.unwrap_or(mysql.host.to_owned()), mysql: MysqlConfig {
port: args.mysql_port.or(mysql.port), host: args.mysql_host.unwrap_or(mysql.host.to_owned()),
username: args.mysql_user.unwrap_or(mysql.username.to_owned()), port: args.mysql_port.or(mysql.port),
password, username: args.mysql_user.unwrap_or(mysql.username.to_owned()),
timeout: args.mysql_connect_timeout.or(mysql.timeout), password,
}; timeout: args.mysql_connect_timeout.or(mysql.timeout),
},
Ok(Config {
mysql: mysql_config,
}) })
} }
pub fn read_config_form_path(config_path: Option<PathBuf>) -> anyhow::Result<ServerConfig> {
let config_path = config_path.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH));
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
))
}
/// Use the provided configuration to establish a connection to a MySQL server. /// Use the provided configuration to establish a connection to a MySQL server.
pub async fn create_mysql_connection_from_config( pub async fn create_mysql_connection_from_config(
config: MysqlConfig, config: &MysqlConfig,
) -> anyhow::Result<MySqlConnection> { ) -> anyhow::Result<MySqlConnection> {
match tokio::time::timeout( match tokio::time::timeout(
Duration::from_secs(config.timeout.unwrap_or(DEFAULT_TIMEOUT)), Duration::from_secs(config.timeout.unwrap_or(DEFAULT_TIMEOUT)),

View File

@ -1,888 +0,0 @@
//! Database privilege operations
//!
//! This module contains functions for querying, modifying,
//! displaying and comparing database privileges.
//!
//! A lot of the complexity comes from two core components:
//!
//! - The privilege editor that needs to be able to print
//! an editable table of privileges and reparse the content
//! after the user has made manual changes.
//!
//! - The comparison functionality that tells the user what
//! changes will be made when applying a set of changes
//! to the list of database privileges.
use std::collections::{BTreeSet, HashMap};
use anyhow::{anyhow, Context};
use indoc::indoc;
use itertools::Itertools;
use prettytable::Table;
use serde::{Deserialize, Serialize};
use sqlx::{mysql::MySqlRow, prelude::*, MySqlConnection};
use crate::core::{
common::{
create_user_group_matching_regex, get_current_unix_user, quote_identifier, rev_yn, yn,
},
database_operations::validate_database_name,
};
/// This is the list of fields that are used to fetch the db + user + privileges
/// from the `db` table in the database. If you need to add or remove privilege
/// fields, this is a good place to start.
pub const DATABASE_PRIVILEGE_FIELDS: [&str; 13] = [
"db",
"user",
"select_priv",
"insert_priv",
"update_priv",
"delete_priv",
"create_priv",
"drop_priv",
"alter_priv",
"index_priv",
"create_tmp_table_priv",
"lock_tables_priv",
"references_priv",
];
pub fn db_priv_field_human_readable_name(name: &str) -> String {
match name {
"db" => "Database".to_owned(),
"user" => "User".to_owned(),
"select_priv" => "Select".to_owned(),
"insert_priv" => "Insert".to_owned(),
"update_priv" => "Update".to_owned(),
"delete_priv" => "Delete".to_owned(),
"create_priv" => "Create".to_owned(),
"drop_priv" => "Drop".to_owned(),
"alter_priv" => "Alter".to_owned(),
"index_priv" => "Index".to_owned(),
"create_tmp_table_priv" => "Temp".to_owned(),
"lock_tables_priv" => "Lock".to_owned(),
"references_priv" => "References".to_owned(),
_ => format!("Unknown({})", name),
}
}
// NOTE: ord is needed for BTreeSet to accept the type, but it
// doesn't have any natural implementation semantics.
/// This struct represents the set of privileges for a single user on a single database.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
pub struct DatabasePrivilegeRow {
pub db: String,
pub user: String,
pub select_priv: bool,
pub insert_priv: bool,
pub update_priv: bool,
pub delete_priv: bool,
pub create_priv: bool,
pub drop_priv: bool,
pub alter_priv: bool,
pub index_priv: bool,
pub create_tmp_table_priv: bool,
pub lock_tables_priv: bool,
pub references_priv: bool,
}
impl DatabasePrivilegeRow {
pub fn empty(db: &str, user: &str) -> Self {
Self {
db: db.to_owned(),
user: user.to_owned(),
select_priv: false,
insert_priv: false,
update_priv: false,
delete_priv: false,
create_priv: false,
drop_priv: false,
alter_priv: false,
index_priv: false,
create_tmp_table_priv: false,
lock_tables_priv: false,
references_priv: false,
}
}
pub fn get_privilege_by_name(&self, name: &str) -> bool {
match name {
"select_priv" => self.select_priv,
"insert_priv" => self.insert_priv,
"update_priv" => self.update_priv,
"delete_priv" => self.delete_priv,
"create_priv" => self.create_priv,
"drop_priv" => self.drop_priv,
"alter_priv" => self.alter_priv,
"index_priv" => self.index_priv,
"create_tmp_table_priv" => self.create_tmp_table_priv,
"lock_tables_priv" => self.lock_tables_priv,
"references_priv" => self.references_priv,
_ => false,
}
}
pub fn diff(&self, other: &DatabasePrivilegeRow) -> DatabasePrivilegeRowDiff {
debug_assert!(self.db == other.db && self.user == other.user);
DatabasePrivilegeRowDiff {
db: self.db.clone(),
user: self.user.clone(),
diff: DATABASE_PRIVILEGE_FIELDS
.into_iter()
.skip(2)
.filter_map(|field| {
DatabasePrivilegeChange::new(
self.get_privilege_by_name(field),
other.get_privilege_by_name(field),
field,
)
})
.collect(),
}
}
}
#[inline]
fn get_mysql_row_priv_field(row: &MySqlRow, position: usize) -> Result<bool, sqlx::Error> {
let field = DATABASE_PRIVILEGE_FIELDS[position];
let value = row.try_get(position)?;
match rev_yn(value) {
Some(val) => Ok(val),
_ => {
log::warn!(r#"Invalid value for privilege "{}": '{}'"#, field, value);
Ok(false)
}
}
}
impl FromRow<'_, MySqlRow> for DatabasePrivilegeRow {
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
Ok(Self {
db: row.try_get("db")?,
user: row.try_get("user")?,
select_priv: get_mysql_row_priv_field(row, 2)?,
insert_priv: get_mysql_row_priv_field(row, 3)?,
update_priv: get_mysql_row_priv_field(row, 4)?,
delete_priv: get_mysql_row_priv_field(row, 5)?,
create_priv: get_mysql_row_priv_field(row, 6)?,
drop_priv: get_mysql_row_priv_field(row, 7)?,
alter_priv: get_mysql_row_priv_field(row, 8)?,
index_priv: get_mysql_row_priv_field(row, 9)?,
create_tmp_table_priv: get_mysql_row_priv_field(row, 10)?,
lock_tables_priv: get_mysql_row_priv_field(row, 11)?,
references_priv: get_mysql_row_priv_field(row, 12)?,
})
}
}
/// Get all users + privileges for a single database.
pub async fn get_database_privileges(
database_name: &str,
connection: &mut MySqlConnection,
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
let unix_user = get_current_unix_user()?;
validate_database_name(database_name, &unix_user)?;
let result = sqlx::query_as::<_, DatabasePrivilegeRow>(&format!(
"SELECT {} FROM `db` WHERE `db` = ?",
DATABASE_PRIVILEGE_FIELDS
.iter()
.map(|field| quote_identifier(field))
.join(","),
))
.bind(database_name)
.fetch_all(connection)
.await
.context("Failed to show database")?;
Ok(result)
}
/// Get all database + user + privileges pairs that are owned by the current user.
pub async fn get_all_database_privileges(
connection: &mut MySqlConnection,
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
let unix_user = get_current_unix_user()?;
let result = sqlx::query_as::<_, DatabasePrivilegeRow>(&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 ?)
"#},
DATABASE_PRIVILEGE_FIELDS
.iter()
.map(|field| format!("`{field}`"))
.join(","),
))
.bind(create_user_group_matching_regex(&unix_user))
.fetch_all(connection)
.await
.context("Failed to show databases")?;
Ok(result)
}
/*************************/
/* CLI INTERFACE PARSING */
/*************************/
/// See documentation for [`DatabaseCommand::EditDbPrivs`].
pub fn parse_privilege_table_cli_arg(arg: &str) -> anyhow::Result<DatabasePrivilegeRow> {
let parts: Vec<&str> = arg.split(':').collect();
if parts.len() != 3 {
anyhow::bail!("Invalid argument format. See `edit-db-privs --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 = DatabasePrivilegeRow {
db,
user,
select_priv: false,
insert_priv: false,
update_priv: false,
delete_priv: false,
create_priv: false,
drop_priv: false,
alter_priv: false,
index_priv: false,
create_tmp_table_priv: false,
lock_tables_priv: false,
references_priv: false,
};
for char in privs.chars() {
match char {
's' => result.select_priv = true,
'i' => result.insert_priv = true,
'u' => result.update_priv = true,
'd' => result.delete_priv = true,
'c' => result.create_priv = true,
'D' => result.drop_priv = true,
'a' => result.alter_priv = true,
'I' => result.index_priv = true,
't' => result.create_tmp_table_priv = true,
'l' => result.lock_tables_priv = true,
'r' => result.references_priv = true,
'A' => {
result.select_priv = true;
result.insert_priv = true;
result.update_priv = true;
result.delete_priv = true;
result.create_priv = true;
result.drop_priv = true;
result.alter_priv = true;
result.index_priv = true;
result.create_tmp_table_priv = true;
result.lock_tables_priv = true;
result.references_priv = true;
}
_ => anyhow::bail!("Invalid privilege character: {}", char),
}
}
Ok(result)
}
/**********************************/
/* EDITOR CONTENT DISPLAY/DISPLAY */
/**********************************/
/// Generates a single row of the privileges table for the editor.
pub fn format_privileges_line_for_editor(
privs: &DatabasePrivilegeRow,
username_len: usize,
database_name_len: usize,
) -> String {
DATABASE_PRIVILEGE_FIELDS
.into_iter()
.map(|field| match field {
"db" => format!("{:width$}", privs.db, width = database_name_len),
"user" => format!("{:width$}", privs.user, width = username_len),
privilege => format!(
"{:width$}",
yn(privs.get_privilege_by_name(privilege)),
width = db_priv_field_human_readable_name(privilege).len()
),
})
.join(" ")
.trim()
.to_string()
}
const EDITOR_COMMENT: &str = r#"
# Welcome to the privilege editor.
# Each line defines what privileges a single user has on a single database.
# The first two columns respectively represent the database name and the user, and the remaining columns are the privileges.
# If the user should have a certain privilege, write 'Y', otherwise write 'N'.
#
# Lines starting with '#' are comments and will be ignored.
"#;
/// Generates the content for the privilege editor.
///
/// The unix user is used in case there are no privileges to edit,
/// so that the user can see an example line based on their username.
pub fn generate_editor_content_from_privilege_data(
privilege_data: &[DatabasePrivilegeRow],
unix_user: &str,
) -> String {
let example_user = format!("{}_user", unix_user);
let example_db = format!("{}_db", unix_user);
// NOTE: `.max()`` fails when the iterator is empty.
// In this case, we know that the only fields in the
// editor will be the example user and example db name.
// Hence, it's put as the fallback value, despite not really
// being a "fallback" in the normal sense.
let longest_username = privilege_data
.iter()
.map(|p| p.user.len())
.max()
.unwrap_or(example_user.len());
let longest_database_name = privilege_data
.iter()
.map(|p| p.db.len())
.max()
.unwrap_or(example_db.len());
let mut header: Vec<_> = DATABASE_PRIVILEGE_FIELDS
.into_iter()
.map(db_priv_field_human_readable_name)
.collect();
// Pad the first two columns with spaces to align the privileges.
header[0] = format!("{:width$}", header[0], width = longest_database_name);
header[1] = format!("{:width$}", header[1], width = longest_username);
let example_line = format_privileges_line_for_editor(
&DatabasePrivilegeRow {
db: example_db,
user: example_user,
select_priv: true,
insert_priv: true,
update_priv: true,
delete_priv: true,
create_priv: false,
drop_priv: false,
alter_priv: false,
index_priv: false,
create_tmp_table_priv: false,
lock_tables_priv: false,
references_priv: false,
},
longest_username,
longest_database_name,
);
format!(
"{}\n{}\n{}",
EDITOR_COMMENT,
header.join(" "),
if privilege_data.is_empty() {
format!("# {}", example_line)
} else {
privilege_data
.iter()
.map(|privs| {
format_privileges_line_for_editor(
privs,
longest_username,
longest_database_name,
)
})
.join("\n")
}
)
}
#[derive(Debug)]
enum PrivilegeRowParseResult {
PrivilegeRow(DatabasePrivilegeRow),
ParserError(anyhow::Error),
TooFewFields(usize),
TooManyFields(usize),
Header,
Comment,
Empty,
}
#[inline]
fn parse_privilege_cell_from_editor(yn: &str, name: &str) -> anyhow::Result<bool> {
rev_yn(yn)
.ok_or_else(|| anyhow!("Expected Y or N, found {}", yn))
.context(format!("Could not parse {} privilege", name))
}
#[inline]
fn editor_row_is_header(row: &str) -> bool {
row.split_ascii_whitespace()
.zip(DATABASE_PRIVILEGE_FIELDS.iter())
.map(|(field, priv_name)| (field, db_priv_field_human_readable_name(priv_name)))
.all(|(field, header_field)| field == header_field)
}
/// Parse a single row of the privileges table from the editor.
fn parse_privilege_row_from_editor(row: &str) -> PrivilegeRowParseResult {
if row.starts_with('#') || row.starts_with("//") {
return PrivilegeRowParseResult::Comment;
}
if row.trim().is_empty() {
return PrivilegeRowParseResult::Empty;
}
let parts: Vec<&str> = row.trim().split_ascii_whitespace().collect();
match parts.len() {
n if (n < DATABASE_PRIVILEGE_FIELDS.len()) => {
return PrivilegeRowParseResult::TooFewFields(n)
}
n if (n > DATABASE_PRIVILEGE_FIELDS.len()) => {
return PrivilegeRowParseResult::TooManyFields(n)
}
_ => {}
}
if editor_row_is_header(row) {
return PrivilegeRowParseResult::Header;
}
let row = DatabasePrivilegeRow {
db: (*parts.first().unwrap()).to_owned(),
user: (*parts.get(1).unwrap()).to_owned(),
select_priv: match parse_privilege_cell_from_editor(
parts.get(2).unwrap(),
DATABASE_PRIVILEGE_FIELDS[2],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
insert_priv: match parse_privilege_cell_from_editor(
parts.get(3).unwrap(),
DATABASE_PRIVILEGE_FIELDS[3],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
update_priv: match parse_privilege_cell_from_editor(
parts.get(4).unwrap(),
DATABASE_PRIVILEGE_FIELDS[4],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
delete_priv: match parse_privilege_cell_from_editor(
parts.get(5).unwrap(),
DATABASE_PRIVILEGE_FIELDS[5],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
create_priv: match parse_privilege_cell_from_editor(
parts.get(6).unwrap(),
DATABASE_PRIVILEGE_FIELDS[6],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
drop_priv: match parse_privilege_cell_from_editor(
parts.get(7).unwrap(),
DATABASE_PRIVILEGE_FIELDS[7],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
alter_priv: match parse_privilege_cell_from_editor(
parts.get(8).unwrap(),
DATABASE_PRIVILEGE_FIELDS[8],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
index_priv: match parse_privilege_cell_from_editor(
parts.get(9).unwrap(),
DATABASE_PRIVILEGE_FIELDS[9],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
create_tmp_table_priv: match parse_privilege_cell_from_editor(
parts.get(10).unwrap(),
DATABASE_PRIVILEGE_FIELDS[10],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
lock_tables_priv: match parse_privilege_cell_from_editor(
parts.get(11).unwrap(),
DATABASE_PRIVILEGE_FIELDS[11],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
references_priv: match parse_privilege_cell_from_editor(
parts.get(12).unwrap(),
DATABASE_PRIVILEGE_FIELDS[12],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
};
PrivilegeRowParseResult::PrivilegeRow(row)
}
// TODO: return better errors
pub fn parse_privilege_data_from_editor_content(
content: String,
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
content
.trim()
.split('\n')
.map(|line| line.trim())
.map(parse_privilege_row_from_editor)
.map(|result| match result {
PrivilegeRowParseResult::PrivilegeRow(row) => Ok(Some(row)),
PrivilegeRowParseResult::ParserError(e) => Err(e),
PrivilegeRowParseResult::TooFewFields(n) => Err(anyhow!(
"Too few fields in line. Expected to find {} fields, found {}",
DATABASE_PRIVILEGE_FIELDS.len(),
n
)),
PrivilegeRowParseResult::TooManyFields(n) => Err(anyhow!(
"Too many fields in line. Expected to find {} fields, found {}",
DATABASE_PRIVILEGE_FIELDS.len(),
n
)),
PrivilegeRowParseResult::Header => Ok(None),
PrivilegeRowParseResult::Comment => Ok(None),
PrivilegeRowParseResult::Empty => Ok(None),
})
.filter_map(|result| result.transpose())
.collect::<anyhow::Result<Vec<DatabasePrivilegeRow>>>()
}
/*****************************/
/* CALCULATE PRIVILEGE DIFFS */
/*****************************/
/// This struct represents encapsulates the differences between two
/// instances of privilege sets for a single user on a single database.
///
/// The `User` and `Database` are the same for both instances.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, PartialOrd, Ord)]
pub struct DatabasePrivilegeRowDiff {
pub db: String,
pub user: String,
pub diff: BTreeSet<DatabasePrivilegeChange>,
}
/// This enum represents a change for a single privilege.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, PartialOrd, Ord)]
pub enum DatabasePrivilegeChange {
YesToNo(String),
NoToYes(String),
}
impl DatabasePrivilegeChange {
pub fn new(p1: bool, p2: bool, name: &str) -> Option<DatabasePrivilegeChange> {
match (p1, p2) {
(true, false) => Some(DatabasePrivilegeChange::YesToNo(name.to_owned())),
(false, true) => Some(DatabasePrivilegeChange::NoToYes(name.to_owned())),
_ => None,
}
}
}
/// This enum encapsulates whether a [`DatabasePrivilegeRow`] was intrduced, modified or deleted.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, PartialOrd, Ord)]
pub enum DatabasePrivilegesDiff {
New(DatabasePrivilegeRow),
Modified(DatabasePrivilegeRowDiff),
Deleted(DatabasePrivilegeRow),
}
/// This function calculates the differences between two sets of database privileges.
/// It returns a set of [`DatabasePrivilegesDiff`] that can be used to display or
/// apply a set of privilege modifications to the database.
pub fn diff_privileges(
from: &[DatabasePrivilegeRow],
to: &[DatabasePrivilegeRow],
) -> BTreeSet<DatabasePrivilegesDiff> {
let from_lookup_table: HashMap<(String, String), DatabasePrivilegeRow> = HashMap::from_iter(
from.iter()
.cloned()
.map(|p| ((p.db.clone(), p.user.clone()), p)),
);
let to_lookup_table: HashMap<(String, String), DatabasePrivilegeRow> = HashMap::from_iter(
to.iter()
.cloned()
.map(|p| ((p.db.clone(), p.user.clone()), p)),
);
let mut result = BTreeSet::new();
for p in to {
if let Some(old_p) = from_lookup_table.get(&(p.db.clone(), p.user.clone())) {
let diff = old_p.diff(p);
if !diff.diff.is_empty() {
result.insert(DatabasePrivilegesDiff::Modified(diff));
}
} else {
result.insert(DatabasePrivilegesDiff::New(p.clone()));
}
}
for p in from {
if !to_lookup_table.contains_key(&(p.db.clone(), p.user.clone())) {
result.insert(DatabasePrivilegesDiff::Deleted(p.clone()));
}
}
result
}
/// Uses the result of [`diff_privileges`] to modify privileges in the database.
pub async fn apply_privilege_diffs(
diffs: BTreeSet<DatabasePrivilegesDiff>,
connection: &mut MySqlConnection,
) -> anyhow::Result<()> {
for diff in diffs {
match diff {
DatabasePrivilegesDiff::New(p) => {
let tables = DATABASE_PRIVILEGE_FIELDS
.iter()
.map(|field| format!("`{field}`"))
.join(",");
let question_marks = std::iter::repeat("?")
.take(DATABASE_PRIVILEGE_FIELDS.len())
.join(",");
sqlx::query(
format!("INSERT INTO `db` ({}) VALUES ({})", tables, question_marks).as_str(),
)
.bind(p.db)
.bind(p.user)
.bind(yn(p.select_priv))
.bind(yn(p.insert_priv))
.bind(yn(p.update_priv))
.bind(yn(p.delete_priv))
.bind(yn(p.create_priv))
.bind(yn(p.drop_priv))
.bind(yn(p.alter_priv))
.bind(yn(p.index_priv))
.bind(yn(p.create_tmp_table_priv))
.bind(yn(p.lock_tables_priv))
.bind(yn(p.references_priv))
.execute(&mut *connection)
.await?;
}
DatabasePrivilegesDiff::Modified(p) => {
let tables = p
.diff
.iter()
.map(|diff| match diff {
DatabasePrivilegeChange::YesToNo(name) => format!("`{}` = 'N'", name),
DatabasePrivilegeChange::NoToYes(name) => format!("`{}` = 'Y'", name),
})
.join(",");
sqlx::query(
format!("UPDATE `db` SET {} WHERE `db` = ? AND `user` = ?", tables).as_str(),
)
.bind(p.db)
.bind(p.user)
.execute(&mut *connection)
.await?;
}
DatabasePrivilegesDiff::Deleted(p) => {
sqlx::query("DELETE FROM `db` WHERE `db` = ? AND `user` = ?")
.bind(p.db)
.bind(p.user)
.execute(&mut *connection)
.await?;
}
}
}
Ok(())
}
fn display_privilege_cell(diff: &DatabasePrivilegeRowDiff) -> String {
diff.diff
.iter()
.map(|change| match change {
DatabasePrivilegeChange::YesToNo(name) => {
format!("{}: Y -> N", db_priv_field_human_readable_name(name))
}
DatabasePrivilegeChange::NoToYes(name) => {
format!("{}: N -> Y", db_priv_field_human_readable_name(name))
}
})
.join("\n")
}
/// Displays the difference between two sets of database privileges.
pub fn display_privilege_diffs(diffs: &BTreeSet<DatabasePrivilegesDiff>) -> String {
let mut table = Table::new();
table.set_titles(row!["Database", "User", "Privilege diff",]);
for row in diffs {
match row {
DatabasePrivilegesDiff::New(p) => {
table.add_row(row![
p.db,
p.user,
"(New user)\n".to_string()
+ &display_privilege_cell(
&DatabasePrivilegeRow::empty(&p.db, &p.user).diff(p)
)
]);
}
DatabasePrivilegesDiff::Modified(p) => {
table.add_row(row![p.db, p.user, display_privilege_cell(p),]);
}
DatabasePrivilegesDiff::Deleted(p) => {
table.add_row(row![
p.db,
p.user,
"(All privileges removed)\n".to_string()
+ &display_privilege_cell(
&p.diff(&DatabasePrivilegeRow::empty(&p.db, &p.user))
)
]);
}
}
}
table.to_string()
}
/*********/
/* TESTS */
/*********/
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_database_privilege_change_creation() {
assert_eq!(
DatabasePrivilegeChange::new(true, false, "test"),
Some(DatabasePrivilegeChange::YesToNo("test".to_owned()))
);
assert_eq!(
DatabasePrivilegeChange::new(false, true, "test"),
Some(DatabasePrivilegeChange::NoToYes("test".to_owned()))
);
assert_eq!(DatabasePrivilegeChange::new(true, true, "test"), None);
assert_eq!(DatabasePrivilegeChange::new(false, false, "test"), None);
}
#[test]
fn test_diff_privileges() {
let row_to_be_modified = DatabasePrivilegeRow {
db: "db".to_owned(),
user: "user".to_owned(),
select_priv: true,
insert_priv: true,
update_priv: true,
delete_priv: true,
create_priv: true,
drop_priv: true,
alter_priv: true,
index_priv: false,
create_tmp_table_priv: true,
lock_tables_priv: true,
references_priv: false,
};
let mut row_to_be_deleted = row_to_be_modified.clone();
"user2".clone_into(&mut row_to_be_deleted.user);
let from = vec![row_to_be_modified.clone(), row_to_be_deleted.clone()];
let mut modified_row = row_to_be_modified.clone();
modified_row.select_priv = false;
modified_row.insert_priv = false;
modified_row.index_priv = true;
let mut new_row = row_to_be_modified.clone();
"user3".clone_into(&mut new_row.user);
let to = vec![modified_row.clone(), new_row.clone()];
let diffs = diff_privileges(&from, &to);
assert_eq!(
diffs,
BTreeSet::from_iter(vec![
DatabasePrivilegesDiff::Deleted(row_to_be_deleted),
DatabasePrivilegesDiff::Modified(DatabasePrivilegeRowDiff {
db: "db".to_owned(),
user: "user".to_owned(),
diff: BTreeSet::from_iter(vec![
DatabasePrivilegeChange::YesToNo("select_priv".to_owned()),
DatabasePrivilegeChange::YesToNo("insert_priv".to_owned()),
DatabasePrivilegeChange::NoToYes("index_priv".to_owned()),
]),
}),
DatabasePrivilegesDiff::New(new_row),
])
);
}
#[test]
fn ensure_generated_and_parsed_editor_content_is_equal() {
let permissions = vec![
DatabasePrivilegeRow {
db: "db".to_owned(),
user: "user".to_owned(),
select_priv: true,
insert_priv: true,
update_priv: true,
delete_priv: true,
create_priv: true,
drop_priv: true,
alter_priv: true,
index_priv: true,
create_tmp_table_priv: true,
lock_tables_priv: true,
references_priv: true,
},
DatabasePrivilegeRow {
db: "db2".to_owned(),
user: "user2".to_owned(),
select_priv: false,
insert_priv: false,
update_priv: false,
delete_priv: false,
create_priv: false,
drop_priv: false,
alter_priv: false,
index_priv: false,
create_tmp_table_priv: false,
lock_tables_priv: false,
references_priv: false,
},
];
let content = generate_editor_content_from_privilege_data(&permissions, "user");
let parsed_permissions = parse_privilege_data_from_editor_content(content).unwrap();
assert_eq!(permissions, parsed_permissions);
}
}

View File

@ -1,89 +0,0 @@
use futures_util::{SinkExt, StreamExt};
use sqlx::MySqlConnection;
use tokio::net::UnixStream;
use tokio_serde::{formats::Bincode, Framed as SerdeFramed};
use tokio_util::codec::{Framed, LengthDelimitedCodec};
// use crate::server::
use crate::server::protocol::{Request, Response};
use super::{
common::UnixUser, database_operations::{create_databases, drop_databases}, user_operations::{create_database_users, drop_database_users, set_password_for_database_user}
};
pub type ClientToServerMessageStream<'a> = SerdeFramed<
Framed<&'a mut UnixStream, LengthDelimitedCodec>,
Request,
Response,
Bincode<Request, Response>,
>;
pub async fn run_server(
socket: &mut UnixStream,
unix_user: &UnixUser,
db_connection: &mut MySqlConnection,
) -> anyhow::Result<()> {
let length_delimited = Framed::new(socket, LengthDelimitedCodec::new());
let mut stream: ClientToServerMessageStream =
tokio_serde::Framed::new(length_delimited, Bincode::default());
// TODO: better error handling
let request = match stream.next().await {
Some(Ok(request)) => request,
Some(Err(e)) => return Err(e.into()),
None => return Err(anyhow::anyhow!("No request received")),
};
match request {
Request::CreateDatabases(databases) => {
let result = create_databases(databases, unix_user, db_connection).await;
stream.send(Response::CreateDatabases(result)).await?;
stream.flush().await?;
}
Request::DropDatabases(databases) => {
let result = drop_databases(databases, unix_user, db_connection).await;
stream.send(Response::DropDatabases(result)).await?;
stream.flush().await?;
}
Request::ListDatabases => {
println!("Listing databases");
// let result = list_databases(unix_user, db_connection).await;
// stream.send(Response::ListDatabases(result)).await?;
// stream.flush().await?;
}
Request::ListPrivileges(users) => {
println!("Listing privileges for users: {:?}", users);
}
Request::ModifyPrivileges(()) => {
println!("Modifying privileges");
}
Request::CreateUsers(db_users) => {
let result = create_database_users(db_users, unix_user, db_connection).await;
stream.send(Response::CreateUsers(result)).await?;
stream.flush().await?;
}
Request::DropUsers(db_users) => {
let result = drop_database_users(db_users, unix_user, db_connection).await;
stream.send(Response::DropUsers(result)).await?;
stream.flush().await?;
}
Request::PasswdUser(db_user, password) => {
let result =
set_password_for_database_user(&db_user, &password, unix_user, db_connection).await;
stream.send(Response::PasswdUser(result)).await?;
stream.flush().await?;
}
Request::ListUsers(db_users) => {
println!("Listing users: {:?}", db_users);
}
Request::LockUsers(db_users) => {
println!("Locking users: {:?}", db_users);
}
Request::UnlockUsers(db_users) => {
println!("Unlocking users: {:?}", db_users);
}
}
Ok(())
}

View File

@ -1,16 +1,10 @@
use super::common::UnixUser; use crate::core::{
common::UnixUser,
use serde::{Deserialize, Serialize}; protocol::server_responses::{NameValidationError, OwnerValidationError},
};
const MAX_NAME_LENGTH: usize = 64; const MAX_NAME_LENGTH: usize = 64;
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
pub enum NameValidationError {
EmptyString,
InvalidCharacters,
TooLong,
}
pub fn validate_name(name: &str) -> Result<(), NameValidationError> { pub fn validate_name(name: &str) -> Result<(), NameValidationError> {
if name.is_empty() { if name.is_empty() {
Err(NameValidationError::EmptyString) Err(NameValidationError::EmptyString)
@ -26,45 +20,6 @@ pub fn validate_name(name: &str) -> Result<(), NameValidationError> {
} }
} }
// TODO: move to cli
// pub fn validate_name_or_error(name: &str, db_or_user: DbOrUser) -> anyhow::Result<()> {
// match validate_name(name) {
// NameValidationError::Valid => Ok(()),
// NameValidationError::EmptyString => {
// anyhow::bail!("{} name cannot be empty.", db_or_user.capitalized())
// }
// NameValidationError::TooLong => anyhow::bail!(
// "{} is too long. Maximum length is 64 characters.",
// db_or_user.capitalized()
// ),
// NameValidationError::InvalidCharacters => anyhow::bail!(
// indoc! {r#"
// Invalid characters in {} name: '{}'
// Only A-Z, a-z, 0-9, _ (underscore) and - (dash) are permitted.
// "#},
// db_or_user.lowercased(),
// name
// ),
// }
// }
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
pub enum OwnerValidationError {
// The name is valid, but none of the given prefixes matched the name
NoMatch,
// The name is empty, which is invalid
StringEmpty,
// The name is in the format "_<postfix>", which is invalid
MissingPrefix,
// The name is in the format "<prefix>_", which is invalid
MissingPostfix,
}
pub fn validate_ownership_by_unix_user( pub fn validate_ownership_by_unix_user(
name: &str, name: &str,
user: &UnixUser, user: &UnixUser,
@ -104,54 +59,6 @@ pub fn validate_ownership_by_prefixes(
Ok(()) Ok(())
} }
// TODO: move to cli
/// Validate the ownership of a database name or database user name.
/// This function takes the name of a database or user and a unix user,
/// for which it fetches the user's groups. It then checks if the name
/// is prefixed with the user's username or any of the user's groups.
// pub fn validate_ownership_or_error<'a>(
// name: &'a str,
// user: &User,
// db_or_user: DbOrUser,
// ) -> anyhow::Result<&'a str> {
// let user_groups = get_unix_groups(user)?;
// let prefixes = std::iter::once(user.name.clone())
// .chain(user_groups.iter().map(|g| g.name.clone()))
// .collect::<Vec<String>>();
// match validate_ownership_by_prefixes(name, &prefixes) {
// OwnerValidationResult::Match => Ok(name),
// OwnerValidationResult::NoMatch => {
// anyhow::bail!(
// indoc! {r#"
// Invalid {} name prefix: '{}' does not match your username or any of your groups.
// Are you sure you are allowed to create {} names with this prefix?
// Allowed prefixes:
// - {}
// {}
// "#},
// db_or_user.lowercased(),
// name,
// db_or_user.lowercased(),
// user.name,
// user_groups
// .iter()
// .filter(|g| g.name != user.name)
// .map(|g| format!(" - {}", g.name))
// .sorted()
// .join("\n"),
// );
// }
// _ => anyhow::bail!(
// "'{}' is not a valid {} name.",
// name,
// db_or_user.lowercased()
// ),
// }
// }
#[inline] #[inline]
pub fn quote_literal(s: &str) -> String { pub fn quote_literal(s: &str) -> String {
format!("'{}'", s.replace('\'', r"\'")) format!("'{}'", s.replace('\'', r"\'"))

View File

@ -1,45 +0,0 @@
use serde::{Deserialize, Serialize};
use super::{database_operations::{CreateDatabasesOutput, DropDatabasesOutput}, user_operations::{
CreateUsersOutput, DropUsersOutput, LockUsersOutput, SetPasswordOutput, UnlockUsersOutput,
}};
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Request {
CreateDatabases(Vec<String>),
DropDatabases(Vec<String>),
ListDatabases,
ListPrivileges(Vec<String>),
ModifyPrivileges(()), // what data should be sent with this command? Who should calculate the diff?
CreateUsers(Vec<String>),
DropUsers(Vec<String>),
PasswdUser(String, String),
ListUsers(Option<Vec<String>>),
LockUsers(Vec<String>),
UnlockUsers(Vec<String>),
}
// TODO: include a generic "message" that will display a message to the user?
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Response {
// Specific data for specific commands
CreateDatabases(CreateDatabasesOutput),
DropDatabases(DropDatabasesOutput),
// ListDatabases(ListDatabasesOutput),
// ListPrivileges(ListPrivilegesOutput),
CreateUsers(CreateUsersOutput),
DropUsers(DropUsersOutput),
PasswdUser(SetPasswordOutput),
ListUsers(()), // what data should be sent with this response?
LockUsers(LockUsersOutput),
UnlockUsers(UnlockUsersOutput),
// Generic responses
OperationAborted,
Error(String),
Exit,
}

229
src/server/server_loop.rs Normal file
View File

@ -0,0 +1,229 @@
use std::{collections::BTreeSet, fs, path::PathBuf};
use anyhow::Context;
use futures_util::{SinkExt, StreamExt};
use tokio::io::AsyncWriteExt;
use tokio::net::{UnixListener, UnixStream};
use sqlx::prelude::*;
use sqlx::MySqlConnection;
use crate::{
core::{
bootstrap::authenticated_unix_socket,
common::{UnixUser, DEFAULT_SOCKET_PATH},
protocol::request_response::{
create_server_to_client_message_stream, Request, Response, ServerToClientMessageStream,
},
},
server::{
config::{create_mysql_connection_from_config, ServerConfig},
sql::{
database_operations::{create_databases, drop_databases, list_databases_for_user},
database_privilege_operations::{
apply_privilege_diffs, get_all_database_privileges, get_databases_privilege_data,
},
user_operations::{
create_database_users, drop_database_users, list_all_database_users_for_unix_user,
list_database_users, lock_database_users, set_password_for_database_user,
unlock_database_users,
},
},
},
};
// TODO: consider using a connection pool
// TODO: use tracing for login, so we can scope the log messages per incoming connection
pub async fn listen_for_incoming_connections(
socket_path: Option<PathBuf>,
config: ServerConfig,
// db_connection: &mut MySqlConnection,
) -> anyhow::Result<()> {
let socket_path = socket_path.unwrap_or(PathBuf::from(DEFAULT_SOCKET_PATH));
let parent_directory = socket_path.parent().unwrap();
if !parent_directory.exists() {
println!("Creating directory {:?}", parent_directory);
fs::create_dir_all(parent_directory)?;
}
println!("Listening on {:?}", socket_path);
match fs::remove_file(socket_path.as_path()) {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
let listener = UnixListener::bind(socket_path)?;
while let Ok((mut conn, _addr)) = listener.accept().await {
let uid = match authenticated_unix_socket::server_authenticate(&mut conn).await {
Ok(uid) => uid,
Err(e) => {
eprintln!("Failed to authenticate client: {}", e);
conn.shutdown().await?;
continue;
}
};
let unix_user = match UnixUser::from_uid(uid.into()) {
Ok(user) => user,
Err(e) => {
eprintln!("Failed to get UnixUser from uid: {}", e);
conn.shutdown().await?;
continue;
}
};
match handle_requests_for_single_session(conn, &unix_user, &config).await {
Ok(_) => {}
Err(e) => {
eprintln!("Failed to run server: {}", e);
}
}
}
Ok(())
}
pub async fn handle_requests_for_single_session(
socket: UnixStream,
unix_user: &UnixUser,
config: &ServerConfig,
) -> anyhow::Result<()> {
let message_stream = create_server_to_client_message_stream(socket);
let mut db_connection = create_mysql_connection_from_config(&config.mysql).await?;
let result = handle_requests_for_single_session_with_db_connection(
message_stream,
unix_user,
&mut db_connection,
)
.await;
if let Err(e) = db_connection
.close()
.await
.context("Failed to close connection properly")
{
eprintln!("{}", e);
eprintln!("Ignoring...");
}
result
}
// TODO: ensure proper db_connection hygiene for functions that invoke
// this function
pub async fn handle_requests_for_single_session_with_db_connection(
mut stream: ServerToClientMessageStream,
unix_user: &UnixUser,
db_connection: &mut MySqlConnection,
) -> anyhow::Result<()> {
loop {
// TODO: better error handling
let request = match stream.next().await {
Some(Ok(request)) => request,
Some(Err(e)) => return Err(e.into()),
None => {
log::warn!("Client disconnected without sending an exit message");
break;
}
};
match request {
Request::CreateDatabases(databases_names) => {
let result = create_databases(databases_names, unix_user, db_connection).await;
stream.send(Response::CreateDatabases(result)).await?;
stream.flush().await?;
}
Request::DropDatabases(databases_names) => {
let result = drop_databases(databases_names, unix_user, db_connection).await;
stream.send(Response::DropDatabases(result)).await?;
stream.flush().await?;
}
Request::ListDatabases => {
let result = list_databases_for_user(unix_user, db_connection).await;
stream.send(Response::ListAllDatabases(result)).await?;
stream.flush().await?;
}
Request::ListPrivileges(database_names) => {
let response = match database_names {
Some(database_names) => {
let privilege_data =
get_databases_privilege_data(database_names, unix_user, db_connection)
.await;
Response::ListPrivileges(privilege_data)
}
None => {
let privilege_data =
get_all_database_privileges(unix_user, db_connection).await;
Response::ListAllPrivileges(privilege_data)
}
};
stream.send(response).await?;
stream.flush().await?;
}
Request::ModifyPrivileges(database_privilege_diffs) => {
let result = apply_privilege_diffs(
BTreeSet::from_iter(database_privilege_diffs),
unix_user,
db_connection,
)
.await;
stream.send(Response::ModifyPrivileges(result)).await?;
stream.flush().await?;
}
Request::CreateUsers(db_users) => {
let result = create_database_users(db_users, unix_user, db_connection).await;
stream.send(Response::CreateUsers(result)).await?;
stream.flush().await?;
}
Request::DropUsers(db_users) => {
let result = drop_database_users(db_users, unix_user, db_connection).await;
stream.send(Response::DropUsers(result)).await?;
stream.flush().await?;
}
Request::PasswdUser(db_user, password) => {
let result =
set_password_for_database_user(&db_user, &password, unix_user, db_connection)
.await;
stream.send(Response::PasswdUser(result)).await?;
stream.flush().await?;
}
Request::ListUsers(db_users) => {
let response = match db_users {
Some(db_users) => {
let result = list_database_users(db_users, unix_user, db_connection).await;
Response::ListUsers(result)
}
None => {
let result =
list_all_database_users_for_unix_user(unix_user, db_connection).await;
Response::ListAllUsers(result)
}
};
stream.send(response).await?;
stream.flush().await?;
}
Request::LockUsers(db_users) => {
let result = lock_database_users(db_users, unix_user, db_connection).await;
stream.send(Response::LockUsers(result)).await?;
stream.flush().await?;
}
Request::UnlockUsers(db_users) => {
let result = unlock_database_users(db_users, unix_user, db_connection).await;
stream.send(Response::UnlockUsers(result)).await?;
stream.flush().await?;
}
Request::Exit => {
break;
}
}
}
Ok(())
}

3
src/server/sql.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod database_operations;
pub mod database_privilege_operations;
pub mod user_operations;

View File

@ -1,39 +1,36 @@
use crate::server::common::UnixUser; use crate::{
use crate::server::input_sanitization::quote_identifier; core::{
use crate::server::input_sanitization::{validate_name, validate_ownership_by_unix_user}; common::UnixUser,
use crate::server::input_sanitization::{NameValidationError, OwnerValidationError}; protocol::{
CreateDatabaseError, CreateDatabasesOutput, DropDatabaseError, DropDatabasesOutput,
ListDatabasesError,
},
},
server::{
common::create_user_group_matching_regex,
input_sanitization::{quote_identifier, validate_name, validate_ownership_by_unix_user},
},
};
use serde::{Deserialize, Serialize};
use sqlx::prelude::*; use sqlx::prelude::*;
use sqlx::MySqlConnection; use sqlx::MySqlConnection;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use super::common::create_user_group_matching_regex;
// NOTE: this function is unsafe because it does no input validation. // NOTE: this function is unsafe because it does no input validation.
async fn unsafe_database_exists( pub(super) async fn unsafe_database_exists(
db_name: &str, database_name: &str,
connection: &mut MySqlConnection, connection: &mut MySqlConnection,
) -> Result<bool, sqlx::Error> { ) -> Result<bool, sqlx::Error> {
let result = let result =
sqlx::query("SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?") sqlx::query("SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?")
.bind(db_name) .bind(database_name)
.fetch_optional(connection) .fetch_optional(connection)
.await?; .await?;
Ok(result.is_some()) Ok(result.is_some())
} }
pub type CreateDatabasesOutput = BTreeMap<String, Result<(), CreateDatabaseError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CreateDatabaseError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
DatabaseAlreadyExists,
MySqlError(String),
}
pub async fn create_databases( pub async fn create_databases(
database_names: Vec<String>, database_names: Vec<String>,
unix_user: &UnixUser, unix_user: &UnixUser,
@ -89,15 +86,6 @@ pub async fn create_databases(
results results
} }
pub type DropDatabasesOutput = BTreeMap<String, Result<(), DropDatabaseError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DropDatabaseError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
DatabaseDoesNotExist,
MySqlError(String),
}
pub async fn drop_databases( pub async fn drop_databases(
database_names: Vec<String>, database_names: Vec<String>,
unix_user: &UnixUser, unix_user: &UnixUser,
@ -153,12 +141,6 @@ pub async fn drop_databases(
results results
} }
pub type ListDatabasesOutput = Result<Vec<String>, ListDatabasesError>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ListDatabasesError {
MySqlError(String),
}
pub async fn list_databases_for_user( pub async fn list_databases_for_user(
unix_user: &UnixUser, unix_user: &UnixUser,
connection: &mut MySqlConnection, connection: &mut MySqlConnection,
@ -180,4 +162,4 @@ pub async fn list_databases_for_user(
.collect::<Result<Vec<String>, sqlx::Error>>() .collect::<Result<Vec<String>, sqlx::Error>>()
}) })
.map_err(|err| ListDatabasesError::MySqlError(err.to_string())) .map_err(|err| ListDatabasesError::MySqlError(err.to_string()))
} }

View File

@ -0,0 +1,470 @@
// TODO: fix comment
//! Database privilege operations
//!
//! This module contains functions for querying, modifying,
//! displaying and comparing database privileges.
//!
//! A lot of the complexity comes from two core components:
//!
//! - The privilege editor that needs to be able to print
//! an editable table of privileges and reparse the content
//! after the user has made manual changes.
//!
//! - The comparison functionality that tells the user what
//! changes will be made when applying a set of changes
//! to the list of database privileges.
use std::collections::{BTreeMap, BTreeSet};
use indoc::indoc;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use sqlx::{mysql::MySqlRow, prelude::*, MySqlConnection};
use crate::{
core::{
common::{rev_yn, yn, UnixUser},
database_privileges::{DatabasePrivilegeChange, DatabasePrivilegesDiff},
protocol::{
DiffDoesNotApplyError, GetAllDatabasesPrivilegeData, GetAllDatabasesPrivilegeDataError,
GetDatabasesPrivilegeData, GetDatabasesPrivilegeDataError,
ModifyDatabasePrivilegesError, ModifyDatabasePrivilegesOutput,
},
},
server::{
common::create_user_group_matching_regex,
input_sanitization::{quote_identifier, validate_name, validate_ownership_by_unix_user},
sql::database_operations::unsafe_database_exists,
},
};
/// This is the list of fields that are used to fetch the db + user + privileges
/// from the `db` table in the database. If you need to add or remove privilege
/// fields, this is a good place to start.
pub const DATABASE_PRIVILEGE_FIELDS: [&str; 13] = [
"db",
"user",
"select_priv",
"insert_priv",
"update_priv",
"delete_priv",
"create_priv",
"drop_priv",
"alter_priv",
"index_priv",
"create_tmp_table_priv",
"lock_tables_priv",
"references_priv",
];
// NOTE: ord is needed for BTreeSet to accept the type, but it
// doesn't have any natural implementation semantics.
/// This struct represents the set of privileges for a single user on a single database.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
pub struct DatabasePrivilegeRow {
pub db: String,
pub user: String,
pub select_priv: bool,
pub insert_priv: bool,
pub update_priv: bool,
pub delete_priv: bool,
pub create_priv: bool,
pub drop_priv: bool,
pub alter_priv: bool,
pub index_priv: bool,
pub create_tmp_table_priv: bool,
pub lock_tables_priv: bool,
pub references_priv: bool,
}
impl DatabasePrivilegeRow {
pub fn empty(db: &str, user: &str) -> Self {
Self {
db: db.to_owned(),
user: user.to_owned(),
select_priv: false,
insert_priv: false,
update_priv: false,
delete_priv: false,
create_priv: false,
drop_priv: false,
alter_priv: false,
index_priv: false,
create_tmp_table_priv: false,
lock_tables_priv: false,
references_priv: false,
}
}
pub fn get_privilege_by_name(&self, name: &str) -> bool {
match name {
"select_priv" => self.select_priv,
"insert_priv" => self.insert_priv,
"update_priv" => self.update_priv,
"delete_priv" => self.delete_priv,
"create_priv" => self.create_priv,
"drop_priv" => self.drop_priv,
"alter_priv" => self.alter_priv,
"index_priv" => self.index_priv,
"create_tmp_table_priv" => self.create_tmp_table_priv,
"lock_tables_priv" => self.lock_tables_priv,
"references_priv" => self.references_priv,
_ => false,
}
}
}
#[inline]
fn get_mysql_row_priv_field(row: &MySqlRow, position: usize) -> Result<bool, sqlx::Error> {
let field = DATABASE_PRIVILEGE_FIELDS[position];
let value = row.try_get(position)?;
match rev_yn(value) {
Some(val) => Ok(val),
_ => {
log::warn!(r#"Invalid value for privilege "{}": '{}'"#, field, value);
Ok(false)
}
}
}
impl FromRow<'_, MySqlRow> for DatabasePrivilegeRow {
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
Ok(Self {
db: row.try_get("db")?,
user: row.try_get("user")?,
select_priv: get_mysql_row_priv_field(row, 2)?,
insert_priv: get_mysql_row_priv_field(row, 3)?,
update_priv: get_mysql_row_priv_field(row, 4)?,
delete_priv: get_mysql_row_priv_field(row, 5)?,
create_priv: get_mysql_row_priv_field(row, 6)?,
drop_priv: get_mysql_row_priv_field(row, 7)?,
alter_priv: get_mysql_row_priv_field(row, 8)?,
index_priv: get_mysql_row_priv_field(row, 9)?,
create_tmp_table_priv: get_mysql_row_priv_field(row, 10)?,
lock_tables_priv: get_mysql_row_priv_field(row, 11)?,
references_priv: get_mysql_row_priv_field(row, 12)?,
})
}
}
// NOTE: this function is unsafe because it does no input validation.
/// Get all users + privileges for a single database.
async fn unsafe_get_database_privileges(
database_name: &str,
connection: &mut MySqlConnection,
) -> Result<Vec<DatabasePrivilegeRow>, sqlx::Error> {
sqlx::query_as::<_, DatabasePrivilegeRow>(&format!(
"SELECT {} FROM `db` WHERE `db` = ?",
DATABASE_PRIVILEGE_FIELDS
.iter()
.map(|field| quote_identifier(field))
.join(","),
))
.bind(database_name)
.fetch_all(connection)
.await
}
// NOTE: this function is unsafe because it does no input validation.
/// Get all users + privileges for a single database-user pair.
pub async fn unsafe_get_database_privileges_for_db_user_pair(
database_name: &str,
user_name: &str,
connection: &mut MySqlConnection,
) -> Result<Option<DatabasePrivilegeRow>, sqlx::Error> {
sqlx::query_as::<_, DatabasePrivilegeRow>(&format!(
"SELECT {} FROM `db` WHERE `db` = ? AND `user` = ?",
DATABASE_PRIVILEGE_FIELDS
.iter()
.map(|field| quote_identifier(field))
.join(","),
))
.bind(database_name)
.bind(user_name)
.fetch_optional(connection)
.await
}
pub async fn get_databases_privilege_data(
database_names: Vec<String>,
unix_user: &UnixUser,
connection: &mut MySqlConnection,
) -> GetDatabasesPrivilegeData {
let mut results = BTreeMap::new();
for database_name in database_names.iter() {
if let Err(err) = validate_name(database_name) {
results.insert(
database_name.clone(),
Err(GetDatabasesPrivilegeDataError::SanitizationError(err)),
);
continue;
}
if let Err(err) = validate_ownership_by_unix_user(database_name, unix_user) {
results.insert(
database_name.clone(),
Err(GetDatabasesPrivilegeDataError::OwnershipError(err)),
);
continue;
}
if !unsafe_database_exists(database_name, connection)
.await
.unwrap()
{
results.insert(
database_name.clone(),
Err(GetDatabasesPrivilegeDataError::DatabaseDoesNotExist),
);
continue;
}
let result = unsafe_get_database_privileges(database_name, connection)
.await
.map_err(|e| GetDatabasesPrivilegeDataError::MySqlError(e.to_string()));
results.insert(database_name.clone(), result);
}
debug_assert!(database_names.len() == results.len());
results
}
/// Get all database + user + privileges pairs that are owned by the current user.
pub async fn get_all_database_privileges(
unix_user: &UnixUser,
connection: &mut MySqlConnection,
) -> GetAllDatabasesPrivilegeData {
sqlx::query_as::<_, DatabasePrivilegeRow>(&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 ?)
"#},
DATABASE_PRIVILEGE_FIELDS
.iter()
.map(|field| quote_identifier(field))
.join(","),
))
.bind(create_user_group_matching_regex(unix_user))
.fetch_all(connection)
.await
.map_err(|e| GetAllDatabasesPrivilegeDataError::MySqlError(e.to_string()))
}
async fn unsafe_apply_privilege_diff(
database_privilege_diff: &DatabasePrivilegesDiff,
connection: &mut MySqlConnection,
) -> Result<(), sqlx::Error> {
match database_privilege_diff {
DatabasePrivilegesDiff::New(p) => {
let tables = DATABASE_PRIVILEGE_FIELDS
.iter()
.map(|field| quote_identifier(field))
.join(",");
let question_marks = std::iter::repeat("?")
.take(DATABASE_PRIVILEGE_FIELDS.len())
.join(",");
sqlx::query(
format!("INSERT INTO `db` ({}) VALUES ({})", tables, question_marks).as_str(),
)
.bind(p.db.to_string())
.bind(p.user.to_string())
.bind(yn(p.select_priv))
.bind(yn(p.insert_priv))
.bind(yn(p.update_priv))
.bind(yn(p.delete_priv))
.bind(yn(p.create_priv))
.bind(yn(p.drop_priv))
.bind(yn(p.alter_priv))
.bind(yn(p.index_priv))
.bind(yn(p.create_tmp_table_priv))
.bind(yn(p.lock_tables_priv))
.bind(yn(p.references_priv))
.execute(connection)
.await
.map(|_| ())
}
DatabasePrivilegesDiff::Modified(p) => {
let changes = p
.diff
.iter()
.map(|diff| match diff {
DatabasePrivilegeChange::YesToNo(name) => {
format!("{} = 'N'", quote_identifier(name))
}
DatabasePrivilegeChange::NoToYes(name) => {
format!("{} = 'Y'", quote_identifier(name))
}
})
.join(",");
sqlx::query(
format!("UPDATE `db` SET {} WHERE `db` = ? AND `user` = ?", changes).as_str(),
)
.bind(p.db.to_string())
.bind(p.user.to_string())
.execute(connection)
.await
.map(|_| ())
}
DatabasePrivilegesDiff::Deleted(p) => {
sqlx::query("DELETE FROM `db` WHERE `db` = ? AND `user` = ?")
.bind(p.db.to_string())
.bind(p.user.to_string())
.execute(connection)
.await
.map(|_| ())
}
}
}
async fn validate_diff(
diff: &DatabasePrivilegesDiff,
connection: &mut MySqlConnection,
) -> Result<(), ModifyDatabasePrivilegesError> {
let privilege_row = unsafe_get_database_privileges_for_db_user_pair(
diff.get_database_name(),
diff.get_user_name(),
connection,
)
.await;
let privilege_row = match privilege_row {
Ok(privilege_row) => privilege_row,
Err(e) => return Err(ModifyDatabasePrivilegesError::MySqlError(e.to_string())),
};
let result = match diff {
DatabasePrivilegesDiff::New(_) => {
if privilege_row.is_some() {
Err(ModifyDatabasePrivilegesError::DiffDoesNotApply(
DiffDoesNotApplyError::RowAlreadyExists(
diff.get_user_name().to_string(),
diff.get_database_name().to_string(),
),
))
} else {
Ok(())
}
}
DatabasePrivilegesDiff::Modified(_) if privilege_row.is_none() => {
Err(ModifyDatabasePrivilegesError::DiffDoesNotApply(
DiffDoesNotApplyError::RowDoesNotExist(
diff.get_user_name().to_string(),
diff.get_database_name().to_string(),
),
))
}
DatabasePrivilegesDiff::Modified(row_diff) => {
let row = privilege_row.unwrap();
let error_exists = row_diff.diff.iter().any(|change| match change {
DatabasePrivilegeChange::YesToNo(name) => !row.get_privilege_by_name(name),
DatabasePrivilegeChange::NoToYes(name) => row.get_privilege_by_name(name),
});
if error_exists {
Err(ModifyDatabasePrivilegesError::DiffDoesNotApply(
DiffDoesNotApplyError::RowPrivilegeChangeDoesNotApply(row_diff.clone(), row),
))
} else {
Ok(())
}
}
DatabasePrivilegesDiff::Deleted(_) => {
if privilege_row.is_none() {
Err(ModifyDatabasePrivilegesError::DiffDoesNotApply(
DiffDoesNotApplyError::RowDoesNotExist(
diff.get_user_name().to_string(),
diff.get_database_name().to_string(),
),
))
} else {
Ok(())
}
}
};
result
}
/// Uses the result of [`diff_privileges`] to modify privileges in the database.
pub async fn apply_privilege_diffs(
database_privilege_diffs: BTreeSet<DatabasePrivilegesDiff>,
unix_user: &UnixUser,
connection: &mut MySqlConnection,
) -> ModifyDatabasePrivilegesOutput {
let mut results: BTreeMap<(String, String), _> = BTreeMap::new();
for diff in database_privilege_diffs {
let key = (
diff.get_database_name().to_string(),
diff.get_user_name().to_string(),
);
if let Err(err) = validate_name(diff.get_database_name()) {
results.insert(
key,
Err(ModifyDatabasePrivilegesError::DatabaseSanitizationError(
err,
)),
);
continue;
}
if let Err(err) = validate_ownership_by_unix_user(diff.get_database_name(), unix_user) {
results.insert(
key,
Err(ModifyDatabasePrivilegesError::DatabaseOwnershipError(err)),
);
continue;
}
if let Err(err) = validate_name(diff.get_user_name()) {
results.insert(
key,
Err(ModifyDatabasePrivilegesError::UserSanitizationError(err)),
);
continue;
}
if let Err(err) = validate_ownership_by_unix_user(diff.get_user_name(), unix_user) {
results.insert(
key,
Err(ModifyDatabasePrivilegesError::UserOwnershipError(err)),
);
continue;
}
if !unsafe_database_exists(diff.get_database_name(), connection)
.await
.unwrap()
{
results.insert(
key,
Err(ModifyDatabasePrivilegesError::DatabaseDoesNotExist),
);
continue;
}
if let Err(err) = validate_diff(&diff, connection).await {
results.insert(key, Err(err));
continue;
}
let result = unsafe_apply_privilege_diff(&diff, connection)
.await
.map_err(|e| ModifyDatabasePrivilegesError::MySqlError(e.to_string()));
results.insert(key, result);
}
results
}

View File

@ -5,11 +5,19 @@ use serde::{Deserialize, Serialize};
use sqlx::prelude::*; use sqlx::prelude::*;
use sqlx::MySqlConnection; use sqlx::MySqlConnection;
use super::common::create_user_group_matching_regex; use crate::{
use super::common::UnixUser; core::{
use super::input_sanitization::{ common::UnixUser,
quote_literal, validate_name, validate_ownership_by_unix_user, NameValidationError, protocol::{
OwnerValidationError, CreateUserError, CreateUsersOutput, DropUserError, DropUsersOutput, ListAllUsersError,
ListAllUsersOutput, ListUsersError, ListUsersOutput, LockUserError, LockUsersOutput,
SetPasswordError, SetPasswordOutput, UnlockUserError, UnlockUsersOutput,
},
},
server::{
common::create_user_group_matching_regex,
input_sanitization::{quote_literal, validate_name, validate_ownership_by_unix_user},
},
}; };
// NOTE: this function is unsafe because it does no input validation. // NOTE: this function is unsafe because it does no input validation.
@ -32,15 +40,6 @@ async fn unsafe_user_exists(
.map(|row| row.get::<bool, _>(0)) .map(|row| row.get::<bool, _>(0))
} }
pub type CreateUsersOutput = BTreeMap<String, Result<(), CreateUserError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CreateUserError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
UserAlreadyExists,
MySqlError(String),
}
pub async fn create_database_users( pub async fn create_database_users(
db_users: Vec<String>, db_users: Vec<String>,
unix_user: &UnixUser, unix_user: &UnixUser,
@ -83,15 +82,6 @@ pub async fn create_database_users(
results results
} }
pub type DropUsersOutput = BTreeMap<String, Result<(), DropUserError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DropUserError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
UserDoesNotExist,
MySqlError(String),
}
pub async fn drop_database_users( pub async fn drop_database_users(
db_users: Vec<String>, db_users: Vec<String>,
unix_user: &UnixUser, unix_user: &UnixUser,
@ -134,15 +124,6 @@ pub async fn drop_database_users(
results results
} }
pub type SetPasswordOutput = Result<(), SetPasswordError>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SetPasswordError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
UserDoesNotExist,
MySqlError(String),
}
pub async fn set_password_for_database_user( pub async fn set_password_for_database_user(
db_user: &str, db_user: &str,
password: &str, password: &str,
@ -196,16 +177,6 @@ async fn database_user_is_locked_unsafe(
.map(|row| row.get::<bool, _>(0)) .map(|row| row.get::<bool, _>(0))
} }
pub type LockUsersOutput = BTreeMap<String, Result<(), LockUserError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum LockUserError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
UserDoesNotExist,
UserIsAlreadyLocked,
MySqlError(String),
}
pub async fn lock_database_users( pub async fn lock_database_users(
db_users: Vec<String>, db_users: Vec<String>,
unix_user: &UnixUser, unix_user: &UnixUser,
@ -262,17 +233,7 @@ pub async fn lock_database_users(
results results
} }
pub type UnlockUsersOutput = BTreeMap<String, Result<(), UnlockUserError>>; pub async fn unlock_database_users(
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum UnlockUserError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
UserDoesNotExist,
UserIsAlreadyUnlocked,
MySqlError(String),
}
pub async fn unlock_database_user(
db_users: Vec<String>, db_users: Vec<String>,
unix_user: &UnixUser, unix_user: &UnixUser,
connection: &mut MySqlConnection, connection: &mut MySqlConnection,
@ -362,34 +323,50 @@ JOIN `mysql`.`global_priv` ON
AND `mysql`.`user`.`Host` = `mysql`.`global_priv`.`Host` AND `mysql`.`user`.`Host` = `mysql`.`global_priv`.`Host`
"#; "#;
pub async fn get_all_database_users_for_unix_user( pub async fn list_database_users(
db_users: Vec<String>,
unix_user: &UnixUser, unix_user: &UnixUser,
connection: &mut MySqlConnection, connection: &mut MySqlConnection,
) -> Result<Vec<DatabaseUser>, sqlx::Error> { ) -> ListUsersOutput {
let mut results = BTreeMap::new();
for db_user in db_users {
if let Err(err) = validate_name(&db_user) {
results.insert(db_user, Err(ListUsersError::SanitizationError(err)));
continue;
}
if let Err(err) = validate_ownership_by_unix_user(&db_user, unix_user) {
results.insert(db_user, Err(ListUsersError::OwnershipError(err)));
continue;
}
let result = sqlx::query_as::<_, DatabaseUser>(
&(DB_USER_SELECT_STATEMENT.to_string() + "WHERE `mysql`.`user`.`User` = ?"),
)
.bind(&db_user)
.fetch_optional(&mut *connection)
.await;
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,
) -> ListAllUsersOutput {
sqlx::query_as::<_, DatabaseUser>( sqlx::query_as::<_, DatabaseUser>(
&(DB_USER_SELECT_STATEMENT.to_string() + "WHERE `mysql`.`user`.`User` REGEXP ?"), &(DB_USER_SELECT_STATEMENT.to_string() + "WHERE `mysql`.`user`.`User` REGEXP ?"),
) )
.bind(create_user_group_matching_regex(unix_user)) .bind(create_user_group_matching_regex(unix_user))
.fetch_all(connection) .fetch_all(connection)
.await .await
.map_err(|err| ListAllUsersError::MySqlError(err.to_string()))
} }
// /// This function fetches a database user if it exists.
// pub async fn get_database_user_for_user(
// username: &str,
// connection: &mut MySqlConnection,
// ) -> anyhow::Result<Option<DatabaseUser>> {
// let user = sqlx::query_as::<_, DatabaseUser>(
// &(DB_USER_SELECT_STATEMENT.to_string() + "WHERE `mysql`.`user`.`User` = ?"),
// )
// .bind(username)
// .fetch_optional(connection)
// .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.