31 Commits

Author SHA1 Message Date
340428c158 client: bold --help ascii banner
All checks were successful
Build and test / check (push) Successful in 1m43s
Build and test / check-license (push) Successful in 1m47s
Build and test / build (push) Successful in 2m53s
Build and test / test (push) Successful in 3m5s
Build and test / docs (push) Successful in 6m43s
2025-12-16 20:28:24 +09:00
dfe20826c1 Add misc. doccomments
All checks were successful
Build and test / check-license (push) Successful in 1m42s
Build and test / check (push) Successful in 1m50s
Build and test / build (push) Successful in 2m38s
Build and test / test (push) Successful in 4m44s
Build and test / docs (push) Successful in 6m1s
2025-12-16 19:36:17 +09:00
97908ce887 core/protocol: set request/response max size 2025-12-16 19:36:17 +09:00
10ef171c91 client: print errors and warnings to stderr
All checks were successful
Build and test / check (push) Successful in 1m55s
Build and test / build (push) Successful in 2m37s
Build and test / check-license (push) Successful in 1m3s
Build and test / test (push) Successful in 3m6s
Build and test / docs (push) Successful in 5m56s
2025-12-16 17:21:58 +09:00
043a1a7a7a move example-config.toml to assets, sync with debian variant
All checks were successful
Build and test / check-license (push) Successful in 59s
Build and test / check (push) Successful in 1m42s
Build and test / build (push) Successful in 3m10s
Build and test / test (push) Successful in 3m26s
Build and test / docs (push) Successful in 5m16s
2025-12-16 16:54:55 +09:00
072bf6a090 assets/debian/group_denylist: remove groups postgres,sync
Some checks failed
Build and test / check (push) Successful in 1m55s
Build and test / test (push) Has been cancelled
Build and test / docs (push) Has been cancelled
Build and test / check-license (push) Has been cancelled
Build and test / build (push) Has been cancelled
2025-12-16 16:52:44 +09:00
21bb5b62ff create-deb.sh: move to scripts, add download-and-upload-debs.sh
Some checks failed
Build and test / check (push) Successful in 1m42s
Build and test / build (push) Successful in 2m55s
Build and test / check-license (push) Successful in 1m30s
Build and test / test (push) Successful in 3m5s
Build and test / docs (push) Has been cancelled
2025-12-16 16:41:59 +09:00
62b2b30f94 server: don't fail on invalid entries in denylist
Some checks failed
Build and test / check-license (push) Successful in 1m7s
Build and test / check (push) Successful in 2m37s
Build and test / build (push) Successful in 2m38s
Build and test / docs (push) Has been cancelled
Build and test / test (push) Has been cancelled
2025-12-16 16:37:27 +09:00
05b5b5dac0 client: add prefix completer for create-{db,user}
All checks were successful
Build and test / check-license (push) Successful in 54s
Build and test / check (push) Successful in 2m23s
Build and test / build (push) Successful in 2m55s
Build and test / test (push) Successful in 3m7s
Build and test / docs (push) Successful in 6m31s
2025-12-16 15:27:36 +09:00
d814008006 docs/installation: document systemd-less creds
All checks were successful
Build and test / check-license (push) Successful in 1m25s
Build and test / check (push) Successful in 1m40s
Build and test / build (push) Successful in 2m54s
Build and test / test (push) Successful in 4m6s
Build and test / docs (push) Successful in 5m20s
2025-12-16 15:03:10 +09:00
67c8e3330c docs/compiling: init
Some checks failed
Build and test / check-license (push) Successful in 1m30s
Build and test / check (push) Successful in 1m40s
Build and test / build (push) Successful in 2m51s
Build and test / docs (push) Has been cancelled
Build and test / test (push) Has been cancelled
2025-12-16 14:58:19 +09:00
57ac26b120 client: display show-db output with human readable sizes
All checks were successful
Build and test / check-license (push) Successful in 55s
Build and test / check (push) Successful in 1m55s
Build and test / build (push) Successful in 3m25s
Build and test / test (push) Successful in 3m4s
Build and test / docs (push) Successful in 5m54s
2025-12-16 14:36:53 +09:00
256c1d1176 client: add example for display subcommand help
All checks were successful
Build and test / check-license (push) Successful in 59s
Build and test / build (push) Successful in 2m37s
Build and test / check (push) Successful in 2m46s
Build and test / test (push) Successful in 3m31s
Build and test / docs (push) Successful in 5m15s
2025-12-16 14:28:30 +09:00
15c8d82373 docs/installation: document denylists
All checks were successful
Build and test / check-license (push) Successful in 53s
Build and test / check (push) Successful in 2m21s
Build and test / build (push) Successful in 2m52s
Build and test / test (push) Successful in 3m5s
Build and test / docs (push) Successful in 6m38s
2025-12-16 14:16:45 +09:00
e6bcac8079 docs/installation: reword not to suggest adding plaintext password to config file
Some checks failed
Build and test / check-license (push) Successful in 1m5s
Build and test / check (push) Successful in 1m48s
Build and test / build (push) Successful in 3m16s
Build and test / test (push) Successful in 3m26s
Build and test / docs (push) Has been cancelled
2025-12-16 14:10:57 +09:00
146421dd79 client/edit-privs: report actual errors instead of non-existence
Some checks failed
Build and test / check-license (push) Successful in 54s
Build and test / check (push) Successful in 1m55s
Build and test / build (push) Successful in 3m26s
Build and test / docs (push) Has been cancelled
Build and test / test (push) Has been cancelled
2025-12-16 14:07:28 +09:00
795c6d3c9d client: exit with error on errors
All checks were successful
Build and test / check (push) Successful in 1m44s
Build and test / check-license (push) Successful in 2m0s
Build and test / build (push) Successful in 2m55s
Build and test / test (push) Successful in 3m4s
Build and test / docs (push) Successful in 6m54s
2025-12-16 13:46:17 +09:00
40ce292083 edit-privs: move examples to different section
Some checks failed
Build and test / check (push) Successful in 1m53s
Build and test / check-license (push) Successful in 2m13s
Build and test / build (push) Successful in 2m37s
Build and test / test (push) Successful in 3m25s
Build and test / docs (push) Has been cancelled
2025-12-16 13:39:12 +09:00
ca6ae43bbc Add a few usage examples to --help 2025-12-16 13:32:14 +09:00
996c9e50c7 docs/installation: limit apt repo architecture 2025-12-16 13:21:04 +09:00
d0e226bff3 assets/systemd: add service dependency on mysql/mariadb
All checks were successful
Build and test / check-license (push) Successful in 1m0s
Build and test / check (push) Successful in 1m43s
Build and test / build (push) Successful in 4m20s
Build and test / test (push) Successful in 3m27s
Build and test / docs (push) Successful in 5m11s
2025-12-16 13:04:49 +09:00
cc1d8b0cf1 Fix protocol error struct name for List(All)PrivilegesError
Some checks failed
Build and test / check (push) Successful in 2m42s
Build and test / check-license (push) Successful in 56s
Build and test / docs (push) Successful in 5m19s
Build and test / test (push) Failing after 11m43s
Build and test / build (push) Failing after 20m23s
2025-12-16 12:41:19 +09:00
8b4d549e18 Implement denylists
All checks were successful
Build and test / check-license (push) Successful in 1m38s
Build and test / check (push) Successful in 1m51s
Build and test / build (push) Successful in 2m40s
Build and test / test (push) Successful in 4m25s
Build and test / docs (push) Successful in 6m1s
2025-12-16 12:21:35 +09:00
45cefb8af4 client/edit-privs: use a more human-friendly interface
All checks were successful
Build and test / check-license (push) Successful in 1m39s
Build and test / check (push) Successful in 1m53s
Build and test / build (push) Successful in 2m43s
Build and test / test (push) Successful in 4m23s
Build and test / docs (push) Successful in 6m3s
2025-12-16 11:00:59 +09:00
891963f4bc Add ListValidNamePrefixes command to protocol
Some checks failed
Build and test / check (push) Has been cancelled
Build and test / test (push) Has been cancelled
Build and test / docs (push) Has been cancelled
Build and test / build (push) Has been cancelled
Build and test / check-license (push) Has been cancelled
2025-12-16 10:13:28 +09:00
912f0e8971 server: hide systemd stuff behind compiletime cond
All checks were successful
Build and test / check (push) Successful in 1m49s
Build and test / check-license (push) Successful in 1m49s
Build and test / build (push) Successful in 2m36s
Build and test / test (push) Successful in 3m25s
Build and test / docs (push) Successful in 6m35s
2025-12-15 17:02:53 +09:00
73f5cd9fd4 .gitea/workflows: actions-rs/toolchain -> dtolnay/rust-toolchain
All checks were successful
Build and test / check-license (push) Successful in 59s
Build and test / check (push) Successful in 1m42s
Build and test / build (push) Successful in 3m2s
Build and test / test (push) Successful in 3m27s
Build and test / docs (push) Successful in 5m7s
2025-12-15 16:22:15 +09:00
caf16c7a21 .gitea/workflows: use cargo-binstall everywhere, disable telemetry
Some checks failed
Build and test / check (push) Successful in 1m44s
Build and test / check-license (push) Successful in 1m48s
Build and test / build (push) Successful in 2m52s
Build and test / test (push) Successful in 3m1s
Build and test / docs (push) Has been cancelled
2025-12-15 16:15:06 +09:00
aac7315fd9 .gitea/workflows: name artifact zips with commit hash
All checks were successful
Build and test / check (push) Successful in 1m40s
Build and test / build (push) Successful in 2m52s
Build and test / test (push) Successful in 3m4s
Build and test / check-license (push) Successful in 6m18s
Build and test / docs (push) Successful in 5m59s
2025-12-15 16:06:22 +09:00
aa96587a35 assets/debian/config.toml: leave link to installation instructions
Some checks failed
Build and test / build (push) Successful in 2m37s
Build and test / check (push) Successful in 2m51s
Build and test / docs (push) Has been cancelled
Build and test / test (push) Has been cancelled
Build and test / check-license (push) Has been cancelled
2025-12-15 16:00:52 +09:00
15ebc5df5b Cargo.toml: (deb) install documentation 2025-12-15 16:00:27 +09:00
67 changed files with 1673 additions and 573 deletions

View File

@@ -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

View File

@@ -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
View File

@@ -9,7 +9,6 @@ result-*
# Nix VM
*.qcow2
.nixos-test-history
# Packaging
!/assets/debian/config.toml

48
Cargo.lock generated
View File

@@ -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"

View File

@@ -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/"

View File

@@ -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)

View File

@@ -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

View 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

View File

@@ -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.

View File

@@ -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
View 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

View File

@@ -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`.

View File

@@ -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
View File

@@ -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"
}

View File

@@ -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: _: {

View File

@@ -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

View File

@@ -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;

View 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"

View File

@@ -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(())
}

View File

@@ -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(())
}

View File

@@ -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(())

View File

@@ -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(())
}

View File

@@ -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(())
}

View File

@@ -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(())

View File

@@ -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())

View File

@@ -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(())

View File

@@ -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(())
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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(())

View File

@@ -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)
}
}

View File

@@ -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)),
},
)

View File

@@ -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(());
}
},

View File

@@ -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(())

View File

@@ -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]

View File

@@ -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::*;

View 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)
}

View File

@@ -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'],
},
}),
);
}

View File

@@ -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),

View File

@@ -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)

View File

@@ -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!();

View File

@@ -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!();

View File

@@ -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!();

View File

@@ -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!();

View File

@@ -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(),
}
}
}

View File

@@ -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)
}
]);
}

View File

@@ -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(),
}
}
}

View File

@@ -0,0 +1 @@
pub type ListValidNamePrefixesResponse = Vec<String>;

View File

@@ -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!();

View File

@@ -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!();

View File

@@ -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...");
}
}
}

View File

@@ -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!();

View File

@@ -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)
);
}
}

View File

@@ -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("?"),
}
}
}

View File

@@ -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,

View File

@@ -1,4 +1,4 @@
mod authorization;
pub mod authorization;
pub mod command;
mod common;
pub mod config;

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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(&regex).unwrap();
assert!(re.is_match("user_something"));

View File

@@ -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,
}

View File

@@ -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 => {

View File

@@ -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()));

View File

@@ -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;

View File

@@ -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()));

View File

@@ -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);