8 Commits

Author SHA1 Message Date
oysteikt 772942eed0 WIP: show-db: limit output to 5 tables/users by default 2026-05-31 05:51:02 +09:00
oysteikt 43e4cc45ca server/sql: great performance improvements for listing databases
Build and test / check-license (push) Successful in 53s
Build and test / check (push) Successful in 1m54s
Build and test / build (push) Successful in 3m5s
Build and test / test (push) Successful in 3m9s
Build and test / docs (push) Successful in 5m36s
2026-05-31 02:01:44 +09:00
oysteikt 62b1b66bb6 CHANGELOG.md: fix broken link
Build and test / check-license (push) Successful in 55s
Build and test / check (push) Successful in 2m38s
Build and test / build (push) Successful in 2m42s
Build and test / test (push) Successful in 3m8s
Build and test / docs (push) Successful in 6m8s
2026-05-31 00:44:52 +09:00
oysteikt f16239aceb server/sql: fixes for new sqlx crate version
Build and test / check-license (push) Successful in 49s
Build and test / check (push) Successful in 1m51s
Build and test / build (push) Successful in 2m42s
Build and test / test (push) Successful in 5m2s
Build and test / docs (push) Successful in 7m6s
2026-05-31 00:24:53 +09:00
oysteikt 8f475eced1 CHANGELOG.md: add release notes, Cargo.toml: bump version number
Build and test / check-license (push) Successful in 49s
Build and test / check (push) Failing after 1m57s
Build and test / test (push) Failing after 2m53s
Build and test / build (push) Failing after 3m11s
Build and test / docs (push) Failing after 4m15s
2026-05-31 00:09:49 +09:00
oysteikt 6849e99c11 flake.lock: bump, Cargo.{toml,lock}: update inputs 2026-05-31 00:09:40 +09:00
oysteikt 759df9ef42 server/sql: flush privileges after modification
Build and test / check-license (push) Successful in 54s
Build and test / check (push) Successful in 1m42s
Build and test / test (push) Successful in 3m34s
Build and test / build (push) Successful in 3m39s
Build and test / docs (push) Successful in 5m33s
2026-04-28 19:10:16 +09:00
oysteikt a64d1fa1bf scripts/download-and-upload-debs: fix download path
Build and test / check-license (push) Successful in 47s
Build and test / check (push) Successful in 2m20s
Build and test / build (push) Successful in 3m6s
Build and test / test (push) Successful in 3m12s
Build and test / docs (push) Successful in 6m20s
2026-04-28 18:32:28 +09:00
15 changed files with 589 additions and 623 deletions
+11 -1
View File
@@ -1,5 +1,15 @@
# Changelog # Changelog
## v1.0.2
Patch release with an important bug fix
### Notable changes
- Run `FLUSH PRIVILEGES` on the server whenever users modify privileges.
- You will have to grant `RELOAD` for the muscl admin user on all databases, see the [installation docs](./docs/installation.md) for details.
- Bump dependencies
## v1.0.1 ## v1.0.1
Patch release with some important bug fixes Patch release with some important bug fixes
@@ -66,7 +76,7 @@ This is the initial release of `muscl`.
interactive tool, there shouldn't have been any scripts relying on the old formatting. interactive tool, there shouldn't have been any scripts relying on the old formatting.
- The configuration file is shared for all variants of the program, and `muscl` will use - The configuration file is shared for all variants of the program, and `muscl` will use
its new logic to look for and parse this file. See the example config and its new logic to look for and parse this file. See the example config and
[installation instructions][installation-instructions] for more information about how to [installation instructions](./docs/installation.md) for more information about how to
configure the software. configure the software.
- The order in which input is validated might be differ from the original - The order in which input is validated might be differ from the original
(e.g. database ownership checks, invalid character checks, existence checks, ...). (e.g. database ownership checks, invalid character checks, existence checks, ...).
Generated
+195 -418
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "muscl" name = "muscl"
version = "1.0.1" version = "1.0.2"
edition = "2024" edition = "2024"
resolver = "2" resolver = "2"
license = "BSD-3-Clause" license = "BSD-3-Clause"
@@ -23,7 +23,7 @@ async-bincode = "0.8.0"
bincode = "2.0.1" bincode = "2.0.1"
clap = { version = "4.6.1", features = ["cargo", "derive"] } clap = { version = "4.6.1", features = ["cargo", "derive"] }
clap-verbosity-flag = { version = "3.0.4", features = [ "tracing" ] } clap-verbosity-flag = { version = "3.0.4", features = [ "tracing" ] }
clap_complete = { version = "4.6.3", features = ["unstable-dynamic"] } clap_complete = { version = "4.6.5", features = ["unstable-dynamic"] }
color-print = "0.3.7" color-print = "0.3.7"
const_format = "0.2.36" const_format = "0.2.36"
derive_more = { version = "2.1.1", features = ["display", "error"] } derive_more = { version = "2.1.1", features = ["display", "error"] }
@@ -32,32 +32,32 @@ futures-util = "0.3.32"
humansize = "2.1.3" humansize = "2.1.3"
indoc = "2.0.7" indoc = "2.0.7"
itertools = "0.14.0" itertools = "0.14.0"
nix = { version = "0.31.2", features = ["fs", "process", "socket", "user"] } nix = { version = "0.31.3", features = ["fs", "process", "socket", "user"] }
num_cpus = "1.17.0" num_cpus = "1.17.0"
prettytable = "0.10.0" prettytable = "0.10.0"
rand = "0.10.1" rand = "0.10.1"
serde = "1.0.228" serde = "1.0.228"
serde_json = { version = "1.0.149", features = ["preserve_order"] } serde_json = { version = "1.0.150", features = ["preserve_order"] }
sqlx = { version = "0.8.6", features = ["runtime-tokio", "mysql", "tls-rustls"] } sqlx = { version = "0.9.0", features = ["runtime-tokio", "mysql", "tls-rustls"] }
thiserror = "2.0.18" thiserror = "2.0.18"
tokio = { version = "1.52.1", features = ["rt-multi-thread", "macros", "signal"] } tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "signal"] }
tokio-serde = { version = "0.9.0", features = ["bincode"] } tokio-serde = { version = "0.9.0", features = ["bincode"] }
tokio-stream = "0.1.18" tokio-stream = "0.1.18"
tokio-util = { version = "0.7.18", features = ["codec", "rt"] } tokio-util = { version = "0.7.18", features = ["codec", "rt"] }
toml = "1.1.2" toml = "1.1.2"
tracing = { version = "0.1.44", features = ["log"] } tracing = { version = "0.1.44", features = ["log"] }
tracing-subscriber = "0.3.23" tracing-subscriber = "0.3.23"
uuid = { version = "1.23.1", features = ["v4"] } uuid = { version = "1.23.2", features = ["v4"] }
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
landlock = "0.4.4" landlock = "0.4.5"
sd-notify = "0.5.0" sd-notify = "0.5.0"
tracing-journald = "0.3.2" tracing-journald = "0.3.2"
[build-dependencies] [build-dependencies]
anyhow = "1.0.102" anyhow = "1.0.102"
build-info-build = "0.0.44" build-info-build = "0.0.44"
git2 = { version = "0.20.4", default-features = false } git2 = { version = "0.21.0", default-features = false }
[dev-dependencies] [dev-dependencies]
pretty_assertions = "1.4.1" pretty_assertions = "1.4.1"
+1 -1
View File
@@ -42,7 +42,7 @@ on the MySQL server as the admin user (or another user with sufficient privilege
```sql ```sql
CREATE USER `muscl`@`localhost` IDENTIFIED BY '<strong_password_here>'; CREATE USER `muscl`@`localhost` IDENTIFIED BY '<strong_password_here>';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mysql`.* TO `muscl`@`localhost`; GRANT SELECT, INSERT, UPDATE, DELETE ON `mysql`.* TO `muscl`@`localhost`;
GRANT GRANT OPTION, CREATE, DROP ON *.* TO `muscl`@`localhost`; GRANT GRANT OPTION, CREATE, DROP, RELOAD ON *.* TO `muscl`@`localhost`;
FLUSH PRIVILEGES; FLUSH PRIVILEGES;
``` ```
Generated
+9 -9
View File
@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"crane": { "crane": {
"locked": { "locked": {
"lastModified": 1774313767, "lastModified": 1780099841,
"narHash": "sha256-hy0XTQND6avzGEUFrJtYBBpFa/POiiaGBr2vpU6Y9tY=", "narHash": "sha256-EVZd2RsbpreRUDSi9rBwPY+ZxoyMaiEBbZxxhljbaS4=",
"owner": "ipetkov", "owner": "ipetkov",
"repo": "crane", "repo": "crane",
"rev": "3d9df76e29656c679c744968b17fbaf28f0e923d", "rev": "0532eb17955225173906d671fb36306bdeb1e2dc",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -17,11 +17,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1775036866, "lastModified": 1779560665,
"narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=", "narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "6201e203d09599479a3b3450ed24fa81537ebc4e", "rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -45,11 +45,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1775099554, "lastModified": 1780110990,
"narHash": "sha256-3xBsGnGDLOFtnPZ1D3j2LU19wpAlYefRKTlkv648rU0=", "narHash": "sha256-6QBThUi7SuK+dgA+DCaEkQGZN4kYx6DpXmK45+MG9zI=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "8d6387ed6d8e6e6672fd3ed4b61b59d44b124d99", "rev": "85570ef134d92a8702de6afd1f6f0209c863fa91",
"type": "github" "type": "github"
}, },
"original": { "original": {
+1 -1
View File
@@ -52,7 +52,7 @@ declare -a OS_VARIANTS=(
for variant in "${OS_VARIANTS[@]}"; do for variant in "${OS_VARIANTS[@]}"; do
echo "Downloading and uploading debs for variant: $variant" echo "Downloading and uploading debs for variant: $variant"
curl "https://git.pvv.ntnu.no/Projects/muscl/actions/runs/$RUN_NUMBER/artifacts/muscl-deb-$variant-$GIT_SHA.zip" --output "$TMPDIR/muscl-deb-$variant-$GIT_SHA.zip" curl "https://git.pvv.ntnu.no/Projects/muscl/actions/runs/$RUN_NUMBER/artifacts/muscl-deb-$variant-$GIT_SHA" --output "$TMPDIR/muscl-deb-$variant-$GIT_SHA.zip"
unzip "$TMPDIR/muscl-deb-$variant-$GIT_SHA.zip" -d "$TMPDIR/muscl-deb-$variant-$GIT_SHA" unzip "$TMPDIR/muscl-deb-$variant-$GIT_SHA.zip" -d "$TMPDIR/muscl-deb-$variant-$GIT_SHA"
+2 -2
View File
@@ -23,7 +23,7 @@ use crate::{
parse_privilege_data_from_editor_content, reduce_privilege_diffs, parse_privilege_data_from_editor_content, reduce_privilege_diffs,
}, },
protocol::{ protocol::{
ClientToServerMessageStream, ListDatabasesError, ListUsersError, ClientToServerMessageStream, ListDatabasesError, ListDatabasesRequest, ListUsersError,
ModifyDatabasePrivilegesError, Request, Response, ModifyDatabasePrivilegesError, Request, Response,
print_modify_database_privileges_output_status, request_validation::ValidationError, print_modify_database_privileges_output_status, request_validation::ValidationError,
}, },
@@ -132,7 +132,7 @@ async fn databases_exist(
.map(|diff| diff.get_database_name().clone()) .map(|diff| diff.get_database_name().clone())
.collect(); .collect();
let message = Request::ListDatabases(Some(database_list)); let message = Request::ListDatabases(ListDatabasesRequest::new(Some(database_list), false));
server_connection.send(message).await?; server_connection.send(message).await?;
let result = match server_connection.next().await { let result = match server_connection.next().await {
+14 -7
View File
@@ -8,8 +8,8 @@ use crate::{
core::{ core::{
completion::mysql_database_completer, completion::mysql_database_completer,
protocol::{ protocol::{
ClientToServerMessageStream, ListDatabasesError, Request, Response, ClientToServerMessageStream, ListDatabasesError, ListDatabasesRequest, Request,
print_list_databases_output_status, print_list_databases_output_status_json, Response, print_list_databases_output_status, print_list_databases_output_status_json,
request_validation::ValidationError, request_validation::ValidationError,
}, },
types::MySQLDatabase, types::MySQLDatabase,
@@ -27,6 +27,10 @@ pub struct ShowDbArgs {
#[arg(short, long)] #[arg(short, long)]
json: bool, json: bool,
/// Show all tables and users for each database
#[arg(short = 'a', long)]
all: bool,
/// Show sizes in bytes instead of human-readable format /// Show sizes in bytes instead of human-readable format
#[arg(short, long)] #[arg(short, long)]
bytes: bool, bytes: bool,
@@ -36,11 +40,14 @@ pub async fn show_databases(
args: ShowDbArgs, args: ShowDbArgs,
mut server_connection: ClientToServerMessageStream, mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let message = if args.name.is_empty() { let message = Request::ListDatabases(ListDatabasesRequest::new(
Request::ListDatabases(None) if args.name.is_empty() {
} else { None
Request::ListDatabases(Some(args.name.clone())) } else {
}; Some(args.name.clone())
},
args.all || args.json,
));
server_connection.send(message).await?; server_connection.send(message).await?;
@@ -22,8 +22,8 @@ use crate::{
completion::{mysql_database_completer, prefix_completer}, completion::{mysql_database_completer, prefix_completer},
database_privileges::DatabasePrivilegeRow, database_privileges::DatabasePrivilegeRow,
protocol::{ protocol::{
ClientToServerMessageStream, ListPrivilegesError, Request, Response, ClientToServerMessageStream, ListDatabasesRequest, ListPrivilegesError, Request,
create_client_to_server_message_stream, Response, create_client_to_server_message_stream,
}, },
types::MySQLDatabase, types::MySQLDatabase,
}, },
@@ -285,7 +285,7 @@ async fn show_databases(
args.name.iter().map(trim_db_name_to_32_chars).collect(); args.name.iter().map(trim_db_name_to_32_chars).collect();
let message = if database_names.is_empty() { let message = if database_names.is_empty() {
let message = Request::ListDatabases(None); let message = Request::ListDatabases(ListDatabasesRequest::new(None, false));
server_connection.send(message).await?; server_connection.send(message).await?;
let response = server_connection.next().await; let response = server_connection.next().await;
let databases = match response { let databases = match response {
+8 -4
View File
@@ -142,7 +142,8 @@ impl Request {
Request::ListDatabases(req) => format!( Request::ListDatabases(req) => format!(
"{}{}", "{}{}",
self.command_name(), self.command_name(),
req.as_ref() req.names
.as_ref()
.map_or("".to_string(), |r| format!("({})", r.len())) .map_or("".to_string(), |r| format!("({})", r.len()))
), ),
Request::ListPrivileges(req) => format!( Request::ListPrivileges(req) => format!(
@@ -206,9 +207,12 @@ impl Request {
Request::CompleteUserName(_) => Default::default(), Request::CompleteUserName(_) => Default::default(),
Request::CreateDatabases(databases) => databases.iter().cloned().collect(), Request::CreateDatabases(databases) => databases.iter().cloned().collect(),
Request::DropDatabases(databases) => databases.iter().cloned().collect(), Request::DropDatabases(databases) => databases.iter().cloned().collect(),
Request::ListDatabases(databases) => { Request::ListDatabases(request) => request
databases.clone().unwrap_or_default().into_iter().collect() .names
} .clone()
.unwrap_or_default()
.into_iter()
.collect(),
Request::ListPrivileges(databases) => { Request::ListPrivileges(databases) => {
databases.clone().unwrap_or_default().into_iter().collect() databases.clone().unwrap_or_default().into_iter().collect()
} }
+30 -2
View File
@@ -14,7 +14,21 @@ use crate::{
server::sql::database_operations::DatabaseRow, server::sql::database_operations::DatabaseRow,
}; };
pub type ListDatabasesRequest = Option<Vec<MySQLDatabase>>; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ListDatabasesRequest {
pub names: Option<Vec<MySQLDatabase>>,
#[serde(default)]
pub include_all_tables_and_users: bool,
}
impl ListDatabasesRequest {
pub fn new(names: Option<Vec<MySQLDatabase>>, include_all_tables_and_users: bool) -> Self {
Self {
names,
include_all_tables_and_users,
}
}
}
pub type ListDatabasesResponse = BTreeMap<MySQLDatabase, Result<DatabaseRow, ListDatabasesError>>; pub type ListDatabasesResponse = BTreeMap<MySQLDatabase, Result<DatabaseRow, ListDatabasesError>>;
@@ -144,7 +158,7 @@ mod tests {
#[test] #[test]
fn test_serialize_deserialize_request() { fn test_serialize_deserialize_request() {
let request = Some(vec!["db1".into(), "db2".into()]); let request = ListDatabasesRequest::new(Some(vec!["db1".into(), "db2".into()]), true);
let json = serde_json::to_string_pretty(&request).unwrap(); let json = serde_json::to_string_pretty(&request).unwrap();
println!("Serialized request:\n{}", json); println!("Serialized request:\n{}", json);
@@ -152,6 +166,20 @@ mod tests {
assert_eq!(request, deserialized); assert_eq!(request, deserialized);
} }
#[test]
fn test_deserialize_request_without_include_all_tables_and_users_defaults_to_false() {
let json = serde_json::json!({
"names": ["db1", "db2"]
})
.to_string();
let deserialized: ListDatabasesRequest = serde_json::from_str(&json).unwrap();
assert_eq!(
deserialized,
ListDatabasesRequest::new(Some(vec!["db1".into(), "db2".into()]), false)
);
}
#[test] #[test]
fn test_serialize_deserialize_response() { fn test_serialize_deserialize_response() {
let response: ListDatabasesResponse = vec![ let response: ListDatabasesResponse = vec![
+4 -2
View File
@@ -332,14 +332,15 @@ async fn handle_request(
.await; .await;
Response::DropDatabases(result) Response::DropDatabases(result)
} }
Request::ListDatabases(ref database_names) => { Request::ListDatabases(ref request) => {
if let Some(database_names) = database_names { if let Some(database_names) = &request.names {
let result = list_databases( let result = list_databases(
database_names, database_names,
unix_user, unix_user,
db_connection, db_connection,
db_is_mariadb, db_is_mariadb,
group_denylist, group_denylist,
request.include_all_tables_and_users,
) )
.await; .await;
Response::ListDatabases(result) Response::ListDatabases(result)
@@ -349,6 +350,7 @@ async fn handle_request(
db_connection, db_connection,
db_is_mariadb, db_is_mariadb,
group_denylist, group_denylist,
request.include_all_tables_and_users,
) )
.await; .await;
Response::ListAllDatabases(result) Response::ListAllDatabases(result)
+190 -66
View File
@@ -1,5 +1,6 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use sqlx::AssertSqlSafe;
use sqlx::MySqlConnection; use sqlx::MySqlConnection;
use sqlx::prelude::*; use sqlx::prelude::*;
@@ -23,6 +24,8 @@ use crate::{
server::{common::create_user_group_matching_regex, sql::quote_identifier}, server::{common::create_user_group_matching_regex, sql::quote_identifier},
}; };
const MAX_SHOW_DB_RELATED_ITEMS: usize = 5;
// NOTE: this function is unsafe because it does no input validation. // NOTE: this function is unsafe because it does no input validation.
pub(super) async fn unsafe_database_exists( pub(super) async fn unsafe_database_exists(
database_name: &str, database_name: &str,
@@ -125,12 +128,15 @@ pub async fn create_databases(
_ => {} _ => {}
} }
let result = let statement = AssertSqlSafe(format!(
sqlx::query(format!("CREATE DATABASE {}", quote_identifier(&database_name)).as_str()) "CREATE DATABASE {}",
.execute(&mut *connection) quote_identifier(&database_name)
.await ));
.map(|_| ()) let result = sqlx::query(statement)
.map_err(|err| CreateDatabaseError::MySqlError(err.to_string())); .execute(&mut *connection)
.await
.map(|_| ())
.map_err(|err| CreateDatabaseError::MySqlError(err.to_string()));
if let Err(err) = &result { if let Err(err) = &result {
tracing::error!("Failed to create database '{}': {:?}", &database_name, err); tracing::error!("Failed to create database '{}': {:?}", &database_name, err);
@@ -181,12 +187,15 @@ pub async fn drop_databases(
_ => {} _ => {}
} }
let result = let statement = AssertSqlSafe(format!(
sqlx::query(format!("DROP DATABASE {}", quote_identifier(&database_name)).as_str()) "DROP DATABASE {}",
.execute(&mut *connection) quote_identifier(&database_name)
.await ));
.map(|_| ()) let result = sqlx::query(statement)
.map_err(|err| DropDatabaseError::MySqlError(err.to_string())); .execute(&mut *connection)
.await
.map(|_| ())
.map_err(|err| DropDatabaseError::MySqlError(err.to_string()));
if let Err(err) = &result { if let Err(err) = &result {
tracing::error!("Failed to drop database '{}': {:?}", &database_name, err); tracing::error!("Failed to drop database '{}': {:?}", &database_name, err);
@@ -241,12 +250,84 @@ impl FromRow<'_, sqlx::mysql::MySqlRow> for DatabaseRow {
} }
} }
fn list_database_query(include_all_tables_and_users: bool) -> AssertSqlSafe<String> {
let limit_clause = if include_all_tables_and_users {
"".to_string()
} else {
format!(" LIMIT {}", MAX_SHOW_DB_RELATED_ITEMS)
};
AssertSqlSafe(format!(
r"
SELECT
CAST(s.SCHEMA_NAME AS CHAR(64)) AS `database`,
t.tables,
u.users,
s.DEFAULT_COLLATION_NAME AS `collation`,
s.DEFAULT_CHARACTER_SET_NAME AS `character_set`,
CAST(COALESCE(sz.size_bytes, 0) AS UNSIGNED) AS size_bytes
FROM information_schema.SCHEMATA s
LEFT JOIN (
SELECT
x.TABLE_SCHEMA,
GROUP_CONCAT(x.TABLE_NAME ORDER BY x.TABLE_NAME SEPARATOR ',') AS tables
FROM (
SELECT
TABLE_SCHEMA,
TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = ?
ORDER BY TABLE_NAME{limit_clause}
) x
GROUP BY x.TABLE_SCHEMA
) t
ON t.TABLE_SCHEMA = s.SCHEMA_NAME
LEFT JOIN (
SELECT
x.DB,
GROUP_CONCAT(DISTINCT x.User ORDER BY x.User SEPARATOR ',') AS users
FROM (
SELECT
DB,
User
FROM mysql.db
WHERE DB = ?
ORDER BY User{limit_clause}
) x
GROUP BY x.DB
) u
ON u.DB = s.SCHEMA_NAME
LEFT JOIN (
SELECT
TABLE_SCHEMA,
SUM(DATA_LENGTH + INDEX_LENGTH) AS size_bytes
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = ?
GROUP BY TABLE_SCHEMA
) sz
ON sz.TABLE_SCHEMA = s.SCHEMA_NAME
WHERE s.SCHEMA_NAME REGEXP ?
AND s.SCHEMA_NAME NOT IN (
'information_schema',
'performance_schema',
'mysql',
'sys'
)
"
))
}
pub async fn list_databases( pub async fn list_databases(
database_names: &[MySQLDatabase], database_names: &[MySQLDatabase],
unix_user: &UnixUser, unix_user: &UnixUser,
connection: &mut MySqlConnection, connection: &mut MySqlConnection,
_db_is_mariadb: bool, _db_is_mariadb: bool,
group_denylist: &GroupDenylist, group_denylist: &GroupDenylist,
include_all_tables_and_users: bool,
) -> ListDatabasesResponse { ) -> ListDatabasesResponse {
let mut results = BTreeMap::new(); let mut results = BTreeMap::new();
@@ -262,35 +343,19 @@ pub async fn list_databases(
continue; continue;
} }
let result = sqlx::query_as::<_, DatabaseRow>( let query = list_database_query(include_all_tables_and_users);
r"
SELECT
CAST(`information_schema`.`SCHEMATA`.`SCHEMA_NAME` AS CHAR(64)) AS `database`,
GROUP_CONCAT(DISTINCT CAST(`information_schema`.`TABLES`.`TABLE_NAME` AS CHAR(64)) SEPARATOR ',') AS `tables`,
GROUP_CONCAT(DISTINCT CAST(`mysql`.`db`.`User` AS CHAR(64)) SEPARATOR ',') AS `users`,
MAX(`information_schema`.`SCHEMATA`.`DEFAULT_COLLATION_NAME`) AS `collation`,
MAX(`information_schema`.`SCHEMATA`.`DEFAULT_CHARACTER_SET_NAME`) AS `character_set`,
CAST(IFNULL(
SUM(`information_schema`.`TABLES`.`DATA_LENGTH` + `information_schema`.`TABLES`.`INDEX_LENGTH`),
0
) AS UNSIGNED INTEGER) AS `size_bytes`
FROM `information_schema`.`SCHEMATA`
LEFT OUTER JOIN `information_schema`.`TABLES`
ON `information_schema`.`SCHEMATA`.`SCHEMA_NAME` = `TABLES`.`TABLE_SCHEMA`
LEFT OUTER JOIN `mysql`.`db`
ON `information_schema`.`SCHEMATA`.`SCHEMA_NAME` = `mysql`.`db`.`DB`
WHERE `information_schema`.`SCHEMATA`.`SCHEMA_NAME` = ?
GROUP BY `information_schema`.`SCHEMATA`.`SCHEMA_NAME`
",
) let result = sqlx::query_as::<_, DatabaseRow>(query)
.bind(database_name.to_string()) .bind(database_name.to_string())
.fetch_optional(&mut *connection) .bind(database_name.to_string())
.await .bind(database_name.to_string())
.map_err(|err| ListDatabasesError::MySqlError(err.to_string())) .bind(database_name.to_string())
.and_then(|database| { .fetch_optional(&mut *connection)
database.map_or_else(|| Err(ListDatabasesError::DatabaseDoesNotExist), Ok) .await
}); .map_err(|err| ListDatabasesError::MySqlError(err.to_string()))
.and_then(|database| {
database.map_or_else(|| Err(ListDatabasesError::DatabaseDoesNotExist), Ok)
});
if let Err(err) = &result { if let Err(err) = &result {
tracing::error!("Failed to list database '{}': {:?}", &database_name, err); tracing::error!("Failed to list database '{}': {:?}", &database_name, err);
@@ -304,38 +369,97 @@ pub async fn list_databases(
results results
} }
fn list_all_databases_for_user_query(include_all_tables_and_users: bool) -> AssertSqlSafe<String> {
let limit_clause = if include_all_tables_and_users {
"".to_string()
} else {
format!(" LIMIT {}", MAX_SHOW_DB_RELATED_ITEMS)
};
AssertSqlSafe(format!(
r"
SELECT
CAST(s.SCHEMA_NAME AS CHAR(64)) AS `database`,
t.tables,
u.users,
s.DEFAULT_COLLATION_NAME AS `collation`,
s.DEFAULT_CHARACTER_SET_NAME AS `character_set`,
CAST(COALESCE(sz.size_bytes, 0) AS UNSIGNED) AS size_bytes
FROM information_schema.SCHEMATA s
LEFT JOIN (
SELECT
x.TABLE_SCHEMA,
GROUP_CONCAT(x.TABLE_NAME ORDER BY x.TABLE_NAME SEPARATOR ',') AS tables
FROM (
SELECT
TABLE_SCHEMA,
TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA REGEXP ?
ORDER BY TABLE_NAME{limit_clause}
) x
GROUP BY x.TABLE_SCHEMA
) t
ON t.TABLE_SCHEMA = s.SCHEMA_NAME
LEFT JOIN (
SELECT
x.DB,
GROUP_CONCAT(DISTINCT x.User ORDER BY x.User SEPARATOR ',') AS users
FROM (
SELECT
DB,
User
FROM mysql.db
WHERE DB REGEXP ?
ORDER BY User{limit_clause}
) x
GROUP BY x.DB
) u
ON u.DB = s.SCHEMA_NAME
LEFT JOIN (
SELECT
TABLE_SCHEMA,
SUM(DATA_LENGTH + INDEX_LENGTH) AS size_bytes
FROM information_schema.TABLES
WHERE TABLE_SCHEMA REGEXP ?
GROUP BY TABLE_SCHEMA
) sz
ON sz.TABLE_SCHEMA = s.SCHEMA_NAME
WHERE s.SCHEMA_NAME REGEXP ?
AND s.SCHEMA_NAME NOT IN (
'information_schema',
'performance_schema',
'mysql',
'sys'
)
ORDER BY s.SCHEMA_NAME
"
))
}
pub async fn list_all_databases_for_user( pub async fn list_all_databases_for_user(
unix_user: &UnixUser, unix_user: &UnixUser,
connection: &mut MySqlConnection, connection: &mut MySqlConnection,
_db_is_mariadb: bool, _db_is_mariadb: bool,
group_denylist: &GroupDenylist, group_denylist: &GroupDenylist,
include_all_tables_and_users: bool,
) -> ListAllDatabasesResponse { ) -> ListAllDatabasesResponse {
let result = sqlx::query_as::<_, DatabaseRow>( let query = list_all_databases_for_user_query(include_all_tables_and_users);
r" let user_group_regex = create_user_group_matching_regex(unix_user, group_denylist);
SELECT
CAST(`information_schema`.`SCHEMATA`.`SCHEMA_NAME` AS CHAR(64)) AS `database`, let result = sqlx::query_as::<_, DatabaseRow>(query)
GROUP_CONCAT(DISTINCT CAST(`information_schema`.`TABLES`.`TABLE_NAME` AS CHAR(64)) SEPARATOR ',') AS `tables`, .bind(&user_group_regex)
GROUP_CONCAT(DISTINCT CAST(`mysql`.`db`.`User` AS CHAR(64)) SEPARATOR ',') AS `users`, .bind(&user_group_regex)
MAX(`information_schema`.`SCHEMATA`.`DEFAULT_COLLATION_NAME`) AS `collation`, .bind(&user_group_regex)
MAX(`information_schema`.`SCHEMATA`.`DEFAULT_CHARACTER_SET_NAME`) AS `character_set`, .bind(&user_group_regex)
CAST(IFNULL( .fetch_all(connection)
SUM(`information_schema`.`TABLES`.`DATA_LENGTH` + `information_schema`.`TABLES`.`INDEX_LENGTH`), .await
0 .map_err(|err| ListAllDatabasesError::MySqlError(err.to_string()));
) AS UNSIGNED INTEGER) AS `size_bytes`
FROM `information_schema`.`SCHEMATA`
LEFT OUTER JOIN `information_schema`.`TABLES`
ON `information_schema`.`SCHEMATA`.`SCHEMA_NAME` = `TABLES`.`TABLE_SCHEMA`
LEFT OUTER JOIN `mysql`.`db`
ON `information_schema`.`SCHEMATA`.`SCHEMA_NAME` = `mysql`.`db`.`DB`
WHERE `information_schema`.`SCHEMATA`.`SCHEMA_NAME` NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')
AND `information_schema`.`SCHEMATA`.`SCHEMA_NAME` REGEXP ?
GROUP BY `information_schema`.`SCHEMATA`.`SCHEMA_NAME`
",
)
.bind(create_user_group_matching_regex(unix_user, group_denylist))
.fetch_all(connection)
.await
.map_err(|err| ListAllDatabasesError::MySqlError(err.to_string()));
// TODO: should we assert that the users are also owned by the unix_user from the request? // TODO: should we assert that the users are also owned by the unix_user from the request?
+47 -38
View File
@@ -18,7 +18,7 @@ use std::collections::{BTreeMap, BTreeSet};
use indoc::indoc; use indoc::indoc;
use itertools::Itertools; use itertools::Itertools;
use sqlx::{MySqlConnection, mysql::MySqlRow, prelude::*}; use sqlx::{AssertSqlSafe, MySqlConnection, mysql::MySqlRow, prelude::*};
use crate::{ use crate::{
core::{ core::{
@@ -84,16 +84,17 @@ async fn unsafe_get_database_privileges(
database_name: &str, database_name: &str,
connection: &mut MySqlConnection, connection: &mut MySqlConnection,
) -> Result<Vec<DatabasePrivilegeRow>, sqlx::Error> { ) -> Result<Vec<DatabasePrivilegeRow>, sqlx::Error> {
let result = sqlx::query_as::<_, DatabasePrivilegeRow>(&format!( let statement = AssertSqlSafe(format!(
"SELECT {} FROM `db` WHERE `Db` = ?", "SELECT {} FROM `db` WHERE `Db` = ?",
DATABASE_PRIVILEGE_FIELDS DATABASE_PRIVILEGE_FIELDS
.iter() .iter()
.map(|field| quote_identifier(field)) .map(|field| quote_identifier(field))
.join(","), .join(","),
)) ));
.bind(database_name) let result = sqlx::query_as::<_, DatabasePrivilegeRow>(statement)
.fetch_all(connection) .bind(database_name)
.await; .fetch_all(connection)
.await;
if let Err(e) = &result { if let Err(e) = &result {
tracing::error!( tracing::error!(
@@ -113,17 +114,18 @@ pub async fn unsafe_get_database_privileges_for_db_user_pair(
user_name: &MySQLUser, user_name: &MySQLUser,
connection: &mut MySqlConnection, connection: &mut MySqlConnection,
) -> Result<Option<DatabasePrivilegeRow>, sqlx::Error> { ) -> Result<Option<DatabasePrivilegeRow>, sqlx::Error> {
let result = sqlx::query_as::<_, DatabasePrivilegeRow>(&format!( let statement = AssertSqlSafe(format!(
"SELECT {} FROM `db` WHERE `Db` = ? AND `User` = ? AND `Host` = '%'", "SELECT {} FROM `db` WHERE `Db` = ? AND `User` = ? AND `Host` = '%'",
DATABASE_PRIVILEGE_FIELDS DATABASE_PRIVILEGE_FIELDS
.iter() .iter()
.map(|field| quote_identifier(field)) .map(|field| quote_identifier(field))
.join(","), .join(","),
)) ));
.bind(database_name.as_str()) let result = sqlx::query_as::<_, DatabasePrivilegeRow>(statement)
.bind(user_name.as_str()) .bind(database_name.as_str())
.fetch_optional(connection) .bind(user_name.as_str())
.await; .fetch_optional(connection)
.await;
if let Err(e) = &result { if let Err(e) = &result {
tracing::error!( tracing::error!(
@@ -189,8 +191,8 @@ pub async fn get_databases_privilege_data(
} }
/// TODO: make this constant /// TODO: make this constant
fn get_all_db_privs_query() -> String { fn get_all_db_privs_query() -> AssertSqlSafe<String> {
format!( AssertSqlSafe(format!(
indoc! {r" indoc! {r"
SELECT {} FROM `db` WHERE `db` IN SELECT {} FROM `db` WHERE `db` IN
(SELECT DISTINCT CAST(`SCHEMA_NAME` AS CHAR(64)) AS `database` (SELECT DISTINCT CAST(`SCHEMA_NAME` AS CHAR(64)) AS `database`
@@ -202,7 +204,7 @@ fn get_all_db_privs_query() -> String {
.iter() .iter()
.map(|field| quote_identifier(field)) .map(|field| quote_identifier(field))
.join(","), .join(","),
) ))
} }
/// Get all database + user + privileges pairs that are owned by the current user. /// Get all database + user + privileges pairs that are owned by the current user.
@@ -212,7 +214,7 @@ pub async fn get_all_database_privileges(
_db_is_mariadb: bool, _db_is_mariadb: bool,
group_denylist: &GroupDenylist, group_denylist: &GroupDenylist,
) -> ListAllPrivilegesResponse { ) -> ListAllPrivilegesResponse {
let result = sqlx::query_as::<_, DatabasePrivilegeRow>(&get_all_db_privs_query()) let result = sqlx::query_as::<_, DatabasePrivilegeRow>(get_all_db_privs_query())
.bind(create_user_group_matching_regex(unix_user, group_denylist)) .bind(create_user_group_matching_regex(unix_user, group_denylist))
.fetch_all(connection) .fetch_all(connection)
.await .await
@@ -241,7 +243,10 @@ async fn unsafe_apply_privilege_diff(
let question_marks = let question_marks =
std::iter::repeat_n("?", DATABASE_PRIVILEGE_FIELDS.len() + 1).join(","); std::iter::repeat_n("?", DATABASE_PRIVILEGE_FIELDS.len() + 1).join(",");
sqlx::query(format!("INSERT INTO `db` ({tables}) VALUES ({question_marks})").as_str()) let statement = AssertSqlSafe(format!(
"INSERT INTO `db` ({tables}) VALUES ({question_marks})"
));
sqlx::query(statement)
.bind(p.db.to_string()) .bind(p.db.to_string())
.bind(p.user.to_string()) .bind(p.user.to_string())
.bind(yn(p.select_priv)) .bind(yn(p.select_priv))
@@ -280,27 +285,27 @@ async fn unsafe_apply_privilege_diff(
} }
} }
sqlx::query( let statement = AssertSqlSafe(format!(
format!("UPDATE `db` SET {changes} WHERE `Db` = ? AND `User` = ? AND `Host` = ?") "UPDATE `db` SET {changes} WHERE `Db` = ? AND `User` = ? AND `Host` = ?"
.as_str(), ));
) sqlx::query(statement)
.bind(p.select_priv.map(change_to_yn)) .bind(p.select_priv.map(change_to_yn))
.bind(p.insert_priv.map(change_to_yn)) .bind(p.insert_priv.map(change_to_yn))
.bind(p.update_priv.map(change_to_yn)) .bind(p.update_priv.map(change_to_yn))
.bind(p.delete_priv.map(change_to_yn)) .bind(p.delete_priv.map(change_to_yn))
.bind(p.create_priv.map(change_to_yn)) .bind(p.create_priv.map(change_to_yn))
.bind(p.drop_priv.map(change_to_yn)) .bind(p.drop_priv.map(change_to_yn))
.bind(p.alter_priv.map(change_to_yn)) .bind(p.alter_priv.map(change_to_yn))
.bind(p.index_priv.map(change_to_yn)) .bind(p.index_priv.map(change_to_yn))
.bind(p.create_tmp_table_priv.map(change_to_yn)) .bind(p.create_tmp_table_priv.map(change_to_yn))
.bind(p.lock_tables_priv.map(change_to_yn)) .bind(p.lock_tables_priv.map(change_to_yn))
.bind(p.references_priv.map(change_to_yn)) .bind(p.references_priv.map(change_to_yn))
.bind(p.db.to_string()) .bind(p.db.to_string())
.bind(p.user.to_string()) .bind(p.user.to_string())
.bind("%") .bind("%")
.execute(connection) .execute(connection)
.await .await
.map(|_| ()) .map(|_| ())
} }
DatabasePrivilegesDiff::Deleted(p) => { DatabasePrivilegesDiff::Deleted(p) => {
sqlx::query("DELETE FROM `db` WHERE `Db` = ? AND `User` = ? AND `Host` = ?") sqlx::query("DELETE FROM `db` WHERE `Db` = ? AND `User` = ? AND `Host` = ?")
@@ -487,6 +492,10 @@ pub async fn apply_privilege_diffs(
results.insert(key, result); results.insert(key, result);
} }
if let Err(err) = connection.execute("FLUSH PRIVILEGES").await {
tracing::error!("Failed to flush privileges: {}", err);
}
results results
.into_iter() .into_iter()
.map(|((k1, k2), v)| (k1, (k2, v))) .map(|((k1, k2), v)| (k1, (k2, v)))
+65 -60
View File
@@ -1,5 +1,6 @@
use indoc::formatdoc; use indoc::formatdoc;
use itertools::Itertools; use itertools::Itertools;
use sqlx::AssertSqlSafe;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -126,7 +127,8 @@ pub async fn create_database_users(
_ => {} _ => {}
} }
let result = sqlx::query(format!("CREATE USER {}@'%'", quote_literal(&db_user),).as_str()) let statement = AssertSqlSafe(format!("CREATE USER {}@'%'", quote_literal(&db_user),));
let result = sqlx::query(statement)
.execute(&mut *connection) .execute(&mut *connection)
.await .await
.map(|_| ()) .map(|_| ())
@@ -172,7 +174,8 @@ pub async fn drop_database_users(
_ => {} _ => {}
} }
let result = sqlx::query(format!("DROP USER {}@'%'", quote_literal(&db_user),).as_str()) let statement = AssertSqlSafe(format!("DROP USER {}@'%'", quote_literal(&db_user),));
let result = sqlx::query(statement)
.execute(&mut *connection) .execute(&mut *connection)
.await .await
.map(|_| ()) .map(|_| ())
@@ -205,18 +208,16 @@ pub async fn set_password_for_database_user(
_ => {} _ => {}
} }
let result = sqlx::query( let statement = AssertSqlSafe(format!(
format!( "ALTER USER {}@'%' IDENTIFIED BY {}",
"ALTER USER {}@'%' IDENTIFIED BY {}", quote_literal(db_user),
quote_literal(db_user), quote_literal(password).as_str(),
quote_literal(password).as_str(), ));
) let result = sqlx::query(statement)
.as_str(), .execute(&mut *connection)
) .await
.execute(&mut *connection) .map(|_| ())
.await .map_err(|err| SetPasswordError::MySqlError(err.to_string()));
.map(|_| ())
.map_err(|err| SetPasswordError::MySqlError(err.to_string()));
if result.is_err() { if result.is_err() {
tracing::error!( tracing::error!(
@@ -315,13 +316,15 @@ pub async fn lock_database_users(
} }
} }
let result = sqlx::query( let statement = AssertSqlSafe(format!(
format!("ALTER USER {}@'%' ACCOUNT LOCK", quote_literal(&db_user),).as_str(), "ALTER USER {}@'%' ACCOUNT LOCK",
) quote_literal(&db_user),
.execute(&mut *connection) ));
.await let result = sqlx::query(statement)
.map(|_| ()) .execute(&mut *connection)
.map_err(|err| LockUserError::MySqlError(err.to_string())); .await
.map(|_| ())
.map_err(|err| LockUserError::MySqlError(err.to_string()));
if let Err(err) = &result { if let Err(err) = &result {
tracing::error!("Failed to lock database user '{}': {:?}", &db_user, err); tracing::error!("Failed to lock database user '{}': {:?}", &db_user, err);
@@ -375,13 +378,15 @@ pub async fn unlock_database_users(
_ => {} _ => {}
} }
let result = sqlx::query( let statement = AssertSqlSafe(format!(
format!("ALTER USER {}@'%' ACCOUNT UNLOCK", quote_literal(&db_user),).as_str(), "ALTER USER {}@'%' ACCOUNT UNLOCK",
) quote_literal(&db_user),
.execute(&mut *connection) ));
.await let result = sqlx::query(statement)
.map(|_| ()) .execute(&mut *connection)
.map_err(|err| UnlockUserError::MySqlError(err.to_string())); .await
.map(|_| ())
.map_err(|err| UnlockUserError::MySqlError(err.to_string()));
if let Err(err) = &result { if let Err(err) = &result {
tracing::error!("Failed to unlock database user '{}': {:?}", &db_user, err); tracing::error!("Failed to unlock database user '{}': {:?}", &db_user, err);
@@ -459,16 +464,17 @@ pub async fn list_database_users(
continue; continue;
} }
let mut result = sqlx::query_as::<_, DatabaseUser>( let statement = AssertSqlSafe(
&(if db_is_mariadb { if db_is_mariadb {
DB_USER_SELECT_STATEMENT_MARIADB.to_string() DB_USER_SELECT_STATEMENT_MARIADB.to_string()
} else { } else {
DB_USER_SELECT_STATEMENT_MYSQL.to_string() DB_USER_SELECT_STATEMENT_MYSQL.to_string()
} + "WHERE `mysql`.`user`.`User` = ? AND `mysql`.`user`.`Host` = '%'"), } + "WHERE `mysql`.`user`.`User` = ? AND `mysql`.`user`.`Host` = '%'",
) );
.bind(db_user.as_str()) let mut result = sqlx::query_as::<_, DatabaseUser>(statement)
.fetch_optional(&mut *connection) .bind(db_user.as_str())
.await; .fetch_optional(&mut *connection)
.await;
if let Err(err) = &result { if let Err(err) = &result {
tracing::error!("Failed to list database user '{}': {:?}", &db_user, err); tracing::error!("Failed to list database user '{}': {:?}", &db_user, err);
@@ -496,17 +502,18 @@ pub async fn list_all_database_users_for_unix_user(
db_is_mariadb: bool, db_is_mariadb: bool,
group_denylist: &GroupDenylist, group_denylist: &GroupDenylist,
) -> ListAllUsersResponse { ) -> ListAllUsersResponse {
let mut result = sqlx::query_as::<_, DatabaseUser>( let statement = AssertSqlSafe(
&(if db_is_mariadb { if db_is_mariadb {
DB_USER_SELECT_STATEMENT_MARIADB.to_string() DB_USER_SELECT_STATEMENT_MARIADB.to_string()
} else { } else {
DB_USER_SELECT_STATEMENT_MYSQL.to_string() DB_USER_SELECT_STATEMENT_MYSQL.to_string()
} + "WHERE `user`.`User` REGEXP ? AND `user`.`Host` = '%'"), } + "WHERE `user`.`User` REGEXP ? AND `user`.`Host` = '%'",
) );
.bind(create_user_group_matching_regex(unix_user, group_denylist)) let mut result = sqlx::query_as::<_, DatabaseUser>(statement)
.fetch_all(&mut *connection) .bind(create_user_group_matching_regex(unix_user, group_denylist))
.await .fetch_all(&mut *connection)
.map_err(|err| ListAllUsersError::MySqlError(err.to_string())); .await
.map_err(|err| ListAllUsersError::MySqlError(err.to_string()));
if let Err(err) = &result { if let Err(err) = &result {
tracing::error!("Failed to list all database users: {:?}", err); tracing::error!("Failed to list all database users: {:?}", err);
@@ -531,23 +538,21 @@ pub async fn set_databases_where_user_has_privileges(
db_user: &mut DatabaseUser, db_user: &mut DatabaseUser,
connection: &mut MySqlConnection, connection: &mut MySqlConnection,
) -> Result<(), sqlx::Error> { ) -> Result<(), sqlx::Error> {
let database_list = sqlx::query( let statement = AssertSqlSafe(formatdoc!(
formatdoc!( r"
r" SELECT `Db` AS `database`
SELECT `Db` AS `database` FROM `db`
FROM `db` WHERE `User` = ? AND `Host` = '%' AND ({})
WHERE `User` = ? AND `Host` = '%' AND ({}) ",
", DATABASE_PRIVILEGE_FIELDS
DATABASE_PRIVILEGE_FIELDS .iter()
.iter() .map(|field| format!("`{field}` = 'Y'"))
.map(|field| format!("`{field}` = 'Y'")) .join(" OR "),
.join(" OR "), ));
) let database_list = sqlx::query(statement)
.as_str(), .bind(db_user.user.as_str())
) .fetch_all(&mut *connection)
.bind(db_user.user.as_str()) .await;
.fetch_all(&mut *connection)
.await;
if let Err(err) = &database_list { if let Err(err) = &database_list {
tracing::error!( tracing::error!(