Compare commits
1 Commits
4bccbdbb2d
...
711be6517a
Author | SHA1 | Date |
---|---|---|
Oystein Kristoffer Tveit | 711be6517a |
File diff suppressed because it is too large
Load Diff
39
Cargo.toml
39
Cargo.toml
|
@ -15,33 +15,21 @@ keywords = ["mysql", "cli", "administration"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.86"
|
anyhow = "1.0.82"
|
||||||
async-bincode = "0.7.2"
|
clap = { version = "4.5.4", features = ["derive"] }
|
||||||
bincode = "1.3.3"
|
|
||||||
clap = { version = "4.5.16", features = ["derive"] }
|
|
||||||
clap_complete = "4.5.18"
|
|
||||||
derive_more = { version = "1.0.0", features = ["display", "error"] }
|
|
||||||
dialoguer = "0.11.0"
|
dialoguer = "0.11.0"
|
||||||
env_logger = "0.11.5"
|
env_logger = "0.11.3"
|
||||||
futures = "0.3.30"
|
|
||||||
futures-util = "0.3.30"
|
|
||||||
indoc = "2.0.5"
|
indoc = "2.0.5"
|
||||||
itertools = "0.13.0"
|
itertools = "0.12.1"
|
||||||
log = "0.4.22"
|
log = "0.4.21"
|
||||||
nix = { version = "0.29.0", features = ["fs", "process", "socket", "user"] }
|
nix = { version = "0.28.0", features = ["user"] }
|
||||||
prettytable = "0.10.0"
|
prettytable = "0.10.0"
|
||||||
rand = "0.8.5"
|
ratatui = { version = "0.26.2", optional = true }
|
||||||
ratatui = { version = "0.28.0", optional = true }
|
serde = "1.0.198"
|
||||||
sd-notify = "0.4.2"
|
serde_json = { version = "1.0.116", features = ["preserve_order"] }
|
||||||
serde = "1.0.208"
|
sqlx = { version = "0.7.4", features = ["runtime-tokio", "mysql", "tls-rustls"] }
|
||||||
serde_json = { version = "1.0.125", features = ["preserve_order"] }
|
tokio = { version = "1.37.0", features = ["rt", "macros"] }
|
||||||
sqlx = { version = "0.8.0", features = ["runtime-tokio", "mysql", "tls-rustls"] }
|
toml = "0.8.12"
|
||||||
tokio = { version = "1.39.3", features = ["rt", "macros"] }
|
|
||||||
tokio-serde = { version = "0.9.0", features = ["bincode"] }
|
|
||||||
tokio-stream = "0.1.15"
|
|
||||||
tokio-util = { version = "0.7.11", features = ["codec"] }
|
|
||||||
toml = "0.8.19"
|
|
||||||
uuid = { version = "1.10.0", features = ["v4"] }
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["mysql-admutils-compatibility"]
|
default = ["mysql-admutils-compatibility"]
|
||||||
|
@ -61,9 +49,6 @@ codegen-units = 1
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
anyhow = "1.0.82"
|
anyhow = "1.0.82"
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
regex = "1.10.6"
|
|
||||||
|
|
||||||
# TODO: package shell completions
|
# TODO: package shell completions
|
||||||
[package.metadata.deb]
|
[package.metadata.deb]
|
||||||
maintainer = "Programvareverkstedet <projects@pvv.ntnu.no>"
|
maintainer = "Programvareverkstedet <projects@pvv.ntnu.no>"
|
||||||
|
|
68
build.rs
68
build.rs
|
@ -3,44 +3,40 @@ use anyhow::anyhow;
|
||||||
#[cfg(feature = "mysql-admutils-compatibility")]
|
#[cfg(feature = "mysql-admutils-compatibility")]
|
||||||
use std::{env, os::unix::fs::symlink, path::PathBuf};
|
use std::{env, os::unix::fs::symlink, path::PathBuf};
|
||||||
|
|
||||||
fn generate_mysql_admutils_symlinks() -> anyhow::Result<()> {
|
|
||||||
// NOTE: This is slightly illegal, and depends on implementation details.
|
|
||||||
// But it is only here for ease of testing the compatibility layer,
|
|
||||||
// and not critical in any way. Considering the code is never going
|
|
||||||
// to be used as a library, it should be fine.
|
|
||||||
let target_profile_dir: PathBuf = PathBuf::from(env::var("OUT_DIR")?)
|
|
||||||
.parent()
|
|
||||||
.and_then(|p| p.parent())
|
|
||||||
.and_then(|p| p.parent())
|
|
||||||
.ok_or(anyhow!("Could not resolve target profile directory"))?
|
|
||||||
.to_path_buf();
|
|
||||||
|
|
||||||
if !target_profile_dir.exists() {
|
|
||||||
std::fs::create_dir_all(&target_profile_dir)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !target_profile_dir.join("mysql-useradm").exists() {
|
|
||||||
symlink(
|
|
||||||
target_profile_dir.join("mysqladm"),
|
|
||||||
target_profile_dir.join("mysql-useradm"),
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
if !target_profile_dir.join("mysql-dbadm").exists() {
|
|
||||||
symlink(
|
|
||||||
target_profile_dir.join("mysqladm"),
|
|
||||||
target_profile_dir.join("mysql-dbadm"),
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
#[cfg(feature = "mysql-admutils-compatibility")]
|
#[cfg(feature = "mysql-admutils-compatibility")]
|
||||||
generate_mysql_admutils_symlinks()?;
|
{
|
||||||
|
// NOTE: This is slightly illegal, and depends on implementation details.
|
||||||
|
// But it is only here for ease of testing the compatibility layer,
|
||||||
|
// and not critical in any way. Considering the code is never going
|
||||||
|
// to be used as a library, it should be fine.
|
||||||
|
let target_profile_dir: PathBuf = PathBuf::from(env::var("OUT_DIR")?)
|
||||||
|
.parent()
|
||||||
|
.and_then(|p| p.parent())
|
||||||
|
.and_then(|p| p.parent())
|
||||||
|
.ok_or(anyhow!("Could not resolve target profile directory"))?
|
||||||
|
.to_path_buf();
|
||||||
|
|
||||||
|
if !target_profile_dir.exists() {
|
||||||
|
std::fs::create_dir_all(&target_profile_dir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !target_profile_dir.join("mysql-useradm").exists() {
|
||||||
|
symlink(
|
||||||
|
target_profile_dir.join("mysqladm"),
|
||||||
|
target_profile_dir.join("mysql-useradm"),
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
if !target_profile_dir.join("mysql-dbadm").exists() {
|
||||||
|
symlink(
|
||||||
|
target_profile_dir.join("mysqladm"),
|
||||||
|
target_profile_dir.join("mysql-dbadm"),
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,22 +1,6 @@
|
||||||
# This should go to `/etc/mysqladm/config.toml`
|
|
||||||
|
|
||||||
[server]
|
|
||||||
# Note that this gets ignored if you are using socket activation.
|
|
||||||
socket_path = "/var/run/mysqladm/mysqladm.sock"
|
|
||||||
|
|
||||||
[mysql]
|
[mysql]
|
||||||
|
|
||||||
# if you use a socket, the host and port will be ignored
|
|
||||||
# socket_path = "/var/run/mysql/mysql.sock"
|
|
||||||
|
|
||||||
host = "localhost"
|
host = "localhost"
|
||||||
port = 3306
|
port = 3306
|
||||||
|
|
||||||
# The username and password can be omitted if you are using
|
|
||||||
# socket based authentication. However, the vendored systemd
|
|
||||||
# service is running as DynamicUser, so by default you need
|
|
||||||
# to at least specify the username.
|
|
||||||
username = "root"
|
username = "root"
|
||||||
password = "secret"
|
password = "secret"
|
||||||
|
|
||||||
timeout = 2 # seconds
|
timeout = 2 # seconds
|
||||||
|
|
14
flake.lock
14
flake.lock
|
@ -2,16 +2,16 @@
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1723938990,
|
"lastModified": 1713297878,
|
||||||
"narHash": "sha256-9tUadhnZQbWIiYVXH8ncfGXGvkNq3Hag4RCBEMUk7MI=",
|
"narHash": "sha256-hOkzkhLT59wR8VaMbh1ESjtZLbGi+XNaBN6h49SPqEc=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "c42fcfbdfeae23e68fc520f9182dde9f38ad1890",
|
"rev": "66adc1e47f8784803f2deb6cacd5e07264ec2d5c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"ref": "nixos-24.05",
|
"ref": "nixos-unstable",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
|
@ -29,11 +29,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1724034091,
|
"lastModified": 1720577957,
|
||||||
"narHash": "sha256-b1g7w0sw+MDAhUAeCoX1vlTghsqcDZkxr+k9OZmxPa8=",
|
"narHash": "sha256-RZuzLdB/8FaXaSzEoWLg3au/mtbuH7MGn2LmXUKT62g=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"rev": "c7d36e0947826e0751a5214ffe82533fbc909bc0",
|
"rev": "a434177dfcc53bf8f1f348a3c39bfb336d760286",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
48
flake.nix
48
flake.nix
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
|
||||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||||
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
@ -8,8 +8,6 @@
|
||||||
|
|
||||||
outputs = { self, nixpkgs, rust-overlay }@inputs:
|
outputs = { self, nixpkgs, rust-overlay }@inputs:
|
||||||
let
|
let
|
||||||
inherit (nixpkgs) lib;
|
|
||||||
|
|
||||||
systems = [
|
systems = [
|
||||||
"x86_64-linux"
|
"x86_64-linux"
|
||||||
"aarch64-linux"
|
"aarch64-linux"
|
||||||
|
@ -17,7 +15,6 @@
|
||||||
"aarch64-darwin"
|
"aarch64-darwin"
|
||||||
"armv7l-linux"
|
"armv7l-linux"
|
||||||
];
|
];
|
||||||
|
|
||||||
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: let
|
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: let
|
||||||
pkgs = import nixpkgs {
|
pkgs = import nixpkgs {
|
||||||
inherit system;
|
inherit system;
|
||||||
|
@ -30,16 +27,6 @@
|
||||||
toolchain = rust-bin.stable.latest.default;
|
toolchain = rust-bin.stable.latest.default;
|
||||||
in f system pkgs toolchain);
|
in f system pkgs toolchain);
|
||||||
in {
|
in {
|
||||||
|
|
||||||
apps = let
|
|
||||||
mkApp = program: { type = "app"; program = toString program; };
|
|
||||||
in forAllSystems (system: pkgs: _: {
|
|
||||||
mysqladm-rs = mkApp (lib.getExe self.packages.${system}.mysqladm-rs);
|
|
||||||
coverage = mkApp (pkgs.writeScript "mysqladm-rs-coverage" ''
|
|
||||||
${lib.getExe pkgs.python3} -m http.server -d "${self.packages.${system}.coverage}/html/src"
|
|
||||||
'');
|
|
||||||
});
|
|
||||||
|
|
||||||
devShell = forAllSystems (system: pkgs: toolchain: pkgs.mkShell {
|
devShell = forAllSystems (system: pkgs: toolchain: pkgs.mkShell {
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = with pkgs; [
|
||||||
toolchain
|
toolchain
|
||||||
|
@ -50,38 +37,5 @@
|
||||||
|
|
||||||
RUST_SRC_PATH = "${toolchain}/lib/rustlib/src/rust/library";
|
RUST_SRC_PATH = "${toolchain}/lib/rustlib/src/rust/library";
|
||||||
});
|
});
|
||||||
|
|
||||||
overlays = {
|
|
||||||
default = self.overlays.mysqladm-rs;
|
|
||||||
mysqladm-rs = final: prev: {
|
|
||||||
inherit (self.packages.${prev.system}) mysqladm-rs;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
nixosModules = {
|
|
||||||
default = self.nixosModules.mysqladm-rs;
|
|
||||||
mysqladm-rs = import ./nix/module.nix;
|
|
||||||
};
|
|
||||||
|
|
||||||
packages = let
|
|
||||||
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
|
|
||||||
cargoLock = ./Cargo.lock;
|
|
||||||
src = builtins.filterSource (path: type: let
|
|
||||||
baseName = baseNameOf (toString path);
|
|
||||||
in !(lib.any (b: b) [
|
|
||||||
(!(lib.cleanSourceFilter path type))
|
|
||||||
(baseName == "target" && type == "directory")
|
|
||||||
(baseName == "nix" && type == "directory")
|
|
||||||
(baseName == "flake.nix" && type == "regular")
|
|
||||||
(baseName == "flake.lock" && type == "regular")
|
|
||||||
])) ./.;
|
|
||||||
in forAllSystems (system: pkgs: _: {
|
|
||||||
default = self.packages.${system}.mysqladm-rs;
|
|
||||||
mysqladm-rs = pkgs.callPackage ./nix/default.nix { inherit cargoToml cargoLock src; };
|
|
||||||
coverage = pkgs.callPackage ./nix/coverage.nix { inherit cargoToml cargoLock src; };
|
|
||||||
filteredSource = pkgs.runCommandLocal "filtered-source" { } ''
|
|
||||||
ln -s ${src} $out
|
|
||||||
'';
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,75 +0,0 @@
|
||||||
{
|
|
||||||
lib
|
|
||||||
, stdenvNoCC
|
|
||||||
, rustPlatform
|
|
||||||
, cargoToml
|
|
||||||
, cargoLock
|
|
||||||
, src
|
|
||||||
|
|
||||||
, rust-bin
|
|
||||||
, cargo-nextest
|
|
||||||
, grcov
|
|
||||||
}:
|
|
||||||
|
|
||||||
stdenvNoCC.mkDerivation {
|
|
||||||
pname = "coverage-${cargoToml.package.name}";
|
|
||||||
version = cargoToml.package.version;
|
|
||||||
inherit src;
|
|
||||||
|
|
||||||
env = {
|
|
||||||
RUSTFLAGS = "-Cinstrument-coverage";
|
|
||||||
LLVM_PROFILE_FILE = "target/coverage/%p-%m.profraw";
|
|
||||||
};
|
|
||||||
|
|
||||||
cargoDeps = rustPlatform.importCargoLock {
|
|
||||||
lockFile = cargoLock;
|
|
||||||
};
|
|
||||||
|
|
||||||
nativeBuildInputs = [
|
|
||||||
rustPlatform.cargoSetupHook
|
|
||||||
cargo-nextest
|
|
||||||
grcov
|
|
||||||
(rust-bin.selectLatestNightlyWith (toolchain: toolchain.default.override {
|
|
||||||
extensions = [ "llvm-tools-preview" ];
|
|
||||||
}))
|
|
||||||
];
|
|
||||||
|
|
||||||
buildPhase = ''
|
|
||||||
runHook preBuild
|
|
||||||
|
|
||||||
export HOME="$(pwd)"
|
|
||||||
|
|
||||||
cargo nextest run --all-features --release --no-fail-fast
|
|
||||||
|
|
||||||
grcov \
|
|
||||||
--source-dir . \
|
|
||||||
--binary-path ./target/release/deps/ \
|
|
||||||
--excl-start 'mod test* \{' \
|
|
||||||
--ignore 'tests/*' \
|
|
||||||
--ignore "*test.rs" \
|
|
||||||
--ignore "*tests.rs" \
|
|
||||||
--ignore "*github.com*" \
|
|
||||||
--ignore "*libcore*" \
|
|
||||||
--ignore "*rustc*" \
|
|
||||||
--ignore "*liballoc*" \
|
|
||||||
--ignore "*cargo*" \
|
|
||||||
-t html \
|
|
||||||
-o ./target/coverage/html \
|
|
||||||
target/coverage/
|
|
||||||
|
|
||||||
runHook postBuild
|
|
||||||
'';
|
|
||||||
|
|
||||||
installPhase = ''
|
|
||||||
runHook preBuild
|
|
||||||
|
|
||||||
mv target/coverage $out
|
|
||||||
|
|
||||||
runHook postBuild
|
|
||||||
'';
|
|
||||||
|
|
||||||
meta = with lib; {
|
|
||||||
license = licenses.mit;
|
|
||||||
platforms = platforms.linux ++ platforms.darwin;
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,35 +0,0 @@
|
||||||
{
|
|
||||||
lib
|
|
||||||
, rustPlatform
|
|
||||||
, cargoToml
|
|
||||||
, cargoLock
|
|
||||||
, src
|
|
||||||
, installShellFiles
|
|
||||||
}:
|
|
||||||
let
|
|
||||||
mainProgram = (lib.head cargoToml.bin).name;
|
|
||||||
in
|
|
||||||
rustPlatform.buildRustPackage {
|
|
||||||
pname = cargoToml.package.name;
|
|
||||||
version = cargoToml.package.version;
|
|
||||||
inherit src;
|
|
||||||
|
|
||||||
cargoLock.lockFile = cargoLock;
|
|
||||||
|
|
||||||
nativeBuildInputs = [ installShellFiles ];
|
|
||||||
postInstall = let
|
|
||||||
commands = lib.mapCartesianProduct ({ shell, command }: ''
|
|
||||||
"$out/bin/${mainProgram}" generate-completions --shell "${shell}" --command "${command}" > "$TMP/mysqladm.${shell}"
|
|
||||||
installShellCompletion "--${shell}" --cmd "${command}" "$TMP/mysqladm.${shell}"
|
|
||||||
'') {
|
|
||||||
shell = [ "bash" "zsh" "fish" ];
|
|
||||||
command = [ "mysqladm" "mysql-dbadm" "mysql-useradm" ];
|
|
||||||
};
|
|
||||||
in lib.concatStringsSep "\n" commands;
|
|
||||||
|
|
||||||
meta = with lib; {
|
|
||||||
license = licenses.mit;
|
|
||||||
platforms = platforms.linux ++ platforms.darwin;
|
|
||||||
inherit mainProgram;
|
|
||||||
};
|
|
||||||
}
|
|
141
nix/module.nix
141
nix/module.nix
|
@ -1,141 +0,0 @@
|
||||||
{ config, pkgs, lib, ... }:
|
|
||||||
let
|
|
||||||
cfg = config.services.mysqladm-rs;
|
|
||||||
format = pkgs.formats.toml { };
|
|
||||||
in
|
|
||||||
{
|
|
||||||
options.services.mysqladm-rs = {
|
|
||||||
enable = lib.mkEnableOption "Enable mysqladm-rs";
|
|
||||||
|
|
||||||
package = lib.mkPackageOption pkgs "mysqladm-rs" { };
|
|
||||||
|
|
||||||
createLocalDatabaseUser = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Create a local database user for mysqladm-rs";
|
|
||||||
};
|
|
||||||
|
|
||||||
settings = lib.mkOption {
|
|
||||||
default = { };
|
|
||||||
type = lib.types.submodule {
|
|
||||||
freeformType = format.type;
|
|
||||||
options = {
|
|
||||||
server = {
|
|
||||||
socket_path = lib.mkOption {
|
|
||||||
type = lib.types.path;
|
|
||||||
default = "/var/run/mysqladm/mysqladm.sock";
|
|
||||||
description = "Path to the MySQL socket";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
mysql = {
|
|
||||||
socket_path = lib.mkOption {
|
|
||||||
type = with lib.types; nullOr path;
|
|
||||||
default = "/var/run/mysqld/mysqld.sock";
|
|
||||||
description = "Path to the MySQL socket";
|
|
||||||
};
|
|
||||||
host = lib.mkOption {
|
|
||||||
type = with lib.types; nullOr str;
|
|
||||||
default = null;
|
|
||||||
description = "MySQL host";
|
|
||||||
};
|
|
||||||
port = lib.mkOption {
|
|
||||||
type = with lib.types; nullOr port;
|
|
||||||
default = 3306;
|
|
||||||
description = "MySQL port";
|
|
||||||
};
|
|
||||||
username = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "mysqladm";
|
|
||||||
description = "MySQL username";
|
|
||||||
};
|
|
||||||
passwordFile = lib.mkOption {
|
|
||||||
type = with lib.types; nullOr path;
|
|
||||||
default = null;
|
|
||||||
description = "Path to a file containing the MySQL password";
|
|
||||||
};
|
|
||||||
timeout = lib.mkOption {
|
|
||||||
type = lib.types.ints.positive;
|
|
||||||
default = 2;
|
|
||||||
description = "Number of seconds to wait for a response from the MySQL server";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
config = let
|
|
||||||
nullStrippedConfig = lib.filterAttrsRecursive (_: v: v != null) cfg.settings;
|
|
||||||
configFile = format.generate "mysqladm-rs.conf" nullStrippedConfig;
|
|
||||||
in lib.mkIf config.services.mysqladm-rs.enable {
|
|
||||||
environment.systemPackages = [ cfg.package ];
|
|
||||||
|
|
||||||
services.mysql.ensureUsers = lib.mkIf cfg.createLocalDatabaseUser [
|
|
||||||
{
|
|
||||||
name = cfg.settings.mysql.username;
|
|
||||||
ensurePermissions = {
|
|
||||||
"mysql.*" = "SELECT, INSERT, UPDATE, DELETE";
|
|
||||||
"*.*" = "CREATE USER, GRANT OPTION";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
systemd.services."mysqladm@" = {
|
|
||||||
description = "MySQL administration tool for non-admin users";
|
|
||||||
environment.RUST_LOG = "debug";
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "notify";
|
|
||||||
ExecStart = "${lib.getExe cfg.package} server socket-activate --config ${configFile}";
|
|
||||||
|
|
||||||
User = "mysqladm";
|
|
||||||
Group = "mysqladm";
|
|
||||||
DynamicUser = true;
|
|
||||||
|
|
||||||
# This is required to read unix user/group details.
|
|
||||||
PrivateUsers = false;
|
|
||||||
|
|
||||||
CapabilityBoundingSet = "";
|
|
||||||
LockPersonality = true;
|
|
||||||
MemoryDenyWriteExecute = true;
|
|
||||||
NoNewPrivileges = true;
|
|
||||||
PrivateDevices = true;
|
|
||||||
PrivateMounts = true;
|
|
||||||
PrivateTmp = "yes";
|
|
||||||
ProcSubset = "pid";
|
|
||||||
ProtectClock = true;
|
|
||||||
ProtectControlGroups = true;
|
|
||||||
ProtectHome = true;
|
|
||||||
ProtectHostname = true;
|
|
||||||
ProtectKernelLogs = true;
|
|
||||||
ProtectKernelModules = true;
|
|
||||||
ProtectKernelTunables = true;
|
|
||||||
ProtectProc = "invisible";
|
|
||||||
ProtectSystem = "strict";
|
|
||||||
RemoveIPC = true;
|
|
||||||
UMask = "0000";
|
|
||||||
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
|
|
||||||
RestrictNamespaces = true;
|
|
||||||
RestrictRealtime = true;
|
|
||||||
RestrictSUIDSGID = true;
|
|
||||||
SystemCallArchitectures = "native";
|
|
||||||
SystemCallFilter = [
|
|
||||||
"@system-service"
|
|
||||||
"~@privileged"
|
|
||||||
"~@resources"
|
|
||||||
];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.sockets."mysqladm" = {
|
|
||||||
description = "MySQL administration tool for non-admin users";
|
|
||||||
wantedBy = [ "sockets.target" ];
|
|
||||||
restartTriggers = [ configFile ];
|
|
||||||
socketConfig = {
|
|
||||||
ListenStream = cfg.settings.server.socket_path;
|
|
||||||
Accept = "yes";
|
|
||||||
PassCredentials = true;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,6 +1,3 @@
|
||||||
mod common;
|
|
||||||
pub mod database_command;
|
pub mod database_command;
|
||||||
pub mod user_command;
|
|
||||||
|
|
||||||
#[cfg(feature = "mysql-admutils-compatibility")]
|
|
||||||
pub mod mysql_admutils_compatibility;
|
pub mod mysql_admutils_compatibility;
|
||||||
|
pub mod user_command;
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
use crate::core::protocol::Response;
|
|
||||||
|
|
||||||
pub fn erroneous_server_response(
|
|
||||||
response: Option<Result<Response, std::io::Error>>,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
match response {
|
|
||||||
Some(Ok(Response::Error(e))) => {
|
|
||||||
anyhow::bail!("Server returned error: {}", e);
|
|
||||||
}
|
|
||||||
Some(Err(e)) => {
|
|
||||||
anyhow::bail!(e);
|
|
||||||
}
|
|
||||||
Some(response) => {
|
|
||||||
anyhow::bail!("Unexpected response from server: {:?}", response);
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
anyhow::bail!("No response from server");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,29 +1,17 @@
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use dialoguer::{Confirm, Editor};
|
use dialoguer::{Confirm, Editor};
|
||||||
use futures_util::{SinkExt, StreamExt};
|
|
||||||
use nix::unistd::{getuid, User};
|
|
||||||
use prettytable::{Cell, Row, Table};
|
use prettytable::{Cell, Row, Table};
|
||||||
|
use sqlx::{Connection, MySqlConnection};
|
||||||
|
|
||||||
use crate::{
|
use crate::core::{
|
||||||
cli::common::erroneous_server_response,
|
common::{close_database_connection, get_current_unix_user, yn, CommandStatus},
|
||||||
core::{
|
database_operations::*,
|
||||||
common::yn,
|
database_privilege_operations::*,
|
||||||
database_privileges::{
|
user_operations::user_exists,
|
||||||
db_priv_field_human_readable_name, diff_privileges, display_privilege_diffs,
|
|
||||||
generate_editor_content_from_privilege_data, parse_privilege_data_from_editor_content,
|
|
||||||
parse_privilege_table_cli_arg,
|
|
||||||
},
|
|
||||||
protocol::{
|
|
||||||
print_create_databases_output_status, print_drop_databases_output_status,
|
|
||||||
print_modify_database_privileges_output_status, ClientToServerMessageStream, Request,
|
|
||||||
Response,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server::sql::database_privilege_operations::{DatabasePrivilegeRow, DATABASE_PRIVILEGE_FIELDS},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser)]
|
||||||
// #[command(next_help_heading = Some(DATABASE_COMMAND_HEADER))]
|
// #[command(next_help_heading = Some(DATABASE_COMMAND_HEADER))]
|
||||||
pub enum DatabaseCommand {
|
pub enum DatabaseCommand {
|
||||||
/// Create one or more databases
|
/// Create one or more databases
|
||||||
|
@ -34,15 +22,13 @@ pub enum DatabaseCommand {
|
||||||
#[command()]
|
#[command()]
|
||||||
DropDb(DatabaseDropArgs),
|
DropDb(DatabaseDropArgs),
|
||||||
|
|
||||||
/// Print information about one or more databases
|
/// List all databases you have access to
|
||||||
///
|
|
||||||
/// If no database name is provided, all databases you have access will be shown.
|
|
||||||
#[command()]
|
#[command()]
|
||||||
ShowDb(DatabaseShowArgs),
|
ListDb(DatabaseListArgs),
|
||||||
|
|
||||||
/// Print user privileges for one or more databases
|
/// List user privileges for one or more databases
|
||||||
///
|
///
|
||||||
/// If no database names are provided, all databases you have access to will be shown.
|
/// If no database names are provided, it will show privileges for all databases you have access to.
|
||||||
#[command()]
|
#[command()]
|
||||||
ShowDbPrivs(DatabaseShowPrivsArgs),
|
ShowDbPrivs(DatabaseShowPrivsArgs),
|
||||||
|
|
||||||
|
@ -100,32 +86,28 @@ pub enum DatabaseCommand {
|
||||||
EditDbPrivs(DatabaseEditPrivsArgs),
|
EditDbPrivs(DatabaseEditPrivsArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser)]
|
||||||
pub struct DatabaseCreateArgs {
|
pub struct DatabaseCreateArgs {
|
||||||
/// The name of the database(s) to create.
|
/// The name of the database(s) to create.
|
||||||
#[arg(num_args = 1..)]
|
#[arg(num_args = 1..)]
|
||||||
name: Vec<String>,
|
name: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser)]
|
||||||
pub struct DatabaseDropArgs {
|
pub struct DatabaseDropArgs {
|
||||||
/// The name of the database(s) to drop.
|
/// The name of the database(s) to drop.
|
||||||
#[arg(num_args = 1..)]
|
#[arg(num_args = 1..)]
|
||||||
name: Vec<String>,
|
name: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser)]
|
||||||
pub struct DatabaseShowArgs {
|
pub struct DatabaseListArgs {
|
||||||
/// The name of the database(s) to show.
|
|
||||||
#[arg(num_args = 0..)]
|
|
||||||
name: Vec<String>,
|
|
||||||
|
|
||||||
/// Whether to output the information in JSON format.
|
/// Whether to output the information in JSON format.
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
json: bool,
|
json: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser)]
|
||||||
pub struct DatabaseShowPrivsArgs {
|
pub struct DatabaseShowPrivsArgs {
|
||||||
/// The name of the database(s) to show.
|
/// The name of the database(s) to show.
|
||||||
#[arg(num_args = 0..)]
|
#[arg(num_args = 0..)]
|
||||||
|
@ -136,7 +118,7 @@ pub struct DatabaseShowPrivsArgs {
|
||||||
json: bool,
|
json: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser)]
|
||||||
pub struct DatabaseEditPrivsArgs {
|
pub struct DatabaseEditPrivsArgs {
|
||||||
/// The name of the database to edit privileges for.
|
/// The name of the database to edit privileges for.
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
@ -159,164 +141,125 @@ pub struct DatabaseEditPrivsArgs {
|
||||||
|
|
||||||
pub async fn handle_command(
|
pub async fn handle_command(
|
||||||
command: DatabaseCommand,
|
command: DatabaseCommand,
|
||||||
server_connection: ClientToServerMessageStream,
|
mut connection: MySqlConnection,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<CommandStatus> {
|
||||||
match command {
|
let result = connection
|
||||||
DatabaseCommand::CreateDb(args) => create_databases(args, server_connection).await,
|
.transaction(|txn| {
|
||||||
DatabaseCommand::DropDb(args) => drop_databases(args, server_connection).await,
|
Box::pin(async move {
|
||||||
DatabaseCommand::ShowDb(args) => show_databases(args, server_connection).await,
|
match command {
|
||||||
DatabaseCommand::ShowDbPrivs(args) => {
|
DatabaseCommand::CreateDb(args) => create_databases(args, txn).await,
|
||||||
show_database_privileges(args, server_connection).await
|
DatabaseCommand::DropDb(args) => drop_databases(args, txn).await,
|
||||||
}
|
DatabaseCommand::ListDb(args) => list_databases(args, txn).await,
|
||||||
DatabaseCommand::EditDbPrivs(args) => {
|
DatabaseCommand::ShowDbPrivs(args) => show_database_privileges(args, txn).await,
|
||||||
edit_database_privileges(args, server_connection).await
|
DatabaseCommand::EditDbPrivs(args) => edit_privileges(args, txn).await,
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
close_database_connection(connection).await;
|
||||||
|
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_databases(
|
async fn create_databases(
|
||||||
args: DatabaseCreateArgs,
|
args: DatabaseCreateArgs,
|
||||||
mut server_connection: ClientToServerMessageStream,
|
connection: &mut MySqlConnection,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<CommandStatus> {
|
||||||
if args.name.is_empty() {
|
if args.name.is_empty() {
|
||||||
anyhow::bail!("No database names provided");
|
anyhow::bail!("No database names provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = Request::CreateDatabases(args.name.clone());
|
let mut result = CommandStatus::SuccessfullyModified;
|
||||||
server_connection.send(message).await?;
|
|
||||||
|
|
||||||
let result = match server_connection.next().await {
|
for name in args.name {
|
||||||
Some(Ok(Response::CreateDatabases(result))) => result,
|
// TODO: This can be optimized by fetching all the database privileges in one query.
|
||||||
response => return erroneous_server_response(response),
|
if let Err(e) = create_database(&name, connection).await {
|
||||||
};
|
eprintln!("Failed to create database '{}': {}", name, e);
|
||||||
|
eprintln!("Skipping...");
|
||||||
|
result = CommandStatus::PartiallySuccessfullyModified;
|
||||||
|
} else {
|
||||||
|
println!("Database '{}' created.", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
server_connection.send(Request::Exit).await?;
|
Ok(result)
|
||||||
|
|
||||||
print_create_databases_output_status(&result);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn drop_databases(
|
async fn drop_databases(
|
||||||
args: DatabaseDropArgs,
|
args: DatabaseDropArgs,
|
||||||
mut server_connection: ClientToServerMessageStream,
|
connection: &mut MySqlConnection,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<CommandStatus> {
|
||||||
if args.name.is_empty() {
|
if args.name.is_empty() {
|
||||||
anyhow::bail!("No database names provided");
|
anyhow::bail!("No database names provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = Request::DropDatabases(args.name.clone());
|
let mut result = CommandStatus::SuccessfullyModified;
|
||||||
server_connection.send(message).await?;
|
|
||||||
|
|
||||||
let result = match server_connection.next().await {
|
for name in args.name {
|
||||||
Some(Ok(Response::DropDatabases(result))) => result,
|
// TODO: This can be optimized by fetching all the database privileges in one query.
|
||||||
response => return erroneous_server_response(response),
|
if let Err(e) = drop_database(&name, connection).await {
|
||||||
};
|
eprintln!("Failed to drop database '{}': {}", name, e);
|
||||||
|
eprintln!("Skipping...");
|
||||||
server_connection.send(Request::Exit).await?;
|
result = CommandStatus::PartiallySuccessfullyModified;
|
||||||
|
} else {
|
||||||
print_drop_databases_output_status(&result);
|
println!("Database '{}' dropped.", name);
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn show_databases(
|
|
||||||
args: DatabaseShowArgs,
|
|
||||||
mut server_connection: ClientToServerMessageStream,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let message = if args.name.is_empty() {
|
|
||||||
Request::ListDatabases(None)
|
|
||||||
} else {
|
|
||||||
Request::ListDatabases(Some(args.name.clone()))
|
|
||||||
};
|
|
||||||
|
|
||||||
server_connection.send(message).await?;
|
|
||||||
|
|
||||||
let database_list = match server_connection.next().await {
|
|
||||||
Some(Ok(Response::ListDatabases(databases))) => databases
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|(database_name, result)| match result {
|
|
||||||
Ok(database_row) => Some(database_row),
|
|
||||||
Err(err) => {
|
|
||||||
eprintln!("{}", err.to_error_message(&database_name));
|
|
||||||
eprintln!("Skipping...");
|
|
||||||
println!();
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
Some(Ok(Response::ListAllDatabases(database_list))) => match database_list {
|
|
||||||
Ok(list) => list,
|
|
||||||
Err(err) => {
|
|
||||||
server_connection.send(Request::Exit).await?;
|
|
||||||
return Err(
|
|
||||||
anyhow::anyhow!(err.to_error_message()).context("Failed to list databases")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
response => return erroneous_server_response(response),
|
|
||||||
};
|
|
||||||
|
|
||||||
server_connection.send(Request::Exit).await?;
|
|
||||||
|
|
||||||
if args.json {
|
|
||||||
println!("{}", serde_json::to_string_pretty(&database_list)?);
|
|
||||||
} else if database_list.is_empty() {
|
|
||||||
println!("No databases to show.");
|
|
||||||
} else {
|
|
||||||
let mut table = Table::new();
|
|
||||||
table.add_row(Row::new(vec![Cell::new("Database")]));
|
|
||||||
for db in database_list {
|
|
||||||
table.add_row(row![db.database]);
|
|
||||||
}
|
}
|
||||||
table.printstd();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_databases(
|
||||||
|
args: DatabaseListArgs,
|
||||||
|
connection: &mut MySqlConnection,
|
||||||
|
) -> anyhow::Result<CommandStatus> {
|
||||||
|
let databases = get_database_list(connection).await?;
|
||||||
|
|
||||||
|
if args.json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&databases)?);
|
||||||
|
return Ok(CommandStatus::NoModificationsIntended);
|
||||||
|
}
|
||||||
|
|
||||||
|
if databases.is_empty() {
|
||||||
|
println!("No databases to show.");
|
||||||
|
} else {
|
||||||
|
for db in databases {
|
||||||
|
println!("{}", db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CommandStatus::NoModificationsIntended)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn show_database_privileges(
|
async fn show_database_privileges(
|
||||||
args: DatabaseShowPrivsArgs,
|
args: DatabaseShowPrivsArgs,
|
||||||
mut server_connection: ClientToServerMessageStream,
|
connection: &mut MySqlConnection,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<CommandStatus> {
|
||||||
let message = if args.name.is_empty() {
|
let database_users_to_show = if args.name.is_empty() {
|
||||||
Request::ListPrivileges(None)
|
get_all_database_privileges(connection).await?
|
||||||
} else {
|
} else {
|
||||||
Request::ListPrivileges(Some(args.name.clone()))
|
// TODO: This can be optimized by fetching all the database privileges in one query.
|
||||||
};
|
let mut result = Vec::with_capacity(args.name.len());
|
||||||
server_connection.send(message).await?;
|
for name in args.name {
|
||||||
|
match get_database_privileges(&name, connection).await {
|
||||||
let privilege_data = match server_connection.next().await {
|
Ok(db) => result.extend(db),
|
||||||
Some(Ok(Response::ListPrivileges(databases))) => databases
|
Err(e) => {
|
||||||
.into_iter()
|
eprintln!("Failed to show database '{}': {}", name, e);
|
||||||
.filter_map(|(database_name, result)| match result {
|
|
||||||
Ok(privileges) => Some(privileges),
|
|
||||||
Err(err) => {
|
|
||||||
eprintln!("{}", err.to_error_message(&database_name));
|
|
||||||
eprintln!("Skipping...");
|
eprintln!("Skipping...");
|
||||||
println!();
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.flatten()
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
Some(Ok(Response::ListAllPrivileges(privilege_rows))) => match privilege_rows {
|
|
||||||
Ok(list) => list,
|
|
||||||
Err(err) => {
|
|
||||||
server_connection.send(Request::Exit).await?;
|
|
||||||
return Err(anyhow::anyhow!(err.to_error_message())
|
|
||||||
.context("Failed to list database privileges"));
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
response => return erroneous_server_response(response),
|
result
|
||||||
};
|
};
|
||||||
|
|
||||||
server_connection.send(Request::Exit).await?;
|
|
||||||
|
|
||||||
if args.json {
|
if args.json {
|
||||||
println!("{}", serde_json::to_string_pretty(&privilege_data)?);
|
println!("{}", serde_json::to_string_pretty(&database_users_to_show)?);
|
||||||
} else if privilege_data.is_empty() {
|
return Ok(CommandStatus::NoModificationsIntended);
|
||||||
println!("No database privileges to show.");
|
}
|
||||||
|
|
||||||
|
if database_users_to_show.is_empty() {
|
||||||
|
println!("No database users to show.");
|
||||||
} else {
|
} else {
|
||||||
let mut table = Table::new();
|
let mut table = Table::new();
|
||||||
table.add_row(Row::new(
|
table.add_row(Row::new(
|
||||||
|
@ -327,7 +270,7 @@ async fn show_database_privileges(
|
||||||
.collect(),
|
.collect(),
|
||||||
));
|
));
|
||||||
|
|
||||||
for row in privilege_data {
|
for row in database_users_to_show {
|
||||||
table.add_row(row![
|
table.add_row(row![
|
||||||
row.db,
|
row.db,
|
||||||
row.user,
|
row.user,
|
||||||
|
@ -347,58 +290,48 @@ async fn show_database_privileges(
|
||||||
table.printstd();
|
table.printstd();
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(CommandStatus::NoModificationsIntended)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn edit_database_privileges(
|
pub async fn edit_privileges(
|
||||||
args: DatabaseEditPrivsArgs,
|
args: DatabaseEditPrivsArgs,
|
||||||
mut server_connection: ClientToServerMessageStream,
|
connection: &mut MySqlConnection,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<CommandStatus> {
|
||||||
let message = Request::ListPrivileges(args.name.clone().map(|name| vec![name]));
|
let privilege_data = if let Some(name) = &args.name {
|
||||||
|
get_database_privileges(name, connection).await?
|
||||||
server_connection.send(message).await?;
|
} else {
|
||||||
|
get_all_database_privileges(connection).await?
|
||||||
let privilege_data = match server_connection.next().await {
|
|
||||||
Some(Ok(Response::ListPrivileges(databases))) => databases
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|(database_name, result)| match result {
|
|
||||||
Ok(privileges) => Some(privileges),
|
|
||||||
Err(err) => {
|
|
||||||
eprintln!("{}", err.to_error_message(&database_name));
|
|
||||||
eprintln!("Skipping...");
|
|
||||||
println!();
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.flatten()
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
Some(Ok(Response::ListAllPrivileges(privilege_rows))) => match privilege_rows {
|
|
||||||
Ok(list) => list,
|
|
||||||
Err(err) => {
|
|
||||||
server_connection.send(Request::Exit).await?;
|
|
||||||
return Err(anyhow::anyhow!(err.to_error_message())
|
|
||||||
.context("Failed to list database privileges"));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
response => return erroneous_server_response(response),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO: The data from args should not be absolute.
|
||||||
|
// 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() {
|
let privileges_to_change = if !args.privs.is_empty() {
|
||||||
parse_privilege_tables_from_args(&args)?
|
parse_privilege_tables_from_args(&args)?
|
||||||
} else {
|
} else {
|
||||||
edit_privileges_with_editor(&privilege_data, args.name.as_deref())?
|
edit_privileges_with_editor(&privilege_data)?
|
||||||
};
|
};
|
||||||
|
|
||||||
|
for row in privileges_to_change.iter() {
|
||||||
|
if !user_exists(&row.user, connection).await? {
|
||||||
|
// TODO: allow user to return and correct their mistake
|
||||||
|
anyhow::bail!("User {} does not exist", row.user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let diffs = diff_privileges(&privilege_data, &privileges_to_change);
|
let diffs = diff_privileges(&privilege_data, &privileges_to_change);
|
||||||
|
|
||||||
if diffs.is_empty() {
|
if diffs.is_empty() {
|
||||||
println!("No changes to make.");
|
println!("No changes to make.");
|
||||||
return Ok(());
|
return Ok(CommandStatus::NoModificationsNeeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("The following changes will be made:\n");
|
println!("The following changes will be made:\n");
|
||||||
println!("{}", display_privilege_diffs(&diffs));
|
println!("{}", display_privilege_diffs(&diffs));
|
||||||
|
|
||||||
if !args.yes
|
if !args.yes
|
||||||
&& !Confirm::new()
|
&& !Confirm::new()
|
||||||
.with_prompt("Do you want to apply these changes?")
|
.with_prompt("Do you want to apply these changes?")
|
||||||
|
@ -406,26 +339,15 @@ pub async fn edit_database_privileges(
|
||||||
.show_default(true)
|
.show_default(true)
|
||||||
.interact()?
|
.interact()?
|
||||||
{
|
{
|
||||||
server_connection.send(Request::Exit).await?;
|
return Ok(CommandStatus::Cancelled);
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = Request::ModifyPrivileges(diffs);
|
apply_privilege_diffs(diffs, connection).await?;
|
||||||
server_connection.send(message).await?;
|
|
||||||
|
|
||||||
let result = match server_connection.next().await {
|
Ok(CommandStatus::SuccessfullyModified)
|
||||||
Some(Ok(Response::ModifyPrivileges(result))) => result,
|
|
||||||
response => return erroneous_server_response(response),
|
|
||||||
};
|
|
||||||
|
|
||||||
print_modify_database_privileges_output_status(&result);
|
|
||||||
|
|
||||||
server_connection.send(Request::Exit).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_privilege_tables_from_args(
|
pub fn parse_privilege_tables_from_args(
|
||||||
args: &DatabaseEditPrivsArgs,
|
args: &DatabaseEditPrivsArgs,
|
||||||
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
|
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
|
||||||
debug_assert!(!args.privs.is_empty());
|
debug_assert!(!args.privs.is_empty());
|
||||||
|
@ -449,23 +371,20 @@ fn parse_privilege_tables_from_args(
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn edit_privileges_with_editor(
|
pub fn edit_privileges_with_editor(
|
||||||
privilege_data: &[DatabasePrivilegeRow],
|
privilege_data: &[DatabasePrivilegeRow],
|
||||||
database_name: Option<&str>,
|
|
||||||
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
|
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
|
||||||
let unix_user = User::from_uid(getuid())
|
let unix_user = get_current_unix_user()?;
|
||||||
.context("Failed to look up your UNIX username")
|
|
||||||
.and_then(|u| u.ok_or(anyhow::anyhow!("Failed to look up your UNIX username")))?;
|
|
||||||
|
|
||||||
let editor_content =
|
let editor_content =
|
||||||
generate_editor_content_from_privilege_data(privilege_data, &unix_user.name, database_name);
|
generate_editor_content_from_privilege_data(privilege_data, &unix_user.name);
|
||||||
|
|
||||||
// TODO: handle errors better here
|
// TODO: handle errors better here
|
||||||
let result = Editor::new().extension("tsv").edit(&editor_content)?;
|
let result = Editor::new()
|
||||||
|
.extension("tsv")
|
||||||
|
.edit(&editor_content)?
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
match result {
|
parse_privilege_data_from_editor_content(result)
|
||||||
None => Ok(privilege_data.to_vec()),
|
.context("Could not parse privilege data from editor")
|
||||||
Some(result) => parse_privilege_data_from_editor_content(result)
|
|
||||||
.context("Could not parse privilege data from editor"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
pub mod common;
|
pub mod common;
|
||||||
mod error_messages;
|
|
||||||
pub mod mysql_dbadm;
|
pub mod mysql_dbadm;
|
||||||
pub mod mysql_useradm;
|
pub mod mysql_useradm;
|
||||||
|
|
|
@ -1,4 +1,57 @@
|
||||||
#[inline]
|
use crate::core::common::{
|
||||||
pub fn trim_to_32_chars(name: &str) -> String {
|
get_current_unix_user, validate_name_or_error, validate_ownership_or_error, DbOrUser,
|
||||||
name.chars().take(32).collect()
|
};
|
||||||
|
|
||||||
|
/// In contrast to the new implementation which reports errors on any invalid name
|
||||||
|
/// for any reason, mysql-admutils would only log the error and skip that particular
|
||||||
|
/// name. This function replicates that behavior.
|
||||||
|
pub fn filter_db_or_user_names(
|
||||||
|
names: Vec<String>,
|
||||||
|
db_or_user: DbOrUser,
|
||||||
|
) -> anyhow::Result<Vec<String>> {
|
||||||
|
let unix_user = get_current_unix_user()?;
|
||||||
|
let argv0 = std::env::args().next().unwrap_or_else(|| match db_or_user {
|
||||||
|
DbOrUser::Database => "mysql-dbadm".to_string(),
|
||||||
|
DbOrUser::User => "mysql-useradm".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let filtered_names = names
|
||||||
|
.into_iter()
|
||||||
|
// NOTE: The original implementation would only copy the first 32 characters
|
||||||
|
// of the argument into it's internal buffer. We replicate that behavior
|
||||||
|
// here.
|
||||||
|
.map(|name| name.chars().take(32).collect::<String>())
|
||||||
|
.filter(|name| {
|
||||||
|
if let Err(_err) = validate_ownership_or_error(name, &unix_user, db_or_user) {
|
||||||
|
println!(
|
||||||
|
"You are not in charge of mysql-{}: '{}'. Skipping.",
|
||||||
|
db_or_user.lowercased(),
|
||||||
|
name
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.filter(|name| {
|
||||||
|
// NOTE: while this also checks for the length of the name,
|
||||||
|
// the name is already truncated to 32 characters. So
|
||||||
|
// if there is an error, it's guaranteed to be due to
|
||||||
|
// invalid characters.
|
||||||
|
if let Err(_err) = validate_name_or_error(name, db_or_user) {
|
||||||
|
println!(
|
||||||
|
concat!(
|
||||||
|
"{}: {} name '{}' contains invalid characters.\n",
|
||||||
|
"Only A-Z, a-z, 0-9, _ (underscore) and - (dash) permitted. Skipping.",
|
||||||
|
),
|
||||||
|
argv0,
|
||||||
|
db_or_user.capitalized(),
|
||||||
|
name
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(filtered_names)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,176 +0,0 @@
|
||||||
use crate::core::protocol::{
|
|
||||||
CreateDatabaseError, CreateUserError, DbOrUser, DropDatabaseError, DropUserError,
|
|
||||||
GetDatabasesPrivilegeDataError, ListUsersError,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn name_validation_error_to_error_message(name: &str, db_or_user: DbOrUser) -> String {
|
|
||||||
let argv0 = std::env::args().next().unwrap_or_else(|| match db_or_user {
|
|
||||||
DbOrUser::Database => "mysql-dbadm".to_string(),
|
|
||||||
DbOrUser::User => "mysql-useradm".to_string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
format!(
|
|
||||||
concat!(
|
|
||||||
"{}: {} name '{}' contains invalid characters.\n",
|
|
||||||
"Only A-Z, a-z, 0-9, _ (underscore) and - (dash) permitted. Skipping.",
|
|
||||||
),
|
|
||||||
argv0,
|
|
||||||
db_or_user.capitalized(),
|
|
||||||
name,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn owner_validation_error_message(name: &str, db_or_user: DbOrUser) -> String {
|
|
||||||
format!(
|
|
||||||
"You are not in charge of mysql-{}: '{}'. Skipping.",
|
|
||||||
db_or_user.lowercased(),
|
|
||||||
name
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_create_user_error(error: CreateUserError, name: &str) {
|
|
||||||
let argv0 = std::env::args()
|
|
||||||
.next()
|
|
||||||
.unwrap_or_else(|| "mysql-useradm".to_string());
|
|
||||||
match error {
|
|
||||||
CreateUserError::SanitizationError(_) => {
|
|
||||||
eprintln!(
|
|
||||||
"{}",
|
|
||||||
name_validation_error_to_error_message(name, DbOrUser::User)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
CreateUserError::OwnershipError(_) => {
|
|
||||||
eprintln!("{}", owner_validation_error_message(name, DbOrUser::User));
|
|
||||||
}
|
|
||||||
CreateUserError::MySqlError(_) | CreateUserError::UserAlreadyExists => {
|
|
||||||
eprintln!("{}: Failed to create user '{}'.", argv0, name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_drop_user_error(error: DropUserError, name: &str) {
|
|
||||||
let argv0 = std::env::args()
|
|
||||||
.next()
|
|
||||||
.unwrap_or_else(|| "mysql-useradm".to_string());
|
|
||||||
match error {
|
|
||||||
DropUserError::SanitizationError(_) => {
|
|
||||||
eprintln!(
|
|
||||||
"{}",
|
|
||||||
name_validation_error_to_error_message(name, DbOrUser::User)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
DropUserError::OwnershipError(_) => {
|
|
||||||
eprintln!("{}", owner_validation_error_message(name, DbOrUser::User));
|
|
||||||
}
|
|
||||||
DropUserError::MySqlError(_) | DropUserError::UserDoesNotExist => {
|
|
||||||
eprintln!("{}: Failed to delete user '{}'.", argv0, name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_list_users_error(error: ListUsersError, name: &str) {
|
|
||||||
let argv0 = std::env::args()
|
|
||||||
.next()
|
|
||||||
.unwrap_or_else(|| "mysql-useradm".to_string());
|
|
||||||
match error {
|
|
||||||
ListUsersError::SanitizationError(_) => {
|
|
||||||
eprintln!(
|
|
||||||
"{}",
|
|
||||||
name_validation_error_to_error_message(name, DbOrUser::User)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ListUsersError::OwnershipError(_) => {
|
|
||||||
eprintln!("{}", owner_validation_error_message(name, DbOrUser::User));
|
|
||||||
}
|
|
||||||
ListUsersError::UserDoesNotExist => {
|
|
||||||
eprintln!(
|
|
||||||
"{}: User '{}' does not exist. You must create it first.",
|
|
||||||
argv0, name,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ListUsersError::MySqlError(_) => {
|
|
||||||
eprintln!("{}: Failed to look up password for user '{}'", argv0, name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub fn handle_create_database_error(error: CreateDatabaseError, name: &str) {
|
|
||||||
let argv0 = std::env::args()
|
|
||||||
.next()
|
|
||||||
.unwrap_or_else(|| "mysql-dbadm".to_string());
|
|
||||||
match error {
|
|
||||||
CreateDatabaseError::SanitizationError(_) => {
|
|
||||||
eprintln!(
|
|
||||||
"{}",
|
|
||||||
name_validation_error_to_error_message(name, DbOrUser::Database)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
CreateDatabaseError::OwnershipError(_) => {
|
|
||||||
eprintln!(
|
|
||||||
"{}",
|
|
||||||
owner_validation_error_message(name, DbOrUser::Database)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
CreateDatabaseError::MySqlError(_) => {
|
|
||||||
eprintln!("{}: Cannot create database '{}'.", argv0, name);
|
|
||||||
}
|
|
||||||
CreateDatabaseError::DatabaseAlreadyExists => {
|
|
||||||
eprintln!("{}: Database '{}' already exists.", argv0, name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_drop_database_error(error: DropDatabaseError, name: &str) {
|
|
||||||
let argv0 = std::env::args()
|
|
||||||
.next()
|
|
||||||
.unwrap_or_else(|| "mysql-dbadm".to_string());
|
|
||||||
match error {
|
|
||||||
DropDatabaseError::SanitizationError(_) => {
|
|
||||||
eprintln!(
|
|
||||||
"{}",
|
|
||||||
name_validation_error_to_error_message(name, DbOrUser::Database)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
DropDatabaseError::OwnershipError(_) => {
|
|
||||||
eprintln!(
|
|
||||||
"{}",
|
|
||||||
owner_validation_error_message(name, DbOrUser::Database)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
DropDatabaseError::MySqlError(_) => {
|
|
||||||
eprintln!("{}: Cannot drop database '{}'.", argv0, name);
|
|
||||||
}
|
|
||||||
DropDatabaseError::DatabaseDoesNotExist => {
|
|
||||||
eprintln!("{}: Database '{}' doesn't exist.", argv0, name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn format_show_database_error_message(
|
|
||||||
error: GetDatabasesPrivilegeDataError,
|
|
||||||
name: &str,
|
|
||||||
) -> String {
|
|
||||||
let argv0 = std::env::args()
|
|
||||||
.next()
|
|
||||||
.unwrap_or_else(|| "mysql-dbadm".to_string());
|
|
||||||
|
|
||||||
match error {
|
|
||||||
GetDatabasesPrivilegeDataError::SanitizationError(_) => {
|
|
||||||
name_validation_error_to_error_message(name, DbOrUser::Database)
|
|
||||||
}
|
|
||||||
GetDatabasesPrivilegeDataError::OwnershipError(_) => {
|
|
||||||
owner_validation_error_message(name, DbOrUser::Database)
|
|
||||||
}
|
|
||||||
GetDatabasesPrivilegeDataError::MySqlError(err) => {
|
|
||||||
format!(
|
|
||||||
"{}: Failed to look up privileges for database '{}': {}",
|
|
||||||
argv0, name, err
|
|
||||||
)
|
|
||||||
}
|
|
||||||
GetDatabasesPrivilegeDataError::DatabaseDoesNotExist => {
|
|
||||||
format!("{}: Database '{}' doesn't exist.", argv0, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,29 +1,14 @@
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use sqlx::MySqlConnection;
|
||||||
use std::os::unix::net::UnixStream as StdUnixStream;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use tokio::net::UnixStream as TokioUnixStream;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
cli::{
|
cli::{database_command, mysql_admutils_compatibility::common::filter_db_or_user_names},
|
||||||
common::erroneous_server_response,
|
|
||||||
database_command,
|
|
||||||
mysql_admutils_compatibility::{
|
|
||||||
common::trim_to_32_chars,
|
|
||||||
error_messages::{
|
|
||||||
format_show_database_error_message, handle_create_database_error,
|
|
||||||
handle_drop_database_error,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
core::{
|
core::{
|
||||||
bootstrap::bootstrap_server_connection_and_drop_privileges,
|
common::{yn, DbOrUser},
|
||||||
protocol::{
|
config::{create_mysql_connection_from_config, get_config, GlobalConfigArgs},
|
||||||
create_client_to_server_message_stream, ClientToServerMessageStream,
|
database_operations::{create_database, drop_database, get_database_list},
|
||||||
GetDatabasesPrivilegeDataError, Request, Response,
|
database_privilege_operations,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
server::sql::database_privilege_operations::DatabasePrivilegeRow,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const HELP_DB_PERM: &str = r#"
|
const HELP_DB_PERM: &str = r#"
|
||||||
|
@ -49,42 +34,13 @@ The Y/N-values corresponds to the following mysql privileges:
|
||||||
References - Enables use of REFERENCES
|
References - Enables use of REFERENCES
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
/// Create, drop or edit permissions for the DATABASE(s),
|
|
||||||
/// as determined by the COMMAND.
|
|
||||||
///
|
|
||||||
/// This is a compatibility layer for the mysql-dbadm command.
|
|
||||||
/// Please consider using the newer mysqladm command instead.
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(
|
|
||||||
bin_name = "mysql-dbadm",
|
|
||||||
version,
|
|
||||||
about,
|
|
||||||
disable_help_subcommand = true,
|
|
||||||
verbatim_doc_comment
|
|
||||||
)]
|
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
pub command: Option<Command>,
|
pub command: Option<Command>,
|
||||||
|
|
||||||
/// Path to the socket of the server, if it already exists.
|
#[command(flatten)]
|
||||||
#[arg(
|
config_overrides: GlobalConfigArgs,
|
||||||
short,
|
|
||||||
long,
|
|
||||||
value_name = "PATH",
|
|
||||||
global = true,
|
|
||||||
hide_short_help = true
|
|
||||||
)]
|
|
||||||
server_socket_path: Option<PathBuf>,
|
|
||||||
|
|
||||||
/// Config file to use for the server.
|
|
||||||
#[arg(
|
|
||||||
short,
|
|
||||||
long,
|
|
||||||
value_name = "PATH",
|
|
||||||
global = true,
|
|
||||||
hide_short_help = true
|
|
||||||
)]
|
|
||||||
config: Option<PathBuf>,
|
|
||||||
|
|
||||||
/// Print help for the 'editperm' subcommand.
|
/// Print help for the 'editperm' subcommand.
|
||||||
#[arg(long, global = true)]
|
#[arg(long, global = true)]
|
||||||
|
@ -94,7 +50,14 @@ pub struct Args {
|
||||||
// NOTE: mysql-dbadm explicitly calls privileges "permissions".
|
// NOTE: mysql-dbadm explicitly calls privileges "permissions".
|
||||||
// This is something we're trying to move away from.
|
// This is something we're trying to move away from.
|
||||||
// See https://git.pvv.ntnu.no/Projects/mysqladm-rs/issues/29
|
// 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.
|
||||||
|
///
|
||||||
|
/// This is a compatibility layer for the mysql-dbadm command.
|
||||||
|
/// Please consider using the newer mysqladm command instead.
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
|
#[command(version, about, disable_help_subcommand = true, verbatim_doc_comment)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
/// create the DATABASE(s).
|
/// create the DATABASE(s).
|
||||||
Create(CreateArgs),
|
Create(CreateArgs),
|
||||||
|
@ -113,7 +76,7 @@ pub enum Command {
|
||||||
/// to make changes to the permission table.
|
/// to make changes to the permission table.
|
||||||
/// Run 'mysql-dbadm --help-editperm' for more
|
/// Run 'mysql-dbadm --help-editperm' for more
|
||||||
/// information.
|
/// information.
|
||||||
Editperm(EditPermArgs),
|
EditPerm(EditPermArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
|
@ -143,7 +106,7 @@ pub struct EditPermArgs {
|
||||||
pub database: String,
|
pub database: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn main() -> anyhow::Result<()> {
|
pub async fn main() -> anyhow::Result<()> {
|
||||||
let args: Args = Args::parse();
|
let args: Args = Args::parse();
|
||||||
|
|
||||||
if args.help_editperm {
|
if args.help_editperm {
|
||||||
|
@ -151,9 +114,6 @@ pub fn main() -> anyhow::Result<()> {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let server_connection =
|
|
||||||
bootstrap_server_connection_and_drop_privileges(args.server_socket_path, args.config)?;
|
|
||||||
|
|
||||||
let command = match args.command {
|
let command = match args.command {
|
||||||
Some(command) => command,
|
Some(command) => command,
|
||||||
None => {
|
None => {
|
||||||
|
@ -165,165 +125,64 @@ pub fn main() -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
tokio_run_command(command, server_connection)?;
|
let config = get_config(args.config_overrides)?;
|
||||||
|
let mut connection = create_mysql_connection_from_config(config.mysql).await?;
|
||||||
|
|
||||||
Ok(())
|
match command {
|
||||||
}
|
Command::Create(args) => {
|
||||||
|
let filtered_names = filter_db_or_user_names(args.name, DbOrUser::Database)?;
|
||||||
fn tokio_run_command(command: Command, server_connection: StdUnixStream) -> anyhow::Result<()> {
|
for name in filtered_names {
|
||||||
tokio::runtime::Builder::new_current_thread()
|
create_database(&name, &mut connection).await?;
|
||||||
.enable_all()
|
println!("Database {} created.", name);
|
||||||
.build()
|
|
||||||
.unwrap()
|
|
||||||
.block_on(async {
|
|
||||||
let tokio_socket = TokioUnixStream::from_std(server_connection)?;
|
|
||||||
let message_stream = create_client_to_server_message_stream(tokio_socket);
|
|
||||||
match command {
|
|
||||||
Command::Create(args) => create_databases(args, message_stream).await,
|
|
||||||
Command::Drop(args) => drop_databases(args, message_stream).await,
|
|
||||||
Command::Show(args) => show_databases(args, message_stream).await,
|
|
||||||
Command::Editperm(args) => {
|
|
||||||
let edit_privileges_args = database_command::DatabaseEditPrivsArgs {
|
|
||||||
name: Some(args.database),
|
|
||||||
privs: vec![],
|
|
||||||
json: false,
|
|
||||||
editor: None,
|
|
||||||
yes: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
database_command::edit_database_privileges(edit_privileges_args, message_stream)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
}
|
Command::Drop(args) => {
|
||||||
|
let filtered_names = filter_db_or_user_names(args.name, DbOrUser::Database)?;
|
||||||
|
for name in filtered_names {
|
||||||
|
drop_database(&name, &mut connection).await?;
|
||||||
|
println!("Database {} dropped.", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command::Show(args) => {
|
||||||
|
let names = if args.name.is_empty() {
|
||||||
|
get_database_list(&mut connection).await?
|
||||||
|
} else {
|
||||||
|
filter_db_or_user_names(args.name, DbOrUser::Database)?
|
||||||
|
};
|
||||||
|
|
||||||
async fn create_databases(
|
for name in names {
|
||||||
args: CreateArgs,
|
show_db(&name, &mut connection).await?;
|
||||||
mut server_connection: ClientToServerMessageStream,
|
}
|
||||||
) -> anyhow::Result<()> {
|
}
|
||||||
let database_names = args
|
Command::EditPerm(args) => {
|
||||||
.name
|
// TODO: This does not accurately replicate the behavior of the old implementation.
|
||||||
.iter()
|
// Hopefully, not many people rely on this in an automated fashion, as it
|
||||||
.map(|name| trim_to_32_chars(name))
|
// is made to be interactive in nature. However, we should still try to
|
||||||
.collect();
|
// replicate the old behavior as closely as possible.
|
||||||
|
let edit_privileges_args = database_command::DatabaseEditPrivsArgs {
|
||||||
|
name: Some(args.database),
|
||||||
|
privs: vec![],
|
||||||
|
json: false,
|
||||||
|
editor: None,
|
||||||
|
yes: false,
|
||||||
|
};
|
||||||
|
|
||||||
let message = Request::CreateDatabases(database_names);
|
database_command::edit_privileges(edit_privileges_args, &mut connection).await?;
|
||||||
server_connection.send(message).await?;
|
|
||||||
|
|
||||||
let result = match server_connection.next().await {
|
|
||||||
Some(Ok(Response::CreateDatabases(result))) => result,
|
|
||||||
response => return erroneous_server_response(response),
|
|
||||||
};
|
|
||||||
|
|
||||||
server_connection.send(Request::Exit).await?;
|
|
||||||
|
|
||||||
for (name, result) in result {
|
|
||||||
match result {
|
|
||||||
Ok(()) => println!("Database {} created.", name),
|
|
||||||
Err(err) => handle_create_database_error(err, &name),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn drop_databases(
|
async fn show_db(name: &str, connection: &mut MySqlConnection) -> anyhow::Result<()> {
|
||||||
args: DatabaseDropArgs,
|
|
||||||
mut server_connection: ClientToServerMessageStream,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let database_names = args
|
|
||||||
.name
|
|
||||||
.iter()
|
|
||||||
.map(|name| trim_to_32_chars(name))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let message = Request::DropDatabases(database_names);
|
|
||||||
server_connection.send(message).await?;
|
|
||||||
|
|
||||||
let result = match server_connection.next().await {
|
|
||||||
Some(Ok(Response::DropDatabases(result))) => result,
|
|
||||||
response => return erroneous_server_response(response),
|
|
||||||
};
|
|
||||||
|
|
||||||
server_connection.send(Request::Exit).await?;
|
|
||||||
|
|
||||||
for (name, result) in result {
|
|
||||||
match result {
|
|
||||||
Ok(()) => println!("Database {} dropped.", name),
|
|
||||||
Err(err) => handle_drop_database_error(err, &name),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn show_databases(
|
|
||||||
args: DatabaseShowArgs,
|
|
||||||
mut server_connection: ClientToServerMessageStream,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let database_names: Vec<String> = args
|
|
||||||
.name
|
|
||||||
.iter()
|
|
||||||
.map(|name| trim_to_32_chars(name))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let message = if database_names.is_empty() {
|
|
||||||
let message = Request::ListDatabases(None);
|
|
||||||
server_connection.send(message).await?;
|
|
||||||
let response = server_connection.next().await;
|
|
||||||
let databases = match response {
|
|
||||||
Some(Ok(Response::ListAllDatabases(databases))) => databases.unwrap_or(vec![]),
|
|
||||||
response => return erroneous_server_response(response),
|
|
||||||
};
|
|
||||||
|
|
||||||
let database_names = databases.into_iter().map(|db| db.database).collect();
|
|
||||||
|
|
||||||
Request::ListPrivileges(Some(database_names))
|
|
||||||
} else {
|
|
||||||
Request::ListPrivileges(Some(database_names))
|
|
||||||
};
|
|
||||||
server_connection.send(message).await?;
|
|
||||||
|
|
||||||
let response = server_connection.next().await;
|
|
||||||
|
|
||||||
server_connection.send(Request::Exit).await?;
|
|
||||||
|
|
||||||
// NOTE: mysql-dbadm show has a quirk where valid database names
|
// NOTE: mysql-dbadm show has a quirk where valid database names
|
||||||
// for non-existent databases will report with no users.
|
// for non-existent databases will report with no users.
|
||||||
let results: Vec<Result<(String, Vec<DatabasePrivilegeRow>), String>> = match response {
|
// This function should *not* check for db existence, only
|
||||||
Some(Ok(Response::ListPrivileges(result))) => result
|
// validate the names.
|
||||||
.into_iter()
|
let privileges = database_privilege_operations::get_database_privileges(name, connection)
|
||||||
.map(|(name, rows)| match rows.map(|rows| (name.clone(), rows)) {
|
.await
|
||||||
Ok(rows) => Ok(rows),
|
.unwrap_or(vec![]);
|
||||||
Err(GetDatabasesPrivilegeDataError::DatabaseDoesNotExist) => Ok((name, vec![])),
|
|
||||||
Err(err) => Err(format_show_database_error_message(err, &name)),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
response => return erroneous_server_response(response),
|
|
||||||
};
|
|
||||||
|
|
||||||
results.into_iter().try_for_each(|result| match result {
|
|
||||||
Ok((name, rows)) => print_db_privs(&name, rows),
|
|
||||||
Err(err) => {
|
|
||||||
eprintln!("{}", err);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn yn(value: bool) -> &'static str {
|
|
||||||
if value {
|
|
||||||
"Y"
|
|
||||||
} else {
|
|
||||||
"N"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_db_privs(name: &str, rows: Vec<DatabasePrivilegeRow>) -> anyhow::Result<()> {
|
|
||||||
println!(
|
println!(
|
||||||
concat!(
|
concat!(
|
||||||
"Database '{}':\n",
|
"Database '{}':\n",
|
||||||
|
@ -332,10 +191,10 @@ fn print_db_privs(name: &str, rows: Vec<DatabasePrivilegeRow>) -> anyhow::Result
|
||||||
),
|
),
|
||||||
name,
|
name,
|
||||||
);
|
);
|
||||||
if rows.is_empty() {
|
if privileges.is_empty() {
|
||||||
println!("# (no permissions currently granted to any users)");
|
println!("# (no permissions currently granted to any users)");
|
||||||
} else {
|
} else {
|
||||||
for privilege in rows {
|
for privilege in privileges {
|
||||||
println!(
|
println!(
|
||||||
" {:<16} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {}",
|
" {:<16} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {}",
|
||||||
privilege.user,
|
privilege.user,
|
||||||
|
|
|
@ -1,69 +1,31 @@
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use sqlx::MySqlConnection;
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use std::os::unix::net::UnixStream as StdUnixStream;
|
|
||||||
use tokio::net::UnixStream as TokioUnixStream;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
cli::{
|
cli::{mysql_admutils_compatibility::common::filter_db_or_user_names, user_command},
|
||||||
common::erroneous_server_response,
|
|
||||||
mysql_admutils_compatibility::{
|
|
||||||
common::trim_to_32_chars,
|
|
||||||
error_messages::{
|
|
||||||
handle_create_user_error, handle_drop_user_error, handle_list_users_error,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user_command::read_password_from_stdin_with_double_check,
|
|
||||||
},
|
|
||||||
core::{
|
core::{
|
||||||
bootstrap::bootstrap_server_connection_and_drop_privileges,
|
common::{close_database_connection, get_current_unix_user, DbOrUser},
|
||||||
protocol::{
|
config::{create_mysql_connection_from_config, get_config, GlobalConfigArgs},
|
||||||
create_client_to_server_message_stream, ClientToServerMessageStream, Request, Response,
|
user_operations::*,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
server::sql::user_operations::DatabaseUser,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
pub struct Args {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Option<Command>,
|
||||||
|
|
||||||
|
#[command(flatten)]
|
||||||
|
config_overrides: GlobalConfigArgs,
|
||||||
|
}
|
||||||
|
|
||||||
/// Create, delete or change password for the USER(s),
|
/// Create, delete or change password for the USER(s),
|
||||||
/// as determined by the COMMAND.
|
/// as determined by the COMMAND.
|
||||||
///
|
///
|
||||||
/// This is a compatibility layer for the mysql-useradm command.
|
/// This is a compatibility layer for the mysql-useradm command.
|
||||||
/// Please consider using the newer mysqladm command instead.
|
/// Please consider using the newer mysqladm command instead.
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(
|
#[command(version, about, disable_help_subcommand = true, verbatim_doc_comment)]
|
||||||
bin_name = "mysql-useradm",
|
|
||||||
version,
|
|
||||||
about,
|
|
||||||
disable_help_subcommand = true,
|
|
||||||
verbatim_doc_comment
|
|
||||||
)]
|
|
||||||
pub struct Args {
|
|
||||||
#[command(subcommand)]
|
|
||||||
pub command: Option<Command>,
|
|
||||||
|
|
||||||
/// Path to the socket of the server, if it already exists.
|
|
||||||
#[arg(
|
|
||||||
short,
|
|
||||||
long,
|
|
||||||
value_name = "PATH",
|
|
||||||
global = true,
|
|
||||||
hide_short_help = true
|
|
||||||
)]
|
|
||||||
server_socket_path: Option<PathBuf>,
|
|
||||||
|
|
||||||
/// Config file to use for the server.
|
|
||||||
#[arg(
|
|
||||||
short,
|
|
||||||
long,
|
|
||||||
value_name = "PATH",
|
|
||||||
global = true,
|
|
||||||
hide_short_help = true
|
|
||||||
)]
|
|
||||||
config: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
/// create the USER(s).
|
/// create the USER(s).
|
||||||
Create(CreateArgs),
|
Create(CreateArgs),
|
||||||
|
@ -107,7 +69,7 @@ pub struct ShowArgs {
|
||||||
name: Vec<String>,
|
name: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn main() -> anyhow::Result<()> {
|
pub async fn main() -> anyhow::Result<()> {
|
||||||
let args: Args = Args::parse();
|
let args: Args = Args::parse();
|
||||||
|
|
||||||
let command = match args.command {
|
let command = match args.command {
|
||||||
|
@ -123,185 +85,78 @@ pub fn main() -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let server_connection =
|
let config = get_config(args.config_overrides)?;
|
||||||
bootstrap_server_connection_and_drop_privileges(args.server_socket_path, args.config)?;
|
let mut connection = create_mysql_connection_from_config(config.mysql).await?;
|
||||||
|
|
||||||
tokio_run_command(command, server_connection)?;
|
match command {
|
||||||
|
Command::Create(args) => {
|
||||||
Ok(())
|
let filtered_names = filter_db_or_user_names(args.name, DbOrUser::User)?;
|
||||||
}
|
for name in filtered_names {
|
||||||
|
create_database_user(&name, &mut connection).await?;
|
||||||
fn tokio_run_command(command: Command, server_connection: StdUnixStream) -> anyhow::Result<()> {
|
|
||||||
tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.unwrap()
|
|
||||||
.block_on(async {
|
|
||||||
let tokio_socket = TokioUnixStream::from_std(server_connection)?;
|
|
||||||
let message_stream = create_client_to_server_message_stream(tokio_socket);
|
|
||||||
match command {
|
|
||||||
Command::Create(args) => create_user(args, message_stream).await,
|
|
||||||
Command::Delete(args) => drop_users(args, message_stream).await,
|
|
||||||
Command::Passwd(args) => passwd_users(args, message_stream).await,
|
|
||||||
Command::Show(args) => show_users(args, message_stream).await,
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_user(
|
|
||||||
args: CreateArgs,
|
|
||||||
mut server_connection: ClientToServerMessageStream,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let usernames = args
|
|
||||||
.name
|
|
||||||
.iter()
|
|
||||||
.map(|name| trim_to_32_chars(name))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let message = Request::CreateUsers(usernames);
|
|
||||||
server_connection.send(message).await?;
|
|
||||||
|
|
||||||
let result = match server_connection.next().await {
|
|
||||||
Some(Ok(Response::CreateUsers(result))) => result,
|
|
||||||
response => return erroneous_server_response(response),
|
|
||||||
};
|
|
||||||
|
|
||||||
server_connection.send(Request::Exit).await?;
|
|
||||||
|
|
||||||
for (name, result) in result {
|
|
||||||
match result {
|
|
||||||
Ok(()) => println!("User '{}' created.", name),
|
|
||||||
Err(err) => handle_create_user_error(err, &name),
|
|
||||||
}
|
}
|
||||||
}
|
Command::Delete(args) => {
|
||||||
|
let filtered_names = filter_db_or_user_names(args.name, DbOrUser::User)?;
|
||||||
Ok(())
|
for name in filtered_names {
|
||||||
}
|
delete_database_user(&name, &mut connection).await?;
|
||||||
|
|
||||||
async fn drop_users(
|
|
||||||
args: DeleteArgs,
|
|
||||||
mut server_connection: ClientToServerMessageStream,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let usernames = args
|
|
||||||
.name
|
|
||||||
.iter()
|
|
||||||
.map(|name| trim_to_32_chars(name))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let message = Request::DropUsers(usernames);
|
|
||||||
server_connection.send(message).await?;
|
|
||||||
|
|
||||||
let result = match server_connection.next().await {
|
|
||||||
Some(Ok(Response::DropUsers(result))) => result,
|
|
||||||
response => return erroneous_server_response(response),
|
|
||||||
};
|
|
||||||
|
|
||||||
server_connection.send(Request::Exit).await?;
|
|
||||||
|
|
||||||
for (name, result) in result {
|
|
||||||
match result {
|
|
||||||
Ok(()) => println!("User '{}' deleted.", name),
|
|
||||||
Err(err) => handle_drop_user_error(err, &name),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn passwd_users(
|
|
||||||
args: PasswdArgs,
|
|
||||||
mut server_connection: ClientToServerMessageStream,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let usernames = args
|
|
||||||
.name
|
|
||||||
.iter()
|
|
||||||
.map(|name| trim_to_32_chars(name))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let message = Request::ListUsers(Some(usernames));
|
|
||||||
server_connection.send(message).await?;
|
|
||||||
|
|
||||||
let response = match server_connection.next().await {
|
|
||||||
Some(Ok(Response::ListUsers(result))) => result,
|
|
||||||
response => return erroneous_server_response(response),
|
|
||||||
};
|
|
||||||
|
|
||||||
let argv0 = std::env::args()
|
|
||||||
.next()
|
|
||||||
.unwrap_or("mysql-useradm".to_string());
|
|
||||||
|
|
||||||
let users = response
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|(name, result)| match result {
|
|
||||||
Ok(user) => Some(user),
|
|
||||||
Err(err) => {
|
|
||||||
handle_list_users_error(err, &name);
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
for user in users {
|
|
||||||
let password = read_password_from_stdin_with_double_check(&user.user)?;
|
|
||||||
let message = Request::PasswdUser(user.user.clone(), password);
|
|
||||||
server_connection.send(message).await?;
|
|
||||||
match server_connection.next().await {
|
|
||||||
Some(Ok(Response::PasswdUser(result))) => match result {
|
|
||||||
Ok(()) => println!("Password updated for user '{}'.", user.user),
|
|
||||||
Err(_) => eprintln!(
|
|
||||||
"{}: Failed to update password for user '{}'.",
|
|
||||||
argv0, user.user,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
response => return erroneous_server_response(response),
|
|
||||||
}
|
}
|
||||||
|
Command::Passwd(args) => passwd(args, &mut connection).await?,
|
||||||
|
Command::Show(args) => show(args, &mut connection).await?,
|
||||||
}
|
}
|
||||||
|
|
||||||
server_connection.send(Request::Exit).await?;
|
close_database_connection(connection).await;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn show_users(
|
async fn passwd(args: PasswdArgs, connection: &mut MySqlConnection) -> anyhow::Result<()> {
|
||||||
args: ShowArgs,
|
let filtered_names = filter_db_or_user_names(args.name, DbOrUser::User)?;
|
||||||
mut server_connection: ClientToServerMessageStream,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let usernames: Vec<_> = args
|
|
||||||
.name
|
|
||||||
.iter()
|
|
||||||
.map(|name| trim_to_32_chars(name))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let message = if usernames.is_empty() {
|
// NOTE: this gets doubly checked during the call to `set_password_for_database_user`.
|
||||||
Request::ListUsers(None)
|
// This is moving the check before asking the user for the password,
|
||||||
|
// to avoid having them figure out that the user does not exist after they
|
||||||
|
// have entered the password twice.
|
||||||
|
let mut better_filtered_names = Vec::with_capacity(filtered_names.len());
|
||||||
|
for name in filtered_names.into_iter() {
|
||||||
|
if !user_exists(&name, connection).await? {
|
||||||
|
println!(
|
||||||
|
"{}: User '{}' does not exist. You must create it first.",
|
||||||
|
std::env::args()
|
||||||
|
.next()
|
||||||
|
.unwrap_or("mysql-useradm".to_string()),
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
better_filtered_names.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for name in better_filtered_names {
|
||||||
|
let password = user_command::read_password_from_stdin_with_double_check(&name)?;
|
||||||
|
set_password_for_database_user(&name, &password, connection).await?;
|
||||||
|
println!("Password updated for user '{}'.", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn show(args: ShowArgs, connection: &mut MySqlConnection) -> anyhow::Result<()> {
|
||||||
|
let users = if args.name.is_empty() {
|
||||||
|
let unix_user = get_current_unix_user()?;
|
||||||
|
get_all_database_users_for_unix_user(&unix_user, connection).await?
|
||||||
} else {
|
} else {
|
||||||
Request::ListUsers(Some(usernames))
|
let filtered_usernames = filter_db_or_user_names(args.name, DbOrUser::User)?;
|
||||||
};
|
let mut result = Vec::with_capacity(filtered_usernames.len());
|
||||||
server_connection.send(message).await?;
|
for username in filtered_usernames.iter() {
|
||||||
|
// TODO: fetch all users in one query
|
||||||
let users: Vec<DatabaseUser> = match server_connection.next().await {
|
if let Some(user) = get_database_user_for_user(username, connection).await? {
|
||||||
Some(Ok(Response::ListAllUsers(result))) => match result {
|
result.push(user)
|
||||||
Ok(users) => users,
|
|
||||||
Err(err) => {
|
|
||||||
println!("Failed to list users: {:?}", err);
|
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
Some(Ok(Response::ListUsers(result))) => result
|
result
|
||||||
.into_iter()
|
|
||||||
.filter_map(|(name, result)| match result {
|
|
||||||
Ok(user) => Some(user),
|
|
||||||
Err(err) => {
|
|
||||||
handle_list_users_error(err, &name);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
response => return erroneous_server_response(response),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
server_connection.send(Request::Exit).await?;
|
|
||||||
|
|
||||||
for user in users {
|
for user in users {
|
||||||
if user.has_password {
|
if user.has_password {
|
||||||
println!("User '{}': password set.", user.user);
|
println!("User '{}': password set.", user.user);
|
||||||
|
|
|
@ -1,25 +1,27 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::vec;
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use dialoguer::{Confirm, Password};
|
use dialoguer::{Confirm, Password};
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use prettytable::Table;
|
||||||
|
use serde_json::json;
|
||||||
|
use sqlx::{Connection, MySqlConnection};
|
||||||
|
|
||||||
use crate::core::protocol::{
|
use crate::core::{
|
||||||
print_create_users_output_status, print_drop_users_output_status,
|
common::{close_database_connection, get_current_unix_user, CommandStatus},
|
||||||
print_lock_users_output_status, print_set_password_output_status,
|
database_operations::*,
|
||||||
print_unlock_users_output_status, ClientToServerMessageStream, ListUsersError, Request,
|
user_operations::*,
|
||||||
Response,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::common::erroneous_server_response;
|
#[derive(Parser)]
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
|
||||||
pub struct UserArgs {
|
pub struct UserArgs {
|
||||||
#[clap(subcommand)]
|
#[clap(subcommand)]
|
||||||
subcmd: UserCommand,
|
subcmd: UserCommand,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::enum_variant_names)]
|
#[allow(clippy::enum_variant_names)]
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser)]
|
||||||
pub enum UserCommand {
|
pub enum UserCommand {
|
||||||
/// Create one or more users
|
/// Create one or more users
|
||||||
#[command()]
|
#[command()]
|
||||||
|
@ -33,7 +35,7 @@ pub enum UserCommand {
|
||||||
#[command()]
|
#[command()]
|
||||||
PasswdUser(UserPasswdArgs),
|
PasswdUser(UserPasswdArgs),
|
||||||
|
|
||||||
/// Print information about one or more users
|
/// Give information about one or more users
|
||||||
///
|
///
|
||||||
/// If no username is provided, all users you have access will be shown.
|
/// If no username is provided, all users you have access will be shown.
|
||||||
#[command()]
|
#[command()]
|
||||||
|
@ -48,7 +50,7 @@ pub enum UserCommand {
|
||||||
UnlockUser(UserUnlockArgs),
|
UnlockUser(UserUnlockArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser)]
|
||||||
pub struct UserCreateArgs {
|
pub struct UserCreateArgs {
|
||||||
#[arg(num_args = 1..)]
|
#[arg(num_args = 1..)]
|
||||||
username: Vec<String>,
|
username: Vec<String>,
|
||||||
|
@ -58,13 +60,13 @@ pub struct UserCreateArgs {
|
||||||
no_password: bool,
|
no_password: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser)]
|
||||||
pub struct UserDeleteArgs {
|
pub struct UserDeleteArgs {
|
||||||
#[arg(num_args = 1..)]
|
#[arg(num_args = 1..)]
|
||||||
username: Vec<String>,
|
username: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser)]
|
||||||
pub struct UserPasswdArgs {
|
pub struct UserPasswdArgs {
|
||||||
username: String,
|
username: String,
|
||||||
|
|
||||||
|
@ -72,7 +74,7 @@ pub struct UserPasswdArgs {
|
||||||
password_file: Option<String>,
|
password_file: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser)]
|
||||||
pub struct UserShowArgs {
|
pub struct UserShowArgs {
|
||||||
#[arg(num_args = 0..)]
|
#[arg(num_args = 0..)]
|
||||||
username: Vec<String>,
|
username: Vec<String>,
|
||||||
|
@ -81,13 +83,13 @@ pub struct UserShowArgs {
|
||||||
json: bool,
|
json: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser)]
|
||||||
pub struct UserLockArgs {
|
pub struct UserLockArgs {
|
||||||
#[arg(num_args = 1..)]
|
#[arg(num_args = 1..)]
|
||||||
username: Vec<String>,
|
username: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser)]
|
||||||
pub struct UserUnlockArgs {
|
pub struct UserUnlockArgs {
|
||||||
#[arg(num_args = 1..)]
|
#[arg(num_args = 1..)]
|
||||||
username: Vec<String>,
|
username: Vec<String>,
|
||||||
|
@ -95,103 +97,91 @@ pub struct UserUnlockArgs {
|
||||||
|
|
||||||
pub async fn handle_command(
|
pub async fn handle_command(
|
||||||
command: UserCommand,
|
command: UserCommand,
|
||||||
server_connection: ClientToServerMessageStream,
|
mut connection: MySqlConnection,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<CommandStatus> {
|
||||||
match command {
|
let result = connection
|
||||||
UserCommand::CreateUser(args) => create_users(args, server_connection).await,
|
.transaction(|txn| {
|
||||||
UserCommand::DropUser(args) => drop_users(args, server_connection).await,
|
Box::pin(async move {
|
||||||
UserCommand::PasswdUser(args) => passwd_user(args, server_connection).await,
|
match command {
|
||||||
UserCommand::ShowUser(args) => show_users(args, server_connection).await,
|
UserCommand::CreateUser(args) => create_users(args, txn).await,
|
||||||
UserCommand::LockUser(args) => lock_users(args, server_connection).await,
|
UserCommand::DropUser(args) => drop_users(args, txn).await,
|
||||||
UserCommand::UnlockUser(args) => unlock_users(args, server_connection).await,
|
UserCommand::PasswdUser(args) => change_password_for_user(args, txn).await,
|
||||||
}
|
UserCommand::ShowUser(args) => show_users(args, txn).await,
|
||||||
|
UserCommand::LockUser(args) => lock_users(args, txn).await,
|
||||||
|
UserCommand::UnlockUser(args) => unlock_users(args, txn).await,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
close_database_connection(connection).await;
|
||||||
|
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_users(
|
async fn create_users(
|
||||||
args: UserCreateArgs,
|
args: UserCreateArgs,
|
||||||
mut server_connection: ClientToServerMessageStream,
|
connection: &mut MySqlConnection,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<CommandStatus> {
|
||||||
if args.username.is_empty() {
|
if args.username.is_empty() {
|
||||||
anyhow::bail!("No usernames provided");
|
anyhow::bail!("No usernames provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = Request::CreateUsers(args.username.clone());
|
let mut result = CommandStatus::SuccessfullyModified;
|
||||||
if let Err(err) = server_connection.send(message).await {
|
|
||||||
server_connection.close().await.ok();
|
|
||||||
anyhow::bail!(anyhow::Error::from(err).context("Failed to communicate with server"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = match server_connection.next().await {
|
for username in args.username {
|
||||||
Some(Ok(Response::CreateUsers(result))) => result,
|
if let Err(e) = create_database_user(&username, connection).await {
|
||||||
response => return erroneous_server_response(response),
|
eprintln!("{}", e);
|
||||||
};
|
eprintln!("Skipping...\n");
|
||||||
|
result = CommandStatus::PartiallySuccessfullyModified;
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
println!("User '{}' created.", username);
|
||||||
|
}
|
||||||
|
|
||||||
print_create_users_output_status(&result);
|
|
||||||
|
|
||||||
let successfully_created_users = result
|
|
||||||
.iter()
|
|
||||||
.filter_map(|(username, result)| result.as_ref().ok().map(|_| username))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
for username in successfully_created_users {
|
|
||||||
if !args.no_password
|
if !args.no_password
|
||||||
&& Confirm::new()
|
&& Confirm::new()
|
||||||
.with_prompt(format!(
|
.with_prompt(format!(
|
||||||
"Do you want to set a password for user '{}'?",
|
"Do you want to set a password for user '{}'?",
|
||||||
username
|
username
|
||||||
))
|
))
|
||||||
.default(false)
|
|
||||||
.interact()?
|
.interact()?
|
||||||
{
|
{
|
||||||
let password = read_password_from_stdin_with_double_check(username)?;
|
change_password_for_user(
|
||||||
let message = Request::PasswdUser(username.clone(), password);
|
UserPasswdArgs {
|
||||||
|
username,
|
||||||
if let Err(err) = server_connection.send(message).await {
|
password_file: None,
|
||||||
server_connection.close().await.ok();
|
},
|
||||||
anyhow::bail!(err);
|
connection,
|
||||||
}
|
)
|
||||||
|
.await?;
|
||||||
match server_connection.next().await {
|
|
||||||
Some(Ok(Response::PasswdUser(result))) => {
|
|
||||||
print_set_password_output_status(&result, username)
|
|
||||||
}
|
|
||||||
response => return erroneous_server_response(response),
|
|
||||||
}
|
|
||||||
|
|
||||||
println!();
|
|
||||||
}
|
}
|
||||||
|
println!();
|
||||||
}
|
}
|
||||||
|
Ok(result)
|
||||||
server_connection.send(Request::Exit).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn drop_users(
|
async fn drop_users(
|
||||||
args: UserDeleteArgs,
|
args: UserDeleteArgs,
|
||||||
mut server_connection: ClientToServerMessageStream,
|
connection: &mut MySqlConnection,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<CommandStatus> {
|
||||||
if args.username.is_empty() {
|
if args.username.is_empty() {
|
||||||
anyhow::bail!("No usernames provided");
|
anyhow::bail!("No usernames provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = Request::DropUsers(args.username.clone());
|
let mut result = CommandStatus::SuccessfullyModified;
|
||||||
|
|
||||||
if let Err(err) = server_connection.send(message).await {
|
for username in args.username {
|
||||||
server_connection.close().await.ok();
|
if let Err(e) = delete_database_user(&username, connection).await {
|
||||||
anyhow::bail!(err);
|
eprintln!("{}", e);
|
||||||
|
eprintln!("Skipping...");
|
||||||
|
result = CommandStatus::PartiallySuccessfullyModified;
|
||||||
|
} else {
|
||||||
|
println!("User '{}' dropped.", username);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = match server_connection.next().await {
|
Ok(result)
|
||||||
Some(Ok(Response::DropUsers(result))) => result,
|
|
||||||
response => return erroneous_server_response(response),
|
|
||||||
};
|
|
||||||
|
|
||||||
server_connection.send(Request::Exit).await?;
|
|
||||||
|
|
||||||
print_drop_users_output_status(&result);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_password_from_stdin_with_double_check(username: &str) -> anyhow::Result<String> {
|
pub fn read_password_from_stdin_with_double_check(username: &str) -> anyhow::Result<String> {
|
||||||
|
@ -205,31 +195,14 @@ pub fn read_password_from_stdin_with_double_check(username: &str) -> anyhow::Res
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn passwd_user(
|
async fn change_password_for_user(
|
||||||
args: UserPasswdArgs,
|
args: UserPasswdArgs,
|
||||||
mut server_connection: ClientToServerMessageStream,
|
connection: &mut MySqlConnection,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<CommandStatus> {
|
||||||
// TODO: create a "user" exists check" command
|
// NOTE: although this also is checked in `set_password_for_database_user`, we check it here
|
||||||
let message = Request::ListUsers(Some(vec![args.username.clone()]));
|
// to provide a more natural order of error messages.
|
||||||
if let Err(err) = server_connection.send(message).await {
|
let unix_user = get_current_unix_user()?;
|
||||||
server_connection.close().await.ok();
|
validate_user_name(&args.username, &unix_user)?;
|
||||||
anyhow::bail!(err);
|
|
||||||
}
|
|
||||||
let response = match server_connection.next().await {
|
|
||||||
Some(Ok(Response::ListUsers(users))) => users,
|
|
||||||
response => return erroneous_server_response(response),
|
|
||||||
};
|
|
||||||
match response
|
|
||||||
.get(&args.username)
|
|
||||||
.unwrap_or(&Err(ListUsersError::UserDoesNotExist))
|
|
||||||
{
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(err) => {
|
|
||||||
server_connection.send(Request::Exit).await?;
|
|
||||||
server_connection.close().await.ok();
|
|
||||||
anyhow::bail!("{}", err.to_error_message(&args.username));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let password = if let Some(password_file) = args.password_file {
|
let password = if let Some(password_file) = args.password_file {
|
||||||
std::fs::read_to_string(password_file)
|
std::fs::read_to_string(password_file)
|
||||||
|
@ -240,75 +213,67 @@ async fn passwd_user(
|
||||||
read_password_from_stdin_with_double_check(&args.username)?
|
read_password_from_stdin_with_double_check(&args.username)?
|
||||||
};
|
};
|
||||||
|
|
||||||
let message = Request::PasswdUser(args.username.clone(), password);
|
set_password_for_database_user(&args.username, &password, connection).await?;
|
||||||
|
|
||||||
if let Err(err) = server_connection.send(message).await {
|
Ok(CommandStatus::SuccessfullyModified)
|
||||||
server_connection.close().await.ok();
|
|
||||||
anyhow::bail!(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = match server_connection.next().await {
|
|
||||||
Some(Ok(Response::PasswdUser(result))) => result,
|
|
||||||
response => return erroneous_server_response(response),
|
|
||||||
};
|
|
||||||
|
|
||||||
server_connection.send(Request::Exit).await?;
|
|
||||||
|
|
||||||
print_set_password_output_status(&result, &args.username);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn show_users(
|
async fn show_users(
|
||||||
args: UserShowArgs,
|
args: UserShowArgs,
|
||||||
mut server_connection: ClientToServerMessageStream,
|
connection: &mut MySqlConnection,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<CommandStatus> {
|
||||||
let message = if args.username.is_empty() {
|
let unix_user = get_current_unix_user()?;
|
||||||
Request::ListUsers(None)
|
|
||||||
|
let users = if args.username.is_empty() {
|
||||||
|
get_all_database_users_for_unix_user(&unix_user, connection).await?
|
||||||
} else {
|
} else {
|
||||||
Request::ListUsers(Some(args.username.clone()))
|
let mut result = vec![];
|
||||||
|
for username in args.username {
|
||||||
|
if let Err(e) = validate_user_name(&username, &unix_user) {
|
||||||
|
eprintln!("{}", e);
|
||||||
|
eprintln!("Skipping...");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = get_database_user_for_user(&username, connection).await?;
|
||||||
|
if let Some(user) = user {
|
||||||
|
result.push(user);
|
||||||
|
} else {
|
||||||
|
eprintln!("User not found: {}", username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(err) = server_connection.send(message).await {
|
let mut user_databases: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||||
server_connection.close().await.ok();
|
for user in users.iter() {
|
||||||
anyhow::bail!(err);
|
user_databases.insert(
|
||||||
|
user.user.clone(),
|
||||||
|
get_databases_where_user_has_privileges(&user.user, connection).await?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let users = match server_connection.next().await {
|
|
||||||
Some(Ok(Response::ListUsers(users))) => users
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|(username, result)| match result {
|
|
||||||
Ok(user) => Some(user),
|
|
||||||
Err(err) => {
|
|
||||||
eprintln!("{}", err.to_error_message(&username));
|
|
||||||
eprintln!("Skipping...");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
Some(Ok(Response::ListAllUsers(users))) => match users {
|
|
||||||
Ok(users) => users,
|
|
||||||
Err(err) => {
|
|
||||||
server_connection.send(Request::Exit).await?;
|
|
||||||
return Err(
|
|
||||||
anyhow::anyhow!(err.to_error_message()).context("Failed to list all users")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
response => return erroneous_server_response(response),
|
|
||||||
};
|
|
||||||
|
|
||||||
server_connection.send(Request::Exit).await?;
|
|
||||||
|
|
||||||
if args.json {
|
if args.json {
|
||||||
|
let users_json = users
|
||||||
|
.into_iter()
|
||||||
|
.map(|user| {
|
||||||
|
json!({
|
||||||
|
"user": user.user,
|
||||||
|
"has_password": user.has_password,
|
||||||
|
"is_locked": user.is_locked,
|
||||||
|
"databases": user_databases.get(&user.user).unwrap_or(&vec![]),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<serde_json::Value>();
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
serde_json::to_string_pretty(&users).context("Failed to serialize users to JSON")?
|
serde_json::to_string_pretty(&users_json)
|
||||||
|
.context("Failed to serialize users to JSON")?
|
||||||
);
|
);
|
||||||
} else if users.is_empty() {
|
} else if users.is_empty() {
|
||||||
println!("No users to show.");
|
println!("No users found.");
|
||||||
} else {
|
} else {
|
||||||
let mut table = prettytable::Table::new();
|
let mut table = Table::new();
|
||||||
table.add_row(row![
|
table.add_row(row![
|
||||||
"User",
|
"User",
|
||||||
"Password is set",
|
"Password is set",
|
||||||
|
@ -320,65 +285,57 @@ async fn show_users(
|
||||||
user.user,
|
user.user,
|
||||||
user.has_password,
|
user.has_password,
|
||||||
user.is_locked,
|
user.is_locked,
|
||||||
user.databases.join("\n")
|
user_databases.get(&user.user).unwrap_or(&vec![]).join("\n")
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
table.printstd();
|
table.printstd();
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(CommandStatus::NoModificationsIntended)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn lock_users(
|
async fn lock_users(
|
||||||
args: UserLockArgs,
|
args: UserLockArgs,
|
||||||
mut server_connection: ClientToServerMessageStream,
|
connection: &mut MySqlConnection,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<CommandStatus> {
|
||||||
if args.username.is_empty() {
|
if args.username.is_empty() {
|
||||||
anyhow::bail!("No usernames provided");
|
anyhow::bail!("No usernames provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = Request::LockUsers(args.username.clone());
|
let mut result = CommandStatus::SuccessfullyModified;
|
||||||
|
|
||||||
if let Err(err) = server_connection.send(message).await {
|
for username in args.username {
|
||||||
server_connection.close().await.ok();
|
if let Err(e) = lock_database_user(&username, connection).await {
|
||||||
anyhow::bail!(err);
|
eprintln!("{}", e);
|
||||||
|
eprintln!("Skipping...");
|
||||||
|
result = CommandStatus::PartiallySuccessfullyModified;
|
||||||
|
} else {
|
||||||
|
println!("User '{}' locked.", username);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = match server_connection.next().await {
|
Ok(result)
|
||||||
Some(Ok(Response::LockUsers(result))) => result,
|
|
||||||
response => return erroneous_server_response(response),
|
|
||||||
};
|
|
||||||
|
|
||||||
server_connection.send(Request::Exit).await?;
|
|
||||||
|
|
||||||
print_lock_users_output_status(&result);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn unlock_users(
|
async fn unlock_users(
|
||||||
args: UserUnlockArgs,
|
args: UserUnlockArgs,
|
||||||
mut server_connection: ClientToServerMessageStream,
|
connection: &mut MySqlConnection,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<CommandStatus> {
|
||||||
if args.username.is_empty() {
|
if args.username.is_empty() {
|
||||||
anyhow::bail!("No usernames provided");
|
anyhow::bail!("No usernames provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = Request::UnlockUsers(args.username.clone());
|
let mut result = CommandStatus::SuccessfullyModified;
|
||||||
|
|
||||||
if let Err(err) = server_connection.send(message).await {
|
for username in args.username {
|
||||||
server_connection.close().await.ok();
|
if let Err(e) = unlock_database_user(&username, connection).await {
|
||||||
anyhow::bail!(err);
|
eprintln!("{}", e);
|
||||||
|
eprintln!("Skipping...");
|
||||||
|
result = CommandStatus::PartiallySuccessfullyModified;
|
||||||
|
} else {
|
||||||
|
println!("User '{}' unlocked.", username);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = match server_connection.next().await {
|
Ok(result)
|
||||||
Some(Ok(Response::UnlockUsers(result))) => result,
|
|
||||||
response => return erroneous_server_response(response),
|
|
||||||
};
|
|
||||||
|
|
||||||
server_connection.send(Request::Exit).await?;
|
|
||||||
|
|
||||||
print_unlock_users_output_status(&result);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod bootstrap;
|
|
||||||
pub mod common;
|
pub mod common;
|
||||||
pub mod database_privileges;
|
pub mod config;
|
||||||
pub mod protocol;
|
pub mod database_operations;
|
||||||
|
pub mod database_privilege_operations;
|
||||||
|
pub mod user_operations;
|
||||||
|
|
|
@ -1,163 +0,0 @@
|
||||||
use std::{fs, path::PathBuf};
|
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
use nix::libc::{exit, EXIT_SUCCESS};
|
|
||||||
use std::os::unix::net::UnixStream as StdUnixStream;
|
|
||||||
use tokio::net::UnixStream as TokioUnixStream;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
core::common::{UnixUser, DEFAULT_CONFIG_PATH, DEFAULT_SOCKET_PATH},
|
|
||||||
server::{config::read_config_from_path, server_loop::handle_requests_for_single_session},
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: this function is security critical, it should be integration tested
|
|
||||||
// in isolation.
|
|
||||||
/// Drop privileges to the real user and group of the process.
|
|
||||||
/// If the process is not running with elevated privileges, this function
|
|
||||||
/// is a no-op.
|
|
||||||
pub fn drop_privs() -> anyhow::Result<()> {
|
|
||||||
log::debug!("Dropping privileges");
|
|
||||||
let real_uid = nix::unistd::getuid();
|
|
||||||
let real_gid = nix::unistd::getgid();
|
|
||||||
|
|
||||||
nix::unistd::setuid(real_uid).context("Failed to drop privileges")?;
|
|
||||||
nix::unistd::setgid(real_gid).context("Failed to drop privileges")?;
|
|
||||||
|
|
||||||
debug_assert_eq!(nix::unistd::getuid(), real_uid);
|
|
||||||
debug_assert_eq!(nix::unistd::getgid(), real_gid);
|
|
||||||
|
|
||||||
log::debug!("Privileges dropped successfully");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This function is used to bootstrap the connection to the server.
|
|
||||||
/// This can happen in two ways:
|
|
||||||
///
|
|
||||||
/// 1. If a socket path is provided, or exists in the default location,
|
|
||||||
/// the function will connect to the socket and authenticate with the
|
|
||||||
/// server to ensure that the server knows the uid of the client.
|
|
||||||
///
|
|
||||||
/// 2. If a config path is provided, or exists in the default location,
|
|
||||||
/// and the config is readable, the function will assume it is either
|
|
||||||
/// setuid or setgid, and will fork a child process to run the server
|
|
||||||
/// with the provided config. The server will exit silently by itself
|
|
||||||
/// when it is done, and this function will only return for the client
|
|
||||||
/// with the socket for the server.
|
|
||||||
///
|
|
||||||
/// If neither of these options are available, the function will fail.
|
|
||||||
pub fn bootstrap_server_connection_and_drop_privileges(
|
|
||||||
server_socket_path: Option<PathBuf>,
|
|
||||||
config_path: Option<PathBuf>,
|
|
||||||
) -> anyhow::Result<StdUnixStream> {
|
|
||||||
if server_socket_path.is_some() && config_path.is_some() {
|
|
||||||
anyhow::bail!("Cannot provide both a socket path and a config path");
|
|
||||||
}
|
|
||||||
|
|
||||||
log::debug!("Starting the server connection bootstrap process");
|
|
||||||
|
|
||||||
let socket = bootstrap_server_connection(server_socket_path, config_path)?;
|
|
||||||
|
|
||||||
drop_privs()?;
|
|
||||||
|
|
||||||
Ok(socket)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inner function for [`bootstrap_server_connection_and_drop_privileges`].
|
|
||||||
/// See that function for more information.
|
|
||||||
fn bootstrap_server_connection(
|
|
||||||
socket_path: Option<PathBuf>,
|
|
||||||
config_path: Option<PathBuf>,
|
|
||||||
) -> anyhow::Result<StdUnixStream> {
|
|
||||||
// TODO: ensure this is both readable and writable
|
|
||||||
if let Some(socket_path) = socket_path {
|
|
||||||
log::debug!("Connecting to socket at {:?}", socket_path);
|
|
||||||
return match StdUnixStream::connect(socket_path) {
|
|
||||||
Ok(socket) => Ok(socket),
|
|
||||||
Err(e) => match e.kind() {
|
|
||||||
std::io::ErrorKind::NotFound => Err(anyhow::anyhow!("Socket not found")),
|
|
||||||
std::io::ErrorKind::PermissionDenied => Err(anyhow::anyhow!("Permission denied")),
|
|
||||||
_ => Err(anyhow::anyhow!("Failed to connect to socket: {}", e)),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if let Some(config_path) = config_path {
|
|
||||||
// ensure config exists and is readable
|
|
||||||
if fs::metadata(&config_path).is_err() {
|
|
||||||
return Err(anyhow::anyhow!("Config file not found or not readable"));
|
|
||||||
}
|
|
||||||
|
|
||||||
log::debug!("Starting server with config at {:?}", config_path);
|
|
||||||
return invoke_server_with_config(config_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
if fs::metadata(DEFAULT_SOCKET_PATH).is_ok() {
|
|
||||||
log::debug!("Connecting to default socket at {:?}", DEFAULT_SOCKET_PATH);
|
|
||||||
return match StdUnixStream::connect(DEFAULT_SOCKET_PATH) {
|
|
||||||
Ok(socket) => Ok(socket),
|
|
||||||
Err(e) => match e.kind() {
|
|
||||||
std::io::ErrorKind::NotFound => Err(anyhow::anyhow!("Socket not found")),
|
|
||||||
std::io::ErrorKind::PermissionDenied => Err(anyhow::anyhow!("Permission denied")),
|
|
||||||
_ => Err(anyhow::anyhow!("Failed to connect to socket: {}", e)),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let config_path = PathBuf::from(DEFAULT_CONFIG_PATH);
|
|
||||||
if fs::metadata(&config_path).is_ok() {
|
|
||||||
log::debug!("Starting server with default config at {:?}", config_path);
|
|
||||||
return invoke_server_with_config(config_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
anyhow::bail!("No socket path or config path provided, and no default socket or config found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: we should somehow ensure that the forked process is killed on completion,
|
|
||||||
// just in case the client does not behave properly.
|
|
||||||
/// Fork a child process to run the server with the provided config.
|
|
||||||
/// The server will exit silently by itself when it is done, and this function
|
|
||||||
/// will only return for the client with the socket for the server.
|
|
||||||
fn invoke_server_with_config(config_path: PathBuf) -> anyhow::Result<StdUnixStream> {
|
|
||||||
let (server_socket, client_socket) = StdUnixStream::pair()?;
|
|
||||||
let unix_user = UnixUser::from_uid(nix::unistd::getuid().as_raw())?;
|
|
||||||
|
|
||||||
match (unsafe { nix::unistd::fork() }).context("Failed to fork")? {
|
|
||||||
nix::unistd::ForkResult::Parent { child } => {
|
|
||||||
log::debug!("Forked child process with PID {}", child);
|
|
||||||
Ok(client_socket)
|
|
||||||
}
|
|
||||||
nix::unistd::ForkResult::Child => {
|
|
||||||
log::debug!("Running server in child process");
|
|
||||||
|
|
||||||
match run_forked_server(config_path, server_socket, unix_user) {
|
|
||||||
Err(e) => Err(e),
|
|
||||||
Ok(_) => unreachable!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run the server in the forked child process.
|
|
||||||
/// This function will not return, but will exit the process with a success code.
|
|
||||||
fn run_forked_server(
|
|
||||||
config_path: PathBuf,
|
|
||||||
server_socket: StdUnixStream,
|
|
||||||
unix_user: UnixUser,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let config = read_config_from_path(Some(config_path))?;
|
|
||||||
|
|
||||||
let result: anyhow::Result<()> = tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.unwrap()
|
|
||||||
.block_on(async {
|
|
||||||
let socket = TokioUnixStream::from_std(server_socket)?;
|
|
||||||
handle_requests_for_single_session(socket, &unix_user, &config).await?;
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
|
|
||||||
result?;
|
|
||||||
|
|
||||||
unsafe {
|
|
||||||
exit(EXIT_SUCCESS);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,32 +1,56 @@
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use nix::unistd::{Group as LibcGroup, User as LibcUser};
|
use indoc::indoc;
|
||||||
|
use itertools::Itertools;
|
||||||
|
use nix::unistd::{getuid, Group, User};
|
||||||
|
use sqlx::{Connection, MySqlConnection};
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
use std::ffi::CString;
|
use std::ffi::CString;
|
||||||
|
|
||||||
pub const DEFAULT_CONFIG_PATH: &str = "/etc/mysqladm/config.toml";
|
/// Report the result status of a command.
|
||||||
pub const DEFAULT_SOCKET_PATH: &str = "/run/mysqladm/mysqladm.sock";
|
/// This is used to display a status message to the user.
|
||||||
|
pub enum CommandStatus {
|
||||||
|
/// The command was successful,
|
||||||
|
/// and made modification to the database.
|
||||||
|
SuccessfullyModified,
|
||||||
|
|
||||||
pub struct UnixUser {
|
/// The command was mostly successful,
|
||||||
pub username: String,
|
/// and modifications have been made to the database.
|
||||||
pub groups: Vec<String>,
|
/// However, some of the requested modifications failed.
|
||||||
|
PartiallySuccessfullyModified,
|
||||||
|
|
||||||
|
/// The command was successful,
|
||||||
|
/// but no modifications were needed.
|
||||||
|
NoModificationsNeeded,
|
||||||
|
|
||||||
|
/// The command was successful,
|
||||||
|
/// and made no modification to the database.
|
||||||
|
NoModificationsIntended,
|
||||||
|
|
||||||
|
/// The command was cancelled, either through a dialog or a signal.
|
||||||
|
/// No modifications have been made to the database.
|
||||||
|
Cancelled,
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: these functions are somewhat critical, and should have integration tests
|
pub fn get_current_unix_user() -> anyhow::Result<User> {
|
||||||
|
User::from_uid(getuid())
|
||||||
|
.context("Failed to look up your UNIX username")
|
||||||
|
.and_then(|u| u.ok_or(anyhow::anyhow!("Failed to look up your UNIX username")))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn get_unix_groups(_user: &LibcUser) -> anyhow::Result<Vec<LibcGroup>> {
|
pub fn get_unix_groups(_user: &User) -> anyhow::Result<Vec<Group>> {
|
||||||
// Return an empty list on macOS since there is no `getgrouplist` function
|
// Return an empty list on macOS since there is no `getgrouplist` function
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
fn get_unix_groups(user: &LibcUser) -> anyhow::Result<Vec<LibcGroup>> {
|
pub fn get_unix_groups(user: &User) -> anyhow::Result<Vec<Group>> {
|
||||||
let user_cstr =
|
let user_cstr =
|
||||||
CString::new(user.name.as_bytes()).context("Failed to convert username to CStr")?;
|
CString::new(user.name.as_bytes()).context("Failed to convert username to CStr")?;
|
||||||
let groups = nix::unistd::getgrouplist(&user_cstr, user.gid)?
|
let groups = nix::unistd::getgrouplist(&user_cstr, user.gid)?
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|gid| match LibcGroup::from_gid(*gid) {
|
.filter_map(|gid| match Group::from_gid(*gid) {
|
||||||
Ok(Some(group)) => Some(group),
|
Ok(Some(group)) => Some(group),
|
||||||
Ok(None) => None,
|
Ok(None) => None,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
@ -38,32 +62,211 @@ fn get_unix_groups(user: &LibcUser) -> anyhow::Result<Vec<LibcGroup>> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect::<Vec<LibcGroup>>();
|
.collect::<Vec<Group>>();
|
||||||
|
|
||||||
Ok(groups)
|
Ok(groups)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UnixUser {
|
/// This function creates a regex that matches items (users, databases)
|
||||||
pub fn from_uid(uid: u32) -> anyhow::Result<Self> {
|
/// that belong to the user or any of the user's groups.
|
||||||
let libc_uid = nix::unistd::Uid::from_raw(uid);
|
pub fn create_user_group_matching_regex(user: &User) -> String {
|
||||||
let libc_user = LibcUser::from_uid(libc_uid)
|
let groups = get_unix_groups(user).unwrap_or_default();
|
||||||
.context("Failed to look up your UNIX username")?
|
|
||||||
.ok_or(anyhow::anyhow!("Failed to look up your UNIX username"))?;
|
|
||||||
|
|
||||||
let groups = get_unix_groups(&libc_user)?;
|
if groups.is_empty() {
|
||||||
|
format!("{}(_.+)?", user.name)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"({}|{})(_.+)?",
|
||||||
|
user.name,
|
||||||
|
groups
|
||||||
|
.iter()
|
||||||
|
.map(|g| g.name.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("|")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(UnixUser {
|
/// This enum is used to differentiate between database and user operations.
|
||||||
username: libc_user.name,
|
/// Their output are very similar, but there are slight differences in the words used.
|
||||||
groups: groups.iter().map(|g| g.name.clone()).collect(),
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||||
})
|
pub enum DbOrUser {
|
||||||
|
Database,
|
||||||
|
User,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DbOrUser {
|
||||||
|
pub fn lowercased(&self) -> String {
|
||||||
|
match self {
|
||||||
|
DbOrUser::Database => "database".to_string(),
|
||||||
|
DbOrUser::User => "user".to_string(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_enviroment() -> anyhow::Result<Self> {
|
pub fn capitalized(&self) -> String {
|
||||||
let libc_uid = nix::unistd::getuid();
|
match self {
|
||||||
UnixUser::from_uid(libc_uid.as_raw())
|
DbOrUser::Database => "Database".to_string(),
|
||||||
|
DbOrUser::User => "User".to_string(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum NameValidationResult {
|
||||||
|
Valid,
|
||||||
|
EmptyString,
|
||||||
|
InvalidCharacters,
|
||||||
|
TooLong,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_name(name: &str) -> NameValidationResult {
|
||||||
|
if name.is_empty() {
|
||||||
|
NameValidationResult::EmptyString
|
||||||
|
} else if name.len() > 64 {
|
||||||
|
NameValidationResult::TooLong
|
||||||
|
} else if !name
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||||
|
{
|
||||||
|
NameValidationResult::InvalidCharacters
|
||||||
|
} else {
|
||||||
|
NameValidationResult::Valid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_name_or_error(name: &str, db_or_user: DbOrUser) -> anyhow::Result<()> {
|
||||||
|
match validate_name(name) {
|
||||||
|
NameValidationResult::Valid => Ok(()),
|
||||||
|
NameValidationResult::EmptyString => {
|
||||||
|
anyhow::bail!("{} name cannot be empty.", db_or_user.capitalized())
|
||||||
|
}
|
||||||
|
NameValidationResult::TooLong => anyhow::bail!(
|
||||||
|
"{} is too long. Maximum length is 64 characters.",
|
||||||
|
db_or_user.capitalized()
|
||||||
|
),
|
||||||
|
NameValidationResult::InvalidCharacters => anyhow::bail!(
|
||||||
|
indoc! {r#"
|
||||||
|
Invalid characters in {} name: '{}'
|
||||||
|
|
||||||
|
Only A-Z, a-z, 0-9, _ (underscore) and - (dash) are permitted.
|
||||||
|
"#},
|
||||||
|
db_or_user.lowercased(),
|
||||||
|
name
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum OwnerValidationResult {
|
||||||
|
// The name is valid and matches one of the given prefixes
|
||||||
|
Match,
|
||||||
|
|
||||||
|
// The name is valid, but none of the given prefixes matched the name
|
||||||
|
NoMatch,
|
||||||
|
|
||||||
|
// The name is empty, which is invalid
|
||||||
|
StringEmpty,
|
||||||
|
|
||||||
|
// The name is in the format "_<postfix>", which is invalid
|
||||||
|
MissingPrefix,
|
||||||
|
|
||||||
|
// The name is in the format "<prefix>_", which is invalid
|
||||||
|
MissingPostfix,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core logic for validating the ownership of a database name.
|
||||||
|
/// This function checks if the given name matches any of the given prefixes.
|
||||||
|
/// These prefixes will in most cases be the user's unix username and any
|
||||||
|
/// unix groups the user is a member of.
|
||||||
|
pub fn validate_ownership_by_prefixes(name: &str, prefixes: &[String]) -> OwnerValidationResult {
|
||||||
|
if name.is_empty() {
|
||||||
|
return OwnerValidationResult::StringEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if name.starts_with('_') {
|
||||||
|
return OwnerValidationResult::MissingPrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (prefix, _) = match name.split_once('_') {
|
||||||
|
Some(pair) => pair,
|
||||||
|
None => return OwnerValidationResult::MissingPostfix,
|
||||||
|
};
|
||||||
|
|
||||||
|
if prefixes.iter().any(|g| g == prefix) {
|
||||||
|
OwnerValidationResult::Match
|
||||||
|
} else {
|
||||||
|
OwnerValidationResult::NoMatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the ownership of a database name or database user name.
|
||||||
|
/// This function takes the name of a database or user and a unix user,
|
||||||
|
/// for which it fetches the user's groups. It then checks if the name
|
||||||
|
/// is prefixed with the user's username or any of the user's groups.
|
||||||
|
pub fn validate_ownership_or_error<'a>(
|
||||||
|
name: &'a str,
|
||||||
|
user: &User,
|
||||||
|
db_or_user: DbOrUser,
|
||||||
|
) -> anyhow::Result<&'a str> {
|
||||||
|
let user_groups = get_unix_groups(user)?;
|
||||||
|
let prefixes = std::iter::once(user.name.clone())
|
||||||
|
.chain(user_groups.iter().map(|g| g.name.clone()))
|
||||||
|
.collect::<Vec<String>>();
|
||||||
|
|
||||||
|
match validate_ownership_by_prefixes(name, &prefixes) {
|
||||||
|
OwnerValidationResult::Match => Ok(name),
|
||||||
|
OwnerValidationResult::NoMatch => {
|
||||||
|
anyhow::bail!(
|
||||||
|
indoc! {r#"
|
||||||
|
Invalid {} name prefix: '{}' does not match your username or any of your groups.
|
||||||
|
Are you sure you are allowed to create {} names with this prefix?
|
||||||
|
|
||||||
|
Allowed prefixes:
|
||||||
|
- {}
|
||||||
|
{}
|
||||||
|
"#},
|
||||||
|
db_or_user.lowercased(),
|
||||||
|
name,
|
||||||
|
db_or_user.lowercased(),
|
||||||
|
user.name,
|
||||||
|
user_groups
|
||||||
|
.iter()
|
||||||
|
.filter(|g| g.name != user.name)
|
||||||
|
.map(|g| format!(" - {}", g.name))
|
||||||
|
.sorted()
|
||||||
|
.join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => anyhow::bail!(
|
||||||
|
"'{}' is not a valid {} name.",
|
||||||
|
name,
|
||||||
|
db_or_user.lowercased()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gracefully close a MySQL connection.
|
||||||
|
pub async fn close_database_connection(connection: MySqlConnection) {
|
||||||
|
if let Err(e) = connection
|
||||||
|
.close()
|
||||||
|
.await
|
||||||
|
.context("Failed to close connection properly")
|
||||||
|
{
|
||||||
|
eprintln!("{}", e);
|
||||||
|
eprintln!("Ignoring...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn quote_literal(s: &str) -> String {
|
||||||
|
format!("'{}'", s.replace('\'', r"\'"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn quote_identifier(s: &str) -> String {
|
||||||
|
format!("`{}`", s.replace('`', r"\`"))
|
||||||
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub(crate) fn yn(b: bool) -> &'static str {
|
pub(crate) fn yn(b: bool) -> &'static str {
|
||||||
if b {
|
if b {
|
||||||
|
@ -100,4 +303,94 @@ mod test {
|
||||||
assert_eq!(rev_yn("n"), Some(false));
|
assert_eq!(rev_yn("n"), Some(false));
|
||||||
assert_eq!(rev_yn("X"), None);
|
assert_eq!(rev_yn("X"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quote_literal() {
|
||||||
|
let payload = "' OR 1=1 --";
|
||||||
|
assert_eq!(quote_literal(payload), r#"'\' OR 1=1 --'"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quote_identifier() {
|
||||||
|
let payload = "` OR 1=1 --";
|
||||||
|
assert_eq!(quote_identifier(payload), r#"`\` OR 1=1 --`"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_name() {
|
||||||
|
assert_eq!(validate_name(""), NameValidationResult::EmptyString);
|
||||||
|
assert_eq!(
|
||||||
|
validate_name("abcdefghijklmnopqrstuvwxyz"),
|
||||||
|
NameValidationResult::Valid
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
validate_name("ABCDEFGHIJKLMNOPQRSTUVWXYZ"),
|
||||||
|
NameValidationResult::Valid
|
||||||
|
);
|
||||||
|
assert_eq!(validate_name("0123456789_-"), NameValidationResult::Valid);
|
||||||
|
|
||||||
|
for c in "\n\t\r !@#$%^&*()+=[]{}|;:,.<>?/".chars() {
|
||||||
|
assert_eq!(
|
||||||
|
validate_name(&c.to_string()),
|
||||||
|
NameValidationResult::InvalidCharacters
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(validate_name(&"a".repeat(64)), NameValidationResult::Valid);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
validate_name(&"a".repeat(65)),
|
||||||
|
NameValidationResult::TooLong
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_owner_by_prefixes() {
|
||||||
|
let prefixes = vec!["user".to_string(), "group".to_string()];
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
validate_ownership_by_prefixes("", &prefixes),
|
||||||
|
OwnerValidationResult::StringEmpty
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
validate_ownership_by_prefixes("user", &prefixes),
|
||||||
|
OwnerValidationResult::MissingPostfix
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
validate_ownership_by_prefixes("something", &prefixes),
|
||||||
|
OwnerValidationResult::MissingPostfix
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
validate_ownership_by_prefixes("user-testdb", &prefixes),
|
||||||
|
OwnerValidationResult::MissingPostfix
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
validate_ownership_by_prefixes("_testdb", &prefixes),
|
||||||
|
OwnerValidationResult::MissingPrefix
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
validate_ownership_by_prefixes("user_testdb", &prefixes),
|
||||||
|
OwnerValidationResult::Match
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
validate_ownership_by_prefixes("group_testdb", &prefixes),
|
||||||
|
OwnerValidationResult::Match
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
validate_ownership_by_prefixes("group_test_db", &prefixes),
|
||||||
|
OwnerValidationResult::Match
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
validate_ownership_by_prefixes("group_test-db", &prefixes),
|
||||||
|
OwnerValidationResult::Match
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
validate_ownership_by_prefixes("nonexistent_testdb", &prefixes),
|
||||||
|
OwnerValidationResult::NoMatch
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,121 @@
|
||||||
|
use std::{fs, path::PathBuf, time::Duration};
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context};
|
||||||
|
use clap::Parser;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{mysql::MySqlConnectOptions, ConnectOptions, MySqlConnection};
|
||||||
|
|
||||||
|
// NOTE: this might look empty now, and the extra wrapping for the mysql
|
||||||
|
// config seems unnecessary, but it will be useful later when we
|
||||||
|
// add more configuration options.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub mysql: MysqlConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename = "mysql")]
|
||||||
|
pub struct MysqlConfig {
|
||||||
|
pub host: String,
|
||||||
|
pub port: Option<u16>,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub timeout: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PORT: u16 = 3306;
|
||||||
|
const DEFAULT_TIMEOUT: u64 = 2;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
pub struct GlobalConfigArgs {
|
||||||
|
/// Path to the configuration file.
|
||||||
|
#[arg(
|
||||||
|
short,
|
||||||
|
long,
|
||||||
|
value_name = "PATH",
|
||||||
|
global = true,
|
||||||
|
hide_short_help = true,
|
||||||
|
default_value = "/etc/mysqladm/config.toml"
|
||||||
|
)]
|
||||||
|
config_file: String,
|
||||||
|
|
||||||
|
/// Hostname of the MySQL server.
|
||||||
|
#[arg(long, value_name = "HOST", global = true, hide_short_help = true)]
|
||||||
|
mysql_host: Option<String>,
|
||||||
|
|
||||||
|
/// Port of the MySQL server.
|
||||||
|
#[arg(long, value_name = "PORT", global = true, hide_short_help = true)]
|
||||||
|
mysql_port: Option<u16>,
|
||||||
|
|
||||||
|
/// Username to use for the MySQL connection.
|
||||||
|
#[arg(long, value_name = "USER", global = true, hide_short_help = true)]
|
||||||
|
mysql_user: Option<String>,
|
||||||
|
|
||||||
|
/// Path to a file containing the MySQL password.
|
||||||
|
#[arg(long, value_name = "PATH", global = true, hide_short_help = true)]
|
||||||
|
mysql_password_file: Option<String>,
|
||||||
|
|
||||||
|
/// Seconds to wait for the MySQL connection to be established.
|
||||||
|
#[arg(long, value_name = "SECONDS", global = true, hide_short_help = true)]
|
||||||
|
mysql_connect_timeout: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Use the arguments and whichever configuration file which might or might not
|
||||||
|
/// be found and default values to determine the configuration for the program.
|
||||||
|
pub fn get_config(args: GlobalConfigArgs) -> anyhow::Result<Config> {
|
||||||
|
let config_path = PathBuf::from(args.config_file);
|
||||||
|
|
||||||
|
let config: Config = fs::read_to_string(&config_path)
|
||||||
|
.context(format!(
|
||||||
|
"Failed to read config file from {:?}",
|
||||||
|
&config_path
|
||||||
|
))
|
||||||
|
.and_then(|c| toml::from_str(&c).context("Failed to parse config file"))
|
||||||
|
.context(format!(
|
||||||
|
"Failed to parse config file from {:?}",
|
||||||
|
&config_path
|
||||||
|
))?;
|
||||||
|
|
||||||
|
let mysql = &config.mysql;
|
||||||
|
|
||||||
|
let password = if let Some(path) = args.mysql_password_file {
|
||||||
|
fs::read_to_string(path)
|
||||||
|
.context("Failed to read MySQL password file")
|
||||||
|
.map(|s| s.trim().to_owned())?
|
||||||
|
} else {
|
||||||
|
mysql.password.to_owned()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mysql_config = MysqlConfig {
|
||||||
|
host: args.mysql_host.unwrap_or(mysql.host.to_owned()),
|
||||||
|
port: args.mysql_port.or(mysql.port),
|
||||||
|
username: args.mysql_user.unwrap_or(mysql.username.to_owned()),
|
||||||
|
password,
|
||||||
|
timeout: args.mysql_connect_timeout.or(mysql.timeout),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Config {
|
||||||
|
mysql: mysql_config,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Use the provided configuration to establish a connection to a MySQL server.
|
||||||
|
pub async fn create_mysql_connection_from_config(
|
||||||
|
config: MysqlConfig,
|
||||||
|
) -> anyhow::Result<MySqlConnection> {
|
||||||
|
match tokio::time::timeout(
|
||||||
|
Duration::from_secs(config.timeout.unwrap_or(DEFAULT_TIMEOUT)),
|
||||||
|
MySqlConnectOptions::new()
|
||||||
|
.host(&config.host)
|
||||||
|
.username(&config.username)
|
||||||
|
.password(&config.password)
|
||||||
|
.port(config.port.unwrap_or(DEFAULT_PORT))
|
||||||
|
.database("mysql")
|
||||||
|
.connect(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(connection) => connection.context("Failed to connect to MySQL"),
|
||||||
|
Err(_) => Err(anyhow!("Timed out after 2 seconds")).context("Failed to connect to MySQL"),
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,120 @@
|
||||||
|
use anyhow::Context;
|
||||||
|
use indoc::formatdoc;
|
||||||
|
use itertools::Itertools;
|
||||||
|
use nix::unistd::User;
|
||||||
|
use sqlx::{prelude::*, MySqlConnection};
|
||||||
|
|
||||||
|
use crate::core::{
|
||||||
|
common::{
|
||||||
|
create_user_group_matching_regex, get_current_unix_user, quote_identifier,
|
||||||
|
validate_name_or_error, validate_ownership_or_error, DbOrUser,
|
||||||
|
},
|
||||||
|
database_privilege_operations::DATABASE_PRIVILEGE_FIELDS,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn create_database(name: &str, connection: &mut MySqlConnection) -> anyhow::Result<()> {
|
||||||
|
let user = get_current_unix_user()?;
|
||||||
|
validate_database_name(name, &user)?;
|
||||||
|
|
||||||
|
// NOTE: see the note about SQL injections in `validate_owner_of_database_name`
|
||||||
|
sqlx::query(&format!("CREATE DATABASE {}", quote_identifier(name)))
|
||||||
|
.execute(connection)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
if e.to_string().contains("database exists") {
|
||||||
|
anyhow::anyhow!("Database '{}' already exists", name)
|
||||||
|
} else {
|
||||||
|
e.into()
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn drop_database(name: &str, connection: &mut MySqlConnection) -> anyhow::Result<()> {
|
||||||
|
let user = get_current_unix_user()?;
|
||||||
|
validate_database_name(name, &user)?;
|
||||||
|
|
||||||
|
// NOTE: see the note about SQL injections in `validate_owner_of_database_name`
|
||||||
|
sqlx::query(&format!("DROP DATABASE {}", quote_identifier(name)))
|
||||||
|
.execute(connection)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
if e.to_string().contains("doesn't exist") {
|
||||||
|
anyhow::anyhow!("Database '{}' does not exist", name)
|
||||||
|
} else {
|
||||||
|
e.into()
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_database_list(connection: &mut MySqlConnection) -> anyhow::Result<Vec<String>> {
|
||||||
|
let unix_user = get_current_unix_user()?;
|
||||||
|
|
||||||
|
let databases: Vec<String> = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT `SCHEMA_NAME` AS `database`
|
||||||
|
FROM `information_schema`.`SCHEMATA`
|
||||||
|
WHERE `SCHEMA_NAME` NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')
|
||||||
|
AND `SCHEMA_NAME` REGEXP ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(create_user_group_matching_regex(&unix_user))
|
||||||
|
.fetch_all(connection)
|
||||||
|
.await
|
||||||
|
.and_then(|row| {
|
||||||
|
row.into_iter()
|
||||||
|
.map(|row| row.try_get::<String, _>("database"))
|
||||||
|
.collect::<Result<_, _>>()
|
||||||
|
})
|
||||||
|
.context(format!(
|
||||||
|
"Failed to get databases for user '{}'",
|
||||||
|
unix_user.name
|
||||||
|
))?;
|
||||||
|
|
||||||
|
Ok(databases)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_databases_where_user_has_privileges(
|
||||||
|
username: &str,
|
||||||
|
connection: &mut MySqlConnection,
|
||||||
|
) -> anyhow::Result<Vec<String>> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
formatdoc!(
|
||||||
|
r#"
|
||||||
|
SELECT `db` AS `database`
|
||||||
|
FROM `db`
|
||||||
|
WHERE `user` = ?
|
||||||
|
AND ({})
|
||||||
|
"#,
|
||||||
|
DATABASE_PRIVILEGE_FIELDS
|
||||||
|
.iter()
|
||||||
|
.map(|field| format!("`{}` = 'Y'", field))
|
||||||
|
.join(" OR "),
|
||||||
|
)
|
||||||
|
.as_str(),
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_all(connection)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|databases| databases.try_get::<String, _>("database").unwrap())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NOTE: It is very critical that this function validates the database name
|
||||||
|
/// properly. MySQL does not seem to allow for prepared statements, binding
|
||||||
|
/// the database name as a parameter to the query. This means that we have
|
||||||
|
/// to validate the database name ourselves to prevent SQL injection.
|
||||||
|
pub fn validate_database_name(name: &str, user: &User) -> anyhow::Result<()> {
|
||||||
|
validate_name_or_error(name, DbOrUser::Database)
|
||||||
|
.context(format!("Invalid database name: '{}'", name))?;
|
||||||
|
validate_ownership_or_error(name, user, DbOrUser::Database)
|
||||||
|
.context(format!("Invalid database name: '{}'", name))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
|
@ -1,21 +1,57 @@
|
||||||
|
//! Database privilege operations
|
||||||
|
//!
|
||||||
|
//! This module contains functions for querying, modifying,
|
||||||
|
//! displaying and comparing database privileges.
|
||||||
|
//!
|
||||||
|
//! A lot of the complexity comes from two core components:
|
||||||
|
//!
|
||||||
|
//! - The privilege editor that needs to be able to print
|
||||||
|
//! an editable table of privileges and reparse the content
|
||||||
|
//! after the user has made manual changes.
|
||||||
|
//!
|
||||||
|
//! - The comparison functionality that tells the user what
|
||||||
|
//! changes will be made when applying a set of changes
|
||||||
|
//! to the list of database privileges.
|
||||||
|
|
||||||
|
use std::collections::{BTreeSet, HashMap};
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use anyhow::{anyhow, Context};
|
||||||
|
use indoc::indoc;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use prettytable::Table;
|
use prettytable::Table;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{
|
use sqlx::{mysql::MySqlRow, prelude::*, MySqlConnection};
|
||||||
cmp::max,
|
|
||||||
collections::{BTreeSet, HashMap},
|
use crate::core::{
|
||||||
|
common::{
|
||||||
|
create_user_group_matching_regex, get_current_unix_user, quote_identifier, rev_yn, yn,
|
||||||
|
},
|
||||||
|
database_operations::validate_database_name,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::common::{rev_yn, yn};
|
/// This is the list of fields that are used to fetch the db + user + privileges
|
||||||
use crate::server::sql::database_privilege_operations::{
|
/// from the `db` table in the database. If you need to add or remove privilege
|
||||||
DatabasePrivilegeRow, DATABASE_PRIVILEGE_FIELDS,
|
/// 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 {
|
pub fn db_priv_field_human_readable_name(name: &str) -> String {
|
||||||
match name {
|
match name {
|
||||||
"Db" => "Database".to_owned(),
|
"db" => "Database".to_owned(),
|
||||||
"User" => "User".to_owned(),
|
"user" => "User".to_owned(),
|
||||||
"select_priv" => "Select".to_owned(),
|
"select_priv" => "Select".to_owned(),
|
||||||
"insert_priv" => "Insert".to_owned(),
|
"insert_priv" => "Insert".to_owned(),
|
||||||
"update_priv" => "Update".to_owned(),
|
"update_priv" => "Update".to_owned(),
|
||||||
|
@ -31,24 +67,162 @@ pub fn db_priv_field_human_readable_name(name: &str) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn diff(row1: &DatabasePrivilegeRow, row2: &DatabasePrivilegeRow) -> DatabasePrivilegeRowDiff {
|
/// This struct represents the set of privileges for a single user on a single database.
|
||||||
debug_assert!(row1.db == row2.db && row1.user == row2.user);
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
|
||||||
|
pub struct DatabasePrivilegeRow {
|
||||||
|
pub db: String,
|
||||||
|
pub user: String,
|
||||||
|
pub select_priv: bool,
|
||||||
|
pub insert_priv: bool,
|
||||||
|
pub update_priv: bool,
|
||||||
|
pub delete_priv: bool,
|
||||||
|
pub create_priv: bool,
|
||||||
|
pub drop_priv: bool,
|
||||||
|
pub alter_priv: bool,
|
||||||
|
pub index_priv: bool,
|
||||||
|
pub create_tmp_table_priv: bool,
|
||||||
|
pub lock_tables_priv: bool,
|
||||||
|
pub references_priv: bool,
|
||||||
|
}
|
||||||
|
|
||||||
DatabasePrivilegeRowDiff {
|
impl DatabasePrivilegeRow {
|
||||||
db: row1.db.clone(),
|
pub fn empty(db: &str, user: &str) -> Self {
|
||||||
user: row1.user.clone(),
|
Self {
|
||||||
diff: DATABASE_PRIVILEGE_FIELDS
|
db: db.to_owned(),
|
||||||
.into_iter()
|
user: user.to_owned(),
|
||||||
.skip(2)
|
select_priv: false,
|
||||||
.filter_map(|field| {
|
insert_priv: false,
|
||||||
DatabasePrivilegeChange::new(
|
update_priv: false,
|
||||||
row1.get_privilege_by_name(field),
|
delete_priv: false,
|
||||||
row2.get_privilege_by_name(field),
|
create_priv: false,
|
||||||
field,
|
drop_priv: false,
|
||||||
)
|
alter_priv: false,
|
||||||
})
|
index_priv: false,
|
||||||
.collect(),
|
create_tmp_table_priv: false,
|
||||||
|
lock_tables_priv: false,
|
||||||
|
references_priv: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_privilege_by_name(&self, name: &str) -> bool {
|
||||||
|
match name {
|
||||||
|
"select_priv" => self.select_priv,
|
||||||
|
"insert_priv" => self.insert_priv,
|
||||||
|
"update_priv" => self.update_priv,
|
||||||
|
"delete_priv" => self.delete_priv,
|
||||||
|
"create_priv" => self.create_priv,
|
||||||
|
"drop_priv" => self.drop_priv,
|
||||||
|
"alter_priv" => self.alter_priv,
|
||||||
|
"index_priv" => self.index_priv,
|
||||||
|
"create_tmp_table_priv" => self.create_tmp_table_priv,
|
||||||
|
"lock_tables_priv" => self.lock_tables_priv,
|
||||||
|
"references_priv" => self.references_priv,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn diff(&self, other: &DatabasePrivilegeRow) -> DatabasePrivilegeRowDiff {
|
||||||
|
debug_assert!(self.db == other.db && self.user == other.user);
|
||||||
|
|
||||||
|
DatabasePrivilegeRowDiff {
|
||||||
|
db: self.db.clone(),
|
||||||
|
user: self.user.clone(),
|
||||||
|
diff: DATABASE_PRIVILEGE_FIELDS
|
||||||
|
.into_iter()
|
||||||
|
.skip(2)
|
||||||
|
.filter_map(|field| {
|
||||||
|
DatabasePrivilegeChange::new(
|
||||||
|
self.get_privilege_by_name(field),
|
||||||
|
other.get_privilege_by_name(field),
|
||||||
|
field,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn get_mysql_row_priv_field(row: &MySqlRow, position: usize) -> Result<bool, sqlx::Error> {
|
||||||
|
let field = DATABASE_PRIVILEGE_FIELDS[position];
|
||||||
|
let value = row.try_get(position)?;
|
||||||
|
match rev_yn(value) {
|
||||||
|
Some(val) => Ok(val),
|
||||||
|
_ => {
|
||||||
|
log::warn!(r#"Invalid value for privilege "{}": '{}'"#, field, value);
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRow<'_, MySqlRow> for DatabasePrivilegeRow {
|
||||||
|
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
|
||||||
|
Ok(Self {
|
||||||
|
db: row.try_get("db")?,
|
||||||
|
user: row.try_get("user")?,
|
||||||
|
select_priv: get_mysql_row_priv_field(row, 2)?,
|
||||||
|
insert_priv: get_mysql_row_priv_field(row, 3)?,
|
||||||
|
update_priv: get_mysql_row_priv_field(row, 4)?,
|
||||||
|
delete_priv: get_mysql_row_priv_field(row, 5)?,
|
||||||
|
create_priv: get_mysql_row_priv_field(row, 6)?,
|
||||||
|
drop_priv: get_mysql_row_priv_field(row, 7)?,
|
||||||
|
alter_priv: get_mysql_row_priv_field(row, 8)?,
|
||||||
|
index_priv: get_mysql_row_priv_field(row, 9)?,
|
||||||
|
create_tmp_table_priv: get_mysql_row_priv_field(row, 10)?,
|
||||||
|
lock_tables_priv: get_mysql_row_priv_field(row, 11)?,
|
||||||
|
references_priv: get_mysql_row_priv_field(row, 12)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all users + privileges for a single database.
|
||||||
|
pub async fn get_database_privileges(
|
||||||
|
database_name: &str,
|
||||||
|
connection: &mut MySqlConnection,
|
||||||
|
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
|
||||||
|
let unix_user = get_current_unix_user()?;
|
||||||
|
validate_database_name(database_name, &unix_user)?;
|
||||||
|
|
||||||
|
let result = sqlx::query_as::<_, DatabasePrivilegeRow>(&format!(
|
||||||
|
"SELECT {} FROM `db` WHERE `db` = ?",
|
||||||
|
DATABASE_PRIVILEGE_FIELDS
|
||||||
|
.iter()
|
||||||
|
.map(|field| quote_identifier(field))
|
||||||
|
.join(","),
|
||||||
|
))
|
||||||
|
.bind(database_name)
|
||||||
|
.fetch_all(connection)
|
||||||
|
.await
|
||||||
|
.context("Failed to show database")?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all database + user + privileges pairs that are owned by the current user.
|
||||||
|
pub async fn get_all_database_privileges(
|
||||||
|
connection: &mut MySqlConnection,
|
||||||
|
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
|
||||||
|
let unix_user = get_current_unix_user()?;
|
||||||
|
|
||||||
|
let result = sqlx::query_as::<_, DatabasePrivilegeRow>(&format!(
|
||||||
|
indoc! {r#"
|
||||||
|
SELECT {} FROM `db` WHERE `db` IN
|
||||||
|
(SELECT DISTINCT `SCHEMA_NAME` AS `database`
|
||||||
|
FROM `information_schema`.`SCHEMATA`
|
||||||
|
WHERE `SCHEMA_NAME` NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')
|
||||||
|
AND `SCHEMA_NAME` REGEXP ?)
|
||||||
|
"#},
|
||||||
|
DATABASE_PRIVILEGE_FIELDS
|
||||||
|
.iter()
|
||||||
|
.map(|field| format!("`{field}`"))
|
||||||
|
.join(","),
|
||||||
|
))
|
||||||
|
.bind(create_user_group_matching_regex(&unix_user))
|
||||||
|
.fetch_all(connection)
|
||||||
|
.await
|
||||||
|
.context("Failed to show databases")?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*************************/
|
/*************************/
|
||||||
|
@ -128,8 +302,8 @@ pub fn format_privileges_line_for_editor(
|
||||||
DATABASE_PRIVILEGE_FIELDS
|
DATABASE_PRIVILEGE_FIELDS
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|field| match field {
|
.map(|field| match field {
|
||||||
"Db" => format!("{:width$}", privs.db, width = database_name_len),
|
"db" => format!("{:width$}", privs.db, width = database_name_len),
|
||||||
"User" => format!("{:width$}", privs.user, width = username_len),
|
"user" => format!("{:width$}", privs.user, width = username_len),
|
||||||
privilege => format!(
|
privilege => format!(
|
||||||
"{:width$}",
|
"{:width$}",
|
||||||
yn(privs.get_privilege_by_name(privilege)),
|
yn(privs.get_privilege_by_name(privilege)),
|
||||||
|
@ -157,35 +331,26 @@ const EDITOR_COMMENT: &str = r#"
|
||||||
pub fn generate_editor_content_from_privilege_data(
|
pub fn generate_editor_content_from_privilege_data(
|
||||||
privilege_data: &[DatabasePrivilegeRow],
|
privilege_data: &[DatabasePrivilegeRow],
|
||||||
unix_user: &str,
|
unix_user: &str,
|
||||||
database_name: Option<&str>,
|
|
||||||
) -> String {
|
) -> String {
|
||||||
let example_user = format!("{}_user", unix_user);
|
let example_user = format!("{}_user", unix_user);
|
||||||
let example_db = database_name
|
let example_db = format!("{}_db", unix_user);
|
||||||
.unwrap_or(&format!("{}_db", unix_user))
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// NOTE: `.max()`` fails when the iterator is empty.
|
// NOTE: `.max()`` fails when the iterator is empty.
|
||||||
// In this case, we know that the only fields in the
|
// In this case, we know that the only fields in the
|
||||||
// editor will be the example user and example db name.
|
// editor will be the example user and example db name.
|
||||||
// Hence, it's put as the fallback value, despite not really
|
// Hence, it's put as the fallback value, despite not really
|
||||||
// being a "fallback" in the normal sense.
|
// being a "fallback" in the normal sense.
|
||||||
let longest_username = max(
|
let longest_username = privilege_data
|
||||||
privilege_data
|
.iter()
|
||||||
.iter()
|
.map(|p| p.user.len())
|
||||||
.map(|p| p.user.len())
|
.max()
|
||||||
.max()
|
.unwrap_or(example_user.len());
|
||||||
.unwrap_or(example_user.len()),
|
|
||||||
"User".len(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let longest_database_name = max(
|
let longest_database_name = privilege_data
|
||||||
privilege_data
|
.iter()
|
||||||
.iter()
|
.map(|p| p.db.len())
|
||||||
.map(|p| p.db.len())
|
.max()
|
||||||
.max()
|
.unwrap_or(example_db.len());
|
||||||
.unwrap_or(example_db.len()),
|
|
||||||
"Database".len(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut header: Vec<_> = DATABASE_PRIVILEGE_FIELDS
|
let mut header: Vec<_> = DATABASE_PRIVILEGE_FIELDS
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
@ -413,7 +578,7 @@ pub fn parse_privilege_data_from_editor_content(
|
||||||
/// instances of privilege sets for a single user on a single database.
|
/// instances of privilege sets for a single user on a single database.
|
||||||
///
|
///
|
||||||
/// The `User` and `Database` are the same for both instances.
|
/// The `User` and `Database` are the same for both instances.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, PartialOrd, Ord)]
|
||||||
pub struct DatabasePrivilegeRowDiff {
|
pub struct DatabasePrivilegeRowDiff {
|
||||||
pub db: String,
|
pub db: String,
|
||||||
pub user: String,
|
pub user: String,
|
||||||
|
@ -421,7 +586,7 @@ pub struct DatabasePrivilegeRowDiff {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This enum represents a change for a single privilege.
|
/// This enum represents a change for a single privilege.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, PartialOrd, Ord)]
|
||||||
pub enum DatabasePrivilegeChange {
|
pub enum DatabasePrivilegeChange {
|
||||||
YesToNo(String),
|
YesToNo(String),
|
||||||
NoToYes(String),
|
NoToYes(String),
|
||||||
|
@ -438,31 +603,13 @@ impl DatabasePrivilegeChange {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This enum encapsulates whether a [`DatabasePrivilegeRow`] was intrduced, modified or deleted.
|
/// This enum encapsulates whether a [`DatabasePrivilegeRow`] was intrduced, modified or deleted.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, PartialOrd, Ord)]
|
||||||
pub enum DatabasePrivilegesDiff {
|
pub enum DatabasePrivilegesDiff {
|
||||||
New(DatabasePrivilegeRow),
|
New(DatabasePrivilegeRow),
|
||||||
Modified(DatabasePrivilegeRowDiff),
|
Modified(DatabasePrivilegeRowDiff),
|
||||||
Deleted(DatabasePrivilegeRow),
|
Deleted(DatabasePrivilegeRow),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DatabasePrivilegesDiff {
|
|
||||||
pub fn get_database_name(&self) -> &str {
|
|
||||||
match self {
|
|
||||||
DatabasePrivilegesDiff::New(p) => &p.db,
|
|
||||||
DatabasePrivilegesDiff::Modified(p) => &p.db,
|
|
||||||
DatabasePrivilegesDiff::Deleted(p) => &p.db,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_user_name(&self) -> &str {
|
|
||||||
match self {
|
|
||||||
DatabasePrivilegesDiff::New(p) => &p.user,
|
|
||||||
DatabasePrivilegesDiff::Modified(p) => &p.user,
|
|
||||||
DatabasePrivilegesDiff::Deleted(p) => &p.user,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This function calculates the differences between two sets of database privileges.
|
/// This function calculates the differences between two sets of database privileges.
|
||||||
/// It returns a set of [`DatabasePrivilegesDiff`] that can be used to display or
|
/// It returns a set of [`DatabasePrivilegesDiff`] that can be used to display or
|
||||||
/// apply a set of privilege modifications to the database.
|
/// apply a set of privilege modifications to the database.
|
||||||
|
@ -486,7 +633,7 @@ pub fn diff_privileges(
|
||||||
|
|
||||||
for p in to {
|
for p in to {
|
||||||
if let Some(old_p) = from_lookup_table.get(&(p.db.clone(), p.user.clone())) {
|
if let Some(old_p) = from_lookup_table.get(&(p.db.clone(), p.user.clone())) {
|
||||||
let diff = diff(old_p, p);
|
let diff = old_p.diff(p);
|
||||||
if !diff.diff.is_empty() {
|
if !diff.diff.is_empty() {
|
||||||
result.insert(DatabasePrivilegesDiff::Modified(diff));
|
result.insert(DatabasePrivilegesDiff::Modified(diff));
|
||||||
}
|
}
|
||||||
|
@ -504,6 +651,72 @@ pub fn diff_privileges(
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Uses the result of [`diff_privileges`] to modify privileges in the database.
|
||||||
|
pub async fn apply_privilege_diffs(
|
||||||
|
diffs: BTreeSet<DatabasePrivilegesDiff>,
|
||||||
|
connection: &mut MySqlConnection,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
for diff in diffs {
|
||||||
|
match diff {
|
||||||
|
DatabasePrivilegesDiff::New(p) => {
|
||||||
|
let tables = DATABASE_PRIVILEGE_FIELDS
|
||||||
|
.iter()
|
||||||
|
.map(|field| format!("`{field}`"))
|
||||||
|
.join(",");
|
||||||
|
|
||||||
|
let question_marks = std::iter::repeat("?")
|
||||||
|
.take(DATABASE_PRIVILEGE_FIELDS.len())
|
||||||
|
.join(",");
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
format!("INSERT INTO `db` ({}) VALUES ({})", tables, question_marks).as_str(),
|
||||||
|
)
|
||||||
|
.bind(p.db)
|
||||||
|
.bind(p.user)
|
||||||
|
.bind(yn(p.select_priv))
|
||||||
|
.bind(yn(p.insert_priv))
|
||||||
|
.bind(yn(p.update_priv))
|
||||||
|
.bind(yn(p.delete_priv))
|
||||||
|
.bind(yn(p.create_priv))
|
||||||
|
.bind(yn(p.drop_priv))
|
||||||
|
.bind(yn(p.alter_priv))
|
||||||
|
.bind(yn(p.index_priv))
|
||||||
|
.bind(yn(p.create_tmp_table_priv))
|
||||||
|
.bind(yn(p.lock_tables_priv))
|
||||||
|
.bind(yn(p.references_priv))
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
DatabasePrivilegesDiff::Modified(p) => {
|
||||||
|
let tables = p
|
||||||
|
.diff
|
||||||
|
.iter()
|
||||||
|
.map(|diff| match diff {
|
||||||
|
DatabasePrivilegeChange::YesToNo(name) => format!("`{}` = 'N'", name),
|
||||||
|
DatabasePrivilegeChange::NoToYes(name) => format!("`{}` = 'Y'", name),
|
||||||
|
})
|
||||||
|
.join(",");
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
format!("UPDATE `db` SET {} WHERE `db` = ? AND `user` = ?", tables).as_str(),
|
||||||
|
)
|
||||||
|
.bind(p.db)
|
||||||
|
.bind(p.user)
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
DatabasePrivilegesDiff::Deleted(p) => {
|
||||||
|
sqlx::query("DELETE FROM `db` WHERE `db` = ? AND `user` = ?")
|
||||||
|
.bind(p.db)
|
||||||
|
.bind(p.user)
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn display_privilege_cell(diff: &DatabasePrivilegeRowDiff) -> String {
|
fn display_privilege_cell(diff: &DatabasePrivilegeRowDiff) -> String {
|
||||||
diff.diff
|
diff.diff
|
||||||
.iter()
|
.iter()
|
||||||
|
@ -518,20 +731,6 @@ fn display_privilege_cell(diff: &DatabasePrivilegeRowDiff) -> String {
|
||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn display_new_privileges_list(row: &DatabasePrivilegeRow) -> String {
|
|
||||||
DATABASE_PRIVILEGE_FIELDS
|
|
||||||
.into_iter()
|
|
||||||
.skip(2)
|
|
||||||
.map(|field| {
|
|
||||||
if row.get_privilege_by_name(field) {
|
|
||||||
format!("{}: Y", db_priv_field_human_readable_name(field))
|
|
||||||
} else {
|
|
||||||
format!("{}: N", db_priv_field_human_readable_name(field))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Displays the difference between two sets of database privileges.
|
/// Displays the difference between two sets of database privileges.
|
||||||
pub fn display_privilege_diffs(diffs: &BTreeSet<DatabasePrivilegesDiff>) -> String {
|
pub fn display_privilege_diffs(diffs: &BTreeSet<DatabasePrivilegesDiff>) -> String {
|
||||||
let mut table = Table::new();
|
let mut table = Table::new();
|
||||||
|
@ -542,14 +741,24 @@ pub fn display_privilege_diffs(diffs: &BTreeSet<DatabasePrivilegesDiff>) -> Stri
|
||||||
table.add_row(row![
|
table.add_row(row![
|
||||||
p.db,
|
p.db,
|
||||||
p.user,
|
p.user,
|
||||||
"(Previously unprivileged)\n".to_string() + &display_new_privileges_list(p)
|
"(New user)\n".to_string()
|
||||||
|
+ &display_privilege_cell(
|
||||||
|
&DatabasePrivilegeRow::empty(&p.db, &p.user).diff(p)
|
||||||
|
)
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
DatabasePrivilegesDiff::Modified(p) => {
|
DatabasePrivilegesDiff::Modified(p) => {
|
||||||
table.add_row(row![p.db, p.user, display_privilege_cell(p),]);
|
table.add_row(row![p.db, p.user, display_privilege_cell(p),]);
|
||||||
}
|
}
|
||||||
DatabasePrivilegesDiff::Deleted(p) => {
|
DatabasePrivilegesDiff::Deleted(p) => {
|
||||||
table.add_row(row![p.db, p.user, "Removed".to_string()]);
|
table.add_row(row![
|
||||||
|
p.db,
|
||||||
|
p.user,
|
||||||
|
"(All privileges removed)\n".to_string()
|
||||||
|
+ &display_privilege_cell(
|
||||||
|
&p.diff(&DatabasePrivilegeRow::empty(&p.db, &p.user))
|
||||||
|
)
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -667,7 +876,7 @@ mod tests {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let content = generate_editor_content_from_privilege_data(&permissions, "user", None);
|
let content = generate_editor_content_from_privilege_data(&permissions, "user");
|
||||||
|
|
||||||
let parsed_permissions = parse_privilege_data_from_editor_content(content).unwrap();
|
let parsed_permissions = parse_privilege_data_from_editor_content(content).unwrap();
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
pub mod request_response;
|
|
||||||
pub mod server_responses;
|
|
||||||
|
|
||||||
pub use request_response::*;
|
|
||||||
pub use server_responses::*;
|
|
|
@ -1,79 +0,0 @@
|
||||||
use std::collections::BTreeSet;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tokio::net::UnixStream;
|
|
||||||
use tokio_serde::{formats::Bincode, Framed as SerdeFramed};
|
|
||||||
use tokio_util::codec::{Framed, LengthDelimitedCodec};
|
|
||||||
|
|
||||||
use crate::core::{database_privileges::DatabasePrivilegesDiff, protocol::*};
|
|
||||||
|
|
||||||
pub type ServerToClientMessageStream = SerdeFramed<
|
|
||||||
Framed<UnixStream, LengthDelimitedCodec>,
|
|
||||||
Request,
|
|
||||||
Response,
|
|
||||||
Bincode<Request, Response>,
|
|
||||||
>;
|
|
||||||
|
|
||||||
pub type ClientToServerMessageStream = SerdeFramed<
|
|
||||||
Framed<UnixStream, LengthDelimitedCodec>,
|
|
||||||
Response,
|
|
||||||
Request,
|
|
||||||
Bincode<Response, Request>,
|
|
||||||
>;
|
|
||||||
|
|
||||||
pub fn create_server_to_client_message_stream(socket: UnixStream) -> ServerToClientMessageStream {
|
|
||||||
let length_delimited = Framed::new(socket, LengthDelimitedCodec::new());
|
|
||||||
tokio_serde::Framed::new(length_delimited, Bincode::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_client_to_server_message_stream(socket: UnixStream) -> ClientToServerMessageStream {
|
|
||||||
let length_delimited = Framed::new(socket, LengthDelimitedCodec::new());
|
|
||||||
tokio_serde::Framed::new(length_delimited, Bincode::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[non_exhaustive]
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum Request {
|
|
||||||
CreateDatabases(Vec<String>),
|
|
||||||
DropDatabases(Vec<String>),
|
|
||||||
ListDatabases(Option<Vec<String>>),
|
|
||||||
ListPrivileges(Option<Vec<String>>),
|
|
||||||
ModifyPrivileges(BTreeSet<DatabasePrivilegesDiff>),
|
|
||||||
|
|
||||||
CreateUsers(Vec<String>),
|
|
||||||
DropUsers(Vec<String>),
|
|
||||||
PasswdUser(String, String),
|
|
||||||
ListUsers(Option<Vec<String>>),
|
|
||||||
LockUsers(Vec<String>),
|
|
||||||
UnlockUsers(Vec<String>),
|
|
||||||
|
|
||||||
// Commit,
|
|
||||||
Exit,
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: include a generic "message" that will display a message to the user?
|
|
||||||
|
|
||||||
#[non_exhaustive]
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum Response {
|
|
||||||
// Specific data for specific commands
|
|
||||||
CreateDatabases(CreateDatabasesOutput),
|
|
||||||
DropDatabases(DropDatabasesOutput),
|
|
||||||
ListDatabases(ListDatabasesOutput),
|
|
||||||
ListAllDatabases(ListAllDatabasesOutput),
|
|
||||||
ListPrivileges(GetDatabasesPrivilegeData),
|
|
||||||
ListAllPrivileges(GetAllDatabasesPrivilegeData),
|
|
||||||
ModifyPrivileges(ModifyDatabasePrivilegesOutput),
|
|
||||||
|
|
||||||
CreateUsers(CreateUsersOutput),
|
|
||||||
DropUsers(DropUsersOutput),
|
|
||||||
PasswdUser(SetPasswordOutput),
|
|
||||||
ListUsers(ListUsersOutput),
|
|
||||||
ListAllUsers(ListAllUsersOutput),
|
|
||||||
LockUsers(LockUsersOutput),
|
|
||||||
UnlockUsers(UnlockUsersOutput),
|
|
||||||
|
|
||||||
// Generic responses
|
|
||||||
Ready,
|
|
||||||
Error(String),
|
|
||||||
}
|
|
|
@ -1,635 +0,0 @@
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use indoc::indoc;
|
|
||||||
use itertools::Itertools;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
core::{common::UnixUser, database_privileges::DatabasePrivilegeRowDiff},
|
|
||||||
server::sql::{
|
|
||||||
database_operations::DatabaseRow, database_privilege_operations::DatabasePrivilegeRow,
|
|
||||||
user_operations::DatabaseUser,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// This enum is used to differentiate between database and user operations.
|
|
||||||
/// Their output are very similar, but there are slight differences in the words used.
|
|
||||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
|
||||||
pub enum DbOrUser {
|
|
||||||
Database,
|
|
||||||
User,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DbOrUser {
|
|
||||||
pub fn lowercased(&self) -> String {
|
|
||||||
match self {
|
|
||||||
DbOrUser::Database => "database".to_string(),
|
|
||||||
DbOrUser::User => "user".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn capitalized(&self) -> String {
|
|
||||||
match self {
|
|
||||||
DbOrUser::Database => "Database".to_string(),
|
|
||||||
DbOrUser::User => "User".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
|
|
||||||
pub enum NameValidationError {
|
|
||||||
EmptyString,
|
|
||||||
InvalidCharacters,
|
|
||||||
TooLong,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NameValidationError {
|
|
||||||
pub fn to_error_message(self, name: &str, db_or_user: DbOrUser) -> String {
|
|
||||||
match self {
|
|
||||||
NameValidationError::EmptyString => {
|
|
||||||
format!("{} name cannot be empty.", db_or_user.capitalized()).to_owned()
|
|
||||||
}
|
|
||||||
NameValidationError::TooLong => format!(
|
|
||||||
"{} is too long. Maximum length is 64 characters.",
|
|
||||||
db_or_user.capitalized()
|
|
||||||
)
|
|
||||||
.to_owned(),
|
|
||||||
NameValidationError::InvalidCharacters => format!(
|
|
||||||
indoc! {r#"
|
|
||||||
Invalid characters in {} name: '{}'
|
|
||||||
|
|
||||||
Only A-Z, a-z, 0-9, _ (underscore) and - (dash) are permitted.
|
|
||||||
"#},
|
|
||||||
db_or_user.lowercased(),
|
|
||||||
name
|
|
||||||
)
|
|
||||||
.to_owned(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OwnerValidationError {
|
|
||||||
pub fn to_error_message(self, name: &str, db_or_user: DbOrUser) -> String {
|
|
||||||
let user = UnixUser::from_enviroment();
|
|
||||||
|
|
||||||
match self {
|
|
||||||
OwnerValidationError::NoMatch => format!(
|
|
||||||
indoc! {r#"
|
|
||||||
Invalid {} name prefix: '{}' does not match your username or any of your groups.
|
|
||||||
Are you sure you are allowed to create {} names with this prefix?
|
|
||||||
The format should be: <prefix>_<{} name>
|
|
||||||
|
|
||||||
Allowed prefixes:
|
|
||||||
- {}
|
|
||||||
{}
|
|
||||||
"#},
|
|
||||||
db_or_user.lowercased(),
|
|
||||||
name,
|
|
||||||
db_or_user.lowercased(),
|
|
||||||
db_or_user.lowercased(),
|
|
||||||
user.as_ref()
|
|
||||||
.map(|u| u.username.clone())
|
|
||||||
.unwrap_or("???".to_string()),
|
|
||||||
user.map(|u| u.groups)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.iter()
|
|
||||||
.map(|g| format!(" - {}", g))
|
|
||||||
.sorted()
|
|
||||||
.join("\n"),
|
|
||||||
)
|
|
||||||
.to_owned(),
|
|
||||||
OwnerValidationError::StringEmpty => format!(
|
|
||||||
"'{}' is not a valid {} name.",
|
|
||||||
name,
|
|
||||||
db_or_user.lowercased()
|
|
||||||
)
|
|
||||||
.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
|
|
||||||
pub enum OwnerValidationError {
|
|
||||||
// The name is valid, but none of the given prefixes matched the name
|
|
||||||
NoMatch,
|
|
||||||
|
|
||||||
// The name is empty, which is invalid
|
|
||||||
StringEmpty,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type CreateDatabasesOutput = BTreeMap<String, Result<(), CreateDatabaseError>>;
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum CreateDatabaseError {
|
|
||||||
SanitizationError(NameValidationError),
|
|
||||||
OwnershipError(OwnerValidationError),
|
|
||||||
DatabaseAlreadyExists,
|
|
||||||
MySqlError(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn print_create_databases_output_status(output: &CreateDatabasesOutput) {
|
|
||||||
for (database_name, result) in output {
|
|
||||||
match result {
|
|
||||||
Ok(()) => {
|
|
||||||
println!("Database '{}' created successfully.", database_name);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
println!("{}", err.to_error_message(database_name));
|
|
||||||
println!("Skipping...");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CreateDatabaseError {
|
|
||||||
pub fn to_error_message(&self, database_name: &str) -> String {
|
|
||||||
match self {
|
|
||||||
CreateDatabaseError::SanitizationError(err) => {
|
|
||||||
err.to_error_message(database_name, DbOrUser::Database)
|
|
||||||
}
|
|
||||||
CreateDatabaseError::OwnershipError(err) => {
|
|
||||||
err.to_error_message(database_name, DbOrUser::Database)
|
|
||||||
}
|
|
||||||
CreateDatabaseError::DatabaseAlreadyExists => {
|
|
||||||
format!("Database {} already exists.", database_name)
|
|
||||||
}
|
|
||||||
CreateDatabaseError::MySqlError(err) => {
|
|
||||||
format!("MySQL error: {}", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type DropDatabasesOutput = BTreeMap<String, Result<(), DropDatabaseError>>;
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum DropDatabaseError {
|
|
||||||
SanitizationError(NameValidationError),
|
|
||||||
OwnershipError(OwnerValidationError),
|
|
||||||
DatabaseDoesNotExist,
|
|
||||||
MySqlError(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn print_drop_databases_output_status(output: &DropDatabasesOutput) {
|
|
||||||
for (database_name, result) in output {
|
|
||||||
match result {
|
|
||||||
Ok(()) => {
|
|
||||||
println!("Database '{}' dropped successfully.", database_name);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
println!("{}", err.to_error_message(database_name));
|
|
||||||
println!("Skipping...");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DropDatabaseError {
|
|
||||||
pub fn to_error_message(&self, database_name: &str) -> String {
|
|
||||||
match self {
|
|
||||||
DropDatabaseError::SanitizationError(err) => {
|
|
||||||
err.to_error_message(database_name, DbOrUser::Database)
|
|
||||||
}
|
|
||||||
DropDatabaseError::OwnershipError(err) => {
|
|
||||||
err.to_error_message(database_name, DbOrUser::Database)
|
|
||||||
}
|
|
||||||
DropDatabaseError::DatabaseDoesNotExist => {
|
|
||||||
format!("Database {} does not exist.", database_name)
|
|
||||||
}
|
|
||||||
DropDatabaseError::MySqlError(err) => {
|
|
||||||
format!("MySQL error: {}", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type ListDatabasesOutput = BTreeMap<String, Result<DatabaseRow, ListDatabasesError>>;
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum ListDatabasesError {
|
|
||||||
SanitizationError(NameValidationError),
|
|
||||||
OwnershipError(OwnerValidationError),
|
|
||||||
DatabaseDoesNotExist,
|
|
||||||
MySqlError(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ListDatabasesError {
|
|
||||||
pub fn to_error_message(&self, database_name: &str) -> String {
|
|
||||||
match self {
|
|
||||||
ListDatabasesError::SanitizationError(err) => {
|
|
||||||
err.to_error_message(database_name, DbOrUser::Database)
|
|
||||||
}
|
|
||||||
ListDatabasesError::OwnershipError(err) => {
|
|
||||||
err.to_error_message(database_name, DbOrUser::Database)
|
|
||||||
}
|
|
||||||
ListDatabasesError::DatabaseDoesNotExist => {
|
|
||||||
format!("Database '{}' does not exist.", database_name)
|
|
||||||
}
|
|
||||||
ListDatabasesError::MySqlError(err) => {
|
|
||||||
format!("MySQL error: {}", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type ListAllDatabasesOutput = Result<Vec<DatabaseRow>, ListAllDatabasesError>;
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum ListAllDatabasesError {
|
|
||||||
MySqlError(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ListAllDatabasesError {
|
|
||||||
pub fn to_error_message(&self) -> String {
|
|
||||||
match self {
|
|
||||||
ListAllDatabasesError::MySqlError(err) => format!("MySQL error: {}", err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: merge all rows into a single collection.
|
|
||||||
// they already contain which database they belong to.
|
|
||||||
// no need to index by database name.
|
|
||||||
|
|
||||||
pub type GetDatabasesPrivilegeData =
|
|
||||||
BTreeMap<String, Result<Vec<DatabasePrivilegeRow>, GetDatabasesPrivilegeDataError>>;
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum GetDatabasesPrivilegeDataError {
|
|
||||||
SanitizationError(NameValidationError),
|
|
||||||
OwnershipError(OwnerValidationError),
|
|
||||||
DatabaseDoesNotExist,
|
|
||||||
MySqlError(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GetDatabasesPrivilegeDataError {
|
|
||||||
pub fn to_error_message(&self, database_name: &str) -> String {
|
|
||||||
match self {
|
|
||||||
GetDatabasesPrivilegeDataError::SanitizationError(err) => {
|
|
||||||
err.to_error_message(database_name, DbOrUser::Database)
|
|
||||||
}
|
|
||||||
GetDatabasesPrivilegeDataError::OwnershipError(err) => {
|
|
||||||
err.to_error_message(database_name, DbOrUser::Database)
|
|
||||||
}
|
|
||||||
GetDatabasesPrivilegeDataError::DatabaseDoesNotExist => {
|
|
||||||
format!("Database '{}' does not exist.", database_name)
|
|
||||||
}
|
|
||||||
GetDatabasesPrivilegeDataError::MySqlError(err) => {
|
|
||||||
format!("MySQL error: {}", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type GetAllDatabasesPrivilegeData =
|
|
||||||
Result<Vec<DatabasePrivilegeRow>, GetAllDatabasesPrivilegeDataError>;
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum GetAllDatabasesPrivilegeDataError {
|
|
||||||
MySqlError(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GetAllDatabasesPrivilegeDataError {
|
|
||||||
pub fn to_error_message(&self) -> String {
|
|
||||||
match self {
|
|
||||||
GetAllDatabasesPrivilegeDataError::MySqlError(err) => format!("MySQL error: {}", err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type ModifyDatabasePrivilegesOutput =
|
|
||||||
BTreeMap<(String, String), Result<(), ModifyDatabasePrivilegesError>>;
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum ModifyDatabasePrivilegesError {
|
|
||||||
DatabaseSanitizationError(NameValidationError),
|
|
||||||
DatabaseOwnershipError(OwnerValidationError),
|
|
||||||
UserSanitizationError(NameValidationError),
|
|
||||||
UserOwnershipError(OwnerValidationError),
|
|
||||||
DatabaseDoesNotExist,
|
|
||||||
DiffDoesNotApply(DiffDoesNotApplyError),
|
|
||||||
MySqlError(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::enum_variant_names)]
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum DiffDoesNotApplyError {
|
|
||||||
RowAlreadyExists(String, String),
|
|
||||||
RowDoesNotExist(String, String),
|
|
||||||
RowPrivilegeChangeDoesNotApply(DatabasePrivilegeRowDiff, DatabasePrivilegeRow),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn print_modify_database_privileges_output_status(output: &ModifyDatabasePrivilegesOutput) {
|
|
||||||
for ((database_name, username), result) in output {
|
|
||||||
match result {
|
|
||||||
Ok(()) => {
|
|
||||||
println!(
|
|
||||||
"Privileges for user '{}' on database '{}' modified successfully.",
|
|
||||||
username, database_name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
println!("{}", err.to_error_message(database_name, username));
|
|
||||||
println!("Skipping...");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ModifyDatabasePrivilegesError {
|
|
||||||
pub fn to_error_message(&self, database_name: &str, username: &str) -> String {
|
|
||||||
match self {
|
|
||||||
ModifyDatabasePrivilegesError::DatabaseSanitizationError(err) => {
|
|
||||||
err.to_error_message(database_name, DbOrUser::Database)
|
|
||||||
}
|
|
||||||
ModifyDatabasePrivilegesError::DatabaseOwnershipError(err) => {
|
|
||||||
err.to_error_message(database_name, DbOrUser::Database)
|
|
||||||
}
|
|
||||||
ModifyDatabasePrivilegesError::UserSanitizationError(err) => {
|
|
||||||
err.to_error_message(username, DbOrUser::User)
|
|
||||||
}
|
|
||||||
ModifyDatabasePrivilegesError::UserOwnershipError(err) => {
|
|
||||||
err.to_error_message(username, DbOrUser::User)
|
|
||||||
}
|
|
||||||
ModifyDatabasePrivilegesError::DatabaseDoesNotExist => {
|
|
||||||
format!("Database '{}' does not exist.", database_name)
|
|
||||||
}
|
|
||||||
ModifyDatabasePrivilegesError::DiffDoesNotApply(diff) => {
|
|
||||||
format!(
|
|
||||||
"Could not apply privilege change:\n{}",
|
|
||||||
diff.to_error_message()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ModifyDatabasePrivilegesError::MySqlError(err) => {
|
|
||||||
format!("MySQL error: {}", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DiffDoesNotApplyError {
|
|
||||||
pub fn to_error_message(&self) -> String {
|
|
||||||
match self {
|
|
||||||
DiffDoesNotApplyError::RowAlreadyExists(database_name, username) => {
|
|
||||||
format!(
|
|
||||||
"Privileges for user '{}' on database '{}' already exist.",
|
|
||||||
username, database_name
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DiffDoesNotApplyError::RowDoesNotExist(database_name, username) => {
|
|
||||||
format!(
|
|
||||||
"Privileges for user '{}' on database '{}' do not exist.",
|
|
||||||
username, database_name
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DiffDoesNotApplyError::RowPrivilegeChangeDoesNotApply(diff, row) => {
|
|
||||||
format!(
|
|
||||||
"Could not apply privilege change {:?} to row {:?}",
|
|
||||||
diff, row
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type CreateUsersOutput = BTreeMap<String, Result<(), CreateUserError>>;
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum CreateUserError {
|
|
||||||
SanitizationError(NameValidationError),
|
|
||||||
OwnershipError(OwnerValidationError),
|
|
||||||
UserAlreadyExists,
|
|
||||||
MySqlError(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn print_create_users_output_status(output: &CreateUsersOutput) {
|
|
||||||
for (username, result) in output {
|
|
||||||
match result {
|
|
||||||
Ok(()) => {
|
|
||||||
println!("User '{}' created successfully.", username);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
println!("{}", err.to_error_message(username));
|
|
||||||
println!("Skipping...");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CreateUserError {
|
|
||||||
pub fn to_error_message(&self, username: &str) -> String {
|
|
||||||
match self {
|
|
||||||
CreateUserError::SanitizationError(err) => {
|
|
||||||
err.to_error_message(username, DbOrUser::User)
|
|
||||||
}
|
|
||||||
CreateUserError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
|
|
||||||
CreateUserError::UserAlreadyExists => {
|
|
||||||
format!("User '{}' already exists.", username)
|
|
||||||
}
|
|
||||||
CreateUserError::MySqlError(err) => {
|
|
||||||
format!("MySQL error: {}", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type DropUsersOutput = BTreeMap<String, Result<(), DropUserError>>;
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum DropUserError {
|
|
||||||
SanitizationError(NameValidationError),
|
|
||||||
OwnershipError(OwnerValidationError),
|
|
||||||
UserDoesNotExist,
|
|
||||||
MySqlError(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn print_drop_users_output_status(output: &DropUsersOutput) {
|
|
||||||
for (username, result) in output {
|
|
||||||
match result {
|
|
||||||
Ok(()) => {
|
|
||||||
println!("User '{}' dropped successfully.", username);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
println!("{}", err.to_error_message(username));
|
|
||||||
println!("Skipping...");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DropUserError {
|
|
||||||
pub fn to_error_message(&self, username: &str) -> String {
|
|
||||||
match self {
|
|
||||||
DropUserError::SanitizationError(err) => err.to_error_message(username, DbOrUser::User),
|
|
||||||
DropUserError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
|
|
||||||
DropUserError::UserDoesNotExist => {
|
|
||||||
format!("User '{}' does not exist.", username)
|
|
||||||
}
|
|
||||||
DropUserError::MySqlError(err) => {
|
|
||||||
format!("MySQL error: {}", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type SetPasswordOutput = Result<(), SetPasswordError>;
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum SetPasswordError {
|
|
||||||
SanitizationError(NameValidationError),
|
|
||||||
OwnershipError(OwnerValidationError),
|
|
||||||
UserDoesNotExist,
|
|
||||||
MySqlError(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn print_set_password_output_status(output: &SetPasswordOutput, username: &str) {
|
|
||||||
match output {
|
|
||||||
Ok(()) => {
|
|
||||||
println!("Password for user '{}' set successfully.", username);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
println!("{}", err.to_error_message(username));
|
|
||||||
println!("Skipping...");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SetPasswordError {
|
|
||||||
pub fn to_error_message(&self, username: &str) -> String {
|
|
||||||
match self {
|
|
||||||
SetPasswordError::SanitizationError(err) => {
|
|
||||||
err.to_error_message(username, DbOrUser::User)
|
|
||||||
}
|
|
||||||
SetPasswordError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
|
|
||||||
SetPasswordError::UserDoesNotExist => {
|
|
||||||
format!("User '{}' does not exist.", username)
|
|
||||||
}
|
|
||||||
SetPasswordError::MySqlError(err) => {
|
|
||||||
format!("MySQL error: {}", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type LockUsersOutput = BTreeMap<String, Result<(), LockUserError>>;
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum LockUserError {
|
|
||||||
SanitizationError(NameValidationError),
|
|
||||||
OwnershipError(OwnerValidationError),
|
|
||||||
UserDoesNotExist,
|
|
||||||
UserIsAlreadyLocked,
|
|
||||||
MySqlError(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn print_lock_users_output_status(output: &LockUsersOutput) {
|
|
||||||
for (username, result) in output {
|
|
||||||
match result {
|
|
||||||
Ok(()) => {
|
|
||||||
println!("User '{}' locked successfully.", username);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
println!("{}", err.to_error_message(username));
|
|
||||||
println!("Skipping...");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LockUserError {
|
|
||||||
pub fn to_error_message(&self, username: &str) -> String {
|
|
||||||
match self {
|
|
||||||
LockUserError::SanitizationError(err) => err.to_error_message(username, DbOrUser::User),
|
|
||||||
LockUserError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
|
|
||||||
LockUserError::UserDoesNotExist => {
|
|
||||||
format!("User '{}' does not exist.", username)
|
|
||||||
}
|
|
||||||
LockUserError::UserIsAlreadyLocked => {
|
|
||||||
format!("User '{}' is already locked.", username)
|
|
||||||
}
|
|
||||||
LockUserError::MySqlError(err) => {
|
|
||||||
format!("MySQL error: {}", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type UnlockUsersOutput = BTreeMap<String, Result<(), UnlockUserError>>;
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum UnlockUserError {
|
|
||||||
SanitizationError(NameValidationError),
|
|
||||||
OwnershipError(OwnerValidationError),
|
|
||||||
UserDoesNotExist,
|
|
||||||
UserIsAlreadyUnlocked,
|
|
||||||
MySqlError(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn print_unlock_users_output_status(output: &UnlockUsersOutput) {
|
|
||||||
for (username, result) in output {
|
|
||||||
match result {
|
|
||||||
Ok(()) => {
|
|
||||||
println!("User '{}' unlocked successfully.", username);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
println!("{}", err.to_error_message(username));
|
|
||||||
println!("Skipping...");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UnlockUserError {
|
|
||||||
pub fn to_error_message(&self, username: &str) -> String {
|
|
||||||
match self {
|
|
||||||
UnlockUserError::SanitizationError(err) => {
|
|
||||||
err.to_error_message(username, DbOrUser::User)
|
|
||||||
}
|
|
||||||
UnlockUserError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
|
|
||||||
UnlockUserError::UserDoesNotExist => {
|
|
||||||
format!("User '{}' does not exist.", username)
|
|
||||||
}
|
|
||||||
UnlockUserError::UserIsAlreadyUnlocked => {
|
|
||||||
format!("User '{}' is already unlocked.", username)
|
|
||||||
}
|
|
||||||
UnlockUserError::MySqlError(err) => {
|
|
||||||
format!("MySQL error: {}", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type ListUsersOutput = BTreeMap<String, Result<DatabaseUser, ListUsersError>>;
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum ListUsersError {
|
|
||||||
SanitizationError(NameValidationError),
|
|
||||||
OwnershipError(OwnerValidationError),
|
|
||||||
UserDoesNotExist,
|
|
||||||
MySqlError(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ListUsersError {
|
|
||||||
pub fn to_error_message(&self, username: &str) -> String {
|
|
||||||
match self {
|
|
||||||
ListUsersError::SanitizationError(err) => {
|
|
||||||
err.to_error_message(username, DbOrUser::User)
|
|
||||||
}
|
|
||||||
ListUsersError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
|
|
||||||
ListUsersError::UserDoesNotExist => {
|
|
||||||
format!("User '{}' does not exist.", username)
|
|
||||||
}
|
|
||||||
ListUsersError::MySqlError(err) => {
|
|
||||||
format!("MySQL error: {}", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type ListAllUsersOutput = Result<Vec<DatabaseUser>, ListAllUsersError>;
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum ListAllUsersError {
|
|
||||||
MySqlError(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ListAllUsersError {
|
|
||||||
pub fn to_error_message(&self) -> String {
|
|
||||||
match self {
|
|
||||||
ListAllUsersError::MySqlError(err) => format!("MySQL error: {}", err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,249 @@
|
||||||
|
use anyhow::Context;
|
||||||
|
use nix::unistd::User;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{prelude::*, MySqlConnection};
|
||||||
|
|
||||||
|
use crate::core::common::{
|
||||||
|
create_user_group_matching_regex, get_current_unix_user, quote_literal, validate_name_or_error,
|
||||||
|
validate_ownership_or_error, DbOrUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn user_exists(db_user: &str, connection: &mut MySqlConnection) -> anyhow::Result<bool> {
|
||||||
|
let unix_user = get_current_unix_user()?;
|
||||||
|
|
||||||
|
validate_user_name(db_user, &unix_user)?;
|
||||||
|
|
||||||
|
let user_exists = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM `mysql`.`user`
|
||||||
|
WHERE `User` = ?
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(db_user)
|
||||||
|
.fetch_one(connection)
|
||||||
|
.await?
|
||||||
|
.get::<bool, _>(0);
|
||||||
|
|
||||||
|
Ok(user_exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_database_user(
|
||||||
|
db_user: &str,
|
||||||
|
connection: &mut MySqlConnection,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let unix_user = get_current_unix_user()?;
|
||||||
|
|
||||||
|
validate_user_name(db_user, &unix_user)?;
|
||||||
|
|
||||||
|
if user_exists(db_user, connection).await? {
|
||||||
|
anyhow::bail!("User '{}' already exists", db_user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: see the note about SQL injections in `validate_ownership_of_user_name`
|
||||||
|
sqlx::query(format!("CREATE USER {}@'%'", quote_literal(db_user),).as_str())
|
||||||
|
.execute(connection)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_database_user(
|
||||||
|
db_user: &str,
|
||||||
|
connection: &mut MySqlConnection,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let unix_user = get_current_unix_user()?;
|
||||||
|
|
||||||
|
validate_user_name(db_user, &unix_user)?;
|
||||||
|
|
||||||
|
if !user_exists(db_user, connection).await? {
|
||||||
|
anyhow::bail!("User '{}' does not exist", db_user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: see the note about SQL injections in `validate_ownership_of_user_name`
|
||||||
|
sqlx::query(format!("DROP USER {}@'%'", quote_literal(db_user),).as_str())
|
||||||
|
.execute(connection)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_password_for_database_user(
|
||||||
|
db_user: &str,
|
||||||
|
password: &str,
|
||||||
|
connection: &mut MySqlConnection,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let unix_user = crate::core::common::get_current_unix_user()?;
|
||||||
|
validate_user_name(db_user, &unix_user)?;
|
||||||
|
|
||||||
|
if !user_exists(db_user, connection).await? {
|
||||||
|
anyhow::bail!("User '{}' does not exist", db_user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: see the note about SQL injections in `validate_ownership_of_user_name`
|
||||||
|
sqlx::query(
|
||||||
|
format!(
|
||||||
|
"ALTER USER {}@'%' IDENTIFIED BY {}",
|
||||||
|
quote_literal(db_user),
|
||||||
|
quote_literal(password).as_str()
|
||||||
|
)
|
||||||
|
.as_str(),
|
||||||
|
)
|
||||||
|
.execute(connection)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn user_is_locked(db_user: &str, connection: &mut MySqlConnection) -> anyhow::Result<bool> {
|
||||||
|
let unix_user = get_current_unix_user()?;
|
||||||
|
|
||||||
|
validate_user_name(db_user, &unix_user)?;
|
||||||
|
|
||||||
|
if !user_exists(db_user, connection).await? {
|
||||||
|
anyhow::bail!("User '{}' does not exist", db_user);
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_locked = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT JSON_EXTRACT(`mysql`.`global_priv`.`priv`, "$.account_locked") = 'true'
|
||||||
|
FROM `mysql`.`global_priv`
|
||||||
|
WHERE `User` = ?
|
||||||
|
AND `Host` = '%'
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(db_user)
|
||||||
|
.fetch_one(connection)
|
||||||
|
.await?
|
||||||
|
.get::<bool, _>(0);
|
||||||
|
|
||||||
|
Ok(is_locked)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn lock_database_user(
|
||||||
|
db_user: &str,
|
||||||
|
connection: &mut MySqlConnection,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let unix_user = get_current_unix_user()?;
|
||||||
|
|
||||||
|
validate_user_name(db_user, &unix_user)?;
|
||||||
|
|
||||||
|
if !user_exists(db_user, connection).await? {
|
||||||
|
anyhow::bail!("User '{}' does not exist", db_user);
|
||||||
|
}
|
||||||
|
|
||||||
|
if user_is_locked(db_user, connection).await? {
|
||||||
|
anyhow::bail!("User '{}' is already locked", db_user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: see the note about SQL injections in `validate_ownership_of_user_name`
|
||||||
|
sqlx::query(format!("ALTER USER {}@'%' ACCOUNT LOCK", quote_literal(db_user),).as_str())
|
||||||
|
.execute(connection)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unlock_database_user(
|
||||||
|
db_user: &str,
|
||||||
|
connection: &mut MySqlConnection,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let unix_user = get_current_unix_user()?;
|
||||||
|
|
||||||
|
validate_user_name(db_user, &unix_user)?;
|
||||||
|
|
||||||
|
if !user_exists(db_user, connection).await? {
|
||||||
|
anyhow::bail!("User '{}' does not exist", db_user);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user_is_locked(db_user, connection).await? {
|
||||||
|
anyhow::bail!("User '{}' is already unlocked", db_user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: see the note about SQL injections in `validate_ownership_of_user_name`
|
||||||
|
sqlx::query(format!("ALTER USER {}@'%' ACCOUNT UNLOCK", quote_literal(db_user),).as_str())
|
||||||
|
.execute(connection)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This struct contains information about a database user.
|
||||||
|
/// This can be extended if we need more information in the future.
|
||||||
|
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
|
||||||
|
pub struct DatabaseUser {
|
||||||
|
#[sqlx(rename = "User")]
|
||||||
|
pub user: String,
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[serde(skip)]
|
||||||
|
#[sqlx(rename = "Host")]
|
||||||
|
pub host: String,
|
||||||
|
|
||||||
|
#[sqlx(rename = "has_password")]
|
||||||
|
pub has_password: bool,
|
||||||
|
|
||||||
|
#[sqlx(rename = "is_locked")]
|
||||||
|
pub is_locked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DB_USER_SELECT_STATEMENT: &str = r#"
|
||||||
|
SELECT
|
||||||
|
`mysql`.`user`.`User`,
|
||||||
|
`mysql`.`user`.`Host`,
|
||||||
|
`mysql`.`user`.`Password` != '' OR `mysql`.`user`.`authentication_string` != '' AS `has_password`,
|
||||||
|
COALESCE(
|
||||||
|
JSON_EXTRACT(`mysql`.`global_priv`.`priv`, "$.account_locked"),
|
||||||
|
'false'
|
||||||
|
) != 'false' AS `is_locked`
|
||||||
|
FROM `mysql`.`user`
|
||||||
|
JOIN `mysql`.`global_priv` ON
|
||||||
|
`mysql`.`user`.`User` = `mysql`.`global_priv`.`User`
|
||||||
|
AND `mysql`.`user`.`Host` = `mysql`.`global_priv`.`Host`
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// This function fetches all database users that have a prefix matching the
|
||||||
|
/// unix username and group names of the given unix user.
|
||||||
|
pub async fn get_all_database_users_for_unix_user(
|
||||||
|
unix_user: &User,
|
||||||
|
connection: &mut MySqlConnection,
|
||||||
|
) -> anyhow::Result<Vec<DatabaseUser>> {
|
||||||
|
let users = sqlx::query_as::<_, DatabaseUser>(
|
||||||
|
&(DB_USER_SELECT_STATEMENT.to_string() + "WHERE `mysql`.`user`.`User` REGEXP ?"),
|
||||||
|
)
|
||||||
|
.bind(create_user_group_matching_regex(unix_user))
|
||||||
|
.fetch_all(connection)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This function fetches a database user if it exists.
|
||||||
|
pub async fn get_database_user_for_user(
|
||||||
|
username: &str,
|
||||||
|
connection: &mut MySqlConnection,
|
||||||
|
) -> anyhow::Result<Option<DatabaseUser>> {
|
||||||
|
let user = sqlx::query_as::<_, DatabaseUser>(
|
||||||
|
&(DB_USER_SELECT_STATEMENT.to_string() + "WHERE `mysql`.`user`.`User` = ?"),
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(connection)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NOTE: It is very critical that this function validates the database name
|
||||||
|
/// properly. MySQL does not seem to allow for prepared statements, binding
|
||||||
|
/// the database name as a parameter to the query. This means that we have
|
||||||
|
/// to validate the database name ourselves to prevent SQL injection.
|
||||||
|
pub fn validate_user_name(name: &str, user: &User) -> anyhow::Result<()> {
|
||||||
|
validate_name_or_error(name, DbOrUser::User)
|
||||||
|
.context(format!("Invalid username: '{}'", name))?;
|
||||||
|
validate_ownership_or_error(name, user, DbOrUser::User)
|
||||||
|
.context(format!("Invalid username: '{}'", name))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
243
src/main.rs
243
src/main.rs
|
@ -1,28 +1,14 @@
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate prettytable;
|
extern crate prettytable;
|
||||||
|
|
||||||
use clap::{CommandFactory, Parser, ValueEnum};
|
use core::common::CommandStatus;
|
||||||
use clap_complete::{generate, Shell};
|
#[cfg(feature = "mysql-admutils-compatibility")]
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use std::os::unix::net::UnixStream as StdUnixStream;
|
|
||||||
use tokio::net::UnixStream as TokioUnixStream;
|
|
||||||
|
|
||||||
use futures::StreamExt;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
core::{
|
|
||||||
bootstrap::bootstrap_server_connection_and_drop_privileges,
|
|
||||||
protocol::{create_client_to_server_message_stream, Response},
|
|
||||||
},
|
|
||||||
server::command::ServerArgs,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(feature = "mysql-admutils-compatibility")]
|
#[cfg(feature = "mysql-admutils-compatibility")]
|
||||||
use crate::cli::mysql_admutils_compatibility::{mysql_dbadm, mysql_useradm};
|
use crate::cli::mysql_admutils_compatibility::{mysql_dbadm, mysql_useradm};
|
||||||
|
|
||||||
mod server;
|
use clap::Parser;
|
||||||
|
|
||||||
mod cli;
|
mod cli;
|
||||||
mod core;
|
mod core;
|
||||||
|
@ -30,205 +16,84 @@ mod core;
|
||||||
#[cfg(feature = "tui")]
|
#[cfg(feature = "tui")]
|
||||||
mod tui;
|
mod tui;
|
||||||
|
|
||||||
/// Database administration tool for non-admin users to manage their own MySQL databases and users.
|
#[derive(Parser)]
|
||||||
///
|
|
||||||
/// This tool allows you to manage users and databases in MySQL.
|
|
||||||
///
|
|
||||||
/// You are only allowed to manage databases and users that are prefixed with
|
|
||||||
/// either your username, or a group that you are a member of.
|
|
||||||
#[derive(Parser, Debug)]
|
|
||||||
#[command(bin_name = "mysqladm", version, about, disable_help_subcommand = true)]
|
|
||||||
struct Args {
|
struct Args {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Command,
|
command: Command,
|
||||||
|
|
||||||
/// Path to the socket of the server, if it already exists.
|
#[command(flatten)]
|
||||||
#[arg(
|
config_overrides: core::config::GlobalConfigArgs,
|
||||||
short,
|
|
||||||
long,
|
|
||||||
value_name = "PATH",
|
|
||||||
global = true,
|
|
||||||
hide_short_help = true
|
|
||||||
)]
|
|
||||||
server_socket_path: Option<PathBuf>,
|
|
||||||
|
|
||||||
/// Config file to use for the server.
|
|
||||||
#[arg(
|
|
||||||
short,
|
|
||||||
long,
|
|
||||||
value_name = "PATH",
|
|
||||||
global = true,
|
|
||||||
hide_short_help = true
|
|
||||||
)]
|
|
||||||
config: Option<PathBuf>,
|
|
||||||
|
|
||||||
#[cfg(feature = "tui")]
|
#[cfg(feature = "tui")]
|
||||||
#[arg(short, long, alias = "tui", global = true)]
|
#[arg(short, long, alias = "tui", global = true)]
|
||||||
interactive: bool,
|
interactive: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
/// Database administration tool for non-admin users to manage their own MySQL databases and users.
|
||||||
|
///
|
||||||
|
/// This tool allows you to manage users and databases in MySQL.
|
||||||
|
///
|
||||||
|
/// You are only allowed to manage databases and users that are prefixed with
|
||||||
|
/// either your username, or a group that you are a member of.
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(version, about, disable_help_subcommand = true)]
|
||||||
enum Command {
|
enum Command {
|
||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
Db(cli::database_command::DatabaseCommand),
|
Db(cli::database_command::DatabaseCommand),
|
||||||
|
|
||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
User(cli::user_command::UserCommand),
|
User(cli::user_command::UserCommand),
|
||||||
|
|
||||||
#[command(hide = true)]
|
|
||||||
Server(server::command::ServerArgs),
|
|
||||||
|
|
||||||
#[command(hide = true)]
|
|
||||||
GenerateCompletions(GenerateCompletionArgs),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[tokio::main(flavor = "current_thread")]
|
||||||
struct GenerateCompletionArgs {
|
async fn main() -> anyhow::Result<()> {
|
||||||
#[arg(long, default_value = "bash")]
|
|
||||||
shell: Shell,
|
|
||||||
|
|
||||||
#[arg(long, default_value = "mysqladm")]
|
|
||||||
command: ToplevelCommands,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "mysql-admutils-compatibility")]
|
|
||||||
#[derive(ValueEnum, Debug, Clone)]
|
|
||||||
enum ToplevelCommands {
|
|
||||||
Mysqladm,
|
|
||||||
MysqlDbadm,
|
|
||||||
MysqlUseradm,
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: tag all functions that are run with elevated privileges with
|
|
||||||
// comments emphasizing the need for caution.
|
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
|
||||||
// TODO: find out if there are any security risks of running
|
|
||||||
// env_logger and clap with elevated privileges.
|
|
||||||
|
|
||||||
env_logger::init();
|
env_logger::init();
|
||||||
|
|
||||||
#[cfg(feature = "mysql-admutils-compatibility")]
|
#[cfg(feature = "mysql-admutils-compatibility")]
|
||||||
if handle_mysql_admutils_command()?.is_some() {
|
{
|
||||||
return Ok(());
|
let argv0 = std::env::args().next().and_then(|s| {
|
||||||
|
PathBuf::from(s)
|
||||||
|
.file_name()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
});
|
||||||
|
|
||||||
|
match argv0.as_deref() {
|
||||||
|
Some("mysql-dbadm") => return mysql_dbadm::main().await,
|
||||||
|
Some("mysql-useradm") => return mysql_useradm::main().await,
|
||||||
|
_ => { /* fall through */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let args: Args = Args::parse();
|
let args: Args = Args::parse();
|
||||||
|
let config = core::config::get_config(args.config_overrides)?;
|
||||||
|
let connection = core::config::create_mysql_connection_from_config(config.mysql).await?;
|
||||||
|
|
||||||
if handle_server_command(&args)?.is_some() {
|
let result = match args.command {
|
||||||
return Ok(());
|
Command::Db(command) => cli::database_command::handle_command(command, connection).await,
|
||||||
}
|
Command::User(user_args) => cli::user_command::handle_command(user_args, connection).await,
|
||||||
|
};
|
||||||
|
|
||||||
if handle_generate_completions_command(&args)?.is_some() {
|
match result {
|
||||||
return Ok(());
|
Ok(CommandStatus::SuccessfullyModified) => {
|
||||||
}
|
println!("Modifications committed successfully");
|
||||||
|
Ok(())
|
||||||
let server_connection =
|
|
||||||
bootstrap_server_connection_and_drop_privileges(args.server_socket_path, args.config)?;
|
|
||||||
|
|
||||||
tokio_run_command(args.command, server_connection)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_mysql_admutils_command() -> anyhow::Result<Option<()>> {
|
|
||||||
let argv0 = std::env::args().next().and_then(|s| {
|
|
||||||
PathBuf::from(s)
|
|
||||||
.file_name()
|
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
|
||||||
});
|
|
||||||
|
|
||||||
match argv0.as_deref() {
|
|
||||||
Some("mysql-dbadm") => mysql_dbadm::main().map(Some),
|
|
||||||
Some("mysql-useradm") => mysql_useradm::main().map(Some),
|
|
||||||
_ => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_server_command(args: &Args) -> anyhow::Result<Option<()>> {
|
|
||||||
match args.command {
|
|
||||||
Command::Server(ref command) => {
|
|
||||||
tokio_start_server(
|
|
||||||
args.server_socket_path.clone(),
|
|
||||||
args.config.clone(),
|
|
||||||
command.clone(),
|
|
||||||
)?;
|
|
||||||
Ok(Some(()))
|
|
||||||
}
|
}
|
||||||
_ => Ok(None),
|
Ok(CommandStatus::PartiallySuccessfullyModified) => {
|
||||||
}
|
println!("Some modifications committed successfully");
|
||||||
}
|
Ok(())
|
||||||
|
|
||||||
fn handle_generate_completions_command(args: &Args) -> anyhow::Result<Option<()>> {
|
|
||||||
match args.command {
|
|
||||||
Command::GenerateCompletions(ref completion_args) => {
|
|
||||||
let mut cmd = match completion_args.command {
|
|
||||||
ToplevelCommands::Mysqladm => Args::command(),
|
|
||||||
#[cfg(feature = "mysql-admutils-compatibility")]
|
|
||||||
ToplevelCommands::MysqlDbadm => mysql_dbadm::Args::command(),
|
|
||||||
#[cfg(feature = "mysql-admutils-compatibility")]
|
|
||||||
ToplevelCommands::MysqlUseradm => mysql_useradm::Args::command(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let binary_name = cmd.get_bin_name().unwrap().to_owned();
|
|
||||||
|
|
||||||
generate(
|
|
||||||
completion_args.shell,
|
|
||||||
&mut cmd,
|
|
||||||
binary_name,
|
|
||||||
&mut std::io::stdout(),
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(Some(()))
|
|
||||||
}
|
}
|
||||||
_ => Ok(None),
|
Ok(CommandStatus::NoModificationsNeeded) => {
|
||||||
|
println!("No modifications made");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Ok(CommandStatus::NoModificationsIntended) => {
|
||||||
|
/* Don't report anything */
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Ok(CommandStatus::Cancelled) => {
|
||||||
|
println!("Command cancelled successfully");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tokio_start_server(
|
|
||||||
server_socket_path: Option<PathBuf>,
|
|
||||||
config_path: Option<PathBuf>,
|
|
||||||
args: ServerArgs,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.unwrap()
|
|
||||||
.block_on(async {
|
|
||||||
server::command::handle_command(server_socket_path, config_path, args).await
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tokio_run_command(command: Command, server_connection: StdUnixStream) -> anyhow::Result<()> {
|
|
||||||
tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.unwrap()
|
|
||||||
.block_on(async {
|
|
||||||
let tokio_socket = TokioUnixStream::from_std(server_connection)?;
|
|
||||||
let mut message_stream = create_client_to_server_message_stream(tokio_socket);
|
|
||||||
|
|
||||||
while let Some(Ok(message)) = message_stream.next().await {
|
|
||||||
match message {
|
|
||||||
Response::Error(err) => {
|
|
||||||
anyhow::bail!("{}", err);
|
|
||||||
}
|
|
||||||
Response::Ready => break,
|
|
||||||
message => {
|
|
||||||
eprintln!("Unexpected message from server: {:?}", message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match command {
|
|
||||||
Command::User(user_args) => {
|
|
||||||
cli::user_command::handle_command(user_args, message_stream).await
|
|
||||||
}
|
|
||||||
Command::Db(db_args) => {
|
|
||||||
cli::database_command::handle_command(db_args, message_stream).await
|
|
||||||
}
|
|
||||||
Command::Server(_) => unreachable!(),
|
|
||||||
Command::GenerateCompletions(_) => unreachable!(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
pub mod command;
|
|
||||||
mod common;
|
|
||||||
pub mod config;
|
|
||||||
pub mod input_sanitization;
|
|
||||||
pub mod server_loop;
|
|
||||||
pub mod sql;
|
|
|
@ -1,74 +0,0 @@
|
||||||
use std::os::fd::FromRawFd;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
use clap::Parser;
|
|
||||||
|
|
||||||
use std::os::unix::net::UnixStream as StdUnixStream;
|
|
||||||
use tokio::net::UnixStream as TokioUnixStream;
|
|
||||||
|
|
||||||
use crate::core::common::UnixUser;
|
|
||||||
use crate::server::config::read_config_from_path_with_arg_overrides;
|
|
||||||
use crate::server::server_loop::listen_for_incoming_connections;
|
|
||||||
use crate::server::{
|
|
||||||
config::{ServerConfig, ServerConfigArgs},
|
|
||||||
server_loop::handle_requests_for_single_session,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
|
||||||
pub struct ServerArgs {
|
|
||||||
#[command(subcommand)]
|
|
||||||
subcmd: ServerCommand,
|
|
||||||
|
|
||||||
#[command(flatten)]
|
|
||||||
config_overrides: ServerConfigArgs,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
|
||||||
pub enum ServerCommand {
|
|
||||||
#[command()]
|
|
||||||
Listen,
|
|
||||||
|
|
||||||
#[command()]
|
|
||||||
SocketActivate,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_command(
|
|
||||||
socket_path: Option<PathBuf>,
|
|
||||||
config_path: Option<PathBuf>,
|
|
||||||
args: ServerArgs,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let config = read_config_from_path_with_arg_overrides(config_path, args.config_overrides)?;
|
|
||||||
|
|
||||||
match args.subcmd {
|
|
||||||
ServerCommand::Listen => listen_for_incoming_connections(socket_path, config).await,
|
|
||||||
ServerCommand::SocketActivate => socket_activate(config).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn socket_activate(config: ServerConfig) -> anyhow::Result<()> {
|
|
||||||
let conn = get_socket_from_systemd().await?;
|
|
||||||
let uid = conn.peer_cred()?.uid();
|
|
||||||
let unix_user = UnixUser::from_uid(uid)?;
|
|
||||||
|
|
||||||
log::info!("Accepted connection from {}", unix_user.username);
|
|
||||||
|
|
||||||
sd_notify::notify(true, &[sd_notify::NotifyState::Ready]).ok();
|
|
||||||
|
|
||||||
handle_requests_for_single_session(conn, &unix_user, &config).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_socket_from_systemd() -> anyhow::Result<TokioUnixStream> {
|
|
||||||
let fd = sd_notify::listen_fds()
|
|
||||||
.context("Failed to get file descriptors from systemd")?
|
|
||||||
.next()
|
|
||||||
.context("No file descriptors received from systemd")?;
|
|
||||||
|
|
||||||
log::debug!("Received file descriptor from systemd: {}", fd);
|
|
||||||
|
|
||||||
let std_unix_stream = unsafe { StdUnixStream::from_raw_fd(fd) };
|
|
||||||
let socket = TokioUnixStream::from_std(std_unix_stream)?;
|
|
||||||
Ok(socket)
|
|
||||||
}
|
|
|
@ -1,51 +0,0 @@
|
||||||
use crate::core::common::UnixUser;
|
|
||||||
use sqlx::prelude::*;
|
|
||||||
|
|
||||||
/// This function creates a regex that matches items (users, databases)
|
|
||||||
/// that belong to the user or any of the user's groups.
|
|
||||||
pub fn create_user_group_matching_regex(user: &UnixUser) -> String {
|
|
||||||
if user.groups.is_empty() {
|
|
||||||
format!("{}_.+", user.username)
|
|
||||||
} else {
|
|
||||||
format!("({}|{})_.+", user.username, user.groups.join("|"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Some mysql versions with some collations mark some columns as binary fields,
|
|
||||||
/// which in the current version of sqlx is not parsable as string.
|
|
||||||
/// See: https://github.com/launchbadge/sqlx/issues/3387
|
|
||||||
#[inline]
|
|
||||||
pub fn try_get_with_binary_fallback(
|
|
||||||
row: &sqlx::mysql::MySqlRow,
|
|
||||||
column: &str,
|
|
||||||
) -> Result<String, sqlx::Error> {
|
|
||||||
row.try_get(column).or_else(|_| {
|
|
||||||
row.try_get::<Vec<u8>, _>(column)
|
|
||||||
.map(|v| String::from_utf8_lossy(&v).to_string())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use regex::Regex;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_create_user_group_matching_regex() {
|
|
||||||
let user = UnixUser {
|
|
||||||
username: "user".to_owned(),
|
|
||||||
groups: vec!["group1".to_owned(), "group2".to_owned()],
|
|
||||||
};
|
|
||||||
|
|
||||||
let regex = create_user_group_matching_regex(&user);
|
|
||||||
let re = Regex::new(®ex).unwrap();
|
|
||||||
|
|
||||||
assert!(re.is_match("user_something"));
|
|
||||||
assert!(re.is_match("group1_something"));
|
|
||||||
assert!(re.is_match("group2_something"));
|
|
||||||
|
|
||||||
assert!(!re.is_match("other_something"));
|
|
||||||
assert!(!re.is_match("user"));
|
|
||||||
assert!(!re.is_match("usersomething"));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,174 +0,0 @@
|
||||||
use std::{fs, path::PathBuf, time::Duration};
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
|
||||||
use clap::Parser;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sqlx::{mysql::MySqlConnectOptions, ConnectOptions, MySqlConnection};
|
|
||||||
|
|
||||||
use crate::core::common::DEFAULT_CONFIG_PATH;
|
|
||||||
|
|
||||||
pub const DEFAULT_PORT: u16 = 3306;
|
|
||||||
pub const DEFAULT_TIMEOUT: u64 = 2;
|
|
||||||
|
|
||||||
// NOTE: this might look empty now, and the extra wrapping for the mysql
|
|
||||||
// config seems unnecessary, but it will be useful later when we
|
|
||||||
// add more configuration options.
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct ServerConfig {
|
|
||||||
pub mysql: MysqlConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
#[serde(rename = "mysql")]
|
|
||||||
pub struct MysqlConfig {
|
|
||||||
pub socket_path: Option<PathBuf>,
|
|
||||||
pub host: Option<String>,
|
|
||||||
pub port: Option<u16>,
|
|
||||||
pub username: Option<String>,
|
|
||||||
pub password: Option<String>,
|
|
||||||
pub password_file: Option<PathBuf>,
|
|
||||||
pub timeout: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
|
||||||
pub struct ServerConfigArgs {
|
|
||||||
/// Path to the socket of the MySQL server.
|
|
||||||
#[arg(long, value_name = "PATH", global = true)]
|
|
||||||
socket_path: Option<PathBuf>,
|
|
||||||
|
|
||||||
/// Hostname of the MySQL server.
|
|
||||||
#[arg(
|
|
||||||
long,
|
|
||||||
value_name = "HOST",
|
|
||||||
global = true,
|
|
||||||
conflicts_with = "socket_path"
|
|
||||||
)]
|
|
||||||
mysql_host: Option<String>,
|
|
||||||
|
|
||||||
/// Port of the MySQL server.
|
|
||||||
#[arg(
|
|
||||||
long,
|
|
||||||
value_name = "PORT",
|
|
||||||
global = true,
|
|
||||||
conflicts_with = "socket_path"
|
|
||||||
)]
|
|
||||||
mysql_port: Option<u16>,
|
|
||||||
|
|
||||||
/// Username to use for the MySQL connection.
|
|
||||||
#[arg(long, value_name = "USER", global = true)]
|
|
||||||
mysql_user: Option<String>,
|
|
||||||
|
|
||||||
/// Path to a file containing the MySQL password.
|
|
||||||
#[arg(long, value_name = "PATH", global = true)]
|
|
||||||
mysql_password_file: Option<PathBuf>,
|
|
||||||
|
|
||||||
/// Seconds to wait for the MySQL connection to be established.
|
|
||||||
#[arg(long, value_name = "SECONDS", global = true)]
|
|
||||||
mysql_connect_timeout: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Use the arguments and whichever configuration file which might or might not
|
|
||||||
/// be found and default values to determine the configuration for the program.
|
|
||||||
pub fn read_config_from_path_with_arg_overrides(
|
|
||||||
config_path: Option<PathBuf>,
|
|
||||||
args: ServerConfigArgs,
|
|
||||||
) -> anyhow::Result<ServerConfig> {
|
|
||||||
let config = read_config_from_path(config_path)?;
|
|
||||||
|
|
||||||
let mysql = config.mysql;
|
|
||||||
|
|
||||||
let password = if let Some(path) = &args.mysql_password_file {
|
|
||||||
Some(
|
|
||||||
fs::read_to_string(path)
|
|
||||||
.context("Failed to read MySQL password file")
|
|
||||||
.map(|s| s.trim().to_owned())?,
|
|
||||||
)
|
|
||||||
} else if let Some(path) = &mysql.password_file {
|
|
||||||
Some(
|
|
||||||
fs::read_to_string(path)
|
|
||||||
.context("Failed to read MySQL password file")
|
|
||||||
.map(|s| s.trim().to_owned())?,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
mysql.password.to_owned()
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(ServerConfig {
|
|
||||||
mysql: MysqlConfig {
|
|
||||||
socket_path: args.socket_path.or(mysql.socket_path),
|
|
||||||
host: args.mysql_host.or(mysql.host),
|
|
||||||
port: args.mysql_port.or(mysql.port),
|
|
||||||
username: args.mysql_user.or(mysql.username.to_owned()),
|
|
||||||
password,
|
|
||||||
password_file: args.mysql_password_file.or(mysql.password_file),
|
|
||||||
timeout: args.mysql_connect_timeout.or(mysql.timeout),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read_config_from_path(config_path: Option<PathBuf>) -> anyhow::Result<ServerConfig> {
|
|
||||||
let config_path = config_path.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH));
|
|
||||||
|
|
||||||
log::debug!("Reading config from {:?}", &config_path);
|
|
||||||
|
|
||||||
fs::read_to_string(&config_path)
|
|
||||||
.context(format!(
|
|
||||||
"Failed to read config file from {:?}",
|
|
||||||
&config_path
|
|
||||||
))
|
|
||||||
.and_then(|c| toml::from_str(&c).context("Failed to parse config file"))
|
|
||||||
.context(format!(
|
|
||||||
"Failed to parse config file from {:?}",
|
|
||||||
&config_path
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn log_config(config: &MysqlConfig) {
|
|
||||||
let mut display_config = config.clone();
|
|
||||||
display_config.password = display_config
|
|
||||||
.password
|
|
||||||
.as_ref()
|
|
||||||
.map(|_| "<REDACTED>".to_owned());
|
|
||||||
log::debug!(
|
|
||||||
"Connecting to MySQL server with parameters: {:#?}",
|
|
||||||
display_config
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Use the provided configuration to establish a connection to a MySQL server.
|
|
||||||
pub async fn create_mysql_connection_from_config(
|
|
||||||
config: &MysqlConfig,
|
|
||||||
) -> anyhow::Result<MySqlConnection> {
|
|
||||||
log_config(config);
|
|
||||||
|
|
||||||
let mut mysql_options = MySqlConnectOptions::new().database("mysql");
|
|
||||||
|
|
||||||
if let Some(username) = &config.username {
|
|
||||||
mysql_options = mysql_options.username(username);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(password) = &config.password {
|
|
||||||
mysql_options = mysql_options.password(password);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(socket_path) = &config.socket_path {
|
|
||||||
mysql_options = mysql_options.socket(socket_path);
|
|
||||||
} else if let Some(host) = &config.host {
|
|
||||||
mysql_options = mysql_options.host(host);
|
|
||||||
mysql_options = mysql_options.port(config.port.unwrap_or(DEFAULT_PORT));
|
|
||||||
} else {
|
|
||||||
anyhow::bail!("No MySQL host or socket path provided");
|
|
||||||
}
|
|
||||||
|
|
||||||
match tokio::time::timeout(
|
|
||||||
Duration::from_secs(config.timeout.unwrap_or(DEFAULT_TIMEOUT)),
|
|
||||||
mysql_options.connect(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(connection) => connection.context("Failed to connect to the database"),
|
|
||||||
Err(_) => {
|
|
||||||
Err(anyhow!("Timed out after 2 seconds")).context("Failed to connect to the database")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,136 +0,0 @@
|
||||||
use crate::core::{
|
|
||||||
common::UnixUser,
|
|
||||||
protocol::server_responses::{NameValidationError, OwnerValidationError},
|
|
||||||
};
|
|
||||||
|
|
||||||
const MAX_NAME_LENGTH: usize = 64;
|
|
||||||
|
|
||||||
pub fn validate_name(name: &str) -> Result<(), NameValidationError> {
|
|
||||||
if name.is_empty() {
|
|
||||||
Err(NameValidationError::EmptyString)
|
|
||||||
} else if name.len() > MAX_NAME_LENGTH {
|
|
||||||
Err(NameValidationError::TooLong)
|
|
||||||
} else if !name
|
|
||||||
.chars()
|
|
||||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
|
||||||
{
|
|
||||||
Err(NameValidationError::InvalidCharacters)
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn validate_ownership_by_unix_user(
|
|
||||||
name: &str,
|
|
||||||
user: &UnixUser,
|
|
||||||
) -> Result<(), OwnerValidationError> {
|
|
||||||
let prefixes = std::iter::once(user.username.clone())
|
|
||||||
.chain(user.groups.iter().cloned())
|
|
||||||
.collect::<Vec<String>>();
|
|
||||||
|
|
||||||
validate_ownership_by_prefixes(name, &prefixes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Core logic for validating the ownership of a database name.
|
|
||||||
/// This function checks if the given name matches any of the given prefixes.
|
|
||||||
/// These prefixes will in most cases be the user's unix username and any
|
|
||||||
/// unix groups the user is a member of.
|
|
||||||
pub fn validate_ownership_by_prefixes(
|
|
||||||
name: &str,
|
|
||||||
prefixes: &[String],
|
|
||||||
) -> Result<(), OwnerValidationError> {
|
|
||||||
if name.is_empty() {
|
|
||||||
return Err(OwnerValidationError::StringEmpty);
|
|
||||||
}
|
|
||||||
|
|
||||||
if prefixes
|
|
||||||
.iter()
|
|
||||||
.filter(|p| name.starts_with(&(p.to_string() + "_")))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.is_empty()
|
|
||||||
{
|
|
||||||
return Err(OwnerValidationError::NoMatch);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn quote_literal(s: &str) -> String {
|
|
||||||
format!("'{}'", s.replace('\'', r"\'"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn quote_identifier(s: &str) -> String {
|
|
||||||
format!("`{}`", s.replace('`', r"\`"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
#[test]
|
|
||||||
fn test_quote_literal() {
|
|
||||||
let payload = "' OR 1=1 --";
|
|
||||||
assert_eq!(quote_literal(payload), r#"'\' OR 1=1 --'"#);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_quote_identifier() {
|
|
||||||
let payload = "` OR 1=1 --";
|
|
||||||
assert_eq!(quote_identifier(payload), r#"`\` OR 1=1 --`"#);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_validate_name() {
|
|
||||||
assert_eq!(validate_name(""), Err(NameValidationError::EmptyString));
|
|
||||||
assert_eq!(validate_name("abcdefghijklmnopqrstuvwxyz"), Ok(()));
|
|
||||||
assert_eq!(validate_name("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), Ok(()));
|
|
||||||
assert_eq!(validate_name("0123456789_-"), Ok(()));
|
|
||||||
|
|
||||||
for c in "\n\t\r !@#$%^&*()+=[]{}|;:,.<>?/".chars() {
|
|
||||||
assert_eq!(
|
|
||||||
validate_name(&c.to_string()),
|
|
||||||
Err(NameValidationError::InvalidCharacters)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(validate_name(&"a".repeat(MAX_NAME_LENGTH)), Ok(()));
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
validate_name(&"a".repeat(MAX_NAME_LENGTH + 1)),
|
|
||||||
Err(NameValidationError::TooLong)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_validate_owner_by_prefixes() {
|
|
||||||
let prefixes = vec!["user".to_string(), "group".to_string()];
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
validate_ownership_by_prefixes("", &prefixes),
|
|
||||||
Err(OwnerValidationError::StringEmpty)
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
validate_ownership_by_prefixes("user_testdb", &prefixes),
|
|
||||||
Ok(())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
validate_ownership_by_prefixes("group_testdb", &prefixes),
|
|
||||||
Ok(())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
validate_ownership_by_prefixes("group_test_db", &prefixes),
|
|
||||||
Ok(())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
validate_ownership_by_prefixes("group_test-db", &prefixes),
|
|
||||||
Ok(())
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
validate_ownership_by_prefixes("nonexistent_testdb", &prefixes),
|
|
||||||
Err(OwnerValidationError::NoMatch)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,271 +0,0 @@
|
||||||
use std::{collections::BTreeSet, fs, path::PathBuf};
|
|
||||||
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
|
||||||
use indoc::concatdoc;
|
|
||||||
use tokio::net::{UnixListener, UnixStream};
|
|
||||||
|
|
||||||
use sqlx::prelude::*;
|
|
||||||
use sqlx::MySqlConnection;
|
|
||||||
|
|
||||||
use crate::server::sql::database_operations::list_databases;
|
|
||||||
use crate::{
|
|
||||||
core::{
|
|
||||||
common::{UnixUser, DEFAULT_SOCKET_PATH},
|
|
||||||
protocol::request_response::{
|
|
||||||
create_server_to_client_message_stream, Request, Response, ServerToClientMessageStream,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server::{
|
|
||||||
config::{create_mysql_connection_from_config, ServerConfig},
|
|
||||||
sql::{
|
|
||||||
database_operations::{create_databases, drop_databases, list_all_databases_for_user},
|
|
||||||
database_privilege_operations::{
|
|
||||||
apply_privilege_diffs, get_all_database_privileges, get_databases_privilege_data,
|
|
||||||
},
|
|
||||||
user_operations::{
|
|
||||||
create_database_users, drop_database_users, list_all_database_users_for_unix_user,
|
|
||||||
list_database_users, lock_database_users, set_password_for_database_user,
|
|
||||||
unlock_database_users,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: consider using a connection pool
|
|
||||||
|
|
||||||
pub async fn listen_for_incoming_connections(
|
|
||||||
socket_path: Option<PathBuf>,
|
|
||||||
config: ServerConfig,
|
|
||||||
// db_connection: &mut MySqlConnection,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let socket_path = socket_path.unwrap_or(PathBuf::from(DEFAULT_SOCKET_PATH));
|
|
||||||
|
|
||||||
let parent_directory = socket_path.parent().unwrap();
|
|
||||||
if !parent_directory.exists() {
|
|
||||||
log::debug!("Creating directory {:?}", parent_directory);
|
|
||||||
fs::create_dir_all(parent_directory)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("Listening on socket {:?}", socket_path);
|
|
||||||
|
|
||||||
match fs::remove_file(socket_path.as_path()) {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
}
|
|
||||||
|
|
||||||
let listener = UnixListener::bind(socket_path)?;
|
|
||||||
|
|
||||||
sd_notify::notify(true, &[sd_notify::NotifyState::Ready]).ok();
|
|
||||||
|
|
||||||
while let Ok((conn, _addr)) = listener.accept().await {
|
|
||||||
let uid = match conn.peer_cred() {
|
|
||||||
Ok(cred) => cred.uid(),
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to get peer credentials from socket: {}", e);
|
|
||||||
let mut message_stream = create_server_to_client_message_stream(conn);
|
|
||||||
message_stream
|
|
||||||
.send(Response::Error(
|
|
||||||
(concatdoc! {
|
|
||||||
"Server failed to get peer credentials from socket\n",
|
|
||||||
"Please check the server logs or contact the system administrators"
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
log::trace!("Accepted connection from uid {}", uid);
|
|
||||||
|
|
||||||
let unix_user = match UnixUser::from_uid(uid) {
|
|
||||||
Ok(user) => user,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to get username from uid: {}", e);
|
|
||||||
let mut message_stream = create_server_to_client_message_stream(conn);
|
|
||||||
message_stream
|
|
||||||
.send(Response::Error(
|
|
||||||
(concatdoc! {
|
|
||||||
"Server failed to get user data from the system\n",
|
|
||||||
"Please check the server logs or contact the system administrators"
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
log::info!("Accepted connection from {}", unix_user.username);
|
|
||||||
|
|
||||||
match handle_requests_for_single_session(conn, &unix_user, &config).await {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to run server: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_requests_for_single_session(
|
|
||||||
socket: UnixStream,
|
|
||||||
unix_user: &UnixUser,
|
|
||||||
config: &ServerConfig,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let mut message_stream = create_server_to_client_message_stream(socket);
|
|
||||||
let mut db_connection = match create_mysql_connection_from_config(&config.mysql).await {
|
|
||||||
Ok(connection) => connection,
|
|
||||||
Err(err) => {
|
|
||||||
message_stream
|
|
||||||
.send(Response::Error(
|
|
||||||
(concatdoc! {
|
|
||||||
"Server failed to connect to database\n",
|
|
||||||
"Please check the server logs or contact the system administrators"
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
))
|
|
||||||
.await?;
|
|
||||||
message_stream.flush().await?;
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
log::debug!("Successfully connected to database");
|
|
||||||
|
|
||||||
let result = handle_requests_for_single_session_with_db_connection(
|
|
||||||
message_stream,
|
|
||||||
unix_user,
|
|
||||||
&mut db_connection,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Err(e) = db_connection.close().await {
|
|
||||||
log::error!("Failed to close database connection: {}", e);
|
|
||||||
log::error!("{}", e);
|
|
||||||
log::error!("Ignoring...");
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: ensure proper db_connection hygiene for functions that invoke
|
|
||||||
// this function
|
|
||||||
|
|
||||||
pub async fn handle_requests_for_single_session_with_db_connection(
|
|
||||||
mut stream: ServerToClientMessageStream,
|
|
||||||
unix_user: &UnixUser,
|
|
||||||
db_connection: &mut MySqlConnection,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
stream.send(Response::Ready).await?;
|
|
||||||
loop {
|
|
||||||
// TODO: better error handling
|
|
||||||
let request = match stream.next().await {
|
|
||||||
Some(Ok(request)) => request,
|
|
||||||
Some(Err(e)) => return Err(e.into()),
|
|
||||||
None => {
|
|
||||||
log::warn!("Client disconnected without sending an exit message");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
log::trace!("Received request: {:?}", request);
|
|
||||||
|
|
||||||
match request {
|
|
||||||
Request::CreateDatabases(databases_names) => {
|
|
||||||
let result = create_databases(databases_names, unix_user, db_connection).await;
|
|
||||||
stream.send(Response::CreateDatabases(result)).await?;
|
|
||||||
}
|
|
||||||
Request::DropDatabases(databases_names) => {
|
|
||||||
let result = drop_databases(databases_names, unix_user, db_connection).await;
|
|
||||||
stream.send(Response::DropDatabases(result)).await?;
|
|
||||||
}
|
|
||||||
Request::ListDatabases(database_names) => {
|
|
||||||
let response = match database_names {
|
|
||||||
Some(database_names) => {
|
|
||||||
let result = list_databases(database_names, unix_user, db_connection).await;
|
|
||||||
Response::ListDatabases(result)
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let result = list_all_databases_for_user(unix_user, db_connection).await;
|
|
||||||
Response::ListAllDatabases(result)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
stream.send(response).await?;
|
|
||||||
}
|
|
||||||
Request::ListPrivileges(database_names) => {
|
|
||||||
let response = match database_names {
|
|
||||||
Some(database_names) => {
|
|
||||||
let privilege_data =
|
|
||||||
get_databases_privilege_data(database_names, unix_user, db_connection)
|
|
||||||
.await;
|
|
||||||
Response::ListPrivileges(privilege_data)
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let privilege_data =
|
|
||||||
get_all_database_privileges(unix_user, db_connection).await;
|
|
||||||
Response::ListAllPrivileges(privilege_data)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
stream.send(response).await?;
|
|
||||||
}
|
|
||||||
Request::ModifyPrivileges(database_privilege_diffs) => {
|
|
||||||
let result = apply_privilege_diffs(
|
|
||||||
BTreeSet::from_iter(database_privilege_diffs),
|
|
||||||
unix_user,
|
|
||||||
db_connection,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
stream.send(Response::ModifyPrivileges(result)).await?;
|
|
||||||
}
|
|
||||||
Request::CreateUsers(db_users) => {
|
|
||||||
let result = create_database_users(db_users, unix_user, db_connection).await;
|
|
||||||
stream.send(Response::CreateUsers(result)).await?;
|
|
||||||
}
|
|
||||||
Request::DropUsers(db_users) => {
|
|
||||||
let result = drop_database_users(db_users, unix_user, db_connection).await;
|
|
||||||
stream.send(Response::DropUsers(result)).await?;
|
|
||||||
}
|
|
||||||
Request::PasswdUser(db_user, password) => {
|
|
||||||
let result =
|
|
||||||
set_password_for_database_user(&db_user, &password, unix_user, db_connection)
|
|
||||||
.await;
|
|
||||||
stream.send(Response::PasswdUser(result)).await?;
|
|
||||||
}
|
|
||||||
Request::ListUsers(db_users) => {
|
|
||||||
let response = match db_users {
|
|
||||||
Some(db_users) => {
|
|
||||||
let result = list_database_users(db_users, unix_user, db_connection).await;
|
|
||||||
Response::ListUsers(result)
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let result =
|
|
||||||
list_all_database_users_for_unix_user(unix_user, db_connection).await;
|
|
||||||
Response::ListAllUsers(result)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
stream.send(response).await?;
|
|
||||||
}
|
|
||||||
Request::LockUsers(db_users) => {
|
|
||||||
let result = lock_database_users(db_users, unix_user, db_connection).await;
|
|
||||||
stream.send(Response::LockUsers(result)).await?;
|
|
||||||
}
|
|
||||||
Request::UnlockUsers(db_users) => {
|
|
||||||
let result = unlock_database_users(db_users, unix_user, db_connection).await;
|
|
||||||
stream.send(Response::UnlockUsers(result)).await?;
|
|
||||||
}
|
|
||||||
Request::Exit => {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stream.flush().await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
pub mod database_operations;
|
|
||||||
pub mod database_privilege_operations;
|
|
||||||
pub mod user_operations;
|
|
|
@ -1,244 +0,0 @@
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use sqlx::prelude::*;
|
|
||||||
use sqlx::MySqlConnection;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
core::{
|
|
||||||
common::UnixUser,
|
|
||||||
protocol::{
|
|
||||||
CreateDatabaseError, CreateDatabasesOutput, DropDatabaseError, DropDatabasesOutput,
|
|
||||||
ListAllDatabasesError, ListAllDatabasesOutput, ListDatabasesError, ListDatabasesOutput,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server::{
|
|
||||||
common::create_user_group_matching_regex,
|
|
||||||
input_sanitization::{quote_identifier, validate_name, validate_ownership_by_unix_user},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// NOTE: this function is unsafe because it does no input validation.
|
|
||||||
pub(super) async fn unsafe_database_exists(
|
|
||||||
database_name: &str,
|
|
||||||
connection: &mut MySqlConnection,
|
|
||||||
) -> Result<bool, sqlx::Error> {
|
|
||||||
let result =
|
|
||||||
sqlx::query("SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?")
|
|
||||||
.bind(database_name)
|
|
||||||
.fetch_optional(connection)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Err(err) = &result {
|
|
||||||
log::error!(
|
|
||||||
"Failed to check if database '{}' exists: {:?}",
|
|
||||||
&database_name,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result?.is_some())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_databases(
|
|
||||||
database_names: Vec<String>,
|
|
||||||
unix_user: &UnixUser,
|
|
||||||
connection: &mut MySqlConnection,
|
|
||||||
) -> CreateDatabasesOutput {
|
|
||||||
let mut results = BTreeMap::new();
|
|
||||||
|
|
||||||
for database_name in database_names {
|
|
||||||
if let Err(err) = validate_name(&database_name) {
|
|
||||||
results.insert(
|
|
||||||
database_name.clone(),
|
|
||||||
Err(CreateDatabaseError::SanitizationError(err)),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(err) = validate_ownership_by_unix_user(&database_name, unix_user) {
|
|
||||||
results.insert(
|
|
||||||
database_name.clone(),
|
|
||||||
Err(CreateDatabaseError::OwnershipError(err)),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
match unsafe_database_exists(&database_name, &mut *connection).await {
|
|
||||||
Ok(true) => {
|
|
||||||
results.insert(
|
|
||||||
database_name.clone(),
|
|
||||||
Err(CreateDatabaseError::DatabaseAlreadyExists),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
results.insert(
|
|
||||||
database_name.clone(),
|
|
||||||
Err(CreateDatabaseError::MySqlError(err.to_string())),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
let result =
|
|
||||||
sqlx::query(format!("CREATE DATABASE {}", quote_identifier(&database_name)).as_str())
|
|
||||||
.execute(&mut *connection)
|
|
||||||
.await
|
|
||||||
.map(|_| ())
|
|
||||||
.map_err(|err| CreateDatabaseError::MySqlError(err.to_string()));
|
|
||||||
|
|
||||||
if let Err(err) = &result {
|
|
||||||
log::error!("Failed to create database '{}': {:?}", &database_name, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
results.insert(database_name, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
results
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn drop_databases(
|
|
||||||
database_names: Vec<String>,
|
|
||||||
unix_user: &UnixUser,
|
|
||||||
connection: &mut MySqlConnection,
|
|
||||||
) -> DropDatabasesOutput {
|
|
||||||
let mut results = BTreeMap::new();
|
|
||||||
|
|
||||||
for database_name in database_names {
|
|
||||||
if let Err(err) = validate_name(&database_name) {
|
|
||||||
results.insert(
|
|
||||||
database_name.clone(),
|
|
||||||
Err(DropDatabaseError::SanitizationError(err)),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(err) = validate_ownership_by_unix_user(&database_name, unix_user) {
|
|
||||||
results.insert(
|
|
||||||
database_name.clone(),
|
|
||||||
Err(DropDatabaseError::OwnershipError(err)),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
match unsafe_database_exists(&database_name, &mut *connection).await {
|
|
||||||
Ok(false) => {
|
|
||||||
results.insert(
|
|
||||||
database_name.clone(),
|
|
||||||
Err(DropDatabaseError::DatabaseDoesNotExist),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
results.insert(
|
|
||||||
database_name.clone(),
|
|
||||||
Err(DropDatabaseError::MySqlError(err.to_string())),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
let result =
|
|
||||||
sqlx::query(format!("DROP DATABASE {}", quote_identifier(&database_name)).as_str())
|
|
||||||
.execute(&mut *connection)
|
|
||||||
.await
|
|
||||||
.map(|_| ())
|
|
||||||
.map_err(|err| DropDatabaseError::MySqlError(err.to_string()));
|
|
||||||
|
|
||||||
if let Err(err) = &result {
|
|
||||||
log::error!("Failed to drop database '{}': {:?}", &database_name, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
results.insert(database_name, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
results
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, FromRow)]
|
|
||||||
pub struct DatabaseRow {
|
|
||||||
pub database: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_databases(
|
|
||||||
database_names: Vec<String>,
|
|
||||||
unix_user: &UnixUser,
|
|
||||||
connection: &mut MySqlConnection,
|
|
||||||
) -> ListDatabasesOutput {
|
|
||||||
let mut results = BTreeMap::new();
|
|
||||||
|
|
||||||
for database_name in database_names {
|
|
||||||
if let Err(err) = validate_name(&database_name) {
|
|
||||||
results.insert(
|
|
||||||
database_name.clone(),
|
|
||||||
Err(ListDatabasesError::SanitizationError(err)),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(err) = validate_ownership_by_unix_user(&database_name, unix_user) {
|
|
||||||
results.insert(
|
|
||||||
database_name.clone(),
|
|
||||||
Err(ListDatabasesError::OwnershipError(err)),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = sqlx::query_as::<_, DatabaseRow>(
|
|
||||||
r#"
|
|
||||||
SELECT `SCHEMA_NAME` AS `database`
|
|
||||||
FROM `information_schema`.`SCHEMATA`
|
|
||||||
WHERE `SCHEMA_NAME` = ?
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(&database_name)
|
|
||||||
.fetch_optional(&mut *connection)
|
|
||||||
.await
|
|
||||||
.map_err(|err| ListDatabasesError::MySqlError(err.to_string()))
|
|
||||||
.and_then(|database| {
|
|
||||||
database
|
|
||||||
.map(Ok)
|
|
||||||
.unwrap_or_else(|| Err(ListDatabasesError::DatabaseDoesNotExist))
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Err(err) = &result {
|
|
||||||
log::error!("Failed to list database '{}': {:?}", &database_name, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
results.insert(database_name, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
results
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_all_databases_for_user(
|
|
||||||
unix_user: &UnixUser,
|
|
||||||
connection: &mut MySqlConnection,
|
|
||||||
) -> ListAllDatabasesOutput {
|
|
||||||
let result = sqlx::query_as::<_, DatabaseRow>(
|
|
||||||
r#"
|
|
||||||
SELECT `SCHEMA_NAME` AS `database`
|
|
||||||
FROM `information_schema`.`SCHEMATA`
|
|
||||||
WHERE `SCHEMA_NAME` NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')
|
|
||||||
AND `SCHEMA_NAME` REGEXP ?
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(create_user_group_matching_regex(unix_user))
|
|
||||||
.fetch_all(connection)
|
|
||||||
.await
|
|
||||||
.map_err(|err| ListAllDatabasesError::MySqlError(err.to_string()));
|
|
||||||
|
|
||||||
if let Err(err) = &result {
|
|
||||||
log::error!(
|
|
||||||
"Failed to list databases for user '{}': {:?}",
|
|
||||||
unix_user.username,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
|
@ -1,487 +0,0 @@
|
||||||
// TODO: fix comment
|
|
||||||
//! Database privilege operations
|
|
||||||
//!
|
|
||||||
//! This module contains functions for querying, modifying,
|
|
||||||
//! displaying and comparing database privileges.
|
|
||||||
//!
|
|
||||||
//! A lot of the complexity comes from two core components:
|
|
||||||
//!
|
|
||||||
//! - The privilege editor that needs to be able to print
|
|
||||||
//! an editable table of privileges and reparse the content
|
|
||||||
//! after the user has made manual changes.
|
|
||||||
//!
|
|
||||||
//! - The comparison functionality that tells the user what
|
|
||||||
//! changes will be made when applying a set of changes
|
|
||||||
//! to the list of database privileges.
|
|
||||||
|
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
|
||||||
|
|
||||||
use indoc::indoc;
|
|
||||||
use itertools::Itertools;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sqlx::{mysql::MySqlRow, prelude::*, MySqlConnection};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
core::{
|
|
||||||
common::{rev_yn, yn, UnixUser},
|
|
||||||
database_privileges::{DatabasePrivilegeChange, DatabasePrivilegesDiff},
|
|
||||||
protocol::{
|
|
||||||
DiffDoesNotApplyError, GetAllDatabasesPrivilegeData, GetAllDatabasesPrivilegeDataError,
|
|
||||||
GetDatabasesPrivilegeData, GetDatabasesPrivilegeDataError,
|
|
||||||
ModifyDatabasePrivilegesError, ModifyDatabasePrivilegesOutput,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server::{
|
|
||||||
common::{create_user_group_matching_regex, try_get_with_binary_fallback},
|
|
||||||
input_sanitization::{quote_identifier, validate_name, validate_ownership_by_unix_user},
|
|
||||||
sql::database_operations::unsafe_database_exists,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// This is the list of fields that are used to fetch the db + user + privileges
|
|
||||||
/// from the `db` table in the database. If you need to add or remove privilege
|
|
||||||
/// fields, this is a good place to start.
|
|
||||||
pub const DATABASE_PRIVILEGE_FIELDS: [&str; 13] = [
|
|
||||||
"Db",
|
|
||||||
"User",
|
|
||||||
"select_priv",
|
|
||||||
"insert_priv",
|
|
||||||
"update_priv",
|
|
||||||
"delete_priv",
|
|
||||||
"create_priv",
|
|
||||||
"drop_priv",
|
|
||||||
"alter_priv",
|
|
||||||
"index_priv",
|
|
||||||
"create_tmp_table_priv",
|
|
||||||
"lock_tables_priv",
|
|
||||||
"references_priv",
|
|
||||||
];
|
|
||||||
|
|
||||||
// NOTE: ord is needed for BTreeSet to accept the type, but it
|
|
||||||
// doesn't have any natural implementation semantics.
|
|
||||||
|
|
||||||
/// This struct represents the set of privileges for a single user on a single database.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
|
|
||||||
pub struct DatabasePrivilegeRow {
|
|
||||||
pub db: String,
|
|
||||||
pub user: String,
|
|
||||||
pub select_priv: bool,
|
|
||||||
pub insert_priv: bool,
|
|
||||||
pub update_priv: bool,
|
|
||||||
pub delete_priv: bool,
|
|
||||||
pub create_priv: bool,
|
|
||||||
pub drop_priv: bool,
|
|
||||||
pub alter_priv: bool,
|
|
||||||
pub index_priv: bool,
|
|
||||||
pub create_tmp_table_priv: bool,
|
|
||||||
pub lock_tables_priv: bool,
|
|
||||||
pub references_priv: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DatabasePrivilegeRow {
|
|
||||||
pub fn 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: get by name instead of row tuple position
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn get_mysql_row_priv_field(row: &MySqlRow, position: usize) -> Result<bool, sqlx::Error> {
|
|
||||||
let field = DATABASE_PRIVILEGE_FIELDS[position];
|
|
||||||
let value = row.try_get(position)?;
|
|
||||||
match rev_yn(value) {
|
|
||||||
Some(val) => Ok(val),
|
|
||||||
_ => {
|
|
||||||
log::warn!(r#"Invalid value for privilege "{}": '{}'"#, field, value);
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromRow<'_, MySqlRow> for DatabasePrivilegeRow {
|
|
||||||
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
|
|
||||||
Ok(Self {
|
|
||||||
db: try_get_with_binary_fallback(row, "Db")?,
|
|
||||||
user: try_get_with_binary_fallback(row, "User")?,
|
|
||||||
select_priv: get_mysql_row_priv_field(row, 2)?,
|
|
||||||
insert_priv: get_mysql_row_priv_field(row, 3)?,
|
|
||||||
update_priv: get_mysql_row_priv_field(row, 4)?,
|
|
||||||
delete_priv: get_mysql_row_priv_field(row, 5)?,
|
|
||||||
create_priv: get_mysql_row_priv_field(row, 6)?,
|
|
||||||
drop_priv: get_mysql_row_priv_field(row, 7)?,
|
|
||||||
alter_priv: get_mysql_row_priv_field(row, 8)?,
|
|
||||||
index_priv: get_mysql_row_priv_field(row, 9)?,
|
|
||||||
create_tmp_table_priv: get_mysql_row_priv_field(row, 10)?,
|
|
||||||
lock_tables_priv: get_mysql_row_priv_field(row, 11)?,
|
|
||||||
references_priv: get_mysql_row_priv_field(row, 12)?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: this function is unsafe because it does no input validation.
|
|
||||||
/// Get all users + privileges for a single database.
|
|
||||||
async fn unsafe_get_database_privileges(
|
|
||||||
database_name: &str,
|
|
||||||
connection: &mut MySqlConnection,
|
|
||||||
) -> Result<Vec<DatabasePrivilegeRow>, sqlx::Error> {
|
|
||||||
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;
|
|
||||||
|
|
||||||
if let Err(e) = &result {
|
|
||||||
log::error!(
|
|
||||||
"Failed to get database privileges for '{}': {}",
|
|
||||||
&database_name,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: this function is unsafe because it does no input validation.
|
|
||||||
/// Get all users + privileges for a single database-user pair.
|
|
||||||
pub async fn unsafe_get_database_privileges_for_db_user_pair(
|
|
||||||
database_name: &str,
|
|
||||||
user_name: &str,
|
|
||||||
connection: &mut MySqlConnection,
|
|
||||||
) -> Result<Option<DatabasePrivilegeRow>, sqlx::Error> {
|
|
||||||
let result = sqlx::query_as::<_, DatabasePrivilegeRow>(&format!(
|
|
||||||
"SELECT {} FROM `db` WHERE `Db` = ? AND `User` = ?",
|
|
||||||
DATABASE_PRIVILEGE_FIELDS
|
|
||||||
.iter()
|
|
||||||
.map(|field| quote_identifier(field))
|
|
||||||
.join(","),
|
|
||||||
))
|
|
||||||
.bind(database_name)
|
|
||||||
.bind(user_name)
|
|
||||||
.fetch_optional(connection)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Err(e) = &result {
|
|
||||||
log::error!(
|
|
||||||
"Failed to get database privileges for '{}.{}': {}",
|
|
||||||
&database_name,
|
|
||||||
&user_name,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_databases_privilege_data(
|
|
||||||
database_names: Vec<String>,
|
|
||||||
unix_user: &UnixUser,
|
|
||||||
connection: &mut MySqlConnection,
|
|
||||||
) -> GetDatabasesPrivilegeData {
|
|
||||||
let mut results = BTreeMap::new();
|
|
||||||
|
|
||||||
for database_name in database_names.iter() {
|
|
||||||
if let Err(err) = validate_name(database_name) {
|
|
||||||
results.insert(
|
|
||||||
database_name.clone(),
|
|
||||||
Err(GetDatabasesPrivilegeDataError::SanitizationError(err)),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(err) = validate_ownership_by_unix_user(database_name, unix_user) {
|
|
||||||
results.insert(
|
|
||||||
database_name.clone(),
|
|
||||||
Err(GetDatabasesPrivilegeDataError::OwnershipError(err)),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !unsafe_database_exists(database_name, connection)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
{
|
|
||||||
results.insert(
|
|
||||||
database_name.clone(),
|
|
||||||
Err(GetDatabasesPrivilegeDataError::DatabaseDoesNotExist),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = unsafe_get_database_privileges(database_name, connection)
|
|
||||||
.await
|
|
||||||
.map_err(|e| GetDatabasesPrivilegeDataError::MySqlError(e.to_string()));
|
|
||||||
|
|
||||||
results.insert(database_name.clone(), result);
|
|
||||||
}
|
|
||||||
|
|
||||||
debug_assert!(database_names.len() == results.len());
|
|
||||||
|
|
||||||
results
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all database + user + privileges pairs that are owned by the current user.
|
|
||||||
pub async fn get_all_database_privileges(
|
|
||||||
unix_user: &UnixUser,
|
|
||||||
connection: &mut MySqlConnection,
|
|
||||||
) -> GetAllDatabasesPrivilegeData {
|
|
||||||
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| quote_identifier(field))
|
|
||||||
.join(","),
|
|
||||||
))
|
|
||||||
.bind(create_user_group_matching_regex(unix_user))
|
|
||||||
.fetch_all(connection)
|
|
||||||
.await
|
|
||||||
.map_err(|e| GetAllDatabasesPrivilegeDataError::MySqlError(e.to_string()));
|
|
||||||
|
|
||||||
if let Err(e) = &result {
|
|
||||||
log::error!("Failed to get all database privileges: {:?}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn unsafe_apply_privilege_diff(
|
|
||||||
database_privilege_diff: &DatabasePrivilegesDiff,
|
|
||||||
connection: &mut MySqlConnection,
|
|
||||||
) -> Result<(), sqlx::Error> {
|
|
||||||
let result = match database_privilege_diff {
|
|
||||||
DatabasePrivilegesDiff::New(p) => {
|
|
||||||
let tables = DATABASE_PRIVILEGE_FIELDS
|
|
||||||
.iter()
|
|
||||||
.map(|field| quote_identifier(field))
|
|
||||||
.join(",");
|
|
||||||
|
|
||||||
let question_marks = std::iter::repeat("?")
|
|
||||||
.take(DATABASE_PRIVILEGE_FIELDS.len())
|
|
||||||
.join(",");
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
format!("INSERT INTO `db` ({}) VALUES ({})", tables, question_marks).as_str(),
|
|
||||||
)
|
|
||||||
.bind(p.db.to_string())
|
|
||||||
.bind(p.user.to_string())
|
|
||||||
.bind(yn(p.select_priv))
|
|
||||||
.bind(yn(p.insert_priv))
|
|
||||||
.bind(yn(p.update_priv))
|
|
||||||
.bind(yn(p.delete_priv))
|
|
||||||
.bind(yn(p.create_priv))
|
|
||||||
.bind(yn(p.drop_priv))
|
|
||||||
.bind(yn(p.alter_priv))
|
|
||||||
.bind(yn(p.index_priv))
|
|
||||||
.bind(yn(p.create_tmp_table_priv))
|
|
||||||
.bind(yn(p.lock_tables_priv))
|
|
||||||
.bind(yn(p.references_priv))
|
|
||||||
.execute(connection)
|
|
||||||
.await
|
|
||||||
.map(|_| ())
|
|
||||||
}
|
|
||||||
DatabasePrivilegesDiff::Modified(p) => {
|
|
||||||
let changes = p
|
|
||||||
.diff
|
|
||||||
.iter()
|
|
||||||
.map(|diff| match diff {
|
|
||||||
DatabasePrivilegeChange::YesToNo(name) => {
|
|
||||||
format!("{} = 'N'", quote_identifier(name))
|
|
||||||
}
|
|
||||||
DatabasePrivilegeChange::NoToYes(name) => {
|
|
||||||
format!("{} = 'Y'", quote_identifier(name))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.join(",");
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
format!("UPDATE `db` SET {} WHERE `Db` = ? AND `User` = ?", changes).as_str(),
|
|
||||||
)
|
|
||||||
.bind(p.db.to_string())
|
|
||||||
.bind(p.user.to_string())
|
|
||||||
.execute(connection)
|
|
||||||
.await
|
|
||||||
.map(|_| ())
|
|
||||||
}
|
|
||||||
DatabasePrivilegesDiff::Deleted(p) => {
|
|
||||||
sqlx::query("DELETE FROM `db` WHERE `Db` = ? AND `User` = ?")
|
|
||||||
.bind(p.db.to_string())
|
|
||||||
.bind(p.user.to_string())
|
|
||||||
.execute(connection)
|
|
||||||
.await
|
|
||||||
.map(|_| ())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = &result {
|
|
||||||
log::error!("Failed to apply database privilege diff: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn validate_diff(
|
|
||||||
diff: &DatabasePrivilegesDiff,
|
|
||||||
connection: &mut MySqlConnection,
|
|
||||||
) -> Result<(), ModifyDatabasePrivilegesError> {
|
|
||||||
let privilege_row = unsafe_get_database_privileges_for_db_user_pair(
|
|
||||||
diff.get_database_name(),
|
|
||||||
diff.get_user_name(),
|
|
||||||
connection,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let privilege_row = match privilege_row {
|
|
||||||
Ok(privilege_row) => privilege_row,
|
|
||||||
Err(e) => return Err(ModifyDatabasePrivilegesError::MySqlError(e.to_string())),
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = match diff {
|
|
||||||
DatabasePrivilegesDiff::New(_) => {
|
|
||||||
if privilege_row.is_some() {
|
|
||||||
Err(ModifyDatabasePrivilegesError::DiffDoesNotApply(
|
|
||||||
DiffDoesNotApplyError::RowAlreadyExists(
|
|
||||||
diff.get_user_name().to_string(),
|
|
||||||
diff.get_database_name().to_string(),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DatabasePrivilegesDiff::Modified(_) if privilege_row.is_none() => {
|
|
||||||
Err(ModifyDatabasePrivilegesError::DiffDoesNotApply(
|
|
||||||
DiffDoesNotApplyError::RowDoesNotExist(
|
|
||||||
diff.get_user_name().to_string(),
|
|
||||||
diff.get_database_name().to_string(),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
DatabasePrivilegesDiff::Modified(row_diff) => {
|
|
||||||
let row = privilege_row.unwrap();
|
|
||||||
|
|
||||||
let error_exists = row_diff.diff.iter().any(|change| match change {
|
|
||||||
DatabasePrivilegeChange::YesToNo(name) => !row.get_privilege_by_name(name),
|
|
||||||
DatabasePrivilegeChange::NoToYes(name) => row.get_privilege_by_name(name),
|
|
||||||
});
|
|
||||||
|
|
||||||
if error_exists {
|
|
||||||
Err(ModifyDatabasePrivilegesError::DiffDoesNotApply(
|
|
||||||
DiffDoesNotApplyError::RowPrivilegeChangeDoesNotApply(row_diff.clone(), row),
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DatabasePrivilegesDiff::Deleted(_) => {
|
|
||||||
if privilege_row.is_none() {
|
|
||||||
Err(ModifyDatabasePrivilegesError::DiffDoesNotApply(
|
|
||||||
DiffDoesNotApplyError::RowDoesNotExist(
|
|
||||||
diff.get_user_name().to_string(),
|
|
||||||
diff.get_database_name().to_string(),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Uses the result of [`diff_privileges`] to modify privileges in the database.
|
|
||||||
pub async fn apply_privilege_diffs(
|
|
||||||
database_privilege_diffs: BTreeSet<DatabasePrivilegesDiff>,
|
|
||||||
unix_user: &UnixUser,
|
|
||||||
connection: &mut MySqlConnection,
|
|
||||||
) -> ModifyDatabasePrivilegesOutput {
|
|
||||||
let mut results: BTreeMap<(String, String), _> = BTreeMap::new();
|
|
||||||
|
|
||||||
for diff in database_privilege_diffs {
|
|
||||||
let key = (
|
|
||||||
diff.get_database_name().to_string(),
|
|
||||||
diff.get_user_name().to_string(),
|
|
||||||
);
|
|
||||||
if let Err(err) = validate_name(diff.get_database_name()) {
|
|
||||||
results.insert(
|
|
||||||
key,
|
|
||||||
Err(ModifyDatabasePrivilegesError::DatabaseSanitizationError(
|
|
||||||
err,
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(err) = validate_ownership_by_unix_user(diff.get_database_name(), unix_user) {
|
|
||||||
results.insert(
|
|
||||||
key,
|
|
||||||
Err(ModifyDatabasePrivilegesError::DatabaseOwnershipError(err)),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(err) = validate_name(diff.get_user_name()) {
|
|
||||||
results.insert(
|
|
||||||
key,
|
|
||||||
Err(ModifyDatabasePrivilegesError::UserSanitizationError(err)),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(err) = validate_ownership_by_unix_user(diff.get_user_name(), unix_user) {
|
|
||||||
results.insert(
|
|
||||||
key,
|
|
||||||
Err(ModifyDatabasePrivilegesError::UserOwnershipError(err)),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !unsafe_database_exists(diff.get_database_name(), connection)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
{
|
|
||||||
results.insert(
|
|
||||||
key,
|
|
||||||
Err(ModifyDatabasePrivilegesError::DatabaseDoesNotExist),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(err) = validate_diff(&diff, connection).await {
|
|
||||||
results.insert(key, Err(err));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = unsafe_apply_privilege_diff(&diff, connection)
|
|
||||||
.await
|
|
||||||
.map_err(|e| ModifyDatabasePrivilegesError::MySqlError(e.to_string()));
|
|
||||||
|
|
||||||
results.insert(key, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
results
|
|
||||||
}
|
|
|
@ -1,486 +0,0 @@
|
||||||
use indoc::formatdoc;
|
|
||||||
use itertools::Itertools;
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use sqlx::prelude::*;
|
|
||||||
use sqlx::MySqlConnection;
|
|
||||||
|
|
||||||
use crate::server::common::try_get_with_binary_fallback;
|
|
||||||
use crate::{
|
|
||||||
core::{
|
|
||||||
common::UnixUser,
|
|
||||||
protocol::{
|
|
||||||
CreateUserError, CreateUsersOutput, DropUserError, DropUsersOutput, ListAllUsersError,
|
|
||||||
ListAllUsersOutput, ListUsersError, ListUsersOutput, LockUserError, LockUsersOutput,
|
|
||||||
SetPasswordError, SetPasswordOutput, UnlockUserError, UnlockUsersOutput,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server::{
|
|
||||||
common::create_user_group_matching_regex,
|
|
||||||
input_sanitization::{quote_literal, validate_name, validate_ownership_by_unix_user},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::database_privilege_operations::DATABASE_PRIVILEGE_FIELDS;
|
|
||||||
|
|
||||||
// NOTE: this function is unsafe because it does no input validation.
|
|
||||||
async fn unsafe_user_exists(
|
|
||||||
db_user: &str,
|
|
||||||
connection: &mut MySqlConnection,
|
|
||||||
) -> Result<bool, sqlx::Error> {
|
|
||||||
let result = sqlx::query(
|
|
||||||
r#"
|
|
||||||
SELECT EXISTS(
|
|
||||||
SELECT 1
|
|
||||||
FROM `mysql`.`user`
|
|
||||||
WHERE `User` = ?
|
|
||||||
)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(db_user)
|
|
||||||
.fetch_one(connection)
|
|
||||||
.await
|
|
||||||
.map(|row| row.get::<bool, _>(0));
|
|
||||||
|
|
||||||
if let Err(err) = &result {
|
|
||||||
log::error!("Failed to check if database user exists: {:?}", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_database_users(
|
|
||||||
db_users: Vec<String>,
|
|
||||||
unix_user: &UnixUser,
|
|
||||||
connection: &mut MySqlConnection,
|
|
||||||
) -> CreateUsersOutput {
|
|
||||||
let mut results = BTreeMap::new();
|
|
||||||
|
|
||||||
for db_user in db_users {
|
|
||||||
if let Err(err) = validate_name(&db_user) {
|
|
||||||
results.insert(db_user, Err(CreateUserError::SanitizationError(err)));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(err) = validate_ownership_by_unix_user(&db_user, unix_user) {
|
|
||||||
results.insert(db_user, Err(CreateUserError::OwnershipError(err)));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
match unsafe_user_exists(&db_user, &mut *connection).await {
|
|
||||||
Ok(true) => {
|
|
||||||
results.insert(db_user, Err(CreateUserError::UserAlreadyExists));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
results.insert(db_user, Err(CreateUserError::MySqlError(err.to_string())));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = sqlx::query(format!("CREATE USER {}@'%'", quote_literal(&db_user),).as_str())
|
|
||||||
.execute(&mut *connection)
|
|
||||||
.await
|
|
||||||
.map(|_| ())
|
|
||||||
.map_err(|err| CreateUserError::MySqlError(err.to_string()));
|
|
||||||
|
|
||||||
if let Err(err) = &result {
|
|
||||||
log::error!("Failed to create database user '{}': {:?}", &db_user, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
results.insert(db_user, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
results
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn drop_database_users(
|
|
||||||
db_users: Vec<String>,
|
|
||||||
unix_user: &UnixUser,
|
|
||||||
connection: &mut MySqlConnection,
|
|
||||||
) -> DropUsersOutput {
|
|
||||||
let mut results = BTreeMap::new();
|
|
||||||
|
|
||||||
for db_user in db_users {
|
|
||||||
if let Err(err) = validate_name(&db_user) {
|
|
||||||
results.insert(db_user, Err(DropUserError::SanitizationError(err)));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(err) = validate_ownership_by_unix_user(&db_user, unix_user) {
|
|
||||||
results.insert(db_user, Err(DropUserError::OwnershipError(err)));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
match unsafe_user_exists(&db_user, &mut *connection).await {
|
|
||||||
Ok(false) => {
|
|
||||||
results.insert(db_user, Err(DropUserError::UserDoesNotExist));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
results.insert(db_user, Err(DropUserError::MySqlError(err.to_string())));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = sqlx::query(format!("DROP USER {}@'%'", quote_literal(&db_user),).as_str())
|
|
||||||
.execute(&mut *connection)
|
|
||||||
.await
|
|
||||||
.map(|_| ())
|
|
||||||
.map_err(|err| DropUserError::MySqlError(err.to_string()));
|
|
||||||
|
|
||||||
if let Err(err) = &result {
|
|
||||||
log::error!("Failed to drop database user '{}': {:?}", &db_user, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
results.insert(db_user, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
results
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_password_for_database_user(
|
|
||||||
db_user: &str,
|
|
||||||
password: &str,
|
|
||||||
unix_user: &UnixUser,
|
|
||||||
connection: &mut MySqlConnection,
|
|
||||||
) -> SetPasswordOutput {
|
|
||||||
if let Err(err) = validate_name(db_user) {
|
|
||||||
return Err(SetPasswordError::SanitizationError(err));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(err) = validate_ownership_by_unix_user(db_user, unix_user) {
|
|
||||||
return Err(SetPasswordError::OwnershipError(err));
|
|
||||||
}
|
|
||||||
|
|
||||||
match unsafe_user_exists(db_user, &mut *connection).await {
|
|
||||||
Ok(false) => return Err(SetPasswordError::UserDoesNotExist),
|
|
||||||
Err(err) => return Err(SetPasswordError::MySqlError(err.to_string())),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = sqlx::query(
|
|
||||||
format!(
|
|
||||||
"ALTER USER {}@'%' IDENTIFIED BY {}",
|
|
||||||
quote_literal(db_user),
|
|
||||||
quote_literal(password).as_str()
|
|
||||||
)
|
|
||||||
.as_str(),
|
|
||||||
)
|
|
||||||
.execute(&mut *connection)
|
|
||||||
.await
|
|
||||||
.map(|_| ())
|
|
||||||
.map_err(|err| SetPasswordError::MySqlError(err.to_string()));
|
|
||||||
|
|
||||||
if let Err(err) = &result {
|
|
||||||
log::error!(
|
|
||||||
"Failed to set password for database user '{}': {:?}",
|
|
||||||
&db_user,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: this function is unsafe because it does no input validation.
|
|
||||||
async fn database_user_is_locked_unsafe(
|
|
||||||
db_user: &str,
|
|
||||||
connection: &mut MySqlConnection,
|
|
||||||
) -> Result<bool, sqlx::Error> {
|
|
||||||
let result = sqlx::query(
|
|
||||||
r#"
|
|
||||||
SELECT COALESCE(
|
|
||||||
JSON_EXTRACT(`mysql`.`global_priv`.`priv`, "$.account_locked"),
|
|
||||||
'false'
|
|
||||||
) != 'false'
|
|
||||||
FROM `mysql`.`global_priv`
|
|
||||||
WHERE `User` = ?
|
|
||||||
AND `Host` = '%'
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(db_user)
|
|
||||||
.fetch_one(connection)
|
|
||||||
.await
|
|
||||||
.map(|row| row.get::<bool, _>(0));
|
|
||||||
|
|
||||||
if let Err(err) = &result {
|
|
||||||
log::error!(
|
|
||||||
"Failed to check if database user is locked '{}': {:?}",
|
|
||||||
&db_user,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn lock_database_users(
|
|
||||||
db_users: Vec<String>,
|
|
||||||
unix_user: &UnixUser,
|
|
||||||
connection: &mut MySqlConnection,
|
|
||||||
) -> LockUsersOutput {
|
|
||||||
let mut results = BTreeMap::new();
|
|
||||||
|
|
||||||
for db_user in db_users {
|
|
||||||
if let Err(err) = validate_name(&db_user) {
|
|
||||||
results.insert(db_user, Err(LockUserError::SanitizationError(err)));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(err) = validate_ownership_by_unix_user(&db_user, unix_user) {
|
|
||||||
results.insert(db_user, Err(LockUserError::OwnershipError(err)));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
match unsafe_user_exists(&db_user, &mut *connection).await {
|
|
||||||
Ok(true) => {}
|
|
||||||
Ok(false) => {
|
|
||||||
results.insert(db_user, Err(LockUserError::UserDoesNotExist));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
results.insert(db_user, Err(LockUserError::MySqlError(err.to_string())));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match database_user_is_locked_unsafe(&db_user, &mut *connection).await {
|
|
||||||
Ok(false) => {}
|
|
||||||
Ok(true) => {
|
|
||||||
results.insert(db_user, Err(LockUserError::UserIsAlreadyLocked));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
results.insert(db_user, Err(LockUserError::MySqlError(err.to_string())));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = sqlx::query(
|
|
||||||
format!("ALTER USER {}@'%' ACCOUNT LOCK", quote_literal(&db_user),).as_str(),
|
|
||||||
)
|
|
||||||
.execute(&mut *connection)
|
|
||||||
.await
|
|
||||||
.map(|_| ())
|
|
||||||
.map_err(|err| LockUserError::MySqlError(err.to_string()));
|
|
||||||
|
|
||||||
if let Err(err) = &result {
|
|
||||||
log::error!("Failed to lock database user '{}': {:?}", &db_user, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
results.insert(db_user, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
results
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn unlock_database_users(
|
|
||||||
db_users: Vec<String>,
|
|
||||||
unix_user: &UnixUser,
|
|
||||||
connection: &mut MySqlConnection,
|
|
||||||
) -> UnlockUsersOutput {
|
|
||||||
let mut results = BTreeMap::new();
|
|
||||||
|
|
||||||
for db_user in db_users {
|
|
||||||
if let Err(err) = validate_name(&db_user) {
|
|
||||||
results.insert(db_user, Err(UnlockUserError::SanitizationError(err)));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(err) = validate_ownership_by_unix_user(&db_user, unix_user) {
|
|
||||||
results.insert(db_user, Err(UnlockUserError::OwnershipError(err)));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
match unsafe_user_exists(&db_user, &mut *connection).await {
|
|
||||||
Ok(false) => {
|
|
||||||
results.insert(db_user, Err(UnlockUserError::UserDoesNotExist));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
results.insert(db_user, Err(UnlockUserError::MySqlError(err.to_string())));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
match database_user_is_locked_unsafe(&db_user, &mut *connection).await {
|
|
||||||
Ok(false) => {
|
|
||||||
results.insert(db_user, Err(UnlockUserError::UserIsAlreadyUnlocked));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
results.insert(db_user, Err(UnlockUserError::MySqlError(err.to_string())));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = sqlx::query(
|
|
||||||
format!("ALTER USER {}@'%' ACCOUNT UNLOCK", quote_literal(&db_user),).as_str(),
|
|
||||||
)
|
|
||||||
.execute(&mut *connection)
|
|
||||||
.await
|
|
||||||
.map(|_| ())
|
|
||||||
.map_err(|err| UnlockUserError::MySqlError(err.to_string()));
|
|
||||||
|
|
||||||
if let Err(err) = &result {
|
|
||||||
log::error!("Failed to unlock database user '{}': {:?}", &db_user, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
results.insert(db_user, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
results
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This struct contains information about a database user.
|
|
||||||
/// This can be extended if we need more information in the future.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct DatabaseUser {
|
|
||||||
pub user: String,
|
|
||||||
#[serde(skip)]
|
|
||||||
pub host: String,
|
|
||||||
pub has_password: bool,
|
|
||||||
pub is_locked: bool,
|
|
||||||
pub databases: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromRow<'_, sqlx::mysql::MySqlRow> for DatabaseUser {
|
|
||||||
fn from_row(row: &sqlx::mysql::MySqlRow) -> Result<Self, sqlx::Error> {
|
|
||||||
Ok(Self {
|
|
||||||
user: try_get_with_binary_fallback(row, "User")?,
|
|
||||||
host: try_get_with_binary_fallback(row, "Host")?,
|
|
||||||
has_password: row.try_get("has_password")?,
|
|
||||||
is_locked: row.try_get("is_locked")?,
|
|
||||||
databases: Vec::new(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const DB_USER_SELECT_STATEMENT: &str = r#"
|
|
||||||
SELECT
|
|
||||||
`user`.`User`,
|
|
||||||
`user`.`Host`,
|
|
||||||
`user`.`Password` != '' OR `user`.`authentication_string` != '' AS `has_password`,
|
|
||||||
COALESCE(
|
|
||||||
JSON_EXTRACT(`global_priv`.`priv`, "$.account_locked"),
|
|
||||||
'false'
|
|
||||||
) != 'false' AS `is_locked`
|
|
||||||
FROM `user`
|
|
||||||
JOIN `global_priv` ON
|
|
||||||
`user`.`User` = `global_priv`.`User`
|
|
||||||
AND `user`.`Host` = `global_priv`.`Host`
|
|
||||||
"#;
|
|
||||||
|
|
||||||
pub async fn list_database_users(
|
|
||||||
db_users: Vec<String>,
|
|
||||||
unix_user: &UnixUser,
|
|
||||||
connection: &mut MySqlConnection,
|
|
||||||
) -> ListUsersOutput {
|
|
||||||
let mut results = BTreeMap::new();
|
|
||||||
|
|
||||||
for db_user in db_users {
|
|
||||||
if let Err(err) = validate_name(&db_user) {
|
|
||||||
results.insert(db_user, Err(ListUsersError::SanitizationError(err)));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(err) = validate_ownership_by_unix_user(&db_user, unix_user) {
|
|
||||||
results.insert(db_user, Err(ListUsersError::OwnershipError(err)));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut result = sqlx::query_as::<_, DatabaseUser>(
|
|
||||||
&(DB_USER_SELECT_STATEMENT.to_string() + "WHERE `mysql`.`user`.`User` = ?"),
|
|
||||||
)
|
|
||||||
.bind(&db_user)
|
|
||||||
.fetch_optional(&mut *connection)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Err(err) = &result {
|
|
||||||
log::error!("Failed to list database user '{}': {:?}", &db_user, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(Some(user)) = result.as_mut() {
|
|
||||||
append_databases_where_user_has_privileges(user, &mut *connection).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(Some(user)) => results.insert(db_user, Ok(user)),
|
|
||||||
Ok(None) => results.insert(db_user, Err(ListUsersError::UserDoesNotExist)),
|
|
||||||
Err(err) => results.insert(db_user, Err(ListUsersError::MySqlError(err.to_string()))),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
results
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_all_database_users_for_unix_user(
|
|
||||||
unix_user: &UnixUser,
|
|
||||||
connection: &mut MySqlConnection,
|
|
||||||
) -> ListAllUsersOutput {
|
|
||||||
let mut result = sqlx::query_as::<_, DatabaseUser>(
|
|
||||||
&(DB_USER_SELECT_STATEMENT.to_string() + "WHERE `user`.`User` REGEXP ?"),
|
|
||||||
)
|
|
||||||
.bind(create_user_group_matching_regex(unix_user))
|
|
||||||
.fetch_all(&mut *connection)
|
|
||||||
.await
|
|
||||||
.map_err(|err| ListAllUsersError::MySqlError(err.to_string()));
|
|
||||||
|
|
||||||
if let Err(err) = &result {
|
|
||||||
log::error!("Failed to list all database users: {:?}", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(users) = result.as_mut() {
|
|
||||||
for user in users {
|
|
||||||
append_databases_where_user_has_privileges(user, &mut *connection).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn append_databases_where_user_has_privileges(
|
|
||||||
db_user: &mut DatabaseUser,
|
|
||||||
connection: &mut MySqlConnection,
|
|
||||||
) {
|
|
||||||
let database_list = 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(db_user.user.clone())
|
|
||||||
.fetch_all(&mut *connection)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Err(err) = &database_list {
|
|
||||||
log::error!(
|
|
||||||
"Failed to list databases for user '{}': {:?}",
|
|
||||||
&db_user.user,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
db_user.databases = database_list
|
|
||||||
.map(|rows| {
|
|
||||||
rows.into_iter()
|
|
||||||
.map(|row| try_get_with_binary_fallback(&row, "database").unwrap())
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
}
|
|
Loading…
Reference in New Issue