diff --git a/Cargo.lock b/Cargo.lock index 60fbe8d..58fa798 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1364,6 +1364,7 @@ version = "1.0.116" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813" dependencies = [ + "indexmap", "itoa", "ryu", "serde", diff --git a/Cargo.toml b/Cargo.toml index 216e6c8..9242ae5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ nix = { version = "0.28.0", features = ["user"] } prettytable = "0.10.0" ratatui = { version = "0.26.2", optional = true } serde = "1.0.198" -serde_json = "1.0.116" +serde_json = { version = "1.0.116", features = ["preserve_order"] } sqlx = { version = "0.7.4", features = ["runtime-tokio", "mysql", "tls-rustls"] } tokio = { version = "1.37.0", features = ["rt", "macros"] } toml = "0.8.12" diff --git a/flake.nix b/flake.nix index f9aed43..d45f8a0 100644 --- a/flake.nix +++ b/flake.nix @@ -28,7 +28,10 @@ in f system pkgs toolchain); in { devShell = forAllSystems (system: pkgs: toolchain: pkgs.mkShell { - nativeBuildInputs = [ toolchain ]; + nativeBuildInputs = [ + toolchain + pkgs.mysql-client + ]; RUST_SRC_PATH = "${toolchain}/lib/rustlib/src/rust/library"; }); diff --git a/src/cli/database_command.rs b/src/cli/database_command.rs index ccad5cd..59f4ece 100644 --- a/src/cli/database_command.rs +++ b/src/cli/database_command.rs @@ -1,18 +1,13 @@ -use anyhow::{anyhow, Context}; +use anyhow::Context; use clap::Parser; use dialoguer::Editor; -use indoc::indoc; -use itertools::Itertools; use prettytable::{Cell, Row, Table}; use sqlx::{Connection, MySqlConnection}; use crate::core::{ - self, - common::{close_database_connection, get_current_unix_user}, - database_operations::{ - apply_permission_diffs, db_priv_field_human_readable_name, diff_permissions, yn, - DatabasePrivileges, DATABASE_PRIVILEGE_FIELDS, - }, + common::{close_database_connection, get_current_unix_user, yn}, + database_operations::*, + database_privilege_operations::*, user_operations::user_exists, }; @@ -31,29 +26,29 @@ pub enum DatabaseCommand { #[command()] ListDb(DatabaseListArgs), - /// List user permissions for one or more databases + /// List user privileges for one or more databases /// - /// If no database names are provided, it will show permissions for all databases you have access to. + /// If no database names are provided, it will show privileges for all databases you have access to. #[command()] - ShowDbPerm(DatabaseShowPermArgs), + ShowDbPrivs(DatabaseShowPrivsArgs), - /// Change user permissions for one or more databases. See `edit-db-perm --help` for details. + /// Change user privileges for one or more databases. See `edit-db-privs --help` for details. /// /// This command has two modes of operation: /// - /// 1. Interactive mode: If nothing else is specified, the user will be prompted to edit the permissions using a text editor. + /// 1. Interactive mode: If nothing else is specified, the user will be prompted to edit the privileges using a text editor. /// /// You can configure your preferred text editor by setting the `VISUAL` or `EDITOR` environment variables. /// /// Follow the instructions inside the editor for more information. /// - /// 2. Non-interactive mode: If the `-p` flag is specified, the user can write permissions using arguments. + /// 2. Non-interactive mode: If the `-p` flag is specified, the user can write privileges using arguments. /// - /// The permission arguments should be formatted as `::` - /// where the privileges are a string of characters, each representing a single permissions. - /// The character `A` is an exception, because it represents all permissions. + /// The privilege arguments should be formatted as `::` + /// where the privileges are a string of characters, each representing a single privilege. + /// The character `A` is an exception - it represents all privileges. /// - /// The character to permission mapping is declared as follows: + /// The character-to-privilege mapping is defined as follows: /// /// - `s` - SELECT /// - `i` - INSERT @@ -68,24 +63,27 @@ pub enum DatabaseCommand { /// - `r` - REFERENCES /// - `A` - ALL PRIVILEGES /// - /// If you provide a database name, you can omit it from the permission arguments. + /// If you provide a database name, you can omit it from the privilege string, + /// e.g. `edit-db-privs my_db -p my_user:siu` is equivalent to `edit-db-privs -p my_db:my_user:siu`. + /// While it doesn't make much of a difference for a single edit, it can be useful for editing multiple users + /// on the same database at once. /// /// Example usage of non-interactive mode: /// - /// Set permissions `SELECT`, `INSERT`, and `UPDATE` for user `my_user` on database `my_db`: + /// Enable privileges `SELECT`, `INSERT`, and `UPDATE` for user `my_user` on database `my_db`: /// - /// mysqladm edit-db-perm -p my_db:my_user:siu + /// `mysqladm edit-db-privs -p my_db:my_user:siu` /// - /// Set all permissions for user `my_other_user` on database `my_other_db`: + /// Enable all privileges for user `my_other_user` on database `my_other_db`: /// - /// mysqladm edit-db-perm -p my_other_db:my_other_user:A + /// `mysqladm edit-db-privs -p my_other_db:my_other_user:A` /// - /// Set miscellaneous permissions for multiple users on database `my_db`: + /// Set miscellaneous privileges for multiple users on database `my_db`: /// - /// mysqladm edit-db-perm my_db -p my_user:siu my_other_user:ct + /// `mysqladm edit-db-privs my_db -p my_user:siu my_other_user:ct`` /// #[command(verbatim_doc_comment)] - EditDbPerm(DatabaseEditPermArgs), + EditDbPrivs(DatabaseEditPrivsArgs), } #[derive(Parser)] @@ -110,7 +108,7 @@ pub struct DatabaseListArgs { } #[derive(Parser)] -pub struct DatabaseShowPermArgs { +pub struct DatabaseShowPrivsArgs { /// The name of the database(s) to show. #[arg(num_args = 0..)] name: Vec, @@ -121,18 +119,18 @@ pub struct DatabaseShowPermArgs { } #[derive(Parser)] -pub struct DatabaseEditPermArgs { - /// The name of the database to edit permissions for. +pub struct DatabaseEditPrivsArgs { + /// The name of the database to edit privileges for. pub name: Option, - #[arg(short, long, value_name = "[DATABASE:]USER:PERMISSIONS", num_args = 0..)] - pub perm: Vec, + #[arg(short, long, value_name = "[DATABASE:]USER:PRIVILEGES", num_args = 0..)] + pub privs: Vec, /// Whether to output the information in JSON format. #[arg(short, long)] pub json: bool, - /// Specify the text editor to use for editing permissions. + /// Specify the text editor to use for editing privileges #[arg(short, long)] pub editor: Option, @@ -143,30 +141,30 @@ pub struct DatabaseEditPermArgs { pub async fn handle_command( command: DatabaseCommand, - mut conn: MySqlConnection, + mut connection: MySqlConnection, ) -> anyhow::Result<()> { - let result = conn + let result = connection .transaction(|txn| { Box::pin(async move { match command { DatabaseCommand::CreateDb(args) => create_databases(args, txn).await, DatabaseCommand::DropDb(args) => drop_databases(args, txn).await, DatabaseCommand::ListDb(args) => list_databases(args, txn).await, - DatabaseCommand::ShowDbPerm(args) => show_databases(args, txn).await, - DatabaseCommand::EditDbPerm(args) => edit_permissions(args, txn).await, + DatabaseCommand::ShowDbPrivs(args) => show_database_privileges(args, txn).await, + DatabaseCommand::EditDbPrivs(args) => edit_privileges(args, txn).await, } }) }) .await; - close_database_connection(conn).await; + close_database_connection(connection).await; result } async fn create_databases( args: DatabaseCreateArgs, - conn: &mut MySqlConnection, + connection: &mut MySqlConnection, ) -> anyhow::Result<()> { if args.name.is_empty() { anyhow::bail!("No database names provided"); @@ -174,7 +172,7 @@ async fn create_databases( for name in args.name { // TODO: This can be optimized by fetching all the database privileges in one query. - if let Err(e) = core::database_operations::create_database(&name, conn).await { + if let Err(e) = create_database(&name, connection).await { eprintln!("Failed to create database '{}': {}", name, e); eprintln!("Skipping..."); } @@ -183,14 +181,17 @@ async fn create_databases( Ok(()) } -async fn drop_databases(args: DatabaseDropArgs, conn: &mut MySqlConnection) -> anyhow::Result<()> { +async fn drop_databases( + args: DatabaseDropArgs, + connection: &mut MySqlConnection, +) -> anyhow::Result<()> { if args.name.is_empty() { anyhow::bail!("No database names provided"); } for name in args.name { // TODO: This can be optimized by fetching all the database privileges in one query. - if let Err(e) = core::database_operations::drop_database(&name, conn).await { + if let Err(e) = drop_database(&name, connection).await { eprintln!("Failed to drop database '{}': {}", name, e); eprintln!("Skipping..."); } @@ -199,8 +200,11 @@ async fn drop_databases(args: DatabaseDropArgs, conn: &mut MySqlConnection) -> a Ok(()) } -async fn list_databases(args: DatabaseListArgs, conn: &mut MySqlConnection) -> anyhow::Result<()> { - let databases = core::database_operations::get_database_list(conn).await?; +async fn list_databases( + args: DatabaseListArgs, + connection: &mut MySqlConnection, +) -> anyhow::Result<()> { + let databases = get_database_list(connection).await?; if databases.is_empty() { println!("No databases to show."); @@ -218,17 +222,17 @@ async fn list_databases(args: DatabaseListArgs, conn: &mut MySqlConnection) -> a Ok(()) } -async fn show_databases( - args: DatabaseShowPermArgs, - conn: &mut MySqlConnection, +async fn show_database_privileges( + args: DatabaseShowPrivsArgs, + connection: &mut MySqlConnection, ) -> anyhow::Result<()> { let database_users_to_show = if args.name.is_empty() { - core::database_operations::get_all_database_privileges(conn).await? + get_all_database_privileges(connection).await? } else { // TODO: This can be optimized by fetching all the database privileges in one query. let mut result = Vec::with_capacity(args.name.len()); for name in args.name { - match core::database_operations::get_database_privileges(&name, conn).await { + match get_database_privileges(&name, connection).await { Ok(db) => result.extend(db), Err(e) => { eprintln!("Failed to show database '{}': {}", name, e); @@ -279,264 +283,37 @@ async fn show_databases( Ok(()) } -/// See documentation for `DatabaseCommand::EditPerm`. -fn parse_permission_table_cli_arg(arg: &str) -> anyhow::Result { - let parts: Vec<&str> = arg.split(':').collect(); - if parts.len() != 3 { - anyhow::bail!("Invalid argument format. See `edit-perm --help` for more information."); - } - - let db = parts[0].to_string(); - let user = parts[1].to_string(); - let privs = parts[2].to_string(); - - let mut result = DatabasePrivileges { - db, - user, - select_priv: 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 permission character: {}", char), - } - } - - Ok(result) -} - -fn parse_permission(yn: &str) -> anyhow::Result { - match yn.to_ascii_lowercase().as_str() { - "y" => Ok(true), - "n" => Ok(false), - _ => Err(anyhow!("Expected Y or N, found {}", yn)), - } -} - -fn parse_permission_data_from_editor(content: String) -> anyhow::Result> { - content - .trim() - .split('\n') - .map(|line| line.trim()) - .filter(|line| !(line.starts_with('#') || line.starts_with("//") || line == &"")) - .skip(1) - .map(|line| { - let line_parts: Vec<&str> = line.trim().split_ascii_whitespace().collect(); - if line_parts.len() != DATABASE_PRIVILEGE_FIELDS.len() { - anyhow::bail!("") - } - - Ok(DatabasePrivileges { - db: (*line_parts.first().unwrap()).to_owned(), - user: (*line_parts.get(1).unwrap()).to_owned(), - select_priv: parse_permission(line_parts.get(2).unwrap()) - .context("Could not parse SELECT privilege")?, - insert_priv: parse_permission(line_parts.get(3).unwrap()) - .context("Could not parse INSERT privilege")?, - update_priv: parse_permission(line_parts.get(4).unwrap()) - .context("Could not parse UPDATE privilege")?, - delete_priv: parse_permission(line_parts.get(5).unwrap()) - .context("Could not parse DELETE privilege")?, - create_priv: parse_permission(line_parts.get(6).unwrap()) - .context("Could not parse CREATE privilege")?, - drop_priv: parse_permission(line_parts.get(7).unwrap()) - .context("Could not parse DROP privilege")?, - alter_priv: parse_permission(line_parts.get(8).unwrap()) - .context("Could not parse ALTER privilege")?, - index_priv: parse_permission(line_parts.get(9).unwrap()) - .context("Could not parse INDEX privilege")?, - create_tmp_table_priv: parse_permission(line_parts.get(10).unwrap()) - .context("Could not parse CREATE TEMPORARY TABLE privilege")?, - lock_tables_priv: parse_permission(line_parts.get(11).unwrap()) - .context("Could not parse LOCK TABLES privilege")?, - references_priv: parse_permission(line_parts.get(12).unwrap()) - .context("Could not parse REFERENCES privilege")?, - }) - }) - .collect::>>() -} - -fn format_privileges_line( - privs: &DatabasePrivileges, - username_len: usize, - database_name_len: usize, -) -> String { - // Format a privileges line by padding each value with spaces - // The first two fields are padded to the length of the longest username and database name - // The remaining fields are padded to the length of the corresponding field name - - 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() -} - -pub async fn edit_permissions( - args: DatabaseEditPermArgs, - conn: &mut MySqlConnection, +pub async fn edit_privileges( + args: DatabaseEditPrivsArgs, + connection: &mut MySqlConnection, ) -> anyhow::Result<()> { - let permission_data = if let Some(name) = &args.name { - core::database_operations::get_database_privileges(name, conn).await? + let privilege_data = if let Some(name) = &args.name { + get_database_privileges(name, connection).await? } else { - core::database_operations::get_all_database_privileges(conn).await? + get_all_database_privileges(connection).await? }; - let permissions_to_change = if !args.perm.is_empty() { - if let Some(name) = args.name { - args.perm - .iter() - .map(|perm| { - parse_permission_table_cli_arg(&format!("{}:{}", name, &perm)) - .context(format!("Failed parsing database permissions: `{}`", &perm)) - }) - .collect::>>()? - } else { - args.perm - .iter() - .map(|perm| { - parse_permission_table_cli_arg(perm) - .context(format!("Failed parsing database permissions: `{}`", &perm)) - }) - .collect::>>()? - } + // TODO: The data from args should not be absolute. + // In the current implementation, the user would need to + // provide all privileges for all users on all databases. + // The intended effect is to modify the privileges which have + // matching users and databases, as well as add any + // new db-user pairs. This makes it impossible to remove + // privileges, but that is an issue for another day. + let privileges_to_change = if !args.privs.is_empty() { + parse_privilege_tables_from_args(&args)? } else { - let comment = indoc! {r#" - # Welcome to the permission editor. - # Each line defines what permissions 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 permissions. - # If the user should have a permission, write 'Y', otherwise write 'N'. - # - # Lines starting with '#' are comments and will be ignored. - "#}; - - let unix_user = get_current_unix_user()?; - let example_user = format!("{}_user", unix_user.name); - let example_db = format!("{}_db", unix_user.name); - - let longest_username = permission_data - .iter() - .map(|p| p.user.len()) - .max() - .unwrap_or(example_user.len()); - - let longest_database_name = permission_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 permissions. - 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( - &DatabasePrivileges { - 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, - ); - - // TODO: handle errors better here - let result = Editor::new() - .extension("tsv") - .edit( - format!( - "{}\n{}\n{}", - comment, - header.join(" "), - if permission_data.is_empty() { - format!("# {}", example_line) - } else { - permission_data - .iter() - .map(|perm| { - format_privileges_line( - perm, - longest_username, - longest_database_name, - ) - }) - .join("\n") - } - ) - .as_str(), - )? - .unwrap(); - - parse_permission_data_from_editor(result) - .context("Could not parse permission data from editor")? + edit_privileges_with_editor(&privilege_data)? }; - for row in permissions_to_change.iter() { - if !user_exists(&row.user, conn).await? { + 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_permissions(permission_data, &permissions_to_change).await; + let diffs = diff_privileges(privilege_data, &privileges_to_change); if diffs.is_empty() { println!("No changes to make."); @@ -545,7 +322,49 @@ pub async fn edit_permissions( // TODO: Add confirmation prompt. - apply_permission_diffs(diffs, conn).await?; + apply_privilege_diffs(diffs, connection).await?; Ok(()) } + +pub fn parse_privilege_tables_from_args( + args: &DatabaseEditPrivsArgs, +) -> anyhow::Result> { + debug_assert!(!args.privs.is_empty()); + let result = if let Some(name) = &args.name { + args.privs + .iter() + .map(|p| { + parse_privilege_table_cli_arg(&format!("{}:{}", name, &p)) + .context(format!("Failed parsing database privileges: `{}`", &p)) + }) + .collect::>>()? + } else { + args.privs + .iter() + .map(|p| { + parse_privilege_table_cli_arg(p) + .context(format!("Failed parsing database privileges: `{}`", &p)) + }) + .collect::>>()? + }; + Ok(result) +} + +pub fn edit_privileges_with_editor( + privilege_data: &[DatabasePrivilegeRow], +) -> anyhow::Result> { + let unix_user = get_current_unix_user()?; + + let editor_content = + generate_editor_content_from_privilege_data(privilege_data, &unix_user.name); + + // TODO: handle errors better here + let result = Editor::new() + .extension("tsv") + .edit(&editor_content)? + .unwrap(); + + parse_privilege_data_from_editor_content(result) + .context("Could not parse privilege data from editor") +} diff --git a/src/cli/mysql_admutils_compatibility/mysql_dbadm.rs b/src/cli/mysql_admutils_compatibility/mysql_dbadm.rs index 9a0081e..2c0ba21 100644 --- a/src/cli/mysql_admutils_compatibility/mysql_dbadm.rs +++ b/src/cli/mysql_admutils_compatibility/mysql_dbadm.rs @@ -7,8 +7,10 @@ use crate::{ mysql_admutils_compatibility::common::{filter_db_or_user_names, DbOrUser}, }, core::{ + common::yn, config::{get_config, mysql_connection_from_config, GlobalConfigArgs}, - database_operations::{self, yn}, + database_operations::{create_database, drop_database, get_database_list}, + database_privilege_operations, }, }; @@ -48,6 +50,10 @@ pub struct Args { pub help_editperm: bool, } +// NOTE: mysql-dbadm explicitly calls privileges "permissions". +// This is something we're trying to move away from. +// See https://git.pvv.ntnu.no/Projects/mysqladm-rs/issues/29 + /// Create, drop or edit permissions for the DATABASE(s), /// as determined by the COMMAND. /// @@ -129,20 +135,20 @@ pub async fn main() -> anyhow::Result<()> { Command::Create(args) => { let filtered_names = filter_db_or_user_names(args.name, DbOrUser::Database)?; for name in filtered_names { - database_operations::create_database(&name, &mut connection).await?; + create_database(&name, &mut connection).await?; println!("Database {} created.", name); } } Command::Drop(args) => { let filtered_names = filter_db_or_user_names(args.name, DbOrUser::Database)?; for name in filtered_names { - database_operations::drop_database(&name, &mut connection).await?; + drop_database(&name, &mut connection).await?; println!("Database {} dropped.", name); } } Command::Show(args) => { let names = if args.name.is_empty() { - database_operations::get_database_list(&mut connection).await? + get_database_list(&mut connection).await? } else { filter_db_or_user_names(args.name, DbOrUser::Database)? }; @@ -156,27 +162,27 @@ pub async fn main() -> anyhow::Result<()> { // Hopefully, not many people rely on this in an automated fashion, as it // is made to be interactive in nature. However, we should still try to // replicate the old behavior as closely as possible. - let edit_permissions_args = database_command::DatabaseEditPermArgs { + let edit_privileges_args = database_command::DatabaseEditPrivsArgs { name: Some(args.database), - perm: vec![], + privs: vec![], json: false, editor: None, yes: false, }; - database_command::edit_permissions(edit_permissions_args, &mut connection).await?; + database_command::edit_privileges(edit_privileges_args, &mut connection).await?; } } Ok(()) } -async fn show_db(name: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> { +async fn show_db(name: &str, connection: &mut MySqlConnection) -> anyhow::Result<()> { // NOTE: mysql-dbadm show has a quirk where valid database names // for non-existent databases will report with no users. // This function should *not* check for db existence, only // validate the names. - let permissions = database_operations::get_database_privileges(name, conn) + let privileges = database_privilege_operations::get_database_privileges(name, connection) .await .unwrap_or(vec![]); @@ -188,24 +194,24 @@ async fn show_db(name: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> { ), name, ); - if permissions.is_empty() { + if privileges.is_empty() { println!("# (no permissions currently granted to any users)"); } else { - for permission in permissions { + for privilege in privileges { println!( " {:<16} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {}", - permission.user, - yn(permission.select_priv), - yn(permission.insert_priv), - yn(permission.update_priv), - yn(permission.delete_priv), - yn(permission.create_priv), - yn(permission.drop_priv), - yn(permission.alter_priv), - yn(permission.index_priv), - yn(permission.create_tmp_table_priv), - yn(permission.lock_tables_priv), - yn(permission.references_priv) + privilege.user, + yn(privilege.select_priv), + yn(privilege.insert_priv), + yn(privilege.update_priv), + yn(privilege.delete_priv), + yn(privilege.create_priv), + yn(privilege.drop_priv), + yn(privilege.alter_priv), + yn(privilege.index_priv), + yn(privilege.create_tmp_table_priv), + yn(privilege.lock_tables_priv), + yn(privilege.references_priv) ); } } diff --git a/src/cli/mysql_admutils_compatibility/mysql_useradm.rs b/src/cli/mysql_admutils_compatibility/mysql_useradm.rs index a4b1381..a44a00f 100644 --- a/src/cli/mysql_admutils_compatibility/mysql_useradm.rs +++ b/src/cli/mysql_admutils_compatibility/mysql_useradm.rs @@ -9,10 +9,7 @@ use crate::{ core::{ common::{close_database_connection, get_current_unix_user}, config::{get_config, mysql_connection_from_config, GlobalConfigArgs}, - user_operations::{ - create_database_user, delete_database_user, get_all_database_users_for_unix_user, - get_database_user_for_user, set_password_for_database_user, user_exists, - }, + user_operations::*, }, }; diff --git a/src/cli/user_command.rs b/src/cli/user_command.rs index 3db7e80..5adfc7c 100644 --- a/src/cli/user_command.rs +++ b/src/cli/user_command.rs @@ -1,11 +1,18 @@ +use std::collections::BTreeMap; use std::vec; use anyhow::Context; use clap::Parser; use dialoguer::{Confirm, Password}; +use prettytable::Table; +use serde_json::json; use sqlx::{Connection, MySqlConnection}; -use crate::core::{common::close_database_connection, user_operations::validate_user_name}; +use crate::core::{ + common::{close_database_connection, get_current_unix_user}, + database_operations::*, + user_operations::*, +}; #[derive(Parser)] pub struct UserArgs { @@ -67,8 +74,11 @@ pub struct UserShowArgs { json: bool, } -pub async fn handle_command(command: UserCommand, mut conn: MySqlConnection) -> anyhow::Result<()> { - let result = conn +pub async fn handle_command( + command: UserCommand, + mut connection: MySqlConnection, +) -> anyhow::Result<()> { + let result = connection .transaction(|txn| { Box::pin(async move { match command { @@ -81,18 +91,21 @@ pub async fn handle_command(command: UserCommand, mut conn: MySqlConnection) -> }) .await; - close_database_connection(conn).await; + close_database_connection(connection).await; result } -async fn create_users(args: UserCreateArgs, conn: &mut MySqlConnection) -> anyhow::Result<()> { +async fn create_users( + args: UserCreateArgs, + connection: &mut MySqlConnection, +) -> anyhow::Result<()> { if args.username.is_empty() { anyhow::bail!("No usernames provided"); } for username in args.username { - if let Err(e) = crate::core::user_operations::create_database_user(&username, conn).await { + if let Err(e) = create_database_user(&username, connection).await { eprintln!("{}", e); eprintln!("Skipping...\n"); continue; @@ -113,7 +126,7 @@ async fn create_users(args: UserCreateArgs, conn: &mut MySqlConnection) -> anyho username, password_file: None, }, - conn, + connection, ) .await?; } @@ -122,13 +135,13 @@ async fn create_users(args: UserCreateArgs, conn: &mut MySqlConnection) -> anyho Ok(()) } -async fn drop_users(args: UserDeleteArgs, conn: &mut MySqlConnection) -> anyhow::Result<()> { +async fn drop_users(args: UserDeleteArgs, connection: &mut MySqlConnection) -> anyhow::Result<()> { if args.username.is_empty() { anyhow::bail!("No usernames provided"); } for username in args.username { - if let Err(e) = crate::core::user_operations::delete_database_user(&username, conn).await { + if let Err(e) = delete_database_user(&username, connection).await { eprintln!("{}", e); eprintln!("Skipping..."); } @@ -149,11 +162,11 @@ pub fn read_password_from_stdin_with_double_check(username: &str) -> anyhow::Res async fn change_password_for_user( args: UserPasswdArgs, - conn: &mut MySqlConnection, + connection: &mut MySqlConnection, ) -> anyhow::Result<()> { // NOTE: although this also is checked in `set_password_for_database_user`, we check it here // to provide a more natural order of error messages. - let unix_user = crate::core::common::get_current_unix_user()?; + let unix_user = get_current_unix_user()?; validate_user_name(&args.username, &unix_user)?; let password = if let Some(password_file) = args.password_file { @@ -165,17 +178,16 @@ async fn change_password_for_user( read_password_from_stdin_with_double_check(&args.username)? }; - crate::core::user_operations::set_password_for_database_user(&args.username, &password, conn) - .await?; + set_password_for_database_user(&args.username, &password, connection).await?; Ok(()) } -async fn show_users(args: UserShowArgs, conn: &mut MySqlConnection) -> anyhow::Result<()> { - let unix_user = crate::core::common::get_current_unix_user()?; +async fn show_users(args: UserShowArgs, connection: &mut MySqlConnection) -> anyhow::Result<()> { + let unix_user = get_current_unix_user()?; let users = if args.username.is_empty() { - crate::core::user_operations::get_all_database_users_for_unix_user(&unix_user, conn).await? + get_all_database_users_for_unix_user(&unix_user, connection).await? } else { let mut result = vec![]; for username in args.username { @@ -185,8 +197,7 @@ async fn show_users(args: UserShowArgs, conn: &mut MySqlConnection) -> anyhow::R continue; } - let user = - crate::core::user_operations::get_database_user_for_user(&username, conn).await?; + let user = get_database_user_for_user(&username, connection).await?; if let Some(user) = user { result.push(user); } else { @@ -196,20 +207,47 @@ async fn show_users(args: UserShowArgs, conn: &mut MySqlConnection) -> anyhow::R result }; + let mut user_databases: BTreeMap> = BTreeMap::new(); + for user in users.iter() { + user_databases.insert( + user.user.clone(), + get_databases_where_user_has_privileges(&user.user, connection).await?, + ); + } + if args.json { - println!("{}", serde_json::to_string_pretty(&users)?); + let users_json = users + .into_iter() + .map(|user| { + json!({ + "user": user.user, + "has_password": user.has_password, + "databases": user_databases.get(&user.user).unwrap_or(&vec![]), + }) + }) + .collect::(); + println!( + "{}", + serde_json::to_string_pretty(&users_json) + .context("Failed to serialize users to JSON")? + ); + } else if users.is_empty() { + println!("No users found."); } else { + let mut table = Table::new(); + table.add_row(row![ + "User", + "Password is set", + "Databases where user has privileges" + ]); for user in users { - println!( - "User '{}': {}", - &user.user, - if user.has_password { - "password set." - } else { - "no password set." - } - ); + table.add_row(row![ + user.user, + user.has_password, + user_databases.get(&user.user).unwrap_or(&vec![]).join("\n") + ]); } + table.printstd(); } Ok(()) diff --git a/src/core.rs b/src/core.rs index b83db7b..aa51dca 100644 --- a/src/core.rs +++ b/src/core.rs @@ -1,4 +1,5 @@ pub mod common; pub mod config; pub mod database_operations; +pub mod database_privilege_operations; pub mod user_operations; diff --git a/src/core/common.rs b/src/core/common.rs index d74cb0c..ebe592f 100644 --- a/src/core/common.rs +++ b/src/core/common.rs @@ -141,8 +141,8 @@ pub fn validate_ownership_by_user_prefix<'a>( Ok(prefix) } -pub async fn close_database_connection(conn: MySqlConnection) { - if let Err(e) = conn +pub async fn close_database_connection(connection: MySqlConnection) { + if let Err(e) = connection .close() .await .context("Failed to close connection properly") @@ -161,3 +161,21 @@ pub fn quote_literal(s: &str) -> String { pub fn quote_identifier(s: &str) -> String { format!("`{}`", s.replace('`', r"\`")) } + +#[inline] +pub(crate) fn yn(b: bool) -> &'static str { + if b { + "Y" + } else { + "N" + } +} + +#[inline] +pub(crate) fn rev_yn(s: &str) -> Option { + match s.to_lowercase().as_str() { + "y" => Some(true), + "n" => Some(false), + _ => None, + } +} diff --git a/src/core/config.rs b/src/core/config.rs index 53a36bf..54cbaf8 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -107,7 +107,7 @@ pub async fn mysql_connection_from_config(config: Config) -> anyhow::Result conn.context("Failed to connect to MySQL"), + Ok(connection) => connection.context("Failed to connect to MySQL"), Err(_) => Err(anyhow!("Timed out after 2 seconds")).context("Failed to connect to MySQL"), } } diff --git a/src/core/database_operations.rs b/src/core/database_operations.rs index 744e843..63687ad 100644 --- a/src/core/database_operations.rs +++ b/src/core/database_operations.rs @@ -1,24 +1,25 @@ -use std::collections::HashMap; - use anyhow::Context; -use indoc::indoc; +use indoc::formatdoc; use itertools::Itertools; use nix::unistd::User; use serde::{Deserialize, Serialize}; -use sqlx::{mysql::MySqlRow, prelude::*, MySqlConnection}; +use sqlx::{prelude::*, MySqlConnection}; -use super::common::{ - create_user_group_matching_regex, get_current_unix_user, quote_identifier, validate_name_token, - validate_ownership_by_user_prefix, +use crate::core::{ + common::{ + create_user_group_matching_regex, get_current_unix_user, quote_identifier, + validate_name_token, validate_ownership_by_user_prefix, + }, + database_privilege_operations::DATABASE_PRIVILEGE_FIELDS, }; -pub async fn create_database(name: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> { +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(conn) + .execute(connection) .await .map_err(|e| { if e.to_string().contains("database exists") { @@ -31,13 +32,13 @@ pub async fn create_database(name: &str, conn: &mut MySqlConnection) -> anyhow:: Ok(()) } -pub async fn drop_database(name: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> { +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(conn) + .execute(connection) .await .map_err(|e| { if e.to_string().contains("doesn't exist") { @@ -55,7 +56,7 @@ struct DatabaseName { database: String, } -pub async fn get_database_list(conn: &mut MySqlConnection) -> anyhow::Result> { +pub async fn get_database_list(connection: &mut MySqlConnection) -> anyhow::Result> { let unix_user = get_current_unix_user()?; let databases = sqlx::query_as::<_, DatabaseName>( @@ -67,7 +68,7 @@ pub async fn get_database_list(conn: &mut MySqlConnection) -> anyhow::Result anyhow::Result 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), - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DatabasePrivileges { - 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 DatabasePrivileges { - 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: &DatabasePrivileges) -> DatabasePrivilegeDiffList { - debug_assert!(self.db == other.db && self.user == other.user); - - DatabasePrivilegeDiffList { - db: self.db.clone(), - user: self.user.clone(), - diff: DATABASE_PRIVILEGE_FIELDS - .into_iter() - .skip(2) - .filter_map(|field| { - diff_single_priv( - self.get_privilege_by_name(field), - other.get_privilege_by_name(field), - field, - ) - }) - .collect(), - } - } -} - -#[inline] -pub(crate) fn yn(b: bool) -> &'static str { - if b { - "Y" - } else { - "N" - } -} - -#[inline] -pub(crate) fn rev_yn(s: &str) -> bool { - match s.to_lowercase().as_str() { - "y" => true, - "n" => false, - _ => { - log::warn!("Invalid value for privilege: {}", s); - false - } - } -} - -impl FromRow<'_, MySqlRow> for DatabasePrivileges { - fn from_row(row: &MySqlRow) -> Result { - Ok(Self { - db: row.try_get("db")?, - user: row.try_get("user")?, - select_priv: row.try_get("select_priv").map(rev_yn)?, - insert_priv: row.try_get("insert_priv").map(rev_yn)?, - update_priv: row.try_get("update_priv").map(rev_yn)?, - delete_priv: row.try_get("delete_priv").map(rev_yn)?, - create_priv: row.try_get("create_priv").map(rev_yn)?, - drop_priv: row.try_get("drop_priv").map(rev_yn)?, - alter_priv: row.try_get("alter_priv").map(rev_yn)?, - index_priv: row.try_get("index_priv").map(rev_yn)?, - create_tmp_table_priv: row.try_get("create_tmp_table_priv").map(rev_yn)?, - lock_tables_priv: row.try_get("lock_tables_priv").map(rev_yn)?, - references_priv: row.try_get("references_priv").map(rev_yn)?, - }) - } -} - -pub async fn get_database_privileges( - database_name: &str, - conn: &mut MySqlConnection, -) -> anyhow::Result> { - let unix_user = get_current_unix_user()?; - validate_database_name(database_name, &unix_user)?; - - let result = sqlx::query_as::<_, DatabasePrivileges>(&format!( - "SELECT {} FROM `db` WHERE `db` = ?", - DATABASE_PRIVILEGE_FIELDS - .iter() - .map(|field| quote_identifier(field)) - .join(","), - )) - .bind(database_name) - .fetch_all(conn) - .await - .context("Failed to show database")?; +pub async fn get_databases_where_user_has_privileges( + username: &str, + connection: &mut MySqlConnection, +) -> anyhow::Result> { + 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::("database").unwrap()) + .collect(); Ok(result) } -pub async fn get_all_database_privileges( - conn: &mut MySqlConnection, -) -> anyhow::Result> { - let unix_user = get_current_unix_user()?; - - let result = sqlx::query_as::<_, DatabasePrivileges>(&format!( - indoc! {r#" - SELECT {} FROM `db` WHERE `db` IN - (SELECT DISTINCT `SCHEMA_NAME` AS `database` - FROM `information_schema`.`SCHEMATA` - WHERE `SCHEMA_NAME` NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys') - AND `SCHEMA_NAME` REGEXP ?) - "#}, - DATABASE_PRIVILEGE_FIELDS - .iter() - .map(|field| format!("`{field}`")) - .join(","), - )) - .bind(create_user_group_matching_regex(&unix_user)) - .fetch_all(conn) - .await - .context("Failed to show databases")?; - - Ok(result) -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DatabasePrivilegeDiffList { - pub db: String, - pub user: String, - pub diff: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum DatabasePrivilegeDiff { - YesToNo(String), - NoToYes(String), -} - -fn diff_single_priv(p1: bool, p2: bool, name: &str) -> Option { - match (p1, p2) { - (true, false) => Some(DatabasePrivilegeDiff::YesToNo(name.to_owned())), - (false, true) => Some(DatabasePrivilegeDiff::NoToYes(name.to_owned())), - _ => None, - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum DatabasePrivilegesDiff { - New(DatabasePrivileges), - Modified(DatabasePrivilegeDiffList), - Deleted(DatabasePrivileges), -} - -pub async fn diff_permissions( - from: Vec, - to: &[DatabasePrivileges], -) -> Vec { - let from_lookup_table: HashMap<(String, String), DatabasePrivileges> = HashMap::from_iter( - from.iter() - .cloned() - .map(|p| ((p.db.clone(), p.user.clone()), p)), - ); - - let to_lookup_table: HashMap<(String, String), DatabasePrivileges> = HashMap::from_iter( - to.iter() - .cloned() - .map(|p| ((p.db.clone(), p.user.clone()), p)), - ); - - let mut result = vec![]; - - 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.push(DatabasePrivilegesDiff::Modified(diff)); - } - } else { - result.push(DatabasePrivilegesDiff::New(p.clone())); - } - } - - for p in from { - if !to_lookup_table.contains_key(&(p.db.clone(), p.user.clone())) { - result.push(DatabasePrivilegesDiff::Deleted(p)); - } - } - - result -} - -pub async fn apply_permission_diffs( - diffs: Vec, - conn: &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 *conn) - .await?; - } - DatabasePrivilegesDiff::Modified(p) => { - let tables = p - .diff - .iter() - .map(|diff| match diff { - DatabasePrivilegeDiff::YesToNo(name) => format!("`{}` = 'N'", name), - DatabasePrivilegeDiff::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 *conn) - .await?; - } - DatabasePrivilegesDiff::Deleted(p) => { - sqlx::query("DELETE FROM `db` WHERE `db` = ? AND `user` = ?") - .bind(p.db) - .bind(p.user) - .execute(&mut *conn) - .await?; - } - } - } - Ok(()) -} - /// 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 @@ -397,72 +117,3 @@ pub fn validate_database_name(name: &str, user: &User) -> anyhow::Result<()> { Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_diff_single_priv() { - assert_eq!( - diff_single_priv(true, false, "test"), - Some(DatabasePrivilegeDiff::YesToNo("test".to_owned())) - ); - assert_eq!( - diff_single_priv(false, true, "test"), - Some(DatabasePrivilegeDiff::NoToYes("test".to_owned())) - ); - assert_eq!(diff_single_priv(true, true, "test"), None); - assert_eq!(diff_single_priv(false, false, "test"), None); - } - - #[tokio::test] - async fn test_diff_permissions() { - let from = vec![DatabasePrivileges { - 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, - }]; - - let to = vec![DatabasePrivileges { - db: "db".to_owned(), - user: "user".to_owned(), - select_priv: false, - 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, - }]; - - let diffs = diff_permissions(from, &to).await; - - assert_eq!( - diffs, - vec![DatabasePrivilegesDiff::Modified( - DatabasePrivilegeDiffList { - db: "db".to_owned(), - user: "user".to_owned(), - diff: vec![DatabasePrivilegeDiff::YesToNo("select_priv".to_owned())], - } - )] - ); - - assert!(matches!(&diffs[0], DatabasePrivilegesDiff::Modified(_))); - } -} diff --git a/src/core/database_privilege_operations.rs b/src/core/database_privilege_operations.rs new file mode 100644 index 0000000..779e4ba --- /dev/null +++ b/src/core/database_privilege_operations.rs @@ -0,0 +1,683 @@ +//! 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::HashMap; + +use anyhow::{anyhow, Context}; +use indoc::indoc; +use itertools::Itertools; +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), + } +} + +/// This struct represents the set of privileges for a single user on a single database. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +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 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_row_priv_field(row: &MySqlRow, field: &str) -> Result { + match rev_yn(row.try_get(field)?) { + Some(val) => Ok(val), + _ => { + log::warn!("Invalid value for privilege: {}", field); + Ok(false) + } + } +} + +impl FromRow<'_, MySqlRow> for DatabasePrivilegeRow { + fn from_row(row: &MySqlRow) -> Result { + Ok(Self { + db: row.try_get("db")?, + user: row.try_get("user")?, + select_priv: get_row_priv_field(row, "select_priv")?, + insert_priv: get_row_priv_field(row, "insert_priv")?, + update_priv: get_row_priv_field(row, "update_priv")?, + delete_priv: get_row_priv_field(row, "delete_priv")?, + create_priv: get_row_priv_field(row, "create_priv")?, + drop_priv: get_row_priv_field(row, "drop_priv")?, + alter_priv: get_row_priv_field(row, "alter_priv")?, + index_priv: get_row_priv_field(row, "index_priv")?, + create_tmp_table_priv: get_row_priv_field(row, "create_tmp_table_priv")?, + lock_tables_priv: get_row_priv_field(row, "lock_tables_priv")?, + references_priv: get_row_priv_field(row, "references_priv")?, + }) + } +} + +pub async fn get_database_privileges( + database_name: &str, + connection: &mut MySqlConnection, +) -> anyhow::Result> { + 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) +} + +pub async fn get_all_database_privileges( + connection: &mut MySqlConnection, +) -> anyhow::Result> { + 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 { + 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 PARSING/DISPLAY */ +/**********************************/ + +// TODO: merge with `rev_yn` in `common.rs` + +fn parse_privilege(yn: &str) -> anyhow::Result { + match yn.to_ascii_lowercase().as_str() { + "y" => Ok(true), + "n" => Ok(false), + _ => Err(anyhow!("Expected Y or N, found {}", yn)), + } +} + +pub fn parse_privilege_data_from_editor_content( + content: String, +) -> anyhow::Result> { + content + .trim() + .split('\n') + .map(|line| line.trim()) + .filter(|line| !(line.starts_with('#') || line.starts_with("//") || line == &"")) + .skip(1) + .map(|line| { + let line_parts: Vec<&str> = line.trim().split_ascii_whitespace().collect(); + if line_parts.len() != DATABASE_PRIVILEGE_FIELDS.len() { + anyhow::bail!("") + } + + Ok(DatabasePrivilegeRow { + db: (*line_parts.first().unwrap()).to_owned(), + user: (*line_parts.get(1).unwrap()).to_owned(), + select_priv: parse_privilege(line_parts.get(2).unwrap()) + .context("Could not parse SELECT privilege")?, + insert_priv: parse_privilege(line_parts.get(3).unwrap()) + .context("Could not parse INSERT privilege")?, + update_priv: parse_privilege(line_parts.get(4).unwrap()) + .context("Could not parse UPDATE privilege")?, + delete_priv: parse_privilege(line_parts.get(5).unwrap()) + .context("Could not parse DELETE privilege")?, + create_priv: parse_privilege(line_parts.get(6).unwrap()) + .context("Could not parse CREATE privilege")?, + drop_priv: parse_privilege(line_parts.get(7).unwrap()) + .context("Could not parse DROP privilege")?, + alter_priv: parse_privilege(line_parts.get(8).unwrap()) + .context("Could not parse ALTER privilege")?, + index_priv: parse_privilege(line_parts.get(9).unwrap()) + .context("Could not parse INDEX privilege")?, + create_tmp_table_priv: parse_privilege(line_parts.get(10).unwrap()) + .context("Could not parse CREATE TEMPORARY TABLE privilege")?, + lock_tables_priv: parse_privilege(line_parts.get(11).unwrap()) + .context("Could not parse LOCK TABLES privilege")?, + references_priv: parse_privilege(line_parts.get(12).unwrap()) + .context("Could not parse REFERENCES privilege")?, + }) + }) + .collect::>>() +} + +/// Generates a single row of the privileges table for the editor. +pub fn format_privileges_line( + privs: &DatabasePrivilegeRow, + username_len: usize, + database_name_len: usize, +) -> String { + // Format a privileges line by padding each value with spaces + // The first two fields are padded to the length of the longest username and database name + // The remaining fields are padded to the length of the corresponding field name + + 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( + &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(privs, longest_username, longest_database_name)) + .join("\n") + } + ) +} + +/*****************************/ +/* 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, Serialize, Deserialize)] +pub struct DatabasePrivilegeRowDiff { + pub db: String, + pub user: String, + pub diff: Vec, +} + +/// This enum represents a change for a single privilege. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum DatabasePrivilegeChange { + YesToNo(String), + NoToYes(String), +} + +impl DatabasePrivilegeChange { + pub fn new(p1: bool, p2: bool, name: &str) -> Option { + 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, Serialize, Deserialize)] +pub enum DatabasePrivilegesDiff { + New(DatabasePrivilegeRow), + Modified(DatabasePrivilegeRowDiff), + Deleted(DatabasePrivilegeRow), +} + +pub fn diff_privileges( + from: Vec, + to: &[DatabasePrivilegeRow], +) -> Vec { + 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 = vec![]; + + 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.push(DatabasePrivilegesDiff::Modified(diff)); + } + } else { + result.push(DatabasePrivilegesDiff::New(p.clone())); + } + } + + for p in from { + if !to_lookup_table.contains_key(&(p.db.clone(), p.user.clone())) { + result.push(DatabasePrivilegesDiff::Deleted(p)); + } + } + + result +} + +/// Uses the resulting diffs to make modifications to the database. +pub async fn apply_privilege_diffs( + diffs: Vec, + 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(()) +} + +/*********/ +/* 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 from = 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: false, + create_tmp_table_priv: true, + lock_tables_priv: true, + references_priv: false, + }]; + + let mut to = from.clone(); + to[0].select_priv = false; + to[0].insert_priv = false; + to[0].index_priv = true; + + let diffs = diff_privileges(from, &to); + + assert_eq!( + diffs, + vec![DatabasePrivilegesDiff::Modified(DatabasePrivilegeRowDiff { + db: "db".to_owned(), + user: "user".to_owned(), + diff: vec![ + DatabasePrivilegeChange::YesToNo("select_priv".to_owned()), + DatabasePrivilegeChange::YesToNo("insert_priv".to_owned()), + DatabasePrivilegeChange::NoToYes("index_priv".to_owned()), + ], + })] + ); + + assert!(matches!(&diffs[0], DatabasePrivilegesDiff::Modified(_))); + } + + #[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); + } +} diff --git a/src/core/user_operations.rs b/src/core/user_operations.rs index 2b0c67e..1916982 100644 --- a/src/core/user_operations.rs +++ b/src/core/user_operations.rs @@ -10,7 +10,7 @@ use super::common::{ validate_ownership_by_user_prefix, }; -pub async fn user_exists(db_user: &str, conn: &mut MySqlConnection) -> anyhow::Result { +pub async fn user_exists(db_user: &str, connection: &mut MySqlConnection) -> anyhow::Result { let unix_user = get_current_unix_user()?; validate_user_name(db_user, &unix_user)?; @@ -25,42 +25,48 @@ pub async fn user_exists(db_user: &str, conn: &mut MySqlConnection) -> anyhow::R "#, ) .bind(db_user) - .fetch_one(conn) + .fetch_one(connection) .await? .get::(0); Ok(user_exists) } -pub async fn create_database_user(db_user: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> { +pub async fn create_database_user( + db_user: &str, + connection: &mut MySqlConnection, +) -> anyhow::Result<()> { let unix_user = get_current_unix_user()?; validate_user_name(db_user, &unix_user)?; - if user_exists(db_user, conn).await? { + 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(conn) + .execute(connection) .await?; Ok(()) } -pub async fn delete_database_user(db_user: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> { +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, conn).await? { + 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(conn) + .execute(connection) .await?; Ok(()) @@ -69,12 +75,12 @@ pub async fn delete_database_user(db_user: &str, conn: &mut MySqlConnection) -> pub async fn set_password_for_database_user( db_user: &str, password: &str, - conn: &mut MySqlConnection, + 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, conn).await? { + if !user_exists(db_user, connection).await? { anyhow::bail!("User '{}' does not exist", db_user); } @@ -87,7 +93,7 @@ pub async fn set_password_for_database_user( ) .as_str(), ) - .execute(conn) + .execute(connection) .await?; Ok(()) @@ -100,6 +106,7 @@ pub struct DatabaseUser { #[sqlx(rename = "User")] pub user: String, + #[allow(dead_code)] #[serde(skip)] #[sqlx(rename = "Host")] pub host: String, @@ -112,7 +119,7 @@ pub struct DatabaseUser { /// unix username and group names of the given unix user. pub async fn get_all_database_users_for_unix_user( unix_user: &User, - conn: &mut MySqlConnection, + connection: &mut MySqlConnection, ) -> anyhow::Result> { let users = sqlx::query_as::<_, DatabaseUser>( r#" @@ -125,7 +132,7 @@ pub async fn get_all_database_users_for_unix_user( "#, ) .bind(create_user_group_matching_regex(unix_user)) - .fetch_all(conn) + .fetch_all(connection) .await?; Ok(users) @@ -134,7 +141,7 @@ pub async fn get_all_database_users_for_unix_user( /// This function fetches a database user if it exists. pub async fn get_database_user_for_user( username: &str, - conn: &mut MySqlConnection, + connection: &mut MySqlConnection, ) -> anyhow::Result> { let user = sqlx::query_as::<_, DatabaseUser>( r#" @@ -147,7 +154,7 @@ pub async fn get_database_user_for_user( "#, ) .bind(username) - .fetch_optional(conn) + .fetch_optional(connection) .await?; Ok(user)