Compare commits
31 Commits
debian-vm-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
340428c158
|
|||
|
dfe20826c1
|
|||
|
97908ce887
|
|||
|
10ef171c91
|
|||
|
043a1a7a7a
|
|||
|
072bf6a090
|
|||
|
21bb5b62ff
|
|||
|
62b2b30f94
|
|||
|
05b5b5dac0
|
|||
|
d814008006
|
|||
|
67c8e3330c
|
|||
|
57ac26b120
|
|||
|
256c1d1176
|
|||
|
15c8d82373
|
|||
|
e6bcac8079
|
|||
|
146421dd79
|
|||
|
795c6d3c9d
|
|||
|
40ce292083
|
|||
|
ca6ae43bbc
|
|||
|
996c9e50c7
|
|||
|
d0e226bff3
|
|||
|
cc1d8b0cf1
|
|||
|
8b4d549e18
|
|||
|
45cefb8af4
|
|||
|
891963f4bc
|
|||
|
912f0e8971
|
|||
|
73f5cd9fd4
|
|||
|
caf16c7a21
|
|||
|
aac7315fd9
|
|||
|
aa96587a35
|
|||
|
15ebc5df5b
|
@@ -6,6 +6,9 @@ on:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
BINSTALL_DISABLE_TELEMETRY: 'true'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: debian-latest
|
||||
@@ -13,10 +16,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Build
|
||||
run: cargo build --all-features --verbose --release
|
||||
@@ -27,10 +27,8 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Check code format
|
||||
@@ -43,15 +41,13 @@ jobs:
|
||||
runs-on: debian-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: cargo-bins/cargo-binstall@main
|
||||
|
||||
- name: Install rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install cargo-deny
|
||||
run: cargo install cargo-deny
|
||||
run: cargo binstall -y cargo-deny
|
||||
|
||||
- name: Check licenses
|
||||
run: |
|
||||
@@ -72,8 +68,7 @@ jobs:
|
||||
run: cargo binstall -y cargo-nextest --secure
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cargo nextest run --release --no-fail-fast
|
||||
run: cargo nextest run --release --no-fail-fast
|
||||
env:
|
||||
RUST_LOG: "trace"
|
||||
RUSTFLAGS: "-Cinstrument-coverage"
|
||||
@@ -116,10 +111,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Build docs
|
||||
run: cargo doc --all-features --document-private-items --release
|
||||
|
||||
@@ -22,6 +22,9 @@ on:
|
||||
- beta
|
||||
default: stable
|
||||
|
||||
env:
|
||||
BINSTALL_DISABLE_TELEMETRY: 'true'
|
||||
|
||||
# TODO: dynamic matrix builds when...
|
||||
# https://github.com/go-gitea/gitea/issues/25179
|
||||
jobs:
|
||||
@@ -33,15 +36,15 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: cargo-bins/cargo-binstall@main
|
||||
|
||||
- name: Install rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ inputs.rust_toolchain }}
|
||||
override: true
|
||||
|
||||
- name: Install cargo-deb
|
||||
run: cargo install cargo-deb
|
||||
run: cargo binstall -y cargo-deb
|
||||
|
||||
- name: Build deb package
|
||||
env:
|
||||
@@ -55,12 +58,12 @@ jobs:
|
||||
CREATE_DEB_ARGS+=("--deb-version" "${{ inputs.deb_version }}")
|
||||
fi
|
||||
|
||||
./create-deb.sh "${CREATE_DEB_ARGS[@]}"
|
||||
./scripts/create-deb.sh "${CREATE_DEB_ARGS[@]}"
|
||||
|
||||
- name: Upload deb package artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: muscl-deb-${{ matrix.os }}.zip
|
||||
name: muscl-deb-${{ matrix.os }}-${{ gitea.sha }}.zip
|
||||
path: target/debian/*.deb
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,7 +9,6 @@ result-*
|
||||
|
||||
# Nix VM
|
||||
*.qcow2
|
||||
.nixos-test-history
|
||||
|
||||
# Packaging
|
||||
!/assets/debian/config.toml
|
||||
|
||||
48
Cargo.lock
generated
48
Cargo.lock
generated
@@ -267,6 +267,27 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
||||
|
||||
[[package]]
|
||||
name = "color-print"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3aa954171903797d5623e047d9ab69d91b493657917bdfb8c2c80ecaf9cdb6f4"
|
||||
dependencies = [
|
||||
"color-print-proc-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color-print-proc-macro"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "692186b5ebe54007e45a59aea47ece9eb4108e141326c304cdc91699a7118a22"
|
||||
dependencies = [
|
||||
"nom",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.4"
|
||||
@@ -840,6 +861,15 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "humansize"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.1.1"
|
||||
@@ -1151,6 +1181,12 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
@@ -1172,11 +1208,13 @@ dependencies = [
|
||||
"clap",
|
||||
"clap-verbosity-flag",
|
||||
"clap_complete",
|
||||
"color-print",
|
||||
"const_format",
|
||||
"derive_more",
|
||||
"dialoguer",
|
||||
"futures-util",
|
||||
"git2",
|
||||
"humansize",
|
||||
"indoc",
|
||||
"itertools",
|
||||
"landlock",
|
||||
@@ -1214,6 +1252,16 @@ dependencies = [
|
||||
"memoffset",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
|
||||
21
Cargo.toml
21
Cargo.toml
@@ -24,17 +24,18 @@ bincode = "2.0.1"
|
||||
clap = { version = "4.5.53", features = ["cargo", "derive"] }
|
||||
clap-verbosity-flag = { version = "3.0.4", features = [ "tracing" ] }
|
||||
clap_complete = { version = "4.5.61", features = ["unstable-dynamic"] }
|
||||
color-print = "0.3.7"
|
||||
const_format = "0.2.35"
|
||||
derive_more = { version = "2.1.0", features = ["display", "error"] }
|
||||
dialoguer = "0.12.0"
|
||||
futures-util = "0.3.31"
|
||||
humansize = "2.1.3"
|
||||
indoc = "2.0.7"
|
||||
itertools = "0.14.0"
|
||||
nix = { version = "0.30.1", features = ["fs", "process", "socket", "user"] }
|
||||
num_cpus = "1.17.0"
|
||||
prettytable = "0.10.0"
|
||||
rand = "0.9.2"
|
||||
sd-notify = "0.4.5"
|
||||
serde = "1.0.228"
|
||||
serde_json = { version = "1.0.145", features = ["preserve_order"] }
|
||||
sqlx = { version = "0.8.6", features = ["runtime-tokio", "mysql", "tls-rustls"] }
|
||||
@@ -45,12 +46,13 @@ tokio-stream = "0.1.17"
|
||||
tokio-util = { version = "0.7.17", features = ["codec", "rt"] }
|
||||
toml = "0.9.8"
|
||||
tracing = { version = "0.1.43", features = ["log"] }
|
||||
tracing-journald = "0.3.2"
|
||||
tracing-subscriber = "0.3.22"
|
||||
uuid = { version = "1.19.0", features = ["v4"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
landlock = "0.4.4"
|
||||
sd-notify = "0.4.5"
|
||||
tracing-journald = "0.3.2"
|
||||
|
||||
[build-dependencies]
|
||||
anyhow = "1.0.100"
|
||||
@@ -132,6 +134,11 @@ assets = [
|
||||
"etc/muscl/config.toml",
|
||||
"644",
|
||||
],
|
||||
[
|
||||
"assets/debian/group_denylist.txt",
|
||||
"etc/muscl/group_denylist.txt",
|
||||
"644",
|
||||
],
|
||||
[
|
||||
"assets/completions/_*",
|
||||
"usr/share/zsh/site-functions/completions/",
|
||||
@@ -147,6 +154,16 @@ assets = [
|
||||
"usr/share/fish/vendor_completions.d/",
|
||||
"644",
|
||||
],
|
||||
[
|
||||
"README.md",
|
||||
"usr/share/doc/muscl/",
|
||||
"644",
|
||||
],
|
||||
[
|
||||
"docs/*.md",
|
||||
"usr/share/doc/muscl/docs/",
|
||||
"644",
|
||||
],
|
||||
]
|
||||
preserve-symlinks = true
|
||||
maintainer-scripts = "debian/"
|
||||
|
||||
@@ -28,6 +28,7 @@ This software is designed for multi-user servers, like tilde servers, university
|
||||
|
||||
- [Installation and configuration](docs/installation.md)
|
||||
- [Development and testing](docs/development.md)
|
||||
- [Compiling and packaging](docs/compiling.md)
|
||||
- [Compatibility mode with mysql-admutils](docs/mysql-admutils-compatibility.md)
|
||||
- [Use with NixOS](docs/nixos.md)
|
||||
- [SUID/SGID mode](docs/suid-sgid-mode.md)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
[authorization]
|
||||
group_denylist_file = "/etc/muscl/group_denylist.txt"
|
||||
|
||||
[mysql]
|
||||
# Hostname and port of the database.
|
||||
host = "localhost"
|
||||
@@ -16,7 +19,8 @@ port = 3306
|
||||
# systemd unit.
|
||||
username = "muscl"
|
||||
# This file gets created by systemd automatically, given you have set
|
||||
# the password with `systemd-creds`.
|
||||
# the password with `systemd-creds`. See /usr/share/doc/muscl/docs/installation.md
|
||||
# for more information.
|
||||
password_file = "/run/credentials/muscl.service/muscl_mysql_password"
|
||||
|
||||
# Database connection timeout in seconds
|
||||
|
||||
56
assets/debian/group_denylist.txt
Normal file
56
assets/debian/group_denylist.txt
Normal file
@@ -0,0 +1,56 @@
|
||||
# These are the default system groups on debian.
|
||||
# You can alos add groups by gid by prefixing the line with 'gid:'.
|
||||
|
||||
group:adm
|
||||
group:audio
|
||||
group:avahi
|
||||
group:backup
|
||||
group:bin
|
||||
group:cdrom
|
||||
group:crontab
|
||||
group:daemon
|
||||
group:dialout
|
||||
group:dip
|
||||
group:disk
|
||||
group:fax
|
||||
group:floppy
|
||||
group:games
|
||||
group:gnats
|
||||
group:input
|
||||
group:irc
|
||||
group:kmem
|
||||
group:kvm
|
||||
group:list
|
||||
group:lp
|
||||
group:mail
|
||||
group:man
|
||||
group:mlocate
|
||||
group:netdev
|
||||
group:news
|
||||
group:nogroup
|
||||
group:openldap
|
||||
group:operator
|
||||
group:plocate
|
||||
group:plugdev
|
||||
group:polkitd
|
||||
group:proxy
|
||||
group:render
|
||||
group:root
|
||||
group:sasl
|
||||
group:shadow
|
||||
group:src
|
||||
group:staff
|
||||
group:sudo
|
||||
group:sys
|
||||
group:systemd-journal
|
||||
group:systemd-network
|
||||
group:systemd-resolve
|
||||
group:systemd-timesync
|
||||
group:tape
|
||||
group:tty
|
||||
group:users
|
||||
group:utmp
|
||||
group:uucp
|
||||
group:video
|
||||
group:voice
|
||||
group:www-data
|
||||
@@ -7,6 +7,9 @@
|
||||
# (see `systemctl status muscl.socket`)
|
||||
socket_path = "/run/muscl/muscl.sock"
|
||||
|
||||
[authorization]
|
||||
group_denylist_file = "/etc/muscl/group_denylist.txt"
|
||||
|
||||
[mysql]
|
||||
|
||||
# Hostname and port of the database.
|
||||
@@ -1,6 +1,7 @@
|
||||
[Unit]
|
||||
Description=Muscl MySQL admin tool
|
||||
Requires=muscl.socket
|
||||
After=mysql.service mariadb.service
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
|
||||
75
docs/compiling.md
Normal file
75
docs/compiling.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Compiling and packaging
|
||||
|
||||
This document describes how to compile `muscl` from source code, along with other related tasks.
|
||||
|
||||
## Build
|
||||
|
||||
To just compile muscl, there is not many special steps needed. You need to have a working Rust toolchain installed.
|
||||
|
||||
```bash
|
||||
# Compile in debug mode
|
||||
cargo build
|
||||
ls target/debug # muscl, mysql-dbadm, mysql-useradm, ...
|
||||
|
||||
# Compile in release mode
|
||||
cargo build --release
|
||||
ls target/release # muscl, mysql-dbadm, mysql-useradm, ...
|
||||
|
||||
# Compile in release mode with link time optimization (only used for distribution builds)
|
||||
cargo build --profile release-lto
|
||||
ls target/release-lto # muscl, mysql-dbadm, mysql-useradm, ...
|
||||
```
|
||||
|
||||
## Generating completions
|
||||
|
||||
> [!NOTE]
|
||||
> This happens automatically when building the deb package, so you can skip this step if that's the goal.
|
||||
|
||||
In order to generate shell completions that work correctly, you want to put `muscl` (or alias symlinks) in your `$PATH`.
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
(
|
||||
PATH="$(pwd)/target/release:$PATH"
|
||||
mkdir -p completions/bash
|
||||
mkdir -p completions/zsh
|
||||
mkdir -p completions/fish
|
||||
|
||||
muscl completions --shell bash > completions/bash/muscl.bash
|
||||
muscl completions --shell zsh > completions/zsh/_muscl
|
||||
muscl completions --shell fish > completions/fish/muscl.fish
|
||||
)
|
||||
```
|
||||
|
||||
Due to a [bug in clap](https://github.com/clap-rs/clap/issues/1764), you will also need to edit the completion files for the aliases.
|
||||
|
||||
```bash
|
||||
sed -i 's/muscl/mysql-dbadm/g' assets/completions/{mysql-dbadm.bash,mysql-dbadm.fish,_mysql-dbadm}
|
||||
sed -i 's/muscl/mysql-useradm/g' assets/completions/{mysql-useradm.bash,mysql-useradm.fish,_mysql-useradm}
|
||||
```
|
||||
|
||||
## Bundling into a deb package
|
||||
|
||||
We have a script that automates the process of building a deb package for Debian-based systems.
|
||||
|
||||
Before running this, you will need to install `cargo-deb` and make sure you have `dpkg-deb` available on your system.
|
||||
|
||||
```bash
|
||||
# Install cargo-deb if you don't have it already
|
||||
cargo install cargo-deb
|
||||
|
||||
# Run the script to create the deb package
|
||||
./scripts/create-deb.sh
|
||||
|
||||
# Inspect the resulting deb package
|
||||
dpkg --contents target/debian/muscl_*.deb
|
||||
dpkg --info target/debian/muscl_*.deb
|
||||
```
|
||||
|
||||
The program will be built with the `release-lto` profile, so it can be a bit slower to build than a normal build.
|
||||
|
||||
## Compiling with CI
|
||||
|
||||
We have a pipeline that builds the deb package for a set of different distributions.
|
||||
|
||||
If you have access, you can trigger a build manually here: https://git.pvv.ntnu.no/Projects/muscl/actions?workflow=publish-deb.yml
|
||||
@@ -13,7 +13,7 @@ This will start a mariadb instance with the root password `secret`, and expose t
|
||||
Run the following command to create a configuration file with the default settings:
|
||||
|
||||
```bash
|
||||
cp ./example-config.toml ./config.toml
|
||||
cp ./assets/example-config.toml ./config.toml
|
||||
```
|
||||
|
||||
If you used the docker command above, you can use these settings as is, but if you are running mariadb/mysql on another host, port or with another password, adjust the corresponding fields in `config.toml`.
|
||||
|
||||
@@ -16,7 +16,7 @@ sudo -i
|
||||
VERSION_CODENAME=$(lsb_release -cs)
|
||||
|
||||
# Add the repository
|
||||
echo "deb [signed-by=/etc/apt/keyrings/pvvgit-projects.asc] https://git.pvv.ntnu.no/api/packages/Projects/debian $VERSION_CODENAME main" | tee -a /etc/apt/sources.list.d/gitea.list
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/pvvgit-projects.asc] https://git.pvv.ntnu.no/api/packages/Projects/debian $VERSION_CODENAME main" | tee -a /etc/apt/sources.list.d/gitea.list
|
||||
|
||||
# Pull the repository key
|
||||
curl https://git.pvv.ntnu.no/api/packages/Projects/debian/repository.key -o /etc/apt/keyrings/pvvgit-projects.asc
|
||||
@@ -41,9 +41,16 @@ GRANT GRANT OPTION, CREATE, DROP ON *.* TO 'muscl'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
Now you should add the login credentials to the muscl configuration file, typically located at `/etc/muscl/config.toml`.
|
||||
Make sure to remember the username and password, as we will now need to add them to the muscl configuration.
|
||||
|
||||
## Setting the myscl password with `systemd-creds`
|
||||
The configuration already comes preconfigured expecting the database user to be named `muscl`.
|
||||
If you named it differently, please edit `/etc/muscl/muscl.conf` accordingly.
|
||||
|
||||
For systemd-based setups, we recommend using `systemd-creds` to provide the database password, see the section below.
|
||||
|
||||
## Setting the myscl password ...
|
||||
|
||||
### ... with `systemd-creds`
|
||||
|
||||
The debian package assumes that you will provide the password for `muscl`'s database user with `systemd-creds`.
|
||||
|
||||
@@ -72,6 +79,41 @@ If you are running systemd older than version 254 (see `systemctl --version`), y
|
||||
LoadCredentialEncrypted=muscl_mysql_password:/etc/credstore.encrypted/muscl_mysql_password
|
||||
```
|
||||
|
||||
### ... without `systemd-creds`
|
||||
|
||||
If you do not have systemd, or if you do not want to use `systemd-creds`, you can also set the password in any other file on the system.
|
||||
Be careful to ensure that the file is not readable by unprivileged users, as it would yield them too much access to the mysql server.
|
||||
Edit `/etc/muscl/muscl.conf` and set the `mysql_password_file` option below `[database]` to point to the file containing the password.
|
||||
|
||||
If you are using systemd, you should also create an override to unset the `ImportCredential=` line. Run `systemctl edit muscl.service` and add the following lines:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
ImportCredential=
|
||||
```
|
||||
|
||||
## Configuring group denylists
|
||||
|
||||
In `/etc/muscl/muscl.conf`, you will find an option below `[authorization]` named `group_denylist_file`,
|
||||
which points to `/etc/muscl/group_denylist.txt` by default.
|
||||
|
||||
In this file, you can add unix group names or GIDs to disallow the groups from being used as prefixes.
|
||||
|
||||
The deb package comes with a default denylist that disallows some common system groups.
|
||||
|
||||
The format of the file is one group name or GID per line. Lines starting with `#` and empty lines are ignored.
|
||||
|
||||
```
|
||||
# Disallow using the 'root' group as a prefix
|
||||
gid:0
|
||||
|
||||
# Disallow using the 'adm' group as a prefix
|
||||
group:adm
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> If a user is named the same as a disallowed group, that user will still be able to use their username as a prefix.
|
||||
|
||||
## A note on minimum version requirements
|
||||
|
||||
The muscl server will work with older versions of systemd, but the recommended version is 254 or newer.
|
||||
|
||||
21
flake.lock
generated
21
flake.lock
generated
@@ -15,26 +15,6 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-vm-test": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1763976673,
|
||||
"narHash": "sha256-QPeI8WR+brwodiy4YNfOnLI7rOHJfFPrGm+xT/HmtT4=",
|
||||
"owner": "numtide",
|
||||
"repo": "nix-vm-test",
|
||||
"rev": "8611bdd7a49750a880be9ee2ea9f68c53f8c9299",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "nix-vm-test",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1765472234,
|
||||
@@ -54,7 +34,6 @@
|
||||
"root": {
|
||||
"inputs": {
|
||||
"crane": "crane",
|
||||
"nix-vm-test": "nix-vm-test",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
|
||||
@@ -6,12 +6,9 @@
|
||||
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
||||
|
||||
crane.url = "github:ipetkov/crane";
|
||||
|
||||
nix-vm-test.url = "github:numtide/nix-vm-test";
|
||||
nix-vm-test.inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, rust-overlay, crane, nix-vm-test }:
|
||||
outputs = { self, nixpkgs, rust-overlay, crane }:
|
||||
let
|
||||
inherit (nixpkgs) lib;
|
||||
|
||||
@@ -98,8 +95,6 @@
|
||||
muscl = import ./nix/module.nix;
|
||||
};
|
||||
|
||||
# vmlib = forAllSystems(system: _: _: nix-vm-test.lib.${system});
|
||||
|
||||
packages = forAllSystems (system: pkgs: _:
|
||||
let
|
||||
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
|
||||
@@ -135,8 +130,6 @@
|
||||
filteredSource = pkgs.runCommandLocal "filtered-source" { } ''
|
||||
ln -s ${src} $out
|
||||
'';
|
||||
|
||||
debianVm = import ./nix/debian-vm-configuration.nix { inherit nix-vm-test nixpkgs system pkgs; };
|
||||
});
|
||||
|
||||
checks = forAllSystems (system: pkgs: _: {
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
{ nix-vm-test, nixpkgs, system, pkgs, ... }:
|
||||
let
|
||||
image = nix-vm-test.lib.${system}.debian.images."13";
|
||||
|
||||
generic = import "${nix-vm-test}/generic" { inherit pkgs nixpkgs; inherit (pkgs) lib; };
|
||||
|
||||
makeVmTestForImage =
|
||||
image:
|
||||
{
|
||||
testScript,
|
||||
sharedDirs ? {},
|
||||
diskSize ? null,
|
||||
config ? { }
|
||||
}:
|
||||
generic.makeVmTest {
|
||||
inherit
|
||||
system
|
||||
testScript
|
||||
sharedDirs;
|
||||
image = nix-vm-test.lib.${system}.debian.prepareDebianImage {
|
||||
inherit diskSize;
|
||||
hostPkgs = pkgs;
|
||||
originalImage = image;
|
||||
};
|
||||
machineConfigModule = config;
|
||||
};
|
||||
|
||||
vmTest = makeVmTestForImage image {
|
||||
diskSize = "10G";
|
||||
sharedDirs = {
|
||||
debDir = {
|
||||
source = "${./.}";
|
||||
target = "/mnt";
|
||||
};
|
||||
};
|
||||
testScript = ''
|
||||
vm.wait_for_unit("multi-user.target")
|
||||
vm.succeed("apt-get update && apt-get -y install mariadb-server build-essential curl")
|
||||
vm.succeed("curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y")
|
||||
vm.succeed("source /root/.cargo/env && cargo install cargo-deb")
|
||||
vm.succeed("cp -r /mnt /root/src && chmod -R +w /root/src")
|
||||
vm.succeed("source /root/.cargo/env && cd /root/src && ./create-deb.sh")
|
||||
'';
|
||||
config.nodes.vm = {
|
||||
virtualisation.memorySize = 8192;
|
||||
virtualisation.cpus = 4;
|
||||
};
|
||||
};
|
||||
in vmTest.driverInteractive
|
||||
@@ -40,6 +40,14 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
authorization = {
|
||||
group_denylist = lib.mkOption {
|
||||
type = with lib.types; nullOr (listOf str);
|
||||
default = [ "wheel" ];
|
||||
description = "List of groups that are denied access";
|
||||
};
|
||||
};
|
||||
|
||||
mysql = {
|
||||
socket_path = lib.mkOption {
|
||||
type = with lib.types; nullOr path;
|
||||
@@ -81,6 +89,12 @@ in
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
|
||||
environment.etc."muscl/config.toml".source = lib.pipe cfg.settings [
|
||||
# Handle group_denylist_file
|
||||
(conf: lib.recursiveUpdate conf {
|
||||
authorization.group_denylist_file = if (conf.authorization.group_denylist != [ ]) then "/etc/muscl/group-denylist" else null;
|
||||
authorization.group_denylist = null;
|
||||
})
|
||||
|
||||
# Remove nulls
|
||||
(lib.filterAttrsRecursive (_: v: v != null))
|
||||
|
||||
@@ -95,6 +109,10 @@ in
|
||||
(format.generate "muscl.conf")
|
||||
];
|
||||
|
||||
environment.etc."muscl/group-denylist" = lib.mkIf (cfg.settings.authorization.group_denylist != [ ]) {
|
||||
text = lib.concatMapStringsSep "\n" (group: "group:${group}") cfg.settings.authorization.group_denylist;
|
||||
};
|
||||
|
||||
services.mysql.ensureUsers = lib.mkIf cfg.createLocalDatabaseUser [
|
||||
{
|
||||
name = cfg.settings.mysql.username;
|
||||
|
||||
69
scripts/download-and-upload-debs.sh
Executable file
69
scripts/download-and-upload-debs.sh
Executable file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${CREATE_DEB_DEBUG:-}" == "1" ]]; then
|
||||
set -x
|
||||
fi
|
||||
|
||||
declare -a COMMANDS=(
|
||||
curl
|
||||
unzip
|
||||
mktemp
|
||||
find
|
||||
)
|
||||
|
||||
for cmd in "${COMMANDS[@]}"; do
|
||||
if ! command -v "$cmd" &> /dev/null; then
|
||||
echo "$cmd could not be found" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$#" -ne 2 ]; then
|
||||
echo "Usage: $0 <gitea-run-number> <git-sha>" >&2
|
||||
echo "Example:" >&2
|
||||
echo " GITEA_USER=me GITEA_TOKEN=secret ./scripts/download-and-upload-debs.sh 123 \$(git rev-parse HEAD)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${GITEA_USER:-}" ]; then
|
||||
echo "GITEA_USER is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
echo "GITEA_TOKEN is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
declare -r RUN_NUMBER="$1"
|
||||
declare -r GIT_SHA="$2"
|
||||
|
||||
TMPDIR="$(mktemp -d)"
|
||||
|
||||
for variant in debian-bookworm debian-trixie ubuntu-jammy ubuntu-noble; do
|
||||
echo "Downloading and uploading debs for variant: $variant"
|
||||
curl "https://git.pvv.ntnu.no/Projects/muscl/actions/runs/$RUN_NUMBER/artifacts/muscl-deb-$variant-$GIT_SHA.zip" --output "$TMPDIR/muscl-deb-$variant-$GIT_SHA.zip"
|
||||
|
||||
unzip "$TMPDIR/muscl-deb-$variant-$GIT_SHA.zip" -d "$TMPDIR/muscl-deb-$variant-$GIT_SHA"
|
||||
|
||||
DISTRO_VERSION_NAME="$(echo "$variant" | cut -d'-' -f2)"
|
||||
|
||||
DEB_NAME=$(find "$TMPDIR/muscl-deb-$variant-$GIT_SHA"/*.deb -print0 | xargs -0 -n1 basename | cut -d'_' -f1 | head -n1)
|
||||
DEB_VERSION=$(find "$TMPDIR/muscl-deb-$variant-$GIT_SHA"/*.deb -print0 | xargs -0 -n1 basename | cut -d'_' -f2 | head -n1)
|
||||
DEB_ARCH=$(find "$TMPDIR/muscl-deb-$variant-$GIT_SHA"/*.deb -print0 | xargs -0 -n1 basename | cut -d'_' -f3 | cut -d'.' -f1 | head -n1)
|
||||
|
||||
curl \
|
||||
-X DELETE \
|
||||
--user "$GITEA_USER:$GITEA_TOKEN" \
|
||||
"https://git.pvv.ntnu.no/api/packages/Projects/debian/pool/$DISTRO_VERSION_NAME/main/$DEB_NAME/$DEB_VERSION/$DEB_ARCH"
|
||||
|
||||
curl \
|
||||
-X PUT \
|
||||
--user "$GITEA_USER:$GITEA_TOKEN" \
|
||||
--upload-file "$TMPDIR/muscl-deb-$variant-$GIT_SHA/${DEB_NAME}_${DEB_VERSION}_${DEB_ARCH}.deb" \
|
||||
"https://git.pvv.ntnu.no/api/packages/Projects/debian/pool/$DISTRO_VERSION_NAME/main/upload"
|
||||
done
|
||||
|
||||
rm -rf "$TMPDIR"
|
||||
@@ -25,8 +25,31 @@ pub use show_user::*;
|
||||
pub use unlock_user::*;
|
||||
|
||||
use clap::Subcommand;
|
||||
use futures_util::SinkExt;
|
||||
use itertools::Itertools;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::core::protocol::{ClientToServerMessageStream, Response};
|
||||
use crate::core::protocol::{ClientToServerMessageStream, Request, Response};
|
||||
|
||||
const EDIT_PRIVS_EXAMPLES: &str = color_print::cstr!(
|
||||
r#"
|
||||
<bold><underline>Examples:</underline></bold>
|
||||
# Open interactive editor to edit privileges
|
||||
muscl edit-privs
|
||||
|
||||
# Set privileges `SELECT`, `INSERT`, and `UPDATE` for user `my_user` on database `my_db`
|
||||
muscl edit-privs my_db my_user siu
|
||||
|
||||
# Set all privileges for user `my_other_user` on database `my_other_db`
|
||||
muscl edit-privs my_other_db my_other_user A
|
||||
|
||||
# Add the `DELETE` privilege for user `my_user` on database `my_db`
|
||||
muscl edit-privs my_db my_user +d
|
||||
|
||||
# Set miscellaneous privileges for multiple users on database `my_db`
|
||||
muscl edit-privs -p my_db:my_user:siu -p my_db:my_other_user:+ct -p my_db:yet_another_user:-d
|
||||
"#
|
||||
);
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
#[command(subcommand_required = true)]
|
||||
@@ -52,23 +75,28 @@ pub enum ClientCommand {
|
||||
|
||||
/// Change user privileges for one or more databases. See `edit-privs --help` for details.
|
||||
///
|
||||
/// This command has two modes of operation:
|
||||
/// This command has three modes of operation:
|
||||
///
|
||||
/// 1. Interactive mode: If nothing else is specified, the user will be prompted to edit the privileges using a text editor.
|
||||
/// 1. Interactive mode:
|
||||
///
|
||||
/// If no arguments are provided, the user will be prompted to edit the privileges using a text editor.
|
||||
///
|
||||
/// You can configure your preferred text editor by setting the `VISUAL` or `EDITOR` environment variables.
|
||||
///
|
||||
/// Follow the instructions inside the editor for more information.
|
||||
///
|
||||
/// 2. Non-interactive mode: If the `-p` flag is specified, the user can write privileges using arguments.
|
||||
/// 2. Non-interactive human-friendly mode:
|
||||
///
|
||||
/// The privilege arguments should be formatted as `<db>:<user>:<op><privileges>`
|
||||
/// where the privileges are a string of characters, each representing a single privilege.
|
||||
/// You can provide the command with three positional arguments:
|
||||
///
|
||||
/// - `<DB_NAME>`: The name of the database for which you want to edit privileges.
|
||||
/// - `<USER_NAME>`: The name of the user whose privileges you want to edit.
|
||||
/// - `<[+-]PRIVILEGES>`: A string representing the privileges to set for the user.
|
||||
///
|
||||
/// The `<[+-]PRIVILEGES>` argument is a string of characters, each representing a single privilege.
|
||||
/// The character `A` is an exception - it represents all privileges.
|
||||
///
|
||||
/// The `<op>` character is optional and can be either `+` to grant additional privileges
|
||||
/// or `-` to revoke privileges. If omitted, the privileges will be set exactly as specified,
|
||||
/// removing any privileges not listed, and adding any that are.
|
||||
/// The optional leading character can be either `+` to grant additional privileges or `-` to revoke privileges.
|
||||
/// If omitted, the privileges will be set exactly as specified, removing any privileges not listed, and adding any that are.
|
||||
///
|
||||
/// The character-to-privilege mapping is defined as follows:
|
||||
///
|
||||
@@ -85,30 +113,19 @@ pub enum ClientCommand {
|
||||
/// - `r` - REFERENCES
|
||||
/// - `A` - ALL PRIVILEGES
|
||||
///
|
||||
/// If you provide a database name, you can omit it from the privilege string,
|
||||
/// e.g. `edit-privs my_db -p my_user:siu` is equivalent to `edit-privs -p my_db:my_user:siu`.
|
||||
/// While it doesn't make much of a difference for a single edit, it can be useful for editing multiple users
|
||||
/// on the same database at once.
|
||||
/// 3. Non-interactive batch mode:
|
||||
///
|
||||
/// Example usage of non-interactive mode:
|
||||
/// By using the `-p` flag, you can provide multiple privilege edits in a single command.
|
||||
///
|
||||
/// Enable privileges `SELECT`, `INSERT`, and `UPDATE` for user `my_user` on database `my_db`:
|
||||
/// The flag value should be formatted as `DB_NAME:USER_NAME:[+-]PRIVILEGES`
|
||||
/// where the privileges are a string of characters, each representing a single privilege.
|
||||
/// (See the character-to-privilege mapping above.)
|
||||
///
|
||||
/// `muscl edit-privs -p my_db:my_user:siu`
|
||||
///
|
||||
/// Enable all privileges for user `my_other_user` on database `my_other_db`:
|
||||
///
|
||||
/// `muscl edit-privs -p my_other_db:my_other_user:A`
|
||||
///
|
||||
/// Set miscellaneous privileges for multiple users on database `my_db`:
|
||||
///
|
||||
/// `muscl edit-privs my_db -p my_user:siu my_other_user:ct``
|
||||
///
|
||||
/// Add the `DELETE` privilege for user `my_user` on database `my_db`:
|
||||
///
|
||||
/// `muscl edit-privs my_db -p my_user:+d
|
||||
///
|
||||
#[command(verbatim_doc_comment)]
|
||||
#[command(
|
||||
verbatim_doc_comment,
|
||||
override_usage = "muscl edit-privs [OPTIONS] [ -p <DB_NAME:USER_NAME:[+-]PRIVILEGES>... | <DB_NAME> <USER_NAME> <[+-]PRIVILEGES> ]",
|
||||
after_long_help = EDIT_PRIVS_EXAMPLES,
|
||||
)]
|
||||
EditPrivs(EditPrivsArgs),
|
||||
|
||||
/// Create one or more users
|
||||
@@ -142,7 +159,9 @@ pub async fn handle_command(
|
||||
ClientCommand::DropDb(args) => drop_databases(args, server_connection).await,
|
||||
ClientCommand::ShowDb(args) => show_databases(args, server_connection).await,
|
||||
ClientCommand::ShowPrivs(args) => show_database_privileges(args, server_connection).await,
|
||||
ClientCommand::EditPrivs(args) => edit_database_privileges(args, server_connection).await,
|
||||
ClientCommand::EditPrivs(args) => {
|
||||
edit_database_privileges(args, None, server_connection).await
|
||||
}
|
||||
ClientCommand::CreateUser(args) => create_users(args, server_connection).await,
|
||||
ClientCommand::DropUser(args) => drop_users(args, server_connection).await,
|
||||
ClientCommand::PasswdUser(args) => passwd_user(args, server_connection).await,
|
||||
@@ -152,6 +171,10 @@ pub async fn handle_command(
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle an unexpected or erroneous response from the server.
|
||||
///
|
||||
/// This function checks the provided response and returns an appropriate error message.
|
||||
/// It is typically used in `match` branches for expecting a specific response type from the server.
|
||||
pub fn erroneous_server_response(
|
||||
response: Option<Result<Response, std::io::Error>>,
|
||||
) -> anyhow::Result<()> {
|
||||
@@ -170,3 +193,28 @@ pub fn erroneous_server_response(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Print a hint about which name prefixes the user is authorized to manage
|
||||
/// by querying the server for valid name prefixes.
|
||||
///
|
||||
/// This function should be used when an authorization error occurs,
|
||||
/// to help the user understand which databases or users they are allowed to manage.
|
||||
pub async fn print_authorization_owner_hint(
|
||||
server_connection: &mut ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
server_connection
|
||||
.send(Request::ListValidNamePrefixes)
|
||||
.await?;
|
||||
|
||||
let response = match server_connection.next().await {
|
||||
Some(Ok(Response::ListValidNamePrefixes(prefixes))) => prefixes,
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
"Note: You are allowed to manage databases and users with the following prefixes:\n{}",
|
||||
response.into_iter().map(|p| format!(" - {}", p)).join("\n")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -63,5 +63,9 @@ pub async fn check_authorization(
|
||||
print_check_authorization_output_status(&result);
|
||||
}
|
||||
|
||||
if result.values().any(|res| res.is_err()) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
use clap::Parser;
|
||||
use clap_complete::ArgValueCompleter;
|
||||
use futures_util::SinkExt;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{
|
||||
client::commands::erroneous_server_response,
|
||||
client::commands::{erroneous_server_response, print_authorization_owner_hint},
|
||||
core::{
|
||||
completion::prefix_completer,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, Request, Response, print_create_databases_output_status,
|
||||
print_create_databases_output_status_json,
|
||||
ClientToServerMessageStream, CreateDatabaseError, Request, Response,
|
||||
print_create_databases_output_status, print_create_databases_output_status_json,
|
||||
request_validation::ValidationError,
|
||||
},
|
||||
types::MySQLDatabase,
|
||||
},
|
||||
@@ -17,6 +20,7 @@ use crate::{
|
||||
pub struct CreateDbArgs {
|
||||
/// The MySQL database(s) to create
|
||||
#[arg(num_args = 1.., value_name = "DB_NAME")]
|
||||
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(prefix_completer)))]
|
||||
name: Vec<MySQLDatabase>,
|
||||
|
||||
/// Print the information as JSON
|
||||
@@ -40,12 +44,27 @@ pub async fn create_databases(
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if args.json {
|
||||
print_create_databases_output_status_json(&result);
|
||||
} else {
|
||||
print_create_databases_output_status(&result);
|
||||
|
||||
if result.iter().any(|(_, res)| {
|
||||
matches!(
|
||||
res,
|
||||
Err(CreateDatabaseError::ValidationError(
|
||||
ValidationError::AuthorizationError(_)
|
||||
))
|
||||
)
|
||||
}) {
|
||||
print_authorization_owner_hint(&mut server_connection).await?
|
||||
}
|
||||
}
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if result.values().any(|res| res.is_err()) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
use clap::Parser;
|
||||
use clap_complete::ArgValueCompleter;
|
||||
use dialoguer::Confirm;
|
||||
use futures_util::SinkExt;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{
|
||||
client::commands::{erroneous_server_response, read_password_from_stdin_with_double_check},
|
||||
client::commands::{
|
||||
erroneous_server_response, print_authorization_owner_hint,
|
||||
read_password_from_stdin_with_double_check,
|
||||
},
|
||||
core::{
|
||||
completion::prefix_completer,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, Request, Response, print_create_users_output_status,
|
||||
print_create_users_output_status_json, print_set_password_output_status,
|
||||
ClientToServerMessageStream, CreateUserError, Request, Response,
|
||||
print_create_users_output_status, print_create_users_output_status_json,
|
||||
print_set_password_output_status, request_validation::ValidationError,
|
||||
},
|
||||
types::MySQLUser,
|
||||
},
|
||||
@@ -18,6 +24,7 @@ use crate::{
|
||||
pub struct CreateUserArgs {
|
||||
/// The MySQL user(s) to create
|
||||
#[arg(num_args = 1.., value_name = "USER_NAME")]
|
||||
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(prefix_completer)))]
|
||||
username: Vec<MySQLUser>,
|
||||
|
||||
/// Do not ask for a password, leave it unset
|
||||
@@ -55,6 +62,17 @@ pub async fn create_users(
|
||||
} else {
|
||||
print_create_users_output_status(&result);
|
||||
|
||||
if result.iter().any(|(_, res)| {
|
||||
matches!(
|
||||
res,
|
||||
Err(CreateUserError::ValidationError(
|
||||
ValidationError::AuthorizationError(_)
|
||||
))
|
||||
)
|
||||
}) {
|
||||
print_authorization_owner_hint(&mut server_connection).await?
|
||||
}
|
||||
|
||||
let successfully_created_users = result
|
||||
.iter()
|
||||
.filter_map(|(username, result)| result.as_ref().ok().map(|_| username))
|
||||
@@ -92,5 +110,9 @@ pub async fn create_users(
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if result.values().any(|res| res.is_err()) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ use futures_util::SinkExt;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{
|
||||
client::commands::erroneous_server_response,
|
||||
client::commands::{erroneous_server_response, print_authorization_owner_hint},
|
||||
core::{
|
||||
completion::mysql_database_completer,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, Request, Response, print_drop_databases_output_status,
|
||||
print_drop_databases_output_status_json,
|
||||
ClientToServerMessageStream, DropDatabaseError, Request, Response,
|
||||
print_drop_databases_output_status, print_drop_databases_output_status_json,
|
||||
request_validation::ValidationError,
|
||||
},
|
||||
types::MySQLDatabase,
|
||||
},
|
||||
@@ -52,8 +53,11 @@ pub async fn drop_databases(
|
||||
))
|
||||
.interact()?;
|
||||
|
||||
//
|
||||
if !confirmation {
|
||||
// TODO: should we return with an error code here?
|
||||
println!("Aborting drop operation.");
|
||||
server_connection.send(Request::Exit).await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -66,13 +70,28 @@ pub async fn drop_databases(
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if args.json {
|
||||
print_drop_databases_output_status_json(&result);
|
||||
} else {
|
||||
print_drop_databases_output_status(&result);
|
||||
|
||||
if result.iter().any(|(_, res)| {
|
||||
matches!(
|
||||
res,
|
||||
Err(DropDatabaseError::ValidationError(
|
||||
ValidationError::AuthorizationError(_)
|
||||
))
|
||||
)
|
||||
}) {
|
||||
print_authorization_owner_hint(&mut server_connection).await?
|
||||
}
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if result.values().any(|res| res.is_err()) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ use futures_util::SinkExt;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{
|
||||
client::commands::erroneous_server_response,
|
||||
client::commands::{erroneous_server_response, print_authorization_owner_hint},
|
||||
core::{
|
||||
completion::mysql_user_completer,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, Request, Response, print_drop_users_output_status,
|
||||
print_drop_users_output_status_json,
|
||||
ClientToServerMessageStream, DropUserError, Request, Response,
|
||||
print_drop_users_output_status, print_drop_users_output_status_json,
|
||||
request_validation::ValidationError,
|
||||
},
|
||||
types::MySQLUser,
|
||||
},
|
||||
@@ -53,7 +54,9 @@ pub async fn drop_users(
|
||||
.interact()?;
|
||||
|
||||
if !confirmation {
|
||||
// TODO: should we return with an error code here?
|
||||
println!("Aborting drop operation.");
|
||||
server_connection.send(Request::Exit).await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -70,12 +73,27 @@ pub async fn drop_users(
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if args.json {
|
||||
print_drop_users_output_status_json(&result);
|
||||
} else {
|
||||
print_drop_users_output_status(&result);
|
||||
|
||||
if result.iter().any(|(_, res)| {
|
||||
matches!(
|
||||
res,
|
||||
Err(DropUserError::ValidationError(
|
||||
ValidationError::AuthorizationError(_)
|
||||
))
|
||||
)
|
||||
}) {
|
||||
print_authorization_owner_hint(&mut server_connection).await?
|
||||
}
|
||||
}
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if result.values().any(|res| res.is_err()) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::Parser;
|
||||
use clap::{Args, Parser};
|
||||
use clap_complete::ArgValueCompleter;
|
||||
use dialoguer::{Confirm, Editor};
|
||||
use futures_util::SinkExt;
|
||||
@@ -9,18 +9,19 @@ use nix::unistd::{User, getuid};
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{
|
||||
client::commands::erroneous_server_response,
|
||||
client::commands::{erroneous_server_response, print_authorization_owner_hint},
|
||||
core::{
|
||||
completion::mysql_database_completer,
|
||||
completion::{mysql_database_completer, mysql_user_completer},
|
||||
database_privileges::{
|
||||
DatabasePrivilegeEditEntry, DatabasePrivilegeRow, DatabasePrivilegeRowDiff,
|
||||
DatabasePrivilegesDiff, create_or_modify_privilege_rows, diff_privileges,
|
||||
display_privilege_diffs, generate_editor_content_from_privilege_data,
|
||||
DatabasePrivilegeEdit, DatabasePrivilegeEditEntry, DatabasePrivilegeRow,
|
||||
DatabasePrivilegeRowDiff, DatabasePrivilegesDiff, create_or_modify_privilege_rows,
|
||||
diff_privileges, display_privilege_diffs, generate_editor_content_from_privilege_data,
|
||||
parse_privilege_data_from_editor_content, reduce_privilege_diffs,
|
||||
},
|
||||
protocol::{
|
||||
ClientToServerMessageStream, Request, Response,
|
||||
print_modify_database_privileges_output_status,
|
||||
ClientToServerMessageStream, ListDatabasesError, ListUsersError,
|
||||
ModifyDatabasePrivilegesError, Request, Response,
|
||||
print_modify_database_privileges_output_status, request_validation::ValidationError,
|
||||
},
|
||||
types::{MySQLDatabase, MySQLUser},
|
||||
},
|
||||
@@ -28,20 +29,24 @@ use crate::{
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct EditPrivsArgs {
|
||||
/// The MySQL database to edit privileges for
|
||||
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_database_completer)))]
|
||||
#[arg(value_name = "DB_NAME")]
|
||||
pub name: Option<MySQLDatabase>,
|
||||
|
||||
/// The privileges to set, grant or revoke, in the format `DATABASE:USER:[+-]PRIVILEGES`
|
||||
///
|
||||
/// This option allows for changing privileges for multiple databases and users in batch.
|
||||
///
|
||||
/// This can not be used together with the positional `DB_NAME`, `USER_NAME` and `PRIVILEGES` arguments.
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
value_name = "[DATABASE:]USER:[+-]PRIVILEGES",
|
||||
value_name = "DB_NAME:USER_NAME:[+-]PRIVILEGES",
|
||||
num_args = 0..,
|
||||
value_parser = DatabasePrivilegeEditEntry::parse_from_str,
|
||||
conflicts_with("single_priv"),
|
||||
)]
|
||||
pub privs: Vec<DatabasePrivilegeEditEntry>,
|
||||
|
||||
#[command(flatten)]
|
||||
pub single_priv: Option<SinglePrivilegeEditArgs>,
|
||||
|
||||
/// Print the information as JSON
|
||||
#[arg(short, long)]
|
||||
pub json: bool,
|
||||
@@ -60,10 +65,35 @@ pub struct EditPrivsArgs {
|
||||
pub yes: bool,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug, Clone)]
|
||||
pub struct SinglePrivilegeEditArgs {
|
||||
/// The MySQL database to edit privileges for
|
||||
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_database_completer)))]
|
||||
#[arg(
|
||||
value_name = "DB_NAME",
|
||||
requires = "user_name",
|
||||
requires = "single_priv"
|
||||
)]
|
||||
pub db_name: Option<MySQLDatabase>,
|
||||
|
||||
/// The MySQL database to edit privileges for
|
||||
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_user_completer)))]
|
||||
#[arg(value_name = "USER_NAME")]
|
||||
pub user_name: Option<MySQLUser>,
|
||||
|
||||
/// The privileges to set, grant or revoke
|
||||
#[arg(
|
||||
allow_hyphen_values = true,
|
||||
value_name = "[+-]PRIVILEGES",
|
||||
value_parser = DatabasePrivilegeEdit::parse_from_str,
|
||||
)]
|
||||
pub single_priv: Option<DatabasePrivilegeEdit>,
|
||||
}
|
||||
|
||||
async fn users_exist(
|
||||
server_connection: &mut ClientToServerMessageStream,
|
||||
privilege_diff: &BTreeSet<DatabasePrivilegesDiff>,
|
||||
) -> anyhow::Result<BTreeMap<MySQLUser, bool>> {
|
||||
) -> anyhow::Result<BTreeMap<MySQLUser, Result<(), ListUsersError>>> {
|
||||
let user_list = privilege_diff
|
||||
.iter()
|
||||
.map(|diff| diff.get_user_name().clone())
|
||||
@@ -83,7 +113,7 @@ async fn users_exist(
|
||||
|
||||
let result = result
|
||||
.into_iter()
|
||||
.map(|(user, user_result)| (user, user_result.is_ok()))
|
||||
.map(|(user, user_result)| (user, user_result.map(|_| ())))
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
@@ -92,7 +122,7 @@ async fn users_exist(
|
||||
async fn databases_exist(
|
||||
server_connection: &mut ClientToServerMessageStream,
|
||||
privilege_diff: &BTreeSet<DatabasePrivilegesDiff>,
|
||||
) -> anyhow::Result<BTreeMap<MySQLDatabase, bool>> {
|
||||
) -> anyhow::Result<BTreeMap<MySQLDatabase, Result<(), ListDatabasesError>>> {
|
||||
let database_list = privilege_diff
|
||||
.iter()
|
||||
.map(|diff| diff.get_database_name().clone())
|
||||
@@ -112,20 +142,51 @@ async fn databases_exist(
|
||||
|
||||
let result = result
|
||||
.into_iter()
|
||||
.map(|(database, db_result)| (database, db_result.is_ok()))
|
||||
.map(|(database, db_result)| (database, db_result.map(|_| ())))
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// TODO: reduce the complexity of this function
|
||||
pub async fn edit_database_privileges(
|
||||
args: EditPrivsArgs,
|
||||
// NOTE: this is only used for backwards compat with mysql-admutils
|
||||
use_database: Option<MySQLDatabase>,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
let message = Request::ListPrivileges(args.name.to_owned().map(|name| vec![name]));
|
||||
let message = Request::ListPrivileges(use_database.clone().map(|db| vec![db]));
|
||||
|
||||
server_connection.send(message).await?;
|
||||
|
||||
debug_assert!(args.privs.is_empty() ^ args.single_priv.is_none());
|
||||
|
||||
let privs = if let Some(single_priv_entry) = &args.single_priv {
|
||||
let database = single_priv_entry.db_name.clone().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"DB_NAME must be specified when editing privileges in single privilege mode"
|
||||
)
|
||||
})?;
|
||||
let user = single_priv_entry.user_name.clone().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"USER_NAME must be specified when DB_NAME is specified in single privilege mode"
|
||||
)
|
||||
})?;
|
||||
let privilege_edit = single_priv_entry.single_priv.clone().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"PRIVILEGES must be specified when DB_NAME is specified in single privilege mode"
|
||||
)
|
||||
})?;
|
||||
|
||||
vec![DatabasePrivilegeEditEntry {
|
||||
database,
|
||||
user,
|
||||
privilege_edit,
|
||||
}]
|
||||
} else {
|
||||
args.privs.clone()
|
||||
};
|
||||
|
||||
let existing_privilege_rows = match server_connection.next().await {
|
||||
Some(Ok(Response::ListPrivileges(databases))) => databases
|
||||
.into_iter()
|
||||
@@ -151,17 +212,17 @@ pub async fn edit_database_privileges(
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
let diffs: BTreeSet<DatabasePrivilegesDiff> = if !args.privs.is_empty() {
|
||||
let privileges_to_change = parse_privilege_tables_from_args(&args)?;
|
||||
let diffs: BTreeSet<DatabasePrivilegesDiff> = if !privs.is_empty() {
|
||||
let privileges_to_change = parse_privilege_tables(&privs)?;
|
||||
create_or_modify_privilege_rows(&existing_privilege_rows, &privileges_to_change)?
|
||||
} else {
|
||||
let privileges_to_change =
|
||||
edit_privileges_with_editor(&existing_privilege_rows, args.name.as_ref())?;
|
||||
edit_privileges_with_editor(&existing_privilege_rows, use_database.as_ref())?;
|
||||
diff_privileges(&existing_privilege_rows, &privileges_to_change)
|
||||
};
|
||||
|
||||
let user_existence_map = users_exist(&mut server_connection, &diffs).await?;
|
||||
let database_existence_map = databases_exist(&mut server_connection, &diffs).await?;
|
||||
let user_existence_map = users_exist(&mut server_connection, &diffs).await?;
|
||||
|
||||
let diffs = reduce_privilege_diffs(&existing_privilege_rows, diffs)?
|
||||
.into_iter()
|
||||
@@ -169,14 +230,14 @@ pub async fn edit_database_privileges(
|
||||
let database_name = diff.get_database_name();
|
||||
let username = diff.get_user_name();
|
||||
|
||||
if let Some(false) = database_existence_map.get(database_name) {
|
||||
println!("Database '{}' does not exist.", database_name);
|
||||
if let Some(Err(err)) = database_existence_map.get(database_name) {
|
||||
println!("{}", err.to_error_message(database_name));
|
||||
println!("Skipping...");
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(false) = user_existence_map.get(username) {
|
||||
println!("User '{}' does not exist.", username);
|
||||
if let Some(Err(err)) = user_existence_map.get(username) {
|
||||
println!("{}", err.to_error_message(username));
|
||||
println!("Skipping...");
|
||||
return false;
|
||||
}
|
||||
@@ -185,6 +246,26 @@ pub async fn edit_database_privileges(
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
if database_existence_map.values().any(|res| {
|
||||
matches!(
|
||||
res,
|
||||
Err(ListDatabasesError::ValidationError(
|
||||
ValidationError::AuthorizationError(_)
|
||||
))
|
||||
)
|
||||
}) || user_existence_map.values().any(|res| {
|
||||
matches!(
|
||||
res,
|
||||
Err(ListUsersError::ValidationError(
|
||||
ValidationError::AuthorizationError(_)
|
||||
))
|
||||
)
|
||||
}) {
|
||||
println!();
|
||||
print_authorization_owner_hint(&mut server_connection).await?;
|
||||
println!();
|
||||
}
|
||||
|
||||
if diffs.is_empty() {
|
||||
println!("No changes to make.");
|
||||
server_connection.send(Request::Exit).await?;
|
||||
@@ -215,20 +296,37 @@ pub async fn edit_database_privileges(
|
||||
|
||||
print_modify_database_privileges_output_status(&result);
|
||||
|
||||
if result.iter().any(|(_, res)| {
|
||||
matches!(
|
||||
res,
|
||||
Err(ModifyDatabasePrivilegesError::UserValidationError(
|
||||
ValidationError::AuthorizationError(_)
|
||||
) | ModifyDatabasePrivilegesError::DatabaseValidationError(
|
||||
ValidationError::AuthorizationError(_)
|
||||
))
|
||||
)
|
||||
}) {
|
||||
print_authorization_owner_hint(&mut server_connection).await?
|
||||
}
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if result.values().any(|res| res.is_err()) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_privilege_tables_from_args(
|
||||
args: &EditPrivsArgs,
|
||||
fn parse_privilege_tables(
|
||||
privs: &[DatabasePrivilegeEditEntry],
|
||||
) -> anyhow::Result<BTreeSet<DatabasePrivilegeRowDiff>> {
|
||||
debug_assert!(!args.privs.is_empty());
|
||||
args.privs
|
||||
debug_assert!(!privs.is_empty());
|
||||
privs
|
||||
.iter()
|
||||
.map(|priv_edit_entry| {
|
||||
priv_edit_entry
|
||||
.as_database_privileges_diff(args.name.as_ref())
|
||||
.as_database_privileges_diff()
|
||||
.context(format!(
|
||||
"Failed parsing database privileges: `{}`",
|
||||
priv_edit_entry
|
||||
@@ -239,6 +337,7 @@ fn parse_privilege_tables_from_args(
|
||||
|
||||
fn edit_privileges_with_editor(
|
||||
privilege_data: &[DatabasePrivilegeRow],
|
||||
// NOTE: this is only used for backwards compat with mysql-admtools
|
||||
database_name: Option<&MySQLDatabase>,
|
||||
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
|
||||
let unix_user = User::from_uid(getuid())
|
||||
|
||||
@@ -4,12 +4,13 @@ use futures_util::SinkExt;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{
|
||||
client::commands::erroneous_server_response,
|
||||
client::commands::{erroneous_server_response, print_authorization_owner_hint},
|
||||
core::{
|
||||
completion::mysql_user_completer,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, Request, Response, print_lock_users_output_status,
|
||||
print_lock_users_output_status_json,
|
||||
ClientToServerMessageStream, LockUserError, Request, Response,
|
||||
print_lock_users_output_status, print_lock_users_output_status_json,
|
||||
request_validation::ValidationError,
|
||||
},
|
||||
types::MySQLUser,
|
||||
},
|
||||
@@ -47,12 +48,27 @@ pub async fn lock_users(
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if args.json {
|
||||
print_lock_users_output_status_json(&result);
|
||||
} else {
|
||||
print_lock_users_output_status(&result);
|
||||
|
||||
if result.iter().any(|(_, res)| {
|
||||
matches!(
|
||||
res,
|
||||
Err(LockUserError::ValidationError(
|
||||
ValidationError::AuthorizationError(_)
|
||||
))
|
||||
)
|
||||
}) {
|
||||
print_authorization_owner_hint(&mut server_connection).await?
|
||||
}
|
||||
}
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if result.values().any(|res| res.is_err()) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -8,12 +8,12 @@ use futures_util::SinkExt;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{
|
||||
client::commands::erroneous_server_response,
|
||||
client::commands::{erroneous_server_response, print_authorization_owner_hint},
|
||||
core::{
|
||||
completion::mysql_user_completer,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, ListUsersError, Request, Response,
|
||||
print_set_password_output_status,
|
||||
ClientToServerMessageStream, ListUsersError, Request, Response, SetPasswordError,
|
||||
print_set_password_output_status, request_validation::ValidationError,
|
||||
},
|
||||
types::MySQLUser,
|
||||
},
|
||||
@@ -103,9 +103,22 @@ pub async fn passwd_user(
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
print_set_password_output_status(&result, &args.username);
|
||||
|
||||
if matches!(
|
||||
result,
|
||||
Err(SetPasswordError::ValidationError(
|
||||
ValidationError::AuthorizationError(_)
|
||||
))
|
||||
) {
|
||||
print_authorization_owner_hint(&mut server_connection).await?
|
||||
}
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
print_set_password_output_status(&result, &args.username);
|
||||
if result.is_err() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ use futures_util::SinkExt;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{
|
||||
client::commands::erroneous_server_response,
|
||||
client::commands::{erroneous_server_response, print_authorization_owner_hint},
|
||||
core::{
|
||||
completion::mysql_database_completer,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, Request, Response, print_list_databases_output_status,
|
||||
print_list_databases_output_status_json,
|
||||
ClientToServerMessageStream, ListDatabasesError, Request, Response,
|
||||
print_list_databases_output_status, print_list_databases_output_status_json,
|
||||
request_validation::ValidationError,
|
||||
},
|
||||
types::MySQLDatabase,
|
||||
},
|
||||
@@ -26,9 +27,9 @@ pub struct ShowDbArgs {
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
|
||||
/// Return a non-zero exit code if any of the results were erroneous
|
||||
/// Show sizes in bytes instead of human-readable format
|
||||
#[arg(short, long)]
|
||||
fail: bool,
|
||||
bytes: bool,
|
||||
}
|
||||
|
||||
pub async fn show_databases(
|
||||
@@ -60,15 +61,26 @@ pub async fn show_databases(
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if args.json {
|
||||
print_list_databases_output_status_json(&databases);
|
||||
} else {
|
||||
print_list_databases_output_status(&databases);
|
||||
print_list_databases_output_status(&databases, args.bytes);
|
||||
|
||||
if databases.iter().any(|(_, res)| {
|
||||
matches!(
|
||||
res,
|
||||
Err(ListDatabasesError::ValidationError(
|
||||
ValidationError::AuthorizationError(_)
|
||||
))
|
||||
)
|
||||
}) {
|
||||
print_authorization_owner_hint(&mut server_connection).await?
|
||||
}
|
||||
}
|
||||
|
||||
if args.fail && databases.values().any(|res| res.is_err()) {
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if databases.values().any(|res| res.is_err()) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,13 @@ use itertools::Itertools;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{
|
||||
client::commands::erroneous_server_response,
|
||||
client::commands::{erroneous_server_response, print_authorization_owner_hint},
|
||||
core::{
|
||||
completion::mysql_database_completer,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, Request, Response, print_list_privileges_output_status,
|
||||
print_list_privileges_output_status_json,
|
||||
ClientToServerMessageStream, ListPrivilegesError, Request, Response,
|
||||
print_list_privileges_output_status, print_list_privileges_output_status_json,
|
||||
request_validation::ValidationError,
|
||||
},
|
||||
types::MySQLDatabase,
|
||||
},
|
||||
@@ -32,10 +33,6 @@ pub struct ShowPrivsArgs {
|
||||
/// This flag has no effect when used with --json
|
||||
#[arg(short, long)]
|
||||
long: bool,
|
||||
|
||||
/// Return a non-zero exit code if any of the results were erroneous
|
||||
#[arg(short, long)]
|
||||
fail: bool,
|
||||
}
|
||||
|
||||
pub async fn show_database_privileges(
|
||||
@@ -68,15 +65,26 @@ pub async fn show_database_privileges(
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if args.json {
|
||||
print_list_privileges_output_status_json(&privilege_data);
|
||||
} else {
|
||||
print_list_privileges_output_status(&privilege_data, args.long);
|
||||
|
||||
if privilege_data.iter().any(|(_, res)| {
|
||||
matches!(
|
||||
res,
|
||||
Err(ListPrivilegesError::ValidationError(
|
||||
ValidationError::AuthorizationError(_)
|
||||
))
|
||||
)
|
||||
}) {
|
||||
print_authorization_owner_hint(&mut server_connection).await?
|
||||
}
|
||||
}
|
||||
|
||||
if args.fail && privilege_data.values().any(|res| res.is_err()) {
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if privilege_data.values().any(|res| res.is_err()) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,13 @@ use futures_util::SinkExt;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{
|
||||
client::commands::erroneous_server_response,
|
||||
client::commands::{erroneous_server_response, print_authorization_owner_hint},
|
||||
core::{
|
||||
completion::mysql_user_completer,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, Request, Response, print_list_users_output_status,
|
||||
print_list_users_output_status_json,
|
||||
ClientToServerMessageStream, ListUsersError, Request, Response,
|
||||
print_list_users_output_status, print_list_users_output_status_json,
|
||||
request_validation::ValidationError,
|
||||
},
|
||||
types::MySQLUser,
|
||||
},
|
||||
@@ -25,10 +26,6 @@ pub struct ShowUserArgs {
|
||||
/// Print the information as JSON
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
|
||||
/// Return a non-zero exit code if any of the results were erroneous
|
||||
#[arg(short, long)]
|
||||
fail: bool,
|
||||
}
|
||||
|
||||
pub async fn show_users(
|
||||
@@ -63,15 +60,26 @@ pub async fn show_users(
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if args.json {
|
||||
print_list_users_output_status_json(&users);
|
||||
} else {
|
||||
print_list_users_output_status(&users);
|
||||
|
||||
if users.iter().any(|(_, res)| {
|
||||
matches!(
|
||||
res,
|
||||
Err(ListUsersError::ValidationError(
|
||||
ValidationError::AuthorizationError(_)
|
||||
))
|
||||
)
|
||||
}) {
|
||||
print_authorization_owner_hint(&mut server_connection).await?
|
||||
}
|
||||
}
|
||||
|
||||
if args.fail && users.values().any(|result| result.is_err()) {
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if users.values().any(|result| result.is_err()) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,13 @@ use futures_util::SinkExt;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{
|
||||
client::commands::erroneous_server_response,
|
||||
client::commands::{erroneous_server_response, print_authorization_owner_hint},
|
||||
core::{
|
||||
completion::mysql_user_completer,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, Request, Response, print_unlock_users_output_status,
|
||||
print_unlock_users_output_status_json,
|
||||
ClientToServerMessageStream, Request, Response, UnlockUserError,
|
||||
print_unlock_users_output_status, print_unlock_users_output_status_json,
|
||||
request_validation::ValidationError,
|
||||
},
|
||||
types::MySQLUser,
|
||||
},
|
||||
@@ -47,12 +48,27 @@ pub async fn unlock_users(
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if args.json {
|
||||
print_unlock_users_output_status_json(&result);
|
||||
} else {
|
||||
print_unlock_users_output_status(&result);
|
||||
|
||||
if result.iter().any(|(_, res)| {
|
||||
matches!(
|
||||
res,
|
||||
Err(UnlockUserError::ValidationError(
|
||||
ValidationError::AuthorizationError(_)
|
||||
))
|
||||
)
|
||||
}) {
|
||||
print_authorization_owner_hint(&mut server_connection).await?
|
||||
}
|
||||
}
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if result.values().any(|result| result.is_err()) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::core::{
|
||||
protocol::{
|
||||
CreateDatabaseError, CreateUserError, DropDatabaseError, DropUserError,
|
||||
GetDatabasesPrivilegeDataError, ListUsersError, request_validation::ValidationError,
|
||||
ListPrivilegesError, ListUsersError, request_validation::ValidationError,
|
||||
},
|
||||
types::DbOrUser,
|
||||
};
|
||||
@@ -161,28 +161,25 @@ pub fn handle_drop_database_error(error: DropDatabaseError, name: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_show_database_error_message(
|
||||
error: GetDatabasesPrivilegeDataError,
|
||||
name: &str,
|
||||
) -> String {
|
||||
pub fn format_show_database_error_message(error: ListPrivilegesError, name: &str) -> String {
|
||||
let argv0 = std::env::args()
|
||||
.next()
|
||||
.unwrap_or_else(|| "mysql-dbadm".to_string());
|
||||
|
||||
match error {
|
||||
GetDatabasesPrivilegeDataError::ValidationError(ValidationError::NameValidationError(
|
||||
_,
|
||||
)) => name_validation_error_to_error_message(DbOrUser::Database(name.into())),
|
||||
GetDatabasesPrivilegeDataError::ValidationError(ValidationError::AuthorizationError(_)) => {
|
||||
ListPrivilegesError::ValidationError(ValidationError::NameValidationError(_)) => {
|
||||
name_validation_error_to_error_message(DbOrUser::Database(name.into()))
|
||||
}
|
||||
ListPrivilegesError::ValidationError(ValidationError::AuthorizationError(_)) => {
|
||||
authorization_error_message(DbOrUser::Database(name.into()))
|
||||
}
|
||||
GetDatabasesPrivilegeDataError::MySqlError(err) => {
|
||||
ListPrivilegesError::MySqlError(err) => {
|
||||
format!(
|
||||
"{}: Failed to look up privileges for database '{}': {}",
|
||||
argv0, name, err
|
||||
)
|
||||
}
|
||||
GetDatabasesPrivilegeDataError::DatabaseDoesNotExist => {
|
||||
ListPrivilegesError::DatabaseDoesNotExist => {
|
||||
format!("{}: Database '{}' doesn't exist.", argv0, name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,10 @@ use crate::{
|
||||
},
|
||||
core::{
|
||||
bootstrap::bootstrap_server_connection_and_drop_privileges,
|
||||
completion::mysql_database_completer,
|
||||
completion::{mysql_database_completer, prefix_completer},
|
||||
database_privileges::DatabasePrivilegeRow,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, GetDatabasesPrivilegeDataError, Request, Response,
|
||||
ClientToServerMessageStream, ListPrivilegesError, Request, Response,
|
||||
create_client_to_server_message_stream,
|
||||
},
|
||||
types::MySQLDatabase,
|
||||
@@ -124,6 +124,7 @@ pub enum Command {
|
||||
pub struct CreateArgs {
|
||||
/// The name of the DATABASE(s) to create.
|
||||
#[arg(num_args = 1..)]
|
||||
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(prefix_completer)))]
|
||||
name: Vec<MySQLDatabase>,
|
||||
}
|
||||
|
||||
@@ -208,14 +209,19 @@ fn tokio_run_command(command: Command, server_connection: StdUnixStream) -> anyh
|
||||
Command::Show(args) => show_databases(args, message_stream).await,
|
||||
Command::Editperm(args) => {
|
||||
let edit_privileges_args = EditPrivsArgs {
|
||||
name: Some(args.database),
|
||||
single_priv: None,
|
||||
privs: vec![],
|
||||
json: false,
|
||||
editor: None,
|
||||
yes: false,
|
||||
};
|
||||
|
||||
edit_database_privileges(edit_privileges_args, message_stream).await
|
||||
edit_database_privileges(
|
||||
edit_privileges_args,
|
||||
Some(args.database),
|
||||
message_stream,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -309,7 +315,7 @@ async fn show_databases(
|
||||
.map(
|
||||
|(name, rows)| match rows.map(|rows| (name.to_owned(), rows)) {
|
||||
Ok(rows) => Ok(rows),
|
||||
Err(GetDatabasesPrivilegeDataError::DatabaseDoesNotExist) => Ok((name, vec![])),
|
||||
Err(ListPrivilegesError::DatabaseDoesNotExist) => Ok((name, vec![])),
|
||||
Err(err) => Err(format_show_database_error_message(err, &name)),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::{
|
||||
},
|
||||
core::{
|
||||
bootstrap::bootstrap_server_connection_and_drop_privileges,
|
||||
completion::mysql_user_completer,
|
||||
completion::{mysql_user_completer, prefix_completer},
|
||||
protocol::{
|
||||
ClientToServerMessageStream, Request, Response, create_client_to_server_message_stream,
|
||||
},
|
||||
@@ -87,6 +87,7 @@ pub enum Command {
|
||||
pub struct CreateArgs {
|
||||
/// The name of the USER(s) to create.
|
||||
#[arg(num_args = 1..)]
|
||||
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(prefix_completer)))]
|
||||
name: Vec<MySQLUser>,
|
||||
}
|
||||
|
||||
@@ -291,7 +292,7 @@ async fn show_users(
|
||||
Some(Ok(Response::ListAllUsers(result))) => match result {
|
||||
Ok(users) => users,
|
||||
Err(err) => {
|
||||
println!("Failed to list users: {:?}", err);
|
||||
eprintln!("Failed to list users: {:?}", err);
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,10 +9,12 @@ use tokio::{net::UnixStream as TokioUnixStream, sync::RwLock};
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
use crate::{
|
||||
core::common::{
|
||||
DEFAULT_CONFIG_PATH, DEFAULT_SOCKET_PATH, UnixUser, executing_in_suid_sgid_mode,
|
||||
core::{
|
||||
common::{DEFAULT_CONFIG_PATH, DEFAULT_SOCKET_PATH, UnixUser, executing_in_suid_sgid_mode},
|
||||
protocol::request_validation::GroupDenylist,
|
||||
},
|
||||
server::{
|
||||
authorization::read_and_parse_group_denylist,
|
||||
config::{MysqlConfig, ServerConfig},
|
||||
landlock::landlock_restrict_server,
|
||||
session_handler,
|
||||
@@ -174,6 +176,8 @@ pub fn drop_privs() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bootstrap an internal server by forking a child process to run the server, giving it
|
||||
/// the other half of a Unix socket pair to communicate with the client process.
|
||||
fn bootstrap_internal_server_and_drop_privs(
|
||||
config_path: Option<PathBuf>,
|
||||
) -> anyhow::Result<StdUnixStream> {
|
||||
@@ -235,6 +239,10 @@ fn invoke_server_with_config(config_path: PathBuf) -> anyhow::Result<StdUnixStre
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a MySQL connection pool that consists of exactly one connection.
|
||||
///
|
||||
/// This is used for the internal server in SUID/SGID mode, where the server session
|
||||
/// only ever will get a single client.
|
||||
async fn construct_single_connection_mysql_pool(
|
||||
config: &MysqlConfig,
|
||||
) -> anyhow::Result<sqlx::MySqlPool> {
|
||||
@@ -260,8 +268,10 @@ async fn construct_single_connection_mysql_pool(
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
/// Run the server in the forked child process.
|
||||
/// Run a single server session in the forked process.
|
||||
///
|
||||
/// This function will not return, but will exit the process with a success code.
|
||||
/// The function assumes that it's caller has already forked the process.
|
||||
fn run_forked_server(
|
||||
config_path: PathBuf,
|
||||
server_socket: StdUnixStream,
|
||||
@@ -270,6 +280,13 @@ fn run_forked_server(
|
||||
let config = ServerConfig::read_config_from_path(&config_path)
|
||||
.context("Failed to read server config in forked process")?;
|
||||
|
||||
let group_denylist = if let Some(denylist_path) = &config.authorization.group_denylist_file {
|
||||
read_and_parse_group_denylist(denylist_path)
|
||||
.context("Failed to read and parse group denylist")?
|
||||
} else {
|
||||
GroupDenylist::new()
|
||||
};
|
||||
|
||||
let result: anyhow::Result<()> = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
@@ -292,6 +309,7 @@ fn run_forked_server(
|
||||
&unix_user,
|
||||
db_pool,
|
||||
db_is_mariadb,
|
||||
&group_denylist,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
|
||||
@@ -99,10 +99,10 @@ impl UnixUser {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_enviroment() -> anyhow::Result<Self> {
|
||||
let libc_uid = nix::unistd::getuid();
|
||||
UnixUser::from_uid(libc_uid.as_raw())
|
||||
}
|
||||
// pub fn from_enviroment() -> anyhow::Result<Self> {
|
||||
// let libc_uid = nix::unistd::getuid();
|
||||
// UnixUser::from_uid(libc_uid.as_raw())
|
||||
// }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
mod mysql_database_completer;
|
||||
mod mysql_user_completer;
|
||||
mod prefix_completer;
|
||||
|
||||
pub use mysql_database_completer::*;
|
||||
pub use mysql_user_completer::*;
|
||||
pub use prefix_completer::*;
|
||||
|
||||
75
src/core/completion/prefix_completer.rs
Normal file
75
src/core/completion/prefix_completer.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use clap_complete::CompletionCandidate;
|
||||
use clap_verbosity_flag::Verbosity;
|
||||
use futures_util::SinkExt;
|
||||
use tokio::net::UnixStream as TokioUnixStream;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{
|
||||
client::commands::erroneous_server_response,
|
||||
core::{
|
||||
bootstrap::bootstrap_server_connection_and_drop_privileges,
|
||||
protocol::{Request, Response, create_client_to_server_message_stream},
|
||||
},
|
||||
};
|
||||
|
||||
pub fn prefix_completer(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
|
||||
match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(runtime) => match runtime.block_on(prefix_completer_(current)) {
|
||||
Ok(completions) => completions,
|
||||
Err(err) => {
|
||||
eprintln!("Error getting prefix completions: {}", err);
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
eprintln!("Error starting Tokio runtime: {}", err);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to the server to get MySQL user completions.
|
||||
async fn prefix_completer_(_current: &std::ffi::OsStr) -> anyhow::Result<Vec<CompletionCandidate>> {
|
||||
let server_connection =
|
||||
bootstrap_server_connection_and_drop_privileges(None, None, Verbosity::new(0, 1))?;
|
||||
|
||||
let tokio_socket = TokioUnixStream::from_std(server_connection)?;
|
||||
let mut server_connection = create_client_to_server_message_stream(tokio_socket);
|
||||
|
||||
while let Some(Ok(message)) = server_connection.next().await {
|
||||
match message {
|
||||
Response::Error(err) => {
|
||||
anyhow::bail!("{}", err);
|
||||
}
|
||||
Response::Ready => break,
|
||||
message => {
|
||||
eprintln!("Unexpected message from server: {:?}", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let message = Request::ListValidNamePrefixes;
|
||||
|
||||
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 {
|
||||
Some(Ok(Response::ListValidNamePrefixes(prefixes))) => prefixes,
|
||||
response => return erroneous_server_response(response).map(|_| vec![]),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
let result = result
|
||||
.into_iter()
|
||||
.map(|prefix| prefix + "_")
|
||||
.map(CompletionCandidate::new)
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
//! This module contains serialization and deserialization logic for
|
||||
//! database privileges related CLI commands.
|
||||
|
||||
use itertools::Itertools;
|
||||
|
||||
use super::diff::{DatabasePrivilegeChange, DatabasePrivilegeRowDiff};
|
||||
use crate::core::types::{MySQLDatabase, MySQLUser};
|
||||
|
||||
const VALID_PRIVILEGE_EDIT_CHARS: &[char] = &[
|
||||
's', 'i', 'u', 'd', 'c', 'D', 'a', 'A', 'I', 't', 'l', 'r', 'A',
|
||||
];
|
||||
|
||||
/// This enum represents a part of a CLI argument for editing database privileges,
|
||||
/// indicating whether privileges are to be added, set, or removed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -13,17 +19,76 @@ pub enum DatabasePrivilegeEditEntryType {
|
||||
Remove,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DatabasePrivilegeEdit {
|
||||
pub type_: DatabasePrivilegeEditEntryType,
|
||||
pub privileges: Vec<char>,
|
||||
}
|
||||
|
||||
impl DatabasePrivilegeEdit {
|
||||
pub fn parse_from_str(input: &str) -> anyhow::Result<Self> {
|
||||
let (edit_type, privs_str) = if let Some(privs_str) = input.strip_prefix('+') {
|
||||
(DatabasePrivilegeEditEntryType::Add, privs_str)
|
||||
} else if let Some(privs_str) = input.strip_prefix('-') {
|
||||
(DatabasePrivilegeEditEntryType::Remove, privs_str)
|
||||
} else {
|
||||
(DatabasePrivilegeEditEntryType::Set, input)
|
||||
};
|
||||
|
||||
let privileges: Vec<char> = privs_str.chars().collect();
|
||||
|
||||
if privileges
|
||||
.iter()
|
||||
.any(|c| !VALID_PRIVILEGE_EDIT_CHARS.contains(c))
|
||||
{
|
||||
let invalid_chars: String = privileges
|
||||
.iter()
|
||||
.filter(|c| !VALID_PRIVILEGE_EDIT_CHARS.contains(c))
|
||||
.map(|c| format!("'{c}'"))
|
||||
.join(", ");
|
||||
let valid_characters: String = VALID_PRIVILEGE_EDIT_CHARS
|
||||
.iter()
|
||||
.map(|c| format!("'{c}'"))
|
||||
.join(", ");
|
||||
anyhow::bail!(
|
||||
"Invalid character(s) in privilege edit entry: {}\n\nValid characters are: {}",
|
||||
invalid_chars,
|
||||
valid_characters,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(DatabasePrivilegeEdit {
|
||||
type_: edit_type,
|
||||
privileges,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DatabasePrivilegeEdit {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self.type_ {
|
||||
DatabasePrivilegeEditEntryType::Add => write!(f, "+")?,
|
||||
DatabasePrivilegeEditEntryType::Set => {}
|
||||
DatabasePrivilegeEditEntryType::Remove => write!(f, "-")?,
|
||||
}
|
||||
for priv_char in &self.privileges {
|
||||
write!(f, "{}", priv_char)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// This struct represents a single CLI argument for editing database privileges.
|
||||
///
|
||||
/// This is typically parsed from a string looking like:
|
||||
///
|
||||
/// `[database_name:]username:[+|-]privileges`
|
||||
/// `database_name:username:[+|-]privileges`
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DatabasePrivilegeEditEntry {
|
||||
pub database: Option<MySQLDatabase>,
|
||||
pub database: MySQLDatabase,
|
||||
pub user: MySQLUser,
|
||||
pub type_: DatabasePrivilegeEditEntryType,
|
||||
pub privileges: Vec<String>,
|
||||
pub privilege_edit: DatabasePrivilegeEdit,
|
||||
}
|
||||
|
||||
impl DatabasePrivilegeEditEntry {
|
||||
@@ -31,80 +96,41 @@ impl DatabasePrivilegeEditEntry {
|
||||
///
|
||||
/// The expected format is:
|
||||
///
|
||||
/// `[database_name:]username:[+|-]privileges`
|
||||
/// `database_name:username:[+|-]privileges`
|
||||
///
|
||||
/// where:
|
||||
/// - database_name is optional, if omitted the entry applies to all databases
|
||||
/// - database_name is the name of the database to edit privileges for
|
||||
/// - username is the name of the user to edit privileges for
|
||||
/// - privileges is a string of characters representing the privileges to add, set or remove
|
||||
/// - the `+` or `-` prefix indicates whether to add or remove the privileges, if omitted the privileges are set directly
|
||||
/// - privileges characters are: siudcDaAItlrA
|
||||
pub fn parse_from_str(arg: &str) -> anyhow::Result<DatabasePrivilegeEditEntry> {
|
||||
pub fn parse_from_str(arg: &str) -> anyhow::Result<Self> {
|
||||
let parts: Vec<&str> = arg.split(':').collect();
|
||||
if parts.len() < 2 || parts.len() > 3 {
|
||||
if parts.len() != 3 {
|
||||
anyhow::bail!("Invalid privilege edit entry format: {}", arg);
|
||||
}
|
||||
|
||||
let (database, user, user_privs) = if parts.len() == 3 {
|
||||
(Some(parts[0].to_string()), parts[1].to_string(), parts[2])
|
||||
} else {
|
||||
(None, parts[0].to_string(), parts[1])
|
||||
};
|
||||
let (database, user, user_privs) = (parts[0].to_string(), parts[1].to_string(), parts[2]);
|
||||
|
||||
if user.is_empty() {
|
||||
anyhow::bail!("Username cannot be empty in privilege edit entry: {}", arg);
|
||||
}
|
||||
|
||||
let (edit_type, privs_str) = if let Some(privs_str) = user_privs.strip_prefix('+') {
|
||||
(DatabasePrivilegeEditEntryType::Add, privs_str)
|
||||
} else if let Some(privs_str) = user_privs.strip_prefix('-') {
|
||||
(DatabasePrivilegeEditEntryType::Remove, privs_str)
|
||||
} else {
|
||||
(DatabasePrivilegeEditEntryType::Set, user_privs)
|
||||
};
|
||||
|
||||
let privileges: Vec<String> = privs_str.chars().map(|c| c.to_string()).collect();
|
||||
if privileges.iter().any(|c| !"siudcDaAItlrA".contains(c)) {
|
||||
let invalid_chars: String = privileges
|
||||
.iter()
|
||||
.filter(|c| !"siudcDaAItlrA".contains(c.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
anyhow::bail!(
|
||||
"Invalid character(s) in privilege edit entry: {}",
|
||||
invalid_chars
|
||||
);
|
||||
}
|
||||
let privilege_edit = DatabasePrivilegeEdit::parse_from_str(user_privs)?;
|
||||
|
||||
Ok(DatabasePrivilegeEditEntry {
|
||||
database: database.map(MySQLDatabase::from),
|
||||
database: MySQLDatabase::from(database),
|
||||
user: MySQLUser::from(user),
|
||||
type_: edit_type,
|
||||
privileges,
|
||||
privilege_edit,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn as_database_privileges_diff(
|
||||
&self,
|
||||
external_database_name: Option<&MySQLDatabase>,
|
||||
) -> anyhow::Result<DatabasePrivilegeRowDiff> {
|
||||
let database = match self.database.as_ref() {
|
||||
Some(db) => db.clone(),
|
||||
None => {
|
||||
if let Some(external_db) = external_database_name {
|
||||
external_db.clone()
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"Database name must be specified either in the privilege edit entry or as an external argument."
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
pub fn as_database_privileges_diff(&self) -> anyhow::Result<DatabasePrivilegeRowDiff> {
|
||||
let mut diff;
|
||||
match self.type_ {
|
||||
match self.privilege_edit.type_ {
|
||||
DatabasePrivilegeEditEntryType::Set => {
|
||||
diff = DatabasePrivilegeRowDiff {
|
||||
db: database,
|
||||
db: self.database.clone(),
|
||||
user: self.user.clone(),
|
||||
select_priv: Some(DatabasePrivilegeChange::YesToNo),
|
||||
insert_priv: Some(DatabasePrivilegeChange::YesToNo),
|
||||
@@ -118,20 +144,20 @@ impl DatabasePrivilegeEditEntry {
|
||||
lock_tables_priv: Some(DatabasePrivilegeChange::YesToNo),
|
||||
references_priv: Some(DatabasePrivilegeChange::YesToNo),
|
||||
};
|
||||
for priv_char in &self.privileges {
|
||||
match priv_char.as_str() {
|
||||
"s" => diff.select_priv = Some(DatabasePrivilegeChange::NoToYes),
|
||||
"i" => diff.insert_priv = Some(DatabasePrivilegeChange::NoToYes),
|
||||
"u" => diff.update_priv = Some(DatabasePrivilegeChange::NoToYes),
|
||||
"d" => diff.delete_priv = Some(DatabasePrivilegeChange::NoToYes),
|
||||
"c" => diff.create_priv = Some(DatabasePrivilegeChange::NoToYes),
|
||||
"D" => diff.drop_priv = Some(DatabasePrivilegeChange::NoToYes),
|
||||
"a" => diff.alter_priv = Some(DatabasePrivilegeChange::NoToYes),
|
||||
"I" => diff.index_priv = Some(DatabasePrivilegeChange::NoToYes),
|
||||
"t" => diff.create_tmp_table_priv = Some(DatabasePrivilegeChange::NoToYes),
|
||||
"l" => diff.lock_tables_priv = Some(DatabasePrivilegeChange::NoToYes),
|
||||
"r" => diff.references_priv = Some(DatabasePrivilegeChange::NoToYes),
|
||||
"A" => {
|
||||
for priv_char in &self.privilege_edit.privileges {
|
||||
match priv_char {
|
||||
's' => diff.select_priv = Some(DatabasePrivilegeChange::NoToYes),
|
||||
'i' => diff.insert_priv = Some(DatabasePrivilegeChange::NoToYes),
|
||||
'u' => diff.update_priv = Some(DatabasePrivilegeChange::NoToYes),
|
||||
'd' => diff.delete_priv = Some(DatabasePrivilegeChange::NoToYes),
|
||||
'c' => diff.create_priv = Some(DatabasePrivilegeChange::NoToYes),
|
||||
'D' => diff.drop_priv = Some(DatabasePrivilegeChange::NoToYes),
|
||||
'a' => diff.alter_priv = Some(DatabasePrivilegeChange::NoToYes),
|
||||
'I' => diff.index_priv = Some(DatabasePrivilegeChange::NoToYes),
|
||||
't' => diff.create_tmp_table_priv = Some(DatabasePrivilegeChange::NoToYes),
|
||||
'l' => diff.lock_tables_priv = Some(DatabasePrivilegeChange::NoToYes),
|
||||
'r' => diff.references_priv = Some(DatabasePrivilegeChange::NoToYes),
|
||||
'A' => {
|
||||
diff.select_priv = Some(DatabasePrivilegeChange::NoToYes);
|
||||
diff.insert_priv = Some(DatabasePrivilegeChange::NoToYes);
|
||||
diff.update_priv = Some(DatabasePrivilegeChange::NoToYes);
|
||||
@@ -150,7 +176,7 @@ impl DatabasePrivilegeEditEntry {
|
||||
}
|
||||
DatabasePrivilegeEditEntryType::Add | DatabasePrivilegeEditEntryType::Remove => {
|
||||
diff = DatabasePrivilegeRowDiff {
|
||||
db: database,
|
||||
db: self.database.clone(),
|
||||
user: self.user.clone(),
|
||||
select_priv: None,
|
||||
insert_priv: None,
|
||||
@@ -164,25 +190,25 @@ impl DatabasePrivilegeEditEntry {
|
||||
lock_tables_priv: None,
|
||||
references_priv: None,
|
||||
};
|
||||
let value = match self.type_ {
|
||||
let value = match self.privilege_edit.type_ {
|
||||
DatabasePrivilegeEditEntryType::Add => DatabasePrivilegeChange::NoToYes,
|
||||
DatabasePrivilegeEditEntryType::Remove => DatabasePrivilegeChange::YesToNo,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
for priv_char in &self.privileges {
|
||||
match priv_char.as_str() {
|
||||
"s" => diff.select_priv = Some(value),
|
||||
"i" => diff.insert_priv = Some(value),
|
||||
"u" => diff.update_priv = Some(value),
|
||||
"d" => diff.delete_priv = Some(value),
|
||||
"c" => diff.create_priv = Some(value),
|
||||
"D" => diff.drop_priv = Some(value),
|
||||
"a" => diff.alter_priv = Some(value),
|
||||
"I" => diff.index_priv = Some(value),
|
||||
"t" => diff.create_tmp_table_priv = Some(value),
|
||||
"l" => diff.lock_tables_priv = Some(value),
|
||||
"r" => diff.references_priv = Some(value),
|
||||
"A" => {
|
||||
for priv_char in &self.privilege_edit.privileges {
|
||||
match priv_char {
|
||||
's' => diff.select_priv = Some(value),
|
||||
'i' => diff.insert_priv = Some(value),
|
||||
'u' => diff.update_priv = Some(value),
|
||||
'd' => diff.delete_priv = Some(value),
|
||||
'c' => diff.create_priv = Some(value),
|
||||
'D' => diff.drop_priv = Some(value),
|
||||
'a' => diff.alter_priv = Some(value),
|
||||
'I' => diff.index_priv = Some(value),
|
||||
't' => diff.create_tmp_table_priv = Some(value),
|
||||
'l' => diff.lock_tables_priv = Some(value),
|
||||
'r' => diff.references_priv = Some(value),
|
||||
'A' => {
|
||||
diff.select_priv = Some(value);
|
||||
diff.insert_priv = Some(value);
|
||||
diff.update_priv = Some(value);
|
||||
@@ -207,19 +233,9 @@ impl DatabasePrivilegeEditEntry {
|
||||
|
||||
impl std::fmt::Display for DatabasePrivilegeEditEntry {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if let Some(db) = &self.database {
|
||||
write!(f, "{}:, ", db)?;
|
||||
}
|
||||
write!(f, "{}:, ", self.database)?;
|
||||
write!(f, "{}: ", self.user)?;
|
||||
match self.type_ {
|
||||
DatabasePrivilegeEditEntryType::Add => write!(f, "+")?,
|
||||
DatabasePrivilegeEditEntryType::Set => {}
|
||||
DatabasePrivilegeEditEntryType::Remove => write!(f, "-")?,
|
||||
}
|
||||
for priv_char in &self.privileges {
|
||||
write!(f, "{}", priv_char)?;
|
||||
}
|
||||
|
||||
write!(f, "{}", self.privilege_edit)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -234,10 +250,12 @@ mod tests {
|
||||
assert_eq!(
|
||||
result.ok(),
|
||||
Some(DatabasePrivilegeEditEntry {
|
||||
database: Some("db".into()),
|
||||
database: "db".into(),
|
||||
user: "user".into(),
|
||||
type_: DatabasePrivilegeEditEntryType::Set,
|
||||
privileges: vec!["A".into()],
|
||||
privilege_edit: DatabasePrivilegeEdit {
|
||||
type_: DatabasePrivilegeEditEntryType::Set,
|
||||
privileges: vec!['A'],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -248,10 +266,12 @@ mod tests {
|
||||
assert_eq!(
|
||||
result.ok(),
|
||||
Some(DatabasePrivilegeEditEntry {
|
||||
database: Some("db".into()),
|
||||
database: "db".into(),
|
||||
user: "user".into(),
|
||||
type_: DatabasePrivilegeEditEntryType::Set,
|
||||
privileges: vec![],
|
||||
privilege_edit: DatabasePrivilegeEdit {
|
||||
type_: DatabasePrivilegeEditEntryType::Set,
|
||||
privileges: vec![],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -262,28 +282,16 @@ mod tests {
|
||||
assert_eq!(
|
||||
result.ok(),
|
||||
Some(DatabasePrivilegeEditEntry {
|
||||
database: Some("db".into()),
|
||||
database: "db".into(),
|
||||
user: "user".into(),
|
||||
type_: DatabasePrivilegeEditEntryType::Set,
|
||||
privileges: vec!["s".into(), "i".into(), "u".into(), "d".into()],
|
||||
privilege_edit: DatabasePrivilegeEdit {
|
||||
type_: DatabasePrivilegeEditEntryType::Set,
|
||||
privileges: vec!['s', 'i', 'u', 'd'],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_arg_parse_set_user_nonexistent_misc() {
|
||||
let result = DatabasePrivilegeEditEntry::parse_from_str("user:siud");
|
||||
assert_eq!(
|
||||
result.ok(),
|
||||
Some(DatabasePrivilegeEditEntry {
|
||||
database: None,
|
||||
user: "user".into(),
|
||||
type_: DatabasePrivilegeEditEntryType::Set,
|
||||
privileges: vec!["s".into(), "i".into(), "u".into(), "d".into()],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_arg_parse_set_db_user_nonexistent_privilege() {
|
||||
let result = DatabasePrivilegeEditEntry::parse_from_str("db:user:F");
|
||||
@@ -308,10 +316,12 @@ mod tests {
|
||||
assert_eq!(
|
||||
result.ok(),
|
||||
Some(DatabasePrivilegeEditEntry {
|
||||
database: Some("db".into()),
|
||||
database: "db".into(),
|
||||
user: "user".into(),
|
||||
type_: DatabasePrivilegeEditEntryType::Add,
|
||||
privileges: vec!["s".into(), "i".into(), "u".into(), "d".into()],
|
||||
privilege_edit: DatabasePrivilegeEdit {
|
||||
type_: DatabasePrivilegeEditEntryType::Add,
|
||||
privileges: vec!['s', 'i', 'u', 'd'],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -322,10 +332,12 @@ mod tests {
|
||||
assert_eq!(
|
||||
result.ok(),
|
||||
Some(DatabasePrivilegeEditEntry {
|
||||
database: Some("db".into()),
|
||||
database: "db".into(),
|
||||
user: "user".into(),
|
||||
type_: DatabasePrivilegeEditEntryType::Remove,
|
||||
privileges: vec!["s".into(), "i".into(), "u".into(), "d".into()],
|
||||
privilege_edit: DatabasePrivilegeEdit {
|
||||
type_: DatabasePrivilegeEditEntryType::Remove,
|
||||
privileges: vec!['s', 'i', 'u', 'd'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ mod list_all_users;
|
||||
mod list_databases;
|
||||
mod list_privileges;
|
||||
mod list_users;
|
||||
mod list_valid_name_prefixes;
|
||||
mod lock_users;
|
||||
mod modify_privileges;
|
||||
mod passwd_user;
|
||||
@@ -29,6 +30,7 @@ pub use list_all_users::*;
|
||||
pub use list_databases::*;
|
||||
pub use list_privileges::*;
|
||||
pub use list_users::*;
|
||||
pub use list_valid_name_prefixes::*;
|
||||
pub use lock_users::*;
|
||||
pub use modify_privileges::*;
|
||||
pub use passwd_user::*;
|
||||
@@ -53,13 +55,26 @@ pub type ClientToServerMessageStream = SerdeFramed<
|
||||
Bincode<Response, Request>,
|
||||
>;
|
||||
|
||||
pub fn create_server_to_client_message_stream(socket: UnixStream) -> ServerToClientMessageStream {
|
||||
let length_delimited = Framed::new(socket, LengthDelimitedCodec::new());
|
||||
const MAX_REQUEST_FRAME_LENGTH: usize = 100 * 1024; // 100 KB
|
||||
const MAX_RESPONSE_FRAME_LENGTH: usize = 1024 * 1024; // 1 MB
|
||||
|
||||
pub fn create_client_to_server_message_stream(socket: UnixStream) -> ClientToServerMessageStream {
|
||||
let codec = {
|
||||
let mut codec = LengthDelimitedCodec::new();
|
||||
codec.set_max_frame_length(MAX_REQUEST_FRAME_LENGTH);
|
||||
codec
|
||||
};
|
||||
let length_delimited = Framed::new(socket, codec);
|
||||
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());
|
||||
pub fn create_server_to_client_message_stream(socket: UnixStream) -> ServerToClientMessageStream {
|
||||
let codec = {
|
||||
let mut codec = LengthDelimitedCodec::new();
|
||||
codec.set_max_frame_length(MAX_RESPONSE_FRAME_LENGTH);
|
||||
codec
|
||||
};
|
||||
let length_delimited = Framed::new(socket, codec);
|
||||
tokio_serde::Framed::new(length_delimited, Bincode::default())
|
||||
}
|
||||
|
||||
@@ -68,6 +83,7 @@ pub fn create_client_to_server_message_stream(socket: UnixStream) -> ClientToSer
|
||||
pub enum Request {
|
||||
CheckAuthorization(CheckAuthorizationRequest),
|
||||
|
||||
ListValidNamePrefixes,
|
||||
CompleteDatabaseName(CompleteDatabaseNameRequest),
|
||||
CompleteUserName(CompleteUserNameRequest),
|
||||
|
||||
@@ -95,6 +111,7 @@ pub enum Request {
|
||||
pub enum Response {
|
||||
CheckAuthorization(CheckAuthorizationResponse),
|
||||
|
||||
ListValidNamePrefixes(ListValidNamePrefixesResponse),
|
||||
CompleteDatabaseName(CompleteDatabaseNameResponse),
|
||||
CompleteUserName(CompleteUserNameResponse),
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn print_check_authorization_output_status(output: &CheckAuthorizationRespon
|
||||
println!("'{}': OK", db_or_user.name());
|
||||
}
|
||||
Err(err) => {
|
||||
println!(
|
||||
eprintln!(
|
||||
"'{}': {}",
|
||||
db_or_user.name(),
|
||||
err.to_error_message(db_or_user)
|
||||
|
||||
@@ -32,8 +32,8 @@ pub fn print_create_databases_output_status(output: &CreateDatabasesResponse) {
|
||||
println!("Database '{}' created successfully.", database_name);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(database_name));
|
||||
println!("Skipping...");
|
||||
eprintln!("{}", err.to_error_message(database_name));
|
||||
eprintln!("Skipping...");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
|
||||
@@ -32,8 +32,8 @@ pub fn print_create_users_output_status(output: &CreateUsersResponse) {
|
||||
println!("User '{}' created successfully.", username);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(username));
|
||||
println!("Skipping...");
|
||||
eprintln!("{}", err.to_error_message(username));
|
||||
eprintln!("Skipping...");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
|
||||
@@ -35,8 +35,8 @@ pub fn print_drop_databases_output_status(output: &DropDatabasesResponse) {
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(database_name));
|
||||
println!("Skipping...");
|
||||
eprintln!("{}", err.to_error_message(database_name));
|
||||
eprintln!("Skipping...");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
|
||||
@@ -32,8 +32,8 @@ pub fn print_drop_users_output_status(output: &DropUsersResponse) {
|
||||
println!("User '{}' dropped successfully.", username);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(username));
|
||||
println!("Skipping...");
|
||||
eprintln!("{}", err.to_error_message(username));
|
||||
eprintln!("Skipping...");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
|
||||
@@ -3,26 +3,25 @@ use thiserror::Error;
|
||||
|
||||
use crate::core::database_privileges::DatabasePrivilegeRow;
|
||||
|
||||
pub type ListAllPrivilegesResponse =
|
||||
Result<Vec<DatabasePrivilegeRow>, GetAllDatabasesPrivilegeDataError>;
|
||||
pub type ListAllPrivilegesResponse = Result<Vec<DatabasePrivilegeRow>, ListAllPrivilegesError>;
|
||||
|
||||
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum GetAllDatabasesPrivilegeDataError {
|
||||
pub enum ListAllPrivilegesError {
|
||||
#[error("MySQL error: {0}")]
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
impl GetAllDatabasesPrivilegeDataError {
|
||||
impl ListAllPrivilegesError {
|
||||
pub fn to_error_message(&self) -> String {
|
||||
match self {
|
||||
GetAllDatabasesPrivilegeDataError::MySqlError(err) => format!("MySQL error: {}", err),
|
||||
ListAllPrivilegesError::MySqlError(err) => format!("MySQL error: {}", err),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn error_type(&self) -> String {
|
||||
match self {
|
||||
GetAllDatabasesPrivilegeDataError::MySqlError(_) => "mysql-error".to_string(),
|
||||
ListAllPrivilegesError::MySqlError(_) => "mysql-error".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,10 @@ pub enum ListDatabasesError {
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
pub fn print_list_databases_output_status(output: &ListDatabasesResponse) {
|
||||
pub fn print_list_databases_output_status(
|
||||
output: &ListDatabasesResponse,
|
||||
display_size_as_bytes: bool,
|
||||
) {
|
||||
let mut final_database_list: Vec<&DatabaseRow> = Vec::new();
|
||||
for (db_name, db_result) in output {
|
||||
match db_result {
|
||||
@@ -52,7 +55,11 @@ pub fn print_list_databases_output_status(output: &ListDatabasesResponse) {
|
||||
"Users",
|
||||
"Collation",
|
||||
"Character Set",
|
||||
"Size (Bytes)"
|
||||
if display_size_as_bytes {
|
||||
"Size (Bytes)"
|
||||
} else {
|
||||
"Size"
|
||||
}
|
||||
]);
|
||||
for db in final_database_list {
|
||||
table.add_row(row![
|
||||
@@ -61,7 +68,11 @@ pub fn print_list_databases_output_status(output: &ListDatabasesResponse) {
|
||||
db.users.iter().map(|user| user.as_str()).join("\n"),
|
||||
db.collation.as_deref().unwrap_or("N/A"),
|
||||
db.character_set.as_deref().unwrap_or("N/A"),
|
||||
db.size_bytes,
|
||||
if display_size_as_bytes {
|
||||
db.size_bytes.to_string()
|
||||
} else {
|
||||
humansize::format_size(db.size_bytes, humansize::DECIMAL)
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ use crate::core::{
|
||||
pub type ListPrivilegesRequest = Option<Vec<MySQLDatabase>>;
|
||||
|
||||
pub type ListPrivilegesResponse =
|
||||
BTreeMap<MySQLDatabase, Result<Vec<DatabasePrivilegeRow>, GetDatabasesPrivilegeDataError>>;
|
||||
BTreeMap<MySQLDatabase, Result<Vec<DatabasePrivilegeRow>, ListPrivilegesError>>;
|
||||
|
||||
pub fn print_list_privileges_output_status(output: &ListPrivilegesResponse, long_names: bool) {
|
||||
let mut final_privs_map: BTreeMap<MySQLDatabase, Vec<DatabasePrivilegeRow>> = BTreeMap::new();
|
||||
@@ -117,7 +117,7 @@ pub fn print_list_privileges_output_status_json(output: &ListPrivilegesResponse)
|
||||
}
|
||||
|
||||
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum GetDatabasesPrivilegeDataError {
|
||||
pub enum ListPrivilegesError {
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(#[from] ValidationError),
|
||||
|
||||
@@ -128,16 +128,16 @@ pub enum GetDatabasesPrivilegeDataError {
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
impl GetDatabasesPrivilegeDataError {
|
||||
impl ListPrivilegesError {
|
||||
pub fn to_error_message(&self, database_name: &MySQLDatabase) -> String {
|
||||
match self {
|
||||
GetDatabasesPrivilegeDataError::ValidationError(err) => {
|
||||
ListPrivilegesError::ValidationError(err) => {
|
||||
err.to_error_message(DbOrUser::Database(database_name.clone()))
|
||||
}
|
||||
GetDatabasesPrivilegeDataError::DatabaseDoesNotExist => {
|
||||
ListPrivilegesError::DatabaseDoesNotExist => {
|
||||
format!("Database '{}' does not exist.", database_name)
|
||||
}
|
||||
GetDatabasesPrivilegeDataError::MySqlError(err) => {
|
||||
ListPrivilegesError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
@@ -145,11 +145,9 @@ impl GetDatabasesPrivilegeDataError {
|
||||
|
||||
pub fn error_type(&self) -> String {
|
||||
match self {
|
||||
GetDatabasesPrivilegeDataError::ValidationError(err) => err.error_type(),
|
||||
GetDatabasesPrivilegeDataError::DatabaseDoesNotExist => {
|
||||
"database-does-not-exist".to_string()
|
||||
}
|
||||
GetDatabasesPrivilegeDataError::MySqlError(_) => "mysql-error".to_string(),
|
||||
ListPrivilegesError::ValidationError(err) => err.error_type(),
|
||||
ListPrivilegesError::DatabaseDoesNotExist => "database-does-not-exist".to_string(),
|
||||
ListPrivilegesError::MySqlError(_) => "mysql-error".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
src/core/protocol/commands/list_valid_name_prefixes.rs
Normal file
1
src/core/protocol/commands/list_valid_name_prefixes.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub type ListValidNamePrefixesResponse = Vec<String>;
|
||||
@@ -35,8 +35,8 @@ pub fn print_lock_users_output_status(output: &LockUsersResponse) {
|
||||
println!("User '{}' locked successfully.", username);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(username));
|
||||
println!("Skipping...");
|
||||
eprintln!("{}", err.to_error_message(username));
|
||||
eprintln!("Skipping...");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
|
||||
@@ -58,8 +58,8 @@ pub fn print_modify_database_privileges_output_status(output: &ModifyPrivilegesR
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(database_name, username));
|
||||
println!("Skipping...");
|
||||
eprintln!("{}", err.to_error_message(database_name, username));
|
||||
eprintln!("Skipping...");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
|
||||
@@ -28,8 +28,8 @@ pub fn print_set_password_output_status(output: &SetUserPasswordResponse, userna
|
||||
println!("Password for user '{}' set successfully.", username);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(username));
|
||||
println!("Skipping...");
|
||||
eprintln!("{}", err.to_error_message(username));
|
||||
eprintln!("Skipping...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ pub fn print_unlock_users_output_status(output: &UnlockUsersResponse) {
|
||||
println!("User '{}' unlocked successfully.", username);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(username));
|
||||
println!("Skipping...");
|
||||
eprintln!("{}", err.to_error_message(username));
|
||||
eprintln!("Skipping...");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use indoc::indoc;
|
||||
use itertools::Itertools;
|
||||
use nix::{libc::gid_t, unistd::Group};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -23,23 +25,19 @@ impl NameValidationError {
|
||||
pub fn to_error_message(self, db_or_user: DbOrUser) -> String {
|
||||
match self {
|
||||
NameValidationError::EmptyString => {
|
||||
format!("{} name cannot be empty.", db_or_user.capitalized_noun()).to_owned()
|
||||
format!("{} name can not be empty.", db_or_user.capitalized_noun())
|
||||
}
|
||||
NameValidationError::TooLong => format!(
|
||||
"{} is too long. Maximum length is 64 characters.",
|
||||
"{} is too long, maximum length is 64 characters.",
|
||||
db_or_user.capitalized_noun()
|
||||
)
|
||||
.to_owned(),
|
||||
),
|
||||
NameValidationError::InvalidCharacters => format!(
|
||||
indoc! {r#"
|
||||
Invalid characters in {} name: '{}'
|
||||
|
||||
Only A-Z, a-z, 0-9, _ (underscore) and - (dash) are permitted.
|
||||
Invalid characters in {} name: '{}', only A-Z, a-z, 0-9, _ (underscore) and - (dash) are permitted.
|
||||
"#},
|
||||
db_or_user.lowercased_noun(),
|
||||
db_or_user.name(),
|
||||
)
|
||||
.to_owned(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,64 +52,41 @@ impl NameValidationError {
|
||||
|
||||
#[derive(Error, Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum AuthorizationError {
|
||||
#[error("No matching owner prefix found")]
|
||||
NoMatch,
|
||||
#[error("Illegal prefix, user is not authorized to manage this resource")]
|
||||
IllegalPrefix,
|
||||
|
||||
// TODO: I don't think this should ever happen?
|
||||
#[error("Name cannot be empty")]
|
||||
StringEmpty,
|
||||
|
||||
#[error("Group was found in denylist")]
|
||||
DenylistError,
|
||||
}
|
||||
|
||||
impl AuthorizationError {
|
||||
pub fn to_error_message(self, db_or_user: DbOrUser) -> String {
|
||||
let user = UnixUser::from_enviroment();
|
||||
|
||||
let UnixUser {
|
||||
username,
|
||||
mut groups,
|
||||
} = user.unwrap_or(UnixUser {
|
||||
username: "???".to_string(),
|
||||
groups: vec![],
|
||||
});
|
||||
|
||||
groups.sort();
|
||||
|
||||
match self {
|
||||
AuthorizationError::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:
|
||||
- {}
|
||||
{}
|
||||
"#},
|
||||
AuthorizationError::IllegalPrefix => format!(
|
||||
"Illegal {} name prefix: you are not allowed to manage databases or users prefixed with '{}'",
|
||||
db_or_user.lowercased_noun(),
|
||||
db_or_user.name(),
|
||||
db_or_user.lowercased_noun(),
|
||||
db_or_user.lowercased_noun(),
|
||||
username,
|
||||
groups
|
||||
.into_iter()
|
||||
.filter(|g| g != &username)
|
||||
.map(|g| format!(" - {}", g))
|
||||
.join("\n"),
|
||||
db_or_user.prefix(),
|
||||
)
|
||||
.to_owned(),
|
||||
AuthorizationError::StringEmpty => format!(
|
||||
"'{}' is not a valid {} name.",
|
||||
db_or_user.name(),
|
||||
db_or_user.lowercased_noun()
|
||||
)
|
||||
.to_string(),
|
||||
// TODO: This error message could be clearer
|
||||
AuthorizationError::StringEmpty => {
|
||||
format!("{} name can not be empty.", db_or_user.capitalized_noun())
|
||||
}
|
||||
AuthorizationError::DenylistError => {
|
||||
format!("'{}' is denied by the group denylist", db_or_user.name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_type(&self) -> &'static str {
|
||||
match self {
|
||||
AuthorizationError::NoMatch => "no-match",
|
||||
AuthorizationError::IllegalPrefix => "illegal-prefix",
|
||||
AuthorizationError::StringEmpty => "string-empty",
|
||||
AuthorizationError::DenylistError => "denylist-error",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,6 +130,8 @@ impl ValidationError {
|
||||
}
|
||||
}
|
||||
|
||||
pub type GroupDenylist = HashSet<gid_t>;
|
||||
|
||||
const MAX_NAME_LENGTH: usize = 64;
|
||||
|
||||
pub fn validate_name(name: &str) -> Result<(), NameValidationError> {
|
||||
@@ -201,21 +178,49 @@ pub fn validate_authorization_by_prefixes(
|
||||
.collect::<Vec<_>>()
|
||||
.is_empty()
|
||||
{
|
||||
return Err(AuthorizationError::NoMatch);
|
||||
return Err(AuthorizationError::IllegalPrefix);
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_authorization_by_group_denylist(
|
||||
name: &str,
|
||||
user: &UnixUser,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> Result<(), AuthorizationError> {
|
||||
// NOTE: if the username matches, we allow it regardless of denylist
|
||||
if user.username == name {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let user_group = Group::from_name(name)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|g| g.gid.as_raw());
|
||||
|
||||
if let Some(gid) = user_group
|
||||
&& group_denylist.contains(&gid)
|
||||
{
|
||||
Err(AuthorizationError::DenylistError)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_db_or_user_request(
|
||||
db_or_user: &DbOrUser,
|
||||
unix_user: &UnixUser,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> Result<(), ValidationError> {
|
||||
validate_name(db_or_user.name()).map_err(ValidationError::NameValidationError)?;
|
||||
|
||||
validate_authorization_by_unix_user(db_or_user.name(), unix_user)
|
||||
.map_err(ValidationError::AuthorizationError)?;
|
||||
|
||||
validate_authorization_by_group_denylist(db_or_user.name(), unix_user, group_denylist)
|
||||
.map_err(ValidationError::AuthorizationError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -273,7 +278,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
validate_authorization_by_prefixes("nonexistent_testdb", &prefixes),
|
||||
Err(AuthorizationError::NoMatch)
|
||||
Err(AuthorizationError::IllegalPrefix)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,4 +132,11 @@ impl DbOrUser {
|
||||
DbOrUser::User(user) => user.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prefix(&self) -> &str {
|
||||
match self {
|
||||
DbOrUser::Database(db) => db.split('_').next().unwrap_or("?"),
|
||||
DbOrUser::User(user) => user.split('_').next().unwrap_or("?"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
35
src/main.rs
35
src/main.rs
@@ -62,6 +62,32 @@ const fn long_version() -> &'static str {
|
||||
|
||||
const LONG_VERSION: &str = long_version();
|
||||
|
||||
const EXAMPLES: &str = const_format::concatcp!(
|
||||
color_print::cstr!("<bold><underline>Examples:</underline></bold>"),
|
||||
r#"
|
||||
# Display help information for any specific command
|
||||
muscl <command> --help
|
||||
|
||||
# Create two users 'alice_user1' and 'alice_user2'
|
||||
muscl create-user alice_user1 alice_user2
|
||||
|
||||
# Create two databases 'alice_db1' and 'alice_db2'
|
||||
muscl create-db alice_db1 alice_db2
|
||||
|
||||
# Grant Select, Update, Insert and Delete privileges on 'alice_db1' to 'alice_user1'
|
||||
muscl edit-privs alice_db1 alice_user1 +suid
|
||||
|
||||
# Show all databases
|
||||
muscl show-db
|
||||
|
||||
# Show which users have privileges on which databases
|
||||
muscl show-privs
|
||||
"#,
|
||||
);
|
||||
|
||||
const BEFORE_LONG_HELP: &str = const_format::concatcp!("\x1b[1m", ASCII_BANNER, "\x1b[0m");
|
||||
const AFTER_LONG_HELP: &str = const_format::concatcp!(EXAMPLES, "\n", KIND_REGARDS,);
|
||||
|
||||
/// 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.
|
||||
@@ -76,8 +102,8 @@ const LONG_VERSION: &str = long_version();
|
||||
about,
|
||||
disable_help_subcommand = true,
|
||||
propagate_version = true,
|
||||
before_long_help = ASCII_BANNER,
|
||||
after_long_help = KIND_REGARDS,
|
||||
before_long_help = BEFORE_LONG_HELP,
|
||||
after_long_help = AFTER_LONG_HELP,
|
||||
long_version = LONG_VERSION,
|
||||
// NOTE: All non-registered "subcommands" are processed before Arg::parse() is called.
|
||||
subcommand_required = true,
|
||||
@@ -86,9 +112,11 @@ struct Args {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
|
||||
// NOTE: be careful not to add short options that collide with the `edit-privs` privilege
|
||||
// characters. It should in theory be possible for `edit-privs` to ignore any options
|
||||
// specified here, but in practice clap is being difficult to work with.
|
||||
/// Path to the socket of the server, if it already exists.
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
value_name = "PATH",
|
||||
value_hint = clap::ValueHint::FilePath,
|
||||
@@ -99,7 +127,6 @@ struct Args {
|
||||
|
||||
/// Config file to use for the server.
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
value_name = "PATH",
|
||||
value_hint = clap::ValueHint::FilePath,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
mod authorization;
|
||||
pub mod authorization;
|
||||
pub mod command;
|
||||
mod common;
|
||||
pub mod config;
|
||||
|
||||
@@ -1,25 +1,148 @@
|
||||
use std::{collections::HashSet, path::Path};
|
||||
|
||||
use anyhow::Context;
|
||||
use nix::unistd::Group;
|
||||
|
||||
use crate::core::{
|
||||
common::UnixUser,
|
||||
protocol::{CheckAuthorizationError, request_validation::validate_db_or_user_request},
|
||||
protocol::{
|
||||
CheckAuthorizationError,
|
||||
request_validation::{GroupDenylist, validate_db_or_user_request},
|
||||
},
|
||||
types::DbOrUser,
|
||||
};
|
||||
|
||||
pub async fn check_authorization(
|
||||
dbs_or_users: Vec<DbOrUser>,
|
||||
unix_user: &UnixUser,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> std::collections::BTreeMap<DbOrUser, Result<(), CheckAuthorizationError>> {
|
||||
let mut results = std::collections::BTreeMap::new();
|
||||
|
||||
for db_or_user in dbs_or_users {
|
||||
if let Err(err) =
|
||||
validate_db_or_user_request(&db_or_user, unix_user).map_err(CheckAuthorizationError)
|
||||
if let Err(err) = validate_db_or_user_request(&db_or_user, unix_user, group_denylist)
|
||||
.map_err(CheckAuthorizationError)
|
||||
{
|
||||
results.insert(db_or_user.clone(), Err(err));
|
||||
continue;
|
||||
}
|
||||
|
||||
results.insert(db_or_user.clone(), Ok(()));
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Reads and parses a group denylist file, returning a set of GUIDs
|
||||
///
|
||||
/// The format of the denylist file is expected to be one group name or GID per line.
|
||||
/// Lines starting with '#' are treated as comments and ignored.
|
||||
/// Empty lines are also ignored.
|
||||
///
|
||||
/// Each line looks like one of the following:
|
||||
/// - `gid:1001`
|
||||
/// - `group:admins`
|
||||
pub fn read_and_parse_group_denylist(denylist_path: &Path) -> anyhow::Result<GroupDenylist> {
|
||||
let content = std::fs::read_to_string(denylist_path).context(format!(
|
||||
"Failed to read denylist file at {:?}",
|
||||
denylist_path
|
||||
))?;
|
||||
|
||||
let mut groups = HashSet::with_capacity(content.lines().count());
|
||||
|
||||
for (line_number, line) in content.lines().enumerate() {
|
||||
let trimmed_line = line.trim();
|
||||
|
||||
if trimmed_line.is_empty() || trimmed_line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = trimmed_line.splitn(2, ':').collect();
|
||||
if parts.len() != 2 {
|
||||
tracing::warn!(
|
||||
"Invalid format in denylist file at {:?} on line {}: {}",
|
||||
denylist_path,
|
||||
line_number + 1,
|
||||
line
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
match parts[0] {
|
||||
"gid" => {
|
||||
let gid: u32 = match parts[1].parse() {
|
||||
Ok(gid) => gid,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"Invalid GID '{}' in denylist file at {:?} on line {}: {}",
|
||||
parts[1],
|
||||
denylist_path,
|
||||
line_number + 1,
|
||||
err
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let group = match Group::from_gid(nix::unistd::Gid::from_raw(gid)) {
|
||||
Ok(Some(g)) => g,
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
"No group found for GID {} in denylist file at {:?} on line {}",
|
||||
gid,
|
||||
denylist_path,
|
||||
line_number + 1
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"Failed to get group for GID {} in denylist file at {:?} on line {}: {}",
|
||||
gid,
|
||||
denylist_path,
|
||||
line_number + 1,
|
||||
err
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
groups.insert(group.gid.as_raw());
|
||||
}
|
||||
"group" => match Group::from_name(parts[1]) {
|
||||
Ok(Some(group)) => {
|
||||
groups.insert(group.gid.as_raw());
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
"No group found for name '{}' in denylist file at {:?} on line {}",
|
||||
parts[1],
|
||||
denylist_path,
|
||||
line_number + 1
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"Failed to get group for name '{}' in denylist file at {:?} on line {}: {}",
|
||||
parts[1],
|
||||
denylist_path,
|
||||
line_number + 1,
|
||||
err
|
||||
);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"Invalid prefix '{}' in denylist file at {:?} on line {}: {}",
|
||||
parts[0],
|
||||
denylist_path,
|
||||
line_number + 1,
|
||||
line
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ pub struct ServerArgs {
|
||||
pub subcmd: ServerCommand,
|
||||
|
||||
/// Enable systemd mode
|
||||
#[cfg(target_os = "linux")]
|
||||
#[arg(long)]
|
||||
pub systemd: bool,
|
||||
|
||||
@@ -58,6 +59,8 @@ pub async fn handle_command(
|
||||
args: ServerArgs,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut auto_detected_systemd_mode = false;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let systemd_mode = args.systemd || {
|
||||
if let Ok(true) = sd_notify::booted() {
|
||||
auto_detected_systemd_mode = true;
|
||||
@@ -67,24 +70,30 @@ pub async fn handle_command(
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let systemd_mode = false;
|
||||
|
||||
if systemd_mode {
|
||||
let subscriber = tracing_subscriber::Registry::default()
|
||||
.with(verbosity.tracing_level_filter())
|
||||
.with(tracing_journald::layer()?);
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let subscriber = tracing_subscriber::Registry::default()
|
||||
.with(verbosity.tracing_level_filter())
|
||||
.with(tracing_journald::layer()?);
|
||||
|
||||
tracing::subscriber::set_global_default(subscriber)
|
||||
.context("Failed to set global default tracing subscriber")?;
|
||||
tracing::subscriber::set_global_default(subscriber)
|
||||
.context("Failed to set global default tracing subscriber")?;
|
||||
|
||||
trace_server_prelude();
|
||||
trace_server_prelude();
|
||||
|
||||
if verbosity.tracing_level_filter() >= tracing::Level::TRACE {
|
||||
tracing::warn!("{}", LOG_LEVEL_WARNING.trim());
|
||||
}
|
||||
if verbosity.tracing_level_filter() >= tracing::Level::TRACE {
|
||||
tracing::warn!("{}", LOG_LEVEL_WARNING.trim());
|
||||
}
|
||||
|
||||
if auto_detected_systemd_mode {
|
||||
tracing::debug!("Running in systemd mode, auto-detected");
|
||||
} else {
|
||||
tracing::debug!("Running in systemd mode");
|
||||
if auto_detected_systemd_mode {
|
||||
tracing::debug!("Running in systemd mode, auto-detected");
|
||||
} else {
|
||||
tracing::debug!("Running in systemd mode");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let subscriber = tracing_subscriber::Registry::default()
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
use crate::core::common::UnixUser;
|
||||
use crate::core::{common::UnixUser, protocol::request_validation::GroupDenylist};
|
||||
use nix::unistd::Group;
|
||||
use sqlx::prelude::*;
|
||||
|
||||
/// This function retrieves the groups of a user, filtering out any groups
|
||||
/// that are present in the provided denylist.
|
||||
pub fn get_user_filtered_groups(user: &UnixUser, group_denylist: &GroupDenylist) -> Vec<String> {
|
||||
user.groups
|
||||
.iter()
|
||||
.cloned()
|
||||
.filter_map(|group_name| {
|
||||
match Group::from_name(&group_name) {
|
||||
Ok(Some(group)) => {
|
||||
if group_denylist.contains(&group.gid.as_raw()) {
|
||||
None
|
||||
} else {
|
||||
Some(group.name)
|
||||
}
|
||||
}
|
||||
// NOTE: allow non-existing groups to pass through the filter
|
||||
_ => Some(group_name),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
pub fn create_user_group_matching_regex(user: &UnixUser, group_denylist: &GroupDenylist) -> String {
|
||||
let filtered_groups = get_user_filtered_groups(user, group_denylist);
|
||||
if filtered_groups.is_empty() {
|
||||
format!("{}_.+", user.username)
|
||||
} else {
|
||||
format!("({}|{})_.+", user.username, user.groups.join("|"))
|
||||
format!("({}|{})_.+", user.username, filtered_groups.join("|"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +61,8 @@ mod tests {
|
||||
groups: vec!["group1".to_owned(), "group2".to_owned()],
|
||||
};
|
||||
|
||||
let regex = create_user_group_matching_regex(&user);
|
||||
let regex = create_user_group_matching_regex(&user, &GroupDenylist::new());
|
||||
println!("Generated regex: {}", regex);
|
||||
let re = Regex::new(®ex).unwrap();
|
||||
|
||||
assert!(re.is_match("user_something"));
|
||||
|
||||
@@ -78,9 +78,15 @@ impl MysqlConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
pub struct AuthorizationConfig {
|
||||
pub group_denylist_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
pub struct ServerConfig {
|
||||
pub socket_path: Option<PathBuf>,
|
||||
pub authorization: AuthorizationConfig,
|
||||
pub mysql: MysqlConfig,
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,12 @@ use crate::{
|
||||
common::UnixUser,
|
||||
protocol::{
|
||||
Request, Response, ServerToClientMessageStream, SetPasswordError,
|
||||
create_server_to_client_message_stream,
|
||||
create_server_to_client_message_stream, request_validation::GroupDenylist,
|
||||
},
|
||||
},
|
||||
server::{
|
||||
authorization::check_authorization,
|
||||
common::get_user_filtered_groups,
|
||||
sql::{
|
||||
database_operations::{
|
||||
complete_database_name, create_databases, drop_databases,
|
||||
@@ -39,6 +40,7 @@ pub async fn session_handler(
|
||||
socket: UnixStream,
|
||||
db_pool: Arc<RwLock<MySqlPool>>,
|
||||
db_is_mariadb: bool,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> anyhow::Result<()> {
|
||||
let uid = match socket.peer_cred() {
|
||||
Ok(cred) => cred.uid(),
|
||||
@@ -85,8 +87,14 @@ pub async fn session_handler(
|
||||
(async move {
|
||||
tracing::info!("Accepted connection from user: {}", unix_user);
|
||||
|
||||
let result =
|
||||
session_handler_with_unix_user(socket, &unix_user, db_pool, db_is_mariadb).await;
|
||||
let result = session_handler_with_unix_user(
|
||||
socket,
|
||||
&unix_user,
|
||||
db_pool,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
|
||||
tracing::info!(
|
||||
"Finished handling requests for connection from user: {}",
|
||||
@@ -104,6 +112,7 @@ pub async fn session_handler_with_unix_user(
|
||||
unix_user: &UnixUser,
|
||||
db_pool: Arc<RwLock<MySqlPool>>,
|
||||
db_is_mariadb: bool,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut message_stream = create_server_to_client_message_stream(socket);
|
||||
|
||||
@@ -131,6 +140,7 @@ pub async fn session_handler_with_unix_user(
|
||||
unix_user,
|
||||
&mut db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -147,6 +157,7 @@ async fn session_handler_with_db_connection(
|
||||
unix_user: &UnixUser,
|
||||
db_connection: &mut MySqlConnection,
|
||||
db_is_mariadb: bool,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> anyhow::Result<()> {
|
||||
stream.send(Response::Ready).await?;
|
||||
loop {
|
||||
@@ -178,9 +189,19 @@ async fn session_handler_with_db_connection(
|
||||
|
||||
let response = match request {
|
||||
Request::CheckAuthorization(dbs_or_users) => {
|
||||
let result = check_authorization(dbs_or_users, unix_user).await;
|
||||
let result = check_authorization(dbs_or_users, unix_user, group_denylist).await;
|
||||
Response::CheckAuthorization(result)
|
||||
}
|
||||
Request::ListValidNamePrefixes => {
|
||||
let mut result = Vec::with_capacity(unix_user.groups.len() + 1);
|
||||
result.push(unix_user.username.to_owned());
|
||||
|
||||
for group in get_user_filtered_groups(unix_user, group_denylist) {
|
||||
result.push(group.to_owned());
|
||||
}
|
||||
|
||||
Response::ListValidNamePrefixes(result)
|
||||
}
|
||||
Request::CompleteDatabaseName(partial_database_name) => {
|
||||
// TODO: more correct validation here
|
||||
if !partial_database_name
|
||||
@@ -194,6 +215,7 @@ async fn session_handler_with_db_connection(
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::CompleteDatabaseName(result)
|
||||
@@ -212,32 +234,54 @@ async fn session_handler_with_db_connection(
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::CompleteUserName(result)
|
||||
}
|
||||
}
|
||||
Request::CreateDatabases(databases_names) => {
|
||||
let result =
|
||||
create_databases(databases_names, unix_user, db_connection, db_is_mariadb)
|
||||
.await;
|
||||
let result = create_databases(
|
||||
databases_names,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::CreateDatabases(result)
|
||||
}
|
||||
Request::DropDatabases(databases_names) => {
|
||||
let result =
|
||||
drop_databases(databases_names, unix_user, db_connection, db_is_mariadb).await;
|
||||
let result = drop_databases(
|
||||
databases_names,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::DropDatabases(result)
|
||||
}
|
||||
Request::ListDatabases(database_names) => match database_names {
|
||||
Some(database_names) => {
|
||||
let result =
|
||||
list_databases(database_names, unix_user, db_connection, db_is_mariadb)
|
||||
.await;
|
||||
let result = list_databases(
|
||||
database_names,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::ListDatabases(result)
|
||||
}
|
||||
None => {
|
||||
let result =
|
||||
list_all_databases_for_user(unix_user, db_connection, db_is_mariadb).await;
|
||||
let result = list_all_databases_for_user(
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::ListAllDatabases(result)
|
||||
}
|
||||
},
|
||||
@@ -248,13 +292,19 @@ async fn session_handler_with_db_connection(
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::ListPrivileges(privilege_data)
|
||||
}
|
||||
None => {
|
||||
let privilege_data =
|
||||
get_all_database_privileges(unix_user, db_connection, db_is_mariadb).await;
|
||||
let privilege_data = get_all_database_privileges(
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::ListAllPrivileges(privilege_data)
|
||||
}
|
||||
},
|
||||
@@ -264,18 +314,31 @@ async fn session_handler_with_db_connection(
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::ModifyPrivileges(result)
|
||||
}
|
||||
Request::CreateUsers(db_users) => {
|
||||
let result =
|
||||
create_database_users(db_users, unix_user, db_connection, db_is_mariadb).await;
|
||||
let result = create_database_users(
|
||||
db_users,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::CreateUsers(result)
|
||||
}
|
||||
Request::DropUsers(db_users) => {
|
||||
let result =
|
||||
drop_database_users(db_users, unix_user, db_connection, db_is_mariadb).await;
|
||||
let result = drop_database_users(
|
||||
db_users,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::DropUsers(result)
|
||||
}
|
||||
Request::PasswdUser((db_user, password)) => {
|
||||
@@ -285,15 +348,21 @@ async fn session_handler_with_db_connection(
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::SetUserPassword(result)
|
||||
}
|
||||
Request::ListUsers(db_users) => match db_users {
|
||||
Some(db_users) => {
|
||||
let result =
|
||||
list_database_users(db_users, unix_user, db_connection, db_is_mariadb)
|
||||
.await;
|
||||
let result = list_database_users(
|
||||
db_users,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::ListUsers(result)
|
||||
}
|
||||
None => {
|
||||
@@ -301,19 +370,32 @@ async fn session_handler_with_db_connection(
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::ListAllUsers(result)
|
||||
}
|
||||
},
|
||||
Request::LockUsers(db_users) => {
|
||||
let result =
|
||||
lock_database_users(db_users, unix_user, db_connection, db_is_mariadb).await;
|
||||
let result = lock_database_users(
|
||||
db_users,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::LockUsers(result)
|
||||
}
|
||||
Request::UnlockUsers(db_users) => {
|
||||
let result =
|
||||
unlock_database_users(db_users, unix_user, db_connection, db_is_mariadb).await;
|
||||
let result = unlock_database_users(
|
||||
db_users,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
group_denylist,
|
||||
)
|
||||
.await;
|
||||
Response::UnlockUsers(result)
|
||||
}
|
||||
Request::Exit => {
|
||||
|
||||
@@ -6,6 +6,7 @@ use sqlx::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::protocol::CompleteDatabaseNameResponse;
|
||||
use crate::core::protocol::request_validation::GroupDenylist;
|
||||
use crate::core::protocol::request_validation::validate_db_or_user_request;
|
||||
use crate::core::types::DbOrUser;
|
||||
use crate::core::types::MySQLDatabase;
|
||||
@@ -49,6 +50,7 @@ pub async fn complete_database_name(
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> CompleteDatabaseNameResponse {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
@@ -59,7 +61,7 @@ pub async fn complete_database_name(
|
||||
AND `SCHEMA_NAME` LIKE ?
|
||||
"#,
|
||||
)
|
||||
.bind(create_user_group_matching_regex(unix_user))
|
||||
.bind(create_user_group_matching_regex(unix_user, group_denylist))
|
||||
.bind(format!("{}%", database_prefix))
|
||||
.fetch_all(connection)
|
||||
.await;
|
||||
@@ -89,13 +91,17 @@ pub async fn create_databases(
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> CreateDatabasesResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for database_name in database_names {
|
||||
if let Err(err) =
|
||||
validate_db_or_user_request(&DbOrUser::Database(database_name.clone()), unix_user)
|
||||
.map_err(CreateDatabaseError::ValidationError)
|
||||
if let Err(err) = validate_db_or_user_request(
|
||||
&DbOrUser::Database(database_name.clone()),
|
||||
unix_user,
|
||||
group_denylist,
|
||||
)
|
||||
.map_err(CreateDatabaseError::ValidationError)
|
||||
{
|
||||
results.insert(database_name.to_owned(), Err(err));
|
||||
continue;
|
||||
@@ -141,13 +147,17 @@ pub async fn drop_databases(
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> DropDatabasesResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for database_name in database_names {
|
||||
if let Err(err) =
|
||||
validate_db_or_user_request(&DbOrUser::Database(database_name.clone()), unix_user)
|
||||
.map_err(DropDatabaseError::ValidationError)
|
||||
if let Err(err) = validate_db_or_user_request(
|
||||
&DbOrUser::Database(database_name.clone()),
|
||||
unix_user,
|
||||
group_denylist,
|
||||
)
|
||||
.map_err(DropDatabaseError::ValidationError)
|
||||
{
|
||||
results.insert(database_name.to_owned(), Err(err));
|
||||
continue;
|
||||
@@ -236,13 +246,17 @@ pub async fn list_databases(
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> ListDatabasesResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for database_name in database_names {
|
||||
if let Err(err) =
|
||||
validate_db_or_user_request(&DbOrUser::Database(database_name.clone()), unix_user)
|
||||
.map_err(ListDatabasesError::ValidationError)
|
||||
if let Err(err) = validate_db_or_user_request(
|
||||
&DbOrUser::Database(database_name.clone()),
|
||||
unix_user,
|
||||
group_denylist,
|
||||
)
|
||||
.map_err(ListDatabasesError::ValidationError)
|
||||
{
|
||||
results.insert(database_name.to_owned(), Err(err));
|
||||
continue;
|
||||
@@ -296,6 +310,7 @@ pub async fn list_all_databases_for_user(
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> ListAllDatabasesResponse {
|
||||
let result = sqlx::query_as::<_, DatabaseRow>(
|
||||
r#"
|
||||
@@ -319,7 +334,7 @@ pub async fn list_all_databases_for_user(
|
||||
GROUP BY `information_schema`.`SCHEMATA`.`SCHEMA_NAME`
|
||||
"#,
|
||||
)
|
||||
.bind(create_user_group_matching_regex(unix_user))
|
||||
.bind(create_user_group_matching_regex(unix_user, group_denylist))
|
||||
.fetch_all(connection)
|
||||
.await
|
||||
.map_err(|err| ListAllDatabasesError::MySqlError(err.to_string()));
|
||||
|
||||
@@ -28,10 +28,10 @@ use crate::{
|
||||
DatabasePrivilegesDiff,
|
||||
},
|
||||
protocol::{
|
||||
DiffDoesNotApplyError, GetAllDatabasesPrivilegeDataError,
|
||||
GetDatabasesPrivilegeDataError, ListAllPrivilegesResponse, ListPrivilegesResponse,
|
||||
ModifyDatabasePrivilegesError, ModifyPrivilegesResponse,
|
||||
request_validation::validate_db_or_user_request,
|
||||
DiffDoesNotApplyError, ListAllPrivilegesError, ListAllPrivilegesResponse,
|
||||
ListPrivilegesError, ListPrivilegesResponse, ModifyDatabasePrivilegesError,
|
||||
ModifyPrivilegesResponse,
|
||||
request_validation::{GroupDenylist, validate_db_or_user_request},
|
||||
},
|
||||
types::{DbOrUser, MySQLDatabase, MySQLUser},
|
||||
},
|
||||
@@ -143,13 +143,17 @@ pub async fn get_databases_privilege_data(
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> ListPrivilegesResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for database_name in database_names.iter() {
|
||||
if let Err(err) =
|
||||
validate_db_or_user_request(&DbOrUser::Database(database_name.clone()), unix_user)
|
||||
.map_err(GetDatabasesPrivilegeDataError::ValidationError)
|
||||
if let Err(err) = validate_db_or_user_request(
|
||||
&DbOrUser::Database(database_name.clone()),
|
||||
unix_user,
|
||||
group_denylist,
|
||||
)
|
||||
.map_err(ListPrivilegesError::ValidationError)
|
||||
{
|
||||
results.insert(database_name.to_owned(), Err(err));
|
||||
continue;
|
||||
@@ -161,14 +165,14 @@ pub async fn get_databases_privilege_data(
|
||||
{
|
||||
results.insert(
|
||||
database_name.to_owned(),
|
||||
Err(GetDatabasesPrivilegeDataError::DatabaseDoesNotExist),
|
||||
Err(ListPrivilegesError::DatabaseDoesNotExist),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let result = unsafe_get_database_privileges(database_name, connection)
|
||||
.await
|
||||
.map_err(|e| GetDatabasesPrivilegeDataError::MySqlError(e.to_string()));
|
||||
.map_err(|e| ListPrivilegesError::MySqlError(e.to_string()));
|
||||
|
||||
results.insert(database_name.to_owned(), result);
|
||||
}
|
||||
@@ -200,12 +204,13 @@ pub async fn get_all_database_privileges(
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> ListAllPrivilegesResponse {
|
||||
let result = sqlx::query_as::<_, DatabasePrivilegeRow>(&get_all_db_privs_query())
|
||||
.bind(create_user_group_matching_regex(unix_user))
|
||||
.bind(create_user_group_matching_regex(unix_user, group_denylist))
|
||||
.fetch_all(connection)
|
||||
.await
|
||||
.map_err(|e| GetAllDatabasesPrivilegeDataError::MySqlError(e.to_string()));
|
||||
.map_err(|e| ListAllPrivilegesError::MySqlError(e.to_string()));
|
||||
|
||||
if let Err(e) = &result {
|
||||
tracing::error!("Failed to get all database privileges: {:?}", e);
|
||||
@@ -397,6 +402,7 @@ pub async fn apply_privilege_diffs(
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> ModifyPrivilegesResponse {
|
||||
let mut results: BTreeMap<(MySQLDatabase, MySQLUser), _> = BTreeMap::new();
|
||||
|
||||
@@ -408,6 +414,7 @@ pub async fn apply_privilege_diffs(
|
||||
if let Err(err) = validate_db_or_user_request(
|
||||
&DbOrUser::Database(diff.get_database_name().to_owned()),
|
||||
unix_user,
|
||||
group_denylist,
|
||||
)
|
||||
.map_err(ModifyDatabasePrivilegesError::UserValidationError)
|
||||
{
|
||||
@@ -415,9 +422,12 @@ pub async fn apply_privilege_diffs(
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(err) =
|
||||
validate_db_or_user_request(&DbOrUser::User(diff.get_user_name().to_owned()), unix_user)
|
||||
.map_err(ModifyDatabasePrivilegesError::UserValidationError)
|
||||
if let Err(err) = validate_db_or_user_request(
|
||||
&DbOrUser::User(diff.get_user_name().to_owned()),
|
||||
unix_user,
|
||||
group_denylist,
|
||||
)
|
||||
.map_err(ModifyDatabasePrivilegesError::UserValidationError)
|
||||
{
|
||||
results.insert(key, Err(err));
|
||||
continue;
|
||||
|
||||
@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
|
||||
use sqlx::MySqlConnection;
|
||||
use sqlx::prelude::*;
|
||||
|
||||
use crate::core::protocol::request_validation::GroupDenylist;
|
||||
use crate::core::protocol::request_validation::validate_db_or_user_request;
|
||||
use crate::core::types::DbOrUser;
|
||||
use crate::{
|
||||
@@ -58,6 +59,7 @@ pub async fn complete_user_name(
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> Vec<MySQLUser> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
@@ -67,7 +69,7 @@ pub async fn complete_user_name(
|
||||
AND `User` LIKE ?
|
||||
"#,
|
||||
)
|
||||
.bind(create_user_group_matching_regex(unix_user))
|
||||
.bind(create_user_group_matching_regex(unix_user, group_denylist))
|
||||
.bind(format!("{}%", user_prefix))
|
||||
.fetch_all(connection)
|
||||
.await;
|
||||
@@ -97,12 +99,14 @@ pub async fn create_database_users(
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> CreateUsersResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for db_user in db_users {
|
||||
if let Err(err) = validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user)
|
||||
.map_err(CreateUserError::ValidationError)
|
||||
if let Err(err) =
|
||||
validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist)
|
||||
.map_err(CreateUserError::ValidationError)
|
||||
{
|
||||
results.insert(db_user, Err(err));
|
||||
continue;
|
||||
@@ -141,12 +145,14 @@ pub async fn drop_database_users(
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> DropUsersResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for db_user in db_users {
|
||||
if let Err(err) = validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user)
|
||||
.map_err(DropUserError::ValidationError)
|
||||
if let Err(err) =
|
||||
validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist)
|
||||
.map_err(DropUserError::ValidationError)
|
||||
{
|
||||
results.insert(db_user, Err(err));
|
||||
continue;
|
||||
@@ -186,8 +192,9 @@ pub async fn set_password_for_database_user(
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> SetUserPasswordResponse {
|
||||
validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user)
|
||||
validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist)
|
||||
.map_err(SetPasswordError::ValidationError)?;
|
||||
|
||||
match unsafe_user_exists(db_user, &mut *connection).await {
|
||||
@@ -269,12 +276,14 @@ pub async fn lock_database_users(
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
db_is_mariadb: bool,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> LockUsersResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for db_user in db_users {
|
||||
if let Err(err) = validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user)
|
||||
.map_err(LockUserError::ValidationError)
|
||||
if let Err(err) =
|
||||
validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist)
|
||||
.map_err(LockUserError::ValidationError)
|
||||
{
|
||||
results.insert(db_user, Err(err));
|
||||
continue;
|
||||
@@ -327,12 +336,14 @@ pub async fn unlock_database_users(
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
db_is_mariadb: bool,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> UnlockUsersResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for db_user in db_users {
|
||||
if let Err(err) = validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user)
|
||||
.map_err(UnlockUserError::ValidationError)
|
||||
if let Err(err) =
|
||||
validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist)
|
||||
.map_err(UnlockUserError::ValidationError)
|
||||
{
|
||||
results.insert(db_user, Err(err));
|
||||
continue;
|
||||
@@ -433,12 +444,14 @@ pub async fn list_database_users(
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
db_is_mariadb: bool,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> ListUsersResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for db_user in db_users {
|
||||
if let Err(err) = validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user)
|
||||
.map_err(ListUsersError::ValidationError)
|
||||
if let Err(err) =
|
||||
validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist)
|
||||
.map_err(ListUsersError::ValidationError)
|
||||
{
|
||||
results.insert(db_user, Err(err));
|
||||
continue;
|
||||
@@ -477,6 +490,7 @@ pub async fn list_all_database_users_for_unix_user(
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
db_is_mariadb: bool,
|
||||
group_denylist: &GroupDenylist,
|
||||
) -> ListAllUsersResponse {
|
||||
let mut result = sqlx::query_as::<_, DatabaseUser>(
|
||||
&(if db_is_mariadb {
|
||||
@@ -485,7 +499,7 @@ pub async fn list_all_database_users_for_unix_user(
|
||||
DB_USER_SELECT_STATEMENT_MYSQL.to_string()
|
||||
} + "WHERE `user`.`User` REGEXP ?"),
|
||||
)
|
||||
.bind(create_user_group_matching_regex(unix_user))
|
||||
.bind(create_user_group_matching_regex(unix_user, group_denylist))
|
||||
.fetch_all(&mut *connection)
|
||||
.await
|
||||
.map_err(|err| ListAllUsersError::MySqlError(err.to_string()));
|
||||
|
||||
@@ -17,9 +17,13 @@ use tokio::{
|
||||
};
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
use crate::server::{
|
||||
config::{MysqlConfig, ServerConfig},
|
||||
session_handler::session_handler,
|
||||
use crate::{
|
||||
core::protocol::request_validation::GroupDenylist,
|
||||
server::{
|
||||
authorization::read_and_parse_group_denylist,
|
||||
config::{MysqlConfig, ServerConfig},
|
||||
session_handler::session_handler,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -36,6 +40,7 @@ pub struct ReloadEvent;
|
||||
pub struct Supervisor {
|
||||
config_path: PathBuf,
|
||||
config: Arc<Mutex<ServerConfig>>,
|
||||
group_deny_list: Arc<RwLock<GroupDenylist>>,
|
||||
systemd_mode: bool,
|
||||
|
||||
shutdown_cancel_token: CancellationToken,
|
||||
@@ -66,8 +71,26 @@ impl Supervisor {
|
||||
let config = ServerConfig::read_config_from_path(&config_path)
|
||||
.context("Failed to read server configuration")?;
|
||||
|
||||
let group_deny_list = match &config.authorization.group_denylist_file {
|
||||
Some(denylist_path) => {
|
||||
let denylist = read_and_parse_group_denylist(denylist_path)
|
||||
.context("Failed to read group denylist file")?;
|
||||
tracing::debug!(
|
||||
"Loaded group denylist with {} entries from {:?}",
|
||||
denylist.len(),
|
||||
denylist_path
|
||||
);
|
||||
Arc::new(RwLock::new(denylist))
|
||||
}
|
||||
None => {
|
||||
tracing::debug!("No group denylist file specified, proceeding without a denylist");
|
||||
Arc::new(RwLock::new(GroupDenylist::new()))
|
||||
}
|
||||
};
|
||||
|
||||
let mut watchdog_duration = None;
|
||||
let mut watchdog_micro_seconds = 0;
|
||||
#[cfg(target_os = "linux")]
|
||||
let watchdog_task =
|
||||
if systemd_mode && sd_notify::watchdog_enabled(true, &mut watchdog_micro_seconds) {
|
||||
watchdog_duration = Some(Duration::from_micros(watchdog_micro_seconds));
|
||||
@@ -80,6 +103,8 @@ impl Supervisor {
|
||||
tracing::debug!("Systemd watchdog not enabled, skipping watchdog thread");
|
||||
None
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let watchdog_task = None;
|
||||
|
||||
let db_connection_pool =
|
||||
Arc::new(RwLock::new(create_db_connection_pool(&config.mysql).await?));
|
||||
@@ -102,19 +127,34 @@ impl Supervisor {
|
||||
|
||||
let task_tracker = TaskTracker::new();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let status_notifier_task = if systemd_mode {
|
||||
Some(spawn_status_notifier_task(task_tracker.clone()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let status_notifier_task = None;
|
||||
|
||||
let (tx, rx) = broadcast::channel(1);
|
||||
|
||||
// TODO: try to detech systemd socket before using the provided socket path
|
||||
#[cfg(target_os = "linux")]
|
||||
let listener = Arc::new(RwLock::new(match config.socket_path {
|
||||
Some(ref path) => create_unix_listener_with_socket_path(path.clone()).await?,
|
||||
None => create_unix_listener_with_systemd_socket().await?,
|
||||
}));
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let listener = Arc::new(RwLock::new(
|
||||
create_unix_listener_with_socket_path(
|
||||
config
|
||||
.socket_path
|
||||
.as_ref()
|
||||
.ok_or(anyhow!("Socket path must be set"))?
|
||||
.clone(),
|
||||
)
|
||||
.await?,
|
||||
));
|
||||
|
||||
let (reload_tx, reload_rx) = broadcast::channel(1);
|
||||
let shutdown_cancel_token = CancellationToken::new();
|
||||
@@ -130,12 +170,14 @@ impl Supervisor {
|
||||
db_connection_pool.clone(),
|
||||
rx,
|
||||
db_is_mariadb.clone(),
|
||||
group_deny_list.clone(),
|
||||
))
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
config_path,
|
||||
config: Arc::new(Mutex::new(config)),
|
||||
group_deny_list,
|
||||
systemd_mode,
|
||||
reload_message_receiver: reload_rx,
|
||||
shutdown_cancel_token,
|
||||
@@ -178,6 +220,26 @@ impl Supervisor {
|
||||
.context("Failed to read server configuration")?;
|
||||
let mut config = self.config.clone().lock_owned().await;
|
||||
*config = new_config;
|
||||
|
||||
let group_deny_list = match &config.authorization.group_denylist_file {
|
||||
Some(denylist_path) => {
|
||||
let denylist = read_and_parse_group_denylist(denylist_path)
|
||||
.context("Failed to read group denylist file")?;
|
||||
|
||||
tracing::debug!(
|
||||
"Loaded group denylist with {} entries from {:?}",
|
||||
denylist.len(),
|
||||
denylist_path
|
||||
);
|
||||
denylist
|
||||
}
|
||||
None => {
|
||||
tracing::debug!("No group denylist file specified, proceeding without a denylist");
|
||||
GroupDenylist::new()
|
||||
}
|
||||
};
|
||||
let mut group_deny_list_lock = self.group_deny_list.write().await;
|
||||
*group_deny_list_lock = group_deny_list;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -211,16 +273,28 @@ impl Supervisor {
|
||||
// first. Make sure to handle that appropriately to avoid a deadlock.
|
||||
async fn reload_listener(&self) -> anyhow::Result<()> {
|
||||
let config = self.config.lock().await;
|
||||
#[cfg(target_os = "linux")]
|
||||
let new_listener = match config.socket_path {
|
||||
Some(ref path) => create_unix_listener_with_socket_path(path.clone()).await?,
|
||||
None => create_unix_listener_with_systemd_socket().await?,
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let new_listener = create_unix_listener_with_socket_path(
|
||||
config
|
||||
.socket_path
|
||||
.as_ref()
|
||||
.ok_or(anyhow!("Socket path must be set"))?
|
||||
.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut listener = self.listener.write().await;
|
||||
*listener = new_listener;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reload(&self) -> anyhow::Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
sd_notify::notify(false, &[sd_notify::NotifyState::Reloading])?;
|
||||
|
||||
let previous_config = self.config.lock().await.clone();
|
||||
@@ -257,12 +331,14 @@ impl Supervisor {
|
||||
self.resume_receiving_new_connections()?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
sd_notify::notify(false, &[sd_notify::NotifyState::Ready])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) -> anyhow::Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
sd_notify::notify(false, &[sd_notify::NotifyState::Stopping])?;
|
||||
|
||||
tracing::debug!("Stop accepting new connections");
|
||||
@@ -323,6 +399,7 @@ impl Supervisor {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn spawn_watchdog_task(duration: Duration) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = interval(duration.div_f32(2.0));
|
||||
@@ -339,6 +416,7 @@ fn spawn_watchdog_task(duration: Duration) -> JoinHandle<()> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn spawn_status_notifier_task(task_tracker: TaskTracker) -> JoinHandle<()> {
|
||||
const STATUS_UPDATE_INTERVAL_SECS: Duration = Duration::from_secs(1);
|
||||
|
||||
@@ -385,6 +463,7 @@ async fn create_unix_listener_with_socket_path(
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn create_unix_listener_with_systemd_socket() -> anyhow::Result<TokioUnixListener> {
|
||||
let fd = sd_notify::listen_fds()
|
||||
.context("Failed to get file descriptors from systemd")?
|
||||
@@ -467,7 +546,9 @@ async fn listener_task(
|
||||
db_pool: Arc<RwLock<MySqlPool>>,
|
||||
mut supervisor_message_receiver: broadcast::Receiver<SupervisorMessage>,
|
||||
db_is_mariadb: Arc<RwLock<bool>>,
|
||||
group_denylist: Arc<RwLock<GroupDenylist>>,
|
||||
) -> anyhow::Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
sd_notify::notify(false, &[sd_notify::NotifyState::Ready])?;
|
||||
|
||||
loop {
|
||||
@@ -503,8 +584,14 @@ async fn listener_task(
|
||||
|
||||
let db_pool_clone = db_pool.clone();
|
||||
let db_is_mariadb_clone = *db_is_mariadb.read().await;
|
||||
let group_denylist_arc_clone = group_denylist.clone();
|
||||
task_tracker.spawn(async move {
|
||||
match session_handler(conn, db_pool_clone, db_is_mariadb_clone).await {
|
||||
match session_handler(
|
||||
conn,
|
||||
db_pool_clone,
|
||||
db_is_mariadb_clone,
|
||||
&*group_denylist_arc_clone.read().await,
|
||||
).await {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to run server: {}", e);
|
||||
|
||||
Reference in New Issue
Block a user