Files
muscl/src/client/commands/edit_privs.rs
h7x4 146421dd79
Some checks failed
Build and test / check-license (push) Successful in 54s
Build and test / check (push) Successful in 1m55s
Build and test / build (push) Successful in 3m26s
Build and test / docs (push) Has been cancelled
Build and test / test (push) Has been cancelled
client/edit-privs: report actual errors instead of non-existence
2025-12-16 14:07:28 +09:00

359 lines
12 KiB
Rust

use std::collections::{BTreeMap, BTreeSet};
use anyhow::Context;
use clap::{Args, Parser};
use clap_complete::ArgValueCompleter;
use dialoguer::{Confirm, Editor};
use futures_util::SinkExt;
use nix::unistd::{User, getuid};
use tokio_stream::StreamExt;
use crate::{
client::commands::{erroneous_server_response, print_authorization_owner_hint},
core::{
completion::{mysql_database_completer, mysql_user_completer},
database_privileges::{
DatabasePrivilegeEdit, DatabasePrivilegeEditEntry, DatabasePrivilegeRow,
DatabasePrivilegeRowDiff, DatabasePrivilegesDiff, create_or_modify_privilege_rows,
diff_privileges, display_privilege_diffs, generate_editor_content_from_privilege_data,
parse_privilege_data_from_editor_content, reduce_privilege_diffs,
},
protocol::{
ClientToServerMessageStream, ListDatabasesError, ListUsersError,
ModifyDatabasePrivilegesError, Request, Response,
print_modify_database_privileges_output_status, request_validation::ValidationError,
},
types::{MySQLDatabase, MySQLUser},
},
};
#[derive(Parser, Debug, Clone)]
pub struct EditPrivsArgs {
/// The privileges to set, grant or revoke, in the format `DATABASE:USER:[+-]PRIVILEGES`
///
/// This option allows for changing privileges for multiple databases and users in batch.
///
/// This can not be used together with the positional `DB_NAME`, `USER_NAME` and `PRIVILEGES` arguments.
#[arg(
short,
long,
value_name = "DB_NAME:USER_NAME:[+-]PRIVILEGES",
num_args = 0..,
value_parser = DatabasePrivilegeEditEntry::parse_from_str,
conflicts_with("single_priv"),
)]
pub privs: Vec<DatabasePrivilegeEditEntry>,
#[command(flatten)]
pub single_priv: Option<SinglePrivilegeEditArgs>,
/// Print the information as JSON
#[arg(short, long)]
pub json: bool,
/// Specify the text editor to use for editing privileges
#[arg(
short,
long,
value_name = "COMMAND",
value_hint = clap::ValueHint::CommandString,
)]
pub editor: Option<String>,
/// Disable interactive confirmation before saving changes
#[arg(short, long)]
pub yes: bool,
}
#[derive(Args, Debug, Clone)]
pub struct SinglePrivilegeEditArgs {
/// The MySQL database to edit privileges for
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_database_completer)))]
#[arg(
value_name = "DB_NAME",
requires = "user_name",
requires = "single_priv"
)]
pub db_name: Option<MySQLDatabase>,
/// The MySQL database to edit privileges for
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_user_completer)))]
#[arg(value_name = "USER_NAME")]
pub user_name: Option<MySQLUser>,
/// The privileges to set, grant or revoke
#[arg(
allow_hyphen_values = true,
value_name = "[+-]PRIVILEGES",
value_parser = DatabasePrivilegeEdit::parse_from_str,
)]
pub single_priv: Option<DatabasePrivilegeEdit>,
}
async fn users_exist(
server_connection: &mut ClientToServerMessageStream,
privilege_diff: &BTreeSet<DatabasePrivilegesDiff>,
) -> anyhow::Result<BTreeMap<MySQLUser, Result<(), ListUsersError>>> {
let user_list = privilege_diff
.iter()
.map(|diff| diff.get_user_name().clone())
.collect();
let message = Request::ListUsers(Some(user_list));
server_connection.send(message).await?;
let result = match server_connection.next().await {
Some(Ok(Response::ListUsers(user_map))) => user_map,
response => {
erroneous_server_response(response)?;
// Unreachable, but needed to satisfy the type checker
BTreeMap::new()
}
};
let result = result
.into_iter()
.map(|(user, user_result)| (user, user_result.map(|_| ())))
.collect();
Ok(result)
}
async fn databases_exist(
server_connection: &mut ClientToServerMessageStream,
privilege_diff: &BTreeSet<DatabasePrivilegesDiff>,
) -> anyhow::Result<BTreeMap<MySQLDatabase, Result<(), ListDatabasesError>>> {
let database_list = privilege_diff
.iter()
.map(|diff| diff.get_database_name().clone())
.collect();
let message = Request::ListDatabases(Some(database_list));
server_connection.send(message).await?;
let result = match server_connection.next().await {
Some(Ok(Response::ListDatabases(database_map))) => database_map,
response => {
erroneous_server_response(response)?;
// Unreachable, but needed to satisfy the type checker
BTreeMap::new()
}
};
let result = result
.into_iter()
.map(|(database, db_result)| (database, db_result.map(|_| ())))
.collect();
Ok(result)
}
// TODO: reduce the complexity of this function
pub async fn edit_database_privileges(
args: EditPrivsArgs,
// NOTE: this is only used for backwards compat with mysql-admutils
use_database: Option<MySQLDatabase>,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
let message = Request::ListPrivileges(use_database.clone().map(|db| vec![db]));
server_connection.send(message).await?;
debug_assert!(args.privs.is_empty() ^ args.single_priv.is_none());
let privs = if let Some(single_priv_entry) = &args.single_priv {
let database = single_priv_entry.db_name.clone().ok_or_else(|| {
anyhow::anyhow!(
"DB_NAME must be specified when editing privileges in single privilege mode"
)
})?;
let user = single_priv_entry.user_name.clone().ok_or_else(|| {
anyhow::anyhow!(
"USER_NAME must be specified when DB_NAME is specified in single privilege mode"
)
})?;
let privilege_edit = single_priv_entry.single_priv.clone().ok_or_else(|| {
anyhow::anyhow!(
"PRIVILEGES must be specified when DB_NAME is specified in single privilege mode"
)
})?;
vec![DatabasePrivilegeEditEntry {
database,
user,
privilege_edit,
}]
} else {
args.privs.clone()
};
let existing_privilege_rows = 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),
};
let diffs: BTreeSet<DatabasePrivilegesDiff> = if !privs.is_empty() {
let privileges_to_change = parse_privilege_tables(&privs)?;
create_or_modify_privilege_rows(&existing_privilege_rows, &privileges_to_change)?
} else {
let privileges_to_change =
edit_privileges_with_editor(&existing_privilege_rows, use_database.as_ref())?;
diff_privileges(&existing_privilege_rows, &privileges_to_change)
};
let database_existence_map = databases_exist(&mut server_connection, &diffs).await?;
let user_existence_map = users_exist(&mut server_connection, &diffs).await?;
let diffs = reduce_privilege_diffs(&existing_privilege_rows, diffs)?
.into_iter()
.filter(|diff| {
let database_name = diff.get_database_name();
let username = diff.get_user_name();
if let Some(Err(err)) = database_existence_map.get(database_name) {
println!("{}", err.to_error_message(database_name));
println!("Skipping...");
return false;
}
if let Some(Err(err)) = user_existence_map.get(username) {
println!("{}", err.to_error_message(username));
println!("Skipping...");
return false;
}
true
})
.collect::<BTreeSet<_>>();
if database_existence_map.values().any(|res| {
matches!(
res,
Err(ListDatabasesError::ValidationError(
ValidationError::AuthorizationError(_)
))
)
}) || user_existence_map.values().any(|res| {
matches!(
res,
Err(ListUsersError::ValidationError(
ValidationError::AuthorizationError(_)
))
)
}) {
println!();
print_authorization_owner_hint(&mut server_connection).await?;
println!();
}
if diffs.is_empty() {
println!("No changes to make.");
server_connection.send(Request::Exit).await?;
return Ok(());
}
println!("The following changes will be made:\n");
println!("{}", display_privilege_diffs(&diffs));
if !args.yes
&& !Confirm::new()
.with_prompt("Do you want to apply these changes?")
.default(false)
.show_default(true)
.interact()?
{
server_connection.send(Request::Exit).await?;
return Ok(());
}
let message = Request::ModifyPrivileges(diffs);
server_connection.send(message).await?;
let result = match server_connection.next().await {
Some(Ok(Response::ModifyPrivileges(result))) => result,
response => return erroneous_server_response(response),
};
print_modify_database_privileges_output_status(&result);
if result.iter().any(|(_, res)| {
matches!(
res,
Err(ModifyDatabasePrivilegesError::UserValidationError(
ValidationError::AuthorizationError(_)
) | ModifyDatabasePrivilegesError::DatabaseValidationError(
ValidationError::AuthorizationError(_)
))
)
}) {
print_authorization_owner_hint(&mut server_connection).await?
}
server_connection.send(Request::Exit).await?;
if result.values().any(|res| res.is_err()) {
std::process::exit(1);
}
Ok(())
}
fn parse_privilege_tables(
privs: &[DatabasePrivilegeEditEntry],
) -> anyhow::Result<BTreeSet<DatabasePrivilegeRowDiff>> {
debug_assert!(!privs.is_empty());
privs
.iter()
.map(|priv_edit_entry| {
priv_edit_entry
.as_database_privileges_diff()
.context(format!(
"Failed parsing database privileges: `{}`",
priv_edit_entry
))
})
.collect::<anyhow::Result<BTreeSet<DatabasePrivilegeRowDiff>>>()
}
fn edit_privileges_with_editor(
privilege_data: &[DatabasePrivilegeRow],
// NOTE: this is only used for backwards compat with mysql-admtools
database_name: Option<&MySQLDatabase>,
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
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 =
generate_editor_content_from_privilege_data(privilege_data, &unix_user.name, database_name);
// TODO: handle errors better here
let result = Editor::new().extension("tsv").edit(&editor_content)?;
match result {
None => Ok(privilege_data.to_vec()),
Some(result) => parse_privilege_data_from_editor_content(result)
.context("Could not parse privilege data from editor"),
}
}