Compare commits
122 Commits
status-sub
...
debian-vm-
| Author | SHA1 | Date | |
|---|---|---|---|
|
c6bce54859
|
|||
|
3f014f073e
|
|||
|
5f03b55eb5
|
|||
|
bf6027f507
|
|||
|
1991e7bfd8
|
|||
|
1cf9273fcd
|
|||
|
47a4bccd2c
|
|||
|
8811a41980
|
|||
|
6e914dec34
|
|||
|
7b79f7b163
|
|||
|
56596835fa
|
|||
|
3bc3f35294
|
|||
|
919fd326ba
|
|||
|
920544ef3a
|
|||
|
4c82da390f
|
|||
|
dc7b72efe5
|
|||
|
e56c41cee6
|
|||
|
bd23cf693d
|
|||
|
6c1ae5479e
|
|||
|
222941509d
|
|||
|
eeef8bd546
|
|||
|
a036fd03c9
|
|||
|
bf66055f7f
|
|||
|
94619edf73
|
|||
|
bfa50b4d7e
|
|||
|
9408096391
|
|||
|
69cb96014b
|
|||
|
67ff31f405
|
|||
|
a4084e2ecc
|
|||
|
a6804e01df
|
|||
|
162c8cd422
|
|||
|
44fde9f780
|
|||
|
7911985410
|
|||
|
1e7911023e
|
|||
|
f5d3c46e60
|
|||
|
b0ae6e563d
|
|||
|
4c21d083df
|
|||
|
c5c6236e50
|
|||
|
a5a5522ad0
|
|||
|
6194fcef26
|
|||
|
51a6390aa6
|
|||
|
614a756aa7
|
|||
|
f2d404e864
|
|||
|
271ce66022
|
|||
|
acde3a9d5d
|
|||
|
fbf90a456a
|
|||
|
0df19654d6
|
|||
|
5faf0c2f0a
|
|||
|
9297afec2f
|
|||
|
829a91705b
|
|||
|
afbba78e39
|
|||
|
32b70c44c6
|
|||
|
6a4a83367e
|
|||
|
6aceda6f3d
|
|||
|
7bdecf78ff
|
|||
|
3ac90dcb26
|
|||
|
7df04ec413
|
|||
|
ed71524e85
|
|||
|
54f794acb6
|
|||
|
32cbf215a8
|
|||
|
25c4c6f3e9
|
|||
|
7e1383609d
|
|||
|
da4a256124
|
|||
|
fae1c2c1c8
|
|||
|
999d6cbc71
|
|||
|
cd58d4507e
|
|||
|
9f9e1ce504
|
|||
|
3e46d6f541
|
|||
|
526819d374
|
|||
|
f348e67622
|
|||
|
cb3f3f3e1d
|
|||
|
1af9748530
|
|||
|
e05a72894f
|
|||
|
16db753f3f
|
|||
|
d7b8167fd3
|
|||
|
67b820c1ad
|
|||
|
e5627b2649
|
|||
|
ff858de178
|
|||
|
025df3490c
|
|||
|
79f2a2b497
|
|||
|
a6db254c20
|
|||
|
152c3ddbcc
|
|||
|
2472936857
|
|||
|
7f5c3310db
|
|||
|
fd3fd30df9
|
|||
|
0e10e6dde9
|
|||
|
de57860395
|
|||
|
1fe08b59a3
|
|||
|
4a6e49110a
|
|||
|
b4db2daac7
|
|||
|
865b24884e
|
|||
|
03ddf0ac8a
|
|||
|
877f45c103
|
|||
|
fe87f72b00
|
|||
|
dac1c147dd
|
|||
|
bc4f2bc71c
|
|||
|
7ce81ddc55
|
|||
|
898a5e6ab0
|
|||
|
9138613267
|
|||
|
3eac8ffd94
|
|||
|
e51e8fe408
|
|||
|
fa1d27e09c
|
|||
|
20331a4429
|
|||
|
f5ff50365f
|
|||
|
7fa6f6aafe
|
|||
|
77667e546c
|
|||
|
f9c5f1347e
|
|||
|
a4acfe91af
|
|||
|
805c2d11ff
|
|||
|
c9815fe7de
|
|||
|
1571f6e2c7
|
|||
|
9e39401049
|
|||
|
4fb60f8563
|
|||
|
39fa228d1c
|
|||
|
412e5c1604
|
|||
|
d350438176
|
|||
|
d1de7b71bb
|
|||
|
8b893db898
|
|||
|
03a761a0ff
|
|||
|
7760b001d8
|
|||
|
9d3b543998
|
|||
|
6a7e8db162
|
135
.gitea/workflows/build-and-test.yml
Normal file
135
.gitea/workflows/build-and-test.yml
Normal file
@@ -0,0 +1,135 @@
|
||||
name: "Build and test"
|
||||
run-name: "Build and test"
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: debian-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
|
||||
- name: Build
|
||||
run: cargo build --all-features --verbose --release
|
||||
|
||||
check:
|
||||
runs-on: debian-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Check code format
|
||||
run: cargo fmt -- --check
|
||||
|
||||
- name: Check clippy
|
||||
run: cargo clippy -- --deny warnings
|
||||
|
||||
check-license:
|
||||
runs-on: debian-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
|
||||
- name: Install cargo-deny
|
||||
run: cargo install cargo-deny
|
||||
|
||||
- name: Check licenses
|
||||
run: |
|
||||
cargo deny check bans licenses sources
|
||||
|
||||
test:
|
||||
runs-on: debian-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: cargo-bins/cargo-binstall@main
|
||||
|
||||
- name: Install rust toolchain
|
||||
uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
|
||||
- name: Install nextest
|
||||
run: cargo binstall -y cargo-nextest --secure
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cargo nextest run --release --no-fail-fast
|
||||
env:
|
||||
RUST_LOG: "trace"
|
||||
RUSTFLAGS: "-Cinstrument-coverage"
|
||||
LLVM_PROFILE_FILE: "target/coverage/%p-%m.profraw"
|
||||
|
||||
- name: Install grcov
|
||||
run: cargo binstall -y grcov
|
||||
|
||||
- name: Generate coverage report
|
||||
run: |
|
||||
grcov \
|
||||
--source-dir . \
|
||||
--binary-path ./target/release/deps/ \
|
||||
--excl-start 'mod test* \{' \
|
||||
--ignore 'tests/*' \
|
||||
--ignore "*test.rs" \
|
||||
--ignore "*tests.rs" \
|
||||
--ignore "*github.com*" \
|
||||
--ignore "*libcore*" \
|
||||
--ignore "*rustc*" \
|
||||
--ignore "*liballoc*" \
|
||||
--ignore "*cargo*" \
|
||||
-t html \
|
||||
-o ./target/coverage/html \
|
||||
target/coverage/
|
||||
|
||||
- name: Upload test report
|
||||
uses: https://git.pvv.ntnu.no/Projects/rsync-action@v1
|
||||
with:
|
||||
source: target/coverage/html/
|
||||
target: ${{ gitea.ref_name }}/coverage/
|
||||
username: gitea-web
|
||||
ssh-key: ${{ secrets.WEB_SYNC_SSH_KEY }}
|
||||
host: pages.pvv.ntnu.no
|
||||
known-hosts: "pages.pvv.ntnu.no ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2QjfFB+city1SYqltkVqWACfo1j37k+oQQfj13mtgg"
|
||||
|
||||
docs:
|
||||
runs-on: debian-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
|
||||
- name: Build docs
|
||||
run: cargo doc --all-features --document-private-items --release
|
||||
|
||||
- name: Transfer files
|
||||
uses: https://git.pvv.ntnu.no/Projects/rsync-action@main
|
||||
with:
|
||||
source: target/doc/
|
||||
target: ${{ gitea.ref_name }}/docs/
|
||||
username: gitea-web
|
||||
ssh-key: ${{ secrets.WEB_SYNC_SSH_KEY }}
|
||||
host: pages.pvv.ntnu.no
|
||||
known-hosts: "pages.pvv.ntnu.no ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2QjfFB+city1SYqltkVqWACfo1j37k+oQQfj13mtgg"
|
||||
@@ -1,64 +0,0 @@
|
||||
name: "Build"
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
|
||||
- name: Build
|
||||
run: cargo build --all-features --verbose --release
|
||||
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Check code format
|
||||
run: cargo fmt -- --check
|
||||
|
||||
- name: Check clippy
|
||||
run: cargo clippy --all-features -- --deny warnings
|
||||
|
||||
docs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
|
||||
- name: Build docs
|
||||
run: cargo doc --all-features --document-private-items --release
|
||||
|
||||
- name: Transfer files
|
||||
uses: https://git.pvv.ntnu.no/Projects/rsync-action@main
|
||||
with:
|
||||
source: target/doc/
|
||||
target: ${{ gitea.ref_name }}/docs/
|
||||
username: gitea-web
|
||||
ssh-key: ${{ secrets.WEB_SYNC_SSH_KEY }}
|
||||
host: pages.pvv.ntnu.no
|
||||
known-hosts: "pages.pvv.ntnu.no ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2QjfFB+city1SYqltkVqWACfo1j37k+oQQfj13mtgg"
|
||||
|
||||
80
.gitea/workflows/publish-deb.yml
Normal file
80
.gitea/workflows/publish-deb.yml
Normal file
@@ -0,0 +1,80 @@
|
||||
name: "Publish Debian package"
|
||||
run-name: "Publish Debian package"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
deb_version:
|
||||
description: "Version to publish"
|
||||
type: string
|
||||
|
||||
deb_revision:
|
||||
description: "Debian package revision"
|
||||
type: string
|
||||
default: "1"
|
||||
required: true
|
||||
|
||||
rust_toolchain:
|
||||
description: "Whether to build the package with stable rust"
|
||||
type: choice
|
||||
options:
|
||||
- stable
|
||||
- nightly
|
||||
- beta
|
||||
default: stable
|
||||
|
||||
# TODO: dynamic matrix builds when...
|
||||
# https://github.com/go-gitea/gitea/issues/25179
|
||||
jobs:
|
||||
build-deb:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [debian-trixie, debian-bookworm, ubuntu-noble, ubuntu-jammy]
|
||||
name: Build and publish for ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ inputs.rust_toolchain }}
|
||||
override: true
|
||||
|
||||
- name: Install cargo-deb
|
||||
run: cargo install cargo-deb
|
||||
|
||||
- name: Build deb package
|
||||
env:
|
||||
CREATE_DEB_DEBUG: "1"
|
||||
run: |
|
||||
CREATE_DEB_ARGS=(
|
||||
--deb-revision "${{ inputs.deb_revision }}"
|
||||
)
|
||||
|
||||
if [ "${{ inputs.deb_version }}" != "" ]; then
|
||||
CREATE_DEB_ARGS+=("--deb-version" "${{ inputs.deb_version }}")
|
||||
fi
|
||||
|
||||
./create-deb.sh "${CREATE_DEB_ARGS[@]}"
|
||||
|
||||
- name: Upload deb package artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: muscl-deb-${{ matrix.os }}.zip
|
||||
path: target/debian/*.deb
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
# Already compressed
|
||||
compression: 0
|
||||
|
||||
# This is not safely doable without either:
|
||||
# - tokens scoped to the repository: https://github.com/go-gitea/gitea/issues/25900
|
||||
# - automatically provisioned tokens: https://github.com/go-gitea/gitea/issues/24635
|
||||
#
|
||||
# - name: Publish deb package
|
||||
# run: |
|
||||
# # TODO: remove ubuntu-/debian- prefix from os.matrix
|
||||
# curl \
|
||||
# --user your_username:your_password_or_token \
|
||||
# --upload-file target/debian/*.deb \
|
||||
# https://git.pvv.ntnu.no/api/packages/${{ github.repository_owner }}/debian/pool/${{ os.matrix }}/${{ inputs.repository_component }}/upload
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -9,3 +9,8 @@ result-*
|
||||
|
||||
# Nix VM
|
||||
*.qcow2
|
||||
.nixos-test-history
|
||||
|
||||
# Packaging
|
||||
!/assets/debian/config.toml
|
||||
/assets/completions/
|
||||
|
||||
64
CHANGELOG.md
Normal file
64
CHANGELOG.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Changelog
|
||||
|
||||
## v1.0.0 - Initial Release
|
||||
|
||||
This is the initial release of `muscl`.
|
||||
|
||||
### Features ported from [`mysql-admutils`](https://git.pvv.ntnu.no/Projects/mysql-admutils)
|
||||
|
||||
- All commands
|
||||
- Support for starting internal server with SUID/SGID
|
||||
- Best-effort CLI interface backwards compatibility (see deviation notes for details)
|
||||
- Best-effort stdout/stderr output backwards compatibility (see line above)
|
||||
- Privilege editor
|
||||
|
||||
### New features and changes from `mysql-admutils`
|
||||
|
||||
- Changed programming language from `C` to `Rust`, for better or for worse
|
||||
- Combined the functionality of both `mysql-dbadm` and `mysql-useradm` into a single executable.
|
||||
- Switched to a server+client architecture. With this change comes:
|
||||
- Added security against SUID/SGID-related vulnerabilities.
|
||||
- Logging and debug information for system administrators.
|
||||
- A limitation on the maximum number of connections to the database.
|
||||
- A lot of sandboxing and hardening for the server-side, limiting the amount
|
||||
of damage that can be done if compromised, and further increasing security.
|
||||
- Added `--json` flag for several commands
|
||||
- Added `check-auth` command, for testing whether you are allowed to manage certain databases or users
|
||||
- Added `lock-user`/`unlock-user` which let's you temporarily disable a database user.
|
||||
- Added dynamic shell completions, aware of which databases and users exist.
|
||||
- Changed the name length limit from `32` characters to `64` characters.
|
||||
- Added `-p`/`--privs` flag for editing privileges using only commandline flags.
|
||||
The flag acts similarly to `chmod` with `+` and `-` variants for adding and removing privileges.
|
||||
See `muscl edit-privs --help` for more information.
|
||||
- Changed handling of database user passwords:
|
||||
- Prompting for passwords will now hide what you write
|
||||
- Allow providing passwords through files and stdin
|
||||
- Respect `$VISUAL` in addition to `$EDITOR` when launching the privilege editor.
|
||||
- Use a commented example line in the template for the privilege editor on first use.
|
||||
- Display the diff before committing privilege changes.
|
||||
- Generally more detailed error reporting:
|
||||
- On entering database or user names you do not own, suggest valid names
|
||||
- Instead of silently trimming database/user names when too long, report as error
|
||||
- When there are other name validation errors, report exactly what went wrong instead of a generic message
|
||||
- Add new errors related to failures inbetween the client and the server
|
||||
- Package and distribute software:
|
||||
- Provide `.deb` packages
|
||||
- Provide systemd units
|
||||
- Provide nix-flake with packages, overlays and NixOS modules.
|
||||
|
||||
### Known deviations from `mysql-admutils`' behaviour
|
||||
|
||||
- `--help` output is formatted by clap in a different style.
|
||||
- `mysql-dbadm edit-perm` uses the new privilege editor implementation. Replicating
|
||||
the old behaviour
|
||||
there shoulnd't have been any (or at least very few) scripts relying on the old
|
||||
command API or behavior.
|
||||
- The new tools use the new implementation to find it's configuration file, and uses the
|
||||
new configuration format. See the example config and installation instructions for more
|
||||
information about how to configure the software.
|
||||
- The order in which input is validated (e.g. whether you own a user, whether the
|
||||
contains illegal characters, whether the user does or does not exist) might be different
|
||||
from the original program, leading to the same command reporting different errors.
|
||||
- Arguments are de-duplicated, meaning that if you run something like
|
||||
`mysql-dbadm create user_db1 user_db2 user_db1`, the program will only try to create
|
||||
the `user_db1` once. The old program would attempt to create it twice, failing the second time.
|
||||
704
Cargo.lock
generated
704
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
133
Cargo.toml
133
Cargo.toml
@@ -1,14 +1,15 @@
|
||||
[package]
|
||||
name = "mysqladm-rs"
|
||||
name = "muscl"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "BSD3"
|
||||
edition = "2024"
|
||||
license = "BSD-3-Clause"
|
||||
authors = [
|
||||
"oysteikt@pvv.ntnu.no",
|
||||
"felixalb@pvv.ntnu.no",
|
||||
]
|
||||
repository = "https://git.pvv.ntnu.no/Projects/mysqladm-rs"
|
||||
documentation = "https://pages.pvv.ntnu.no/Projects/mysqladm-rs/main/docs/mysqladm/"
|
||||
homepage = "https://git.pvv.ntnu.no/Projects/muscl"
|
||||
repository = "https://git.pvv.ntnu.no/Projects/muscl"
|
||||
documentation = "https://pages.pvv.ntnu.no/Projects/muscl/main/docs/muscl"
|
||||
description = "A command-line utility for MySQL administration for non-admin users"
|
||||
categories = ["command-line-interface", "command-line-utilities"]
|
||||
keywords = ["mysql", "cli", "administration"]
|
||||
@@ -20,51 +21,135 @@ autolib = false
|
||||
anyhow = "1.0.100"
|
||||
async-bincode = "0.8.0"
|
||||
bincode = "2.0.1"
|
||||
clap = { version = "4.5.51", features = ["derive"] }
|
||||
clap-verbosity-flag = "3.0.4"
|
||||
clap_complete = "4.5.60"
|
||||
derive_more = { version = "2.0.1", features = ["display", "error"] }
|
||||
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"] }
|
||||
const_format = "0.2.35"
|
||||
derive_more = { version = "2.1.0", features = ["display", "error"] }
|
||||
dialoguer = "0.12.0"
|
||||
env_logger = "0.11.8"
|
||||
futures = "0.3.31"
|
||||
futures-util = "0.3.31"
|
||||
indoc = "2.0.7"
|
||||
itertools = "0.14.0"
|
||||
log = "0.4.28"
|
||||
nix = { version = "0.30.1", features = ["fs", "process", "socket", "user"] }
|
||||
num_cpus = "1.17.0"
|
||||
prettytable = "0.10.0"
|
||||
rand = "0.9.2"
|
||||
ratatui = { version = "0.29.0", optional = true }
|
||||
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"] }
|
||||
systemd-journal-logger = "2.2.2"
|
||||
tokio = { version = "1.48.0", features = ["rt-multi-thread", "macros"] }
|
||||
thiserror = "2.0.17"
|
||||
tokio = { version = "1.48.0", features = ["rt-multi-thread", "macros", "signal"] }
|
||||
tokio-serde = { version = "0.9.0", features = ["bincode"] }
|
||||
tokio-stream = "0.1.17"
|
||||
tokio-util = { version = "0.7.17", features = ["codec"] }
|
||||
tokio-util = { version = "0.7.17", features = ["codec", "rt"] }
|
||||
toml = "0.9.8"
|
||||
uuid = { version = "1.18.1", features = ["v4"] }
|
||||
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"
|
||||
|
||||
[build-dependencies]
|
||||
anyhow = "1.0.100"
|
||||
git2 = { version = "0.20.3", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
regex = "1.12.2"
|
||||
|
||||
[features]
|
||||
default = ["mysql-admutils-compatibility"]
|
||||
tui = ["dep:ratatui"]
|
||||
mysql-admutils-compatibility = []
|
||||
suid-sgid-mode = []
|
||||
|
||||
[[bin]]
|
||||
name = "mysqladm"
|
||||
name = "muscl"
|
||||
bench = false
|
||||
path = "src/main.rs"
|
||||
|
||||
[profile.release]
|
||||
[profile.release-lto]
|
||||
inherits = "release"
|
||||
strip = true
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
|
||||
[build-dependencies]
|
||||
anyhow = "1.0.100"
|
||||
[package.metadata.deb]
|
||||
name = "muscl"
|
||||
priority = "optional"
|
||||
section = "databases"
|
||||
depends = "$auto"
|
||||
license-file = ["LICENSE", "0"]
|
||||
maintainer = "Programvareverkstedet <projects@pvv.ntnu.no>"
|
||||
copyright = "Copyright (c) 2025, Programvareverkstedet"
|
||||
# NOTE: try to keep this in sync with README, and keep an 80 char limit.
|
||||
extended-description = """
|
||||
|
||||
[dev-dependencies]
|
||||
regex = "1.12.2"
|
||||
Muscl is a CLI tool that let's unprivileged users perform administrative
|
||||
operations on a MySQL DBMS, given the are authorized to perform the action
|
||||
on the database or database user in question. The default authorization
|
||||
mechanism is to only let the user perform these actions on databases and
|
||||
database users that are prefixed with their username, or with the name of any
|
||||
unix group that the user is a part of. i.e. `<user>_mydb`, `<user>_mydbuser`,
|
||||
or `<group>_myotherdb`.
|
||||
|
||||
The available administrative actions include:
|
||||
|
||||
- creating/listing/modifying/deleting databases and database users
|
||||
- modifying privileges for a database user on a database
|
||||
- changing the passwords of the database users
|
||||
- locking and unlocking database users
|
||||
- ... more to come
|
||||
|
||||
The software is designed to be run as a client and a server. The server has
|
||||
administrative access to the mysql server, and is responsible for authorizing
|
||||
any requests from the clients.
|
||||
|
||||
This software is designed for multi-user servers, like tilde servers,
|
||||
university servers, etc.\
|
||||
"""
|
||||
|
||||
changelog = "CHANGELOG.md"
|
||||
assets = [
|
||||
[
|
||||
"target/release/muscl",
|
||||
"usr/bin/",
|
||||
"755",
|
||||
],
|
||||
[
|
||||
"target/release/mysql-useradm",
|
||||
"usr/bin/",
|
||||
"755",
|
||||
],
|
||||
[
|
||||
"target/release/mysql-dbadm",
|
||||
"usr/bin/",
|
||||
"755",
|
||||
],
|
||||
[
|
||||
"assets/debian/config.toml",
|
||||
"etc/muscl/config.toml",
|
||||
"644",
|
||||
],
|
||||
[
|
||||
"assets/completions/_*",
|
||||
"usr/share/zsh/site-functions/completions/",
|
||||
"644",
|
||||
],
|
||||
[
|
||||
"assets/completions/*.bash",
|
||||
"usr/share/bash-completion/completions/",
|
||||
"644",
|
||||
],
|
||||
[
|
||||
"assets/completions/*.fish",
|
||||
"usr/share/fish/vendor_completions.d/",
|
||||
"644",
|
||||
],
|
||||
]
|
||||
preserve-symlinks = true
|
||||
maintainer-scripts = "debian/"
|
||||
systemd-units = [
|
||||
{ unit-name = "muscl", unit-scripts = "assets/systemd", enable = true },
|
||||
]
|
||||
|
||||
108
README.md
108
README.md
@@ -1,105 +1,33 @@
|
||||
[](https://pages.pvv.ntnu.no/Projects/mysqladm-rs/main/docs/mysqladm/)
|
||||
[](https://pages.pvv.ntnu.no/Projects/muscl/main/coverage/)
|
||||
[](https://pages.pvv.ntnu.no/Projects/muscl/main/docs/muscl/)
|
||||
|
||||
# mysqladm-rs
|
||||
# muscl 💪
|
||||
|
||||
Healing mysql spasms since 2024
|
||||
Dropping DBs (dumbbells) and having mysql spasms since 2024
|
||||
|
||||
## What is this?
|
||||
|
||||
This is a CLI tool that let's normal users perform administrative operations on a MySQL DBMS, with some restrictions.
|
||||
The default restriction is to only let the user perform these actions on databases and database users that are prefixed with their username,
|
||||
This is a CLI tool that let's unprivileged users perform administrative operations on a MySQL DBMS, given the are authorized to perform the action on the database or database user in question.
|
||||
The default authorization mechanism is to only let the user perform these actions on databases and database users that are prefixed with their username,
|
||||
or with the name of any unix group that the user is a part of. i.e. `<user>_mydb`, `<user>_mydbuser`, or `<group>_myotherdb`.
|
||||
|
||||
The administrative actions available to the user includes:
|
||||
The available administrative actions include:
|
||||
|
||||
- creating/listing/modifying/deleting databases and database users
|
||||
- modifying database user privileges
|
||||
- modifying privileges for a database user on a database
|
||||
- changing the passwords of the database users
|
||||
- locking and unlocking database user accounts
|
||||
- locking and unlocking database users
|
||||
- ... more to come
|
||||
|
||||
The software is split into a client and a server. The server has administrative access to the mysql server,
|
||||
and is responsible for checking client authorization for the different types of actions the client might request.
|
||||
The software is designed to be run as a client and a server. The server has administrative access to the mysql server,
|
||||
and is responsible for authorizing any requests from the clients.
|
||||
|
||||
This is designed for (and is only really useful for) multi-user servers, like tilde servers, university unix servers, etc.
|
||||
This software is designed for multi-user servers, like tilde servers, university servers, etc.
|
||||
|
||||
## Installation
|
||||
## Documentation
|
||||
|
||||
The resulting binary will probably need to be marked as either SUID or SGID to work in a multi-user environment.
|
||||
The UID/GID of the binary should have access to the config file, which contains secrets to log in to an admin-like MySQL user.
|
||||
Preferrably, this UID/GID should not be root, in order to minimize the potential damage that can be done in case of security vulnerabilities in the program.
|
||||
|
||||
## Development and testing
|
||||
|
||||
### Nix
|
||||
|
||||
If you have nix installed, you can test your changes in a NixOS vm by running:
|
||||
|
||||
```bash
|
||||
nix run .#vm
|
||||
```
|
||||
|
||||
### General setup
|
||||
|
||||
Ensure you have a [rust toolchain](https://www.rust-lang.org/tools/install) installed.
|
||||
|
||||
In order to set up a test instance of mariadb in a docker container, run the following command:
|
||||
|
||||
```bash
|
||||
docker run --rm --name mariadb -e MYSQL_ROOT_PASSWORD=secret -p 3306:3306 -d mariadb:latest
|
||||
```
|
||||
|
||||
This will start a mariadb instance with the root password `secret`, and expose the port 3306 on the host machine.
|
||||
|
||||
|
||||
Run the following command to create a configuration file with the default settings:
|
||||
|
||||
```bash
|
||||
cp ./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`.
|
||||
This file will contain your database password, but is ignored by git, so it will not be committed to the repository.
|
||||
|
||||
You should now be able to connect to the mariadb instance, after building the program and using arguments to specify the config file.
|
||||
|
||||
```bash
|
||||
cargo run -- --config-file ./config.toml <args>
|
||||
|
||||
# example usage
|
||||
cargo run -- --config-file ./config.toml create-db "${USER}_testdb"
|
||||
cargo run -- --config-file ./config.toml create-user "${USER}_testuser"
|
||||
cargo run -- --config-file ./config.toml edit-db-privs -p "${USER}_testdb:${USER}_testuser:A"
|
||||
cargo run -- --config-file ./config.toml show-db-privs
|
||||
```
|
||||
|
||||
To stop and remove the container, run the following command:
|
||||
|
||||
```bash
|
||||
docker stop mariadb
|
||||
```
|
||||
|
||||
## Compatibility mode with [mysql-admutils](https://git.pvv.ntnu.no/Projects/mysql-admutils)
|
||||
|
||||
If you enable the feature flag `mysql-admutils-compatibility` (enabled by default), the output directory will contain two symlinks to the binary, `mysql-dbadm` and `mysql-useradm`. In the same fashion as busybox, the binary will react to its `argv[0]` and behave as if it was called with the corresponding name. While the internal functionality is written in rust, these modes strive to behave as similar as possible to the original programs.
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
./target/debug/mysql-dbadm --help
|
||||
./target/debug/mysql-useradm --help
|
||||
```
|
||||
|
||||
### Known deviations from the original programs
|
||||
|
||||
- Added flags for database configuration, not present in the original programs
|
||||
- `--help` output is formatted by clap in a modern style.
|
||||
- `mysql-dbadm edit-perm` uses the new implementation. The idea was that the parsing
|
||||
logic was too complex to be worth porting, and there wouldn't be any scripts depending
|
||||
on this command anyway. As such, the new implementation is more user-friendly and only
|
||||
brings positive changes.
|
||||
- The new tools use the modern implementation to find it's configuration. If you compiled
|
||||
the old programs with `--sysconfdir=<somewhere>`, you might have to provide `--config-file`
|
||||
where the old program would just work by itself.
|
||||
- The order in which some things are validated (e.g. whether you own a user, whether the
|
||||
contains illegal characters, whether the user does or does not exist) might be different
|
||||
from the original program, leading to the same command giving the errors in a different order.
|
||||
- [Installation and configuration](docs/installation.md)
|
||||
- [Development and testing](docs/development.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)
|
||||
|
||||
23
assets/debian/config.toml
Normal file
23
assets/debian/config.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[mysql]
|
||||
# Hostname and port of the database.
|
||||
host = "localhost"
|
||||
port = 3306
|
||||
|
||||
# The path to the unix socket of the database.
|
||||
# If you uncomment this line, the host and port will be ignored
|
||||
|
||||
# socket_path = "/run/mysql/mysql.sock"
|
||||
|
||||
# The username and password for the database connection.
|
||||
# The username and password can be omitted if you are connecting
|
||||
# to the database using socket based authentication.
|
||||
# However, the vendored systemd service is running as DynamicUser,
|
||||
# so these need to be specified by default unless you override the
|
||||
# systemd unit.
|
||||
username = "muscl"
|
||||
# This file gets created by systemd automatically, given you have set
|
||||
# the password with `systemd-creds`.
|
||||
password_file = "/run/credentials/muscl.service/muscl_mysql_password"
|
||||
|
||||
# Database connection timeout in seconds
|
||||
timeout = 2
|
||||
62
assets/systemd/muscl.service
Normal file
62
assets/systemd/muscl.service
Normal file
@@ -0,0 +1,62 @@
|
||||
[Unit]
|
||||
Description=Muscl MySQL admin tool
|
||||
Requires=muscl.socket
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
ExecStart=/usr/bin/muscl server --systemd --disable-landlock socket-activate
|
||||
ExecReload=/usr/bin/kill -HUP $MAINPID
|
||||
|
||||
WatchdogSec=15
|
||||
|
||||
# Although this is a multi-instance unit, the constant `User` field is needed
|
||||
# for authentication via mysql's auth_socket plugin to work.
|
||||
User=muscl
|
||||
Group=muscl
|
||||
DynamicUser=yes
|
||||
|
||||
ConfigurationDirectory=muscl
|
||||
|
||||
ImportCredential=muscl_mysql_password
|
||||
|
||||
# This is required to read unix user/group details.
|
||||
PrivateUsers=false
|
||||
|
||||
# Needed to communicate with MySQL.
|
||||
PrivateNetwork=false
|
||||
PrivateIPC=false
|
||||
|
||||
AmbientCapabilities=
|
||||
CapabilityBoundingSet=
|
||||
DeviceAllow=
|
||||
DevicePolicy=closed
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
NoNewPrivileges=true
|
||||
PrivateDevices=true
|
||||
PrivateMounts=true
|
||||
PrivateTmp=yes
|
||||
ProcSubset=pid
|
||||
ProtectClock=true
|
||||
ProtectControlGroups=strict
|
||||
ProtectHome=true
|
||||
ProtectHostname=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectProc=invisible
|
||||
ProtectSystem=strict
|
||||
RemoveIPC=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
RestrictNamespaces=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
SocketBindDeny=any
|
||||
SystemCallArchitectures=native
|
||||
|
||||
SystemCallFilter=@system-service
|
||||
# This is needed for landlock
|
||||
# SystemCallFilter=@sandbox
|
||||
SystemCallFilter=~@privileged @resources
|
||||
|
||||
UMask=0777
|
||||
10
assets/systemd/muscl.socket
Normal file
10
assets/systemd/muscl.socket
Normal file
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Muscl MySQL admin tool
|
||||
|
||||
[Socket]
|
||||
ListenStream=/run/muscl/muscl.sock
|
||||
Accept=no
|
||||
PassCredentials=true
|
||||
|
||||
[Install]
|
||||
WantedBy=sockets.target
|
||||
30
build.rs
30
build.rs
@@ -3,6 +3,30 @@ use anyhow::anyhow;
|
||||
#[cfg(feature = "mysql-admutils-compatibility")]
|
||||
use std::{env, os::unix::fs::symlink, path::PathBuf};
|
||||
|
||||
fn get_git_commit() -> Option<String> {
|
||||
let repo = git2::Repository::discover(".").ok()?;
|
||||
let head = repo.head().ok()?;
|
||||
let commit = head.peel_to_commit().ok()?;
|
||||
Some(commit.id().to_string())
|
||||
}
|
||||
|
||||
fn embed_build_time_info() {
|
||||
let commit = option_env!("GIT_COMMIT")
|
||||
.map(|s| s.to_string())
|
||||
.or_else(get_git_commit)
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
let build_profile = std::env::var("OUT_DIR")
|
||||
.unwrap_or_else(|_| "unknown".to_string())
|
||||
.split(std::path::MAIN_SEPARATOR)
|
||||
.nth_back(3)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
println!("cargo:rustc-env=GIT_COMMIT={}", commit);
|
||||
println!("cargo:rustc-env=BUILD_PROFILE={}", build_profile);
|
||||
}
|
||||
|
||||
fn generate_mysql_admutils_symlinks() -> anyhow::Result<()> {
|
||||
// NOTE: This is slightly illegal, and depends on implementation details.
|
||||
// But it is only here for ease of testing the compatibility layer,
|
||||
@@ -21,7 +45,7 @@ fn generate_mysql_admutils_symlinks() -> anyhow::Result<()> {
|
||||
|
||||
if !target_profile_dir.join("mysql-useradm").exists() {
|
||||
symlink(
|
||||
target_profile_dir.join("mysqladm"),
|
||||
PathBuf::from("./muscl"),
|
||||
target_profile_dir.join("mysql-useradm"),
|
||||
)
|
||||
.ok();
|
||||
@@ -29,7 +53,7 @@ fn generate_mysql_admutils_symlinks() -> anyhow::Result<()> {
|
||||
|
||||
if !target_profile_dir.join("mysql-dbadm").exists() {
|
||||
symlink(
|
||||
target_profile_dir.join("mysqladm"),
|
||||
PathBuf::from("./muscl"),
|
||||
target_profile_dir.join("mysql-dbadm"),
|
||||
)
|
||||
.ok();
|
||||
@@ -42,5 +66,7 @@ fn main() -> anyhow::Result<()> {
|
||||
#[cfg(feature = "mysql-admutils-compatibility")]
|
||||
generate_mysql_admutils_symlinks()?;
|
||||
|
||||
embed_build_time_info();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
50
create-deb.sh
Executable file
50
create-deb.sh
Executable file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
declare -r RUST_PROFILE="release-lto"
|
||||
|
||||
if [[ "${CREATE_DEB_DEBUG:-}" == "1" ]]; then
|
||||
set -x
|
||||
fi
|
||||
|
||||
if ! command -v cargo &> /dev/null; then
|
||||
echo "cargo could not be found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v cargo-deb &> /dev/null; then
|
||||
echo "cargo-deb could not be found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cargo build --profile "$RUST_PROFILE"
|
||||
|
||||
mkdir -p assets/completions
|
||||
|
||||
(
|
||||
PATH="./target/$RUST_PROFILE:$PATH"
|
||||
|
||||
COMPLETE=bash muscl > assets/completions/muscl.bash
|
||||
COMPLETE=zsh muscl > assets/completions/_muscl
|
||||
COMPLETE=fish muscl > assets/completions/muscl.fish
|
||||
|
||||
COMPLETE=bash mysql-dbadm > assets/completions/mysql-dbadm.bash
|
||||
COMPLETE=zsh mysql-dbadm > assets/completions/_mysql-dbadm
|
||||
COMPLETE=fish mysql-dbadm > assets/completions/mysql-dbadm.fish
|
||||
|
||||
COMPLETE=bash mysql-useradm > assets/completions/mysql-useradm.bash
|
||||
COMPLETE=zsh mysql-useradm > assets/completions/_mysql-useradm
|
||||
COMPLETE=fish mysql-useradm > assets/completions/mysql-useradm.fish
|
||||
)
|
||||
|
||||
# See https://github.com/clap-rs/clap/issues/1764
|
||||
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}
|
||||
|
||||
DEFAULT_CARGO_DEB_ARGS=(
|
||||
--profile "$RUST_PROFILE"
|
||||
--no-build
|
||||
)
|
||||
|
||||
cargo deb "${DEFAULT_CARGO_DEB_ARGS[@]}" "$@"
|
||||
12
deny.toml
12
deny.toml
@@ -27,14 +27,13 @@ ignore = []
|
||||
|
||||
[licenses]
|
||||
allow = [
|
||||
"GPL-2.0",
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"ISC",
|
||||
"MPL-2.0",
|
||||
"Unicode-DFS-2016",
|
||||
"BSD-3-Clause",
|
||||
"OpenSSL",
|
||||
"CDLA-Permissive-2.0",
|
||||
"ISC",
|
||||
"MIT",
|
||||
"Unicode-3.0",
|
||||
"Zlib"
|
||||
]
|
||||
confidence-threshold = 0.8
|
||||
exceptions = []
|
||||
@@ -75,4 +74,3 @@ allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
allow-git = []
|
||||
|
||||
[sources.allow-org]
|
||||
|
||||
|
||||
56
docs/development.md
Normal file
56
docs/development.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Development and testing
|
||||
|
||||
Ensure you have a [rust toolchain](https://www.rust-lang.org/tools/install) installed.
|
||||
|
||||
In order to set up a test instance of mariadb in a docker container, run the following command:
|
||||
|
||||
```bash
|
||||
docker run --rm --name mariadb -e MYSQL_ROOT_PASSWORD=secret -p 3306:3306 -d mariadb:latest
|
||||
```
|
||||
|
||||
This will start a mariadb instance with the root password `secret`, and expose the port 3306 on the host machine.
|
||||
|
||||
Run the following command to create a configuration file with the default settings:
|
||||
|
||||
```bash
|
||||
cp ./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`.
|
||||
This file will contain your database password, but is ignored by git, so it will not be committed to the repository.
|
||||
|
||||
You should now be able to connect to the mariadb instance, after building the program and using arguments to specify the config file.
|
||||
|
||||
```bash
|
||||
cargo run -- --config-file ./config.toml <args>
|
||||
|
||||
# example usage
|
||||
cargo run -- --config-file ./config.toml create-db "${USER}_testdb"
|
||||
cargo run -- --config-file ./config.toml create-user "${USER}_testuser"
|
||||
cargo run -- --config-file ./config.toml edit-privs -p "${USER}_testdb:${USER}_testuser:A"
|
||||
cargo run -- --config-file ./config.toml show-privs
|
||||
```
|
||||
|
||||
To stop and remove the container, run the following command:
|
||||
|
||||
```bash
|
||||
docker stop mariadb
|
||||
```
|
||||
|
||||
## Development using Nix
|
||||
|
||||
If you have nix installed, you can easily test your changes in a NixOS vm by running:
|
||||
|
||||
```bash
|
||||
nix run .#vm
|
||||
```
|
||||
|
||||
You can configure the vm in `flake.nix`
|
||||
|
||||
## Filter logs by user with journalctl
|
||||
|
||||
If you want to filter the server logs by user, you can use journalctl's built-in filtering capabilities.
|
||||
|
||||
```bash
|
||||
journalctl -eu muscl F_USER=<username>
|
||||
```
|
||||
81
docs/installation.md
Normal file
81
docs/installation.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Installation and configuration
|
||||
|
||||
This document contains instructions for the recommended way of installing and configuring muscl.
|
||||
|
||||
Note that there are separate instructions for [installing on NixOS](nixos.md) and [installing with SUID/SGID mode](suid-sgid-mode.md).
|
||||
|
||||
## Installing with deb on Debian
|
||||
|
||||
You can install muscl by adding the [PVV apt repository][pvv-apt-repository] and installing the package:
|
||||
|
||||
```bash
|
||||
# Become root (if not already)
|
||||
sudo -i
|
||||
|
||||
# Check the version of your Debian installation
|
||||
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
|
||||
|
||||
# Pull the repository key
|
||||
curl https://git.pvv.ntnu.no/api/packages/Projects/debian/repository.key -o /etc/apt/keyrings/pvvgit-projects.asc
|
||||
|
||||
# Update package lists
|
||||
apt update
|
||||
|
||||
# Install muscl
|
||||
apt install muscl
|
||||
```
|
||||
|
||||
## Creating a database user
|
||||
|
||||
In order for the daemon to be able to do anything interesting on the mysql server, it needs
|
||||
a database user with sufficient privileges. You can create such a user by running the following commands
|
||||
on the mysql server as the admin user (or another user with sufficient privileges):
|
||||
|
||||
```sql
|
||||
CREATE USER `muscl`@`%` IDENTIFIED BY '<strong_password_here>';
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON `mysql`.* TO `muscl`@`%`;
|
||||
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`.
|
||||
|
||||
## 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`.
|
||||
|
||||
You can add the password like this:
|
||||
|
||||
```bash
|
||||
# Become root (if not already)
|
||||
sudo -i
|
||||
|
||||
# Unless you already have a working credential store, you need to set it up first
|
||||
mkdir -p /etc/credstore.encrypted
|
||||
systemd-creds setup
|
||||
|
||||
# Be careful not to leave the password in your shell history!
|
||||
# Add a space before setting the next line to avoid this.
|
||||
export MUSCL_MYSQL_PASSWORD="<strong_password_here>"
|
||||
|
||||
# Now set the muscl mysql password
|
||||
systemd-creds encrypt --name=muscl_mysql_password <(echo "$MUSCL_MYSQL_PASSWORD") /etc/credstore.encrypted/muscl_mysql_password
|
||||
```
|
||||
|
||||
If you are running systemd older than version 254 (see `systemctl --version`), you might have to override the service to point to the path of the credential manually, because `ImportCredential=` is not supported. Run `systemctl edit muscl.service` and add the following lines:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
LoadCredentialEncrypted=muscl_mysql_password:/etc/credstore.encrypted/muscl_mysql_password
|
||||
```
|
||||
|
||||
## A note on minimum version requirements
|
||||
|
||||
The muscl server will work with older versions of systemd, but the recommended version is 254 or newer.
|
||||
|
||||
For full landlock support (disabled by default), you need a linux kernel version 6.7 or newer.
|
||||
|
||||
[pvv-apt-repository]: https://git.pvv.ntnu.no/Projects/-/packages/debian/muscl
|
||||
28
docs/mysql-admutils-compatibility.md
Normal file
28
docs/mysql-admutils-compatibility.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Compatibility mode with [mysql-admutils](https://git.pvv.ntnu.no/Projects/mysql-admutils)
|
||||
|
||||
If you enable the feature flag `mysql-admutils-compatibility` (enabled by default for now), the output directory will contain two symlinks to the `musl` binary: `mysql-dbadm` and `mysql-useradm`. When invoked through these symlinks, the binary will react to its `argv[0]` and behave accordingly. These modes strive to behave as similar as possible to the original programs.
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
./target/debug/mysql-dbadm --help
|
||||
./target/debug/mysql-useradm --help
|
||||
```
|
||||
|
||||
These symlinks are also included in the deb packages.
|
||||
|
||||
### Known deviations from `mysql-admutils`' behaviour
|
||||
|
||||
- `--help` output is formatted by clap in a different style.
|
||||
- `mysql-dbadm edit-perm` uses the new privilege editor implementation. Replicating
|
||||
the old behaviour
|
||||
there shoulnd't have been any (or at least very few) scripts relying on the old
|
||||
command API or behavior.
|
||||
- The new tools use the new implementation to find it's configuration file, and uses the
|
||||
new configuration format. See the example config and installation instructions for more
|
||||
information about how to configure the software.
|
||||
- The order in which input is validated (e.g. whether you own a user, whether the
|
||||
contains illegal characters, whether the user does or does not exist) might be different
|
||||
from the original program, leading to the same command reporting different errors.
|
||||
- Arguments are de-duplicated, meaning that if you run something like
|
||||
`mysql-dbadm create user_db1 user_db2 user_db1`, the program will only try to create
|
||||
the `user_db1` once. The old program would attempt to create it twice, failing the second time.
|
||||
16
docs/nixos.md
Normal file
16
docs/nixos.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Use with NixOS
|
||||
|
||||
For NixOS, there is a module available via the nix flake. You can include it in your configuration like this:
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-XX.YY";
|
||||
|
||||
inputs.muscl.url = "git+https://git.pvv.ntnu.no/Projects/muscle.git";
|
||||
inputs.muscl.inputs.nixpkgs.follows = "nixpkgs";
|
||||
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The module allows for easy setup on a local machine by enabling `services.muscl.createLocalDatabaseUser`.
|
||||
17
docs/suid-sgid-mode.md
Normal file
17
docs/suid-sgid-mode.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# SUID/SGID mode
|
||||
|
||||
> [!WARNING]
|
||||
> This will be deprecated in a future release, see https://git.pvv.ntnu.no/Projects/muscl/issues/101
|
||||
>
|
||||
> We do not recommend you use this mode unless you absolutely have to. The biggest reason why `muscl` was rewritten from scratch
|
||||
> was to fix an architectural issue that easily caused vulnerabilites due to reliance on SUID/SGID. Althought the architecture now
|
||||
> is more resistant against such vulnerabilites, it is not failsafe.
|
||||
|
||||
For backwards compatibility reasons, it is possible to run the program without a daemon by utilizing SUID/SGID.
|
||||
|
||||
In order to do this, you should set either the SUID/SGID bit and preferably make the executable owned by a non-privileged user.
|
||||
If the database is running on the same machine, the user/group will need access to write and read from the database socket.
|
||||
Otherwise, the only requirement is that the user/group is able to read the config file (typically `/etc/muscl/config.toml`).
|
||||
|
||||
Note that the feature flag for SUID/SGID mode is not enabled by default, and is not included in the default deb package.
|
||||
You will need to compile the program yourself with `--features suid-sgid-mode`.
|
||||
@@ -1,11 +1,11 @@
|
||||
# This should go to `/etc/mysqladm/config.toml`
|
||||
# This should go to `/etc/muscl/config.toml`
|
||||
|
||||
[server]
|
||||
# The path to the socket where users can connect to the daemon.
|
||||
#
|
||||
# Note that this options gets ignored if you are using systemd socket activation
|
||||
# (see `systemctl status mysqladm.socket`)
|
||||
socket_path = "/run/mysqladm/mysqladm.sock"
|
||||
# (see `systemctl status muscl.socket`)
|
||||
socket_path = "/run/muscl/muscl.sock"
|
||||
|
||||
[mysql]
|
||||
|
||||
@@ -13,7 +13,7 @@ socket_path = "/run/mysqladm/mysqladm.sock"
|
||||
host = "localhost"
|
||||
port = 3306
|
||||
|
||||
# The path to the unix socket of the databse.
|
||||
# The path to the unix socket of the database.
|
||||
# If you uncomment this line, the host and port will be ignored
|
||||
|
||||
# socket_path = "/run/mysql/mysql.sock"
|
||||
|
||||
49
flake.lock
generated
49
flake.lock
generated
@@ -1,12 +1,47 @@
|
||||
{
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1765739568,
|
||||
"narHash": "sha256-gQYx35Of4UDKUjAYvmxjUEh/DdszYeTtT6MDin4loGE=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "67d2baff0f9f677af35db61b32b5df6863bcc075",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"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": 1762363567,
|
||||
"narHash": "sha256-YRqMDEtSMbitIMj+JLpheSz0pwEr0Rmy5mC7myl17xs=",
|
||||
"lastModified": 1765472234,
|
||||
"narHash": "sha256-9VvC20PJPsleGMewwcWYKGzDIyjckEz8uWmT0vCDYK0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "ae814fd3904b621d8ab97418f1d0f2eb0d3716f4",
|
||||
"rev": "2fbfb1d73d239d2402a8fe03963e37aab15abe8b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -18,6 +53,8 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"crane": "crane",
|
||||
"nix-vm-test": "nix-vm-test",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
@@ -29,11 +66,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1762655942,
|
||||
"narHash": "sha256-hOM12KcQNQALrhB9w6KJmV5hPpm3GA763HRe9o7JUiI=",
|
||||
"lastModified": 1765680428,
|
||||
"narHash": "sha256-fyPmRof9SZeI14ChPk5rVPOm7ISiiGkwGCunkhM+eUg=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "6ac961b02d4235572692241e333d0470637f5492",
|
||||
"rev": "eb3898d8ef143d4bf0f7f2229105fc51c7731b2f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
172
flake.nix
172
flake.nix
@@ -4,9 +4,14 @@
|
||||
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
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 }:
|
||||
outputs = { self, nixpkgs, rust-overlay, crane, nix-vm-test }:
|
||||
let
|
||||
inherit (nixpkgs) lib;
|
||||
|
||||
@@ -33,15 +38,31 @@
|
||||
in f system pkgs toolchain);
|
||||
in {
|
||||
apps = let
|
||||
mkApp = program: { type = "app"; program = toString program; };
|
||||
mkApp = program: description: {
|
||||
type = "app";
|
||||
program = toString program;
|
||||
meta = {
|
||||
inherit description;
|
||||
};
|
||||
};
|
||||
mkVm = name: mkApp "${self.nixosConfigurations.${name}.config.system.build.vm}/bin/run-nixos-vm";
|
||||
in forAllSystems (system: pkgs: _: {
|
||||
mysqladm-rs = mkApp (lib.getExe self.packages.${system}.mysqladm-rs);
|
||||
coverage = mkApp (pkgs.writeScript "mysqladm-rs-coverage" ''
|
||||
${lib.getExe pkgs.python3} -m http.server -d "${self.packages.${system}.coverage}/html/src"
|
||||
'');
|
||||
vm = mkApp "${self.nixosConfigurations.vm.config.system.build.vm}/bin/run-nixos-vm";
|
||||
muscl = mkApp (lib.getExe self.packages.${system}.muscl) "Run muscl without any setup";
|
||||
coverage = mkApp (pkgs.writeShellScript "muscl-coverage" ''
|
||||
${lib.getExe pkgs.python3} -m http.server -d "${self.packages.${system}.coverage}/html"
|
||||
'') "Serve code coverage report at http://localhost:8000";
|
||||
|
||||
vm = mkVm "vm" "Start a NixOS VM with muscl and mariadb installed";
|
||||
vm-mysql = mkVm "vm-mysql" "Start a NixOS VM with muscl and mysql installed";
|
||||
vm-suid = mkVm "vm-suid" "Start a NixOS VM with muscl as SUID/SGID installed";
|
||||
});
|
||||
|
||||
nixosConfigurations = {
|
||||
vm = import ./nix/nixos-configurations/vm.nix { inherit self nixpkgs; useMariadb = true; };
|
||||
vm-mysql = import ./nix/nixos-configurations/vm.nix { inherit self nixpkgs; useMariadb = false; };
|
||||
vm-suid = import ./nix/nixos-configurations/vm-suid.nix { inherit self nixpkgs; };
|
||||
};
|
||||
|
||||
devShell = forAllSystems (system: pkgs: toolchain: pkgs.mkShell {
|
||||
nativeBuildInputs = with pkgs; [
|
||||
toolchain
|
||||
@@ -49,109 +70,78 @@
|
||||
cargo-nextest
|
||||
cargo-edit
|
||||
cargo-deny
|
||||
cargo-deb
|
||||
dpkg
|
||||
];
|
||||
|
||||
RUST_SRC_PATH = "${toolchain}/lib/rustlib/src/rust/library";
|
||||
});
|
||||
|
||||
overlays = {
|
||||
default = self.overlays.mysqladm-rs;
|
||||
mysqladm-rs = final: prev: {
|
||||
inherit (self.packages.${prev.stdenv.hostPlatform.system}) mysqladm-rs;
|
||||
default = self.overlays.muscl;
|
||||
muscl = final: prev: {
|
||||
inherit (self.packages.${prev.stdenv.hostPlatform.system}) muscl;
|
||||
};
|
||||
muscl-crane = final: prev: {
|
||||
muscl = self.packages.${prev.stdenv.hostPlatform.system}.muscl-crane;
|
||||
};
|
||||
muscl-suid = final: prev: {
|
||||
muscl = self.packages.${prev.stdenv.hostPlatform.system}.muscl-suid;
|
||||
};
|
||||
muscl-suid-crane = final: prev: {
|
||||
muscl = self.packages.${prev.stdenv.hostPlatform.system}.muscl-suid-crane;
|
||||
};
|
||||
};
|
||||
|
||||
nixosModules = {
|
||||
default = self.nixosModules.mysqladm-rs;
|
||||
mysqladm-rs = import ./nix/module.nix;
|
||||
default = self.nixosModules.muscl;
|
||||
muscl = import ./nix/module.nix;
|
||||
};
|
||||
|
||||
packages = let
|
||||
# vmlib = forAllSystems(system: _: _: nix-vm-test.lib.${system});
|
||||
|
||||
packages = forAllSystems (system: pkgs: _:
|
||||
let
|
||||
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
|
||||
cargoLock = ./Cargo.lock;
|
||||
src = builtins.filterSource (path: type: let
|
||||
baseName = baseNameOf (toString path);
|
||||
in !(lib.any (b: b) [
|
||||
(!(lib.cleanSourceFilter path type))
|
||||
(baseName == "target" && type == "directory")
|
||||
(baseName == "nix" && type == "directory")
|
||||
(baseName == "flake.nix" && type == "regular")
|
||||
(baseName == "flake.lock" && type == "regular")
|
||||
])) ./.;
|
||||
in forAllSystems (system: pkgs: _: {
|
||||
default = self.packages.${system}.mysqladm-rs;
|
||||
mysqladm-rs = pkgs.callPackage ./nix/default.nix { inherit cargoToml cargoLock src; };
|
||||
craneLib = crane.mkLib pkgs;
|
||||
src = lib.fileset.toSource {
|
||||
root = ./.;
|
||||
fileset = lib.fileset.unions [
|
||||
(craneLib.fileset.commonCargoSources ./.)
|
||||
./assets
|
||||
];
|
||||
};
|
||||
in {
|
||||
default = self.packages.${system}.muscl-crane;
|
||||
|
||||
muscl = pkgs.callPackage ./nix/default.nix { inherit cargoToml cargoLock src; };
|
||||
muscl-crane = pkgs.callPackage ./nix/default.nix {
|
||||
useCrane = true;
|
||||
inherit cargoToml cargoLock src craneLib;
|
||||
};
|
||||
|
||||
muscl-suid = pkgs.callPackage ./nix/default.nix {
|
||||
suidSgidSupport = true;
|
||||
inherit cargoToml cargoLock src;
|
||||
};
|
||||
muscl-suid-crane = pkgs.callPackage ./nix/default.nix {
|
||||
useCrane = true;
|
||||
suidSgidSupport = true;
|
||||
inherit cargoToml cargoLock src craneLib;
|
||||
};
|
||||
|
||||
coverage = pkgs.callPackage ./nix/coverage.nix { inherit cargoToml cargoLock src; };
|
||||
filteredSource = pkgs.runCommandLocal "filtered-source" { } ''
|
||||
ln -s ${src} $out
|
||||
'';
|
||||
|
||||
debianVm = import ./nix/debian-vm-configuration.nix { inherit nix-vm-test nixpkgs system pkgs; };
|
||||
});
|
||||
|
||||
nixosConfigurations.vm = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
pkgs = import nixpkgs {
|
||||
system = "x86_64-linux";
|
||||
overlays = [
|
||||
self.overlays.default
|
||||
];
|
||||
};
|
||||
modules = [
|
||||
"${nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix"
|
||||
"${nixpkgs}/nixos/tests/common/user-account.nix"
|
||||
|
||||
self.nixosModules.default
|
||||
|
||||
({ config, pkgs, ... }: {
|
||||
system.stateVersion = config.system.nixos.release;
|
||||
virtualisation.graphics = false;
|
||||
|
||||
users = {
|
||||
groups = {
|
||||
a = { };
|
||||
b = { };
|
||||
};
|
||||
users.alice.extraGroups = [
|
||||
"a"
|
||||
"b"
|
||||
"wheel"
|
||||
"systemd-journal"
|
||||
];
|
||||
extraUsers.root.password = "root";
|
||||
};
|
||||
|
||||
services.getty.autologinUser = "alice";
|
||||
|
||||
users.motd = ''
|
||||
=================================
|
||||
Welcome to the mysqladm-rs vm!
|
||||
|
||||
Try running:
|
||||
${config.services.mysqladm-rs.package.meta.mainProgram}
|
||||
|
||||
Password for alice is 'foobar'
|
||||
Password for root is 'root'
|
||||
|
||||
To exit, press Ctrl+A, then X
|
||||
=================================
|
||||
'';
|
||||
|
||||
services.mysql = {
|
||||
enable = true;
|
||||
package = pkgs.mariadb;
|
||||
};
|
||||
services.mysqladm-rs = {
|
||||
enable = true;
|
||||
createLocalDatabaseUser = true;
|
||||
};
|
||||
|
||||
systemd.services."mysqladm@".environment.RUST_LOG = "debug";
|
||||
|
||||
programs.vim = {
|
||||
enable = true;
|
||||
defaultEditor = true;
|
||||
};
|
||||
})
|
||||
];
|
||||
};
|
||||
checks = forAllSystems (system: pkgs: _: {
|
||||
# NOTE: the non-crane build runs tests during checkPhase
|
||||
inherit (self.packages.${system}) muscl muscl-suid;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
49
nix/debian-vm-configuration.nix
Normal file
49
nix/debian-vm-configuration.nix
Normal file
@@ -0,0 +1,49 @@
|
||||
{ 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
|
||||
@@ -1,31 +1,91 @@
|
||||
{
|
||||
lib
|
||||
, rustPlatform
|
||||
, stdenv
|
||||
, installShellFiles
|
||||
, versionCheckHook
|
||||
|
||||
, cargoToml
|
||||
, cargoLock
|
||||
, src
|
||||
, installShellFiles
|
||||
|
||||
, useCrane ? false
|
||||
, craneLib ? null
|
||||
, suidSgidSupport ? false
|
||||
}:
|
||||
let
|
||||
mainProgram = (lib.head cargoToml.bin).name;
|
||||
in
|
||||
rustPlatform.buildRustPackage {
|
||||
pname = cargoToml.package.name;
|
||||
version = cargoToml.package.version;
|
||||
inherit src;
|
||||
buildFunction = if useCrane then craneLib.buildPackage else rustPlatform.buildRustPackage;
|
||||
|
||||
cargoLock.lockFile = cargoLock;
|
||||
pnameCraneSuffix = lib.optionalString useCrane "-crane";
|
||||
pnameSuidSuffix = lib.optionalString suidSgidSupport "-suid";
|
||||
pname = "${cargoToml.package.name}${pnameSuidSuffix}${pnameCraneSuffix}";
|
||||
|
||||
rustPlatformArgs = {
|
||||
buildType = "release-lto";
|
||||
buildFeatures = lib.optional suidSgidSupport "suid-sgid-mode";
|
||||
cargoLock.lockFile = cargoLock;
|
||||
|
||||
doCheck = true;
|
||||
useNextest = true;
|
||||
nativeCheckInputs = [
|
||||
versionCheckHook
|
||||
];
|
||||
cargoCheckFeatures = lib.optional suidSgidSupport "suid-sgid-mode";
|
||||
|
||||
postCheck = lib.optionalString (stdenv.buildPlatform.system == stdenv.hostPlatform.system && suidSgidSupport) ''
|
||||
./target/${stdenv.hostPlatform.rust.rustcTarget}/release/muscl --version | grep "SUID/SGID mode: enabled"
|
||||
'';
|
||||
};
|
||||
|
||||
craneArgs = {
|
||||
cargoLock = cargoLock;
|
||||
cargoExtraArgs = lib.escapeShellArgs [ "--features" (lib.concatStringsSep "," (lib.optional suidSgidSupport "suid-sgid-mode")) ];
|
||||
cargoArtifacts = craneLib.buildDepsOnly {
|
||||
inherit pname;
|
||||
inherit (cargoToml.package) version;
|
||||
src = lib.fileset.toSource {
|
||||
root = ../.;
|
||||
fileset = lib.fileset.unions [
|
||||
(craneLib.fileset.cargoTomlAndLock ../.)
|
||||
];
|
||||
};
|
||||
|
||||
cargoLock = cargoLock;
|
||||
};
|
||||
};
|
||||
in
|
||||
buildFunction ({
|
||||
inherit pname;
|
||||
inherit (cargoToml.package) version;
|
||||
inherit src;
|
||||
|
||||
nativeBuildInputs = [ installShellFiles ];
|
||||
postInstall = let
|
||||
commands = lib.mapCartesianProduct ({ shell, command }: ''
|
||||
"$out/bin/${mainProgram}" generate-completions --shell "${shell}" --command "${command}" > "$TMP/mysqladm.${shell}"
|
||||
installShellCompletion "--${shell}" --cmd "${command}" "$TMP/mysqladm.${shell}"
|
||||
installShellCompletions = lib.mapCartesianProduct ({ shell, command }: ''
|
||||
(
|
||||
export PATH="$out/bin:$PATH"
|
||||
export COMPLETE="${shell}"
|
||||
"${command}" > "$TMP/${command}.${shell}"
|
||||
)
|
||||
# See https://github.com/clap-rs/clap/issues/1764
|
||||
sed -i 's/muscl/${command}/g' "$TMP/${command}.${shell}"
|
||||
installShellCompletion "--${shell}" --cmd "${command}" "$TMP/${command}.${shell}"
|
||||
'') {
|
||||
shell = [ "bash" "zsh" "fish" ];
|
||||
command = [ "mysqladm" "mysql-dbadm" "mysql-useradm" ];
|
||||
command = [ "muscl" "mysql-dbadm" "mysql-useradm" ];
|
||||
};
|
||||
in lib.concatStringsSep "\n" commands;
|
||||
in ''
|
||||
ln -sr "$out/bin/muscl" "$out/bin/mysql-dbadm"
|
||||
ln -sr "$out/bin/muscl" "$out/bin/mysql-useradm"
|
||||
|
||||
${lib.concatStringsSep "\n" installShellCompletions}
|
||||
|
||||
install -Dm444 assets/systemd/muscl.socket -t "$out/lib/systemd/system"
|
||||
install -Dm644 assets/systemd/muscl.service -t "$out/lib/systemd/system"
|
||||
substituteInPlace "$out/lib/systemd/system/muscl.service" \
|
||||
--replace-fail '/usr/bin/muscl' "$out/bin/muscl"
|
||||
'';
|
||||
|
||||
meta = with lib; {
|
||||
license = licenses.mit;
|
||||
@@ -33,3 +93,6 @@ rustPlatform.buildRustPackage {
|
||||
inherit mainProgram;
|
||||
};
|
||||
}
|
||||
//
|
||||
(if useCrane then craneArgs else rustPlatformArgs)
|
||||
)
|
||||
|
||||
140
nix/module.nix
140
nix/module.nix
@@ -1,31 +1,29 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
cfg = config.services.mysqladm-rs;
|
||||
cfg = config.services.muscl;
|
||||
format = pkgs.formats.toml { };
|
||||
in
|
||||
{
|
||||
options.services.mysqladm-rs = {
|
||||
enable = lib.mkEnableOption "Enable mysqladm-rs";
|
||||
options.services.muscl = {
|
||||
enable = lib.mkEnableOption "Enable muscl";
|
||||
|
||||
package = lib.mkPackageOption pkgs "mysqladm-rs" { };
|
||||
package = lib.mkPackageOption pkgs "muscl" { };
|
||||
|
||||
createLocalDatabaseUser = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Create a local database user for mysqladm-rs";
|
||||
description = "Create a local database user for muscl";
|
||||
};
|
||||
|
||||
logLevel = lib.mkOption {
|
||||
type = lib.types.enum [ "quiet" "error" "warn" "info" "debug" "trace" ];
|
||||
default = "debug";
|
||||
description = "Log level for mysqladm-rs";
|
||||
type = lib.types.enum [ "quiet" "info" "debug" "trace" ];
|
||||
default = "info";
|
||||
description = "Log level for muscl";
|
||||
apply = level: {
|
||||
"quiet" = "-q";
|
||||
"error" = "";
|
||||
"warn" = "-v";
|
||||
"info" = "-vv";
|
||||
"debug" = "-vvv";
|
||||
"trace" = "-vvvv";
|
||||
"info" = "";
|
||||
"debug" = "-v";
|
||||
"trace" = "-vv";
|
||||
}.${level};
|
||||
};
|
||||
|
||||
@@ -37,8 +35,8 @@ in
|
||||
server = {
|
||||
socket_path = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
default = "/run/mysqladm/mysqladm.sock";
|
||||
description = "Path to the mysqladm socket";
|
||||
default = "/run/muscl/muscl.sock";
|
||||
description = "Path to the muscl socket";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -60,7 +58,7 @@ in
|
||||
};
|
||||
username = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "mysqladm";
|
||||
default = "muscl";
|
||||
description = "MySQL username";
|
||||
};
|
||||
passwordFile = lib.mkOption {
|
||||
@@ -79,12 +77,23 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf config.services.mysqladm-rs.enable {
|
||||
config = lib.mkIf config.services.muscl.enable {
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
|
||||
environment.etc."mysqladm/config.toml".source = let
|
||||
nullStrippedConfig = lib.filterAttrsRecursive (_: v: v != null) cfg.settings;
|
||||
in format.generate "mysqladm-rs.conf" nullStrippedConfig;
|
||||
environment.etc."muscl/config.toml".source = lib.pipe cfg.settings [
|
||||
# Remove nulls
|
||||
(lib.filterAttrsRecursive (_: v: v != null))
|
||||
|
||||
# Load mysql.passwordFile via LoadCredentials
|
||||
(conf:
|
||||
if conf.mysql.passwordFile or null != null
|
||||
then lib.recursiveUpdate conf { mysql.passwordFile = "/run/credentials/muscl.service/mysql-password"; }
|
||||
else conf
|
||||
)
|
||||
|
||||
# Render file
|
||||
(format.generate "muscl.conf")
|
||||
];
|
||||
|
||||
services.mysql.ensureUsers = lib.mkIf cfg.createLocalDatabaseUser [
|
||||
{
|
||||
@@ -96,31 +105,38 @@ in
|
||||
}
|
||||
];
|
||||
|
||||
systemd.services."mysqladm" = {
|
||||
description = "MySQL administration tool for non-admin users";
|
||||
restartTriggers = [ config.environment.etc."mysqladm/config.toml".source ];
|
||||
requires = [ "mysqladm.socket" ];
|
||||
systemd.packages = [ cfg.package ];
|
||||
|
||||
systemd.sockets."muscl".wantedBy = [ "sockets.target" ];
|
||||
|
||||
systemd.services."muscl" = {
|
||||
reloadTriggers = [ config.environment.etc."muscl/config.toml".source ];
|
||||
serviceConfig = {
|
||||
Type = "notify";
|
||||
ExecStart = "${lib.getExe cfg.package} ${cfg.logLevel} server --systemd socket-activate";
|
||||
ExecStart = [
|
||||
""
|
||||
"${lib.getExe cfg.package} ${cfg.logLevel} server --systemd --disable-landlock socket-activate"
|
||||
];
|
||||
|
||||
WatchdogSec = 15;
|
||||
ExecReload = [
|
||||
""
|
||||
"${lib.getExe' pkgs.coreutils "kill"} -HUP $MAINPID"
|
||||
];
|
||||
|
||||
# Although this is a multi-instance unit, the constant `User` field is needed
|
||||
# for authentication via mysql's auth_socket plugin to work.
|
||||
User = "mysqladm";
|
||||
Group = "mysqladm";
|
||||
DynamicUser = true;
|
||||
RuntimeDirectory = "muscl/root-mnt";
|
||||
RuntimeDirectoryMode = "0700";
|
||||
RootDirectory = "/run/muscl/root-mnt";
|
||||
BindReadOnlyPaths = [
|
||||
builtins.storeDir
|
||||
"/etc"
|
||||
]
|
||||
++ lib.optionals (cfg.settings.mysql.socket_path != null) [
|
||||
cfg.settings.mysql.socket_path
|
||||
];
|
||||
|
||||
ConfigurationDirectory = "mysqladm";
|
||||
RuntimeDirectory = "mysqladm";
|
||||
|
||||
# This is required to read unix user/group details.
|
||||
PrivateUsers = false;
|
||||
|
||||
# Needed to communicate with MySQL.
|
||||
PrivateNetwork = false;
|
||||
PrivateIPC = false;
|
||||
ImportCredential = "";
|
||||
LoadCredential = lib.mkIf (cfg.settings.mysql.passwordFile != null) [
|
||||
"mysql-password:${cfg.settings.mysql.passwordFile}"
|
||||
];
|
||||
|
||||
IPAddressDeny = "any";
|
||||
IPAddressAllow = [
|
||||
@@ -131,48 +147,6 @@ in
|
||||
|
||||
RestrictAddressFamilies = [ "AF_UNIX" ]
|
||||
++ (lib.optionals (cfg.settings.mysql.host != null) [ "AF_INET" "AF_INET6" ]);
|
||||
|
||||
AmbientCapabilities = [ "" ];
|
||||
CapabilityBoundingSet = [ "" ];
|
||||
DeviceAllow = [ "" ];
|
||||
LockPersonality = true;
|
||||
MemoryDenyWriteExecute = true;
|
||||
NoNewPrivileges = true;
|
||||
PrivateDevices = true;
|
||||
PrivateMounts = true;
|
||||
PrivateTmp = "yes";
|
||||
ProcSubset = "pid";
|
||||
ProtectClock = true;
|
||||
ProtectControlGroups = "strict";
|
||||
ProtectHome = true;
|
||||
ProtectHostname = true;
|
||||
ProtectKernelLogs = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectProc = "invisible";
|
||||
ProtectSystem = "strict";
|
||||
RemoveIPC = true;
|
||||
UMask = "0777";
|
||||
RestrictNamespaces = true;
|
||||
RestrictRealtime = true;
|
||||
RestrictSUIDSGID = true;
|
||||
SystemCallArchitectures = "native";
|
||||
SocketBindDeny = [ "any" ];
|
||||
SystemCallFilter = [
|
||||
"@system-service"
|
||||
"~@privileged"
|
||||
"~@resources"
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
systemd.sockets."mysqladm" = {
|
||||
description = "MySQL administration tool for non-admin users";
|
||||
wantedBy = [ "sockets.target" ];
|
||||
socketConfig = {
|
||||
ListenStream = cfg.settings.server.socket_path;
|
||||
Accept = "no";
|
||||
PassCredentials = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
96
nix/nixos-configurations/vm-suid.nix
Normal file
96
nix/nixos-configurations/vm-suid.nix
Normal file
@@ -0,0 +1,96 @@
|
||||
{ self, nixpkgs, ... }:
|
||||
let
|
||||
inherit (nixpkgs) lib;
|
||||
in
|
||||
nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
pkgs = import nixpkgs {
|
||||
system = "x86_64-linux";
|
||||
overlays = [
|
||||
self.overlays.muscl-suid-crane
|
||||
];
|
||||
};
|
||||
modules = [
|
||||
"${nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix"
|
||||
"${nixpkgs}/nixos/tests/common/user-account.nix"
|
||||
|
||||
({ config, pkgs, ... }: {
|
||||
system.stateVersion = config.system.nixos.release;
|
||||
virtualisation.graphics = false;
|
||||
|
||||
users = {
|
||||
groups = {
|
||||
a = { };
|
||||
b = { };
|
||||
muscl = { };
|
||||
};
|
||||
users.muscl = {
|
||||
isSystemUser = true;
|
||||
group = "muscl";
|
||||
};
|
||||
users.alice.extraGroups = [
|
||||
"a"
|
||||
"b"
|
||||
"wheel"
|
||||
"systemd-journal"
|
||||
];
|
||||
extraUsers.root.password = "root";
|
||||
};
|
||||
|
||||
services.getty.autologinUser = "alice";
|
||||
|
||||
users.motd = ''
|
||||
=================================
|
||||
Welcome to the muscl SUID/SGID vm!
|
||||
|
||||
Try running:
|
||||
${pkgs.muscl.meta.mainProgram}
|
||||
|
||||
Password for alice is 'foobar'
|
||||
Password for root is 'root'
|
||||
|
||||
To exit, press Ctrl+A, then X
|
||||
=================================
|
||||
'';
|
||||
|
||||
services.mysql = {
|
||||
enable = true;
|
||||
package = pkgs.mariadb;
|
||||
ensureUsers = [
|
||||
{
|
||||
name = "muscl";
|
||||
ensurePermissions = {
|
||||
"mysql.*" = "SELECT, INSERT, UPDATE, DELETE";
|
||||
"*.*" = "GRANT OPTION, CREATE, DROP";
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
security.wrappers.muscl = {
|
||||
owner = "muscl";
|
||||
group = "muscl";
|
||||
setuid = true;
|
||||
source = lib.getExe pkgs.muscl;
|
||||
};
|
||||
|
||||
environment.etc."muscl/config.toml".source = (pkgs.formats.toml { }).generate "muscl-config.toml" {
|
||||
mysql = {
|
||||
username = "muscl";
|
||||
password = "snakeoil";
|
||||
socket_path = "/run/mysqld/mysqld.sock";
|
||||
};
|
||||
};
|
||||
|
||||
# TODO: extra setup commands:
|
||||
# set password for mysql user
|
||||
|
||||
programs.vim = {
|
||||
enable = true;
|
||||
defaultEditor = true;
|
||||
};
|
||||
|
||||
environment.systemPackages = with pkgs; [ jq pkgs.muscl ];
|
||||
})
|
||||
];
|
||||
}
|
||||
69
nix/nixos-configurations/vm.nix
Normal file
69
nix/nixos-configurations/vm.nix
Normal file
@@ -0,0 +1,69 @@
|
||||
{ self, nixpkgs, useMariadb ? true, ... }:
|
||||
nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
pkgs = import nixpkgs {
|
||||
system = "x86_64-linux";
|
||||
overlays = [
|
||||
self.overlays.muscl-crane
|
||||
];
|
||||
};
|
||||
modules = [
|
||||
"${nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix"
|
||||
"${nixpkgs}/nixos/tests/common/user-account.nix"
|
||||
|
||||
self.nixosModules.default
|
||||
|
||||
({ config, pkgs, ... }: {
|
||||
system.stateVersion = config.system.nixos.release;
|
||||
virtualisation.graphics = false;
|
||||
|
||||
users = {
|
||||
groups = {
|
||||
a = { };
|
||||
b = { };
|
||||
};
|
||||
users.alice.extraGroups = [
|
||||
"a"
|
||||
"b"
|
||||
"wheel"
|
||||
"systemd-journal"
|
||||
];
|
||||
extraUsers.root.password = "root";
|
||||
};
|
||||
|
||||
services.getty.autologinUser = "alice";
|
||||
|
||||
users.motd = ''
|
||||
=================================
|
||||
Welcome to the muscl vm!
|
||||
|
||||
Try running:
|
||||
${config.services.muscl.package.meta.mainProgram}
|
||||
|
||||
Password for alice is 'foobar'
|
||||
Password for root is 'root'
|
||||
|
||||
To exit, press Ctrl+A, then X
|
||||
=================================
|
||||
'';
|
||||
|
||||
services.mysql = {
|
||||
enable = true;
|
||||
package = if useMariadb then pkgs.mariadb else pkgs.mysql84;
|
||||
dataDir = if useMariadb then "/var/lib/mariadb" else "/var/lib/mysql";
|
||||
};
|
||||
services.muscl = {
|
||||
enable = true;
|
||||
logLevel = "trace";
|
||||
createLocalDatabaseUser = true;
|
||||
};
|
||||
|
||||
programs.vim = {
|
||||
enable = true;
|
||||
defaultEditor = true;
|
||||
};
|
||||
|
||||
environment.systemPackages = with pkgs; [ jq ];
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
use crate::core::protocol::Response;
|
||||
|
||||
pub fn erroneous_server_response(
|
||||
response: Option<Result<Response, std::io::Error>>,
|
||||
) -> anyhow::Result<()> {
|
||||
match response {
|
||||
Some(Ok(Response::Error(e))) => {
|
||||
anyhow::bail!("Server returned error: {}", e);
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
anyhow::bail!(e);
|
||||
}
|
||||
Some(response) => {
|
||||
anyhow::bail!("Unexpected response from server: {:?}", response);
|
||||
}
|
||||
None => {
|
||||
anyhow::bail!("No response from server");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,491 +0,0 @@
|
||||
use anyhow::Context;
|
||||
use clap::Parser;
|
||||
use dialoguer::{Confirm, Editor};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use nix::unistd::{User, getuid};
|
||||
use prettytable::{Cell, Row, Table};
|
||||
|
||||
use crate::{
|
||||
cli::common::erroneous_server_response,
|
||||
core::{
|
||||
common::yn,
|
||||
database_privileges::{
|
||||
db_priv_field_human_readable_name, diff_privileges, display_privilege_diffs,
|
||||
generate_editor_content_from_privilege_data, parse_privilege_data_from_editor_content,
|
||||
parse_privilege_table_cli_arg,
|
||||
},
|
||||
protocol::{
|
||||
ClientToServerMessageStream, MySQLDatabase, Request, Response,
|
||||
print_create_databases_output_status, print_create_databases_output_status_json,
|
||||
print_drop_databases_output_status, print_drop_databases_output_status_json,
|
||||
print_modify_database_privileges_output_status,
|
||||
},
|
||||
},
|
||||
server::sql::database_privilege_operations::{DATABASE_PRIVILEGE_FIELDS, DatabasePrivilegeRow},
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
// #[command(next_help_heading = Some(DATABASE_COMMAND_HEADER))]
|
||||
pub enum DatabaseCommand {
|
||||
/// Create one or more databases
|
||||
#[command()]
|
||||
CreateDb(DatabaseCreateArgs),
|
||||
|
||||
/// Delete one or more databases
|
||||
#[command()]
|
||||
DropDb(DatabaseDropArgs),
|
||||
|
||||
/// Print information about one or more databases
|
||||
///
|
||||
/// If no database name is provided, all databases you have access will be shown.
|
||||
#[command()]
|
||||
ShowDb(DatabaseShowArgs),
|
||||
|
||||
/// Print user privileges for one or more databases
|
||||
///
|
||||
/// If no database names are provided, all databases you have access to will be shown.
|
||||
#[command()]
|
||||
ShowDbPrivs(DatabaseShowPrivsArgs),
|
||||
|
||||
/// Change user privileges for one or more databases. See `edit-db-privs --help` for details.
|
||||
///
|
||||
/// This command has two modes of operation:
|
||||
///
|
||||
/// 1. Interactive mode: If nothing else is specified, 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.
|
||||
///
|
||||
/// The privilege arguments should be formatted as `<db>:<user>:<privileges>`
|
||||
/// where the privileges are a string of characters, each representing a single privilege.
|
||||
/// The character `A` is an exception - it represents all privileges.
|
||||
///
|
||||
/// The character-to-privilege mapping is defined as follows:
|
||||
///
|
||||
/// - `s` - SELECT
|
||||
/// - `i` - INSERT
|
||||
/// - `u` - UPDATE
|
||||
/// - `d` - DELETE
|
||||
/// - `c` - CREATE
|
||||
/// - `D` - DROP
|
||||
/// - `a` - ALTER
|
||||
/// - `I` - INDEX
|
||||
/// - `t` - CREATE TEMPORARY TABLES
|
||||
/// - `l` - LOCK TABLES
|
||||
/// - `r` - REFERENCES
|
||||
/// - `A` - ALL PRIVILEGES
|
||||
///
|
||||
/// If you provide a database name, you can omit it from the privilege string,
|
||||
/// e.g. `edit-db-privs my_db -p my_user:siu` is equivalent to `edit-db-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.
|
||||
///
|
||||
/// Example usage of non-interactive mode:
|
||||
///
|
||||
/// Enable privileges `SELECT`, `INSERT`, and `UPDATE` for user `my_user` on database `my_db`:
|
||||
///
|
||||
/// `mysqladm edit-db-privs -p my_db:my_user:siu`
|
||||
///
|
||||
/// Enable all privileges for user `my_other_user` on database `my_other_db`:
|
||||
///
|
||||
/// `mysqladm edit-db-privs -p my_other_db:my_other_user:A`
|
||||
///
|
||||
/// Set miscellaneous privileges for multiple users on database `my_db`:
|
||||
///
|
||||
/// `mysqladm edit-db-privs my_db -p my_user:siu my_other_user:ct``
|
||||
///
|
||||
#[command(verbatim_doc_comment)]
|
||||
EditDbPrivs(DatabaseEditPrivsArgs),
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct DatabaseCreateArgs {
|
||||
/// The name of the database(s) to create
|
||||
#[arg(num_args = 1..)]
|
||||
name: Vec<MySQLDatabase>,
|
||||
|
||||
/// Print the information as JSON
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct DatabaseDropArgs {
|
||||
/// The name of the database(s) to drop
|
||||
#[arg(num_args = 1..)]
|
||||
name: Vec<MySQLDatabase>,
|
||||
|
||||
/// Print the information as JSON
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct DatabaseShowArgs {
|
||||
/// The name of the database(s) to show
|
||||
#[arg(num_args = 0..)]
|
||||
name: Vec<MySQLDatabase>,
|
||||
|
||||
/// Print the information as JSON
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct DatabaseShowPrivsArgs {
|
||||
/// The name of the database(s) to show
|
||||
#[arg(num_args = 0..)]
|
||||
name: Vec<MySQLDatabase>,
|
||||
|
||||
/// Print the information as JSON
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct DatabaseEditPrivsArgs {
|
||||
/// The name of the database to edit privileges for
|
||||
pub name: Option<MySQLDatabase>,
|
||||
|
||||
#[arg(short, long, value_name = "[DATABASE:]USER:PRIVILEGES", num_args = 0..)]
|
||||
pub privs: Vec<String>,
|
||||
|
||||
/// Print the information as JSON
|
||||
#[arg(short, long)]
|
||||
pub json: bool,
|
||||
|
||||
/// Specify the text editor to use for editing privileges
|
||||
#[arg(short, long)]
|
||||
pub editor: Option<String>,
|
||||
|
||||
/// Disable interactive confirmation before saving changes
|
||||
#[arg(short, long)]
|
||||
pub yes: bool,
|
||||
}
|
||||
|
||||
pub async fn handle_command(
|
||||
command: DatabaseCommand,
|
||||
server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
match command {
|
||||
DatabaseCommand::CreateDb(args) => create_databases(args, server_connection).await,
|
||||
DatabaseCommand::DropDb(args) => drop_databases(args, server_connection).await,
|
||||
DatabaseCommand::ShowDb(args) => show_databases(args, server_connection).await,
|
||||
DatabaseCommand::ShowDbPrivs(args) => {
|
||||
show_database_privileges(args, server_connection).await
|
||||
}
|
||||
DatabaseCommand::EditDbPrivs(args) => {
|
||||
edit_database_privileges(args, server_connection).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_databases(
|
||||
args: DatabaseCreateArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
if args.name.is_empty() {
|
||||
anyhow::bail!("No database names provided");
|
||||
}
|
||||
|
||||
let message = Request::CreateDatabases(args.name.to_owned());
|
||||
server_connection.send(message).await?;
|
||||
|
||||
let result = match server_connection.next().await {
|
||||
Some(Ok(Response::CreateDatabases(result))) => result,
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if args.json {
|
||||
print_create_databases_output_status_json(&result);
|
||||
} else {
|
||||
print_create_databases_output_status(&result);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn drop_databases(
|
||||
args: DatabaseDropArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
if args.name.is_empty() {
|
||||
anyhow::bail!("No database names provided");
|
||||
}
|
||||
|
||||
let message = Request::DropDatabases(args.name.to_owned());
|
||||
server_connection.send(message).await?;
|
||||
|
||||
let result = match server_connection.next().await {
|
||||
Some(Ok(Response::DropDatabases(result))) => result,
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if args.json {
|
||||
print_drop_databases_output_status_json(&result);
|
||||
} else {
|
||||
print_drop_databases_output_status(&result);
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn show_databases(
|
||||
args: DatabaseShowArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
let message = if args.name.is_empty() {
|
||||
Request::ListDatabases(None)
|
||||
} else {
|
||||
Request::ListDatabases(Some(args.name.to_owned()))
|
||||
};
|
||||
|
||||
server_connection.send(message).await?;
|
||||
|
||||
// TODO: collect errors for json output.
|
||||
|
||||
let database_list = match server_connection.next().await {
|
||||
Some(Ok(Response::ListDatabases(databases))) => databases
|
||||
.into_iter()
|
||||
.filter_map(|(database_name, result)| match result {
|
||||
Ok(database_row) => Some(database_row),
|
||||
Err(err) => {
|
||||
eprintln!("{}", err.to_error_message(&database_name));
|
||||
eprintln!("Skipping...");
|
||||
println!();
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
Some(Ok(Response::ListAllDatabases(database_list))) => match database_list {
|
||||
Ok(list) => list,
|
||||
Err(err) => {
|
||||
server_connection.send(Request::Exit).await?;
|
||||
return Err(
|
||||
anyhow::anyhow!(err.to_error_message()).context("Failed to list databases")
|
||||
);
|
||||
}
|
||||
},
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if args.json {
|
||||
println!("{}", serde_json::to_string_pretty(&database_list)?);
|
||||
} else if database_list.is_empty() {
|
||||
println!("No databases to show.");
|
||||
} else {
|
||||
let mut table = Table::new();
|
||||
table.add_row(Row::new(vec![Cell::new("Database")]));
|
||||
for db in database_list {
|
||||
table.add_row(row![db.database]);
|
||||
}
|
||||
table.printstd();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn show_database_privileges(
|
||||
args: DatabaseShowPrivsArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
let message = if args.name.is_empty() {
|
||||
Request::ListPrivileges(None)
|
||||
} else {
|
||||
Request::ListPrivileges(Some(args.name.to_owned()))
|
||||
};
|
||||
server_connection.send(message).await?;
|
||||
|
||||
let privilege_data = match server_connection.next().await {
|
||||
Some(Ok(Response::ListPrivileges(databases))) => databases
|
||||
.into_iter()
|
||||
.filter_map(|(database_name, result)| match result {
|
||||
Ok(privileges) => Some(privileges),
|
||||
Err(err) => {
|
||||
eprintln!("{}", err.to_error_message(&database_name));
|
||||
eprintln!("Skipping...");
|
||||
println!();
|
||||
None
|
||||
}
|
||||
})
|
||||
.flatten()
|
||||
.collect::<Vec<_>>(),
|
||||
Some(Ok(Response::ListAllPrivileges(privilege_rows))) => match privilege_rows {
|
||||
Ok(list) => list,
|
||||
Err(err) => {
|
||||
server_connection.send(Request::Exit).await?;
|
||||
return Err(anyhow::anyhow!(err.to_error_message())
|
||||
.context("Failed to list database privileges"));
|
||||
}
|
||||
},
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if args.json {
|
||||
println!("{}", serde_json::to_string_pretty(&privilege_data)?);
|
||||
} else if privilege_data.is_empty() {
|
||||
println!("No database privileges to show.");
|
||||
} else {
|
||||
let mut table = Table::new();
|
||||
table.add_row(Row::new(
|
||||
DATABASE_PRIVILEGE_FIELDS
|
||||
.into_iter()
|
||||
.map(db_priv_field_human_readable_name)
|
||||
.map(|name| Cell::new(&name))
|
||||
.collect(),
|
||||
));
|
||||
|
||||
for row in privilege_data {
|
||||
table.add_row(row![
|
||||
row.db,
|
||||
row.user,
|
||||
c->yn(row.select_priv),
|
||||
c->yn(row.insert_priv),
|
||||
c->yn(row.update_priv),
|
||||
c->yn(row.delete_priv),
|
||||
c->yn(row.create_priv),
|
||||
c->yn(row.drop_priv),
|
||||
c->yn(row.alter_priv),
|
||||
c->yn(row.index_priv),
|
||||
c->yn(row.create_tmp_table_priv),
|
||||
c->yn(row.lock_tables_priv),
|
||||
c->yn(row.references_priv),
|
||||
]);
|
||||
}
|
||||
table.printstd();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn edit_database_privileges(
|
||||
args: DatabaseEditPrivsArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
let message = Request::ListPrivileges(args.name.to_owned().map(|name| vec![name]));
|
||||
|
||||
server_connection.send(message).await?;
|
||||
|
||||
let privilege_data = match server_connection.next().await {
|
||||
Some(Ok(Response::ListPrivileges(databases))) => databases
|
||||
.into_iter()
|
||||
.filter_map(|(database_name, result)| match result {
|
||||
Ok(privileges) => Some(privileges),
|
||||
Err(err) => {
|
||||
eprintln!("{}", err.to_error_message(&database_name));
|
||||
eprintln!("Skipping...");
|
||||
println!();
|
||||
None
|
||||
}
|
||||
})
|
||||
.flatten()
|
||||
.collect::<Vec<_>>(),
|
||||
Some(Ok(Response::ListAllPrivileges(privilege_rows))) => match privilege_rows {
|
||||
Ok(list) => list,
|
||||
Err(err) => {
|
||||
server_connection.send(Request::Exit).await?;
|
||||
return Err(anyhow::anyhow!(err.to_error_message())
|
||||
.context("Failed to list database privileges"));
|
||||
}
|
||||
},
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
let privileges_to_change = if !args.privs.is_empty() {
|
||||
parse_privilege_tables_from_args(&args)?
|
||||
} else {
|
||||
edit_privileges_with_editor(&privilege_data, args.name.as_ref())?
|
||||
};
|
||||
|
||||
let diffs = diff_privileges(&privilege_data, &privileges_to_change);
|
||||
|
||||
if diffs.is_empty() {
|
||||
println!("No changes to make.");
|
||||
server_connection.send(Request::Exit).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("The following changes will be made:\n");
|
||||
println!("{}", display_privilege_diffs(&diffs));
|
||||
|
||||
if !args.yes
|
||||
&& !Confirm::new()
|
||||
.with_prompt("Do you want to apply these changes?")
|
||||
.default(false)
|
||||
.show_default(true)
|
||||
.interact()?
|
||||
{
|
||||
server_connection.send(Request::Exit).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let message = Request::ModifyPrivileges(diffs);
|
||||
server_connection.send(message).await?;
|
||||
|
||||
let result = match server_connection.next().await {
|
||||
Some(Ok(Response::ModifyPrivileges(result))) => result,
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
print_modify_database_privileges_output_status(&result);
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_privilege_tables_from_args(
|
||||
args: &DatabaseEditPrivsArgs,
|
||||
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
|
||||
debug_assert!(!args.privs.is_empty());
|
||||
let result = if let Some(name) = &args.name {
|
||||
args.privs
|
||||
.iter()
|
||||
.map(|p| {
|
||||
parse_privilege_table_cli_arg(&format!("{}:{}", name, &p))
|
||||
.context(format!("Failed parsing database privileges: `{}`", &p))
|
||||
})
|
||||
.collect::<anyhow::Result<Vec<DatabasePrivilegeRow>>>()?
|
||||
} else {
|
||||
args.privs
|
||||
.iter()
|
||||
.map(|p| {
|
||||
parse_privilege_table_cli_arg(p)
|
||||
.context(format!("Failed parsing database privileges: `{}`", &p))
|
||||
})
|
||||
.collect::<anyhow::Result<Vec<DatabasePrivilegeRow>>>()?
|
||||
};
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn edit_privileges_with_editor(
|
||||
privilege_data: &[DatabasePrivilegeRow],
|
||||
database_name: Option<&MySQLDatabase>,
|
||||
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
|
||||
let unix_user = User::from_uid(getuid())
|
||||
.context("Failed to look up your UNIX username")
|
||||
.and_then(|u| u.ok_or(anyhow::anyhow!("Failed to look up your UNIX username")))?;
|
||||
|
||||
let editor_content =
|
||||
generate_editor_content_from_privilege_data(privilege_data, &unix_user.name, database_name);
|
||||
|
||||
// TODO: handle errors better here
|
||||
let result = Editor::new().extension("tsv").edit(&editor_content)?;
|
||||
|
||||
match result {
|
||||
None => Ok(privilege_data.to_vec()),
|
||||
Some(result) => parse_privilege_data_from_editor_content(result)
|
||||
.context("Could not parse privilege data from editor"),
|
||||
}
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
use anyhow::Context;
|
||||
use clap::Parser;
|
||||
use dialoguer::{Confirm, Password};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
|
||||
use crate::core::protocol::{
|
||||
ClientToServerMessageStream, ListUsersError, MySQLUser, Request, Response,
|
||||
print_create_users_output_status, print_create_users_output_status_json,
|
||||
print_drop_users_output_status, print_drop_users_output_status_json,
|
||||
print_lock_users_output_status, print_lock_users_output_status_json,
|
||||
print_set_password_output_status, print_unlock_users_output_status,
|
||||
print_unlock_users_output_status_json,
|
||||
};
|
||||
|
||||
use super::common::erroneous_server_response;
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct UserArgs {
|
||||
#[clap(subcommand)]
|
||||
subcmd: UserCommand,
|
||||
}
|
||||
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub enum UserCommand {
|
||||
/// Create one or more users
|
||||
#[command()]
|
||||
CreateUser(UserCreateArgs),
|
||||
|
||||
/// Delete one or more users
|
||||
#[command()]
|
||||
DropUser(UserDeleteArgs),
|
||||
|
||||
/// Change the MySQL password for a user
|
||||
#[command()]
|
||||
PasswdUser(UserPasswdArgs),
|
||||
|
||||
/// Print information about one or more users
|
||||
///
|
||||
/// If no username is provided, all users you have access will be shown.
|
||||
#[command()]
|
||||
ShowUser(UserShowArgs),
|
||||
|
||||
/// Lock account for one or more users
|
||||
#[command()]
|
||||
LockUser(UserLockArgs),
|
||||
|
||||
/// Unlock account for one or more users
|
||||
#[command()]
|
||||
UnlockUser(UserUnlockArgs),
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct UserCreateArgs {
|
||||
#[arg(num_args = 1..)]
|
||||
username: Vec<MySQLUser>,
|
||||
|
||||
/// Do not ask for a password, leave it unset
|
||||
#[clap(long)]
|
||||
no_password: bool,
|
||||
|
||||
/// Print the information as JSON
|
||||
///
|
||||
/// Note that this implies `--no-password`, since the command will become non-interactive.
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct UserDeleteArgs {
|
||||
#[arg(num_args = 1..)]
|
||||
username: Vec<MySQLUser>,
|
||||
|
||||
/// Print the information as JSON
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct UserPasswdArgs {
|
||||
username: MySQLUser,
|
||||
|
||||
#[clap(short, long)]
|
||||
password_file: Option<String>,
|
||||
|
||||
/// Print the information as JSON
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct UserShowArgs {
|
||||
#[arg(num_args = 0..)]
|
||||
username: Vec<MySQLUser>,
|
||||
|
||||
/// Print the information as JSON
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct UserLockArgs {
|
||||
#[arg(num_args = 1..)]
|
||||
username: Vec<MySQLUser>,
|
||||
|
||||
/// Print the information as JSON
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct UserUnlockArgs {
|
||||
#[arg(num_args = 1..)]
|
||||
username: Vec<MySQLUser>,
|
||||
|
||||
/// Print the information as JSON
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
pub async fn handle_command(
|
||||
command: UserCommand,
|
||||
server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
match command {
|
||||
UserCommand::CreateUser(args) => create_users(args, server_connection).await,
|
||||
UserCommand::DropUser(args) => drop_users(args, server_connection).await,
|
||||
UserCommand::PasswdUser(args) => passwd_user(args, server_connection).await,
|
||||
UserCommand::ShowUser(args) => show_users(args, server_connection).await,
|
||||
UserCommand::LockUser(args) => lock_users(args, server_connection).await,
|
||||
UserCommand::UnlockUser(args) => unlock_users(args, server_connection).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_users(
|
||||
args: UserCreateArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
if args.username.is_empty() {
|
||||
anyhow::bail!("No usernames provided");
|
||||
}
|
||||
|
||||
let message = Request::CreateUsers(args.username.to_owned());
|
||||
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::CreateUsers(result))) => result,
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
if args.json {
|
||||
print_create_users_output_status_json(&result);
|
||||
} else {
|
||||
print_create_users_output_status(&result);
|
||||
|
||||
let successfully_created_users = result
|
||||
.iter()
|
||||
.filter_map(|(username, result)| result.as_ref().ok().map(|_| username))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for username in successfully_created_users {
|
||||
if !args.no_password
|
||||
&& Confirm::new()
|
||||
.with_prompt(format!(
|
||||
"Do you want to set a password for user '{}'?",
|
||||
username
|
||||
))
|
||||
.default(false)
|
||||
.interact()?
|
||||
{
|
||||
let password = read_password_from_stdin_with_double_check(username)?;
|
||||
let message = Request::PasswdUser(username.to_owned(), password);
|
||||
|
||||
if let Err(err) = server_connection.send(message).await {
|
||||
server_connection.close().await.ok();
|
||||
anyhow::bail!(err);
|
||||
}
|
||||
|
||||
match server_connection.next().await {
|
||||
Some(Ok(Response::PasswdUser(result))) => {
|
||||
print_set_password_output_status(&result, username)
|
||||
}
|
||||
response => return erroneous_server_response(response),
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn drop_users(
|
||||
args: UserDeleteArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
if args.username.is_empty() {
|
||||
anyhow::bail!("No usernames provided");
|
||||
}
|
||||
|
||||
let message = Request::DropUsers(args.username.to_owned());
|
||||
|
||||
if let Err(err) = server_connection.send(message).await {
|
||||
server_connection.close().await.ok();
|
||||
anyhow::bail!(err);
|
||||
}
|
||||
|
||||
let result = match server_connection.next().await {
|
||||
Some(Ok(Response::DropUsers(result))) => result,
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if args.json {
|
||||
print_drop_users_output_status_json(&result);
|
||||
} else {
|
||||
print_drop_users_output_status(&result);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_password_from_stdin_with_double_check(username: &MySQLUser) -> anyhow::Result<String> {
|
||||
Password::new()
|
||||
.with_prompt(format!("New MySQL password for user '{}'", username))
|
||||
.with_confirmation(
|
||||
format!("Retype new MySQL password for user '{}'", username),
|
||||
"Passwords do not match",
|
||||
)
|
||||
.interact()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn passwd_user(
|
||||
args: UserPasswdArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
// TODO: create a "user" exists check" command
|
||||
let message = Request::ListUsers(Some(vec![args.username.to_owned()]));
|
||||
if let Err(err) = server_connection.send(message).await {
|
||||
server_connection.close().await.ok();
|
||||
anyhow::bail!(err);
|
||||
}
|
||||
let response = match server_connection.next().await {
|
||||
Some(Ok(Response::ListUsers(users))) => users,
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
match response
|
||||
.get(&args.username)
|
||||
.unwrap_or(&Err(ListUsersError::UserDoesNotExist))
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
server_connection.send(Request::Exit).await?;
|
||||
server_connection.close().await.ok();
|
||||
anyhow::bail!("{}", err.to_error_message(&args.username));
|
||||
}
|
||||
}
|
||||
|
||||
let password = if let Some(password_file) = args.password_file {
|
||||
std::fs::read_to_string(password_file)
|
||||
.context("Failed to read password file")?
|
||||
.trim()
|
||||
.to_string()
|
||||
} else {
|
||||
read_password_from_stdin_with_double_check(&args.username)?
|
||||
};
|
||||
|
||||
let message = Request::PasswdUser(args.username.to_owned(), password);
|
||||
|
||||
if let Err(err) = server_connection.send(message).await {
|
||||
server_connection.close().await.ok();
|
||||
anyhow::bail!(err);
|
||||
}
|
||||
|
||||
let result = match server_connection.next().await {
|
||||
Some(Ok(Response::PasswdUser(result))) => result,
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
print_set_password_output_status(&result, &args.username);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn show_users(
|
||||
args: UserShowArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
let message = if args.username.is_empty() {
|
||||
Request::ListUsers(None)
|
||||
} else {
|
||||
Request::ListUsers(Some(args.username.to_owned()))
|
||||
};
|
||||
|
||||
if let Err(err) = server_connection.send(message).await {
|
||||
server_connection.close().await.ok();
|
||||
anyhow::bail!(err);
|
||||
}
|
||||
|
||||
let users = match server_connection.next().await {
|
||||
Some(Ok(Response::ListUsers(users))) => users
|
||||
.into_iter()
|
||||
.filter_map(|(username, result)| match result {
|
||||
Ok(user) => Some(user),
|
||||
Err(err) => {
|
||||
eprintln!("{}", err.to_error_message(&username));
|
||||
eprintln!("Skipping...");
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
Some(Ok(Response::ListAllUsers(users))) => match users {
|
||||
Ok(users) => users,
|
||||
Err(err) => {
|
||||
server_connection.send(Request::Exit).await?;
|
||||
return Err(
|
||||
anyhow::anyhow!(err.to_error_message()).context("Failed to list all users")
|
||||
);
|
||||
}
|
||||
},
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if args.json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&users).context("Failed to serialize users to JSON")?
|
||||
);
|
||||
} else if users.is_empty() {
|
||||
println!("No users to show.");
|
||||
} else {
|
||||
let mut table = prettytable::Table::new();
|
||||
table.add_row(row![
|
||||
"User",
|
||||
"Password is set",
|
||||
"Locked",
|
||||
"Databases where user has privileges"
|
||||
]);
|
||||
for user in users {
|
||||
table.add_row(row![
|
||||
user.user,
|
||||
user.has_password,
|
||||
user.is_locked,
|
||||
user.databases.join("\n")
|
||||
]);
|
||||
}
|
||||
table.printstd();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn lock_users(
|
||||
args: UserLockArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
if args.username.is_empty() {
|
||||
anyhow::bail!("No usernames provided");
|
||||
}
|
||||
|
||||
let message = Request::LockUsers(args.username.to_owned());
|
||||
|
||||
if let Err(err) = server_connection.send(message).await {
|
||||
server_connection.close().await.ok();
|
||||
anyhow::bail!(err);
|
||||
}
|
||||
|
||||
let result = match server_connection.next().await {
|
||||
Some(Ok(Response::LockUsers(result))) => result,
|
||||
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);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn unlock_users(
|
||||
args: UserUnlockArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
if args.username.is_empty() {
|
||||
anyhow::bail!("No usernames provided");
|
||||
}
|
||||
|
||||
let message = Request::UnlockUsers(args.username.to_owned());
|
||||
|
||||
if let Err(err) = server_connection.send(message).await {
|
||||
server_connection.close().await.ok();
|
||||
anyhow::bail!(err);
|
||||
}
|
||||
|
||||
let result = match server_connection.next().await {
|
||||
Some(Ok(Response::UnlockUsers(result))) => result,
|
||||
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);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
mod common;
|
||||
pub mod database_command;
|
||||
pub mod user_command;
|
||||
pub mod commands;
|
||||
|
||||
#[cfg(feature = "mysql-admutils-compatibility")]
|
||||
pub mod mysql_admutils_compatibility;
|
||||
172
src/client/commands.rs
Normal file
172
src/client/commands.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
mod check_auth;
|
||||
mod create_db;
|
||||
mod create_user;
|
||||
mod drop_db;
|
||||
mod drop_user;
|
||||
mod edit_privs;
|
||||
mod lock_user;
|
||||
mod passwd_user;
|
||||
mod show_db;
|
||||
mod show_privs;
|
||||
mod show_user;
|
||||
mod unlock_user;
|
||||
|
||||
pub use check_auth::*;
|
||||
pub use create_db::*;
|
||||
pub use create_user::*;
|
||||
pub use drop_db::*;
|
||||
pub use drop_user::*;
|
||||
pub use edit_privs::*;
|
||||
pub use lock_user::*;
|
||||
pub use passwd_user::*;
|
||||
pub use show_db::*;
|
||||
pub use show_privs::*;
|
||||
pub use show_user::*;
|
||||
pub use unlock_user::*;
|
||||
|
||||
use clap::Subcommand;
|
||||
|
||||
use crate::core::protocol::{ClientToServerMessageStream, Response};
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
#[command(subcommand_required = true)]
|
||||
pub enum ClientCommand {
|
||||
/// Check whether you are authorized to manage the specified databases or users.
|
||||
CheckAuth(CheckAuthArgs),
|
||||
|
||||
/// Create one or more databases
|
||||
CreateDb(CreateDbArgs),
|
||||
|
||||
/// Delete one or more databases
|
||||
DropDb(DropDbArgs),
|
||||
|
||||
/// Print information about one or more databases
|
||||
///
|
||||
/// If no database name is provided, all databases you have access will be shown.
|
||||
ShowDb(ShowDbArgs),
|
||||
|
||||
/// Print user privileges for one or more databases
|
||||
///
|
||||
/// If no database names are provided, all databases you have access to will be shown.
|
||||
ShowPrivs(ShowPrivsArgs),
|
||||
|
||||
/// Change user privileges for one or more databases. See `edit-privs --help` for details.
|
||||
///
|
||||
/// This command has two modes of operation:
|
||||
///
|
||||
/// 1. Interactive mode: If nothing else is specified, 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.
|
||||
///
|
||||
/// The privilege arguments should be formatted as `<db>:<user>:<op><privileges>`
|
||||
/// where the privileges are 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 character-to-privilege mapping is defined as follows:
|
||||
///
|
||||
/// - `s` - SELECT
|
||||
/// - `i` - INSERT
|
||||
/// - `u` - UPDATE
|
||||
/// - `d` - DELETE
|
||||
/// - `c` - CREATE
|
||||
/// - `D` - DROP
|
||||
/// - `a` - ALTER
|
||||
/// - `I` - INDEX
|
||||
/// - `t` - CREATE TEMPORARY TABLES
|
||||
/// - `l` - LOCK TABLES
|
||||
/// - `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.
|
||||
///
|
||||
/// Example usage of non-interactive mode:
|
||||
///
|
||||
/// Enable privileges `SELECT`, `INSERT`, and `UPDATE` for user `my_user` on database `my_db`:
|
||||
///
|
||||
/// `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)]
|
||||
EditPrivs(EditPrivsArgs),
|
||||
|
||||
/// Create one or more users
|
||||
CreateUser(CreateUserArgs),
|
||||
|
||||
/// Delete one or more users
|
||||
DropUser(DropUserArgs),
|
||||
|
||||
/// Change the MySQL password for a user
|
||||
PasswdUser(PasswdUserArgs),
|
||||
|
||||
/// Print information about one or more users
|
||||
///
|
||||
/// If no username is provided, all users you have access will be shown.
|
||||
ShowUser(ShowUserArgs),
|
||||
|
||||
/// Lock account for one or more users
|
||||
LockUser(LockUserArgs),
|
||||
|
||||
/// Unlock account for one or more users
|
||||
UnlockUser(UnlockUserArgs),
|
||||
}
|
||||
|
||||
pub async fn handle_command(
|
||||
command: ClientCommand,
|
||||
server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
match command {
|
||||
ClientCommand::CheckAuth(args) => check_authorization(args, server_connection).await,
|
||||
ClientCommand::CreateDb(args) => create_databases(args, server_connection).await,
|
||||
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::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,
|
||||
ClientCommand::ShowUser(args) => show_users(args, server_connection).await,
|
||||
ClientCommand::LockUser(args) => lock_users(args, server_connection).await,
|
||||
ClientCommand::UnlockUser(args) => unlock_users(args, server_connection).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn erroneous_server_response(
|
||||
response: Option<Result<Response, std::io::Error>>,
|
||||
) -> anyhow::Result<()> {
|
||||
match response {
|
||||
Some(Ok(Response::Error(e))) => {
|
||||
anyhow::bail!("Server returned error: {}", e);
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
anyhow::bail!(e);
|
||||
}
|
||||
Some(response) => {
|
||||
anyhow::bail!("Unexpected response from server: {:?}", response);
|
||||
}
|
||||
None => {
|
||||
anyhow::bail!("No response from server");
|
||||
}
|
||||
}
|
||||
}
|
||||
67
src/client/commands/check_auth.rs
Normal file
67
src/client/commands/check_auth.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use crate::{
|
||||
client::commands::erroneous_server_response,
|
||||
core::{
|
||||
protocol::{
|
||||
ClientToServerMessageStream, Request, Response,
|
||||
print_check_authorization_output_status, print_check_authorization_output_status_json,
|
||||
},
|
||||
types::DbOrUser,
|
||||
},
|
||||
};
|
||||
use clap::Parser;
|
||||
use futures_util::SinkExt;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct CheckAuthArgs {
|
||||
/// The MySQL database(s) or user(s) to check authorization for
|
||||
#[arg(num_args = 1.., value_name = "NAME")]
|
||||
name: Vec<String>,
|
||||
|
||||
/// Treat the provided names as users instead of databases
|
||||
#[arg(short, long)]
|
||||
users: bool,
|
||||
|
||||
/// Print the information as JSON
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
pub async fn check_authorization(
|
||||
args: CheckAuthArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
if args.name.is_empty() {
|
||||
anyhow::bail!("No database/user names provided");
|
||||
}
|
||||
|
||||
let payload = args
|
||||
.name
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
if args.users {
|
||||
DbOrUser::User(name.into())
|
||||
} else {
|
||||
DbOrUser::Database(name.into())
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let message = Request::CheckAuthorization(payload);
|
||||
server_connection.send(message).await?;
|
||||
|
||||
let result = match server_connection.next().await {
|
||||
Some(Ok(Response::CheckAuthorization(response))) => response,
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if args.json {
|
||||
print_check_authorization_output_status_json(&result);
|
||||
} else {
|
||||
print_check_authorization_output_status(&result);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
52
src/client/commands/create_db.rs
Normal file
52
src/client/commands/create_db.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use clap::Parser;
|
||||
use futures_util::SinkExt;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{
|
||||
client::commands::erroneous_server_response,
|
||||
core::{
|
||||
protocol::{
|
||||
ClientToServerMessageStream, Request, Response, print_create_databases_output_status,
|
||||
print_create_databases_output_status_json,
|
||||
},
|
||||
types::MySQLDatabase,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct CreateDbArgs {
|
||||
/// The MySQL database(s) to create
|
||||
#[arg(num_args = 1.., value_name = "DB_NAME")]
|
||||
name: Vec<MySQLDatabase>,
|
||||
|
||||
/// Print the information as JSON
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
pub async fn create_databases(
|
||||
args: CreateDbArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
if args.name.is_empty() {
|
||||
anyhow::bail!("No database names provided");
|
||||
}
|
||||
|
||||
let message = Request::CreateDatabases(args.name.to_owned());
|
||||
server_connection.send(message).await?;
|
||||
|
||||
let result = match server_connection.next().await {
|
||||
Some(Ok(Response::CreateDatabases(result))) => result,
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if args.json {
|
||||
print_create_databases_output_status_json(&result);
|
||||
} else {
|
||||
print_create_databases_output_status(&result);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
96
src/client/commands/create_user.rs
Normal file
96
src/client/commands/create_user.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use clap::Parser;
|
||||
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},
|
||||
core::{
|
||||
protocol::{
|
||||
ClientToServerMessageStream, Request, Response, print_create_users_output_status,
|
||||
print_create_users_output_status_json, print_set_password_output_status,
|
||||
},
|
||||
types::MySQLUser,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct CreateUserArgs {
|
||||
/// The MySQL user(s) to create
|
||||
#[arg(num_args = 1.., value_name = "USER_NAME")]
|
||||
username: Vec<MySQLUser>,
|
||||
|
||||
/// Do not ask for a password, leave it unset
|
||||
#[clap(long)]
|
||||
no_password: bool,
|
||||
|
||||
/// Print the information as JSON
|
||||
///
|
||||
/// Note that this implies `--no-password`, since the command will become non-interactive.
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
pub async fn create_users(
|
||||
args: CreateUserArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
if args.username.is_empty() {
|
||||
anyhow::bail!("No usernames provided");
|
||||
}
|
||||
|
||||
let message = Request::CreateUsers(args.username.to_owned());
|
||||
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::CreateUsers(result))) => result,
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
if args.json {
|
||||
print_create_users_output_status_json(&result);
|
||||
} else {
|
||||
print_create_users_output_status(&result);
|
||||
|
||||
let successfully_created_users = result
|
||||
.iter()
|
||||
.filter_map(|(username, result)| result.as_ref().ok().map(|_| username))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for username in successfully_created_users {
|
||||
if !args.no_password
|
||||
&& Confirm::new()
|
||||
.with_prompt(format!(
|
||||
"Do you want to set a password for user '{}'?",
|
||||
username
|
||||
))
|
||||
.default(false)
|
||||
.interact()?
|
||||
{
|
||||
let password = read_password_from_stdin_with_double_check(username)?;
|
||||
let message = Request::PasswdUser((username.to_owned(), password));
|
||||
|
||||
if let Err(err) = server_connection.send(message).await {
|
||||
server_connection.close().await.ok();
|
||||
anyhow::bail!(err);
|
||||
}
|
||||
|
||||
match server_connection.next().await {
|
||||
Some(Ok(Response::SetUserPassword(result))) => {
|
||||
print_set_password_output_status(&result, username)
|
||||
}
|
||||
response => return erroneous_server_response(response),
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
78
src/client/commands/drop_db.rs
Normal file
78
src/client/commands/drop_db.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
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,
|
||||
core::{
|
||||
completion::mysql_database_completer,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, Request, Response, print_drop_databases_output_status,
|
||||
print_drop_databases_output_status_json,
|
||||
},
|
||||
types::MySQLDatabase,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct DropDbArgs {
|
||||
/// The MySQL database(s) to drop
|
||||
#[arg(num_args = 1.., value_name = "DB_NAME")]
|
||||
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_database_completer)))]
|
||||
name: Vec<MySQLDatabase>,
|
||||
|
||||
/// Print the information as JSON
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
|
||||
/// Automatically confirm action without prompting
|
||||
#[arg(short, long)]
|
||||
yes: bool,
|
||||
}
|
||||
|
||||
pub async fn drop_databases(
|
||||
args: DropDbArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
if args.name.is_empty() {
|
||||
anyhow::bail!("No database names provided");
|
||||
}
|
||||
|
||||
if !args.yes {
|
||||
let confirmation = Confirm::new()
|
||||
.with_prompt(format!(
|
||||
"Are you sure you want to drop the databases?\n\n{}\n\nThis action cannot be undone",
|
||||
args.name
|
||||
.iter()
|
||||
.map(|d| format!("- {}", d))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
))
|
||||
.interact()?;
|
||||
|
||||
if !confirmation {
|
||||
println!("Aborting drop operation.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let message = Request::DropDatabases(args.name.to_owned());
|
||||
server_connection.send(message).await?;
|
||||
|
||||
let result = match server_connection.next().await {
|
||||
Some(Ok(Response::DropDatabases(result))) => result,
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if args.json {
|
||||
print_drop_databases_output_status_json(&result);
|
||||
} else {
|
||||
print_drop_databases_output_status(&result);
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
82
src/client/commands/drop_user.rs
Normal file
82
src/client/commands/drop_user.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
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,
|
||||
core::{
|
||||
completion::mysql_user_completer,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, Request, Response, print_drop_users_output_status,
|
||||
print_drop_users_output_status_json,
|
||||
},
|
||||
types::MySQLUser,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct DropUserArgs {
|
||||
/// The MySQL user(s) to drop
|
||||
#[arg(num_args = 1.., value_name = "USER_NAME")]
|
||||
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_user_completer)))]
|
||||
username: Vec<MySQLUser>,
|
||||
|
||||
/// Print the information as JSON
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
|
||||
/// Automatically confirm action without prompting
|
||||
#[arg(short, long)]
|
||||
yes: bool,
|
||||
}
|
||||
|
||||
pub async fn drop_users(
|
||||
args: DropUserArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
if args.username.is_empty() {
|
||||
anyhow::bail!("No usernames provided");
|
||||
}
|
||||
|
||||
if !args.yes {
|
||||
let confirmation = Confirm::new()
|
||||
.with_prompt(format!(
|
||||
"Are you sure you want to drop the users?\n\n{}\n\nThis action cannot be undone",
|
||||
args.username
|
||||
.iter()
|
||||
.map(|d| format!("- {}", d))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
))
|
||||
.interact()?;
|
||||
|
||||
if !confirmation {
|
||||
println!("Aborting drop operation.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let message = Request::DropUsers(args.username.to_owned());
|
||||
|
||||
if let Err(err) = server_connection.send(message).await {
|
||||
server_connection.close().await.ok();
|
||||
anyhow::bail!(err);
|
||||
}
|
||||
|
||||
let result = match server_connection.next().await {
|
||||
Some(Ok(Response::DropUsers(result))) => result,
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if args.json {
|
||||
print_drop_users_output_status_json(&result);
|
||||
} else {
|
||||
print_drop_users_output_status(&result);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
259
src/client/commands/edit_privs.rs
Normal file
259
src/client/commands/edit_privs.rs
Normal file
@@ -0,0 +1,259 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::Parser;
|
||||
use clap_complete::ArgValueCompleter;
|
||||
use dialoguer::{Confirm, Editor};
|
||||
use futures_util::SinkExt;
|
||||
use nix::unistd::{User, getuid};
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{
|
||||
client::commands::erroneous_server_response,
|
||||
core::{
|
||||
completion::mysql_database_completer,
|
||||
database_privileges::{
|
||||
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,
|
||||
},
|
||||
types::{MySQLDatabase, MySQLUser},
|
||||
},
|
||||
};
|
||||
|
||||
#[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>,
|
||||
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
value_name = "[DATABASE:]USER:[+-]PRIVILEGES",
|
||||
num_args = 0..,
|
||||
value_parser = DatabasePrivilegeEditEntry::parse_from_str,
|
||||
)]
|
||||
pub privs: Vec<DatabasePrivilegeEditEntry>,
|
||||
|
||||
/// Print the information as JSON
|
||||
#[arg(short, long)]
|
||||
pub json: bool,
|
||||
|
||||
/// Specify the text editor to use for editing privileges
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
value_name = "COMMAND",
|
||||
value_hint = clap::ValueHint::CommandString,
|
||||
)]
|
||||
pub editor: Option<String>,
|
||||
|
||||
/// Disable interactive confirmation before saving changes
|
||||
#[arg(short, long)]
|
||||
pub yes: bool,
|
||||
}
|
||||
|
||||
async fn users_exist(
|
||||
server_connection: &mut ClientToServerMessageStream,
|
||||
privilege_diff: &BTreeSet<DatabasePrivilegesDiff>,
|
||||
) -> anyhow::Result<BTreeMap<MySQLUser, bool>> {
|
||||
let user_list = privilege_diff
|
||||
.iter()
|
||||
.map(|diff| diff.get_user_name().clone())
|
||||
.collect();
|
||||
|
||||
let message = Request::ListUsers(Some(user_list));
|
||||
server_connection.send(message).await?;
|
||||
|
||||
let result = match server_connection.next().await {
|
||||
Some(Ok(Response::ListUsers(user_map))) => user_map,
|
||||
response => {
|
||||
erroneous_server_response(response)?;
|
||||
// Unreachable, but needed to satisfy the type checker
|
||||
BTreeMap::new()
|
||||
}
|
||||
};
|
||||
|
||||
let result = result
|
||||
.into_iter()
|
||||
.map(|(user, user_result)| (user, user_result.is_ok()))
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn databases_exist(
|
||||
server_connection: &mut ClientToServerMessageStream,
|
||||
privilege_diff: &BTreeSet<DatabasePrivilegesDiff>,
|
||||
) -> anyhow::Result<BTreeMap<MySQLDatabase, bool>> {
|
||||
let database_list = privilege_diff
|
||||
.iter()
|
||||
.map(|diff| diff.get_database_name().clone())
|
||||
.collect();
|
||||
|
||||
let message = Request::ListDatabases(Some(database_list));
|
||||
server_connection.send(message).await?;
|
||||
|
||||
let result = match server_connection.next().await {
|
||||
Some(Ok(Response::ListDatabases(database_map))) => database_map,
|
||||
response => {
|
||||
erroneous_server_response(response)?;
|
||||
// Unreachable, but needed to satisfy the type checker
|
||||
BTreeMap::new()
|
||||
}
|
||||
};
|
||||
|
||||
let result = result
|
||||
.into_iter()
|
||||
.map(|(database, db_result)| (database, db_result.is_ok()))
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn edit_database_privileges(
|
||||
args: EditPrivsArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
let message = Request::ListPrivileges(args.name.to_owned().map(|name| vec![name]));
|
||||
|
||||
server_connection.send(message).await?;
|
||||
|
||||
let existing_privilege_rows = match server_connection.next().await {
|
||||
Some(Ok(Response::ListPrivileges(databases))) => databases
|
||||
.into_iter()
|
||||
.filter_map(|(database_name, result)| match result {
|
||||
Ok(privileges) => Some(privileges),
|
||||
Err(err) => {
|
||||
eprintln!("{}", err.to_error_message(&database_name));
|
||||
eprintln!("Skipping...");
|
||||
println!();
|
||||
None
|
||||
}
|
||||
})
|
||||
.flatten()
|
||||
.collect::<Vec<_>>(),
|
||||
Some(Ok(Response::ListAllPrivileges(privilege_rows))) => match privilege_rows {
|
||||
Ok(list) => list,
|
||||
Err(err) => {
|
||||
server_connection.send(Request::Exit).await?;
|
||||
return Err(anyhow::anyhow!(err.to_error_message())
|
||||
.context("Failed to list database privileges"));
|
||||
}
|
||||
},
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
let diffs: BTreeSet<DatabasePrivilegesDiff> = if !args.privs.is_empty() {
|
||||
let privileges_to_change = parse_privilege_tables_from_args(&args)?;
|
||||
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())?;
|
||||
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 diffs = reduce_privilege_diffs(&existing_privilege_rows, diffs)?
|
||||
.into_iter()
|
||||
.filter(|diff| {
|
||||
let database_name = diff.get_database_name();
|
||||
let username = diff.get_user_name();
|
||||
|
||||
if let Some(false) = database_existence_map.get(database_name) {
|
||||
println!("Database '{}' does not exist.", database_name);
|
||||
println!("Skipping...");
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(false) = user_existence_map.get(username) {
|
||||
println!("User '{}' does not exist.", username);
|
||||
println!("Skipping...");
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
if diffs.is_empty() {
|
||||
println!("No changes to make.");
|
||||
server_connection.send(Request::Exit).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("The following changes will be made:\n");
|
||||
println!("{}", display_privilege_diffs(&diffs));
|
||||
|
||||
if !args.yes
|
||||
&& !Confirm::new()
|
||||
.with_prompt("Do you want to apply these changes?")
|
||||
.default(false)
|
||||
.show_default(true)
|
||||
.interact()?
|
||||
{
|
||||
server_connection.send(Request::Exit).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let message = Request::ModifyPrivileges(diffs);
|
||||
server_connection.send(message).await?;
|
||||
|
||||
let result = match server_connection.next().await {
|
||||
Some(Ok(Response::ModifyPrivileges(result))) => result,
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
print_modify_database_privileges_output_status(&result);
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_privilege_tables_from_args(
|
||||
args: &EditPrivsArgs,
|
||||
) -> anyhow::Result<BTreeSet<DatabasePrivilegeRowDiff>> {
|
||||
debug_assert!(!args.privs.is_empty());
|
||||
args.privs
|
||||
.iter()
|
||||
.map(|priv_edit_entry| {
|
||||
priv_edit_entry
|
||||
.as_database_privileges_diff(args.name.as_ref())
|
||||
.context(format!(
|
||||
"Failed parsing database privileges: `{}`",
|
||||
priv_edit_entry
|
||||
))
|
||||
})
|
||||
.collect::<anyhow::Result<BTreeSet<DatabasePrivilegeRowDiff>>>()
|
||||
}
|
||||
|
||||
fn edit_privileges_with_editor(
|
||||
privilege_data: &[DatabasePrivilegeRow],
|
||||
database_name: Option<&MySQLDatabase>,
|
||||
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
|
||||
let unix_user = User::from_uid(getuid())
|
||||
.context("Failed to look up your UNIX username")
|
||||
.and_then(|u| u.ok_or(anyhow::anyhow!("Failed to look up your UNIX username")))?;
|
||||
|
||||
let editor_content =
|
||||
generate_editor_content_from_privilege_data(privilege_data, &unix_user.name, database_name);
|
||||
|
||||
// TODO: handle errors better here
|
||||
let result = Editor::new().extension("tsv").edit(&editor_content)?;
|
||||
|
||||
match result {
|
||||
None => Ok(privilege_data.to_vec()),
|
||||
Some(result) => parse_privilege_data_from_editor_content(result)
|
||||
.context("Could not parse privilege data from editor"),
|
||||
}
|
||||
}
|
||||
59
src/client/commands/lock_user.rs
Normal file
59
src/client/commands/lock_user.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use clap::Parser;
|
||||
use clap_complete::ArgValueCompleter;
|
||||
use futures_util::SinkExt;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{
|
||||
client::commands::erroneous_server_response,
|
||||
core::{
|
||||
completion::mysql_user_completer,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, Request, Response, print_lock_users_output_status,
|
||||
print_lock_users_output_status_json,
|
||||
},
|
||||
types::MySQLUser,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct LockUserArgs {
|
||||
/// The MySQL user(s) to loc
|
||||
#[arg(num_args = 1.., value_name = "USER_NAME")]
|
||||
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_user_completer)))]
|
||||
username: Vec<MySQLUser>,
|
||||
|
||||
/// Print the information as JSON
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
pub async fn lock_users(
|
||||
args: LockUserArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
if args.username.is_empty() {
|
||||
anyhow::bail!("No usernames provided");
|
||||
}
|
||||
|
||||
let message = Request::LockUsers(args.username.to_owned());
|
||||
|
||||
if let Err(err) = server_connection.send(message).await {
|
||||
server_connection.close().await.ok();
|
||||
anyhow::bail!(err);
|
||||
}
|
||||
|
||||
let result = match server_connection.next().await {
|
||||
Some(Ok(Response::LockUsers(result))) => result,
|
||||
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);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
111
src/client/commands/passwd_user.rs
Normal file
111
src/client/commands/passwd_user.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::Parser;
|
||||
use clap_complete::ArgValueCompleter;
|
||||
use dialoguer::Password;
|
||||
use futures_util::SinkExt;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{
|
||||
client::commands::erroneous_server_response,
|
||||
core::{
|
||||
completion::mysql_user_completer,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, ListUsersError, Request, Response,
|
||||
print_set_password_output_status,
|
||||
},
|
||||
types::MySQLUser,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct PasswdUserArgs {
|
||||
/// The MySQL user whose password is to be changed
|
||||
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_user_completer)))]
|
||||
#[arg(value_name = "USER_NAME")]
|
||||
username: MySQLUser,
|
||||
|
||||
/// Read the new password from a file instead of prompting for it
|
||||
#[clap(short, long, value_name = "PATH", conflicts_with = "stdin")]
|
||||
password_file: Option<PathBuf>,
|
||||
|
||||
/// Read the new password from stdin instead of prompting for it
|
||||
#[clap(short = 'i', long, conflicts_with = "password_file")]
|
||||
stdin: bool,
|
||||
|
||||
/// Print the information as JSON
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
pub fn read_password_from_stdin_with_double_check(username: &MySQLUser) -> anyhow::Result<String> {
|
||||
Password::new()
|
||||
.with_prompt(format!("New MySQL password for user '{}'", username))
|
||||
.with_confirmation(
|
||||
format!("Retype new MySQL password for user '{}'", username),
|
||||
"Passwords do not match",
|
||||
)
|
||||
.interact()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn passwd_user(
|
||||
args: PasswdUserArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
// TODO: create a "user" exists check" command
|
||||
let message = Request::ListUsers(Some(vec![args.username.to_owned()]));
|
||||
if let Err(err) = server_connection.send(message).await {
|
||||
server_connection.close().await.ok();
|
||||
anyhow::bail!(err);
|
||||
}
|
||||
let response = match server_connection.next().await {
|
||||
Some(Ok(Response::ListUsers(users))) => users,
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
match response
|
||||
.get(&args.username)
|
||||
.unwrap_or(&Err(ListUsersError::UserDoesNotExist))
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
server_connection.send(Request::Exit).await?;
|
||||
server_connection.close().await.ok();
|
||||
anyhow::bail!("{}", err.to_error_message(&args.username));
|
||||
}
|
||||
}
|
||||
|
||||
let password = if let Some(password_file) = args.password_file {
|
||||
std::fs::read_to_string(password_file)
|
||||
.context("Failed to read password file")?
|
||||
.trim()
|
||||
.to_string()
|
||||
} else if args.stdin {
|
||||
let mut buffer = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut buffer)
|
||||
.context("Failed to read password from stdin")?;
|
||||
buffer.trim().to_string()
|
||||
} else {
|
||||
read_password_from_stdin_with_double_check(&args.username)?
|
||||
};
|
||||
|
||||
let message = Request::PasswdUser((args.username.to_owned(), password));
|
||||
|
||||
if let Err(err) = server_connection.send(message).await {
|
||||
server_connection.close().await.ok();
|
||||
anyhow::bail!(err);
|
||||
}
|
||||
|
||||
let result = match server_connection.next().await {
|
||||
Some(Ok(Response::SetUserPassword(result))) => result,
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
print_set_password_output_status(&result, &args.username);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
76
src/client/commands/show_db.rs
Normal file
76
src/client/commands/show_db.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use clap::Parser;
|
||||
use clap_complete::ArgValueCompleter;
|
||||
use futures_util::SinkExt;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{
|
||||
client::commands::erroneous_server_response,
|
||||
core::{
|
||||
completion::mysql_database_completer,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, Request, Response, print_list_databases_output_status,
|
||||
print_list_databases_output_status_json,
|
||||
},
|
||||
types::MySQLDatabase,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct ShowDbArgs {
|
||||
/// The MySQL database(s) to show
|
||||
#[arg(num_args = 0.., value_name = "DB_NAME")]
|
||||
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_database_completer)))]
|
||||
name: Vec<MySQLDatabase>,
|
||||
|
||||
/// 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_databases(
|
||||
args: ShowDbArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
let message = if args.name.is_empty() {
|
||||
Request::ListDatabases(None)
|
||||
} else {
|
||||
Request::ListDatabases(Some(args.name.to_owned()))
|
||||
};
|
||||
|
||||
server_connection.send(message).await?;
|
||||
|
||||
let databases = match server_connection.next().await {
|
||||
Some(Ok(Response::ListDatabases(databases))) => databases,
|
||||
Some(Ok(Response::ListAllDatabases(database_list))) => match database_list {
|
||||
Ok(list) => list
|
||||
.into_iter()
|
||||
.map(|db| (db.database.clone(), Ok(db)))
|
||||
.collect(),
|
||||
Err(err) => {
|
||||
server_connection.send(Request::Exit).await?;
|
||||
return Err(
|
||||
anyhow::anyhow!(err.to_error_message()).context("Failed to list databases")
|
||||
);
|
||||
}
|
||||
},
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if args.json {
|
||||
print_list_databases_output_status_json(&databases);
|
||||
} else {
|
||||
print_list_databases_output_status(&databases);
|
||||
}
|
||||
|
||||
if args.fail && databases.values().any(|res| res.is_err()) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
84
src/client/commands/show_privs.rs
Normal file
84
src/client/commands/show_privs.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use clap::Parser;
|
||||
use clap_complete::ArgValueCompleter;
|
||||
use futures_util::SinkExt;
|
||||
use itertools::Itertools;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{
|
||||
client::commands::erroneous_server_response,
|
||||
core::{
|
||||
completion::mysql_database_completer,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, Request, Response, print_list_privileges_output_status,
|
||||
print_list_privileges_output_status_json,
|
||||
},
|
||||
types::MySQLDatabase,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct ShowPrivsArgs {
|
||||
/// The MySQL database(s) to show privileges for
|
||||
#[arg(num_args = 0.., value_name = "DB_NAME")]
|
||||
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_database_completer)))]
|
||||
name: Vec<MySQLDatabase>,
|
||||
|
||||
/// Print the information as JSON
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
|
||||
/// Show single-character privilege names in addition to human-readable names
|
||||
///
|
||||
/// 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(
|
||||
args: ShowPrivsArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
let message = if args.name.is_empty() {
|
||||
Request::ListPrivileges(None)
|
||||
} else {
|
||||
Request::ListPrivileges(Some(args.name.to_owned()))
|
||||
};
|
||||
server_connection.send(message).await?;
|
||||
|
||||
let privilege_data = match server_connection.next().await {
|
||||
Some(Ok(Response::ListPrivileges(databases))) => databases,
|
||||
Some(Ok(Response::ListAllPrivileges(privilege_rows))) => match privilege_rows {
|
||||
Ok(list) => list
|
||||
.into_iter()
|
||||
.map(|row| (row.db.clone(), row))
|
||||
.into_group_map()
|
||||
.into_iter()
|
||||
.map(|(db, rows)| (db, Ok(rows)))
|
||||
.collect(),
|
||||
Err(err) => {
|
||||
server_connection.send(Request::Exit).await?;
|
||||
return Err(anyhow::anyhow!(err.to_error_message())
|
||||
.context("Failed to list database privileges"));
|
||||
}
|
||||
},
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
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 args.fail && privilege_data.values().any(|res| res.is_err()) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
79
src/client/commands/show_user.rs
Normal file
79
src/client/commands/show_user.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use clap::Parser;
|
||||
use clap_complete::ArgValueCompleter;
|
||||
use futures_util::SinkExt;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{
|
||||
client::commands::erroneous_server_response,
|
||||
core::{
|
||||
completion::mysql_user_completer,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, Request, Response, print_list_users_output_status,
|
||||
print_list_users_output_status_json,
|
||||
},
|
||||
types::MySQLUser,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct ShowUserArgs {
|
||||
/// The MySQL user(s) to show
|
||||
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_user_completer)))]
|
||||
#[arg(num_args = 0.., value_name = "USER_NAME")]
|
||||
username: Vec<MySQLUser>,
|
||||
|
||||
/// 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(
|
||||
args: ShowUserArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
let message = if args.username.is_empty() {
|
||||
Request::ListUsers(None)
|
||||
} else {
|
||||
Request::ListUsers(Some(args.username.to_owned()))
|
||||
};
|
||||
|
||||
if let Err(err) = server_connection.send(message).await {
|
||||
server_connection.close().await.ok();
|
||||
anyhow::bail!(err);
|
||||
}
|
||||
|
||||
let users = match server_connection.next().await {
|
||||
Some(Ok(Response::ListUsers(users))) => users,
|
||||
Some(Ok(Response::ListAllUsers(users))) => match users {
|
||||
Ok(users) => users
|
||||
.into_iter()
|
||||
.map(|user| (user.user.clone(), Ok(user)))
|
||||
.collect(),
|
||||
Err(err) => {
|
||||
server_connection.send(Request::Exit).await?;
|
||||
return Err(
|
||||
anyhow::anyhow!(err.to_error_message()).context("Failed to list all users")
|
||||
);
|
||||
}
|
||||
},
|
||||
response => return erroneous_server_response(response),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
if args.json {
|
||||
print_list_users_output_status_json(&users);
|
||||
} else {
|
||||
print_list_users_output_status(&users);
|
||||
}
|
||||
|
||||
if args.fail && users.values().any(|result| result.is_err()) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
59
src/client/commands/unlock_user.rs
Normal file
59
src/client/commands/unlock_user.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use clap::Parser;
|
||||
use clap_complete::ArgValueCompleter;
|
||||
use futures_util::SinkExt;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{
|
||||
client::commands::erroneous_server_response,
|
||||
core::{
|
||||
completion::mysql_user_completer,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, Request, Response, print_unlock_users_output_status,
|
||||
print_unlock_users_output_status_json,
|
||||
},
|
||||
types::MySQLUser,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct UnlockUserArgs {
|
||||
/// The MySQL user(s) to unlock
|
||||
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_user_completer)))]
|
||||
#[arg(num_args = 1.., value_name = "USER_NAME")]
|
||||
username: Vec<MySQLUser>,
|
||||
|
||||
/// Print the information as JSON
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
pub async fn unlock_users(
|
||||
args: UnlockUserArgs,
|
||||
mut server_connection: ClientToServerMessageStream,
|
||||
) -> anyhow::Result<()> {
|
||||
if args.username.is_empty() {
|
||||
anyhow::bail!("No usernames provided");
|
||||
}
|
||||
|
||||
let message = Request::UnlockUsers(args.username.to_owned());
|
||||
|
||||
if let Err(err) = server_connection.send(message).await {
|
||||
server_connection.close().await.ok();
|
||||
anyhow::bail!(err);
|
||||
}
|
||||
|
||||
let result = match server_connection.next().await {
|
||||
Some(Ok(Response::UnlockUsers(result))) => result,
|
||||
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);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::core::protocol::{MySQLDatabase, MySQLUser};
|
||||
use crate::core::types::{MySQLDatabase, MySQLUser};
|
||||
|
||||
#[inline]
|
||||
pub fn trim_db_name_to_32_chars(db_name: &MySQLDatabase) -> MySQLDatabase {
|
||||
@@ -1,12 +1,15 @@
|
||||
use crate::core::protocol::{
|
||||
CreateDatabaseError, CreateUserError, DbOrUser, DropDatabaseError, DropUserError,
|
||||
GetDatabasesPrivilegeDataError, ListUsersError,
|
||||
use crate::core::{
|
||||
protocol::{
|
||||
CreateDatabaseError, CreateUserError, DropDatabaseError, DropUserError,
|
||||
GetDatabasesPrivilegeDataError, ListUsersError, request_validation::ValidationError,
|
||||
},
|
||||
types::DbOrUser,
|
||||
};
|
||||
|
||||
pub fn name_validation_error_to_error_message(name: &str, db_or_user: DbOrUser) -> String {
|
||||
pub fn name_validation_error_to_error_message(db_or_user: DbOrUser) -> String {
|
||||
let argv0 = std::env::args().next().unwrap_or_else(|| match db_or_user {
|
||||
DbOrUser::Database => "mysql-dbadm".to_string(),
|
||||
DbOrUser::User => "mysql-useradm".to_string(),
|
||||
DbOrUser::Database(_) => "mysql-dbadm".to_string(),
|
||||
DbOrUser::User(_) => "mysql-useradm".to_string(),
|
||||
});
|
||||
|
||||
format!(
|
||||
@@ -15,16 +18,16 @@ pub fn name_validation_error_to_error_message(name: &str, db_or_user: DbOrUser)
|
||||
"Only A-Z, a-z, 0-9, _ (underscore) and - (dash) permitted. Skipping.",
|
||||
),
|
||||
argv0,
|
||||
db_or_user.capitalized(),
|
||||
name,
|
||||
db_or_user.capitalized_noun(),
|
||||
db_or_user.name(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn owner_validation_error_message(name: &str, db_or_user: DbOrUser) -> String {
|
||||
pub fn authorization_error_message(db_or_user: DbOrUser) -> String {
|
||||
format!(
|
||||
"You are not in charge of mysql-{}: '{}'. Skipping.",
|
||||
db_or_user.lowercased(),
|
||||
name
|
||||
db_or_user.lowercased_noun(),
|
||||
db_or_user.name(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,14 +36,17 @@ pub fn handle_create_user_error(error: CreateUserError, name: &str) {
|
||||
.next()
|
||||
.unwrap_or_else(|| "mysql-useradm".to_string());
|
||||
match error {
|
||||
CreateUserError::SanitizationError(_) => {
|
||||
CreateUserError::ValidationError(ValidationError::NameValidationError(_)) => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
name_validation_error_to_error_message(name, DbOrUser::User)
|
||||
name_validation_error_to_error_message(DbOrUser::User(name.into()))
|
||||
);
|
||||
}
|
||||
CreateUserError::OwnershipError(_) => {
|
||||
eprintln!("{}", owner_validation_error_message(name, DbOrUser::User));
|
||||
CreateUserError::ValidationError(ValidationError::AuthorizationError(_)) => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
authorization_error_message(DbOrUser::User(name.into()))
|
||||
);
|
||||
}
|
||||
CreateUserError::MySqlError(_) | CreateUserError::UserAlreadyExists => {
|
||||
eprintln!("{}: Failed to create user '{}'.", argv0, name);
|
||||
@@ -53,14 +59,17 @@ pub fn handle_drop_user_error(error: DropUserError, name: &str) {
|
||||
.next()
|
||||
.unwrap_or_else(|| "mysql-useradm".to_string());
|
||||
match error {
|
||||
DropUserError::SanitizationError(_) => {
|
||||
DropUserError::ValidationError(ValidationError::NameValidationError(_)) => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
name_validation_error_to_error_message(name, DbOrUser::User)
|
||||
name_validation_error_to_error_message(DbOrUser::User(name.into()))
|
||||
);
|
||||
}
|
||||
DropUserError::OwnershipError(_) => {
|
||||
eprintln!("{}", owner_validation_error_message(name, DbOrUser::User));
|
||||
DropUserError::ValidationError(ValidationError::AuthorizationError(_)) => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
authorization_error_message(DbOrUser::User(name.into()))
|
||||
);
|
||||
}
|
||||
DropUserError::MySqlError(_) | DropUserError::UserDoesNotExist => {
|
||||
eprintln!("{}: Failed to delete user '{}'.", argv0, name);
|
||||
@@ -73,14 +82,17 @@ pub fn handle_list_users_error(error: ListUsersError, name: &str) {
|
||||
.next()
|
||||
.unwrap_or_else(|| "mysql-useradm".to_string());
|
||||
match error {
|
||||
ListUsersError::SanitizationError(_) => {
|
||||
ListUsersError::ValidationError(ValidationError::NameValidationError(_)) => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
name_validation_error_to_error_message(name, DbOrUser::User)
|
||||
name_validation_error_to_error_message(DbOrUser::User(name.into()))
|
||||
);
|
||||
}
|
||||
ListUsersError::OwnershipError(_) => {
|
||||
eprintln!("{}", owner_validation_error_message(name, DbOrUser::User));
|
||||
ListUsersError::ValidationError(ValidationError::AuthorizationError(_)) => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
authorization_error_message(DbOrUser::User(name.into()))
|
||||
);
|
||||
}
|
||||
ListUsersError::UserDoesNotExist => {
|
||||
eprintln!(
|
||||
@@ -101,16 +113,17 @@ pub fn handle_create_database_error(error: CreateDatabaseError, name: &str) {
|
||||
.next()
|
||||
.unwrap_or_else(|| "mysql-dbadm".to_string());
|
||||
match error {
|
||||
CreateDatabaseError::SanitizationError(_) => {
|
||||
CreateDatabaseError::ValidationError(ValidationError::NameValidationError(_)) => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
name_validation_error_to_error_message(name, DbOrUser::Database)
|
||||
name_validation_error_to_error_message(DbOrUser::Database(name.into()))
|
||||
);
|
||||
}
|
||||
CreateDatabaseError::OwnershipError(_) => {
|
||||
|
||||
CreateDatabaseError::ValidationError(ValidationError::AuthorizationError(_)) => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
owner_validation_error_message(name, DbOrUser::Database)
|
||||
authorization_error_message(DbOrUser::Database(name.into()))
|
||||
);
|
||||
}
|
||||
CreateDatabaseError::MySqlError(_) => {
|
||||
@@ -127,16 +140,16 @@ pub fn handle_drop_database_error(error: DropDatabaseError, name: &str) {
|
||||
.next()
|
||||
.unwrap_or_else(|| "mysql-dbadm".to_string());
|
||||
match error {
|
||||
DropDatabaseError::SanitizationError(_) => {
|
||||
DropDatabaseError::ValidationError(ValidationError::NameValidationError(_)) => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
name_validation_error_to_error_message(name, DbOrUser::Database)
|
||||
name_validation_error_to_error_message(DbOrUser::Database(name.into()))
|
||||
);
|
||||
}
|
||||
DropDatabaseError::OwnershipError(_) => {
|
||||
DropDatabaseError::ValidationError(ValidationError::AuthorizationError(_)) => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
owner_validation_error_message(name, DbOrUser::Database)
|
||||
authorization_error_message(DbOrUser::Database(name.into()))
|
||||
);
|
||||
}
|
||||
DropDatabaseError::MySqlError(_) => {
|
||||
@@ -157,11 +170,11 @@ pub fn format_show_database_error_message(
|
||||
.unwrap_or_else(|| "mysql-dbadm".to_string());
|
||||
|
||||
match error {
|
||||
GetDatabasesPrivilegeDataError::SanitizationError(_) => {
|
||||
name_validation_error_to_error_message(name, DbOrUser::Database)
|
||||
}
|
||||
GetDatabasesPrivilegeDataError::OwnershipError(_) => {
|
||||
owner_validation_error_message(name, DbOrUser::Database)
|
||||
GetDatabasesPrivilegeDataError::ValidationError(ValidationError::NameValidationError(
|
||||
_,
|
||||
)) => name_validation_error_to_error_message(DbOrUser::Database(name.into())),
|
||||
GetDatabasesPrivilegeDataError::ValidationError(ValidationError::AuthorizationError(_)) => {
|
||||
authorization_error_message(DbOrUser::Database(name.into()))
|
||||
}
|
||||
GetDatabasesPrivilegeDataError::MySqlError(err) => {
|
||||
format!(
|
||||
@@ -1,13 +1,13 @@
|
||||
use clap::Parser;
|
||||
use clap::{Parser, Subcommand};
|
||||
use clap_complete::ArgValueCompleter;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use std::os::unix::net::UnixStream as StdUnixStream;
|
||||
use std::path::PathBuf;
|
||||
use tokio::net::UnixStream as TokioUnixStream;
|
||||
|
||||
use crate::{
|
||||
cli::{
|
||||
common::erroneous_server_response,
|
||||
database_command,
|
||||
client::{
|
||||
commands::{EditPrivsArgs, edit_database_privileges, erroneous_server_response},
|
||||
mysql_admutils_compatibility::{
|
||||
common::trim_db_name_to_32_chars,
|
||||
error_messages::{
|
||||
@@ -18,12 +18,14 @@ use crate::{
|
||||
},
|
||||
core::{
|
||||
bootstrap::bootstrap_server_connection_and_drop_privileges,
|
||||
completion::mysql_database_completer,
|
||||
database_privileges::DatabasePrivilegeRow,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, GetDatabasesPrivilegeDataError, MySQLDatabase, Request,
|
||||
Response, create_client_to_server_message_stream,
|
||||
ClientToServerMessageStream, GetDatabasesPrivilegeDataError, Request, Response,
|
||||
create_client_to_server_message_stream,
|
||||
},
|
||||
types::MySQLDatabase,
|
||||
},
|
||||
server::sql::database_privilege_operations::DatabasePrivilegeRow,
|
||||
};
|
||||
|
||||
const HELP_DB_PERM: &str = r#"
|
||||
@@ -52,8 +54,8 @@ The Y/N-values corresponds to the following mysql privileges:
|
||||
/// Create, drop or edit permissions for the DATABASE(s),
|
||||
/// as determined by the COMMAND.
|
||||
///
|
||||
/// This is a compatibility layer for the mysql-dbadm command.
|
||||
/// Please consider using the newer mysqladm command instead.
|
||||
/// This is a compatibility layer for the 'mysql-dbadm' command.
|
||||
/// Please consider using the newer 'muscl' command instead.
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
bin_name = "mysql-dbadm",
|
||||
@@ -71,6 +73,7 @@ pub struct Args {
|
||||
short,
|
||||
long,
|
||||
value_name = "PATH",
|
||||
value_hint = clap::ValueHint::FilePath,
|
||||
global = true,
|
||||
hide_short_help = true
|
||||
)]
|
||||
@@ -81,6 +84,7 @@ pub struct Args {
|
||||
short,
|
||||
long,
|
||||
value_name = "PATH",
|
||||
value_hint = clap::ValueHint::FilePath,
|
||||
global = true,
|
||||
hide_short_help = true
|
||||
)]
|
||||
@@ -93,8 +97,8 @@ pub struct Args {
|
||||
|
||||
// NOTE: mysql-dbadm explicitly calls privileges "permissions".
|
||||
// This is something we're trying to move away from.
|
||||
// See https://git.pvv.ntnu.no/Projects/mysqladm-rs/issues/29
|
||||
#[derive(Parser)]
|
||||
// See https://git.pvv.ntnu.no/Projects/muscl/issues/29
|
||||
#[derive(Subcommand)]
|
||||
pub enum Command {
|
||||
/// create the DATABASE(s).
|
||||
Create(CreateArgs),
|
||||
@@ -127,6 +131,7 @@ pub struct CreateArgs {
|
||||
pub struct DatabaseDropArgs {
|
||||
/// The name of the DATABASE(s) to drop.
|
||||
#[arg(num_args = 1..)]
|
||||
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_database_completer)))]
|
||||
name: Vec<MySQLDatabase>,
|
||||
}
|
||||
|
||||
@@ -134,12 +139,14 @@ pub struct DatabaseDropArgs {
|
||||
pub struct DatabaseShowArgs {
|
||||
/// The name of the DATABASE(s) to show.
|
||||
#[arg(num_args = 0..)]
|
||||
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_database_completer)))]
|
||||
name: Vec<MySQLDatabase>,
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
pub struct EditPermArgs {
|
||||
/// The name of the DATABASE to edit permissions for.
|
||||
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_database_completer)))]
|
||||
pub database: MySQLDatabase,
|
||||
}
|
||||
|
||||
@@ -181,13 +188,26 @@ fn tokio_run_command(command: Command, server_connection: StdUnixStream) -> anyh
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
let tokio_socket = TokioUnixStream::from_std(server_connection)?;
|
||||
let message_stream = create_client_to_server_message_stream(tokio_socket);
|
||||
let mut message_stream = create_client_to_server_message_stream(tokio_socket);
|
||||
|
||||
while let Some(Ok(message)) = message_stream.next().await {
|
||||
match message {
|
||||
Response::Error(err) => {
|
||||
anyhow::bail!("{}", err);
|
||||
}
|
||||
Response::Ready => break,
|
||||
message => {
|
||||
eprintln!("Unexpected message from server: {:?}", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match command {
|
||||
Command::Create(args) => create_databases(args, message_stream).await,
|
||||
Command::Drop(args) => drop_databases(args, message_stream).await,
|
||||
Command::Show(args) => show_databases(args, message_stream).await,
|
||||
Command::Editperm(args) => {
|
||||
let edit_privileges_args = database_command::DatabaseEditPrivsArgs {
|
||||
let edit_privileges_args = EditPrivsArgs {
|
||||
name: Some(args.database),
|
||||
privs: vec![],
|
||||
json: false,
|
||||
@@ -195,8 +215,7 @@ fn tokio_run_command(command: Command, server_connection: StdUnixStream) -> anyh
|
||||
yes: false,
|
||||
};
|
||||
|
||||
database_command::edit_database_privileges(edit_privileges_args, message_stream)
|
||||
.await
|
||||
edit_database_privileges(edit_privileges_args, message_stream).await
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
use clap::Parser;
|
||||
use clap::{Parser, Subcommand};
|
||||
use clap_complete::ArgValueCompleter;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -6,22 +7,22 @@ use std::os::unix::net::UnixStream as StdUnixStream;
|
||||
use tokio::net::UnixStream as TokioUnixStream;
|
||||
|
||||
use crate::{
|
||||
cli::{
|
||||
common::erroneous_server_response,
|
||||
client::{
|
||||
commands::{erroneous_server_response, read_password_from_stdin_with_double_check},
|
||||
mysql_admutils_compatibility::{
|
||||
common::trim_user_name_to_32_chars,
|
||||
error_messages::{
|
||||
handle_create_user_error, handle_drop_user_error, handle_list_users_error,
|
||||
},
|
||||
},
|
||||
user_command::read_password_from_stdin_with_double_check,
|
||||
},
|
||||
core::{
|
||||
bootstrap::bootstrap_server_connection_and_drop_privileges,
|
||||
completion::mysql_user_completer,
|
||||
protocol::{
|
||||
ClientToServerMessageStream, MySQLUser, Request, Response,
|
||||
create_client_to_server_message_stream,
|
||||
ClientToServerMessageStream, Request, Response, create_client_to_server_message_stream,
|
||||
},
|
||||
types::MySQLUser,
|
||||
},
|
||||
server::sql::user_operations::DatabaseUser,
|
||||
};
|
||||
@@ -29,8 +30,8 @@ use crate::{
|
||||
/// Create, delete or change password for the USER(s),
|
||||
/// as determined by the COMMAND.
|
||||
///
|
||||
/// This is a compatibility layer for the mysql-useradm command.
|
||||
/// Please consider using the newer mysqladm command instead.
|
||||
/// This is a compatibility layer for the 'mysql-useradm' command.
|
||||
/// Please consider using the newer 'muscl' command instead.
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
bin_name = "mysql-useradm",
|
||||
@@ -48,6 +49,7 @@ pub struct Args {
|
||||
short,
|
||||
long,
|
||||
value_name = "PATH",
|
||||
value_hint = clap::ValueHint::FilePath,
|
||||
global = true,
|
||||
hide_short_help = true
|
||||
)]
|
||||
@@ -58,13 +60,14 @@ pub struct Args {
|
||||
short,
|
||||
long,
|
||||
value_name = "PATH",
|
||||
value_hint = clap::ValueHint::FilePath,
|
||||
global = true,
|
||||
hide_short_help = true
|
||||
)]
|
||||
config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[derive(Subcommand)]
|
||||
pub enum Command {
|
||||
/// create the USER(s).
|
||||
Create(CreateArgs),
|
||||
@@ -91,6 +94,7 @@ pub struct CreateArgs {
|
||||
pub struct DeleteArgs {
|
||||
/// The name of the USER(s) to delete.
|
||||
#[arg(num_args = 1..)]
|
||||
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_user_completer)))]
|
||||
name: Vec<MySQLUser>,
|
||||
}
|
||||
|
||||
@@ -98,6 +102,7 @@ pub struct DeleteArgs {
|
||||
pub struct PasswdArgs {
|
||||
/// The name of the USER(s) to change the password for.
|
||||
#[arg(num_args = 1..)]
|
||||
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_user_completer)))]
|
||||
name: Vec<MySQLUser>,
|
||||
}
|
||||
|
||||
@@ -105,6 +110,7 @@ pub struct PasswdArgs {
|
||||
pub struct ShowArgs {
|
||||
/// The name of the USER(s) to show.
|
||||
#[arg(num_args = 0..)]
|
||||
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_user_completer)))]
|
||||
name: Vec<MySQLUser>,
|
||||
}
|
||||
|
||||
@@ -143,7 +149,20 @@ fn tokio_run_command(command: Command, server_connection: StdUnixStream) -> anyh
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
let tokio_socket = TokioUnixStream::from_std(server_connection)?;
|
||||
let message_stream = create_client_to_server_message_stream(tokio_socket);
|
||||
let mut message_stream = create_client_to_server_message_stream(tokio_socket);
|
||||
|
||||
while let Some(Ok(message)) = message_stream.next().await {
|
||||
match message {
|
||||
Response::Error(err) => {
|
||||
anyhow::bail!("{}", err);
|
||||
}
|
||||
Response::Ready => break,
|
||||
message => {
|
||||
eprintln!("Unexpected message from server: {:?}", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match command {
|
||||
Command::Create(args) => create_user(args, message_stream).await,
|
||||
Command::Delete(args) => drop_users(args, message_stream).await,
|
||||
@@ -236,10 +255,10 @@ async fn passwd_users(
|
||||
|
||||
for user in users {
|
||||
let password = read_password_from_stdin_with_double_check(&user.user)?;
|
||||
let message = Request::PasswdUser(user.user.to_owned(), password);
|
||||
let message = Request::PasswdUser((user.user.to_owned(), password));
|
||||
server_connection.send(message).await?;
|
||||
match server_connection.next().await {
|
||||
Some(Ok(Response::PasswdUser(result))) => match result {
|
||||
Some(Ok(Response::SetUserPassword(result))) => match result {
|
||||
Ok(()) => println!("Password updated for user '{}'.", &user.user),
|
||||
Err(_) => eprintln!(
|
||||
"{}: Failed to update password for user '{}'.",
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod bootstrap;
|
||||
pub mod common;
|
||||
pub mod completion;
|
||||
pub mod database_privileges;
|
||||
pub mod protocol;
|
||||
pub mod types;
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
use std::{fs, path::PathBuf};
|
||||
use std::{fs, path::PathBuf, sync::Arc, time::Duration};
|
||||
|
||||
use anyhow::Context;
|
||||
use clap_verbosity_flag::Verbosity;
|
||||
use anyhow::{Context, anyhow};
|
||||
use clap_verbosity_flag::{InfoLevel, Verbosity};
|
||||
use nix::libc::{EXIT_SUCCESS, exit};
|
||||
use sqlx::mysql::MySqlPoolOptions;
|
||||
use std::os::unix::net::UnixStream as StdUnixStream;
|
||||
use tokio::net::UnixStream as TokioUnixStream;
|
||||
use tokio::{net::UnixStream as TokioUnixStream, sync::RwLock};
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
use crate::{
|
||||
core::common::{
|
||||
DEFAULT_CONFIG_PATH, DEFAULT_SOCKET_PATH, UnixUser, executable_is_suid_or_sgid,
|
||||
DEFAULT_CONFIG_PATH, DEFAULT_SOCKET_PATH, UnixUser, executing_in_suid_sgid_mode,
|
||||
},
|
||||
server::{
|
||||
config::{MysqlConfig, ServerConfig},
|
||||
landlock::landlock_restrict_server,
|
||||
session_handler,
|
||||
},
|
||||
server::{config::read_config_from_path, server_loop::handle_requests_for_single_session},
|
||||
};
|
||||
|
||||
/// Determine whether we will make a connection to an external server
|
||||
@@ -71,17 +77,26 @@ fn will_connect_to_external_server(
|
||||
pub fn bootstrap_server_connection_and_drop_privileges(
|
||||
server_socket_path: Option<PathBuf>,
|
||||
config: Option<PathBuf>,
|
||||
verbose: Verbosity,
|
||||
verbose: Verbosity<InfoLevel>,
|
||||
) -> anyhow::Result<StdUnixStream> {
|
||||
if will_connect_to_external_server(server_socket_path.as_ref(), config.as_ref())? {
|
||||
assert!(
|
||||
!executable_is_suid_or_sgid()?,
|
||||
!executing_in_suid_sgid_mode()?,
|
||||
"The executable should not be SUID or SGID when connecting to an external server"
|
||||
);
|
||||
|
||||
env_logger::Builder::new()
|
||||
.filter_level(verbose.log_level_filter())
|
||||
.init();
|
||||
let subscriber = tracing_subscriber::Registry::default()
|
||||
.with(verbose.tracing_level_filter())
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_line_number(cfg!(debug_assertions))
|
||||
.with_target(cfg!(debug_assertions))
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false),
|
||||
);
|
||||
|
||||
tracing::subscriber::set_global_default(subscriber)
|
||||
.context("Failed to set global default tracing subscriber")?;
|
||||
|
||||
connect_to_external_server(server_socket_path)
|
||||
} else if cfg!(feature = "suid-sgid-mode") {
|
||||
@@ -89,9 +104,18 @@ pub fn bootstrap_server_connection_and_drop_privileges(
|
||||
// as we might be running with elevated privileges.
|
||||
let server_connection = bootstrap_internal_server_and_drop_privs(config)?;
|
||||
|
||||
env_logger::Builder::new()
|
||||
.filter_level(verbose.log_level_filter())
|
||||
.init();
|
||||
let subscriber = tracing_subscriber::Registry::default()
|
||||
.with(verbose.tracing_level_filter())
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_line_number(cfg!(debug_assertions))
|
||||
.with_target(cfg!(debug_assertions))
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false),
|
||||
);
|
||||
|
||||
tracing::subscriber::set_global_default(subscriber)
|
||||
.context("Failed to set global default tracing subscriber")?;
|
||||
|
||||
Ok(server_connection)
|
||||
} else {
|
||||
@@ -104,7 +128,7 @@ fn connect_to_external_server(
|
||||
) -> anyhow::Result<StdUnixStream> {
|
||||
// TODO: ensure this is both readable and writable
|
||||
if let Some(socket_path) = server_socket_path {
|
||||
log::debug!("Connecting to socket at {:?}", socket_path);
|
||||
tracing::debug!("Connecting to socket at {:?}", socket_path);
|
||||
return match StdUnixStream::connect(socket_path) {
|
||||
Ok(socket) => Ok(socket),
|
||||
Err(e) => match e.kind() {
|
||||
@@ -116,7 +140,7 @@ fn connect_to_external_server(
|
||||
}
|
||||
|
||||
if fs::metadata(DEFAULT_SOCKET_PATH).is_ok() {
|
||||
log::debug!("Connecting to default socket at {:?}", DEFAULT_SOCKET_PATH);
|
||||
tracing::debug!("Connecting to default socket at {:?}", DEFAULT_SOCKET_PATH);
|
||||
return match StdUnixStream::connect(DEFAULT_SOCKET_PATH) {
|
||||
Ok(socket) => Ok(socket),
|
||||
Err(e) => match e.kind() {
|
||||
@@ -135,8 +159,8 @@ fn connect_to_external_server(
|
||||
/// Drop privileges to the real user and group of the process.
|
||||
/// If the process is not running with elevated privileges, this function
|
||||
/// is a no-op.
|
||||
fn drop_privs() -> anyhow::Result<()> {
|
||||
log::debug!("Dropping privileges");
|
||||
pub fn drop_privs() -> anyhow::Result<()> {
|
||||
tracing::debug!("Dropping privileges");
|
||||
let real_uid = nix::unistd::getuid();
|
||||
let real_gid = nix::unistd::getgid();
|
||||
|
||||
@@ -146,7 +170,7 @@ fn drop_privs() -> anyhow::Result<()> {
|
||||
debug_assert_eq!(nix::unistd::getuid(), real_uid);
|
||||
debug_assert_eq!(nix::unistd::getgid(), real_gid);
|
||||
|
||||
log::debug!("Privileges dropped successfully");
|
||||
tracing::debug!("Privileges dropped successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -154,7 +178,7 @@ fn bootstrap_internal_server_and_drop_privs(
|
||||
config_path: Option<PathBuf>,
|
||||
) -> anyhow::Result<StdUnixStream> {
|
||||
if let Some(config_path) = config_path {
|
||||
if !executable_is_suid_or_sgid()? {
|
||||
if !executing_in_suid_sgid_mode()? {
|
||||
anyhow::bail!("Executable is not SUID/SGID - refusing to start internal sever");
|
||||
}
|
||||
|
||||
@@ -163,7 +187,7 @@ fn bootstrap_internal_server_and_drop_privs(
|
||||
return Err(anyhow::anyhow!("Config file not found or not readable"));
|
||||
}
|
||||
|
||||
log::debug!("Starting server with config at {:?}", config_path);
|
||||
tracing::debug!("Starting server with config at {:?}", config_path);
|
||||
let socket = invoke_server_with_config(config_path)?;
|
||||
drop_privs()?;
|
||||
return Ok(socket);
|
||||
@@ -171,10 +195,10 @@ fn bootstrap_internal_server_and_drop_privs(
|
||||
|
||||
let config_path = PathBuf::from(DEFAULT_CONFIG_PATH);
|
||||
if fs::metadata(&config_path).is_ok() {
|
||||
if !executable_is_suid_or_sgid()? {
|
||||
if !executing_in_suid_sgid_mode()? {
|
||||
anyhow::bail!("Executable is not SUID/SGID - refusing to start internal sever");
|
||||
}
|
||||
log::debug!("Starting server with default config at {:?}", config_path);
|
||||
tracing::debug!("Starting server with default config at {:?}", config_path);
|
||||
let socket = invoke_server_with_config(config_path)?;
|
||||
drop_privs()?;
|
||||
return Ok(socket);
|
||||
@@ -194,11 +218,14 @@ fn invoke_server_with_config(config_path: PathBuf) -> anyhow::Result<StdUnixStre
|
||||
|
||||
match (unsafe { nix::unistd::fork() }).context("Failed to fork")? {
|
||||
nix::unistd::ForkResult::Parent { child } => {
|
||||
log::debug!("Forked child process with PID {}", child);
|
||||
tracing::debug!("Forked child process with PID {}", child);
|
||||
Ok(client_socket)
|
||||
}
|
||||
nix::unistd::ForkResult::Child => {
|
||||
log::debug!("Running server in child process");
|
||||
tracing::debug!("Running server in child process");
|
||||
|
||||
landlock_restrict_server(Some(config_path.as_path()))
|
||||
.context("Failed to apply Landlock restrictions to the server process")?;
|
||||
|
||||
match run_forked_server(config_path, server_socket, unix_user) {
|
||||
Err(e) => Err(e),
|
||||
@@ -208,6 +235,31 @@ fn invoke_server_with_config(config_path: PathBuf) -> anyhow::Result<StdUnixStre
|
||||
}
|
||||
}
|
||||
|
||||
async fn construct_single_connection_mysql_pool(
|
||||
config: &MysqlConfig,
|
||||
) -> anyhow::Result<sqlx::MySqlPool> {
|
||||
let mysql_config = config.as_mysql_connect_options()?;
|
||||
|
||||
let pool_opts = MySqlPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.min_connections(1);
|
||||
|
||||
config.log_connection_notice();
|
||||
|
||||
let pool = match tokio::time::timeout(
|
||||
Duration::from_secs(config.timeout),
|
||||
pool_opts.connect_with(mysql_config),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(connection) => connection.context("Failed to connect to the database"),
|
||||
Err(_) => Err(anyhow!("Timed out after {} seconds", config.timeout))
|
||||
.context("Failed to connect to the database"),
|
||||
}?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
/// Run the server in the forked child process.
|
||||
/// This function will not return, but will exit the process with a success code.
|
||||
fn run_forked_server(
|
||||
@@ -215,7 +267,8 @@ fn run_forked_server(
|
||||
server_socket: StdUnixStream,
|
||||
unix_user: UnixUser,
|
||||
) -> anyhow::Result<()> {
|
||||
let config = read_config_from_path(Some(config_path))?;
|
||||
let config = ServerConfig::read_config_from_path(&config_path)
|
||||
.context("Failed to read server config in forked process")?;
|
||||
|
||||
let result: anyhow::Result<()> = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
@@ -223,7 +276,24 @@ fn run_forked_server(
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
let socket = TokioUnixStream::from_std(server_socket)?;
|
||||
handle_requests_for_single_session(socket, &unix_user, &config).await?;
|
||||
let db_pool = construct_single_connection_mysql_pool(&config.mysql).await?;
|
||||
let db_is_mariadb = {
|
||||
let mut conn = db_pool.acquire().await?;
|
||||
let version_row: String = sqlx::query_scalar("SELECT VERSION()")
|
||||
.fetch_one(&mut *conn)
|
||||
.await
|
||||
.context("Failed to query MySQL version")?;
|
||||
version_row.to_lowercase().contains("mariadb")
|
||||
};
|
||||
|
||||
let db_pool = Arc::new(RwLock::new(db_pool));
|
||||
session_handler::session_handler_with_unix_user(
|
||||
socket,
|
||||
&unix_user,
|
||||
db_pool,
|
||||
db_is_mariadb,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
});
|
||||
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
use anyhow::Context;
|
||||
use indoc::indoc;
|
||||
use nix::unistd::{Group as LibcGroup, User as LibcUser};
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
use std::ffi::CString;
|
||||
use std::fmt;
|
||||
|
||||
pub const DEFAULT_CONFIG_PATH: &str = "/etc/mysqladm/config.toml";
|
||||
pub const DEFAULT_SOCKET_PATH: &str = "/run/mysqladm/mysqladm.sock";
|
||||
pub const DEFAULT_CONFIG_PATH: &str = "/etc/muscl/config.toml";
|
||||
pub const DEFAULT_SOCKET_PATH: &str = "/run/muscl/muscl.sock";
|
||||
|
||||
pub const ASCII_BANNER: &str = indoc! {
|
||||
r#"
|
||||
__
|
||||
____ ___ __ ____________/ /
|
||||
/ __ `__ \/ / / / ___/ ___/ /
|
||||
/ / / / / / /_/ (__ ) /__/ /
|
||||
/_/ /_/ /_/\__,_/____/\___/_/
|
||||
"#
|
||||
};
|
||||
|
||||
pub const KIND_REGARDS: &str = concat!(
|
||||
"Hacked together by yours truly, Programvareverkstedet <projects@pvv.ntnu.no>\n",
|
||||
"If you experience any bugs or turbulence, please give us a heads up :)",
|
||||
);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnixUser {
|
||||
pub username: String,
|
||||
pub groups: Vec<String>,
|
||||
}
|
||||
|
||||
impl fmt::Display for UnixUser {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.username)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: these functions are somewhat critical, and should have integration tests
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -30,7 +54,7 @@ fn get_unix_groups(user: &LibcUser) -> anyhow::Result<Vec<LibcGroup>> {
|
||||
Ok(Some(group)) => Some(group),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
tracing::warn!(
|
||||
"Failed to look up group with GID {}: {}\nIgnoring...",
|
||||
gid,
|
||||
e
|
||||
@@ -43,28 +67,20 @@ fn get_unix_groups(user: &LibcUser) -> anyhow::Result<Vec<LibcGroup>> {
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
/// Check if the current executable is SUID or SGID.
|
||||
///
|
||||
/// If the check fails, an error is returned.
|
||||
/// Check if the current executable is running in SUID/SGID mode
|
||||
#[cfg(feature = "suid-sgid-mode")]
|
||||
pub fn executable_is_suid_or_sgid() -> anyhow::Result<bool> {
|
||||
use std::{fs, os::unix::fs::PermissionsExt};
|
||||
let result = std::env::current_exe()
|
||||
.context("Failed to get current executable path")
|
||||
.and_then(|executable| {
|
||||
fs::metadata(executable).context("Failed to get executable metadata")
|
||||
})
|
||||
.context("Failed to check SUID/SGID bits on executable")
|
||||
.map(|metadata| {
|
||||
let mode = metadata.permissions().mode();
|
||||
mode & 0o4000 != 0 || mode & 0o2000 != 0
|
||||
})?;
|
||||
Ok(result)
|
||||
pub fn executing_in_suid_sgid_mode() -> anyhow::Result<bool> {
|
||||
let euid = nix::unistd::geteuid();
|
||||
let uid = nix::unistd::getuid();
|
||||
let egid = nix::unistd::getegid();
|
||||
let gid = nix::unistd::getgid();
|
||||
|
||||
Ok(euid != uid || egid != gid)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "suid-sgid-mode"))]
|
||||
#[inline]
|
||||
pub fn executable_is_suid_or_sgid() -> anyhow::Result<bool> {
|
||||
pub fn executing_in_suid_sgid_mode() -> anyhow::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
|
||||
5
src/core/completion.rs
Normal file
5
src/core/completion.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod mysql_database_completer;
|
||||
mod mysql_user_completer;
|
||||
|
||||
pub use mysql_database_completer::*;
|
||||
pub use mysql_user_completer::*;
|
||||
73
src/core/completion/mysql_database_completer.rs
Normal file
73
src/core/completion/mysql_database_completer.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
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 mysql_database_completer(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
|
||||
match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(runtime) => match runtime.block_on(mysql_database_completer_(current)) {
|
||||
Ok(completions) => completions,
|
||||
Err(err) => {
|
||||
eprintln!("Error getting MySQL database completions: {}", err);
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
eprintln!("Error starting Tokio runtime: {}", err);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to the server to get MySQL database completions.
|
||||
async fn mysql_database_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::CompleteDatabaseName(current.to_string_lossy().to_string());
|
||||
|
||||
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::CompleteDatabaseName(suggestions))) => suggestions,
|
||||
response => return erroneous_server_response(response).map(|_| vec![]),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
let result = result.into_iter().map(CompletionCandidate::new).collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
73
src/core/completion/mysql_user_completer.rs
Normal file
73
src/core/completion/mysql_user_completer.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
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 mysql_user_completer(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
|
||||
match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(runtime) => match runtime.block_on(mysql_user_completer_(current)) {
|
||||
Ok(completions) => completions,
|
||||
Err(err) => {
|
||||
eprintln!("Error getting MySQL user 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 mysql_user_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::CompleteUserName(current.to_string_lossy().to_string());
|
||||
|
||||
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::CompleteUserName(suggestions))) => suggestions,
|
||||
response => return erroneous_server_response(response).map(|_| vec![]),
|
||||
};
|
||||
|
||||
server_connection.send(Request::Exit).await?;
|
||||
|
||||
let result = result.into_iter().map(CompletionCandidate::new).collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -1,767 +1,9 @@
|
||||
use anyhow::{Context, anyhow};
|
||||
use itertools::Itertools;
|
||||
use prettytable::Table;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
cmp::max,
|
||||
collections::{BTreeSet, HashMap},
|
||||
};
|
||||
|
||||
use super::{
|
||||
common::{rev_yn, yn},
|
||||
protocol::{MySQLDatabase, MySQLUser},
|
||||
};
|
||||
use crate::server::sql::database_privilege_operations::{
|
||||
DATABASE_PRIVILEGE_FIELDS, DatabasePrivilegeRow,
|
||||
};
|
||||
|
||||
pub fn db_priv_field_human_readable_name(name: &str) -> String {
|
||||
match name {
|
||||
"Db" => "Database".to_owned(),
|
||||
"User" => "User".to_owned(),
|
||||
"select_priv" => "Select".to_owned(),
|
||||
"insert_priv" => "Insert".to_owned(),
|
||||
"update_priv" => "Update".to_owned(),
|
||||
"delete_priv" => "Delete".to_owned(),
|
||||
"create_priv" => "Create".to_owned(),
|
||||
"drop_priv" => "Drop".to_owned(),
|
||||
"alter_priv" => "Alter".to_owned(),
|
||||
"index_priv" => "Index".to_owned(),
|
||||
"create_tmp_table_priv" => "Temp".to_owned(),
|
||||
"lock_tables_priv" => "Lock".to_owned(),
|
||||
"references_priv" => "References".to_owned(),
|
||||
_ => format!("Unknown({})", name),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn diff(row1: &DatabasePrivilegeRow, row2: &DatabasePrivilegeRow) -> DatabasePrivilegeRowDiff {
|
||||
debug_assert!(row1.db == row2.db && row1.user == row2.user);
|
||||
|
||||
DatabasePrivilegeRowDiff {
|
||||
db: row1.db.to_owned(),
|
||||
user: row1.user.to_owned(),
|
||||
diff: DATABASE_PRIVILEGE_FIELDS
|
||||
.into_iter()
|
||||
.skip(2)
|
||||
.filter_map(|field| {
|
||||
DatabasePrivilegeChange::new(
|
||||
row1.get_privilege_by_name(field),
|
||||
row2.get_privilege_by_name(field),
|
||||
field,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/*************************/
|
||||
/* CLI INTERFACE PARSING */
|
||||
/*************************/
|
||||
|
||||
/// See documentation for [`DatabaseCommand::EditDbPrivs`].
|
||||
pub fn parse_privilege_table_cli_arg(arg: &str) -> anyhow::Result<DatabasePrivilegeRow> {
|
||||
let parts: Vec<&str> = arg.split(':').collect();
|
||||
if parts.len() != 3 {
|
||||
anyhow::bail!("Invalid argument format. See `edit-db-privs --help` for more information.");
|
||||
}
|
||||
|
||||
if parts[0].is_empty() {
|
||||
anyhow::bail!("Database name cannot be empty.");
|
||||
}
|
||||
|
||||
if parts[1].is_empty() {
|
||||
anyhow::bail!("Username cannot be empty.");
|
||||
}
|
||||
|
||||
let db = parts[0].into();
|
||||
let user = parts[1].into();
|
||||
let privs = parts[2].to_string();
|
||||
|
||||
let mut result = DatabasePrivilegeRow {
|
||||
db,
|
||||
user,
|
||||
select_priv: false,
|
||||
insert_priv: false,
|
||||
update_priv: false,
|
||||
delete_priv: false,
|
||||
create_priv: false,
|
||||
drop_priv: false,
|
||||
alter_priv: false,
|
||||
index_priv: false,
|
||||
create_tmp_table_priv: false,
|
||||
lock_tables_priv: false,
|
||||
references_priv: false,
|
||||
};
|
||||
|
||||
for char in privs.chars() {
|
||||
match char {
|
||||
's' => result.select_priv = true,
|
||||
'i' => result.insert_priv = true,
|
||||
'u' => result.update_priv = true,
|
||||
'd' => result.delete_priv = true,
|
||||
'c' => result.create_priv = true,
|
||||
'D' => result.drop_priv = true,
|
||||
'a' => result.alter_priv = true,
|
||||
'I' => result.index_priv = true,
|
||||
't' => result.create_tmp_table_priv = true,
|
||||
'l' => result.lock_tables_priv = true,
|
||||
'r' => result.references_priv = true,
|
||||
'A' => {
|
||||
result.select_priv = true;
|
||||
result.insert_priv = true;
|
||||
result.update_priv = true;
|
||||
result.delete_priv = true;
|
||||
result.create_priv = true;
|
||||
result.drop_priv = true;
|
||||
result.alter_priv = true;
|
||||
result.index_priv = true;
|
||||
result.create_tmp_table_priv = true;
|
||||
result.lock_tables_priv = true;
|
||||
result.references_priv = true;
|
||||
}
|
||||
_ => anyhow::bail!("Invalid privilege character: {}", char),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/**********************************/
|
||||
/* EDITOR CONTENT DISPLAY/DISPLAY */
|
||||
/**********************************/
|
||||
|
||||
/// Generates a single row of the privileges table for the editor.
|
||||
pub fn format_privileges_line_for_editor(
|
||||
privs: &DatabasePrivilegeRow,
|
||||
username_len: usize,
|
||||
database_name_len: usize,
|
||||
) -> String {
|
||||
DATABASE_PRIVILEGE_FIELDS
|
||||
.into_iter()
|
||||
.map(|field| match field {
|
||||
"Db" => format!("{:width$}", privs.db, width = database_name_len),
|
||||
"User" => format!("{:width$}", privs.user, width = username_len),
|
||||
privilege => format!(
|
||||
"{:width$}",
|
||||
yn(privs.get_privilege_by_name(privilege)),
|
||||
width = db_priv_field_human_readable_name(privilege).len()
|
||||
),
|
||||
})
|
||||
.join(" ")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
const EDITOR_COMMENT: &str = r#"
|
||||
# Welcome to the privilege editor.
|
||||
# Each line defines what privileges a single user has on a single database.
|
||||
# The first two columns respectively represent the database name and the user, and the remaining columns are the privileges.
|
||||
# If the user should have a certain privilege, write 'Y', otherwise write 'N'.
|
||||
#
|
||||
# Lines starting with '#' are comments and will be ignored.
|
||||
"#;
|
||||
|
||||
/// Generates the content for the privilege editor.
|
||||
///
|
||||
/// The unix user is used in case there are no privileges to edit,
|
||||
/// so that the user can see an example line based on their username.
|
||||
pub fn generate_editor_content_from_privilege_data(
|
||||
privilege_data: &[DatabasePrivilegeRow],
|
||||
unix_user: &str,
|
||||
database_name: Option<&MySQLDatabase>,
|
||||
) -> String {
|
||||
let example_user = format!("{}_user", unix_user);
|
||||
let example_db = database_name
|
||||
.unwrap_or(&format!("{}_db", unix_user).into())
|
||||
.to_string();
|
||||
|
||||
// NOTE: `.max()`` fails when the iterator is empty.
|
||||
// In this case, we know that the only fields in the
|
||||
// editor will be the example user and example db name.
|
||||
// Hence, it's put as the fallback value, despite not really
|
||||
// being a "fallback" in the normal sense.
|
||||
let longest_username = max(
|
||||
privilege_data
|
||||
.iter()
|
||||
.map(|p| p.user.len())
|
||||
.max()
|
||||
.unwrap_or(example_user.len()),
|
||||
"User".len(),
|
||||
);
|
||||
|
||||
let longest_database_name = max(
|
||||
privilege_data
|
||||
.iter()
|
||||
.map(|p| p.db.len())
|
||||
.max()
|
||||
.unwrap_or(example_db.len()),
|
||||
"Database".len(),
|
||||
);
|
||||
|
||||
let mut header: Vec<_> = DATABASE_PRIVILEGE_FIELDS
|
||||
.into_iter()
|
||||
.map(db_priv_field_human_readable_name)
|
||||
.collect();
|
||||
|
||||
// Pad the first two columns with spaces to align the privileges.
|
||||
header[0] = format!("{:width$}", header[0], width = longest_database_name);
|
||||
header[1] = format!("{:width$}", header[1], width = longest_username);
|
||||
|
||||
let example_line = format_privileges_line_for_editor(
|
||||
&DatabasePrivilegeRow {
|
||||
db: example_db.into(),
|
||||
user: example_user.into(),
|
||||
select_priv: true,
|
||||
insert_priv: true,
|
||||
update_priv: true,
|
||||
delete_priv: true,
|
||||
create_priv: false,
|
||||
drop_priv: false,
|
||||
alter_priv: false,
|
||||
index_priv: false,
|
||||
create_tmp_table_priv: false,
|
||||
lock_tables_priv: false,
|
||||
references_priv: false,
|
||||
},
|
||||
longest_username,
|
||||
longest_database_name,
|
||||
);
|
||||
|
||||
format!(
|
||||
"{}\n{}\n{}",
|
||||
EDITOR_COMMENT,
|
||||
header.join(" "),
|
||||
if privilege_data.is_empty() {
|
||||
format!("# {}", example_line)
|
||||
} else {
|
||||
privilege_data
|
||||
.iter()
|
||||
.map(|privs| {
|
||||
format_privileges_line_for_editor(
|
||||
privs,
|
||||
longest_username,
|
||||
longest_database_name,
|
||||
)
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum PrivilegeRowParseResult {
|
||||
PrivilegeRow(DatabasePrivilegeRow),
|
||||
ParserError(anyhow::Error),
|
||||
TooFewFields(usize),
|
||||
TooManyFields(usize),
|
||||
Header,
|
||||
Comment,
|
||||
Empty,
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn parse_privilege_cell_from_editor(yn: &str, name: &str) -> anyhow::Result<bool> {
|
||||
rev_yn(yn)
|
||||
.ok_or_else(|| anyhow!("Expected Y or N, found {}", yn))
|
||||
.context(format!("Could not parse {} privilege", name))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn editor_row_is_header(row: &str) -> bool {
|
||||
row.split_ascii_whitespace()
|
||||
.zip(DATABASE_PRIVILEGE_FIELDS.iter())
|
||||
.map(|(field, priv_name)| (field, db_priv_field_human_readable_name(priv_name)))
|
||||
.all(|(field, header_field)| field == header_field)
|
||||
}
|
||||
|
||||
/// Parse a single row of the privileges table from the editor.
|
||||
fn parse_privilege_row_from_editor(row: &str) -> PrivilegeRowParseResult {
|
||||
if row.starts_with('#') || row.starts_with("//") {
|
||||
return PrivilegeRowParseResult::Comment;
|
||||
}
|
||||
|
||||
if row.trim().is_empty() {
|
||||
return PrivilegeRowParseResult::Empty;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = row.trim().split_ascii_whitespace().collect();
|
||||
|
||||
match parts.len() {
|
||||
n if (n < DATABASE_PRIVILEGE_FIELDS.len()) => {
|
||||
return PrivilegeRowParseResult::TooFewFields(n);
|
||||
}
|
||||
n if (n > DATABASE_PRIVILEGE_FIELDS.len()) => {
|
||||
return PrivilegeRowParseResult::TooManyFields(n);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if editor_row_is_header(row) {
|
||||
return PrivilegeRowParseResult::Header;
|
||||
}
|
||||
|
||||
let row = DatabasePrivilegeRow {
|
||||
db: (*parts.first().unwrap()).into(),
|
||||
user: (*parts.get(1).unwrap()).into(),
|
||||
select_priv: match parse_privilege_cell_from_editor(
|
||||
parts.get(2).unwrap(),
|
||||
DATABASE_PRIVILEGE_FIELDS[2],
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PrivilegeRowParseResult::ParserError(e),
|
||||
},
|
||||
insert_priv: match parse_privilege_cell_from_editor(
|
||||
parts.get(3).unwrap(),
|
||||
DATABASE_PRIVILEGE_FIELDS[3],
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PrivilegeRowParseResult::ParserError(e),
|
||||
},
|
||||
update_priv: match parse_privilege_cell_from_editor(
|
||||
parts.get(4).unwrap(),
|
||||
DATABASE_PRIVILEGE_FIELDS[4],
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PrivilegeRowParseResult::ParserError(e),
|
||||
},
|
||||
delete_priv: match parse_privilege_cell_from_editor(
|
||||
parts.get(5).unwrap(),
|
||||
DATABASE_PRIVILEGE_FIELDS[5],
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PrivilegeRowParseResult::ParserError(e),
|
||||
},
|
||||
create_priv: match parse_privilege_cell_from_editor(
|
||||
parts.get(6).unwrap(),
|
||||
DATABASE_PRIVILEGE_FIELDS[6],
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PrivilegeRowParseResult::ParserError(e),
|
||||
},
|
||||
drop_priv: match parse_privilege_cell_from_editor(
|
||||
parts.get(7).unwrap(),
|
||||
DATABASE_PRIVILEGE_FIELDS[7],
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PrivilegeRowParseResult::ParserError(e),
|
||||
},
|
||||
alter_priv: match parse_privilege_cell_from_editor(
|
||||
parts.get(8).unwrap(),
|
||||
DATABASE_PRIVILEGE_FIELDS[8],
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PrivilegeRowParseResult::ParserError(e),
|
||||
},
|
||||
index_priv: match parse_privilege_cell_from_editor(
|
||||
parts.get(9).unwrap(),
|
||||
DATABASE_PRIVILEGE_FIELDS[9],
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PrivilegeRowParseResult::ParserError(e),
|
||||
},
|
||||
create_tmp_table_priv: match parse_privilege_cell_from_editor(
|
||||
parts.get(10).unwrap(),
|
||||
DATABASE_PRIVILEGE_FIELDS[10],
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PrivilegeRowParseResult::ParserError(e),
|
||||
},
|
||||
lock_tables_priv: match parse_privilege_cell_from_editor(
|
||||
parts.get(11).unwrap(),
|
||||
DATABASE_PRIVILEGE_FIELDS[11],
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PrivilegeRowParseResult::ParserError(e),
|
||||
},
|
||||
references_priv: match parse_privilege_cell_from_editor(
|
||||
parts.get(12).unwrap(),
|
||||
DATABASE_PRIVILEGE_FIELDS[12],
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PrivilegeRowParseResult::ParserError(e),
|
||||
},
|
||||
};
|
||||
|
||||
PrivilegeRowParseResult::PrivilegeRow(row)
|
||||
}
|
||||
|
||||
// TODO: return better errors
|
||||
|
||||
pub fn parse_privilege_data_from_editor_content(
|
||||
content: String,
|
||||
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
|
||||
content
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(|line| line.trim())
|
||||
.map(parse_privilege_row_from_editor)
|
||||
.map(|result| match result {
|
||||
PrivilegeRowParseResult::PrivilegeRow(row) => Ok(Some(row)),
|
||||
PrivilegeRowParseResult::ParserError(e) => Err(e),
|
||||
PrivilegeRowParseResult::TooFewFields(n) => Err(anyhow!(
|
||||
"Too few fields in line. Expected to find {} fields, found {}",
|
||||
DATABASE_PRIVILEGE_FIELDS.len(),
|
||||
n
|
||||
)),
|
||||
PrivilegeRowParseResult::TooManyFields(n) => Err(anyhow!(
|
||||
"Too many fields in line. Expected to find {} fields, found {}",
|
||||
DATABASE_PRIVILEGE_FIELDS.len(),
|
||||
n
|
||||
)),
|
||||
PrivilegeRowParseResult::Header => Ok(None),
|
||||
PrivilegeRowParseResult::Comment => Ok(None),
|
||||
PrivilegeRowParseResult::Empty => Ok(None),
|
||||
})
|
||||
.filter_map(|result| result.transpose())
|
||||
.collect::<anyhow::Result<Vec<DatabasePrivilegeRow>>>()
|
||||
}
|
||||
|
||||
/*****************************/
|
||||
/* CALCULATE PRIVILEGE DIFFS */
|
||||
/*****************************/
|
||||
|
||||
/// This struct represents encapsulates the differences between two
|
||||
/// instances of privilege sets for a single user on a single database.
|
||||
///
|
||||
/// The `User` and `Database` are the same for both instances.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
|
||||
pub struct DatabasePrivilegeRowDiff {
|
||||
pub db: MySQLDatabase,
|
||||
pub user: MySQLUser,
|
||||
pub diff: BTreeSet<DatabasePrivilegeChange>,
|
||||
}
|
||||
|
||||
/// This enum represents a change for a single privilege.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
|
||||
pub enum DatabasePrivilegeChange {
|
||||
YesToNo(String),
|
||||
NoToYes(String),
|
||||
}
|
||||
|
||||
impl DatabasePrivilegeChange {
|
||||
pub fn new(p1: bool, p2: bool, name: &str) -> Option<DatabasePrivilegeChange> {
|
||||
match (p1, p2) {
|
||||
(true, false) => Some(DatabasePrivilegeChange::YesToNo(name.to_owned())),
|
||||
(false, true) => Some(DatabasePrivilegeChange::NoToYes(name.to_owned())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This enum encapsulates whether a [`DatabasePrivilegeRow`] was intrduced, modified or deleted.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
|
||||
pub enum DatabasePrivilegesDiff {
|
||||
New(DatabasePrivilegeRow),
|
||||
Modified(DatabasePrivilegeRowDiff),
|
||||
Deleted(DatabasePrivilegeRow),
|
||||
}
|
||||
|
||||
impl DatabasePrivilegesDiff {
|
||||
pub fn get_database_name(&self) -> &MySQLDatabase {
|
||||
match self {
|
||||
DatabasePrivilegesDiff::New(p) => &p.db,
|
||||
DatabasePrivilegesDiff::Modified(p) => &p.db,
|
||||
DatabasePrivilegesDiff::Deleted(p) => &p.db,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_user_name(&self) -> &MySQLUser {
|
||||
match self {
|
||||
DatabasePrivilegesDiff::New(p) => &p.user,
|
||||
DatabasePrivilegesDiff::Modified(p) => &p.user,
|
||||
DatabasePrivilegesDiff::Deleted(p) => &p.user,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This function calculates the differences between two sets of database privileges.
|
||||
/// It returns a set of [`DatabasePrivilegesDiff`] that can be used to display or
|
||||
/// apply a set of privilege modifications to the database.
|
||||
pub fn diff_privileges(
|
||||
from: &[DatabasePrivilegeRow],
|
||||
to: &[DatabasePrivilegeRow],
|
||||
) -> BTreeSet<DatabasePrivilegesDiff> {
|
||||
let from_lookup_table: HashMap<(MySQLDatabase, MySQLUser), DatabasePrivilegeRow> =
|
||||
HashMap::from_iter(
|
||||
from.iter()
|
||||
.cloned()
|
||||
.map(|p| ((p.db.to_owned(), p.user.to_owned()), p)),
|
||||
);
|
||||
|
||||
let to_lookup_table: HashMap<(MySQLDatabase, MySQLUser), DatabasePrivilegeRow> =
|
||||
HashMap::from_iter(
|
||||
to.iter()
|
||||
.cloned()
|
||||
.map(|p| ((p.db.to_owned(), p.user.to_owned()), p)),
|
||||
);
|
||||
|
||||
let mut result = BTreeSet::new();
|
||||
|
||||
for p in to {
|
||||
if let Some(old_p) = from_lookup_table.get(&(p.db.to_owned(), p.user.to_owned())) {
|
||||
let diff = diff(old_p, p);
|
||||
if !diff.diff.is_empty() {
|
||||
result.insert(DatabasePrivilegesDiff::Modified(diff));
|
||||
}
|
||||
} else {
|
||||
result.insert(DatabasePrivilegesDiff::New(p.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
for p in from {
|
||||
if !to_lookup_table.contains_key(&(p.db.to_owned(), p.user.to_owned())) {
|
||||
result.insert(DatabasePrivilegesDiff::Deleted(p.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn display_privilege_cell(diff: &DatabasePrivilegeRowDiff) -> String {
|
||||
diff.diff
|
||||
.iter()
|
||||
.map(|change| match change {
|
||||
DatabasePrivilegeChange::YesToNo(name) => {
|
||||
format!("{}: Y -> N", db_priv_field_human_readable_name(name))
|
||||
}
|
||||
DatabasePrivilegeChange::NoToYes(name) => {
|
||||
format!("{}: N -> Y", db_priv_field_human_readable_name(name))
|
||||
}
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn display_new_privileges_list(row: &DatabasePrivilegeRow) -> String {
|
||||
DATABASE_PRIVILEGE_FIELDS
|
||||
.into_iter()
|
||||
.skip(2)
|
||||
.map(|field| {
|
||||
if row.get_privilege_by_name(field) {
|
||||
format!("{}: Y", db_priv_field_human_readable_name(field))
|
||||
} else {
|
||||
format!("{}: N", db_priv_field_human_readable_name(field))
|
||||
}
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Displays the difference between two sets of database privileges.
|
||||
pub fn display_privilege_diffs(diffs: &BTreeSet<DatabasePrivilegesDiff>) -> String {
|
||||
let mut table = Table::new();
|
||||
table.set_titles(row!["Database", "User", "Privilege diff",]);
|
||||
for row in diffs {
|
||||
match row {
|
||||
DatabasePrivilegesDiff::New(p) => {
|
||||
table.add_row(row![
|
||||
p.db,
|
||||
p.user,
|
||||
"(Previously unprivileged)\n".to_string() + &display_new_privileges_list(p)
|
||||
]);
|
||||
}
|
||||
DatabasePrivilegesDiff::Modified(p) => {
|
||||
table.add_row(row![p.db, p.user, display_privilege_cell(p),]);
|
||||
}
|
||||
DatabasePrivilegesDiff::Deleted(p) => {
|
||||
table.add_row(row![p.db, p.user, "Removed".to_string()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table.to_string()
|
||||
}
|
||||
|
||||
/*********/
|
||||
/* TESTS */
|
||||
/*********/
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_database_privilege_change_creation() {
|
||||
assert_eq!(
|
||||
DatabasePrivilegeChange::new(true, false, "test"),
|
||||
Some(DatabasePrivilegeChange::YesToNo("test".to_owned()))
|
||||
);
|
||||
assert_eq!(
|
||||
DatabasePrivilegeChange::new(false, true, "test"),
|
||||
Some(DatabasePrivilegeChange::NoToYes("test".to_owned()))
|
||||
);
|
||||
assert_eq!(DatabasePrivilegeChange::new(true, true, "test"), None);
|
||||
assert_eq!(DatabasePrivilegeChange::new(false, false, "test"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_privilege_table_cli_arg() {
|
||||
let result = parse_privilege_table_cli_arg("db:user:A");
|
||||
assert_eq!(
|
||||
result.ok(),
|
||||
Some(DatabasePrivilegeRow {
|
||||
db: "db".into(),
|
||||
user: "user".into(),
|
||||
select_priv: true,
|
||||
insert_priv: true,
|
||||
update_priv: true,
|
||||
delete_priv: true,
|
||||
create_priv: true,
|
||||
drop_priv: true,
|
||||
alter_priv: true,
|
||||
index_priv: true,
|
||||
create_tmp_table_priv: true,
|
||||
lock_tables_priv: true,
|
||||
references_priv: true,
|
||||
})
|
||||
);
|
||||
|
||||
let result = parse_privilege_table_cli_arg("db:user:");
|
||||
assert_eq!(
|
||||
result.ok(),
|
||||
Some(DatabasePrivilegeRow {
|
||||
db: "db".into(),
|
||||
user: "user".into(),
|
||||
select_priv: false,
|
||||
insert_priv: false,
|
||||
update_priv: false,
|
||||
delete_priv: false,
|
||||
create_priv: false,
|
||||
drop_priv: false,
|
||||
alter_priv: false,
|
||||
index_priv: false,
|
||||
create_tmp_table_priv: false,
|
||||
lock_tables_priv: false,
|
||||
references_priv: false,
|
||||
})
|
||||
);
|
||||
|
||||
let result = parse_privilege_table_cli_arg("db:user:siud");
|
||||
assert_eq!(
|
||||
result.ok(),
|
||||
Some(DatabasePrivilegeRow {
|
||||
db: "db".into(),
|
||||
user: "user".into(),
|
||||
select_priv: true,
|
||||
insert_priv: true,
|
||||
update_priv: true,
|
||||
delete_priv: true,
|
||||
create_priv: false,
|
||||
drop_priv: false,
|
||||
alter_priv: false,
|
||||
index_priv: false,
|
||||
create_tmp_table_priv: false,
|
||||
lock_tables_priv: false,
|
||||
references_priv: false,
|
||||
})
|
||||
);
|
||||
|
||||
let result = parse_privilege_table_cli_arg("db:user:F");
|
||||
assert!(result.is_err());
|
||||
|
||||
let result = parse_privilege_table_cli_arg("db:s");
|
||||
assert!(result.is_err());
|
||||
|
||||
let result = parse_privilege_table_cli_arg("::");
|
||||
assert!(result.is_err());
|
||||
|
||||
let result = parse_privilege_table_cli_arg("db::");
|
||||
assert!(result.is_err());
|
||||
|
||||
let result = parse_privilege_table_cli_arg(":user:");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_privileges() {
|
||||
let row_to_be_modified = DatabasePrivilegeRow {
|
||||
db: "db".into(),
|
||||
user: "user".into(),
|
||||
select_priv: true,
|
||||
insert_priv: true,
|
||||
update_priv: true,
|
||||
delete_priv: true,
|
||||
create_priv: true,
|
||||
drop_priv: true,
|
||||
alter_priv: true,
|
||||
index_priv: false,
|
||||
create_tmp_table_priv: true,
|
||||
lock_tables_priv: true,
|
||||
references_priv: false,
|
||||
};
|
||||
|
||||
let mut row_to_be_deleted = row_to_be_modified.to_owned();
|
||||
"user2".clone_into(&mut row_to_be_deleted.user);
|
||||
|
||||
let from = vec![row_to_be_modified.to_owned(), row_to_be_deleted.to_owned()];
|
||||
|
||||
let mut modified_row = row_to_be_modified.to_owned();
|
||||
modified_row.select_priv = false;
|
||||
modified_row.insert_priv = false;
|
||||
modified_row.index_priv = true;
|
||||
|
||||
let mut new_row = row_to_be_modified.to_owned();
|
||||
"user3".clone_into(&mut new_row.user);
|
||||
|
||||
let to = vec![modified_row.to_owned(), new_row.to_owned()];
|
||||
|
||||
let diffs = diff_privileges(&from, &to);
|
||||
|
||||
assert_eq!(
|
||||
diffs,
|
||||
BTreeSet::from_iter(vec![
|
||||
DatabasePrivilegesDiff::Deleted(row_to_be_deleted),
|
||||
DatabasePrivilegesDiff::Modified(DatabasePrivilegeRowDiff {
|
||||
db: "db".into(),
|
||||
user: "user".into(),
|
||||
diff: BTreeSet::from_iter(vec![
|
||||
DatabasePrivilegeChange::YesToNo("select_priv".to_owned()),
|
||||
DatabasePrivilegeChange::YesToNo("insert_priv".to_owned()),
|
||||
DatabasePrivilegeChange::NoToYes("index_priv".to_owned()),
|
||||
]),
|
||||
}),
|
||||
DatabasePrivilegesDiff::New(new_row),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_generated_and_parsed_editor_content_is_equal() {
|
||||
let permissions = vec![
|
||||
DatabasePrivilegeRow {
|
||||
db: "db".into(),
|
||||
user: "user".into(),
|
||||
select_priv: true,
|
||||
insert_priv: true,
|
||||
update_priv: true,
|
||||
delete_priv: true,
|
||||
create_priv: true,
|
||||
drop_priv: true,
|
||||
alter_priv: true,
|
||||
index_priv: true,
|
||||
create_tmp_table_priv: true,
|
||||
lock_tables_priv: true,
|
||||
references_priv: true,
|
||||
},
|
||||
DatabasePrivilegeRow {
|
||||
db: "db".into(),
|
||||
user: "user".into(),
|
||||
select_priv: false,
|
||||
insert_priv: false,
|
||||
update_priv: false,
|
||||
delete_priv: false,
|
||||
create_priv: false,
|
||||
drop_priv: false,
|
||||
alter_priv: false,
|
||||
index_priv: false,
|
||||
create_tmp_table_priv: false,
|
||||
lock_tables_priv: false,
|
||||
references_priv: false,
|
||||
},
|
||||
];
|
||||
|
||||
let content = generate_editor_content_from_privilege_data(&permissions, "user", None);
|
||||
|
||||
let parsed_permissions = parse_privilege_data_from_editor_content(content).unwrap();
|
||||
|
||||
assert_eq!(permissions, parsed_permissions);
|
||||
}
|
||||
}
|
||||
mod base;
|
||||
mod cli;
|
||||
mod diff;
|
||||
mod editor;
|
||||
|
||||
pub use base::*;
|
||||
pub use cli::*;
|
||||
pub use diff::*;
|
||||
pub use editor::*;
|
||||
|
||||
122
src/core/database_privileges/base.rs
Normal file
122
src/core/database_privileges/base.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
//! This module contains some base datastructures and functionality for dealing with
|
||||
//! database privileges in MySQL.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::core::types::{MySQLDatabase, MySQLUser};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// This is the list of fields that are used to fetch the db + user + privileges
|
||||
/// from the `db` table in the database. If you need to add or remove privilege
|
||||
/// fields, this is a good place to start.
|
||||
pub const DATABASE_PRIVILEGE_FIELDS: [&str; 13] = [
|
||||
"Db",
|
||||
"User",
|
||||
"select_priv",
|
||||
"insert_priv",
|
||||
"update_priv",
|
||||
"delete_priv",
|
||||
"create_priv",
|
||||
"drop_priv",
|
||||
"alter_priv",
|
||||
"index_priv",
|
||||
"create_tmp_table_priv",
|
||||
"lock_tables_priv",
|
||||
"references_priv",
|
||||
];
|
||||
|
||||
// NOTE: ord is needed for BTreeSet to accept the type, but it
|
||||
// doesn't have any natural implementation semantics.
|
||||
|
||||
/// Representation of the set of privileges for a single user on a single database.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
|
||||
pub struct DatabasePrivilegeRow {
|
||||
// TODO: don't store the db and user here, let the type be stored in a mapping
|
||||
pub db: MySQLDatabase,
|
||||
pub user: MySQLUser,
|
||||
pub select_priv: bool,
|
||||
pub insert_priv: bool,
|
||||
pub update_priv: bool,
|
||||
pub delete_priv: bool,
|
||||
pub create_priv: bool,
|
||||
pub drop_priv: bool,
|
||||
pub alter_priv: bool,
|
||||
pub index_priv: bool,
|
||||
pub create_tmp_table_priv: bool,
|
||||
pub lock_tables_priv: bool,
|
||||
pub references_priv: bool,
|
||||
}
|
||||
|
||||
impl DatabasePrivilegeRow {
|
||||
/// Gets the value of a privilege by its name as a &str.
|
||||
pub fn get_privilege_by_name(&self, name: &str) -> Option<bool> {
|
||||
match name {
|
||||
"select_priv" => Some(self.select_priv),
|
||||
"insert_priv" => Some(self.insert_priv),
|
||||
"update_priv" => Some(self.update_priv),
|
||||
"delete_priv" => Some(self.delete_priv),
|
||||
"create_priv" => Some(self.create_priv),
|
||||
"drop_priv" => Some(self.drop_priv),
|
||||
"alter_priv" => Some(self.alter_priv),
|
||||
"index_priv" => Some(self.index_priv),
|
||||
"create_tmp_table_priv" => Some(self.create_tmp_table_priv),
|
||||
"lock_tables_priv" => Some(self.lock_tables_priv),
|
||||
"references_priv" => Some(self.references_priv),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DatabasePrivilegeRow {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
for field in DATABASE_PRIVILEGE_FIELDS.into_iter().skip(2) {
|
||||
if self.get_privilege_by_name(field).unwrap() {
|
||||
f.write_str(db_priv_field_human_readable_name(field).as_str())?;
|
||||
f.write_str(": Y\n")?;
|
||||
} else {
|
||||
f.write_str(db_priv_field_human_readable_name(field).as_str())?;
|
||||
f.write_str(": N\n")?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a database privilege field name to a human-readable name.
|
||||
pub fn db_priv_field_human_readable_name(name: &str) -> String {
|
||||
match name {
|
||||
"Db" => "Database".to_owned(),
|
||||
"User" => "User".to_owned(),
|
||||
"select_priv" => "Select".to_owned(),
|
||||
"insert_priv" => "Insert".to_owned(),
|
||||
"update_priv" => "Update".to_owned(),
|
||||
"delete_priv" => "Delete".to_owned(),
|
||||
"create_priv" => "Create".to_owned(),
|
||||
"drop_priv" => "Drop".to_owned(),
|
||||
"alter_priv" => "Alter".to_owned(),
|
||||
"index_priv" => "Index".to_owned(),
|
||||
"create_tmp_table_priv" => "Temp".to_owned(),
|
||||
"lock_tables_priv" => "Lock".to_owned(),
|
||||
"references_priv" => "References".to_owned(),
|
||||
_ => format!("Unknown({})", name),
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a database privilege field name to a single-character name.
|
||||
/// (the characters from the cli privilege editor)
|
||||
pub fn db_priv_field_single_character_name(name: &str) -> &str {
|
||||
match name {
|
||||
"select_priv" => "s",
|
||||
"insert_priv" => "i",
|
||||
"update_priv" => "u",
|
||||
"delete_priv" => "d",
|
||||
"create_priv" => "c",
|
||||
"drop_priv" => "D",
|
||||
"alter_priv" => "a",
|
||||
"index_priv" => "I",
|
||||
"create_tmp_table_priv" => "t",
|
||||
"lock_tables_priv" => "l",
|
||||
"references_priv" => "r",
|
||||
_ => "?",
|
||||
}
|
||||
}
|
||||
332
src/core/database_privileges/cli.rs
Normal file
332
src/core/database_privileges/cli.rs
Normal file
@@ -0,0 +1,332 @@
|
||||
//! This module contains serialization and deserialization logic for
|
||||
//! database privileges related CLI commands.
|
||||
|
||||
use super::diff::{DatabasePrivilegeChange, DatabasePrivilegeRowDiff};
|
||||
use crate::core::types::{MySQLDatabase, MySQLUser};
|
||||
|
||||
/// 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)]
|
||||
pub enum DatabasePrivilegeEditEntryType {
|
||||
Add,
|
||||
Set,
|
||||
Remove,
|
||||
}
|
||||
|
||||
/// This struct represents a single CLI argument for editing database privileges.
|
||||
///
|
||||
/// This is typically parsed from a string looking like:
|
||||
///
|
||||
/// `[database_name:]username:[+|-]privileges`
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DatabasePrivilegeEditEntry {
|
||||
pub database: Option<MySQLDatabase>,
|
||||
pub user: MySQLUser,
|
||||
pub type_: DatabasePrivilegeEditEntryType,
|
||||
pub privileges: Vec<String>,
|
||||
}
|
||||
|
||||
impl DatabasePrivilegeEditEntry {
|
||||
/// Parses a privilege edit entry from a string.
|
||||
///
|
||||
/// The expected format is:
|
||||
///
|
||||
/// `[database_name:]username:[+|-]privileges`
|
||||
///
|
||||
/// where:
|
||||
/// - database_name is optional, if omitted the entry applies to all databases
|
||||
/// - 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> {
|
||||
let parts: Vec<&str> = arg.split(':').collect();
|
||||
if parts.len() < 2 || 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])
|
||||
};
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
Ok(DatabasePrivilegeEditEntry {
|
||||
database: database.map(MySQLDatabase::from),
|
||||
user: MySQLUser::from(user),
|
||||
type_: edit_type,
|
||||
privileges,
|
||||
})
|
||||
}
|
||||
|
||||
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."
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut diff;
|
||||
match self.type_ {
|
||||
DatabasePrivilegeEditEntryType::Set => {
|
||||
diff = DatabasePrivilegeRowDiff {
|
||||
db: database,
|
||||
user: self.user.clone(),
|
||||
select_priv: Some(DatabasePrivilegeChange::YesToNo),
|
||||
insert_priv: Some(DatabasePrivilegeChange::YesToNo),
|
||||
update_priv: Some(DatabasePrivilegeChange::YesToNo),
|
||||
delete_priv: Some(DatabasePrivilegeChange::YesToNo),
|
||||
create_priv: Some(DatabasePrivilegeChange::YesToNo),
|
||||
drop_priv: Some(DatabasePrivilegeChange::YesToNo),
|
||||
alter_priv: Some(DatabasePrivilegeChange::YesToNo),
|
||||
index_priv: Some(DatabasePrivilegeChange::YesToNo),
|
||||
create_tmp_table_priv: Some(DatabasePrivilegeChange::YesToNo),
|
||||
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" => {
|
||||
diff.select_priv = Some(DatabasePrivilegeChange::NoToYes);
|
||||
diff.insert_priv = Some(DatabasePrivilegeChange::NoToYes);
|
||||
diff.update_priv = Some(DatabasePrivilegeChange::NoToYes);
|
||||
diff.delete_priv = Some(DatabasePrivilegeChange::NoToYes);
|
||||
diff.create_priv = Some(DatabasePrivilegeChange::NoToYes);
|
||||
diff.drop_priv = Some(DatabasePrivilegeChange::NoToYes);
|
||||
diff.alter_priv = Some(DatabasePrivilegeChange::NoToYes);
|
||||
diff.index_priv = Some(DatabasePrivilegeChange::NoToYes);
|
||||
diff.create_tmp_table_priv = Some(DatabasePrivilegeChange::NoToYes);
|
||||
diff.lock_tables_priv = Some(DatabasePrivilegeChange::NoToYes);
|
||||
diff.references_priv = Some(DatabasePrivilegeChange::NoToYes);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
DatabasePrivilegeEditEntryType::Add | DatabasePrivilegeEditEntryType::Remove => {
|
||||
diff = DatabasePrivilegeRowDiff {
|
||||
db: database,
|
||||
user: self.user.clone(),
|
||||
select_priv: None,
|
||||
insert_priv: None,
|
||||
update_priv: None,
|
||||
delete_priv: None,
|
||||
create_priv: None,
|
||||
drop_priv: None,
|
||||
alter_priv: None,
|
||||
index_priv: None,
|
||||
create_tmp_table_priv: None,
|
||||
lock_tables_priv: None,
|
||||
references_priv: None,
|
||||
};
|
||||
let value = match self.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" => {
|
||||
diff.select_priv = Some(value);
|
||||
diff.insert_priv = Some(value);
|
||||
diff.update_priv = Some(value);
|
||||
diff.delete_priv = Some(value);
|
||||
diff.create_priv = Some(value);
|
||||
diff.drop_priv = Some(value);
|
||||
diff.alter_priv = Some(value);
|
||||
diff.index_priv = Some(value);
|
||||
diff.create_tmp_table_priv = Some(value);
|
||||
diff.lock_tables_priv = Some(value);
|
||||
diff.references_priv = Some(value);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(diff)
|
||||
}
|
||||
}
|
||||
|
||||
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.user)?;
|
||||
match self.type_ {
|
||||
DatabasePrivilegeEditEntryType::Add => write!(f, "+")?,
|
||||
DatabasePrivilegeEditEntryType::Set => {}
|
||||
DatabasePrivilegeEditEntryType::Remove => write!(f, "-")?,
|
||||
}
|
||||
for priv_char in &self.privileges {
|
||||
write!(f, "{}", priv_char)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_cli_arg_parse_set_db_user_all() {
|
||||
let result = DatabasePrivilegeEditEntry::parse_from_str("db:user:A");
|
||||
assert_eq!(
|
||||
result.ok(),
|
||||
Some(DatabasePrivilegeEditEntry {
|
||||
database: Some("db".into()),
|
||||
user: "user".into(),
|
||||
type_: DatabasePrivilegeEditEntryType::Set,
|
||||
privileges: vec!["A".into()],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_arg_parse_set_db_user_none() {
|
||||
let result = DatabasePrivilegeEditEntry::parse_from_str("db:user:");
|
||||
assert_eq!(
|
||||
result.ok(),
|
||||
Some(DatabasePrivilegeEditEntry {
|
||||
database: Some("db".into()),
|
||||
user: "user".into(),
|
||||
type_: DatabasePrivilegeEditEntryType::Set,
|
||||
privileges: vec![],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_arg_parse_set_db_user_misc() {
|
||||
let result = DatabasePrivilegeEditEntry::parse_from_str("db:user:siud");
|
||||
assert_eq!(
|
||||
result.ok(),
|
||||
Some(DatabasePrivilegeEditEntry {
|
||||
database: Some("db".into()),
|
||||
user: "user".into(),
|
||||
type_: DatabasePrivilegeEditEntryType::Set,
|
||||
privileges: vec!["s".into(), "i".into(), "u".into(), "d".into()],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[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");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_arg_parse_set_user_empty_string() {
|
||||
let result = DatabasePrivilegeEditEntry::parse_from_str("::");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_arg_parse_set_db_user_empty_string() {
|
||||
let result = DatabasePrivilegeEditEntry::parse_from_str("db::");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_arg_parse_add_db_user_misc() {
|
||||
let result = DatabasePrivilegeEditEntry::parse_from_str("db:user:+siud");
|
||||
assert_eq!(
|
||||
result.ok(),
|
||||
Some(DatabasePrivilegeEditEntry {
|
||||
database: Some("db".into()),
|
||||
user: "user".into(),
|
||||
type_: DatabasePrivilegeEditEntryType::Add,
|
||||
privileges: vec!["s".into(), "i".into(), "u".into(), "d".into()],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_arg_parse_remove_db_user_misc() {
|
||||
let result = DatabasePrivilegeEditEntry::parse_from_str("db:user:-siud");
|
||||
assert_eq!(
|
||||
result.ok(),
|
||||
Some(DatabasePrivilegeEditEntry {
|
||||
database: Some("db".into()),
|
||||
user: "user".into(),
|
||||
type_: DatabasePrivilegeEditEntryType::Remove,
|
||||
privileges: vec!["s".into(), "i".into(), "u".into(), "d".into()],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
686
src/core/database_privileges/diff.rs
Normal file
686
src/core/database_privileges/diff.rs
Normal file
@@ -0,0 +1,686 @@
|
||||
//! This module contains datastructures and logic for comparing database privileges,
|
||||
//! generating, validating and reducing diffs between two sets of database privileges.
|
||||
|
||||
use super::base::{DatabasePrivilegeRow, db_priv_field_human_readable_name};
|
||||
use crate::core::types::{MySQLDatabase, MySQLUser};
|
||||
use prettytable::Table;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::{BTreeSet, HashMap, hash_map::Entry},
|
||||
fmt,
|
||||
};
|
||||
|
||||
/// This enum represents a change for a single privilege.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
|
||||
pub enum DatabasePrivilegeChange {
|
||||
YesToNo,
|
||||
NoToYes,
|
||||
}
|
||||
|
||||
impl DatabasePrivilegeChange {
|
||||
pub fn new(p1: bool, p2: bool) -> Option<DatabasePrivilegeChange> {
|
||||
match (p1, p2) {
|
||||
(true, false) => Some(DatabasePrivilegeChange::YesToNo),
|
||||
(false, true) => Some(DatabasePrivilegeChange::NoToYes),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This struct encapsulates the before and after states of the
|
||||
/// access privileges for a single user on a single database.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord, Default)]
|
||||
pub struct DatabasePrivilegeRowDiff {
|
||||
// TODO: don't store the db and user here, let the type be stored in a mapping
|
||||
pub db: MySQLDatabase,
|
||||
pub user: MySQLUser,
|
||||
pub select_priv: Option<DatabasePrivilegeChange>,
|
||||
pub insert_priv: Option<DatabasePrivilegeChange>,
|
||||
pub update_priv: Option<DatabasePrivilegeChange>,
|
||||
pub delete_priv: Option<DatabasePrivilegeChange>,
|
||||
pub create_priv: Option<DatabasePrivilegeChange>,
|
||||
pub drop_priv: Option<DatabasePrivilegeChange>,
|
||||
pub alter_priv: Option<DatabasePrivilegeChange>,
|
||||
pub index_priv: Option<DatabasePrivilegeChange>,
|
||||
pub create_tmp_table_priv: Option<DatabasePrivilegeChange>,
|
||||
pub lock_tables_priv: Option<DatabasePrivilegeChange>,
|
||||
pub references_priv: Option<DatabasePrivilegeChange>,
|
||||
}
|
||||
|
||||
impl DatabasePrivilegeRowDiff {
|
||||
/// Calculates the difference between two [`DatabasePrivilegeRow`] instances.
|
||||
pub fn from_rows(
|
||||
row1: &DatabasePrivilegeRow,
|
||||
row2: &DatabasePrivilegeRow,
|
||||
) -> DatabasePrivilegeRowDiff {
|
||||
debug_assert!(row1.db == row2.db && row1.user == row2.user);
|
||||
|
||||
DatabasePrivilegeRowDiff {
|
||||
db: row1.db.to_owned(),
|
||||
user: row1.user.to_owned(),
|
||||
select_priv: DatabasePrivilegeChange::new(row1.select_priv, row2.select_priv),
|
||||
insert_priv: DatabasePrivilegeChange::new(row1.insert_priv, row2.insert_priv),
|
||||
update_priv: DatabasePrivilegeChange::new(row1.update_priv, row2.update_priv),
|
||||
delete_priv: DatabasePrivilegeChange::new(row1.delete_priv, row2.delete_priv),
|
||||
create_priv: DatabasePrivilegeChange::new(row1.create_priv, row2.create_priv),
|
||||
drop_priv: DatabasePrivilegeChange::new(row1.drop_priv, row2.drop_priv),
|
||||
alter_priv: DatabasePrivilegeChange::new(row1.alter_priv, row2.alter_priv),
|
||||
index_priv: DatabasePrivilegeChange::new(row1.index_priv, row2.index_priv),
|
||||
create_tmp_table_priv: DatabasePrivilegeChange::new(
|
||||
row1.create_tmp_table_priv,
|
||||
row2.create_tmp_table_priv,
|
||||
),
|
||||
lock_tables_priv: DatabasePrivilegeChange::new(
|
||||
row1.lock_tables_priv,
|
||||
row2.lock_tables_priv,
|
||||
),
|
||||
references_priv: DatabasePrivilegeChange::new(
|
||||
row1.references_priv,
|
||||
row2.references_priv,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if there are no changes in this diff.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.select_priv.is_none()
|
||||
&& self.insert_priv.is_none()
|
||||
&& self.update_priv.is_none()
|
||||
&& self.delete_priv.is_none()
|
||||
&& self.create_priv.is_none()
|
||||
&& self.drop_priv.is_none()
|
||||
&& self.alter_priv.is_none()
|
||||
&& self.index_priv.is_none()
|
||||
&& self.create_tmp_table_priv.is_none()
|
||||
&& self.lock_tables_priv.is_none()
|
||||
&& self.references_priv.is_none()
|
||||
}
|
||||
|
||||
/// Retrieves the privilege change for a given privilege name.
|
||||
pub fn get_privilege_change_by_name(
|
||||
&self,
|
||||
privilege_name: &str,
|
||||
) -> anyhow::Result<Option<DatabasePrivilegeChange>> {
|
||||
match privilege_name {
|
||||
"select_priv" => Ok(self.select_priv),
|
||||
"insert_priv" => Ok(self.insert_priv),
|
||||
"update_priv" => Ok(self.update_priv),
|
||||
"delete_priv" => Ok(self.delete_priv),
|
||||
"create_priv" => Ok(self.create_priv),
|
||||
"drop_priv" => Ok(self.drop_priv),
|
||||
"alter_priv" => Ok(self.alter_priv),
|
||||
"index_priv" => Ok(self.index_priv),
|
||||
"create_tmp_table_priv" => Ok(self.create_tmp_table_priv),
|
||||
"lock_tables_priv" => Ok(self.lock_tables_priv),
|
||||
"references_priv" => Ok(self.references_priv),
|
||||
_ => anyhow::bail!("Unknown privilege name: {}", privilege_name),
|
||||
}
|
||||
}
|
||||
|
||||
/// Merges another diff into this one, combining them in a sequential manner.
|
||||
fn mappend(&mut self, other: &DatabasePrivilegeRowDiff) {
|
||||
debug_assert!(self.db == other.db && self.user == other.user);
|
||||
|
||||
if other.select_priv.is_some() {
|
||||
self.select_priv = other.select_priv;
|
||||
}
|
||||
if other.insert_priv.is_some() {
|
||||
self.insert_priv = other.insert_priv;
|
||||
}
|
||||
if other.update_priv.is_some() {
|
||||
self.update_priv = other.update_priv;
|
||||
}
|
||||
if other.delete_priv.is_some() {
|
||||
self.delete_priv = other.delete_priv;
|
||||
}
|
||||
if other.create_priv.is_some() {
|
||||
self.create_priv = other.create_priv;
|
||||
}
|
||||
if other.drop_priv.is_some() {
|
||||
self.drop_priv = other.drop_priv;
|
||||
}
|
||||
if other.alter_priv.is_some() {
|
||||
self.alter_priv = other.alter_priv;
|
||||
}
|
||||
if other.index_priv.is_some() {
|
||||
self.index_priv = other.index_priv;
|
||||
}
|
||||
if other.create_tmp_table_priv.is_some() {
|
||||
self.create_tmp_table_priv = other.create_tmp_table_priv;
|
||||
}
|
||||
if other.lock_tables_priv.is_some() {
|
||||
self.lock_tables_priv = other.lock_tables_priv;
|
||||
}
|
||||
if other.references_priv.is_some() {
|
||||
self.references_priv = other.references_priv;
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes any no-op changes from the diff, based on the original privilege row.
|
||||
fn remove_noops(&mut self, from: &DatabasePrivilegeRow) {
|
||||
fn new_value(
|
||||
change: &Option<DatabasePrivilegeChange>,
|
||||
from_value: bool,
|
||||
) -> Option<DatabasePrivilegeChange> {
|
||||
change.as_ref().and_then(|c| match c {
|
||||
DatabasePrivilegeChange::YesToNo if from_value => {
|
||||
Some(DatabasePrivilegeChange::YesToNo)
|
||||
}
|
||||
DatabasePrivilegeChange::NoToYes if !from_value => {
|
||||
Some(DatabasePrivilegeChange::NoToYes)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
self.select_priv = new_value(&self.select_priv, from.select_priv);
|
||||
self.insert_priv = new_value(&self.insert_priv, from.insert_priv);
|
||||
self.update_priv = new_value(&self.update_priv, from.update_priv);
|
||||
self.delete_priv = new_value(&self.delete_priv, from.delete_priv);
|
||||
self.create_priv = new_value(&self.create_priv, from.create_priv);
|
||||
self.drop_priv = new_value(&self.drop_priv, from.drop_priv);
|
||||
self.alter_priv = new_value(&self.alter_priv, from.alter_priv);
|
||||
self.index_priv = new_value(&self.index_priv, from.index_priv);
|
||||
self.create_tmp_table_priv =
|
||||
new_value(&self.create_tmp_table_priv, from.create_tmp_table_priv);
|
||||
self.lock_tables_priv = new_value(&self.lock_tables_priv, from.lock_tables_priv);
|
||||
self.references_priv = new_value(&self.references_priv, from.references_priv);
|
||||
}
|
||||
|
||||
fn apply(&self, base: &mut DatabasePrivilegeRow) {
|
||||
fn apply_change(change: &Option<DatabasePrivilegeChange>, target: &mut bool) {
|
||||
match change {
|
||||
Some(DatabasePrivilegeChange::YesToNo) => *target = false,
|
||||
Some(DatabasePrivilegeChange::NoToYes) => *target = true,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
apply_change(&self.select_priv, &mut base.select_priv);
|
||||
apply_change(&self.insert_priv, &mut base.insert_priv);
|
||||
apply_change(&self.update_priv, &mut base.update_priv);
|
||||
apply_change(&self.delete_priv, &mut base.delete_priv);
|
||||
apply_change(&self.create_priv, &mut base.create_priv);
|
||||
apply_change(&self.drop_priv, &mut base.drop_priv);
|
||||
apply_change(&self.alter_priv, &mut base.alter_priv);
|
||||
apply_change(&self.index_priv, &mut base.index_priv);
|
||||
apply_change(&self.create_tmp_table_priv, &mut base.create_tmp_table_priv);
|
||||
apply_change(&self.lock_tables_priv, &mut base.lock_tables_priv);
|
||||
apply_change(&self.references_priv, &mut base.references_priv);
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DatabasePrivilegeRowDiff {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fn format_change(
|
||||
f: &mut fmt::Formatter<'_>,
|
||||
change: &Option<DatabasePrivilegeChange>,
|
||||
field_name: &str,
|
||||
) -> fmt::Result {
|
||||
if let Some(change) = change {
|
||||
match change {
|
||||
DatabasePrivilegeChange::YesToNo => f.write_fmt(format_args!(
|
||||
"{}: Y -> N\n",
|
||||
db_priv_field_human_readable_name(field_name)
|
||||
)),
|
||||
DatabasePrivilegeChange::NoToYes => f.write_fmt(format_args!(
|
||||
"{}: N -> Y\n",
|
||||
db_priv_field_human_readable_name(field_name)
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
format_change(f, &self.select_priv, "select_priv")?;
|
||||
format_change(f, &self.insert_priv, "insert_priv")?;
|
||||
format_change(f, &self.update_priv, "update_priv")?;
|
||||
format_change(f, &self.delete_priv, "delete_priv")?;
|
||||
format_change(f, &self.create_priv, "create_priv")?;
|
||||
format_change(f, &self.drop_priv, "drop_priv")?;
|
||||
format_change(f, &self.alter_priv, "alter_priv")?;
|
||||
format_change(f, &self.index_priv, "index_priv")?;
|
||||
format_change(f, &self.create_tmp_table_priv, "create_tmp_table_priv")?;
|
||||
format_change(f, &self.lock_tables_priv, "lock_tables_priv")?;
|
||||
format_change(f, &self.references_priv, "references_priv")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// This enum encapsulates whether a [`DatabasePrivilegeRow`] was introduced, modified or deleted.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
|
||||
pub enum DatabasePrivilegesDiff {
|
||||
New(DatabasePrivilegeRow),
|
||||
Modified(DatabasePrivilegeRowDiff),
|
||||
Deleted(DatabasePrivilegeRow),
|
||||
Noop { db: MySQLDatabase, user: MySQLUser },
|
||||
}
|
||||
|
||||
impl DatabasePrivilegesDiff {
|
||||
pub fn get_database_name(&self) -> &MySQLDatabase {
|
||||
match self {
|
||||
DatabasePrivilegesDiff::New(p) => &p.db,
|
||||
DatabasePrivilegesDiff::Modified(p) => &p.db,
|
||||
DatabasePrivilegesDiff::Deleted(p) => &p.db,
|
||||
DatabasePrivilegesDiff::Noop { db, .. } => db,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_user_name(&self) -> &MySQLUser {
|
||||
match self {
|
||||
DatabasePrivilegesDiff::New(p) => &p.user,
|
||||
DatabasePrivilegesDiff::Modified(p) => &p.user,
|
||||
DatabasePrivilegesDiff::Deleted(p) => &p.user,
|
||||
DatabasePrivilegesDiff::Noop { user, .. } => user,
|
||||
}
|
||||
}
|
||||
|
||||
/// Merges another [`DatabasePrivilegesDiff`] into this one, combining them in a sequential manner.
|
||||
/// For example, if this diff represents a creation and the other represents a modification,
|
||||
/// the result will be a creation with the modifications applied.
|
||||
pub fn mappend(&mut self, other: &DatabasePrivilegesDiff) -> anyhow::Result<()> {
|
||||
debug_assert!(
|
||||
self.get_database_name() == other.get_database_name()
|
||||
&& self.get_user_name() == other.get_user_name()
|
||||
);
|
||||
|
||||
if matches!(self, DatabasePrivilegesDiff::Deleted(_))
|
||||
&& (matches!(other, DatabasePrivilegesDiff::Modified(_)))
|
||||
{
|
||||
anyhow::bail!("Cannot modify a deleted database privilege row");
|
||||
}
|
||||
|
||||
if matches!(self, DatabasePrivilegesDiff::New(_))
|
||||
&& (matches!(other, DatabasePrivilegesDiff::New(_)))
|
||||
{
|
||||
anyhow::bail!("Cannot create an already existing database privilege row");
|
||||
}
|
||||
|
||||
if matches!(self, DatabasePrivilegesDiff::Modified(_))
|
||||
&& (matches!(other, DatabasePrivilegesDiff::New(_)))
|
||||
{
|
||||
anyhow::bail!("Cannot create an already existing database privilege row");
|
||||
}
|
||||
|
||||
if matches!(self, DatabasePrivilegesDiff::Noop { .. }) {
|
||||
*self = other.to_owned();
|
||||
return Ok(());
|
||||
} else if matches!(other, DatabasePrivilegesDiff::Noop { .. }) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match (&self, other) {
|
||||
(DatabasePrivilegesDiff::New(_), DatabasePrivilegesDiff::Modified(modified)) => {
|
||||
let inner_row = match self {
|
||||
DatabasePrivilegesDiff::New(r) => r,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
modified.apply(inner_row);
|
||||
}
|
||||
(DatabasePrivilegesDiff::Modified(_), DatabasePrivilegesDiff::Modified(modified)) => {
|
||||
let inner_diff = match self {
|
||||
DatabasePrivilegesDiff::Modified(r) => r,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
inner_diff.mappend(modified);
|
||||
|
||||
if inner_diff.is_empty() {
|
||||
let db = inner_diff.db.to_owned();
|
||||
let user = inner_diff.user.to_owned();
|
||||
*self = DatabasePrivilegesDiff::Noop { db, user };
|
||||
}
|
||||
}
|
||||
(DatabasePrivilegesDiff::Modified(_), DatabasePrivilegesDiff::Deleted(deleted)) => {
|
||||
*self = DatabasePrivilegesDiff::Deleted(deleted.to_owned());
|
||||
}
|
||||
(DatabasePrivilegesDiff::New(_), DatabasePrivilegesDiff::Deleted(_)) => {
|
||||
let db = self.get_database_name().to_owned();
|
||||
let user = self.get_user_name().to_owned();
|
||||
*self = DatabasePrivilegesDiff::Noop { db, user };
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub type DatabasePrivilegeState<'a> = &'a [DatabasePrivilegeRow];
|
||||
|
||||
/// This function calculates the differences between two sets of database privileges.
|
||||
/// It returns a set of [`DatabasePrivilegesDiff`] that can be used to display or
|
||||
/// apply a set of privilege modifications to the database.
|
||||
pub fn diff_privileges(
|
||||
from: DatabasePrivilegeState<'_>,
|
||||
to: &[DatabasePrivilegeRow],
|
||||
) -> BTreeSet<DatabasePrivilegesDiff> {
|
||||
let from_lookup_table: HashMap<(MySQLDatabase, MySQLUser), DatabasePrivilegeRow> =
|
||||
HashMap::from_iter(
|
||||
from.iter()
|
||||
.cloned()
|
||||
.map(|p| ((p.db.to_owned(), p.user.to_owned()), p)),
|
||||
);
|
||||
|
||||
let to_lookup_table: HashMap<(MySQLDatabase, MySQLUser), DatabasePrivilegeRow> =
|
||||
HashMap::from_iter(
|
||||
to.iter()
|
||||
.cloned()
|
||||
.map(|p| ((p.db.to_owned(), p.user.to_owned()), p)),
|
||||
);
|
||||
|
||||
let mut result = BTreeSet::new();
|
||||
|
||||
for p in to {
|
||||
if let Some(old_p) = from_lookup_table.get(&(p.db.to_owned(), p.user.to_owned())) {
|
||||
let diff = DatabasePrivilegeRowDiff::from_rows(old_p, p);
|
||||
if !diff.is_empty() {
|
||||
result.insert(DatabasePrivilegesDiff::Modified(diff));
|
||||
}
|
||||
} else {
|
||||
result.insert(DatabasePrivilegesDiff::New(p.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
for p in from {
|
||||
if !to_lookup_table.contains_key(&(p.db.to_owned(), p.user.to_owned())) {
|
||||
result.insert(DatabasePrivilegesDiff::Deleted(p.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Converts a set of [`DatabasePrivilegeRowDiff`] into a set of [`DatabasePrivilegesDiff`],
|
||||
/// representing either creating new privilege rows, or modifying the existing ones.
|
||||
///
|
||||
/// This is particularly useful for processing CLI arguments.
|
||||
pub fn create_or_modify_privilege_rows(
|
||||
from: DatabasePrivilegeState<'_>,
|
||||
to: &BTreeSet<DatabasePrivilegeRowDiff>,
|
||||
) -> anyhow::Result<BTreeSet<DatabasePrivilegesDiff>> {
|
||||
let from_lookup_table: HashMap<(MySQLDatabase, MySQLUser), DatabasePrivilegeRow> =
|
||||
HashMap::from_iter(
|
||||
from.iter()
|
||||
.cloned()
|
||||
.map(|p| ((p.db.to_owned(), p.user.to_owned()), p)),
|
||||
);
|
||||
|
||||
let mut result = BTreeSet::new();
|
||||
|
||||
for diff in to {
|
||||
if let Some(old_p) = from_lookup_table.get(&(diff.db.to_owned(), diff.user.to_owned())) {
|
||||
let mut modified_diff = diff.to_owned();
|
||||
modified_diff.remove_noops(old_p);
|
||||
if !modified_diff.is_empty() {
|
||||
result.insert(DatabasePrivilegesDiff::Modified(modified_diff));
|
||||
}
|
||||
} else {
|
||||
let mut new_row = DatabasePrivilegeRow {
|
||||
db: diff.db.to_owned(),
|
||||
user: diff.user.to_owned(),
|
||||
select_priv: false,
|
||||
insert_priv: false,
|
||||
update_priv: false,
|
||||
delete_priv: false,
|
||||
create_priv: false,
|
||||
drop_priv: false,
|
||||
alter_priv: false,
|
||||
index_priv: false,
|
||||
create_tmp_table_priv: false,
|
||||
lock_tables_priv: false,
|
||||
references_priv: false,
|
||||
};
|
||||
diff.apply(&mut new_row);
|
||||
result.insert(DatabasePrivilegesDiff::New(new_row));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Reduces a set of [`DatabasePrivilegesDiff`] by removing any modifications that would be no-ops.
|
||||
/// For example, if a privilege is changed from Yes to No, but it was already No, that change
|
||||
/// is removed from the diff.
|
||||
///
|
||||
/// The `from` parameter is used to determine the current state of the privileges.
|
||||
/// The `to` parameter is the set of diffs to be reduced.
|
||||
pub fn reduce_privilege_diffs(
|
||||
from: DatabasePrivilegeState<'_>,
|
||||
to: BTreeSet<DatabasePrivilegesDiff>,
|
||||
) -> anyhow::Result<BTreeSet<DatabasePrivilegesDiff>> {
|
||||
let from_lookup_table: HashMap<(MySQLDatabase, MySQLUser), DatabasePrivilegeRow> =
|
||||
HashMap::from_iter(
|
||||
from.iter()
|
||||
.cloned()
|
||||
.map(|p| ((p.db.to_owned(), p.user.to_owned()), p)),
|
||||
);
|
||||
|
||||
let mut result: HashMap<(MySQLDatabase, MySQLUser), DatabasePrivilegesDiff> = from_lookup_table
|
||||
.iter()
|
||||
.map(|((db, user), _)| {
|
||||
(
|
||||
(db.to_owned(), user.to_owned()),
|
||||
DatabasePrivilegesDiff::Noop {
|
||||
db: db.to_owned(),
|
||||
user: user.to_owned(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for diff in to {
|
||||
let entry = result.entry((
|
||||
diff.get_database_name().to_owned(),
|
||||
diff.get_user_name().to_owned(),
|
||||
));
|
||||
match entry {
|
||||
Entry::Occupied(mut occupied_entry) => {
|
||||
let existing_diff = occupied_entry.get_mut();
|
||||
existing_diff.mappend(&diff)?;
|
||||
}
|
||||
Entry::Vacant(vacant_entry) => {
|
||||
vacant_entry.insert(diff.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (key, diff) in result.iter_mut() {
|
||||
if let Some(from_row) = from_lookup_table.get(key)
|
||||
&& let DatabasePrivilegesDiff::Modified(modified_diff) = diff
|
||||
{
|
||||
modified_diff.remove_noops(from_row);
|
||||
if modified_diff.is_empty() {
|
||||
let db = modified_diff.db.to_owned();
|
||||
let user = modified_diff.user.to_owned();
|
||||
*diff = DatabasePrivilegesDiff::Noop { db, user };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result
|
||||
.into_values()
|
||||
.filter(|diff| !matches!(diff, DatabasePrivilegesDiff::Noop { .. }))
|
||||
.collect::<BTreeSet<DatabasePrivilegesDiff>>())
|
||||
}
|
||||
|
||||
/// Renders a set of [`DatabasePrivilegesDiff`] into a human-readable formatted table.
|
||||
pub fn display_privilege_diffs(diffs: &BTreeSet<DatabasePrivilegesDiff>) -> String {
|
||||
let mut table = Table::new();
|
||||
table.set_titles(row!["Database", "User", "Privilege diff",]);
|
||||
for row in diffs {
|
||||
match row {
|
||||
DatabasePrivilegesDiff::New(p) => {
|
||||
table.add_row(row![
|
||||
p.db,
|
||||
p.user,
|
||||
"(Previously unprivileged)\n".to_string() + &p.to_string()
|
||||
]);
|
||||
}
|
||||
DatabasePrivilegesDiff::Modified(p) => {
|
||||
table.add_row(row![p.db, p.user, p.to_string(),]);
|
||||
}
|
||||
DatabasePrivilegesDiff::Deleted(p) => {
|
||||
table.add_row(row![p.db, p.user, "Removed".to_string()]);
|
||||
}
|
||||
DatabasePrivilegesDiff::Noop { db, user } => {
|
||||
table.add_row(row![db, user, "No changes".to_string()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_database_privilege_change_creation() {
|
||||
assert_eq!(
|
||||
DatabasePrivilegeChange::new(true, false),
|
||||
Some(DatabasePrivilegeChange::YesToNo),
|
||||
);
|
||||
assert_eq!(
|
||||
DatabasePrivilegeChange::new(false, true),
|
||||
Some(DatabasePrivilegeChange::NoToYes),
|
||||
);
|
||||
assert_eq!(DatabasePrivilegeChange::new(true, true), None);
|
||||
assert_eq!(DatabasePrivilegeChange::new(false, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_database_privilege_row_diff_from_rows() {
|
||||
let row1 = DatabasePrivilegeRow {
|
||||
db: "db".into(),
|
||||
user: "user".into(),
|
||||
|
||||
select_priv: true,
|
||||
insert_priv: false,
|
||||
update_priv: true,
|
||||
delete_priv: false,
|
||||
|
||||
create_priv: false,
|
||||
drop_priv: false,
|
||||
alter_priv: false,
|
||||
index_priv: false,
|
||||
create_tmp_table_priv: false,
|
||||
lock_tables_priv: false,
|
||||
references_priv: false,
|
||||
};
|
||||
let row2 = DatabasePrivilegeRow {
|
||||
db: "db".into(),
|
||||
user: "user".into(),
|
||||
|
||||
select_priv: true,
|
||||
insert_priv: true,
|
||||
update_priv: false,
|
||||
delete_priv: false,
|
||||
|
||||
create_priv: false,
|
||||
drop_priv: false,
|
||||
alter_priv: false,
|
||||
index_priv: false,
|
||||
create_tmp_table_priv: false,
|
||||
lock_tables_priv: false,
|
||||
references_priv: false,
|
||||
};
|
||||
|
||||
let diff = DatabasePrivilegeRowDiff::from_rows(&row1, &row2);
|
||||
assert_eq!(
|
||||
diff,
|
||||
DatabasePrivilegeRowDiff {
|
||||
db: "db".into(),
|
||||
user: "user".into(),
|
||||
select_priv: None,
|
||||
insert_priv: Some(DatabasePrivilegeChange::NoToYes),
|
||||
update_priv: Some(DatabasePrivilegeChange::YesToNo),
|
||||
delete_priv: None,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_database_privilege_row_diff_is_empty() {
|
||||
let empty_diff = DatabasePrivilegeRowDiff {
|
||||
db: "db".into(),
|
||||
user: "user".into(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(empty_diff.is_empty());
|
||||
|
||||
let non_empty_diff = DatabasePrivilegeRowDiff {
|
||||
db: "db".into(),
|
||||
user: "user".into(),
|
||||
select_priv: Some(DatabasePrivilegeChange::YesToNo),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!non_empty_diff.is_empty());
|
||||
}
|
||||
|
||||
// TODO: test in isolation:
|
||||
// DatabasePrivilegeRowDiff::mappend
|
||||
// DatabasePrivilegeRowDiff::remove_noops
|
||||
// DatabasePrivilegeRowDiff::apply
|
||||
//
|
||||
// DatabasePrivilegesDiff::mappend
|
||||
//
|
||||
// reduce_privilege_diffs
|
||||
|
||||
#[test]
|
||||
fn test_diff_privileges() {
|
||||
let row_to_be_modified = DatabasePrivilegeRow {
|
||||
db: "db".into(),
|
||||
user: "user".into(),
|
||||
select_priv: true,
|
||||
insert_priv: true,
|
||||
update_priv: true,
|
||||
delete_priv: true,
|
||||
create_priv: true,
|
||||
drop_priv: true,
|
||||
alter_priv: true,
|
||||
index_priv: false,
|
||||
create_tmp_table_priv: true,
|
||||
lock_tables_priv: true,
|
||||
references_priv: false,
|
||||
};
|
||||
|
||||
let mut row_to_be_deleted = row_to_be_modified.to_owned();
|
||||
"user2".clone_into(&mut row_to_be_deleted.user);
|
||||
|
||||
let from = vec![row_to_be_modified.to_owned(), row_to_be_deleted.to_owned()];
|
||||
|
||||
let mut modified_row = row_to_be_modified.to_owned();
|
||||
modified_row.select_priv = false;
|
||||
modified_row.insert_priv = false;
|
||||
modified_row.index_priv = true;
|
||||
|
||||
let mut new_row = row_to_be_modified.to_owned();
|
||||
"user3".clone_into(&mut new_row.user);
|
||||
|
||||
let to = vec![modified_row.to_owned(), new_row.to_owned()];
|
||||
|
||||
let diffs = diff_privileges(&from, &to);
|
||||
|
||||
assert_eq!(
|
||||
diffs,
|
||||
BTreeSet::from_iter(vec![
|
||||
DatabasePrivilegesDiff::Deleted(row_to_be_deleted),
|
||||
DatabasePrivilegesDiff::Modified(DatabasePrivilegeRowDiff {
|
||||
db: "db".into(),
|
||||
user: "user".into(),
|
||||
select_priv: Some(DatabasePrivilegeChange::YesToNo),
|
||||
insert_priv: Some(DatabasePrivilegeChange::YesToNo),
|
||||
index_priv: Some(DatabasePrivilegeChange::NoToYes),
|
||||
..Default::default()
|
||||
}),
|
||||
DatabasePrivilegesDiff::New(new_row),
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
366
src/core/database_privileges/editor.rs
Normal file
366
src/core/database_privileges/editor.rs
Normal file
@@ -0,0 +1,366 @@
|
||||
//! This module contains serialization and deserialization logic for
|
||||
//! editing database privileges in a text editor.
|
||||
|
||||
use super::base::{
|
||||
DATABASE_PRIVILEGE_FIELDS, DatabasePrivilegeRow, db_priv_field_human_readable_name,
|
||||
};
|
||||
use crate::core::{
|
||||
common::{rev_yn, yn},
|
||||
types::MySQLDatabase,
|
||||
};
|
||||
use anyhow::{Context, anyhow};
|
||||
use itertools::Itertools;
|
||||
use std::cmp::max;
|
||||
|
||||
/// Generates a single row of the privileges table for the editor.
|
||||
pub fn format_privileges_line_for_editor(
|
||||
privs: &DatabasePrivilegeRow,
|
||||
username_len: usize,
|
||||
database_name_len: usize,
|
||||
) -> String {
|
||||
DATABASE_PRIVILEGE_FIELDS
|
||||
.into_iter()
|
||||
.map(|field| match field {
|
||||
"Db" => format!("{:width$}", privs.db, width = database_name_len),
|
||||
"User" => format!("{:width$}", privs.user, width = username_len),
|
||||
privilege => format!(
|
||||
"{:width$}",
|
||||
yn(privs.get_privilege_by_name(privilege).unwrap()),
|
||||
width = db_priv_field_human_readable_name(privilege).len()
|
||||
),
|
||||
})
|
||||
.join(" ")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
const EDITOR_COMMENT: &str = r#"
|
||||
# Welcome to the privilege editor.
|
||||
# Each line defines what privileges a single user has on a single database.
|
||||
# The first two columns respectively represent the database name and the user, and the remaining columns are the privileges.
|
||||
# If the user should have a certain privilege, write 'Y', otherwise write 'N'.
|
||||
#
|
||||
# Lines starting with '#' are comments and will be ignored.
|
||||
"#;
|
||||
|
||||
/// Generates the content for the privilege editor.
|
||||
///
|
||||
/// The unix user is used in case there are no privileges to edit,
|
||||
/// so that the user can see an example line based on their username.
|
||||
pub fn generate_editor_content_from_privilege_data(
|
||||
privilege_data: &[DatabasePrivilegeRow],
|
||||
unix_user: &str,
|
||||
database_name: Option<&MySQLDatabase>,
|
||||
) -> String {
|
||||
let example_user = format!("{}_user", unix_user);
|
||||
let example_db = database_name
|
||||
.unwrap_or(&format!("{}_db", unix_user).into())
|
||||
.to_string();
|
||||
|
||||
// NOTE: `.max()`` fails when the iterator is empty.
|
||||
// In this case, we know that the only fields in the
|
||||
// editor will be the example user and example db name.
|
||||
// Hence, it's put as the fallback value, despite not really
|
||||
// being a "fallback" in the normal sense.
|
||||
let longest_username = max(
|
||||
privilege_data
|
||||
.iter()
|
||||
.map(|p| p.user.len())
|
||||
.max()
|
||||
.unwrap_or(example_user.len()),
|
||||
"User".len(),
|
||||
);
|
||||
|
||||
let longest_database_name = max(
|
||||
privilege_data
|
||||
.iter()
|
||||
.map(|p| p.db.len())
|
||||
.max()
|
||||
.unwrap_or(example_db.len()),
|
||||
"Database".len(),
|
||||
);
|
||||
|
||||
let mut header: Vec<_> = DATABASE_PRIVILEGE_FIELDS
|
||||
.into_iter()
|
||||
.map(db_priv_field_human_readable_name)
|
||||
.collect();
|
||||
|
||||
// Pad the first two columns with spaces to align the privileges.
|
||||
header[0] = format!("{:width$}", header[0], width = longest_database_name);
|
||||
header[1] = format!("{:width$}", header[1], width = longest_username);
|
||||
|
||||
let example_line = format_privileges_line_for_editor(
|
||||
&DatabasePrivilegeRow {
|
||||
db: example_db.into(),
|
||||
user: example_user.into(),
|
||||
select_priv: true,
|
||||
insert_priv: true,
|
||||
update_priv: true,
|
||||
delete_priv: true,
|
||||
create_priv: false,
|
||||
drop_priv: false,
|
||||
alter_priv: false,
|
||||
index_priv: false,
|
||||
create_tmp_table_priv: false,
|
||||
lock_tables_priv: false,
|
||||
references_priv: false,
|
||||
},
|
||||
longest_username,
|
||||
longest_database_name,
|
||||
);
|
||||
|
||||
format!(
|
||||
"{}\n{}\n{}",
|
||||
EDITOR_COMMENT,
|
||||
header.join(" "),
|
||||
if privilege_data.is_empty() {
|
||||
format!("# {}", example_line)
|
||||
} else {
|
||||
privilege_data
|
||||
.iter()
|
||||
.map(|privs| {
|
||||
format_privileges_line_for_editor(
|
||||
privs,
|
||||
longest_username,
|
||||
longest_database_name,
|
||||
)
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum PrivilegeRowParseResult {
|
||||
PrivilegeRow(DatabasePrivilegeRow),
|
||||
ParserError(anyhow::Error),
|
||||
TooFewFields(usize),
|
||||
TooManyFields(usize),
|
||||
Header,
|
||||
Comment,
|
||||
Empty,
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn parse_privilege_cell_from_editor(yn: &str, name: &str) -> anyhow::Result<bool> {
|
||||
let human_readable_name = db_priv_field_human_readable_name(name);
|
||||
rev_yn(yn)
|
||||
.ok_or_else(|| anyhow!("Expected Y or N, found {}", yn))
|
||||
.context(format!(
|
||||
"Could not parse '{}' privilege",
|
||||
human_readable_name
|
||||
))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn editor_row_is_header(row: &str) -> bool {
|
||||
row.split_ascii_whitespace()
|
||||
.zip(DATABASE_PRIVILEGE_FIELDS.iter())
|
||||
.map(|(field, priv_name)| (field, db_priv_field_human_readable_name(priv_name)))
|
||||
.all(|(field, header_field)| field == header_field)
|
||||
}
|
||||
|
||||
/// Parse a single row of the privileges table from the editor.
|
||||
fn parse_privilege_row_from_editor(row: &str) -> PrivilegeRowParseResult {
|
||||
if row.starts_with('#') || row.starts_with("//") {
|
||||
return PrivilegeRowParseResult::Comment;
|
||||
}
|
||||
|
||||
if row.trim().is_empty() {
|
||||
return PrivilegeRowParseResult::Empty;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = row.trim().split_ascii_whitespace().collect();
|
||||
|
||||
match parts.len() {
|
||||
n if (n < DATABASE_PRIVILEGE_FIELDS.len()) => {
|
||||
return PrivilegeRowParseResult::TooFewFields(n);
|
||||
}
|
||||
n if (n > DATABASE_PRIVILEGE_FIELDS.len()) => {
|
||||
return PrivilegeRowParseResult::TooManyFields(n);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if editor_row_is_header(row) {
|
||||
return PrivilegeRowParseResult::Header;
|
||||
}
|
||||
|
||||
let row = DatabasePrivilegeRow {
|
||||
db: (*parts.first().unwrap()).into(),
|
||||
user: (*parts.get(1).unwrap()).into(),
|
||||
select_priv: match parse_privilege_cell_from_editor(
|
||||
parts.get(2).unwrap(),
|
||||
DATABASE_PRIVILEGE_FIELDS[2],
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PrivilegeRowParseResult::ParserError(e),
|
||||
},
|
||||
insert_priv: match parse_privilege_cell_from_editor(
|
||||
parts.get(3).unwrap(),
|
||||
DATABASE_PRIVILEGE_FIELDS[3],
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PrivilegeRowParseResult::ParserError(e),
|
||||
},
|
||||
update_priv: match parse_privilege_cell_from_editor(
|
||||
parts.get(4).unwrap(),
|
||||
DATABASE_PRIVILEGE_FIELDS[4],
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PrivilegeRowParseResult::ParserError(e),
|
||||
},
|
||||
delete_priv: match parse_privilege_cell_from_editor(
|
||||
parts.get(5).unwrap(),
|
||||
DATABASE_PRIVILEGE_FIELDS[5],
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PrivilegeRowParseResult::ParserError(e),
|
||||
},
|
||||
create_priv: match parse_privilege_cell_from_editor(
|
||||
parts.get(6).unwrap(),
|
||||
DATABASE_PRIVILEGE_FIELDS[6],
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PrivilegeRowParseResult::ParserError(e),
|
||||
},
|
||||
drop_priv: match parse_privilege_cell_from_editor(
|
||||
parts.get(7).unwrap(),
|
||||
DATABASE_PRIVILEGE_FIELDS[7],
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PrivilegeRowParseResult::ParserError(e),
|
||||
},
|
||||
alter_priv: match parse_privilege_cell_from_editor(
|
||||
parts.get(8).unwrap(),
|
||||
DATABASE_PRIVILEGE_FIELDS[8],
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PrivilegeRowParseResult::ParserError(e),
|
||||
},
|
||||
index_priv: match parse_privilege_cell_from_editor(
|
||||
parts.get(9).unwrap(),
|
||||
DATABASE_PRIVILEGE_FIELDS[9],
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PrivilegeRowParseResult::ParserError(e),
|
||||
},
|
||||
create_tmp_table_priv: match parse_privilege_cell_from_editor(
|
||||
parts.get(10).unwrap(),
|
||||
DATABASE_PRIVILEGE_FIELDS[10],
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PrivilegeRowParseResult::ParserError(e),
|
||||
},
|
||||
lock_tables_priv: match parse_privilege_cell_from_editor(
|
||||
parts.get(11).unwrap(),
|
||||
DATABASE_PRIVILEGE_FIELDS[11],
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PrivilegeRowParseResult::ParserError(e),
|
||||
},
|
||||
references_priv: match parse_privilege_cell_from_editor(
|
||||
parts.get(12).unwrap(),
|
||||
DATABASE_PRIVILEGE_FIELDS[12],
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PrivilegeRowParseResult::ParserError(e),
|
||||
},
|
||||
};
|
||||
|
||||
PrivilegeRowParseResult::PrivilegeRow(row)
|
||||
}
|
||||
|
||||
pub fn parse_privilege_data_from_editor_content(
|
||||
content: String,
|
||||
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
|
||||
content
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(|line| line.trim())
|
||||
.enumerate()
|
||||
.map(|(i, line)| {
|
||||
let mut header: Vec<_> = DATABASE_PRIVILEGE_FIELDS
|
||||
.into_iter()
|
||||
.map(db_priv_field_human_readable_name)
|
||||
.collect();
|
||||
|
||||
let splitline = line.split_ascii_whitespace().collect::<Vec<&str>>();
|
||||
let dbname = splitline.first().unwrap_or(&"");
|
||||
let username = splitline.get(1).unwrap_or(&"");
|
||||
|
||||
// Pad the first two columns with spaces to align the privileges.
|
||||
header[0] = format!("{:width$}", header[0], width = dbname.len());
|
||||
header[1] = format!("{:width$}", header[1], width = username.len());
|
||||
|
||||
let header: String = header.join(" ");
|
||||
|
||||
match parse_privilege_row_from_editor(line) {
|
||||
PrivilegeRowParseResult::PrivilegeRow(row) => Ok(Some(row)),
|
||||
PrivilegeRowParseResult::ParserError(e) => Err(anyhow!(
|
||||
"Could not parse privilege row from line {i}:\n {header}\n {line}\n {e}",
|
||||
)),
|
||||
|
||||
PrivilegeRowParseResult::TooFewFields(n) => Err(anyhow!(
|
||||
"Too few fields in line {i}:\n {header}\n {line}\n Expected to find {} fields, found {n}",
|
||||
DATABASE_PRIVILEGE_FIELDS.len(),
|
||||
)),
|
||||
PrivilegeRowParseResult::TooManyFields(n) => Err(anyhow!(
|
||||
"Too many fields in line {i}:\n {header}\n {line}\n Expected to find {} fields, found {n}",
|
||||
DATABASE_PRIVILEGE_FIELDS.len(),
|
||||
)),
|
||||
PrivilegeRowParseResult::Header => Ok(None),
|
||||
PrivilegeRowParseResult::Comment => Ok(None),
|
||||
PrivilegeRowParseResult::Empty => Ok(None),
|
||||
}
|
||||
})
|
||||
.filter_map(|result| result.transpose())
|
||||
.collect::<anyhow::Result<Vec<DatabasePrivilegeRow>>>()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ensure_generated_and_parsed_editor_content_is_equal() {
|
||||
let permissions = vec![
|
||||
DatabasePrivilegeRow {
|
||||
db: "db".into(),
|
||||
user: "user".into(),
|
||||
select_priv: true,
|
||||
insert_priv: true,
|
||||
update_priv: true,
|
||||
delete_priv: true,
|
||||
create_priv: true,
|
||||
drop_priv: true,
|
||||
alter_priv: true,
|
||||
index_priv: true,
|
||||
create_tmp_table_priv: true,
|
||||
lock_tables_priv: true,
|
||||
references_priv: true,
|
||||
},
|
||||
DatabasePrivilegeRow {
|
||||
db: "db".into(),
|
||||
user: "user".into(),
|
||||
select_priv: false,
|
||||
insert_priv: false,
|
||||
update_priv: false,
|
||||
delete_priv: false,
|
||||
create_priv: false,
|
||||
drop_priv: false,
|
||||
alter_priv: false,
|
||||
index_priv: false,
|
||||
create_tmp_table_priv: false,
|
||||
lock_tables_priv: false,
|
||||
references_priv: false,
|
||||
},
|
||||
];
|
||||
|
||||
let content = generate_editor_content_from_privilege_data(&permissions, "user", None);
|
||||
|
||||
let parsed_permissions = parse_privilege_data_from_editor_content(content).unwrap();
|
||||
|
||||
assert_eq!(permissions, parsed_permissions);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
pub mod request_response;
|
||||
pub mod server_responses;
|
||||
mod commands;
|
||||
pub mod request_validation;
|
||||
|
||||
pub use request_response::*;
|
||||
pub use server_responses::*;
|
||||
pub use commands::*;
|
||||
|
||||
121
src/core/protocol/commands.rs
Normal file
121
src/core/protocol/commands.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
mod check_authorization;
|
||||
mod complete_database_name;
|
||||
mod complete_user_name;
|
||||
mod create_databases;
|
||||
mod create_users;
|
||||
mod drop_databases;
|
||||
mod drop_users;
|
||||
mod list_all_databases;
|
||||
mod list_all_privileges;
|
||||
mod list_all_users;
|
||||
mod list_databases;
|
||||
mod list_privileges;
|
||||
mod list_users;
|
||||
mod lock_users;
|
||||
mod modify_privileges;
|
||||
mod passwd_user;
|
||||
mod unlock_users;
|
||||
|
||||
pub use check_authorization::*;
|
||||
pub use complete_database_name::*;
|
||||
pub use complete_user_name::*;
|
||||
pub use create_databases::*;
|
||||
pub use create_users::*;
|
||||
pub use drop_databases::*;
|
||||
pub use drop_users::*;
|
||||
pub use list_all_databases::*;
|
||||
pub use list_all_privileges::*;
|
||||
pub use list_all_users::*;
|
||||
pub use list_databases::*;
|
||||
pub use list_privileges::*;
|
||||
pub use list_users::*;
|
||||
pub use lock_users::*;
|
||||
pub use modify_privileges::*;
|
||||
pub use passwd_user::*;
|
||||
pub use unlock_users::*;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio_serde::{Framed as SerdeFramed, formats::Bincode};
|
||||
use tokio_util::codec::{Framed, LengthDelimitedCodec};
|
||||
|
||||
pub type ServerToClientMessageStream = SerdeFramed<
|
||||
Framed<UnixStream, LengthDelimitedCodec>,
|
||||
Request,
|
||||
Response,
|
||||
Bincode<Request, Response>,
|
||||
>;
|
||||
|
||||
pub type ClientToServerMessageStream = SerdeFramed<
|
||||
Framed<UnixStream, LengthDelimitedCodec>,
|
||||
Response,
|
||||
Request,
|
||||
Bincode<Response, Request>,
|
||||
>;
|
||||
|
||||
pub fn create_server_to_client_message_stream(socket: UnixStream) -> ServerToClientMessageStream {
|
||||
let length_delimited = Framed::new(socket, LengthDelimitedCodec::new());
|
||||
tokio_serde::Framed::new(length_delimited, Bincode::default())
|
||||
}
|
||||
|
||||
pub fn create_client_to_server_message_stream(socket: UnixStream) -> ClientToServerMessageStream {
|
||||
let length_delimited = Framed::new(socket, LengthDelimitedCodec::new());
|
||||
tokio_serde::Framed::new(length_delimited, Bincode::default())
|
||||
}
|
||||
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Request {
|
||||
CheckAuthorization(CheckAuthorizationRequest),
|
||||
|
||||
CompleteDatabaseName(CompleteDatabaseNameRequest),
|
||||
CompleteUserName(CompleteUserNameRequest),
|
||||
|
||||
CreateDatabases(CreateDatabasesRequest),
|
||||
DropDatabases(DropDatabasesRequest),
|
||||
ListDatabases(ListDatabasesRequest),
|
||||
ListPrivileges(ListPrivilegesRequest),
|
||||
ModifyPrivileges(ModifyPrivilegesRequest),
|
||||
|
||||
CreateUsers(CreateUsersRequest),
|
||||
DropUsers(DropUsersRequest),
|
||||
PasswdUser(SetUserPasswordRequest),
|
||||
ListUsers(ListUsersRequest),
|
||||
LockUsers(LockUsersRequest),
|
||||
UnlockUsers(UnlockUsersRequest),
|
||||
|
||||
// Commit,
|
||||
Exit,
|
||||
}
|
||||
|
||||
// TODO: include a generic "message" that will display a message to the user?
|
||||
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Response {
|
||||
CheckAuthorization(CheckAuthorizationResponse),
|
||||
|
||||
CompleteDatabaseName(CompleteDatabaseNameResponse),
|
||||
CompleteUserName(CompleteUserNameResponse),
|
||||
|
||||
// Specific data for specific commands
|
||||
CreateDatabases(CreateDatabasesResponse),
|
||||
DropDatabases(DropDatabasesResponse),
|
||||
ListDatabases(ListDatabasesResponse),
|
||||
ListAllDatabases(ListAllDatabasesResponse),
|
||||
ListPrivileges(ListPrivilegesResponse),
|
||||
ListAllPrivileges(ListAllPrivilegesResponse),
|
||||
ModifyPrivileges(ModifyPrivilegesResponse),
|
||||
|
||||
CreateUsers(CreateUsersResponse),
|
||||
DropUsers(DropUsersResponse),
|
||||
SetUserPassword(SetUserPasswordResponse),
|
||||
ListUsers(ListUsersResponse),
|
||||
ListAllUsers(ListAllUsersResponse),
|
||||
LockUsers(LockUsersResponse),
|
||||
UnlockUsers(UnlockUsersResponse),
|
||||
|
||||
// Generic responses
|
||||
Ready,
|
||||
Error(String),
|
||||
}
|
||||
67
src/core/protocol/commands/check_authorization.rs
Normal file
67
src/core/protocol/commands/check_authorization.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::core::{protocol::request_validation::ValidationError, types::DbOrUser};
|
||||
|
||||
pub type CheckAuthorizationRequest = Vec<DbOrUser>;
|
||||
|
||||
pub type CheckAuthorizationResponse = BTreeMap<DbOrUser, Result<(), CheckAuthorizationError>>;
|
||||
|
||||
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[error("Validation error: {0}")]
|
||||
pub struct CheckAuthorizationError(#[from] pub ValidationError);
|
||||
|
||||
pub fn print_check_authorization_output_status(output: &CheckAuthorizationResponse) {
|
||||
for (db_or_user, result) in output {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!("'{}': OK", db_or_user.name());
|
||||
}
|
||||
Err(err) => {
|
||||
println!(
|
||||
"'{}': {}",
|
||||
db_or_user.name(),
|
||||
err.to_error_message(db_or_user)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_check_authorization_output_status_json(output: &CheckAuthorizationResponse) {
|
||||
let value = output
|
||||
.iter()
|
||||
.map(|(db_or_user, result)| match result {
|
||||
Ok(()) => (
|
||||
db_or_user.name().to_string(),
|
||||
json!({ "status": "success" }),
|
||||
),
|
||||
Err(err) => (
|
||||
db_or_user.name().to_string(),
|
||||
json!({
|
||||
"status": "error",
|
||||
"type": err.error_type(),
|
||||
"error": err.to_error_message(db_or_user),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&value)
|
||||
.unwrap_or("Failed to serialize result to JSON".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
impl CheckAuthorizationError {
|
||||
pub fn to_error_message(&self, db_or_user: &DbOrUser) -> String {
|
||||
self.0.to_error_message(db_or_user.clone())
|
||||
}
|
||||
|
||||
pub fn error_type(&self) -> String {
|
||||
self.0.error_type()
|
||||
}
|
||||
}
|
||||
5
src/core/protocol/commands/complete_database_name.rs
Normal file
5
src/core/protocol/commands/complete_database_name.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use crate::core::types::MySQLDatabase;
|
||||
|
||||
pub type CompleteDatabaseNameRequest = String;
|
||||
|
||||
pub type CompleteDatabaseNameResponse = Vec<MySQLDatabase>;
|
||||
5
src/core/protocol/commands/complete_user_name.rs
Normal file
5
src/core/protocol/commands/complete_user_name.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use crate::core::types::MySQLUser;
|
||||
|
||||
pub type CompleteUserNameRequest = String;
|
||||
|
||||
pub type CompleteUserNameResponse = Vec<MySQLUser>;
|
||||
87
src/core/protocol/commands/create_databases.rs
Normal file
87
src/core/protocol/commands/create_databases.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::core::{
|
||||
protocol::request_validation::ValidationError,
|
||||
types::{DbOrUser, MySQLDatabase},
|
||||
};
|
||||
|
||||
pub type CreateDatabasesRequest = Vec<MySQLDatabase>;
|
||||
|
||||
pub type CreateDatabasesResponse = BTreeMap<MySQLDatabase, Result<(), CreateDatabaseError>>;
|
||||
|
||||
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum CreateDatabaseError {
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(#[from] ValidationError),
|
||||
|
||||
#[error("Database already exists")]
|
||||
DatabaseAlreadyExists,
|
||||
|
||||
#[error("MySQL error: {0}")]
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
pub fn print_create_databases_output_status(output: &CreateDatabasesResponse) {
|
||||
for (database_name, result) in output {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!("Database '{}' created successfully.", database_name);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(database_name));
|
||||
println!("Skipping...");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_create_databases_output_status_json(output: &CreateDatabasesResponse) {
|
||||
let value = output
|
||||
.iter()
|
||||
.map(|(name, result)| match result {
|
||||
Ok(()) => (name.to_string(), json!({ "status": "success" })),
|
||||
Err(err) => (
|
||||
name.to_string(),
|
||||
json!({
|
||||
"status": "error",
|
||||
"type": err.error_type(),
|
||||
"error": err.to_error_message(name),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&value)
|
||||
.unwrap_or("Failed to serialize result to JSON".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
impl CreateDatabaseError {
|
||||
pub fn to_error_message(&self, database_name: &MySQLDatabase) -> String {
|
||||
match self {
|
||||
CreateDatabaseError::ValidationError(err) => {
|
||||
err.to_error_message(DbOrUser::Database(database_name.clone()))
|
||||
}
|
||||
CreateDatabaseError::DatabaseAlreadyExists => {
|
||||
format!("Database {} already exists.", database_name)
|
||||
}
|
||||
CreateDatabaseError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_type(&self) -> String {
|
||||
match self {
|
||||
CreateDatabaseError::ValidationError(err) => err.error_type(),
|
||||
CreateDatabaseError::DatabaseAlreadyExists => "database-already-exists".to_string(),
|
||||
CreateDatabaseError::MySqlError(_) => "mysql-error".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
87
src/core/protocol/commands/create_users.rs
Normal file
87
src/core/protocol/commands/create_users.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::core::{
|
||||
protocol::request_validation::ValidationError,
|
||||
types::{DbOrUser, MySQLUser},
|
||||
};
|
||||
|
||||
pub type CreateUsersRequest = Vec<MySQLUser>;
|
||||
|
||||
pub type CreateUsersResponse = BTreeMap<MySQLUser, Result<(), CreateUserError>>;
|
||||
|
||||
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum CreateUserError {
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(#[from] ValidationError),
|
||||
|
||||
#[error("User already exists")]
|
||||
UserAlreadyExists,
|
||||
|
||||
#[error("MySQL error: {0}")]
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
pub fn print_create_users_output_status(output: &CreateUsersResponse) {
|
||||
for (username, result) in output {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!("User '{}' created successfully.", username);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(username));
|
||||
println!("Skipping...");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_create_users_output_status_json(output: &CreateUsersResponse) {
|
||||
let value = output
|
||||
.iter()
|
||||
.map(|(name, result)| match result {
|
||||
Ok(()) => (name.to_string(), json!({ "status": "success" })),
|
||||
Err(err) => (
|
||||
name.to_string(),
|
||||
json!({
|
||||
"status": "error",
|
||||
"type": err.error_type(),
|
||||
"error": err.to_error_message(name),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&value)
|
||||
.unwrap_or("Failed to serialize result to JSON".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
impl CreateUserError {
|
||||
pub fn to_error_message(&self, username: &MySQLUser) -> String {
|
||||
match self {
|
||||
CreateUserError::ValidationError(err) => {
|
||||
err.to_error_message(DbOrUser::User(username.clone()))
|
||||
}
|
||||
CreateUserError::UserAlreadyExists => {
|
||||
format!("User '{}' already exists.", username)
|
||||
}
|
||||
CreateUserError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_type(&self) -> String {
|
||||
match self {
|
||||
CreateUserError::ValidationError(err) => err.error_type(),
|
||||
CreateUserError::UserAlreadyExists => "user-already-exists".to_string(),
|
||||
CreateUserError::MySqlError(_) => "mysql-error".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
90
src/core/protocol/commands/drop_databases.rs
Normal file
90
src/core/protocol/commands/drop_databases.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::core::{
|
||||
protocol::request_validation::ValidationError,
|
||||
types::{DbOrUser, MySQLDatabase},
|
||||
};
|
||||
|
||||
pub type DropDatabasesRequest = Vec<MySQLDatabase>;
|
||||
|
||||
pub type DropDatabasesResponse = BTreeMap<MySQLDatabase, Result<(), DropDatabaseError>>;
|
||||
|
||||
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DropDatabaseError {
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(#[from] ValidationError),
|
||||
|
||||
#[error("Database does not exist")]
|
||||
DatabaseDoesNotExist,
|
||||
|
||||
#[error("MySQL error: {0}")]
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
pub fn print_drop_databases_output_status(output: &DropDatabasesResponse) {
|
||||
for (database_name, result) in output {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!(
|
||||
"Database '{}' dropped successfully.",
|
||||
database_name.as_str()
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(database_name));
|
||||
println!("Skipping...");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_drop_databases_output_status_json(output: &DropDatabasesResponse) {
|
||||
let value = output
|
||||
.iter()
|
||||
.map(|(name, result)| match result {
|
||||
Ok(()) => (name.to_string(), json!({ "status": "success" })),
|
||||
Err(err) => (
|
||||
name.to_string(),
|
||||
json!({
|
||||
"status": "error",
|
||||
"type": err.error_type(),
|
||||
"error": err.to_error_message(name),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&value)
|
||||
.unwrap_or("Failed to serialize result to JSON".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
impl DropDatabaseError {
|
||||
pub fn to_error_message(&self, database_name: &MySQLDatabase) -> String {
|
||||
match self {
|
||||
DropDatabaseError::ValidationError(err) => {
|
||||
err.to_error_message(DbOrUser::Database(database_name.clone()))
|
||||
}
|
||||
DropDatabaseError::DatabaseDoesNotExist => {
|
||||
format!("Database {} does not exist.", database_name)
|
||||
}
|
||||
DropDatabaseError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_type(&self) -> String {
|
||||
match self {
|
||||
DropDatabaseError::ValidationError(err) => err.error_type(),
|
||||
DropDatabaseError::DatabaseDoesNotExist => "database-does-not-exist".to_string(),
|
||||
DropDatabaseError::MySqlError(_) => "mysql-error".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
87
src/core/protocol/commands/drop_users.rs
Normal file
87
src/core/protocol/commands/drop_users.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::core::{
|
||||
protocol::request_validation::ValidationError,
|
||||
types::{DbOrUser, MySQLUser},
|
||||
};
|
||||
|
||||
pub type DropUsersRequest = Vec<MySQLUser>;
|
||||
|
||||
pub type DropUsersResponse = BTreeMap<MySQLUser, Result<(), DropUserError>>;
|
||||
|
||||
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DropUserError {
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(#[from] ValidationError),
|
||||
|
||||
#[error("User does not exist")]
|
||||
UserDoesNotExist,
|
||||
|
||||
#[error("MySQL error: {0}")]
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
pub fn print_drop_users_output_status(output: &DropUsersResponse) {
|
||||
for (username, result) in output {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!("User '{}' dropped successfully.", username);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(username));
|
||||
println!("Skipping...");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_drop_users_output_status_json(output: &DropUsersResponse) {
|
||||
let value = output
|
||||
.iter()
|
||||
.map(|(name, result)| match result {
|
||||
Ok(()) => (name.to_string(), json!({ "status": "success" })),
|
||||
Err(err) => (
|
||||
name.to_string(),
|
||||
json!({
|
||||
"status": "error",
|
||||
"type": err.error_type(),
|
||||
"error": err.to_error_message(name),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&value)
|
||||
.unwrap_or("Failed to serialize result to JSON".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
impl DropUserError {
|
||||
pub fn to_error_message(&self, username: &MySQLUser) -> String {
|
||||
match self {
|
||||
DropUserError::ValidationError(err) => {
|
||||
err.to_error_message(DbOrUser::User(username.clone()))
|
||||
}
|
||||
DropUserError::UserDoesNotExist => {
|
||||
format!("User '{}' does not exist.", username)
|
||||
}
|
||||
DropUserError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_type(&self) -> String {
|
||||
match self {
|
||||
DropUserError::ValidationError(err) => err.error_type(),
|
||||
DropUserError::UserDoesNotExist => "user-does-not-exist".to_string(),
|
||||
DropUserError::MySqlError(_) => "mysql-error".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/core/protocol/commands/list_all_databases.rs
Normal file
27
src/core/protocol/commands/list_all_databases.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::server::sql::database_operations::DatabaseRow;
|
||||
|
||||
pub type ListAllDatabasesResponse = Result<Vec<DatabaseRow>, ListAllDatabasesError>;
|
||||
|
||||
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ListAllDatabasesError {
|
||||
#[error("MySQL error: {0}")]
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
impl ListAllDatabasesError {
|
||||
pub fn to_error_message(&self) -> String {
|
||||
match self {
|
||||
ListAllDatabasesError::MySqlError(err) => format!("MySQL error: {}", err),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn error_type(&self) -> String {
|
||||
match self {
|
||||
ListAllDatabasesError::MySqlError(_) => "mysql-error".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/core/protocol/commands/list_all_privileges.rs
Normal file
28
src/core/protocol/commands/list_all_privileges.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::core::database_privileges::DatabasePrivilegeRow;
|
||||
|
||||
pub type ListAllPrivilegesResponse =
|
||||
Result<Vec<DatabasePrivilegeRow>, GetAllDatabasesPrivilegeDataError>;
|
||||
|
||||
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum GetAllDatabasesPrivilegeDataError {
|
||||
#[error("MySQL error: {0}")]
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
impl GetAllDatabasesPrivilegeDataError {
|
||||
pub fn to_error_message(&self) -> String {
|
||||
match self {
|
||||
GetAllDatabasesPrivilegeDataError::MySqlError(err) => format!("MySQL error: {}", err),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn error_type(&self) -> String {
|
||||
match self {
|
||||
GetAllDatabasesPrivilegeDataError::MySqlError(_) => "mysql-error".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/core/protocol/commands/list_all_users.rs
Normal file
27
src/core/protocol/commands/list_all_users.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::server::sql::user_operations::DatabaseUser;
|
||||
|
||||
pub type ListAllUsersResponse = Result<Vec<DatabaseUser>, ListAllUsersError>;
|
||||
|
||||
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ListAllUsersError {
|
||||
#[error("MySQL error: {0}")]
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
impl ListAllUsersError {
|
||||
pub fn to_error_message(&self) -> String {
|
||||
match self {
|
||||
ListAllUsersError::MySqlError(err) => format!("MySQL error: {}", err),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn error_type(&self) -> String {
|
||||
match self {
|
||||
ListAllUsersError::MySqlError(_) => "mysql-error".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
126
src/core/protocol/commands/list_databases.rs
Normal file
126
src/core/protocol/commands/list_databases.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use itertools::Itertools;
|
||||
use prettytable::Table;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
core::{
|
||||
protocol::request_validation::ValidationError,
|
||||
types::{DbOrUser, MySQLDatabase},
|
||||
},
|
||||
server::sql::database_operations::DatabaseRow,
|
||||
};
|
||||
|
||||
pub type ListDatabasesRequest = Option<Vec<MySQLDatabase>>;
|
||||
|
||||
pub type ListDatabasesResponse = BTreeMap<MySQLDatabase, Result<DatabaseRow, ListDatabasesError>>;
|
||||
|
||||
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ListDatabasesError {
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(#[from] ValidationError),
|
||||
|
||||
#[error("Database does not exist")]
|
||||
DatabaseDoesNotExist,
|
||||
|
||||
#[error("MySQL error: {0}")]
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
pub fn print_list_databases_output_status(output: &ListDatabasesResponse) {
|
||||
let mut final_database_list: Vec<&DatabaseRow> = Vec::new();
|
||||
for (db_name, db_result) in output {
|
||||
match db_result {
|
||||
Ok(db_row) => final_database_list.push(db_row),
|
||||
Err(err) => {
|
||||
eprintln!("{}", err.to_error_message(db_name));
|
||||
eprintln!("Skipping...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if final_database_list.is_empty() {
|
||||
println!("No databases to show.");
|
||||
} else {
|
||||
let mut table = Table::new();
|
||||
table.add_row(row![
|
||||
"Database",
|
||||
"Tables",
|
||||
"Users",
|
||||
"Collation",
|
||||
"Character Set",
|
||||
"Size (Bytes)"
|
||||
]);
|
||||
for db in final_database_list {
|
||||
table.add_row(row![
|
||||
db.database,
|
||||
db.tables.join("\n"),
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
table.printstd();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_list_databases_output_status_json(output: &ListDatabasesResponse) {
|
||||
let value = output
|
||||
.iter()
|
||||
.map(|(name, result)| match result {
|
||||
Ok(row) => (
|
||||
name.to_string(),
|
||||
json!({
|
||||
"status": "success",
|
||||
"tables": row.tables,
|
||||
"users": row.users,
|
||||
"collation": row.collation,
|
||||
"character_set": row.character_set,
|
||||
"size_bytes": row.size_bytes,
|
||||
}),
|
||||
),
|
||||
Err(err) => (
|
||||
name.to_string(),
|
||||
json!({
|
||||
"status": "error",
|
||||
"type": err.error_type(),
|
||||
"error": err.to_error_message(name),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&value)
|
||||
.unwrap_or("Failed to serialize result to JSON".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
impl ListDatabasesError {
|
||||
pub fn to_error_message(&self, database_name: &MySQLDatabase) -> String {
|
||||
match self {
|
||||
ListDatabasesError::ValidationError(err) => {
|
||||
err.to_error_message(DbOrUser::Database(database_name.clone()))
|
||||
}
|
||||
ListDatabasesError::DatabaseDoesNotExist => {
|
||||
format!("Database '{}' does not exist.", database_name)
|
||||
}
|
||||
ListDatabasesError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_type(&self) -> String {
|
||||
match self {
|
||||
ListDatabasesError::ValidationError(err) => err.error_type(),
|
||||
ListDatabasesError::DatabaseDoesNotExist => "database-does-not-exist".to_string(),
|
||||
ListDatabasesError::MySqlError(_) => "mysql-error".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
155
src/core/protocol/commands/list_privileges.rs
Normal file
155
src/core/protocol/commands/list_privileges.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
// TODO: merge all rows into a single collection.
|
||||
// they already contain which database they belong to.
|
||||
// no need to index by database name.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use itertools::Itertools;
|
||||
use prettytable::{Cell, Row, Table};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::core::{
|
||||
common::yn,
|
||||
database_privileges::{
|
||||
DATABASE_PRIVILEGE_FIELDS, DatabasePrivilegeRow, db_priv_field_human_readable_name,
|
||||
db_priv_field_single_character_name,
|
||||
},
|
||||
protocol::request_validation::ValidationError,
|
||||
types::{DbOrUser, MySQLDatabase},
|
||||
};
|
||||
|
||||
pub type ListPrivilegesRequest = Option<Vec<MySQLDatabase>>;
|
||||
|
||||
pub type ListPrivilegesResponse =
|
||||
BTreeMap<MySQLDatabase, Result<Vec<DatabasePrivilegeRow>, GetDatabasesPrivilegeDataError>>;
|
||||
|
||||
pub fn print_list_privileges_output_status(output: &ListPrivilegesResponse, long_names: bool) {
|
||||
let mut final_privs_map: BTreeMap<MySQLDatabase, Vec<DatabasePrivilegeRow>> = BTreeMap::new();
|
||||
for (db_name, db_result) in output {
|
||||
match db_result {
|
||||
Ok(db_rows) => {
|
||||
final_privs_map.insert(db_name.clone(), db_rows.clone());
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("{}", err.to_error_message(db_name));
|
||||
eprintln!("Skipping...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if final_privs_map.is_empty() {
|
||||
println!("No privileges to show.");
|
||||
} else {
|
||||
let mut table = Table::new();
|
||||
|
||||
table.add_row(Row::new(
|
||||
DATABASE_PRIVILEGE_FIELDS
|
||||
.into_iter()
|
||||
.map(|field| {
|
||||
if field == "Db" || field == "User" {
|
||||
db_priv_field_human_readable_name(field)
|
||||
} else if long_names {
|
||||
format!(
|
||||
"{} ({})",
|
||||
db_priv_field_human_readable_name(field),
|
||||
db_priv_field_single_character_name(field),
|
||||
)
|
||||
} else {
|
||||
db_priv_field_human_readable_name(field)
|
||||
}
|
||||
})
|
||||
.map(|name| Cell::new(&name))
|
||||
.collect(),
|
||||
));
|
||||
|
||||
for (_database, rows) in final_privs_map {
|
||||
for row in rows.iter() {
|
||||
table.add_row(row![
|
||||
row.db,
|
||||
row.user,
|
||||
c->yn(row.select_priv),
|
||||
c->yn(row.insert_priv),
|
||||
c->yn(row.update_priv),
|
||||
c->yn(row.delete_priv),
|
||||
c->yn(row.create_priv),
|
||||
c->yn(row.drop_priv),
|
||||
c->yn(row.alter_priv),
|
||||
c->yn(row.index_priv),
|
||||
c->yn(row.create_tmp_table_priv),
|
||||
c->yn(row.lock_tables_priv),
|
||||
c->yn(row.references_priv),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
table.printstd();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_list_privileges_output_status_json(output: &ListPrivilegesResponse) {
|
||||
let value = output
|
||||
.iter()
|
||||
.map(|(name, result)| match result {
|
||||
Ok(row) => (
|
||||
name.to_string(),
|
||||
json!({
|
||||
"status": "success",
|
||||
"value": row.iter().into_group_map_by(|priv_row| priv_row.user.clone()),
|
||||
}),
|
||||
),
|
||||
Err(err) => (
|
||||
name.to_string(),
|
||||
json!({
|
||||
"status": "error",
|
||||
"type": err.error_type(),
|
||||
"error": err.to_error_message(name),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&value)
|
||||
.unwrap_or("Failed to serialize result to JSON".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum GetDatabasesPrivilegeDataError {
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(#[from] ValidationError),
|
||||
|
||||
#[error("Database does not exist")]
|
||||
DatabaseDoesNotExist,
|
||||
|
||||
#[error("MySQL error: {0}")]
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
impl GetDatabasesPrivilegeDataError {
|
||||
pub fn to_error_message(&self, database_name: &MySQLDatabase) -> String {
|
||||
match self {
|
||||
GetDatabasesPrivilegeDataError::ValidationError(err) => {
|
||||
err.to_error_message(DbOrUser::Database(database_name.clone()))
|
||||
}
|
||||
GetDatabasesPrivilegeDataError::DatabaseDoesNotExist => {
|
||||
format!("Database '{}' does not exist.", database_name)
|
||||
}
|
||||
GetDatabasesPrivilegeDataError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
121
src/core/protocol/commands/list_users.rs
Normal file
121
src/core/protocol/commands/list_users.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use prettytable::Table;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
core::{
|
||||
protocol::request_validation::ValidationError,
|
||||
types::{DbOrUser, MySQLUser},
|
||||
},
|
||||
server::sql::user_operations::DatabaseUser,
|
||||
};
|
||||
|
||||
pub type ListUsersRequest = Option<Vec<MySQLUser>>;
|
||||
|
||||
pub type ListUsersResponse = BTreeMap<MySQLUser, Result<DatabaseUser, ListUsersError>>;
|
||||
|
||||
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ListUsersError {
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(#[from] ValidationError),
|
||||
|
||||
#[error("User does not exist")]
|
||||
UserDoesNotExist,
|
||||
|
||||
#[error("MySQL error: {0}")]
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
pub fn print_list_users_output_status(output: &ListUsersResponse) {
|
||||
let mut final_user_list: Vec<&DatabaseUser> = Vec::new();
|
||||
for (db_name, db_result) in output {
|
||||
match db_result {
|
||||
Ok(db_row) => final_user_list.push(db_row),
|
||||
Err(err) => {
|
||||
eprintln!("{}", err.to_error_message(db_name));
|
||||
eprintln!("Skipping...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if final_user_list.is_empty() {
|
||||
println!("No users to show.");
|
||||
} else {
|
||||
let mut table = Table::new();
|
||||
table.add_row(row![
|
||||
"User",
|
||||
"Password is set",
|
||||
"Locked",
|
||||
"Databases where user has privileges"
|
||||
]);
|
||||
for user in final_user_list {
|
||||
table.add_row(row![
|
||||
user.user,
|
||||
user.has_password,
|
||||
user.is_locked,
|
||||
user.databases.join("\n")
|
||||
]);
|
||||
}
|
||||
table.printstd();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_list_users_output_status_json(output: &ListUsersResponse) {
|
||||
let value = output
|
||||
.iter()
|
||||
.map(|(name, result)| match result {
|
||||
Ok(row) => (
|
||||
name.to_string(),
|
||||
json!({
|
||||
"status": "success",
|
||||
"value": {
|
||||
"user": row.user,
|
||||
"has_password": row.has_password,
|
||||
"is_locked": row.is_locked,
|
||||
"databases": row.databases,
|
||||
}
|
||||
}),
|
||||
),
|
||||
Err(err) => (
|
||||
name.to_string(),
|
||||
json!({
|
||||
"status": "error",
|
||||
"type": err.error_type(),
|
||||
"error": err.to_error_message(name),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&value)
|
||||
.unwrap_or("Failed to serialize result to JSON".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
impl ListUsersError {
|
||||
pub fn to_error_message(&self, username: &MySQLUser) -> String {
|
||||
match self {
|
||||
ListUsersError::ValidationError(err) => {
|
||||
err.to_error_message(DbOrUser::User(username.clone()))
|
||||
}
|
||||
ListUsersError::UserDoesNotExist => {
|
||||
format!("User '{}' does not exist.", username)
|
||||
}
|
||||
ListUsersError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_type(&self) -> String {
|
||||
match self {
|
||||
ListUsersError::ValidationError(err) => err.error_type(),
|
||||
ListUsersError::UserDoesNotExist => "user-does-not-exist".to_string(),
|
||||
ListUsersError::MySqlError(_) => "mysql-error".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
94
src/core/protocol/commands/lock_users.rs
Normal file
94
src/core/protocol/commands/lock_users.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::core::{
|
||||
protocol::request_validation::ValidationError,
|
||||
types::{DbOrUser, MySQLUser},
|
||||
};
|
||||
|
||||
pub type LockUsersRequest = Vec<MySQLUser>;
|
||||
|
||||
pub type LockUsersResponse = BTreeMap<MySQLUser, Result<(), LockUserError>>;
|
||||
|
||||
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum LockUserError {
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(#[from] ValidationError),
|
||||
|
||||
#[error("User does not exist")]
|
||||
UserDoesNotExist,
|
||||
|
||||
#[error("User is already locked")]
|
||||
UserIsAlreadyLocked,
|
||||
|
||||
#[error("MySQL error: {0}")]
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
pub fn print_lock_users_output_status(output: &LockUsersResponse) {
|
||||
for (username, result) in output {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!("User '{}' locked successfully.", username);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(username));
|
||||
println!("Skipping...");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_lock_users_output_status_json(output: &LockUsersResponse) {
|
||||
let value = output
|
||||
.iter()
|
||||
.map(|(name, result)| match result {
|
||||
Ok(()) => (name.to_string(), json!({ "status": "success" })),
|
||||
Err(err) => (
|
||||
name.to_string(),
|
||||
json!({
|
||||
"status": "error",
|
||||
"type": err.error_type(),
|
||||
"error": err.to_error_message(name),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&value)
|
||||
.unwrap_or("Failed to serialize result to JSON".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
impl LockUserError {
|
||||
pub fn to_error_message(&self, username: &MySQLUser) -> String {
|
||||
match self {
|
||||
LockUserError::ValidationError(err) => {
|
||||
err.to_error_message(DbOrUser::User(username.clone()))
|
||||
}
|
||||
LockUserError::UserDoesNotExist => {
|
||||
format!("User '{}' does not exist.", username)
|
||||
}
|
||||
LockUserError::UserIsAlreadyLocked => {
|
||||
format!("User '{}' is already locked.", username)
|
||||
}
|
||||
LockUserError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_type(&self) -> String {
|
||||
match self {
|
||||
LockUserError::ValidationError(err) => err.error_type(),
|
||||
LockUserError::UserDoesNotExist => "user-does-not-exist".to_string(),
|
||||
LockUserError::UserIsAlreadyLocked => "user-is-already-locked".to_string(),
|
||||
LockUserError::MySqlError(_) => "mysql-error".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
147
src/core/protocol/commands/modify_privileges.rs
Normal file
147
src/core/protocol/commands/modify_privileges.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::core::{
|
||||
database_privileges::{DatabasePrivilegeRow, DatabasePrivilegeRowDiff, DatabasePrivilegesDiff},
|
||||
protocol::request_validation::ValidationError,
|
||||
types::{DbOrUser, MySQLDatabase, MySQLUser},
|
||||
};
|
||||
|
||||
pub type ModifyPrivilegesRequest = BTreeSet<DatabasePrivilegesDiff>;
|
||||
|
||||
pub type ModifyPrivilegesResponse =
|
||||
BTreeMap<(MySQLDatabase, MySQLUser), Result<(), ModifyDatabasePrivilegesError>>;
|
||||
|
||||
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ModifyDatabasePrivilegesError {
|
||||
#[error("Database validation error: {0}")]
|
||||
DatabaseValidationError(ValidationError),
|
||||
|
||||
#[error("User validation error: {0}")]
|
||||
UserValidationError(ValidationError),
|
||||
|
||||
#[error("Database does not exist")]
|
||||
DatabaseDoesNotExist,
|
||||
|
||||
#[error("User does not exist")]
|
||||
UserDoesNotExist,
|
||||
|
||||
#[error("Diff does not apply: {0}")]
|
||||
DiffDoesNotApply(DiffDoesNotApplyError),
|
||||
|
||||
#[error("MySQL error: {0}")]
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DiffDoesNotApplyError {
|
||||
#[error("Privileges row already exists for database '{0}' and user '{1}'")]
|
||||
RowAlreadyExists(MySQLDatabase, MySQLUser),
|
||||
|
||||
#[error("Privileges row does not exist for database '{0}' and user '{1}'")]
|
||||
RowDoesNotExist(MySQLDatabase, MySQLUser),
|
||||
|
||||
#[error("Privilege change '{0:?}' does not apply to row '{1:?}'")]
|
||||
RowPrivilegeChangeDoesNotApply(DatabasePrivilegeRowDiff, DatabasePrivilegeRow),
|
||||
}
|
||||
|
||||
pub fn print_modify_database_privileges_output_status(output: &ModifyPrivilegesResponse) {
|
||||
for ((database_name, username), result) in output {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!(
|
||||
"Privileges for user '{}' on database '{}' modified successfully.",
|
||||
username, database_name
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(database_name, username));
|
||||
println!("Skipping...");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
impl ModifyDatabasePrivilegesError {
|
||||
pub fn to_error_message(&self, database_name: &MySQLDatabase, username: &MySQLUser) -> String {
|
||||
match self {
|
||||
ModifyDatabasePrivilegesError::DatabaseValidationError(err) => {
|
||||
err.to_error_message(DbOrUser::Database(database_name.clone()))
|
||||
}
|
||||
ModifyDatabasePrivilegesError::UserValidationError(err) => {
|
||||
err.to_error_message(DbOrUser::User(username.clone()))
|
||||
}
|
||||
ModifyDatabasePrivilegesError::DatabaseDoesNotExist => {
|
||||
format!("Database '{}' does not exist.", database_name)
|
||||
}
|
||||
ModifyDatabasePrivilegesError::UserDoesNotExist => {
|
||||
format!("User '{}' does not exist.", username)
|
||||
}
|
||||
ModifyDatabasePrivilegesError::DiffDoesNotApply(diff) => {
|
||||
format!(
|
||||
"Could not apply privilege change:\n{}",
|
||||
diff.to_error_message()
|
||||
)
|
||||
}
|
||||
ModifyDatabasePrivilegesError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn error_type(&self) -> String {
|
||||
match self {
|
||||
// TODO: should these be subtyped?
|
||||
ModifyDatabasePrivilegesError::DatabaseValidationError(err) => err.error_type(),
|
||||
ModifyDatabasePrivilegesError::UserValidationError(err) => err.error_type(),
|
||||
ModifyDatabasePrivilegesError::DatabaseDoesNotExist => {
|
||||
"database-does-not-exist".to_string()
|
||||
}
|
||||
ModifyDatabasePrivilegesError::UserDoesNotExist => "user-does-not-exist".to_string(),
|
||||
ModifyDatabasePrivilegesError::DiffDoesNotApply(err) => {
|
||||
format!("diff-does-not-apply/{}", err.error_type())
|
||||
}
|
||||
ModifyDatabasePrivilegesError::MySqlError(_) => "mysql-error".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DiffDoesNotApplyError {
|
||||
pub fn to_error_message(&self) -> String {
|
||||
match self {
|
||||
DiffDoesNotApplyError::RowAlreadyExists(database_name, username) => {
|
||||
format!(
|
||||
"Privileges for user '{}' on database '{}' already exist.",
|
||||
username, database_name
|
||||
)
|
||||
}
|
||||
DiffDoesNotApplyError::RowDoesNotExist(database_name, username) => {
|
||||
format!(
|
||||
"Privileges for user '{}' on database '{}' do not exist.",
|
||||
username, database_name
|
||||
)
|
||||
}
|
||||
DiffDoesNotApplyError::RowPrivilegeChangeDoesNotApply(diff, row) => {
|
||||
format!(
|
||||
"Could not apply privilege change {:?} to row {:?}",
|
||||
diff, row
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_type(&self) -> String {
|
||||
match self {
|
||||
DiffDoesNotApplyError::RowAlreadyExists(_, _) => "row-already-exists".to_string(),
|
||||
DiffDoesNotApplyError::RowDoesNotExist(_, _) => "row-does-not-exist".to_string(),
|
||||
DiffDoesNotApplyError::RowPrivilegeChangeDoesNotApply(_, _) => {
|
||||
"row-privilege-change-does-not-apply".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
60
src/core/protocol/commands/passwd_user.rs
Normal file
60
src/core/protocol/commands/passwd_user.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::core::{
|
||||
protocol::request_validation::ValidationError,
|
||||
types::{DbOrUser, MySQLUser},
|
||||
};
|
||||
|
||||
pub type SetUserPasswordRequest = (MySQLUser, String);
|
||||
|
||||
pub type SetUserPasswordResponse = Result<(), SetPasswordError>;
|
||||
|
||||
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum SetPasswordError {
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(#[from] ValidationError),
|
||||
|
||||
#[error("User does not exist")]
|
||||
UserDoesNotExist,
|
||||
|
||||
#[error("MySQL error: {0}")]
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
pub fn print_set_password_output_status(output: &SetUserPasswordResponse, username: &MySQLUser) {
|
||||
match output {
|
||||
Ok(()) => {
|
||||
println!("Password for user '{}' set successfully.", username);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(username));
|
||||
println!("Skipping...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SetPasswordError {
|
||||
pub fn to_error_message(&self, username: &MySQLUser) -> String {
|
||||
match self {
|
||||
SetPasswordError::ValidationError(err) => {
|
||||
err.to_error_message(DbOrUser::User(username.clone()))
|
||||
}
|
||||
SetPasswordError::UserDoesNotExist => {
|
||||
format!("User '{}' does not exist.", username)
|
||||
}
|
||||
SetPasswordError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn error_type(&self) -> String {
|
||||
match self {
|
||||
SetPasswordError::ValidationError(err) => err.error_type(),
|
||||
SetPasswordError::UserDoesNotExist => "user-does-not-exist".to_string(),
|
||||
SetPasswordError::MySqlError(_) => "mysql-error".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
94
src/core/protocol/commands/unlock_users.rs
Normal file
94
src/core/protocol/commands/unlock_users.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::core::{
|
||||
protocol::request_validation::ValidationError,
|
||||
types::{DbOrUser, MySQLUser},
|
||||
};
|
||||
|
||||
pub type UnlockUsersRequest = Vec<MySQLUser>;
|
||||
|
||||
pub type UnlockUsersResponse = BTreeMap<MySQLUser, Result<(), UnlockUserError>>;
|
||||
|
||||
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum UnlockUserError {
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(#[from] ValidationError),
|
||||
|
||||
#[error("User does not exist")]
|
||||
UserDoesNotExist,
|
||||
|
||||
#[error("User is already unlocked")]
|
||||
UserIsAlreadyUnlocked,
|
||||
|
||||
#[error("MySQL error: {0}")]
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
pub fn print_unlock_users_output_status(output: &UnlockUsersResponse) {
|
||||
for (username, result) in output {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!("User '{}' unlocked successfully.", username);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(username));
|
||||
println!("Skipping...");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_unlock_users_output_status_json(output: &UnlockUsersResponse) {
|
||||
let value = output
|
||||
.iter()
|
||||
.map(|(name, result)| match result {
|
||||
Ok(()) => (name.to_string(), json!({ "status": "success" })),
|
||||
Err(err) => (
|
||||
name.to_string(),
|
||||
json!({
|
||||
"status": "error",
|
||||
"type": err.error_type(),
|
||||
"error": err.to_error_message(name),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&value)
|
||||
.unwrap_or("Failed to serialize result to JSON".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
impl UnlockUserError {
|
||||
pub fn to_error_message(&self, username: &MySQLUser) -> String {
|
||||
match self {
|
||||
UnlockUserError::ValidationError(err) => {
|
||||
err.to_error_message(DbOrUser::User(username.clone()))
|
||||
}
|
||||
UnlockUserError::UserDoesNotExist => {
|
||||
format!("User '{}' does not exist.", username)
|
||||
}
|
||||
UnlockUserError::UserIsAlreadyUnlocked => {
|
||||
format!("User '{}' is already unlocked.", username)
|
||||
}
|
||||
UnlockUserError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_type(&self) -> String {
|
||||
match self {
|
||||
UnlockUserError::ValidationError(err) => err.error_type(),
|
||||
UnlockUserError::UserDoesNotExist => "user-does-not-exist".to_string(),
|
||||
UnlockUserError::UserIsAlreadyUnlocked => "user-is-already-unlocked".to_string(),
|
||||
UnlockUserError::MySqlError(_) => "mysql-error".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
fmt::{Display, Formatter},
|
||||
ops::{Deref, DerefMut},
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio_serde::{Framed as SerdeFramed, formats::Bincode};
|
||||
use tokio_util::codec::{Framed, LengthDelimitedCodec};
|
||||
|
||||
use crate::core::{database_privileges::DatabasePrivilegesDiff, protocol::*};
|
||||
|
||||
pub type ServerToClientMessageStream = SerdeFramed<
|
||||
Framed<UnixStream, LengthDelimitedCodec>,
|
||||
Request,
|
||||
Response,
|
||||
Bincode<Request, Response>,
|
||||
>;
|
||||
|
||||
pub type ClientToServerMessageStream = SerdeFramed<
|
||||
Framed<UnixStream, LengthDelimitedCodec>,
|
||||
Response,
|
||||
Request,
|
||||
Bincode<Response, Request>,
|
||||
>;
|
||||
|
||||
pub fn create_server_to_client_message_stream(socket: UnixStream) -> ServerToClientMessageStream {
|
||||
let length_delimited = Framed::new(socket, LengthDelimitedCodec::new());
|
||||
tokio_serde::Framed::new(length_delimited, Bincode::default())
|
||||
}
|
||||
|
||||
pub fn create_client_to_server_message_stream(socket: UnixStream) -> ClientToServerMessageStream {
|
||||
let length_delimited = Framed::new(socket, LengthDelimitedCodec::new());
|
||||
tokio_serde::Framed::new(length_delimited, Bincode::default())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct MySQLUser(String);
|
||||
|
||||
impl FromStr for MySQLUser {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(MySQLUser(s.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for MySQLUser {
|
||||
type Target = String;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for MySQLUser {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for MySQLUser {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for MySQLUser {
|
||||
fn from(s: &str) -> Self {
|
||||
MySQLUser(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for MySQLUser {
|
||||
fn from(s: String) -> Self {
|
||||
MySQLUser(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct MySQLDatabase(String);
|
||||
|
||||
impl FromStr for MySQLDatabase {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(MySQLDatabase(s.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for MySQLDatabase {
|
||||
type Target = String;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for MySQLDatabase {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for MySQLDatabase {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for MySQLDatabase {
|
||||
fn from(s: &str) -> Self {
|
||||
MySQLDatabase(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for MySQLDatabase {
|
||||
fn from(s: String) -> Self {
|
||||
MySQLDatabase(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Request {
|
||||
CreateDatabases(Vec<MySQLDatabase>),
|
||||
DropDatabases(Vec<MySQLDatabase>),
|
||||
ListDatabases(Option<Vec<MySQLDatabase>>),
|
||||
ListPrivileges(Option<Vec<MySQLDatabase>>),
|
||||
ModifyPrivileges(BTreeSet<DatabasePrivilegesDiff>),
|
||||
|
||||
CreateUsers(Vec<MySQLUser>),
|
||||
DropUsers(Vec<MySQLUser>),
|
||||
PasswdUser(MySQLUser, String),
|
||||
ListUsers(Option<Vec<MySQLUser>>),
|
||||
LockUsers(Vec<MySQLUser>),
|
||||
UnlockUsers(Vec<MySQLUser>),
|
||||
|
||||
// Commit,
|
||||
Exit,
|
||||
}
|
||||
|
||||
// TODO: include a generic "message" that will display a message to the user?
|
||||
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Response {
|
||||
// Specific data for specific commands
|
||||
CreateDatabases(CreateDatabasesOutput),
|
||||
DropDatabases(DropDatabasesOutput),
|
||||
ListDatabases(ListDatabasesOutput),
|
||||
ListAllDatabases(ListAllDatabasesOutput),
|
||||
ListPrivileges(GetDatabasesPrivilegeData),
|
||||
ListAllPrivileges(GetAllDatabasesPrivilegeData),
|
||||
ModifyPrivileges(ModifyDatabasePrivilegesOutput),
|
||||
|
||||
CreateUsers(CreateUsersOutput),
|
||||
DropUsers(DropUsersOutput),
|
||||
PasswdUser(SetPasswordOutput),
|
||||
ListUsers(ListUsersOutput),
|
||||
ListAllUsers(ListAllUsersOutput),
|
||||
LockUsers(LockUsersOutput),
|
||||
UnlockUsers(UnlockUsersOutput),
|
||||
|
||||
// Generic responses
|
||||
Ready,
|
||||
Error(String),
|
||||
}
|
||||
279
src/core/protocol/request_validation.rs
Normal file
279
src/core/protocol/request_validation.rs
Normal file
@@ -0,0 +1,279 @@
|
||||
use indoc::indoc;
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::core::{common::UnixUser, types::DbOrUser};
|
||||
|
||||
#[derive(Error, Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum NameValidationError {
|
||||
#[error("Name cannot be empty.")]
|
||||
EmptyString,
|
||||
|
||||
#[error(
|
||||
"Name contains invalid characters. Only A-Z, a-z, 0-9, _ (underscore) and - (dash) are permitted."
|
||||
)]
|
||||
InvalidCharacters,
|
||||
|
||||
#[error("Name is too long. Maximum length is 64 characters.")]
|
||||
TooLong,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
NameValidationError::TooLong => format!(
|
||||
"{} 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.
|
||||
"#},
|
||||
db_or_user.lowercased_noun(),
|
||||
db_or_user.name(),
|
||||
)
|
||||
.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_type(&self) -> &'static str {
|
||||
match self {
|
||||
NameValidationError::EmptyString => "empty-string",
|
||||
NameValidationError::InvalidCharacters => "invalid-characters",
|
||||
NameValidationError::TooLong => "too-long",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum AuthorizationError {
|
||||
#[error("No matching owner prefix found")]
|
||||
NoMatch,
|
||||
|
||||
// TODO: I don't think this should ever happen?
|
||||
#[error("Name cannot be empty")]
|
||||
StringEmpty,
|
||||
}
|
||||
|
||||
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:
|
||||
- {}
|
||||
{}
|
||||
"#},
|
||||
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"),
|
||||
)
|
||||
.to_owned(),
|
||||
AuthorizationError::StringEmpty => format!(
|
||||
"'{}' is not a valid {} name.",
|
||||
db_or_user.name(),
|
||||
db_or_user.lowercased_noun()
|
||||
)
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_type(&self) -> &'static str {
|
||||
match self {
|
||||
AuthorizationError::NoMatch => "no-match",
|
||||
AuthorizationError::StringEmpty => "string-empty",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
||||
pub enum ValidationError {
|
||||
#[error("Name validation error: {0}")]
|
||||
NameValidationError(NameValidationError),
|
||||
|
||||
#[error("Authorization error: {0}")]
|
||||
AuthorizationError(AuthorizationError),
|
||||
// AuthorizationHandlerError(String),
|
||||
}
|
||||
|
||||
impl ValidationError {
|
||||
pub fn to_error_message(&self, db_or_user: DbOrUser) -> String {
|
||||
match self {
|
||||
ValidationError::NameValidationError(err) => err.to_error_message(db_or_user),
|
||||
ValidationError::AuthorizationError(err) => err.to_error_message(db_or_user),
|
||||
// AuthorizationError::AuthorizationHandlerError(msg) => {
|
||||
// format!(
|
||||
// "Authorization handler error for '{}': {}",
|
||||
// db_or_user.name(),
|
||||
// msg
|
||||
// )
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_type(&self) -> String {
|
||||
match self {
|
||||
ValidationError::NameValidationError(err) => {
|
||||
format!("name-validation-error/{}", err.error_type())
|
||||
}
|
||||
ValidationError::AuthorizationError(err) => {
|
||||
format!("authorization-error/{}", err.error_type())
|
||||
} // AuthorizationError::AuthorizationHandlerError(_) => {
|
||||
// "authorization-handler-error".to_string()
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_NAME_LENGTH: usize = 64;
|
||||
|
||||
pub fn validate_name(name: &str) -> Result<(), NameValidationError> {
|
||||
if name.is_empty() {
|
||||
Err(NameValidationError::EmptyString)
|
||||
} else if name.len() > MAX_NAME_LENGTH {
|
||||
Err(NameValidationError::TooLong)
|
||||
} else if !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
Err(NameValidationError::InvalidCharacters)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_authorization_by_unix_user(
|
||||
name: &str,
|
||||
user: &UnixUser,
|
||||
) -> Result<(), AuthorizationError> {
|
||||
let prefixes = std::iter::once(user.username.to_owned())
|
||||
.chain(user.groups.iter().cloned())
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
validate_authorization_by_prefixes(name, &prefixes)
|
||||
}
|
||||
|
||||
/// Core logic for validating the ownership of a database name.
|
||||
/// This function checks if the given name matches any of the given prefixes.
|
||||
/// These prefixes will in most cases be the user's unix username and any
|
||||
/// unix groups the user is a member of.
|
||||
pub fn validate_authorization_by_prefixes(
|
||||
name: &str,
|
||||
prefixes: &[String],
|
||||
) -> Result<(), AuthorizationError> {
|
||||
if name.is_empty() {
|
||||
return Err(AuthorizationError::StringEmpty);
|
||||
}
|
||||
|
||||
if prefixes
|
||||
.iter()
|
||||
.filter(|p| name.starts_with(&(p.to_string() + "_")))
|
||||
.collect::<Vec<_>>()
|
||||
.is_empty()
|
||||
{
|
||||
return Err(AuthorizationError::NoMatch);
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_db_or_user_request(
|
||||
db_or_user: &DbOrUser,
|
||||
unix_user: &UnixUser,
|
||||
) -> 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)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_validate_name() {
|
||||
assert_eq!(validate_name(""), Err(NameValidationError::EmptyString));
|
||||
assert_eq!(validate_name("abcdefghijklmnopqrstuvwxyz"), Ok(()));
|
||||
assert_eq!(validate_name("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), Ok(()));
|
||||
assert_eq!(validate_name("0123456789_-"), Ok(()));
|
||||
|
||||
for c in "\n\t\r !@#$%^&*()+=[]{}|;:,.<>?/".chars() {
|
||||
assert_eq!(
|
||||
validate_name(&c.to_string()),
|
||||
Err(NameValidationError::InvalidCharacters)
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(validate_name(&"a".repeat(MAX_NAME_LENGTH)), Ok(()));
|
||||
|
||||
assert_eq!(
|
||||
validate_name(&"a".repeat(MAX_NAME_LENGTH + 1)),
|
||||
Err(NameValidationError::TooLong)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_authorization_by_prefixes() {
|
||||
let prefixes = vec!["user".to_string(), "group".to_string()];
|
||||
|
||||
assert_eq!(
|
||||
validate_authorization_by_prefixes("", &prefixes),
|
||||
Err(AuthorizationError::StringEmpty)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
validate_authorization_by_prefixes("user_testdb", &prefixes),
|
||||
Ok(())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_authorization_by_prefixes("group_testdb", &prefixes),
|
||||
Ok(())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_authorization_by_prefixes("group_test_db", &prefixes),
|
||||
Ok(())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_authorization_by_prefixes("group_test-db", &prefixes),
|
||||
Ok(())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
validate_authorization_by_prefixes("nonexistent_testdb", &prefixes),
|
||||
Err(AuthorizationError::NoMatch)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,774 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use indoc::indoc;
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
core::{common::UnixUser, database_privileges::DatabasePrivilegeRowDiff},
|
||||
server::sql::{
|
||||
database_operations::DatabaseRow, database_privilege_operations::DatabasePrivilegeRow,
|
||||
user_operations::DatabaseUser,
|
||||
},
|
||||
};
|
||||
|
||||
use super::{MySQLDatabase, MySQLUser};
|
||||
|
||||
/// This enum is used to differentiate between database and user operations.
|
||||
/// Their output are very similar, but there are slight differences in the words used.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum DbOrUser {
|
||||
Database,
|
||||
User,
|
||||
}
|
||||
|
||||
impl DbOrUser {
|
||||
pub fn lowercased(&self) -> &'static str {
|
||||
match self {
|
||||
DbOrUser::Database => "database",
|
||||
DbOrUser::User => "user",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn capitalized(&self) -> &'static str {
|
||||
match self {
|
||||
DbOrUser::Database => "Database",
|
||||
DbOrUser::User => "User",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum NameValidationError {
|
||||
EmptyString,
|
||||
InvalidCharacters,
|
||||
TooLong,
|
||||
}
|
||||
|
||||
impl NameValidationError {
|
||||
pub fn to_error_message(self, name: &str, db_or_user: DbOrUser) -> String {
|
||||
match self {
|
||||
NameValidationError::EmptyString => {
|
||||
format!("{} name cannot be empty.", db_or_user.capitalized()).to_owned()
|
||||
}
|
||||
NameValidationError::TooLong => format!(
|
||||
"{} is too long. Maximum length is 64 characters.",
|
||||
db_or_user.capitalized()
|
||||
)
|
||||
.to_owned(),
|
||||
NameValidationError::InvalidCharacters => format!(
|
||||
indoc! {r#"
|
||||
Invalid characters in {} name: '{}'
|
||||
|
||||
Only A-Z, a-z, 0-9, _ (underscore) and - (dash) are permitted.
|
||||
"#},
|
||||
db_or_user.lowercased(),
|
||||
name
|
||||
)
|
||||
.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OwnerValidationError {
|
||||
pub fn to_error_message(self, name: &str, db_or_user: DbOrUser) -> String {
|
||||
let user = UnixUser::from_enviroment();
|
||||
|
||||
let UnixUser {
|
||||
username,
|
||||
mut groups,
|
||||
} = user.unwrap_or(UnixUser {
|
||||
username: "???".to_string(),
|
||||
groups: vec![],
|
||||
});
|
||||
|
||||
groups.sort();
|
||||
|
||||
match self {
|
||||
OwnerValidationError::NoMatch => format!(
|
||||
indoc! {r#"
|
||||
Invalid {} name prefix: '{}' does not match your username or any of your groups.
|
||||
Are you sure you are allowed to create {} names with this prefix?
|
||||
The format should be: <prefix>_<{} name>
|
||||
|
||||
Allowed prefixes:
|
||||
- {}
|
||||
{}
|
||||
"#},
|
||||
db_or_user.lowercased(),
|
||||
name,
|
||||
db_or_user.lowercased(),
|
||||
db_or_user.lowercased(),
|
||||
username,
|
||||
groups
|
||||
.into_iter()
|
||||
.filter(|g| g != &username)
|
||||
.map(|g| format!(" - {}", g))
|
||||
.join("\n"),
|
||||
)
|
||||
.to_owned(),
|
||||
OwnerValidationError::StringEmpty => format!(
|
||||
"'{}' is not a valid {} name.",
|
||||
name,
|
||||
db_or_user.lowercased()
|
||||
)
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum OwnerValidationError {
|
||||
// The name is valid, but none of the given prefixes matched the name
|
||||
NoMatch,
|
||||
|
||||
// The name is empty, which is invalid
|
||||
StringEmpty,
|
||||
}
|
||||
|
||||
pub type CreateDatabasesOutput = BTreeMap<MySQLDatabase, Result<(), CreateDatabaseError>>;
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum CreateDatabaseError {
|
||||
SanitizationError(NameValidationError),
|
||||
OwnershipError(OwnerValidationError),
|
||||
DatabaseAlreadyExists,
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
pub fn print_create_databases_output_status(output: &CreateDatabasesOutput) {
|
||||
for (database_name, result) in output {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!("Database '{}' created successfully.", database_name);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(database_name));
|
||||
println!("Skipping...");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_create_databases_output_status_json(output: &CreateDatabasesOutput) {
|
||||
let value = output
|
||||
.iter()
|
||||
.map(|(name, result)| match result {
|
||||
Ok(()) => (name.to_string(), json!({ "status": "success" })),
|
||||
Err(err) => (
|
||||
name.to_string(),
|
||||
json!({
|
||||
"status": "error",
|
||||
"error": err.to_error_message(name),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&value)
|
||||
.unwrap_or("Failed to serialize result to JSON".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
impl CreateDatabaseError {
|
||||
pub fn to_error_message(&self, database_name: &MySQLDatabase) -> String {
|
||||
match self {
|
||||
CreateDatabaseError::SanitizationError(err) => {
|
||||
err.to_error_message(database_name, DbOrUser::Database)
|
||||
}
|
||||
CreateDatabaseError::OwnershipError(err) => {
|
||||
err.to_error_message(database_name, DbOrUser::Database)
|
||||
}
|
||||
CreateDatabaseError::DatabaseAlreadyExists => {
|
||||
format!("Database {} already exists.", database_name)
|
||||
}
|
||||
CreateDatabaseError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type DropDatabasesOutput = BTreeMap<MySQLDatabase, Result<(), DropDatabaseError>>;
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DropDatabaseError {
|
||||
SanitizationError(NameValidationError),
|
||||
OwnershipError(OwnerValidationError),
|
||||
DatabaseDoesNotExist,
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
pub fn print_drop_databases_output_status(output: &DropDatabasesOutput) {
|
||||
for (database_name, result) in output {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!(
|
||||
"Database '{}' dropped successfully.",
|
||||
database_name.as_str()
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(database_name));
|
||||
println!("Skipping...");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_drop_databases_output_status_json(output: &DropDatabasesOutput) {
|
||||
let value = output
|
||||
.iter()
|
||||
.map(|(name, result)| match result {
|
||||
Ok(()) => (name.to_string(), json!({ "status": "success" })),
|
||||
Err(err) => (
|
||||
name.to_string(),
|
||||
json!({
|
||||
"status": "error",
|
||||
"error": err.to_error_message(name),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&value)
|
||||
.unwrap_or("Failed to serialize result to JSON".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
impl DropDatabaseError {
|
||||
pub fn to_error_message(&self, database_name: &MySQLDatabase) -> String {
|
||||
match self {
|
||||
DropDatabaseError::SanitizationError(err) => {
|
||||
err.to_error_message(database_name, DbOrUser::Database)
|
||||
}
|
||||
DropDatabaseError::OwnershipError(err) => {
|
||||
err.to_error_message(database_name, DbOrUser::Database)
|
||||
}
|
||||
DropDatabaseError::DatabaseDoesNotExist => {
|
||||
format!("Database {} does not exist.", database_name)
|
||||
}
|
||||
DropDatabaseError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type ListDatabasesOutput = BTreeMap<MySQLDatabase, Result<DatabaseRow, ListDatabasesError>>;
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ListDatabasesError {
|
||||
SanitizationError(NameValidationError),
|
||||
OwnershipError(OwnerValidationError),
|
||||
DatabaseDoesNotExist,
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
impl ListDatabasesError {
|
||||
pub fn to_error_message(&self, database_name: &MySQLDatabase) -> String {
|
||||
match self {
|
||||
ListDatabasesError::SanitizationError(err) => {
|
||||
err.to_error_message(database_name, DbOrUser::Database)
|
||||
}
|
||||
ListDatabasesError::OwnershipError(err) => {
|
||||
err.to_error_message(database_name, DbOrUser::Database)
|
||||
}
|
||||
ListDatabasesError::DatabaseDoesNotExist => {
|
||||
format!("Database '{}' does not exist.", database_name)
|
||||
}
|
||||
ListDatabasesError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type ListAllDatabasesOutput = Result<Vec<DatabaseRow>, ListAllDatabasesError>;
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ListAllDatabasesError {
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
impl ListAllDatabasesError {
|
||||
pub fn to_error_message(&self) -> String {
|
||||
match self {
|
||||
ListAllDatabasesError::MySqlError(err) => format!("MySQL error: {}", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: merge all rows into a single collection.
|
||||
// they already contain which database they belong to.
|
||||
// no need to index by database name.
|
||||
|
||||
pub type GetDatabasesPrivilegeData =
|
||||
BTreeMap<MySQLDatabase, Result<Vec<DatabasePrivilegeRow>, GetDatabasesPrivilegeDataError>>;
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum GetDatabasesPrivilegeDataError {
|
||||
SanitizationError(NameValidationError),
|
||||
OwnershipError(OwnerValidationError),
|
||||
DatabaseDoesNotExist,
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
impl GetDatabasesPrivilegeDataError {
|
||||
pub fn to_error_message(&self, database_name: &MySQLDatabase) -> String {
|
||||
match self {
|
||||
GetDatabasesPrivilegeDataError::SanitizationError(err) => {
|
||||
err.to_error_message(database_name, DbOrUser::Database)
|
||||
}
|
||||
GetDatabasesPrivilegeDataError::OwnershipError(err) => {
|
||||
err.to_error_message(database_name, DbOrUser::Database)
|
||||
}
|
||||
GetDatabasesPrivilegeDataError::DatabaseDoesNotExist => {
|
||||
format!("Database '{}' does not exist.", database_name)
|
||||
}
|
||||
GetDatabasesPrivilegeDataError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type GetAllDatabasesPrivilegeData =
|
||||
Result<Vec<DatabasePrivilegeRow>, GetAllDatabasesPrivilegeDataError>;
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum GetAllDatabasesPrivilegeDataError {
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
impl GetAllDatabasesPrivilegeDataError {
|
||||
pub fn to_error_message(&self) -> String {
|
||||
match self {
|
||||
GetAllDatabasesPrivilegeDataError::MySqlError(err) => format!("MySQL error: {}", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type ModifyDatabasePrivilegesOutput =
|
||||
BTreeMap<(MySQLDatabase, MySQLUser), Result<(), ModifyDatabasePrivilegesError>>;
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ModifyDatabasePrivilegesError {
|
||||
DatabaseSanitizationError(NameValidationError),
|
||||
DatabaseOwnershipError(OwnerValidationError),
|
||||
UserSanitizationError(NameValidationError),
|
||||
UserOwnershipError(OwnerValidationError),
|
||||
DatabaseDoesNotExist,
|
||||
DiffDoesNotApply(DiffDoesNotApplyError),
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DiffDoesNotApplyError {
|
||||
RowAlreadyExists(MySQLDatabase, MySQLUser),
|
||||
RowDoesNotExist(MySQLDatabase, MySQLUser),
|
||||
RowPrivilegeChangeDoesNotApply(DatabasePrivilegeRowDiff, DatabasePrivilegeRow),
|
||||
}
|
||||
|
||||
pub fn print_modify_database_privileges_output_status(output: &ModifyDatabasePrivilegesOutput) {
|
||||
for ((database_name, username), result) in output {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!(
|
||||
"Privileges for user '{}' on database '{}' modified successfully.",
|
||||
username, database_name
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(database_name, username));
|
||||
println!("Skipping...");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
impl ModifyDatabasePrivilegesError {
|
||||
pub fn to_error_message(&self, database_name: &MySQLDatabase, username: &MySQLUser) -> String {
|
||||
match self {
|
||||
ModifyDatabasePrivilegesError::DatabaseSanitizationError(err) => {
|
||||
err.to_error_message(database_name, DbOrUser::Database)
|
||||
}
|
||||
ModifyDatabasePrivilegesError::DatabaseOwnershipError(err) => {
|
||||
err.to_error_message(database_name, DbOrUser::Database)
|
||||
}
|
||||
ModifyDatabasePrivilegesError::UserSanitizationError(err) => {
|
||||
err.to_error_message(username, DbOrUser::User)
|
||||
}
|
||||
ModifyDatabasePrivilegesError::UserOwnershipError(err) => {
|
||||
err.to_error_message(username, DbOrUser::User)
|
||||
}
|
||||
ModifyDatabasePrivilegesError::DatabaseDoesNotExist => {
|
||||
format!("Database '{}' does not exist.", database_name)
|
||||
}
|
||||
ModifyDatabasePrivilegesError::DiffDoesNotApply(diff) => {
|
||||
format!(
|
||||
"Could not apply privilege change:\n{}",
|
||||
diff.to_error_message()
|
||||
)
|
||||
}
|
||||
ModifyDatabasePrivilegesError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DiffDoesNotApplyError {
|
||||
pub fn to_error_message(&self) -> String {
|
||||
match self {
|
||||
DiffDoesNotApplyError::RowAlreadyExists(database_name, username) => {
|
||||
format!(
|
||||
"Privileges for user '{}' on database '{}' already exist.",
|
||||
username, database_name
|
||||
)
|
||||
}
|
||||
DiffDoesNotApplyError::RowDoesNotExist(database_name, username) => {
|
||||
format!(
|
||||
"Privileges for user '{}' on database '{}' do not exist.",
|
||||
username, database_name
|
||||
)
|
||||
}
|
||||
DiffDoesNotApplyError::RowPrivilegeChangeDoesNotApply(diff, row) => {
|
||||
format!(
|
||||
"Could not apply privilege change {:?} to row {:?}",
|
||||
diff, row
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type CreateUsersOutput = BTreeMap<MySQLUser, Result<(), CreateUserError>>;
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum CreateUserError {
|
||||
SanitizationError(NameValidationError),
|
||||
OwnershipError(OwnerValidationError),
|
||||
UserAlreadyExists,
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
pub fn print_create_users_output_status(output: &CreateUsersOutput) {
|
||||
for (username, result) in output {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!("User '{}' created successfully.", username);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(username));
|
||||
println!("Skipping...");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_create_users_output_status_json(output: &CreateUsersOutput) {
|
||||
let value = output
|
||||
.iter()
|
||||
.map(|(name, result)| match result {
|
||||
Ok(()) => (name.to_string(), json!({ "status": "success" })),
|
||||
Err(err) => (
|
||||
name.to_string(),
|
||||
json!({
|
||||
"status": "error",
|
||||
"error": err.to_error_message(name),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&value)
|
||||
.unwrap_or("Failed to serialize result to JSON".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
impl CreateUserError {
|
||||
pub fn to_error_message(&self, username: &MySQLUser) -> String {
|
||||
match self {
|
||||
CreateUserError::SanitizationError(err) => {
|
||||
err.to_error_message(username, DbOrUser::User)
|
||||
}
|
||||
CreateUserError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
|
||||
CreateUserError::UserAlreadyExists => {
|
||||
format!("User '{}' already exists.", username)
|
||||
}
|
||||
CreateUserError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type DropUsersOutput = BTreeMap<MySQLUser, Result<(), DropUserError>>;
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DropUserError {
|
||||
SanitizationError(NameValidationError),
|
||||
OwnershipError(OwnerValidationError),
|
||||
UserDoesNotExist,
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
pub fn print_drop_users_output_status(output: &DropUsersOutput) {
|
||||
for (username, result) in output {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!("User '{}' dropped successfully.", username);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(username));
|
||||
println!("Skipping...");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_drop_users_output_status_json(output: &DropUsersOutput) {
|
||||
let value = output
|
||||
.iter()
|
||||
.map(|(name, result)| match result {
|
||||
Ok(()) => (name.to_string(), json!({ "status": "success" })),
|
||||
Err(err) => (
|
||||
name.to_string(),
|
||||
json!({
|
||||
"status": "error",
|
||||
"error": err.to_error_message(name),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&value)
|
||||
.unwrap_or("Failed to serialize result to JSON".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
impl DropUserError {
|
||||
pub fn to_error_message(&self, username: &MySQLUser) -> String {
|
||||
match self {
|
||||
DropUserError::SanitizationError(err) => err.to_error_message(username, DbOrUser::User),
|
||||
DropUserError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
|
||||
DropUserError::UserDoesNotExist => {
|
||||
format!("User '{}' does not exist.", username)
|
||||
}
|
||||
DropUserError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type SetPasswordOutput = Result<(), SetPasswordError>;
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum SetPasswordError {
|
||||
SanitizationError(NameValidationError),
|
||||
OwnershipError(OwnerValidationError),
|
||||
UserDoesNotExist,
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
pub fn print_set_password_output_status(output: &SetPasswordOutput, username: &MySQLUser) {
|
||||
match output {
|
||||
Ok(()) => {
|
||||
println!("Password for user '{}' set successfully.", username);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(username));
|
||||
println!("Skipping...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SetPasswordError {
|
||||
pub fn to_error_message(&self, username: &MySQLUser) -> String {
|
||||
match self {
|
||||
SetPasswordError::SanitizationError(err) => {
|
||||
err.to_error_message(username, DbOrUser::User)
|
||||
}
|
||||
SetPasswordError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
|
||||
SetPasswordError::UserDoesNotExist => {
|
||||
format!("User '{}' does not exist.", username)
|
||||
}
|
||||
SetPasswordError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type LockUsersOutput = BTreeMap<MySQLUser, Result<(), LockUserError>>;
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum LockUserError {
|
||||
SanitizationError(NameValidationError),
|
||||
OwnershipError(OwnerValidationError),
|
||||
UserDoesNotExist,
|
||||
UserIsAlreadyLocked,
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
pub fn print_lock_users_output_status(output: &LockUsersOutput) {
|
||||
for (username, result) in output {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!("User '{}' locked successfully.", username);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(username));
|
||||
println!("Skipping...");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_lock_users_output_status_json(output: &LockUsersOutput) {
|
||||
let value = output
|
||||
.iter()
|
||||
.map(|(name, result)| match result {
|
||||
Ok(()) => (name.to_string(), json!({ "status": "success" })),
|
||||
Err(err) => (
|
||||
name.to_string(),
|
||||
json!({
|
||||
"status": "error",
|
||||
"error": err.to_error_message(name),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&value)
|
||||
.unwrap_or("Failed to serialize result to JSON".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
impl LockUserError {
|
||||
pub fn to_error_message(&self, username: &MySQLUser) -> String {
|
||||
match self {
|
||||
LockUserError::SanitizationError(err) => err.to_error_message(username, DbOrUser::User),
|
||||
LockUserError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
|
||||
LockUserError::UserDoesNotExist => {
|
||||
format!("User '{}' does not exist.", username)
|
||||
}
|
||||
LockUserError::UserIsAlreadyLocked => {
|
||||
format!("User '{}' is already locked.", username)
|
||||
}
|
||||
LockUserError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type UnlockUsersOutput = BTreeMap<MySQLUser, Result<(), UnlockUserError>>;
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum UnlockUserError {
|
||||
SanitizationError(NameValidationError),
|
||||
OwnershipError(OwnerValidationError),
|
||||
UserDoesNotExist,
|
||||
UserIsAlreadyUnlocked,
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
pub fn print_unlock_users_output_status(output: &UnlockUsersOutput) {
|
||||
for (username, result) in output {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!("User '{}' unlocked successfully.", username);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", err.to_error_message(username));
|
||||
println!("Skipping...");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_unlock_users_output_status_json(output: &UnlockUsersOutput) {
|
||||
let value = output
|
||||
.iter()
|
||||
.map(|(name, result)| match result {
|
||||
Ok(()) => (name.to_string(), json!({ "status": "success" })),
|
||||
Err(err) => (
|
||||
name.to_string(),
|
||||
json!({
|
||||
"status": "error",
|
||||
"error": err.to_error_message(name),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&value)
|
||||
.unwrap_or("Failed to serialize result to JSON".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
impl UnlockUserError {
|
||||
pub fn to_error_message(&self, username: &MySQLUser) -> String {
|
||||
match self {
|
||||
UnlockUserError::SanitizationError(err) => {
|
||||
err.to_error_message(username, DbOrUser::User)
|
||||
}
|
||||
UnlockUserError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
|
||||
UnlockUserError::UserDoesNotExist => {
|
||||
format!("User '{}' does not exist.", username)
|
||||
}
|
||||
UnlockUserError::UserIsAlreadyUnlocked => {
|
||||
format!("User '{}' is already unlocked.", username)
|
||||
}
|
||||
UnlockUserError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type ListUsersOutput = BTreeMap<MySQLUser, Result<DatabaseUser, ListUsersError>>;
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ListUsersError {
|
||||
SanitizationError(NameValidationError),
|
||||
OwnershipError(OwnerValidationError),
|
||||
UserDoesNotExist,
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
impl ListUsersError {
|
||||
pub fn to_error_message(&self, username: &MySQLUser) -> String {
|
||||
match self {
|
||||
ListUsersError::SanitizationError(err) => {
|
||||
err.to_error_message(username, DbOrUser::User)
|
||||
}
|
||||
ListUsersError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
|
||||
ListUsersError::UserDoesNotExist => {
|
||||
format!("User '{}' does not exist.", username)
|
||||
}
|
||||
ListUsersError::MySqlError(err) => {
|
||||
format!("MySQL error: {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type ListAllUsersOutput = Result<Vec<DatabaseUser>, ListAllUsersError>;
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ListAllUsersError {
|
||||
MySqlError(String),
|
||||
}
|
||||
|
||||
impl ListAllUsersError {
|
||||
pub fn to_error_message(&self) -> String {
|
||||
match self {
|
||||
ListAllUsersError::MySqlError(err) => format!("MySQL error: {}", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
135
src/core/types.rs
Normal file
135
src/core/types.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
fmt,
|
||||
ops::{Deref, DerefMut},
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
|
||||
pub struct MySQLUser(String);
|
||||
|
||||
impl FromStr for MySQLUser {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(MySQLUser(s.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for MySQLUser {
|
||||
type Target = String;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for MySQLUser {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MySQLUser {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for MySQLUser {
|
||||
fn from(s: &str) -> Self {
|
||||
MySQLUser(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for MySQLUser {
|
||||
fn from(s: String) -> Self {
|
||||
MySQLUser(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MySQLUser> for OsString {
|
||||
fn from(val: MySQLUser) -> Self {
|
||||
val.0.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
|
||||
pub struct MySQLDatabase(String);
|
||||
|
||||
impl FromStr for MySQLDatabase {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(MySQLDatabase(s.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for MySQLDatabase {
|
||||
type Target = String;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for MySQLDatabase {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MySQLDatabase {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for MySQLDatabase {
|
||||
fn from(s: &str) -> Self {
|
||||
MySQLDatabase(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for MySQLDatabase {
|
||||
fn from(s: String) -> Self {
|
||||
MySQLDatabase(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MySQLDatabase> for OsString {
|
||||
fn from(val: MySQLDatabase) -> Self {
|
||||
val.0.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum DbOrUser {
|
||||
Database(MySQLDatabase),
|
||||
User(MySQLUser),
|
||||
}
|
||||
|
||||
impl DbOrUser {
|
||||
pub fn lowercased_noun(&self) -> &'static str {
|
||||
match self {
|
||||
DbOrUser::Database(_) => "database",
|
||||
DbOrUser::User(_) => "user",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn capitalized_noun(&self) -> &'static str {
|
||||
match self {
|
||||
DbOrUser::Database(_) => "Database",
|
||||
DbOrUser::User(_) => "User",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
DbOrUser::Database(db) => db.as_str(),
|
||||
DbOrUser::User(user) => user.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
203
src/main.rs
203
src/main.rs
@@ -2,36 +2,65 @@
|
||||
extern crate prettytable;
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::{CommandFactory, Parser, ValueEnum};
|
||||
use clap_complete::{Shell, generate};
|
||||
use clap_verbosity_flag::Verbosity;
|
||||
use clap::{CommandFactory, Parser, crate_version};
|
||||
use clap_complete::CompleteEnv;
|
||||
use clap_verbosity_flag::{InfoLevel, Verbosity};
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use std::os::unix::net::UnixStream as StdUnixStream;
|
||||
use tokio::net::UnixStream as TokioUnixStream;
|
||||
|
||||
use futures::StreamExt;
|
||||
use futures_util::StreamExt;
|
||||
|
||||
use crate::{
|
||||
core::{
|
||||
bootstrap::bootstrap_server_connection_and_drop_privileges,
|
||||
common::executable_is_suid_or_sgid,
|
||||
common::{ASCII_BANNER, KIND_REGARDS, executing_in_suid_sgid_mode},
|
||||
protocol::{Response, create_client_to_server_message_stream},
|
||||
},
|
||||
server::command::ServerArgs,
|
||||
server::{command::ServerArgs, landlock::landlock_restrict_server},
|
||||
};
|
||||
|
||||
#[cfg(feature = "mysql-admutils-compatibility")]
|
||||
use crate::cli::mysql_admutils_compatibility::{mysql_dbadm, mysql_useradm};
|
||||
use crate::client::mysql_admutils_compatibility::{mysql_dbadm, mysql_useradm};
|
||||
|
||||
mod server;
|
||||
|
||||
mod cli;
|
||||
mod client;
|
||||
mod core;
|
||||
|
||||
#[cfg(feature = "tui")]
|
||||
mod tui;
|
||||
const fn long_version() -> &'static str {
|
||||
macro_rules! feature {
|
||||
($title:expr, $flag:expr) => {
|
||||
if cfg!(feature = $flag) {
|
||||
concat!($title, ": enabled")
|
||||
} else {
|
||||
concat!($title, ": disabled")
|
||||
}
|
||||
};
|
||||
}
|
||||
const_format::concatcp!(
|
||||
crate_version!(),
|
||||
"\n",
|
||||
"build profile: ",
|
||||
env!("BUILD_PROFILE"),
|
||||
"\n",
|
||||
"commit: ",
|
||||
env!("GIT_COMMIT"),
|
||||
"\n\n",
|
||||
"[features]\n",
|
||||
feature!("SUID/SGID mode", "suid-sgid-mode"),
|
||||
"\n",
|
||||
feature!(
|
||||
"mysql-admutils compatibility",
|
||||
"mysql-admutils-compatibility"
|
||||
),
|
||||
"\n",
|
||||
)
|
||||
}
|
||||
|
||||
const LONG_VERSION: &str = long_version();
|
||||
|
||||
/// Database administration tool for non-admin users to manage their own MySQL databases and users.
|
||||
///
|
||||
@@ -40,7 +69,19 @@ mod tui;
|
||||
/// You are only allowed to manage databases and users that are prefixed with
|
||||
/// either your username, or a group that you are a member of.
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(bin_name = "mysqladm", version, about, disable_help_subcommand = true)]
|
||||
#[command(
|
||||
bin_name = "muscl",
|
||||
author = "Programvareverkstedet <projects@pvv.ntnu.no>",
|
||||
version,
|
||||
about,
|
||||
disable_help_subcommand = true,
|
||||
propagate_version = true,
|
||||
before_long_help = ASCII_BANNER,
|
||||
after_long_help = KIND_REGARDS,
|
||||
long_version = LONG_VERSION,
|
||||
// NOTE: All non-registered "subcommands" are processed before Arg::parse() is called.
|
||||
subcommand_required = true,
|
||||
)]
|
||||
struct Args {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
@@ -50,6 +91,7 @@ struct Args {
|
||||
short,
|
||||
long,
|
||||
value_name = "PATH",
|
||||
value_hint = clap::ValueHint::FilePath,
|
||||
global = true,
|
||||
hide_short_help = true
|
||||
)]
|
||||
@@ -60,54 +102,32 @@ struct Args {
|
||||
short,
|
||||
long,
|
||||
value_name = "PATH",
|
||||
value_hint = clap::ValueHint::FilePath,
|
||||
global = true,
|
||||
hide_short_help = true
|
||||
)]
|
||||
config: Option<PathBuf>,
|
||||
|
||||
#[command(flatten)]
|
||||
verbose: Verbosity,
|
||||
|
||||
/// Run in TUI mode.
|
||||
#[cfg(feature = "tui")]
|
||||
#[arg(short, long, alias = "tui", global = true)]
|
||||
interactive: bool,
|
||||
verbose: Verbosity<InfoLevel>,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
enum Command {
|
||||
#[command(flatten)]
|
||||
Db(cli::database_command::DatabaseCommand),
|
||||
|
||||
#[command(flatten)]
|
||||
User(cli::user_command::UserCommand),
|
||||
Client(client::commands::ClientCommand),
|
||||
|
||||
/// Run the server
|
||||
#[command(hide = true)]
|
||||
Server(server::command::ServerArgs),
|
||||
|
||||
#[command(hide = true)]
|
||||
GenerateCompletions(GenerateCompletionArgs),
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
struct GenerateCompletionArgs {
|
||||
#[arg(long, default_value = "bash")]
|
||||
shell: Shell,
|
||||
|
||||
#[arg(long, default_value = "mysqladm")]
|
||||
command: ToplevelCommands,
|
||||
}
|
||||
|
||||
#[cfg(feature = "mysql-admutils-compatibility")]
|
||||
#[derive(ValueEnum, Debug, Clone)]
|
||||
enum ToplevelCommands {
|
||||
Mysqladm,
|
||||
MysqlDbadm,
|
||||
MysqlUseradm,
|
||||
}
|
||||
|
||||
/// **WARNING:** This function may be run with elevated privileges.
|
||||
fn main() -> anyhow::Result<()> {
|
||||
if handle_dynamic_completion()?.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(feature = "mysql-admutils-compatibility")]
|
||||
if handle_mysql_admutils_command()?.is_some() {
|
||||
return Ok(());
|
||||
@@ -119,10 +139,6 @@ fn main() -> anyhow::Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if handle_generate_completions_command(&args)?.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let connection = bootstrap_server_connection_and_drop_privileges(
|
||||
args.server_socket_path,
|
||||
args.config,
|
||||
@@ -134,6 +150,41 @@ fn main() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// **WARNING:** This function may be run with elevated privileges.
|
||||
fn handle_dynamic_completion() -> anyhow::Result<Option<()>> {
|
||||
if std::env::var_os("COMPLETE").is_some() {
|
||||
#[cfg(feature = "suid-sgid-mode")]
|
||||
if executing_in_suid_sgid_mode()? {
|
||||
use crate::core::bootstrap::drop_privs;
|
||||
drop_privs()?
|
||||
}
|
||||
|
||||
let argv0 = std::env::args()
|
||||
.next()
|
||||
.and_then(|s| {
|
||||
PathBuf::from(s)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
})
|
||||
.ok_or(anyhow::anyhow!(
|
||||
"Could not determine executable name for completion"
|
||||
))?;
|
||||
|
||||
let command = match argv0.as_str() {
|
||||
"muscl" => Args::command(),
|
||||
"mysql-dbadm" => mysql_dbadm::Args::command(),
|
||||
"mysql-useradm" => mysql_useradm::Args::command(),
|
||||
command => anyhow::bail!("Unknown executable name: `{}`", command),
|
||||
};
|
||||
|
||||
CompleteEnv::with_factory(move || command.clone()).complete();
|
||||
|
||||
Ok(Some(()))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// **WARNING:** This function may be run with elevated privileges.
|
||||
fn handle_mysql_admutils_command() -> anyhow::Result<Option<()>> {
|
||||
let argv0 = std::env::args().next().and_then(|s| {
|
||||
@@ -154,11 +205,16 @@ fn handle_server_command(args: &Args) -> anyhow::Result<Option<()>> {
|
||||
match args.command {
|
||||
Command::Server(ref command) => {
|
||||
assert!(
|
||||
!executable_is_suid_or_sgid()?,
|
||||
!executing_in_suid_sgid_mode()?,
|
||||
"The executable should not be SUID or SGID when running the server manually"
|
||||
);
|
||||
|
||||
if !command.disable_landlock {
|
||||
landlock_restrict_server(args.config.as_deref())
|
||||
.context("Failed to apply Landlock restrictions to the server process")?;
|
||||
}
|
||||
|
||||
tokio_start_server(
|
||||
args.server_socket_path.to_owned(),
|
||||
args.config.to_owned(),
|
||||
args.verbose.to_owned(),
|
||||
command.to_owned(),
|
||||
@@ -169,51 +225,26 @@ fn handle_server_command(args: &Args) -> anyhow::Result<Option<()>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// **WARNING:** This function may be run with elevated privileges.
|
||||
fn handle_generate_completions_command(args: &Args) -> anyhow::Result<Option<()>> {
|
||||
match args.command {
|
||||
Command::GenerateCompletions(ref completion_args) => {
|
||||
assert!(
|
||||
!executable_is_suid_or_sgid()?,
|
||||
"The executable should not be SUID or SGID when generating completions"
|
||||
);
|
||||
let mut cmd = match completion_args.command {
|
||||
ToplevelCommands::Mysqladm => Args::command(),
|
||||
#[cfg(feature = "mysql-admutils-compatibility")]
|
||||
ToplevelCommands::MysqlDbadm => mysql_dbadm::Args::command(),
|
||||
#[cfg(feature = "mysql-admutils-compatibility")]
|
||||
ToplevelCommands::MysqlUseradm => mysql_useradm::Args::command(),
|
||||
};
|
||||
|
||||
let binary_name = cmd.get_bin_name().unwrap().to_owned();
|
||||
|
||||
generate(
|
||||
completion_args.shell,
|
||||
&mut cmd,
|
||||
binary_name,
|
||||
&mut std::io::stdout(),
|
||||
);
|
||||
|
||||
Ok(Some(()))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
const MIN_TOKIO_WORKER_THREADS: usize = 4;
|
||||
|
||||
/// Start a long-lived server using Tokio.
|
||||
fn tokio_start_server(
|
||||
server_socket_path: Option<PathBuf>,
|
||||
config_path: Option<PathBuf>,
|
||||
verbosity: Verbosity,
|
||||
verbosity: Verbosity<InfoLevel>,
|
||||
args: ServerArgs,
|
||||
) -> anyhow::Result<()> {
|
||||
let worker_thread_count = std::cmp::max(num_cpus::get(), MIN_TOKIO_WORKER_THREADS);
|
||||
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(worker_thread_count)
|
||||
.enable_all()
|
||||
.build()
|
||||
.context("Failed to start Tokio runtime")?
|
||||
.block_on(async {
|
||||
server::command::handle_command(server_socket_path, config_path, verbosity, args).await
|
||||
})
|
||||
.block_on(server::command::handle_command(
|
||||
config_path,
|
||||
verbosity,
|
||||
args,
|
||||
))
|
||||
}
|
||||
|
||||
/// Run the given commmand (from the client side) using Tokio.
|
||||
@@ -241,14 +272,10 @@ fn tokio_run_command(command: Command, server_connection: StdUnixStream) -> anyh
|
||||
}
|
||||
|
||||
match command {
|
||||
Command::User(user_args) => {
|
||||
cli::user_command::handle_command(user_args, message_stream).await
|
||||
}
|
||||
Command::Db(db_args) => {
|
||||
cli::database_command::handle_command(db_args, message_stream).await
|
||||
Command::Client(client_args) => {
|
||||
client::commands::handle_command(client_args, message_stream).await
|
||||
}
|
||||
Command::Server(_) => unreachable!(),
|
||||
Command::GenerateCompletions(_) => unreachable!(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
mod authorization;
|
||||
pub mod command;
|
||||
mod common;
|
||||
pub mod config;
|
||||
pub mod input_sanitization;
|
||||
pub mod server_loop;
|
||||
pub mod landlock;
|
||||
pub mod session_handler;
|
||||
pub mod sql;
|
||||
pub mod supervisor;
|
||||
|
||||
25
src/server/authorization.rs
Normal file
25
src/server/authorization.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use crate::core::{
|
||||
common::UnixUser,
|
||||
protocol::{CheckAuthorizationError, request_validation::validate_db_or_user_request},
|
||||
types::DbOrUser,
|
||||
};
|
||||
|
||||
pub async fn check_authorization(
|
||||
dbs_or_users: Vec<DbOrUser>,
|
||||
unix_user: &UnixUser,
|
||||
) -> 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)
|
||||
{
|
||||
results.insert(db_or_user.clone(), Err(err));
|
||||
continue;
|
||||
}
|
||||
|
||||
results.insert(db_or_user.clone(), Ok(()));
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
@@ -1,36 +1,38 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::Parser;
|
||||
use clap_verbosity_flag::Verbosity;
|
||||
use systemd_journal_logger::JournalLog;
|
||||
use clap::{Parser, Subcommand};
|
||||
use clap_verbosity_flag::{InfoLevel, Verbosity};
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
use crate::server::{
|
||||
config::{ServerConfigArgs, read_config_from_path_with_arg_overrides},
|
||||
server_loop::{
|
||||
listen_for_incoming_connections_with_socket_path,
|
||||
listen_for_incoming_connections_with_systemd_socket,
|
||||
},
|
||||
use crate::{
|
||||
core::common::{ASCII_BANNER, DEFAULT_CONFIG_PATH, KIND_REGARDS},
|
||||
server::supervisor::Supervisor,
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct ServerArgs {
|
||||
#[command(subcommand)]
|
||||
subcmd: ServerCommand,
|
||||
|
||||
#[command(flatten)]
|
||||
config_overrides: ServerConfigArgs,
|
||||
pub subcmd: ServerCommand,
|
||||
|
||||
/// Enable systemd mode
|
||||
#[arg(long)]
|
||||
systemd: bool,
|
||||
pub systemd: bool,
|
||||
|
||||
/// Disable Landlock sandboxing.
|
||||
///
|
||||
/// This is useful if you are planning to reload the server's configuration.
|
||||
#[arg(long)]
|
||||
pub disable_landlock: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum ServerCommand {
|
||||
#[command()]
|
||||
/// Start the server and listen for incoming connections on the unix socket
|
||||
/// specified in the configuration file.
|
||||
Listen,
|
||||
|
||||
#[command()]
|
||||
/// Start the server using systemd socket activation.
|
||||
SocketActivate,
|
||||
}
|
||||
|
||||
@@ -45,10 +47,14 @@ const LOG_LEVEL_WARNING: &str = r#"
|
||||
===================================================
|
||||
"#;
|
||||
|
||||
pub fn trace_server_prelude() {
|
||||
let message = [ASCII_BANNER, "", KIND_REGARDS, ""].join("\n");
|
||||
tracing::info!(message);
|
||||
}
|
||||
|
||||
pub async fn handle_command(
|
||||
socket_path: Option<PathBuf>,
|
||||
config_path: Option<PathBuf>,
|
||||
verbosity: Verbosity,
|
||||
verbosity: Verbosity<InfoLevel>,
|
||||
args: ServerArgs,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut auto_detected_systemd_mode = false;
|
||||
@@ -60,38 +66,53 @@ pub async fn handle_command(
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if systemd_mode {
|
||||
JournalLog::new()
|
||||
.context("Failed to initialize journald logging")?
|
||||
.install()
|
||||
.context("Failed to install journald logger")?;
|
||||
let subscriber = tracing_subscriber::Registry::default()
|
||||
.with(verbosity.tracing_level_filter())
|
||||
.with(tracing_journald::layer()?);
|
||||
|
||||
log::set_max_level(verbosity.log_level_filter());
|
||||
tracing::subscriber::set_global_default(subscriber)
|
||||
.context("Failed to set global default tracing subscriber")?;
|
||||
|
||||
if verbosity.log_level_filter() >= log::LevelFilter::Trace {
|
||||
log::warn!("{}", LOG_LEVEL_WARNING.trim());
|
||||
trace_server_prelude();
|
||||
|
||||
if verbosity.tracing_level_filter() >= tracing::Level::TRACE {
|
||||
tracing::warn!("{}", LOG_LEVEL_WARNING.trim());
|
||||
}
|
||||
|
||||
if auto_detected_systemd_mode {
|
||||
log::info!("Running in systemd mode, auto-detected");
|
||||
tracing::debug!("Running in systemd mode, auto-detected");
|
||||
} else {
|
||||
log::info!("Running in systemd mode");
|
||||
tracing::debug!("Running in systemd mode");
|
||||
}
|
||||
|
||||
start_watchdog_thread_if_enabled();
|
||||
} else {
|
||||
env_logger::Builder::new()
|
||||
.filter_level(verbosity.log_level_filter())
|
||||
.init();
|
||||
let subscriber = tracing_subscriber::Registry::default()
|
||||
.with(verbosity.tracing_level_filter())
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_line_number(cfg!(debug_assertions))
|
||||
.with_target(cfg!(debug_assertions))
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false),
|
||||
);
|
||||
|
||||
log::info!("Running in standalone mode");
|
||||
tracing::subscriber::set_global_default(subscriber)
|
||||
.context("Failed to set global default tracing subscriber")?;
|
||||
|
||||
trace_server_prelude();
|
||||
|
||||
tracing::debug!("Running in standalone mode");
|
||||
}
|
||||
|
||||
let config = read_config_from_path_with_arg_overrides(config_path, args.config_overrides)?;
|
||||
let config_path = config_path.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH));
|
||||
|
||||
match args.subcmd {
|
||||
ServerCommand::Listen => {
|
||||
listen_for_incoming_connections_with_socket_path(socket_path, config).await
|
||||
Supervisor::new(config_path, systemd_mode)
|
||||
.await?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
ServerCommand::SocketActivate => {
|
||||
if !args.systemd {
|
||||
@@ -101,33 +122,10 @@ pub async fn handle_command(
|
||||
));
|
||||
}
|
||||
|
||||
listen_for_incoming_connections_with_systemd_socket(config).await
|
||||
Supervisor::new(config_path, systemd_mode)
|
||||
.await?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_watchdog_thread_if_enabled() {
|
||||
let mut micro_seconds: u64 = 0;
|
||||
let watchdog_enabled = sd_notify::watchdog_enabled(false, &mut micro_seconds);
|
||||
|
||||
if watchdog_enabled {
|
||||
micro_seconds = micro_seconds.max(2_000_000).div_ceil(2);
|
||||
|
||||
tokio::spawn(async move {
|
||||
log::debug!(
|
||||
"Starting systemd watchdog thread with {} millisecond interval",
|
||||
micro_seconds.div_ceil(1000)
|
||||
);
|
||||
loop {
|
||||
tokio::time::sleep(tokio::time::Duration::from_micros(micro_seconds)).await;
|
||||
if let Err(err) = sd_notify::notify(false, &[sd_notify::NotifyState::Watchdog]) {
|
||||
log::warn!("Failed to notify systemd watchdog: {}", err);
|
||||
} else {
|
||||
log::trace!("Ping sent to systemd watchdog");
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
log::debug!("Systemd watchdog not enabled, skipping watchdog thread");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,170 +1,97 @@
|
||||
use std::{fs, path::PathBuf, time::Duration};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use anyhow::{Context, anyhow};
|
||||
use clap::Parser;
|
||||
use anyhow::Context;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{ConnectOptions, MySqlConnection, mysql::MySqlConnectOptions};
|
||||
|
||||
use crate::core::common::DEFAULT_CONFIG_PATH;
|
||||
use sqlx::{ConnectOptions, mysql::MySqlConnectOptions};
|
||||
|
||||
pub const DEFAULT_PORT: u16 = 3306;
|
||||
pub const DEFAULT_TIMEOUT: u64 = 2;
|
||||
|
||||
// NOTE: this might look empty now, and the extra wrapping for the mysql
|
||||
// config seems unnecessary, but it will be useful later when we
|
||||
// add more configuration options.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ServerConfig {
|
||||
pub mysql: MysqlConfig,
|
||||
fn default_mysql_port() -> u16 {
|
||||
DEFAULT_PORT
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub const DEFAULT_TIMEOUT: u64 = 2;
|
||||
fn default_mysql_timeout() -> u64 {
|
||||
DEFAULT_TIMEOUT
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename = "mysql")]
|
||||
pub struct MysqlConfig {
|
||||
pub socket_path: Option<PathBuf>,
|
||||
pub host: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
#[serde(default = "default_mysql_port")]
|
||||
pub port: u16,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub password_file: Option<PathBuf>,
|
||||
pub timeout: Option<u64>,
|
||||
#[serde(default = "default_mysql_timeout")]
|
||||
pub timeout: u64,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct ServerConfigArgs {
|
||||
/// Path to the socket of the MySQL server.
|
||||
#[arg(long, value_name = "PATH", global = true)]
|
||||
socket_path: Option<PathBuf>,
|
||||
impl MysqlConfig {
|
||||
pub fn as_mysql_connect_options(&self) -> anyhow::Result<MySqlConnectOptions> {
|
||||
let mut options = MySqlConnectOptions::new()
|
||||
.database("mysql")
|
||||
.log_statements(tracing::log::LevelFilter::Trace);
|
||||
|
||||
/// Hostname of the MySQL server.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "HOST",
|
||||
global = true,
|
||||
conflicts_with = "socket_path"
|
||||
)]
|
||||
mysql_host: Option<String>,
|
||||
|
||||
/// Port of the MySQL server.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "PORT",
|
||||
global = true,
|
||||
conflicts_with = "socket_path"
|
||||
)]
|
||||
mysql_port: Option<u16>,
|
||||
|
||||
/// Username to use for the MySQL connection.
|
||||
#[arg(long, value_name = "USER", global = true)]
|
||||
mysql_user: Option<String>,
|
||||
|
||||
/// Path to a file containing the MySQL password.
|
||||
#[arg(long, value_name = "PATH", global = true)]
|
||||
mysql_password_file: Option<PathBuf>,
|
||||
|
||||
/// Seconds to wait for the MySQL connection to be established.
|
||||
#[arg(long, value_name = "SECONDS", global = true)]
|
||||
mysql_connect_timeout: Option<u64>,
|
||||
}
|
||||
|
||||
/// Use the arguments and whichever configuration file which might or might not
|
||||
/// be found and default values to determine the configuration for the program.
|
||||
pub fn read_config_from_path_with_arg_overrides(
|
||||
config_path: Option<PathBuf>,
|
||||
args: ServerConfigArgs,
|
||||
) -> anyhow::Result<ServerConfig> {
|
||||
let config = read_config_from_path(config_path)?;
|
||||
|
||||
let mysql = config.mysql;
|
||||
|
||||
let password = if let Some(path) = &args.mysql_password_file {
|
||||
Some(
|
||||
fs::read_to_string(path)
|
||||
.context("Failed to read MySQL password file")
|
||||
.map(|s| s.trim().to_owned())?,
|
||||
)
|
||||
} else if let Some(path) = &mysql.password_file {
|
||||
Some(
|
||||
fs::read_to_string(path)
|
||||
.context("Failed to read MySQL password file")
|
||||
.map(|s| s.trim().to_owned())?,
|
||||
)
|
||||
} else {
|
||||
mysql.password.to_owned()
|
||||
};
|
||||
|
||||
Ok(ServerConfig {
|
||||
mysql: MysqlConfig {
|
||||
socket_path: args.socket_path.or(mysql.socket_path),
|
||||
host: args.mysql_host.or(mysql.host),
|
||||
port: args.mysql_port.or(mysql.port),
|
||||
username: args.mysql_user.or(mysql.username.to_owned()),
|
||||
password,
|
||||
password_file: args.mysql_password_file.or(mysql.password_file),
|
||||
timeout: args.mysql_connect_timeout.or(mysql.timeout),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_config_from_path(config_path: Option<PathBuf>) -> anyhow::Result<ServerConfig> {
|
||||
let config_path = config_path.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH));
|
||||
|
||||
log::debug!("Reading config file at {:?}", &config_path);
|
||||
|
||||
fs::read_to_string(&config_path)
|
||||
.context(format!("Failed to read config file at {:?}", &config_path))
|
||||
.and_then(|c| toml::from_str(&c).context("Failed to parse config file"))
|
||||
.context(format!("Failed to parse config file at {:?}", &config_path))
|
||||
}
|
||||
|
||||
fn log_config(config: &MysqlConfig) {
|
||||
let mut display_config = config.to_owned();
|
||||
display_config.password = display_config
|
||||
.password
|
||||
.as_ref()
|
||||
.map(|_| "<REDACTED>".to_owned());
|
||||
log::debug!(
|
||||
"Connecting to MySQL server with parameters: {:#?}",
|
||||
display_config
|
||||
);
|
||||
}
|
||||
|
||||
/// Use the provided configuration to establish a connection to a MySQL server.
|
||||
pub async fn create_mysql_connection_from_config(
|
||||
config: &MysqlConfig,
|
||||
) -> anyhow::Result<MySqlConnection> {
|
||||
log_config(config);
|
||||
|
||||
let mut mysql_options = MySqlConnectOptions::new()
|
||||
.database("mysql")
|
||||
.log_statements(log::LevelFilter::Trace);
|
||||
|
||||
if let Some(username) = &config.username {
|
||||
mysql_options = mysql_options.username(username);
|
||||
}
|
||||
|
||||
if let Some(password) = &config.password {
|
||||
mysql_options = mysql_options.password(password);
|
||||
}
|
||||
|
||||
if let Some(socket_path) = &config.socket_path {
|
||||
mysql_options = mysql_options.socket(socket_path);
|
||||
} else if let Some(host) = &config.host {
|
||||
mysql_options = mysql_options.host(host);
|
||||
mysql_options = mysql_options.port(config.port.unwrap_or(DEFAULT_PORT));
|
||||
} else {
|
||||
anyhow::bail!("No MySQL host or socket path provided");
|
||||
}
|
||||
|
||||
match tokio::time::timeout(
|
||||
Duration::from_secs(config.timeout.unwrap_or(DEFAULT_TIMEOUT)),
|
||||
mysql_options.connect(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(connection) => connection.context("Failed to connect to the database"),
|
||||
Err(_) => {
|
||||
Err(anyhow!("Timed out after 2 seconds")).context("Failed to connect to the database")
|
||||
if let Some(username) = &self.username {
|
||||
options = options.username(username);
|
||||
}
|
||||
|
||||
if let Some(password_file) = &self.password_file {
|
||||
let password = fs::read_to_string(password_file)
|
||||
.with_context(|| {
|
||||
format!("Failed to read MySQL password file at {:?}", password_file)
|
||||
})?
|
||||
.trim()
|
||||
.to_owned();
|
||||
options = options.password(&password);
|
||||
} else if let Some(password) = &self.password {
|
||||
options = options.password(password);
|
||||
}
|
||||
|
||||
if let Some(socket_path) = &self.socket_path {
|
||||
options = options.socket(socket_path);
|
||||
} else if let Some(host) = &self.host {
|
||||
options = options.host(host);
|
||||
options = options.port(self.port);
|
||||
} else {
|
||||
anyhow::bail!("No MySQL host or socket path provided");
|
||||
}
|
||||
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
pub fn log_connection_notice(&self) {
|
||||
let mut display_config = self.to_owned();
|
||||
display_config.password = display_config
|
||||
.password
|
||||
.as_ref()
|
||||
.map(|_| "<REDACTED>".to_owned());
|
||||
tracing::debug!(
|
||||
"Connecting to MySQL server with parameters: {:#?}",
|
||||
display_config
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
pub struct ServerConfig {
|
||||
pub socket_path: Option<PathBuf>,
|
||||
pub mysql: MysqlConfig,
|
||||
}
|
||||
|
||||
impl ServerConfig {
|
||||
/// Reads the server configuration from the specified path, or the default path if none is provided.
|
||||
pub fn read_config_from_path(config_path: &Path) -> anyhow::Result<Self> {
|
||||
tracing::debug!("Reading config file at {:?}", config_path);
|
||||
|
||||
fs::read_to_string(config_path)
|
||||
.context(format!("Failed to read config file at {:?}", config_path))
|
||||
.and_then(|c| toml::from_str(&c).context("Failed to parse config file"))
|
||||
.context(format!("Failed to parse config file at {:?}", config_path))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
use crate::core::{
|
||||
common::UnixUser,
|
||||
protocol::server_responses::{NameValidationError, OwnerValidationError},
|
||||
};
|
||||
|
||||
const MAX_NAME_LENGTH: usize = 64;
|
||||
|
||||
pub fn validate_name(name: &str) -> Result<(), NameValidationError> {
|
||||
if name.is_empty() {
|
||||
Err(NameValidationError::EmptyString)
|
||||
} else if name.len() > MAX_NAME_LENGTH {
|
||||
Err(NameValidationError::TooLong)
|
||||
} else if !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
Err(NameValidationError::InvalidCharacters)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_ownership_by_unix_user(
|
||||
name: &str,
|
||||
user: &UnixUser,
|
||||
) -> Result<(), OwnerValidationError> {
|
||||
let prefixes = std::iter::once(user.username.to_owned())
|
||||
.chain(user.groups.iter().cloned())
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
validate_ownership_by_prefixes(name, &prefixes)
|
||||
}
|
||||
|
||||
/// Core logic for validating the ownership of a database name.
|
||||
/// This function checks if the given name matches any of the given prefixes.
|
||||
/// These prefixes will in most cases be the user's unix username and any
|
||||
/// unix groups the user is a member of.
|
||||
pub fn validate_ownership_by_prefixes(
|
||||
name: &str,
|
||||
prefixes: &[String],
|
||||
) -> Result<(), OwnerValidationError> {
|
||||
if name.is_empty() {
|
||||
return Err(OwnerValidationError::StringEmpty);
|
||||
}
|
||||
|
||||
if prefixes
|
||||
.iter()
|
||||
.filter(|p| name.starts_with(&(p.to_string() + "_")))
|
||||
.collect::<Vec<_>>()
|
||||
.is_empty()
|
||||
{
|
||||
return Err(OwnerValidationError::NoMatch);
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn quote_literal(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', r"\'"))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn quote_identifier(s: &str) -> String {
|
||||
format!("`{}`", s.replace('`', r"\`"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn test_quote_literal() {
|
||||
let payload = "' OR 1=1 --";
|
||||
assert_eq!(quote_literal(payload), r#"'\' OR 1=1 --'"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quote_identifier() {
|
||||
let payload = "` OR 1=1 --";
|
||||
assert_eq!(quote_identifier(payload), r#"`\` OR 1=1 --`"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_name() {
|
||||
assert_eq!(validate_name(""), Err(NameValidationError::EmptyString));
|
||||
assert_eq!(validate_name("abcdefghijklmnopqrstuvwxyz"), Ok(()));
|
||||
assert_eq!(validate_name("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), Ok(()));
|
||||
assert_eq!(validate_name("0123456789_-"), Ok(()));
|
||||
|
||||
for c in "\n\t\r !@#$%^&*()+=[]{}|;:,.<>?/".chars() {
|
||||
assert_eq!(
|
||||
validate_name(&c.to_string()),
|
||||
Err(NameValidationError::InvalidCharacters)
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(validate_name(&"a".repeat(MAX_NAME_LENGTH)), Ok(()));
|
||||
|
||||
assert_eq!(
|
||||
validate_name(&"a".repeat(MAX_NAME_LENGTH + 1)),
|
||||
Err(NameValidationError::TooLong)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_owner_by_prefixes() {
|
||||
let prefixes = vec!["user".to_string(), "group".to_string()];
|
||||
|
||||
assert_eq!(
|
||||
validate_ownership_by_prefixes("", &prefixes),
|
||||
Err(OwnerValidationError::StringEmpty)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
validate_ownership_by_prefixes("user_testdb", &prefixes),
|
||||
Ok(())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_ownership_by_prefixes("group_testdb", &prefixes),
|
||||
Ok(())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_ownership_by_prefixes("group_test_db", &prefixes),
|
||||
Ok(())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_ownership_by_prefixes("group_test-db", &prefixes),
|
||||
Ok(())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
validate_ownership_by_prefixes("nonexistent_testdb", &prefixes),
|
||||
Err(OwnerValidationError::NoMatch)
|
||||
);
|
||||
}
|
||||
}
|
||||
89
src/server/landlock.rs
Normal file
89
src/server/landlock.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn landlock_restrict_server(config_path: Option<&Path>) -> anyhow::Result<()> {
|
||||
use crate::{core::common::DEFAULT_CONFIG_PATH, server::config::ServerConfig};
|
||||
use anyhow::Context;
|
||||
use landlock::{
|
||||
ABI, Access, AccessFs, AccessNet, NetPort, Ruleset, RulesetAttr, RulesetCreatedAttr,
|
||||
path_beneath_rules,
|
||||
};
|
||||
|
||||
let config_path = config_path.unwrap_or(Path::new(DEFAULT_CONFIG_PATH));
|
||||
|
||||
let config = ServerConfig::read_config_from_path(config_path)?;
|
||||
|
||||
let abi = ABI::V4;
|
||||
let mut ruleset = Ruleset::default()
|
||||
.handle_access(AccessFs::from_all(abi))?
|
||||
.handle_access(AccessNet::from_all(abi))?
|
||||
.create()
|
||||
.context("Failed to create Landlock ruleset")?
|
||||
.add_rules(path_beneath_rules(
|
||||
&["/run/muscl"],
|
||||
AccessFs::from_read(abi),
|
||||
))
|
||||
.context("Failed to add Landlock rules for /run/muscl")?
|
||||
// Needs read access to /etc to access unix user/group info
|
||||
.add_rules(path_beneath_rules(&["/etc"], AccessFs::from_read(abi)))
|
||||
.context("Failed to add Landlock rules for /etc")?
|
||||
.add_rules(path_beneath_rules(&[config_path], AccessFs::from_read(abi)))
|
||||
.context(format!(
|
||||
"Failed to add Landlock rules for server config path at {}",
|
||||
config_path.display()
|
||||
))?;
|
||||
|
||||
if let Some(socket_path) = &config.socket_path {
|
||||
ruleset = ruleset
|
||||
.add_rules(path_beneath_rules(&[socket_path], AccessFs::from_all(abi)))
|
||||
.context(format!(
|
||||
"Failed to add Landlock rules for server socket path at {}",
|
||||
socket_path.display()
|
||||
))?;
|
||||
}
|
||||
|
||||
if let Some(mysql_socket_path) = &config.mysql.socket_path {
|
||||
ruleset = ruleset
|
||||
.add_rules(path_beneath_rules(
|
||||
&[mysql_socket_path],
|
||||
AccessFs::from_all(abi),
|
||||
))
|
||||
.context(format!(
|
||||
"Failed to add Landlock rules for MySQL socket path at {}",
|
||||
mysql_socket_path.display()
|
||||
))?;
|
||||
}
|
||||
|
||||
if let Some(mysql_host) = &config.mysql.host {
|
||||
ruleset = ruleset
|
||||
.add_rule(NetPort::new(config.mysql.port, AccessNet::ConnectTcp))
|
||||
.context(format!(
|
||||
"Failed to add Landlock rules for MySQL host at {}:{}",
|
||||
mysql_host, config.mysql.port
|
||||
))?;
|
||||
}
|
||||
|
||||
if let Some(mysql_passwd_file) = &config.mysql.password_file {
|
||||
ruleset = ruleset
|
||||
.add_rules(path_beneath_rules(
|
||||
&[mysql_passwd_file],
|
||||
AccessFs::from_read(abi),
|
||||
))
|
||||
.context(format!(
|
||||
"Failed to add Landlock rules for MySQL password file at {}",
|
||||
mysql_passwd_file.display()
|
||||
))?;
|
||||
}
|
||||
|
||||
ruleset
|
||||
.restrict_self()
|
||||
.context("Failed to apply Landlock restrictions to the server process")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn landlock_restrict_server() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
fs,
|
||||
os::unix::{io::FromRawFd, net::UnixListener as StdUnixListener},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use anyhow::Context;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use indoc::concatdoc;
|
||||
use tokio::net::{UnixListener as TokioUnixListener, UnixStream as TokioUnixStream};
|
||||
|
||||
use sqlx::MySqlConnection;
|
||||
use sqlx::prelude::*;
|
||||
|
||||
use crate::core::protocol::SetPasswordError;
|
||||
use crate::server::sql::database_operations::list_databases;
|
||||
use crate::{
|
||||
core::{
|
||||
common::{DEFAULT_SOCKET_PATH, UnixUser},
|
||||
protocol::request_response::{
|
||||
Request, Response, ServerToClientMessageStream, create_server_to_client_message_stream,
|
||||
},
|
||||
},
|
||||
server::{
|
||||
config::{ServerConfig, create_mysql_connection_from_config},
|
||||
sql::{
|
||||
database_operations::{create_databases, drop_databases, list_all_databases_for_user},
|
||||
database_privilege_operations::{
|
||||
apply_privilege_diffs, get_all_database_privileges, get_databases_privilege_data,
|
||||
},
|
||||
user_operations::{
|
||||
create_database_users, drop_database_users, list_all_database_users_for_unix_user,
|
||||
list_database_users, lock_database_users, set_password_for_database_user,
|
||||
unlock_database_users,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// TODO: consider using a connection pool
|
||||
|
||||
pub async fn listen_for_incoming_connections_with_socket_path(
|
||||
socket_path: Option<PathBuf>,
|
||||
config: ServerConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let socket_path = socket_path.unwrap_or(PathBuf::from(DEFAULT_SOCKET_PATH));
|
||||
|
||||
let parent_directory = socket_path.parent().unwrap();
|
||||
if !parent_directory.exists() {
|
||||
log::debug!("Creating directory {:?}", parent_directory);
|
||||
fs::create_dir_all(parent_directory)?;
|
||||
}
|
||||
|
||||
log::info!("Listening on socket {:?}", socket_path);
|
||||
|
||||
match fs::remove_file(socket_path.as_path()) {
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
|
||||
let listener = TokioUnixListener::bind(socket_path)?;
|
||||
|
||||
listen_for_incoming_connections_with_listener(listener, config).await
|
||||
}
|
||||
|
||||
pub async fn listen_for_incoming_connections_with_systemd_socket(
|
||||
config: ServerConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let fd = sd_notify::listen_fds()
|
||||
.context("Failed to get file descriptors from systemd")?
|
||||
.next()
|
||||
.context("No file descriptors received from systemd")?;
|
||||
|
||||
debug_assert!(fd == 3, "Unexpected file descriptor from systemd: {}", fd);
|
||||
|
||||
log::debug!(
|
||||
"Received file descriptor from systemd with id: '{}', assuming socket",
|
||||
fd
|
||||
);
|
||||
|
||||
let std_unix_listener = unsafe { StdUnixListener::from_raw_fd(fd) };
|
||||
let listener = TokioUnixListener::from_std(std_unix_listener)?;
|
||||
listen_for_incoming_connections_with_listener(listener, config).await
|
||||
}
|
||||
|
||||
pub async fn listen_for_incoming_connections_with_listener(
|
||||
listener: TokioUnixListener,
|
||||
config: ServerConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
sd_notify::notify(false, &[sd_notify::NotifyState::Ready]).ok();
|
||||
|
||||
while let Ok((conn, _addr)) = listener.accept().await {
|
||||
let uid = match conn.peer_cred() {
|
||||
Ok(cred) => cred.uid(),
|
||||
Err(e) => {
|
||||
log::error!("Failed to get peer credentials from socket: {}", e);
|
||||
let mut message_stream = create_server_to_client_message_stream(conn);
|
||||
message_stream
|
||||
.send(Response::Error(
|
||||
(concatdoc! {
|
||||
"Server failed to get peer credentials from socket\n",
|
||||
"Please check the server logs or contact the system administrators"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.await
|
||||
.ok();
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
log::debug!("Accepted connection from uid {}", uid);
|
||||
|
||||
let unix_user = match UnixUser::from_uid(uid) {
|
||||
Ok(user) => user,
|
||||
Err(e) => {
|
||||
log::error!("Failed to get username from uid: {}", e);
|
||||
let mut message_stream = create_server_to_client_message_stream(conn);
|
||||
message_stream
|
||||
.send(Response::Error(
|
||||
(concatdoc! {
|
||||
"Server failed to get user data from the system\n",
|
||||
"Please check the server logs or contact the system administrators"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.await
|
||||
.ok();
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
log::info!("Accepted connection from {}", unix_user.username);
|
||||
|
||||
match handle_requests_for_single_session(conn, &unix_user, &config).await {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
log::error!("Failed to run server: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn close_or_ignore_db_connection(db_connection: MySqlConnection) {
|
||||
if let Err(e) = db_connection.close().await {
|
||||
log::error!("Failed to close database connection: {}", e);
|
||||
log::error!("{}", e);
|
||||
log::error!("Ignoring...");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_requests_for_single_session(
|
||||
socket: TokioUnixStream,
|
||||
unix_user: &UnixUser,
|
||||
config: &ServerConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut message_stream = create_server_to_client_message_stream(socket);
|
||||
|
||||
log::debug!("Opening connection to database");
|
||||
|
||||
let mut db_connection = match create_mysql_connection_from_config(&config.mysql).await {
|
||||
Ok(connection) => connection,
|
||||
Err(err) => {
|
||||
message_stream
|
||||
.send(Response::Error(
|
||||
(concatdoc! {
|
||||
"Server failed to connect to database\n",
|
||||
"Please check the server logs or contact the system administrators"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.await?;
|
||||
message_stream.flush().await?;
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
log::debug!("Verifying that database connection is valid");
|
||||
|
||||
if let Err(e) = db_connection.ping().await {
|
||||
log::error!("Failed to ping database: {}", e);
|
||||
message_stream
|
||||
.send(Response::Error(
|
||||
(concatdoc! {
|
||||
"Server failed to connect to database\n",
|
||||
"Please check the server logs or contact the system administrators"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.await?;
|
||||
message_stream.flush().await?;
|
||||
close_or_ignore_db_connection(db_connection).await;
|
||||
return Err(e.into());
|
||||
}
|
||||
|
||||
log::debug!("Successfully connected to database");
|
||||
|
||||
let result = handle_requests_for_single_session_with_db_connection(
|
||||
message_stream,
|
||||
unix_user,
|
||||
&mut db_connection,
|
||||
)
|
||||
.await;
|
||||
|
||||
close_or_ignore_db_connection(db_connection).await;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// TODO: ensure proper db_connection hygiene for functions that invoke
|
||||
// this function
|
||||
|
||||
async fn handle_requests_for_single_session_with_db_connection(
|
||||
mut stream: ServerToClientMessageStream,
|
||||
unix_user: &UnixUser,
|
||||
db_connection: &mut MySqlConnection,
|
||||
) -> anyhow::Result<()> {
|
||||
stream.send(Response::Ready).await?;
|
||||
loop {
|
||||
// TODO: better error handling
|
||||
let request = match stream.next().await {
|
||||
Some(Ok(request)) => request,
|
||||
Some(Err(e)) => return Err(e.into()),
|
||||
None => {
|
||||
log::warn!("Client disconnected without sending an exit message");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: don't clone the request
|
||||
let request_to_display = match &request {
|
||||
Request::PasswdUser(db_user, _) => {
|
||||
Request::PasswdUser(db_user.to_owned(), "<REDACTED>".to_string())
|
||||
}
|
||||
request => request.to_owned(),
|
||||
};
|
||||
log::info!("Received request: {:#?}", request_to_display);
|
||||
|
||||
let response = match request {
|
||||
Request::CreateDatabases(databases_names) => {
|
||||
let result = create_databases(databases_names, unix_user, db_connection).await;
|
||||
Response::CreateDatabases(result)
|
||||
}
|
||||
Request::DropDatabases(databases_names) => {
|
||||
let result = drop_databases(databases_names, unix_user, db_connection).await;
|
||||
Response::DropDatabases(result)
|
||||
}
|
||||
Request::ListDatabases(database_names) => match database_names {
|
||||
Some(database_names) => {
|
||||
let result = list_databases(database_names, unix_user, db_connection).await;
|
||||
Response::ListDatabases(result)
|
||||
}
|
||||
None => {
|
||||
let result = list_all_databases_for_user(unix_user, db_connection).await;
|
||||
Response::ListAllDatabases(result)
|
||||
}
|
||||
},
|
||||
Request::ListPrivileges(database_names) => match database_names {
|
||||
Some(database_names) => {
|
||||
let privilege_data =
|
||||
get_databases_privilege_data(database_names, unix_user, db_connection)
|
||||
.await;
|
||||
Response::ListPrivileges(privilege_data)
|
||||
}
|
||||
None => {
|
||||
let privilege_data =
|
||||
get_all_database_privileges(unix_user, db_connection).await;
|
||||
Response::ListAllPrivileges(privilege_data)
|
||||
}
|
||||
},
|
||||
Request::ModifyPrivileges(database_privilege_diffs) => {
|
||||
let result = apply_privilege_diffs(
|
||||
BTreeSet::from_iter(database_privilege_diffs),
|
||||
unix_user,
|
||||
db_connection,
|
||||
)
|
||||
.await;
|
||||
Response::ModifyPrivileges(result)
|
||||
}
|
||||
Request::CreateUsers(db_users) => {
|
||||
let result = create_database_users(db_users, unix_user, db_connection).await;
|
||||
Response::CreateUsers(result)
|
||||
}
|
||||
Request::DropUsers(db_users) => {
|
||||
let result = drop_database_users(db_users, unix_user, db_connection).await;
|
||||
Response::DropUsers(result)
|
||||
}
|
||||
Request::PasswdUser(db_user, password) => {
|
||||
let result =
|
||||
set_password_for_database_user(&db_user, &password, unix_user, db_connection)
|
||||
.await;
|
||||
Response::PasswdUser(result)
|
||||
}
|
||||
Request::ListUsers(db_users) => match db_users {
|
||||
Some(db_users) => {
|
||||
let result = list_database_users(db_users, unix_user, db_connection).await;
|
||||
Response::ListUsers(result)
|
||||
}
|
||||
None => {
|
||||
let result =
|
||||
list_all_database_users_for_unix_user(unix_user, db_connection).await;
|
||||
Response::ListAllUsers(result)
|
||||
}
|
||||
},
|
||||
Request::LockUsers(db_users) => {
|
||||
let result = lock_database_users(db_users, unix_user, db_connection).await;
|
||||
Response::LockUsers(result)
|
||||
}
|
||||
Request::UnlockUsers(db_users) => {
|
||||
let result = unlock_database_users(db_users, unix_user, db_connection).await;
|
||||
Response::UnlockUsers(result)
|
||||
}
|
||||
Request::Exit => {
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: don't clone the response
|
||||
let response_to_display = match &response {
|
||||
Response::PasswdUser(Err(SetPasswordError::MySqlError(_))) => {
|
||||
Response::PasswdUser(Err(SetPasswordError::MySqlError("<REDACTED>".to_string())))
|
||||
}
|
||||
response => response.to_owned(),
|
||||
};
|
||||
log::info!("Response: {:#?}", response_to_display);
|
||||
|
||||
stream.send(response).await?;
|
||||
stream.flush().await?;
|
||||
log::debug!("Successfully processed request");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
341
src/server/session_handler.rs
Normal file
341
src/server/session_handler.rs
Normal file
@@ -0,0 +1,341 @@
|
||||
use std::{collections::BTreeSet, sync::Arc};
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use indoc::concatdoc;
|
||||
use sqlx::{MySqlConnection, MySqlPool};
|
||||
use tokio::{net::UnixStream, sync::RwLock};
|
||||
use tracing::Instrument;
|
||||
|
||||
use crate::{
|
||||
core::{
|
||||
common::UnixUser,
|
||||
protocol::{
|
||||
Request, Response, ServerToClientMessageStream, SetPasswordError,
|
||||
create_server_to_client_message_stream,
|
||||
},
|
||||
},
|
||||
server::{
|
||||
authorization::check_authorization,
|
||||
sql::{
|
||||
database_operations::{
|
||||
complete_database_name, create_databases, drop_databases,
|
||||
list_all_databases_for_user, list_databases,
|
||||
},
|
||||
database_privilege_operations::{
|
||||
apply_privilege_diffs, get_all_database_privileges, get_databases_privilege_data,
|
||||
},
|
||||
user_operations::{
|
||||
complete_user_name, create_database_users, drop_database_users,
|
||||
list_all_database_users_for_unix_user, list_database_users, lock_database_users,
|
||||
set_password_for_database_user, unlock_database_users,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// TODO: don't use database connection unless necessary.
|
||||
|
||||
pub async fn session_handler(
|
||||
socket: UnixStream,
|
||||
db_pool: Arc<RwLock<MySqlPool>>,
|
||||
db_is_mariadb: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
let uid = match socket.peer_cred() {
|
||||
Ok(cred) => cred.uid(),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to get peer credentials from socket: {}", e);
|
||||
let mut message_stream = create_server_to_client_message_stream(socket);
|
||||
message_stream
|
||||
.send(Response::Error(
|
||||
(concatdoc! {
|
||||
"Server failed to get peer credentials from socket\n",
|
||||
"Please check the server logs or contact the system administrators"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.await
|
||||
.ok();
|
||||
anyhow::bail!("Failed to get peer credentials from socket");
|
||||
}
|
||||
};
|
||||
|
||||
tracing::debug!("Validated peer UID: {}", uid);
|
||||
|
||||
let unix_user = match UnixUser::from_uid(uid) {
|
||||
Ok(user) => user,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to get username from uid: {}", e);
|
||||
let mut message_stream = create_server_to_client_message_stream(socket);
|
||||
message_stream
|
||||
.send(Response::Error(
|
||||
(concatdoc! {
|
||||
"Server failed to get user data from the system\n",
|
||||
"Please check the server logs or contact the system administrators"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.await
|
||||
.ok();
|
||||
anyhow::bail!("Failed to get username from uid: {}", e);
|
||||
}
|
||||
};
|
||||
|
||||
let span = tracing::info_span!("user_session", user = %unix_user);
|
||||
|
||||
(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;
|
||||
|
||||
tracing::info!(
|
||||
"Finished handling requests for connection from user: {}",
|
||||
unix_user,
|
||||
);
|
||||
|
||||
result
|
||||
})
|
||||
.instrument(span)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn session_handler_with_unix_user(
|
||||
socket: UnixStream,
|
||||
unix_user: &UnixUser,
|
||||
db_pool: Arc<RwLock<MySqlPool>>,
|
||||
db_is_mariadb: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut message_stream = create_server_to_client_message_stream(socket);
|
||||
|
||||
tracing::debug!("Requesting database connection from pool");
|
||||
let mut db_connection = match db_pool.read().await.acquire().await {
|
||||
Ok(connection) => connection,
|
||||
Err(err) => {
|
||||
message_stream
|
||||
.send(Response::Error(
|
||||
(concatdoc! {
|
||||
"Server failed to connect to database\n",
|
||||
"Please check the server logs or contact the system administrators"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.await?;
|
||||
message_stream.flush().await?;
|
||||
return Err(err.into());
|
||||
}
|
||||
};
|
||||
tracing::debug!("Successfully acquired database connection from pool");
|
||||
|
||||
let result = session_handler_with_db_connection(
|
||||
message_stream,
|
||||
unix_user,
|
||||
&mut db_connection,
|
||||
db_is_mariadb,
|
||||
)
|
||||
.await;
|
||||
|
||||
tracing::debug!("Releasing database connection back to pool");
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// TODO: ensure proper db_connection hygiene for functions that invoke
|
||||
// this function
|
||||
|
||||
async fn session_handler_with_db_connection(
|
||||
mut stream: ServerToClientMessageStream,
|
||||
unix_user: &UnixUser,
|
||||
db_connection: &mut MySqlConnection,
|
||||
db_is_mariadb: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
stream.send(Response::Ready).await?;
|
||||
loop {
|
||||
// TODO: better error handling
|
||||
// TODO: timeout for receiving requests
|
||||
// TODO: cancel on request by supervisor
|
||||
let request = match stream.next().await {
|
||||
Some(Ok(request)) => request,
|
||||
Some(Err(e)) => return Err(e.into()),
|
||||
None => {
|
||||
tracing::warn!("Client disconnected without sending an exit message");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: don't clone the request
|
||||
let request_to_display = match &request {
|
||||
Request::PasswdUser((db_user, _)) => {
|
||||
Request::PasswdUser((db_user.to_owned(), "<REDACTED>".to_string()))
|
||||
}
|
||||
request => request.to_owned(),
|
||||
};
|
||||
|
||||
if request_to_display != Request::Exit {
|
||||
tracing::info!("Received request: {:#?}", request_to_display);
|
||||
} else {
|
||||
tracing::debug!("Received request: {:#?}", request_to_display);
|
||||
}
|
||||
|
||||
let response = match request {
|
||||
Request::CheckAuthorization(dbs_or_users) => {
|
||||
let result = check_authorization(dbs_or_users, unix_user).await;
|
||||
Response::CheckAuthorization(result)
|
||||
}
|
||||
Request::CompleteDatabaseName(partial_database_name) => {
|
||||
// TODO: more correct validation here
|
||||
if !partial_database_name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
Response::CompleteDatabaseName(vec![])
|
||||
} else {
|
||||
let result = complete_database_name(
|
||||
partial_database_name,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
)
|
||||
.await;
|
||||
Response::CompleteDatabaseName(result)
|
||||
}
|
||||
}
|
||||
Request::CompleteUserName(partial_user_name) => {
|
||||
// TODO: more correct validation here
|
||||
if !partial_user_name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
Response::CompleteUserName(vec![])
|
||||
} else {
|
||||
let result = complete_user_name(
|
||||
partial_user_name,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
)
|
||||
.await;
|
||||
Response::CompleteUserName(result)
|
||||
}
|
||||
}
|
||||
Request::CreateDatabases(databases_names) => {
|
||||
let result =
|
||||
create_databases(databases_names, unix_user, db_connection, db_is_mariadb)
|
||||
.await;
|
||||
Response::CreateDatabases(result)
|
||||
}
|
||||
Request::DropDatabases(databases_names) => {
|
||||
let result =
|
||||
drop_databases(databases_names, unix_user, db_connection, db_is_mariadb).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;
|
||||
Response::ListDatabases(result)
|
||||
}
|
||||
None => {
|
||||
let result =
|
||||
list_all_databases_for_user(unix_user, db_connection, db_is_mariadb).await;
|
||||
Response::ListAllDatabases(result)
|
||||
}
|
||||
},
|
||||
Request::ListPrivileges(database_names) => match database_names {
|
||||
Some(database_names) => {
|
||||
let privilege_data = get_databases_privilege_data(
|
||||
database_names,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
)
|
||||
.await;
|
||||
Response::ListPrivileges(privilege_data)
|
||||
}
|
||||
None => {
|
||||
let privilege_data =
|
||||
get_all_database_privileges(unix_user, db_connection, db_is_mariadb).await;
|
||||
Response::ListAllPrivileges(privilege_data)
|
||||
}
|
||||
},
|
||||
Request::ModifyPrivileges(database_privilege_diffs) => {
|
||||
let result = apply_privilege_diffs(
|
||||
BTreeSet::from_iter(database_privilege_diffs),
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
)
|
||||
.await;
|
||||
Response::ModifyPrivileges(result)
|
||||
}
|
||||
Request::CreateUsers(db_users) => {
|
||||
let result =
|
||||
create_database_users(db_users, unix_user, db_connection, db_is_mariadb).await;
|
||||
Response::CreateUsers(result)
|
||||
}
|
||||
Request::DropUsers(db_users) => {
|
||||
let result =
|
||||
drop_database_users(db_users, unix_user, db_connection, db_is_mariadb).await;
|
||||
Response::DropUsers(result)
|
||||
}
|
||||
Request::PasswdUser((db_user, password)) => {
|
||||
let result = set_password_for_database_user(
|
||||
&db_user,
|
||||
&password,
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
)
|
||||
.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;
|
||||
Response::ListUsers(result)
|
||||
}
|
||||
None => {
|
||||
let result = list_all_database_users_for_unix_user(
|
||||
unix_user,
|
||||
db_connection,
|
||||
db_is_mariadb,
|
||||
)
|
||||
.await;
|
||||
Response::ListAllUsers(result)
|
||||
}
|
||||
},
|
||||
Request::LockUsers(db_users) => {
|
||||
let result =
|
||||
lock_database_users(db_users, unix_user, db_connection, db_is_mariadb).await;
|
||||
Response::LockUsers(result)
|
||||
}
|
||||
Request::UnlockUsers(db_users) => {
|
||||
let result =
|
||||
unlock_database_users(db_users, unix_user, db_connection, db_is_mariadb).await;
|
||||
Response::UnlockUsers(result)
|
||||
}
|
||||
Request::Exit => {
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: don't clone the response
|
||||
let response_to_display = match &response {
|
||||
Response::SetUserPassword(Err(SetPasswordError::MySqlError(_))) => {
|
||||
Response::SetUserPassword(Err(SetPasswordError::MySqlError(
|
||||
"<REDACTED>".to_string(),
|
||||
)))
|
||||
}
|
||||
response => response.to_owned(),
|
||||
};
|
||||
tracing::debug!("Response: {:#?}", response_to_display);
|
||||
|
||||
stream.send(response).await?;
|
||||
stream.flush().await?;
|
||||
tracing::debug!("Successfully processed request");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,3 +1,29 @@
|
||||
pub mod database_operations;
|
||||
pub mod database_privilege_operations;
|
||||
pub mod user_operations;
|
||||
|
||||
#[inline]
|
||||
pub fn quote_literal(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', r"\'"))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn quote_identifier(s: &str) -> String {
|
||||
format!("`{}`", s.replace('`', r"\`"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn test_quote_literal() {
|
||||
let payload = "' OR 1=1 --";
|
||||
assert_eq!(quote_literal(payload), r#"'\' OR 1=1 --'"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quote_identifier() {
|
||||
let payload = "` OR 1=1 --";
|
||||
assert_eq!(quote_identifier(payload), r#"`\` OR 1=1 --`"#);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,21 @@ use sqlx::prelude::*;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::protocol::MySQLDatabase;
|
||||
use crate::core::protocol::CompleteDatabaseNameResponse;
|
||||
use crate::core::protocol::request_validation::validate_db_or_user_request;
|
||||
use crate::core::types::DbOrUser;
|
||||
use crate::core::types::MySQLDatabase;
|
||||
use crate::core::types::MySQLUser;
|
||||
use crate::{
|
||||
core::{
|
||||
common::UnixUser,
|
||||
protocol::{
|
||||
CreateDatabaseError, CreateDatabasesOutput, DropDatabaseError, DropDatabasesOutput,
|
||||
ListAllDatabasesError, ListAllDatabasesOutput, ListDatabasesError, ListDatabasesOutput,
|
||||
CreateDatabaseError, CreateDatabasesResponse, DropDatabaseError, DropDatabasesResponse,
|
||||
ListAllDatabasesError, ListAllDatabasesResponse, ListDatabasesError,
|
||||
ListDatabasesResponse,
|
||||
},
|
||||
},
|
||||
server::{
|
||||
common::create_user_group_matching_regex,
|
||||
input_sanitization::{quote_identifier, validate_name, validate_ownership_by_unix_user},
|
||||
},
|
||||
server::{common::create_user_group_matching_regex, sql::quote_identifier},
|
||||
};
|
||||
|
||||
// NOTE: this function is unsafe because it does no input validation.
|
||||
@@ -32,7 +34,7 @@ pub(super) async fn unsafe_database_exists(
|
||||
.await;
|
||||
|
||||
if let Err(err) = &result {
|
||||
log::error!(
|
||||
tracing::error!(
|
||||
"Failed to check if database '{}' exists: {:?}",
|
||||
&database_name,
|
||||
err
|
||||
@@ -42,27 +44,60 @@ pub(super) async fn unsafe_database_exists(
|
||||
Ok(result?.is_some())
|
||||
}
|
||||
|
||||
pub async fn complete_database_name(
|
||||
database_prefix: String,
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
) -> CompleteDatabaseNameResponse {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
SELECT CAST(`SCHEMA_NAME` AS CHAR(64)) AS `database`
|
||||
FROM `information_schema`.`SCHEMATA`
|
||||
WHERE `SCHEMA_NAME` NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')
|
||||
AND `SCHEMA_NAME` REGEXP ?
|
||||
AND `SCHEMA_NAME` LIKE ?
|
||||
"#,
|
||||
)
|
||||
.bind(create_user_group_matching_regex(unix_user))
|
||||
.bind(format!("{}%", database_prefix))
|
||||
.fetch_all(connection)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(rows) => rows
|
||||
.into_iter()
|
||||
.filter_map(|row| {
|
||||
let database: String = row.try_get("database").ok()?;
|
||||
Some(database.into())
|
||||
})
|
||||
.collect(),
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
"Failed to complete database name for prefix '{}' and user '{}': {:?}",
|
||||
database_prefix,
|
||||
unix_user.username,
|
||||
err
|
||||
);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_databases(
|
||||
database_names: Vec<MySQLDatabase>,
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
) -> CreateDatabasesOutput {
|
||||
_db_is_mariadb: bool,
|
||||
) -> CreateDatabasesResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for database_name in database_names {
|
||||
if let Err(err) = validate_name(&database_name) {
|
||||
results.insert(
|
||||
database_name.to_owned(),
|
||||
Err(CreateDatabaseError::SanitizationError(err)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(err) = validate_ownership_by_unix_user(&database_name, unix_user) {
|
||||
results.insert(
|
||||
database_name.to_owned(),
|
||||
Err(CreateDatabaseError::OwnershipError(err)),
|
||||
);
|
||||
if let Err(err) =
|
||||
validate_db_or_user_request(&DbOrUser::Database(database_name.clone()), unix_user)
|
||||
.map_err(CreateDatabaseError::ValidationError)
|
||||
{
|
||||
results.insert(database_name.to_owned(), Err(err));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -92,7 +127,7 @@ pub async fn create_databases(
|
||||
.map_err(|err| CreateDatabaseError::MySqlError(err.to_string()));
|
||||
|
||||
if let Err(err) = &result {
|
||||
log::error!("Failed to create database '{}': {:?}", &database_name, err);
|
||||
tracing::error!("Failed to create database '{}': {:?}", &database_name, err);
|
||||
}
|
||||
|
||||
results.insert(database_name, result);
|
||||
@@ -105,23 +140,16 @@ pub async fn drop_databases(
|
||||
database_names: Vec<MySQLDatabase>,
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
) -> DropDatabasesOutput {
|
||||
_db_is_mariadb: bool,
|
||||
) -> DropDatabasesResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for database_name in database_names {
|
||||
if let Err(err) = validate_name(&database_name) {
|
||||
results.insert(
|
||||
database_name.to_owned(),
|
||||
Err(DropDatabaseError::SanitizationError(err)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(err) = validate_ownership_by_unix_user(&database_name, unix_user) {
|
||||
results.insert(
|
||||
database_name.to_owned(),
|
||||
Err(DropDatabaseError::OwnershipError(err)),
|
||||
);
|
||||
if let Err(err) =
|
||||
validate_db_or_user_request(&DbOrUser::Database(database_name.clone()), unix_user)
|
||||
.map_err(DropDatabaseError::ValidationError)
|
||||
{
|
||||
results.insert(database_name.to_owned(), Err(err));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -151,7 +179,7 @@ pub async fn drop_databases(
|
||||
.map_err(|err| DropDatabaseError::MySqlError(err.to_string()));
|
||||
|
||||
if let Err(err) = &result {
|
||||
log::error!("Failed to drop database '{}': {:?}", &database_name, err);
|
||||
tracing::error!("Failed to drop database '{}': {:?}", &database_name, err);
|
||||
}
|
||||
|
||||
results.insert(database_name, result);
|
||||
@@ -163,12 +191,42 @@ pub async fn drop_databases(
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DatabaseRow {
|
||||
pub database: MySQLDatabase,
|
||||
pub tables: Vec<String>,
|
||||
pub users: Vec<MySQLUser>,
|
||||
pub collation: Option<String>,
|
||||
pub character_set: Option<String>,
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
impl FromRow<'_, sqlx::mysql::MySqlRow> for DatabaseRow {
|
||||
fn from_row(row: &sqlx::mysql::MySqlRow) -> Result<Self, sqlx::Error> {
|
||||
Ok(DatabaseRow {
|
||||
database: row.try_get::<String, _>("database")?.into(),
|
||||
tables: {
|
||||
let s: Option<String> = row.try_get("tables")?;
|
||||
s.and_then(|s| {
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s.split(',').map(|s| s.to_owned()).collect())
|
||||
}
|
||||
})
|
||||
.unwrap_or_default()
|
||||
},
|
||||
users: {
|
||||
let s: Option<String> = row.try_get("users")?;
|
||||
s.and_then(|s| {
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s.split(',').map(|s| s.to_owned().into()).collect())
|
||||
}
|
||||
})
|
||||
.unwrap_or_default()
|
||||
},
|
||||
collation: row.try_get::<Option<String>, _>("collation")?,
|
||||
character_set: row.try_get::<Option<String>, _>("character_set")?,
|
||||
size_bytes: row.try_get::<u64, _>("size_bytes")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -177,32 +235,40 @@ pub async fn list_databases(
|
||||
database_names: Vec<MySQLDatabase>,
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
) -> ListDatabasesOutput {
|
||||
_db_is_mariadb: bool,
|
||||
) -> ListDatabasesResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for database_name in database_names {
|
||||
if let Err(err) = validate_name(&database_name) {
|
||||
results.insert(
|
||||
database_name.to_owned(),
|
||||
Err(ListDatabasesError::SanitizationError(err)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(err) = validate_ownership_by_unix_user(&database_name, unix_user) {
|
||||
results.insert(
|
||||
database_name.to_owned(),
|
||||
Err(ListDatabasesError::OwnershipError(err)),
|
||||
);
|
||||
if let Err(err) =
|
||||
validate_db_or_user_request(&DbOrUser::Database(database_name.clone()), unix_user)
|
||||
.map_err(ListDatabasesError::ValidationError)
|
||||
{
|
||||
results.insert(database_name.to_owned(), Err(err));
|
||||
continue;
|
||||
}
|
||||
|
||||
let result = sqlx::query_as::<_, DatabaseRow>(
|
||||
r#"
|
||||
SELECT `SCHEMA_NAME` AS `database`
|
||||
FROM `information_schema`.`SCHEMATA`
|
||||
WHERE `SCHEMA_NAME` = ?
|
||||
"#,
|
||||
SELECT
|
||||
CAST(`information_schema`.`SCHEMATA`.`SCHEMA_NAME` AS CHAR(64)) AS `database`,
|
||||
GROUP_CONCAT(DISTINCT CAST(`information_schema`.`TABLES`.`TABLE_NAME` AS CHAR(64)) SEPARATOR ',') AS `tables`,
|
||||
GROUP_CONCAT(DISTINCT CAST(`mysql`.`db`.`User` AS CHAR(64)) SEPARATOR ',') AS `users`,
|
||||
MAX(`information_schema`.`SCHEMATA`.`DEFAULT_COLLATION_NAME`) AS `collation`,
|
||||
MAX(`information_schema`.`SCHEMATA`.`DEFAULT_CHARACTER_SET_NAME`) AS `character_set`,
|
||||
CAST(IFNULL(
|
||||
SUM(`information_schema`.`TABLES`.`DATA_LENGTH` + `information_schema`.`TABLES`.`INDEX_LENGTH`),
|
||||
0
|
||||
) AS UNSIGNED INTEGER) AS `size_bytes`
|
||||
FROM `information_schema`.`SCHEMATA`
|
||||
LEFT OUTER JOIN `information_schema`.`TABLES`
|
||||
ON `information_schema`.`SCHEMATA`.`SCHEMA_NAME` = `TABLES`.`TABLE_SCHEMA`
|
||||
LEFT OUTER JOIN `mysql`.`db`
|
||||
ON `information_schema`.`SCHEMATA`.`SCHEMA_NAME` = `mysql`.`db`.`DB`
|
||||
WHERE `information_schema`.`SCHEMATA`.`SCHEMA_NAME` = ?
|
||||
GROUP BY `information_schema`.`SCHEMATA`.`SCHEMA_NAME`
|
||||
"#,
|
||||
|
||||
)
|
||||
.bind(database_name.to_string())
|
||||
.fetch_optional(&mut *connection)
|
||||
@@ -215,9 +281,11 @@ pub async fn list_databases(
|
||||
});
|
||||
|
||||
if let Err(err) = &result {
|
||||
log::error!("Failed to list database '{}': {:?}", &database_name, err);
|
||||
tracing::error!("Failed to list database '{}': {:?}", &database_name, err);
|
||||
}
|
||||
|
||||
// TODO: should we assert that the users are also owned by the unix_user from the request?
|
||||
|
||||
results.insert(database_name, result);
|
||||
}
|
||||
|
||||
@@ -227,13 +295,28 @@ pub async fn list_databases(
|
||||
pub async fn list_all_databases_for_user(
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
) -> ListAllDatabasesOutput {
|
||||
_db_is_mariadb: bool,
|
||||
) -> ListAllDatabasesResponse {
|
||||
let result = sqlx::query_as::<_, DatabaseRow>(
|
||||
r#"
|
||||
SELECT `SCHEMA_NAME` AS `database`
|
||||
SELECT
|
||||
CAST(`information_schema`.`SCHEMATA`.`SCHEMA_NAME` AS CHAR(64)) AS `database`,
|
||||
GROUP_CONCAT(DISTINCT CAST(`information_schema`.`TABLES`.`TABLE_NAME` AS CHAR(64)) SEPARATOR ',') AS `tables`,
|
||||
GROUP_CONCAT(DISTINCT CAST(`mysql`.`db`.`User` AS CHAR(64)) SEPARATOR ',') AS `users`,
|
||||
MAX(`information_schema`.`SCHEMATA`.`DEFAULT_COLLATION_NAME`) AS `collation`,
|
||||
MAX(`information_schema`.`SCHEMATA`.`DEFAULT_CHARACTER_SET_NAME`) AS `character_set`,
|
||||
CAST(IFNULL(
|
||||
SUM(`information_schema`.`TABLES`.`DATA_LENGTH` + `information_schema`.`TABLES`.`INDEX_LENGTH`),
|
||||
0
|
||||
) AS UNSIGNED INTEGER) AS `size_bytes`
|
||||
FROM `information_schema`.`SCHEMATA`
|
||||
WHERE `SCHEMA_NAME` NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')
|
||||
AND `SCHEMA_NAME` REGEXP ?
|
||||
LEFT OUTER JOIN `information_schema`.`TABLES`
|
||||
ON `information_schema`.`SCHEMATA`.`SCHEMA_NAME` = `TABLES`.`TABLE_SCHEMA`
|
||||
LEFT OUTER JOIN `mysql`.`db`
|
||||
ON `information_schema`.`SCHEMATA`.`SCHEMA_NAME` = `mysql`.`db`.`DB`
|
||||
WHERE `information_schema`.`SCHEMATA`.`SCHEMA_NAME` NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')
|
||||
AND `information_schema`.`SCHEMATA`.`SCHEMA_NAME` REGEXP ?
|
||||
GROUP BY `information_schema`.`SCHEMATA`.`SCHEMA_NAME`
|
||||
"#,
|
||||
)
|
||||
.bind(create_user_group_matching_regex(unix_user))
|
||||
@@ -241,8 +324,10 @@ pub async fn list_all_databases_for_user(
|
||||
.await
|
||||
.map_err(|err| ListAllDatabasesError::MySqlError(err.to_string()));
|
||||
|
||||
// TODO: should we assert that the users are also owned by the unix_user from the request?
|
||||
|
||||
if let Err(err) = &result {
|
||||
log::error!(
|
||||
tracing::error!(
|
||||
"Failed to list databases for user '{}': {:?}",
|
||||
unix_user.username,
|
||||
err
|
||||
|
||||
@@ -18,86 +18,32 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use indoc::indoc;
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{MySqlConnection, mysql::MySqlRow, prelude::*};
|
||||
|
||||
use crate::{
|
||||
core::{
|
||||
common::{UnixUser, rev_yn, yn},
|
||||
database_privileges::{DatabasePrivilegeChange, DatabasePrivilegesDiff},
|
||||
protocol::{
|
||||
DiffDoesNotApplyError, GetAllDatabasesPrivilegeData, GetAllDatabasesPrivilegeDataError,
|
||||
GetDatabasesPrivilegeData, GetDatabasesPrivilegeDataError,
|
||||
ModifyDatabasePrivilegesError, ModifyDatabasePrivilegesOutput, MySQLDatabase,
|
||||
MySQLUser,
|
||||
database_privileges::{
|
||||
DATABASE_PRIVILEGE_FIELDS, DatabasePrivilegeChange, DatabasePrivilegeRow,
|
||||
DatabasePrivilegesDiff,
|
||||
},
|
||||
protocol::{
|
||||
DiffDoesNotApplyError, GetAllDatabasesPrivilegeDataError,
|
||||
GetDatabasesPrivilegeDataError, ListAllPrivilegesResponse, ListPrivilegesResponse,
|
||||
ModifyDatabasePrivilegesError, ModifyPrivilegesResponse,
|
||||
request_validation::validate_db_or_user_request,
|
||||
},
|
||||
types::{DbOrUser, MySQLDatabase, MySQLUser},
|
||||
},
|
||||
server::{
|
||||
common::{create_user_group_matching_regex, try_get_with_binary_fallback},
|
||||
input_sanitization::{quote_identifier, validate_name, validate_ownership_by_unix_user},
|
||||
sql::database_operations::unsafe_database_exists,
|
||||
sql::{
|
||||
database_operations::unsafe_database_exists, quote_identifier,
|
||||
user_operations::unsafe_user_exists,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/// This is the list of fields that are used to fetch the db + user + privileges
|
||||
/// from the `db` table in the database. If you need to add or remove privilege
|
||||
/// fields, this is a good place to start.
|
||||
pub const DATABASE_PRIVILEGE_FIELDS: [&str; 13] = [
|
||||
"Db",
|
||||
"User",
|
||||
"select_priv",
|
||||
"insert_priv",
|
||||
"update_priv",
|
||||
"delete_priv",
|
||||
"create_priv",
|
||||
"drop_priv",
|
||||
"alter_priv",
|
||||
"index_priv",
|
||||
"create_tmp_table_priv",
|
||||
"lock_tables_priv",
|
||||
"references_priv",
|
||||
];
|
||||
|
||||
// NOTE: ord is needed for BTreeSet to accept the type, but it
|
||||
// doesn't have any natural implementation semantics.
|
||||
|
||||
/// This struct represents the set of privileges for a single user on a single database.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
|
||||
pub struct DatabasePrivilegeRow {
|
||||
pub db: MySQLDatabase,
|
||||
pub user: MySQLUser,
|
||||
pub select_priv: bool,
|
||||
pub insert_priv: bool,
|
||||
pub update_priv: bool,
|
||||
pub delete_priv: bool,
|
||||
pub create_priv: bool,
|
||||
pub drop_priv: bool,
|
||||
pub alter_priv: bool,
|
||||
pub index_priv: bool,
|
||||
pub create_tmp_table_priv: bool,
|
||||
pub lock_tables_priv: bool,
|
||||
pub references_priv: bool,
|
||||
}
|
||||
|
||||
impl DatabasePrivilegeRow {
|
||||
pub fn get_privilege_by_name(&self, name: &str) -> bool {
|
||||
match name {
|
||||
"select_priv" => self.select_priv,
|
||||
"insert_priv" => self.insert_priv,
|
||||
"update_priv" => self.update_priv,
|
||||
"delete_priv" => self.delete_priv,
|
||||
"create_priv" => self.create_priv,
|
||||
"drop_priv" => self.drop_priv,
|
||||
"alter_priv" => self.alter_priv,
|
||||
"index_priv" => self.index_priv,
|
||||
"create_tmp_table_priv" => self.create_tmp_table_priv,
|
||||
"lock_tables_priv" => self.lock_tables_priv,
|
||||
"references_priv" => self.references_priv,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: get by name instead of row tuple position
|
||||
|
||||
#[inline]
|
||||
@@ -107,7 +53,7 @@ fn get_mysql_row_priv_field(row: &MySqlRow, position: usize) -> Result<bool, sql
|
||||
match rev_yn(value) {
|
||||
Some(val) => Ok(val),
|
||||
_ => {
|
||||
log::warn!(r#"Invalid value for privilege "{}": '{}'"#, field, value);
|
||||
tracing::warn!(r#"Invalid value for privilege "{}": '{}'"#, field, value);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
@@ -151,7 +97,7 @@ async fn unsafe_get_database_privileges(
|
||||
.await;
|
||||
|
||||
if let Err(e) = &result {
|
||||
log::error!(
|
||||
tracing::error!(
|
||||
"Failed to get database privileges for '{}': {}",
|
||||
&database_name,
|
||||
e
|
||||
@@ -181,7 +127,7 @@ pub async fn unsafe_get_database_privileges_for_db_user_pair(
|
||||
.await;
|
||||
|
||||
if let Err(e) = &result {
|
||||
log::error!(
|
||||
tracing::error!(
|
||||
"Failed to get database privileges for '{}.{}': {}",
|
||||
&database_name,
|
||||
&user_name,
|
||||
@@ -196,23 +142,16 @@ pub async fn get_databases_privilege_data(
|
||||
database_names: Vec<MySQLDatabase>,
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
) -> GetDatabasesPrivilegeData {
|
||||
_db_is_mariadb: bool,
|
||||
) -> ListPrivilegesResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for database_name in database_names.iter() {
|
||||
if let Err(err) = validate_name(database_name) {
|
||||
results.insert(
|
||||
database_name.to_owned(),
|
||||
Err(GetDatabasesPrivilegeDataError::SanitizationError(err)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(err) = validate_ownership_by_unix_user(database_name, unix_user) {
|
||||
results.insert(
|
||||
database_name.to_owned(),
|
||||
Err(GetDatabasesPrivilegeDataError::OwnershipError(err)),
|
||||
);
|
||||
if let Err(err) =
|
||||
validate_db_or_user_request(&DbOrUser::Database(database_name.clone()), unix_user)
|
||||
.map_err(GetDatabasesPrivilegeDataError::ValidationError)
|
||||
{
|
||||
results.insert(database_name.to_owned(), Err(err));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -239,36 +178,43 @@ pub async fn get_databases_privilege_data(
|
||||
results
|
||||
}
|
||||
|
||||
/// Get all database + user + privileges pairs that are owned by the current user.
|
||||
pub async fn get_all_database_privileges(
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
) -> GetAllDatabasesPrivilegeData {
|
||||
let result = sqlx::query_as::<_, DatabasePrivilegeRow>(&format!(
|
||||
/// TODO: make this constant
|
||||
fn get_all_db_privs_query() -> String {
|
||||
format!(
|
||||
indoc! {r#"
|
||||
SELECT {} FROM `db` WHERE `db` IN
|
||||
(SELECT DISTINCT `SCHEMA_NAME` AS `database`
|
||||
FROM `information_schema`.`SCHEMATA`
|
||||
WHERE `SCHEMA_NAME` NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')
|
||||
AND `SCHEMA_NAME` REGEXP ?)
|
||||
SELECT {} FROM `db` WHERE `db` IN
|
||||
(SELECT DISTINCT CAST(`SCHEMA_NAME` AS CHAR(64)) AS `database`
|
||||
FROM `information_schema`.`SCHEMATA`
|
||||
WHERE `SCHEMA_NAME` NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')
|
||||
AND `SCHEMA_NAME` REGEXP ?)
|
||||
"#},
|
||||
DATABASE_PRIVILEGE_FIELDS
|
||||
.iter()
|
||||
.map(|field| quote_identifier(field))
|
||||
.join(","),
|
||||
))
|
||||
.bind(create_user_group_matching_regex(unix_user))
|
||||
.fetch_all(connection)
|
||||
.await
|
||||
.map_err(|e| GetAllDatabasesPrivilegeDataError::MySqlError(e.to_string()));
|
||||
)
|
||||
}
|
||||
|
||||
/// Get all database + user + privileges pairs that are owned by the current user.
|
||||
pub async fn get_all_database_privileges(
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
) -> ListAllPrivilegesResponse {
|
||||
let result = sqlx::query_as::<_, DatabasePrivilegeRow>(&get_all_db_privs_query())
|
||||
.bind(create_user_group_matching_regex(unix_user))
|
||||
.fetch_all(connection)
|
||||
.await
|
||||
.map_err(|e| GetAllDatabasesPrivilegeDataError::MySqlError(e.to_string()));
|
||||
|
||||
if let Err(e) = &result {
|
||||
log::error!("Failed to get all database privileges: {:?}", e);
|
||||
tracing::error!("Failed to get all database privileges: {:?}", e);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// TODO: make these queries constant strings.
|
||||
async fn unsafe_apply_privilege_diff(
|
||||
database_privilege_diff: &DatabasePrivilegesDiff,
|
||||
connection: &mut MySqlConnection,
|
||||
@@ -304,22 +250,39 @@ async fn unsafe_apply_privilege_diff(
|
||||
.map(|_| ())
|
||||
}
|
||||
DatabasePrivilegesDiff::Modified(p) => {
|
||||
let changes = p
|
||||
.diff
|
||||
let changes = DATABASE_PRIVILEGE_FIELDS
|
||||
.iter()
|
||||
.map(|diff| match diff {
|
||||
DatabasePrivilegeChange::YesToNo(name) => {
|
||||
format!("{} = 'N'", quote_identifier(name))
|
||||
}
|
||||
DatabasePrivilegeChange::NoToYes(name) => {
|
||||
format!("{} = 'Y'", quote_identifier(name))
|
||||
}
|
||||
.skip(2) // Skip Db and User fields
|
||||
.map(|field| {
|
||||
format!(
|
||||
"{} = COALESCE(?, {})",
|
||||
quote_identifier(field),
|
||||
quote_identifier(field)
|
||||
)
|
||||
})
|
||||
.join(",");
|
||||
|
||||
fn change_to_yn(change: DatabasePrivilegeChange) -> &'static str {
|
||||
match change {
|
||||
DatabasePrivilegeChange::YesToNo => "N",
|
||||
DatabasePrivilegeChange::NoToYes => "Y",
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
format!("UPDATE `db` SET {} WHERE `Db` = ? AND `User` = ?", changes).as_str(),
|
||||
)
|
||||
.bind(p.select_priv.map(change_to_yn))
|
||||
.bind(p.insert_priv.map(change_to_yn))
|
||||
.bind(p.update_priv.map(change_to_yn))
|
||||
.bind(p.delete_priv.map(change_to_yn))
|
||||
.bind(p.create_priv.map(change_to_yn))
|
||||
.bind(p.drop_priv.map(change_to_yn))
|
||||
.bind(p.alter_priv.map(change_to_yn))
|
||||
.bind(p.index_priv.map(change_to_yn))
|
||||
.bind(p.create_tmp_table_priv.map(change_to_yn))
|
||||
.bind(p.lock_tables_priv.map(change_to_yn))
|
||||
.bind(p.references_priv.map(change_to_yn))
|
||||
.bind(p.db.to_string())
|
||||
.bind(p.user.to_string())
|
||||
.execute(connection)
|
||||
@@ -334,10 +297,11 @@ async fn unsafe_apply_privilege_diff(
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
DatabasePrivilegesDiff::Noop { .. } => Ok(()),
|
||||
};
|
||||
|
||||
if let Err(e) = &result {
|
||||
log::error!("Failed to apply database privilege diff: {}", e);
|
||||
tracing::error!("Failed to apply database privilege diff: {}", e);
|
||||
}
|
||||
|
||||
result
|
||||
@@ -359,7 +323,7 @@ async fn validate_diff(
|
||||
Err(e) => return Err(ModifyDatabasePrivilegesError::MySqlError(e.to_string())),
|
||||
};
|
||||
|
||||
let result = match diff {
|
||||
match diff {
|
||||
DatabasePrivilegesDiff::New(_) => {
|
||||
if privilege_row.is_some() {
|
||||
Err(ModifyDatabasePrivilegesError::DiffDoesNotApply(
|
||||
@@ -383,10 +347,20 @@ async fn validate_diff(
|
||||
DatabasePrivilegesDiff::Modified(row_diff) => {
|
||||
let row = privilege_row.unwrap();
|
||||
|
||||
let error_exists = row_diff.diff.iter().any(|change| match change {
|
||||
DatabasePrivilegeChange::YesToNo(name) => !row.get_privilege_by_name(name),
|
||||
DatabasePrivilegeChange::NoToYes(name) => row.get_privilege_by_name(name),
|
||||
});
|
||||
let error_exists = DATABASE_PRIVILEGE_FIELDS
|
||||
.iter()
|
||||
.skip(2) // Skip Db and User fields
|
||||
.any(
|
||||
|field| match row_diff.get_privilege_change_by_name(field).unwrap() {
|
||||
Some(DatabasePrivilegeChange::YesToNo) => {
|
||||
!row.get_privilege_by_name(field).unwrap()
|
||||
}
|
||||
Some(DatabasePrivilegeChange::NoToYes) => {
|
||||
row.get_privilege_by_name(field).unwrap()
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
);
|
||||
|
||||
if error_exists {
|
||||
Err(ModifyDatabasePrivilegesError::DiffDoesNotApply(
|
||||
@@ -408,9 +382,13 @@ async fn validate_diff(
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
result
|
||||
DatabasePrivilegesDiff::Noop { .. } => {
|
||||
tracing::warn!(
|
||||
"Server got sent a noop database privilege diff to validate, is the client buggy?"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Uses the result of [`diff_privileges`] to modify privileges in the database.
|
||||
@@ -418,7 +396,8 @@ pub async fn apply_privilege_diffs(
|
||||
database_privilege_diffs: BTreeSet<DatabasePrivilegesDiff>,
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
) -> ModifyDatabasePrivilegesOutput {
|
||||
_db_is_mariadb: bool,
|
||||
) -> ModifyPrivilegesResponse {
|
||||
let mut results: BTreeMap<(MySQLDatabase, MySQLUser), _> = BTreeMap::new();
|
||||
|
||||
for diff in database_privilege_diffs {
|
||||
@@ -426,37 +405,21 @@ pub async fn apply_privilege_diffs(
|
||||
diff.get_database_name().to_owned(),
|
||||
diff.get_user_name().to_owned(),
|
||||
);
|
||||
if let Err(err) = validate_name(diff.get_database_name()) {
|
||||
results.insert(
|
||||
key,
|
||||
Err(ModifyDatabasePrivilegesError::DatabaseSanitizationError(
|
||||
err,
|
||||
)),
|
||||
);
|
||||
if let Err(err) = validate_db_or_user_request(
|
||||
&DbOrUser::Database(diff.get_database_name().to_owned()),
|
||||
unix_user,
|
||||
)
|
||||
.map_err(ModifyDatabasePrivilegesError::UserValidationError)
|
||||
{
|
||||
results.insert(key, Err(err));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(err) = validate_ownership_by_unix_user(diff.get_database_name(), unix_user) {
|
||||
results.insert(
|
||||
key,
|
||||
Err(ModifyDatabasePrivilegesError::DatabaseOwnershipError(err)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(err) = validate_name(diff.get_user_name()) {
|
||||
results.insert(
|
||||
key,
|
||||
Err(ModifyDatabasePrivilegesError::UserSanitizationError(err)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(err) = validate_ownership_by_unix_user(diff.get_user_name(), unix_user) {
|
||||
results.insert(
|
||||
key,
|
||||
Err(ModifyDatabasePrivilegesError::UserOwnershipError(err)),
|
||||
);
|
||||
if let Err(err) =
|
||||
validate_db_or_user_request(&DbOrUser::User(diff.get_user_name().to_owned()), unix_user)
|
||||
.map_err(ModifyDatabasePrivilegesError::UserValidationError)
|
||||
{
|
||||
results.insert(key, Err(err));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -471,6 +434,14 @@ pub async fn apply_privilege_diffs(
|
||||
continue;
|
||||
}
|
||||
|
||||
if !unsafe_user_exists(diff.get_user_name(), connection)
|
||||
.await
|
||||
.unwrap()
|
||||
{
|
||||
results.insert(key, Err(ModifyDatabasePrivilegesError::UserDoesNotExist));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(err) = validate_diff(&diff, connection).await {
|
||||
results.insert(key, Err(err));
|
||||
continue;
|
||||
|
||||
@@ -7,25 +7,28 @@ use serde::{Deserialize, Serialize};
|
||||
use sqlx::MySqlConnection;
|
||||
use sqlx::prelude::*;
|
||||
|
||||
use crate::core::protocol::request_validation::validate_db_or_user_request;
|
||||
use crate::core::types::DbOrUser;
|
||||
use crate::{
|
||||
core::{
|
||||
common::UnixUser,
|
||||
database_privileges::DATABASE_PRIVILEGE_FIELDS,
|
||||
protocol::{
|
||||
CreateUserError, CreateUsersOutput, DropUserError, DropUsersOutput, ListAllUsersError,
|
||||
ListAllUsersOutput, ListUsersError, ListUsersOutput, LockUserError, LockUsersOutput,
|
||||
MySQLUser, SetPasswordError, SetPasswordOutput, UnlockUserError, UnlockUsersOutput,
|
||||
CreateUserError, CreateUsersResponse, DropUserError, DropUsersResponse,
|
||||
ListAllUsersError, ListAllUsersResponse, ListUsersError, ListUsersResponse,
|
||||
LockUserError, LockUsersResponse, SetPasswordError, SetUserPasswordResponse,
|
||||
UnlockUserError, UnlockUsersResponse,
|
||||
},
|
||||
types::MySQLUser,
|
||||
},
|
||||
server::{
|
||||
common::{create_user_group_matching_regex, try_get_with_binary_fallback},
|
||||
input_sanitization::{quote_literal, validate_name, validate_ownership_by_unix_user},
|
||||
sql::quote_literal,
|
||||
},
|
||||
};
|
||||
|
||||
use super::database_privilege_operations::DATABASE_PRIVILEGE_FIELDS;
|
||||
|
||||
// NOTE: this function is unsafe because it does no input validation.
|
||||
async fn unsafe_user_exists(
|
||||
pub(super) async fn unsafe_user_exists(
|
||||
db_user: &str,
|
||||
connection: &mut MySqlConnection,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
@@ -44,27 +47,64 @@ async fn unsafe_user_exists(
|
||||
.map(|row| row.get::<bool, _>(0));
|
||||
|
||||
if let Err(err) = &result {
|
||||
log::error!("Failed to check if database user exists: {:?}", err);
|
||||
tracing::error!("Failed to check if database user exists: {:?}", err);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn complete_user_name(
|
||||
user_prefix: String,
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
_db_is_mariadb: bool,
|
||||
) -> Vec<MySQLUser> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
SELECT `User` AS `user`
|
||||
FROM `mysql`.`user`
|
||||
WHERE `User` REGEXP ?
|
||||
AND `User` LIKE ?
|
||||
"#,
|
||||
)
|
||||
.bind(create_user_group_matching_regex(unix_user))
|
||||
.bind(format!("{}%", user_prefix))
|
||||
.fetch_all(connection)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(rows) => rows
|
||||
.into_iter()
|
||||
.filter_map(|row| {
|
||||
let user: String = try_get_with_binary_fallback(&row, "user").ok()?;
|
||||
Some(user.into())
|
||||
})
|
||||
.collect(),
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
"Failed to complete user name for prefix '{}' and user '{}': {:?}",
|
||||
user_prefix,
|
||||
unix_user.username,
|
||||
err
|
||||
);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_database_users(
|
||||
db_users: Vec<MySQLUser>,
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
) -> CreateUsersOutput {
|
||||
_db_is_mariadb: bool,
|
||||
) -> CreateUsersResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for db_user in db_users {
|
||||
if let Err(err) = validate_name(&db_user) {
|
||||
results.insert(db_user, Err(CreateUserError::SanitizationError(err)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(err) = validate_ownership_by_unix_user(&db_user, unix_user) {
|
||||
results.insert(db_user, Err(CreateUserError::OwnershipError(err)));
|
||||
if let Err(err) = validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user)
|
||||
.map_err(CreateUserError::ValidationError)
|
||||
{
|
||||
results.insert(db_user, Err(err));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -87,7 +127,7 @@ pub async fn create_database_users(
|
||||
.map_err(|err| CreateUserError::MySqlError(err.to_string()));
|
||||
|
||||
if let Err(err) = &result {
|
||||
log::error!("Failed to create database user '{}': {:?}", &db_user, err);
|
||||
tracing::error!("Failed to create database user '{}': {:?}", &db_user, err);
|
||||
}
|
||||
|
||||
results.insert(db_user, result);
|
||||
@@ -100,17 +140,15 @@ pub async fn drop_database_users(
|
||||
db_users: Vec<MySQLUser>,
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
) -> DropUsersOutput {
|
||||
_db_is_mariadb: bool,
|
||||
) -> DropUsersResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for db_user in db_users {
|
||||
if let Err(err) = validate_name(&db_user) {
|
||||
results.insert(db_user, Err(DropUserError::SanitizationError(err)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(err) = validate_ownership_by_unix_user(&db_user, unix_user) {
|
||||
results.insert(db_user, Err(DropUserError::OwnershipError(err)));
|
||||
if let Err(err) = validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user)
|
||||
.map_err(DropUserError::ValidationError)
|
||||
{
|
||||
results.insert(db_user, Err(err));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -133,7 +171,7 @@ pub async fn drop_database_users(
|
||||
.map_err(|err| DropUserError::MySqlError(err.to_string()));
|
||||
|
||||
if let Err(err) = &result {
|
||||
log::error!("Failed to drop database user '{}': {:?}", &db_user, err);
|
||||
tracing::error!("Failed to drop database user '{}': {:?}", &db_user, err);
|
||||
}
|
||||
|
||||
results.insert(db_user, result);
|
||||
@@ -147,14 +185,10 @@ pub async fn set_password_for_database_user(
|
||||
password: &str,
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
) -> SetPasswordOutput {
|
||||
if let Err(err) = validate_name(db_user) {
|
||||
return Err(SetPasswordError::SanitizationError(err));
|
||||
}
|
||||
|
||||
if let Err(err) = validate_ownership_by_unix_user(db_user, unix_user) {
|
||||
return Err(SetPasswordError::OwnershipError(err));
|
||||
}
|
||||
_db_is_mariadb: bool,
|
||||
) -> SetUserPasswordResponse {
|
||||
validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user)
|
||||
.map_err(SetPasswordError::ValidationError)?;
|
||||
|
||||
match unsafe_user_exists(db_user, &mut *connection).await {
|
||||
Ok(false) => return Err(SetPasswordError::UserDoesNotExist),
|
||||
@@ -176,7 +210,7 @@ pub async fn set_password_for_database_user(
|
||||
.map_err(|err| SetPasswordError::MySqlError(err.to_string()));
|
||||
|
||||
if result.is_err() {
|
||||
log::error!(
|
||||
tracing::error!(
|
||||
"Failed to set password for database user '{}': <REDACTED>",
|
||||
&db_user,
|
||||
);
|
||||
@@ -185,29 +219,42 @@ pub async fn set_password_for_database_user(
|
||||
result
|
||||
}
|
||||
|
||||
const DATABASE_USER_LOCK_STATUS_QUERY_MARIADB: &str = r#"
|
||||
SELECT COALESCE(
|
||||
JSON_EXTRACT(`mysql`.`global_priv`.`priv`, "$.account_locked"),
|
||||
'false'
|
||||
) != 'false'
|
||||
FROM `mysql`.`global_priv`
|
||||
WHERE `User` = ?
|
||||
AND `Host` = '%'
|
||||
"#;
|
||||
|
||||
const DATABASE_USER_LOCK_STATUS_QUERY_MYSQL: &str = r#"
|
||||
SELECT `mysql`.`user`.`account_locked` = 'Y'
|
||||
FROM `mysql`.`user`
|
||||
WHERE `User` = ?
|
||||
AND `Host` = '%'
|
||||
"#;
|
||||
|
||||
// NOTE: this function is unsafe because it does no input validation.
|
||||
async fn database_user_is_locked_unsafe(
|
||||
db_user: &str,
|
||||
connection: &mut MySqlConnection,
|
||||
db_is_mariadb: bool,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
SELECT COALESCE(
|
||||
JSON_EXTRACT(`mysql`.`global_priv`.`priv`, "$.account_locked"),
|
||||
'false'
|
||||
) != 'false'
|
||||
FROM `mysql`.`global_priv`
|
||||
WHERE `User` = ?
|
||||
AND `Host` = '%'
|
||||
"#,
|
||||
)
|
||||
let result = sqlx::query(if db_is_mariadb {
|
||||
DATABASE_USER_LOCK_STATUS_QUERY_MARIADB
|
||||
} else {
|
||||
DATABASE_USER_LOCK_STATUS_QUERY_MYSQL
|
||||
})
|
||||
.bind(db_user)
|
||||
.fetch_one(connection)
|
||||
.await
|
||||
.map(|row| row.get::<bool, _>(0));
|
||||
.map(|row| row.try_get(0))
|
||||
.and_then(|res| res);
|
||||
|
||||
if let Err(err) = &result {
|
||||
log::error!(
|
||||
tracing::error!(
|
||||
"Failed to check if database user is locked '{}': {:?}",
|
||||
&db_user,
|
||||
err
|
||||
@@ -221,17 +268,15 @@ pub async fn lock_database_users(
|
||||
db_users: Vec<MySQLUser>,
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
) -> LockUsersOutput {
|
||||
db_is_mariadb: bool,
|
||||
) -> LockUsersResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for db_user in db_users {
|
||||
if let Err(err) = validate_name(&db_user) {
|
||||
results.insert(db_user, Err(LockUserError::SanitizationError(err)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(err) = validate_ownership_by_unix_user(&db_user, unix_user) {
|
||||
results.insert(db_user, Err(LockUserError::OwnershipError(err)));
|
||||
if let Err(err) = validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user)
|
||||
.map_err(LockUserError::ValidationError)
|
||||
{
|
||||
results.insert(db_user, Err(err));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -247,7 +292,7 @@ pub async fn lock_database_users(
|
||||
}
|
||||
}
|
||||
|
||||
match database_user_is_locked_unsafe(&db_user, &mut *connection).await {
|
||||
match database_user_is_locked_unsafe(&db_user, &mut *connection, db_is_mariadb).await {
|
||||
Ok(false) => {}
|
||||
Ok(true) => {
|
||||
results.insert(db_user, Err(LockUserError::UserIsAlreadyLocked));
|
||||
@@ -268,7 +313,7 @@ pub async fn lock_database_users(
|
||||
.map_err(|err| LockUserError::MySqlError(err.to_string()));
|
||||
|
||||
if let Err(err) = &result {
|
||||
log::error!("Failed to lock database user '{}': {:?}", &db_user, err);
|
||||
tracing::error!("Failed to lock database user '{}': {:?}", &db_user, err);
|
||||
}
|
||||
|
||||
results.insert(db_user, result);
|
||||
@@ -281,17 +326,15 @@ pub async fn unlock_database_users(
|
||||
db_users: Vec<MySQLUser>,
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
) -> UnlockUsersOutput {
|
||||
db_is_mariadb: bool,
|
||||
) -> UnlockUsersResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for db_user in db_users {
|
||||
if let Err(err) = validate_name(&db_user) {
|
||||
results.insert(db_user, Err(UnlockUserError::SanitizationError(err)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(err) = validate_ownership_by_unix_user(&db_user, unix_user) {
|
||||
results.insert(db_user, Err(UnlockUserError::OwnershipError(err)));
|
||||
if let Err(err) = validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user)
|
||||
.map_err(UnlockUserError::ValidationError)
|
||||
{
|
||||
results.insert(db_user, Err(err));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -307,7 +350,7 @@ pub async fn unlock_database_users(
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match database_user_is_locked_unsafe(&db_user, &mut *connection).await {
|
||||
match database_user_is_locked_unsafe(&db_user, &mut *connection, db_is_mariadb).await {
|
||||
Ok(false) => {
|
||||
results.insert(db_user, Err(UnlockUserError::UserIsAlreadyUnlocked));
|
||||
continue;
|
||||
@@ -328,7 +371,7 @@ pub async fn unlock_database_users(
|
||||
.map_err(|err| UnlockUserError::MySqlError(err.to_string()));
|
||||
|
||||
if let Err(err) = &result {
|
||||
log::error!("Failed to unlock database user '{}': {:?}", &db_user, err);
|
||||
tracing::error!("Failed to unlock database user '{}': {:?}", &db_user, err);
|
||||
}
|
||||
|
||||
results.insert(db_user, result);
|
||||
@@ -355,13 +398,13 @@ impl FromRow<'_, sqlx::mysql::MySqlRow> for DatabaseUser {
|
||||
user: try_get_with_binary_fallback(row, "User")?.into(),
|
||||
host: try_get_with_binary_fallback(row, "Host")?,
|
||||
has_password: row.try_get("has_password")?,
|
||||
is_locked: row.try_get("is_locked")?,
|
||||
is_locked: row.try_get("account_locked")?,
|
||||
databases: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const DB_USER_SELECT_STATEMENT: &str = r#"
|
||||
const DB_USER_SELECT_STATEMENT_MARIADB: &str = r#"
|
||||
SELECT
|
||||
`user`.`User`,
|
||||
`user`.`Host`,
|
||||
@@ -369,40 +412,51 @@ SELECT
|
||||
COALESCE(
|
||||
JSON_EXTRACT(`global_priv`.`priv`, "$.account_locked"),
|
||||
'false'
|
||||
) != 'false' AS `is_locked`
|
||||
) != 'false' AS `account_locked`
|
||||
FROM `user`
|
||||
JOIN `global_priv` ON
|
||||
`user`.`User` = `global_priv`.`User`
|
||||
AND `user`.`Host` = `global_priv`.`Host`
|
||||
"#;
|
||||
|
||||
const DB_USER_SELECT_STATEMENT_MYSQL: &str = r#"
|
||||
SELECT
|
||||
`user`.`User`,
|
||||
`user`.`Host`,
|
||||
`user`.`authentication_string` != '' AS `has_password`,
|
||||
`user`.`account_locked` = 'Y' AS `account_locked`
|
||||
FROM `user`
|
||||
"#;
|
||||
|
||||
pub async fn list_database_users(
|
||||
db_users: Vec<MySQLUser>,
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
) -> ListUsersOutput {
|
||||
db_is_mariadb: bool,
|
||||
) -> ListUsersResponse {
|
||||
let mut results = BTreeMap::new();
|
||||
|
||||
for db_user in db_users {
|
||||
if let Err(err) = validate_name(&db_user) {
|
||||
results.insert(db_user, Err(ListUsersError::SanitizationError(err)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(err) = validate_ownership_by_unix_user(&db_user, unix_user) {
|
||||
results.insert(db_user, Err(ListUsersError::OwnershipError(err)));
|
||||
if let Err(err) = validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user)
|
||||
.map_err(ListUsersError::ValidationError)
|
||||
{
|
||||
results.insert(db_user, Err(err));
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut result = sqlx::query_as::<_, DatabaseUser>(
|
||||
&(DB_USER_SELECT_STATEMENT.to_string() + "WHERE `mysql`.`user`.`User` = ?"),
|
||||
&(if db_is_mariadb {
|
||||
DB_USER_SELECT_STATEMENT_MARIADB.to_string()
|
||||
} else {
|
||||
DB_USER_SELECT_STATEMENT_MYSQL.to_string()
|
||||
} + "WHERE `mysql`.`user`.`User` = ?"),
|
||||
)
|
||||
.bind(db_user.as_str())
|
||||
.fetch_optional(&mut *connection)
|
||||
.await;
|
||||
|
||||
if let Err(err) = &result {
|
||||
log::error!("Failed to list database user '{}': {:?}", &db_user, err);
|
||||
tracing::error!("Failed to list database user '{}': {:?}", &db_user, err);
|
||||
}
|
||||
|
||||
if let Ok(Some(user)) = result.as_mut() {
|
||||
@@ -422,9 +476,14 @@ pub async fn list_database_users(
|
||||
pub async fn list_all_database_users_for_unix_user(
|
||||
unix_user: &UnixUser,
|
||||
connection: &mut MySqlConnection,
|
||||
) -> ListAllUsersOutput {
|
||||
db_is_mariadb: bool,
|
||||
) -> ListAllUsersResponse {
|
||||
let mut result = sqlx::query_as::<_, DatabaseUser>(
|
||||
&(DB_USER_SELECT_STATEMENT.to_string() + "WHERE `user`.`User` REGEXP ?"),
|
||||
&(if db_is_mariadb {
|
||||
DB_USER_SELECT_STATEMENT_MARIADB.to_string()
|
||||
} else {
|
||||
DB_USER_SELECT_STATEMENT_MYSQL.to_string()
|
||||
} + "WHERE `user`.`User` REGEXP ?"),
|
||||
)
|
||||
.bind(create_user_group_matching_regex(unix_user))
|
||||
.fetch_all(&mut *connection)
|
||||
@@ -432,7 +491,7 @@ pub async fn list_all_database_users_for_unix_user(
|
||||
.map_err(|err| ListAllUsersError::MySqlError(err.to_string()));
|
||||
|
||||
if let Err(err) = &result {
|
||||
log::error!("Failed to list all database users: {:?}", err);
|
||||
tracing::error!("Failed to list all database users: {:?}", err);
|
||||
}
|
||||
|
||||
if let Ok(users) = result.as_mut() {
|
||||
@@ -467,7 +526,7 @@ pub async fn append_databases_where_user_has_privileges(
|
||||
.await;
|
||||
|
||||
if let Err(err) = &database_list {
|
||||
log::error!(
|
||||
tracing::error!(
|
||||
"Failed to list databases for user '{}': {:?}",
|
||||
&db_user.user,
|
||||
err
|
||||
|
||||
524
src/server/supervisor.rs
Normal file
524
src/server/supervisor.rs
Normal file
@@ -0,0 +1,524 @@
|
||||
use std::{
|
||||
fs,
|
||||
os::{fd::FromRawFd, unix::net::UnixListener as StdUnixListener},
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{Context, anyhow};
|
||||
use sqlx::MySqlPool;
|
||||
use tokio::{
|
||||
net::UnixListener as TokioUnixListener,
|
||||
select,
|
||||
sync::{Mutex, RwLock, broadcast},
|
||||
task::JoinHandle,
|
||||
time::interval,
|
||||
};
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
use crate::server::{
|
||||
config::{MysqlConfig, ServerConfig},
|
||||
session_handler::session_handler,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum SupervisorMessage {
|
||||
StopAcceptingNewConnections,
|
||||
ResumeAcceptingNewConnections,
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ReloadEvent;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct Supervisor {
|
||||
config_path: PathBuf,
|
||||
config: Arc<Mutex<ServerConfig>>,
|
||||
systemd_mode: bool,
|
||||
|
||||
shutdown_cancel_token: CancellationToken,
|
||||
reload_message_receiver: broadcast::Receiver<ReloadEvent>,
|
||||
signal_handler_task: JoinHandle<()>,
|
||||
|
||||
db_connection_pool: Arc<RwLock<MySqlPool>>,
|
||||
db_is_mariadb: Arc<RwLock<bool>>,
|
||||
listener: Arc<RwLock<TokioUnixListener>>,
|
||||
listener_task: JoinHandle<anyhow::Result<()>>,
|
||||
handler_task_tracker: TaskTracker,
|
||||
supervisor_message_sender: broadcast::Sender<SupervisorMessage>,
|
||||
|
||||
watchdog_timeout: Option<Duration>,
|
||||
systemd_watchdog_task: Option<JoinHandle<()>>,
|
||||
|
||||
status_notifier_task: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Supervisor {
|
||||
pub async fn new(config_path: PathBuf, systemd_mode: bool) -> anyhow::Result<Self> {
|
||||
tracing::debug!("Starting server supervisor");
|
||||
tracing::debug!(
|
||||
"Running in tokio with {} worker threads",
|
||||
tokio::runtime::Handle::current().metrics().num_workers()
|
||||
);
|
||||
|
||||
let config = ServerConfig::read_config_from_path(&config_path)
|
||||
.context("Failed to read server configuration")?;
|
||||
|
||||
let mut watchdog_duration = None;
|
||||
let mut watchdog_micro_seconds = 0;
|
||||
let watchdog_task =
|
||||
if systemd_mode && sd_notify::watchdog_enabled(true, &mut watchdog_micro_seconds) {
|
||||
watchdog_duration = Some(Duration::from_micros(watchdog_micro_seconds));
|
||||
tracing::debug!(
|
||||
"Systemd watchdog enabled with {} millisecond interval",
|
||||
watchdog_micro_seconds.div_ceil(1000),
|
||||
);
|
||||
Some(spawn_watchdog_task(watchdog_duration.unwrap()))
|
||||
} else {
|
||||
tracing::debug!("Systemd watchdog not enabled, skipping watchdog thread");
|
||||
None
|
||||
};
|
||||
|
||||
let db_connection_pool =
|
||||
Arc::new(RwLock::new(create_db_connection_pool(&config.mysql).await?));
|
||||
|
||||
let db_is_mariadb = {
|
||||
let connection = db_connection_pool.read().await;
|
||||
let version: String = sqlx::query_scalar("SELECT VERSION()")
|
||||
.fetch_one(&*connection)
|
||||
.await
|
||||
.context("Failed to query database version")?;
|
||||
|
||||
let result = version.to_lowercase().contains("mariadb");
|
||||
tracing::debug!(
|
||||
"Connected to {} database server",
|
||||
if result { "MariaDB" } else { "MySQL" }
|
||||
);
|
||||
|
||||
Arc::new(RwLock::new(result))
|
||||
};
|
||||
|
||||
let task_tracker = TaskTracker::new();
|
||||
|
||||
let status_notifier_task = if systemd_mode {
|
||||
Some(spawn_status_notifier_task(task_tracker.clone()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (tx, rx) = broadcast::channel(1);
|
||||
|
||||
// TODO: try to detech systemd socket before using the provided socket path
|
||||
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?,
|
||||
}));
|
||||
|
||||
let (reload_tx, reload_rx) = broadcast::channel(1);
|
||||
let shutdown_cancel_token = CancellationToken::new();
|
||||
let signal_handler_task =
|
||||
spawn_signal_handler_task(reload_tx, shutdown_cancel_token.clone());
|
||||
|
||||
let listener_clone = listener.clone();
|
||||
let task_tracker_clone = task_tracker.clone();
|
||||
let listener_task = {
|
||||
tokio::spawn(listener_task(
|
||||
listener_clone,
|
||||
task_tracker_clone,
|
||||
db_connection_pool.clone(),
|
||||
rx,
|
||||
db_is_mariadb.clone(),
|
||||
))
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
config_path,
|
||||
config: Arc::new(Mutex::new(config)),
|
||||
systemd_mode,
|
||||
reload_message_receiver: reload_rx,
|
||||
shutdown_cancel_token,
|
||||
signal_handler_task,
|
||||
db_connection_pool,
|
||||
db_is_mariadb,
|
||||
listener,
|
||||
listener_task,
|
||||
handler_task_tracker: task_tracker,
|
||||
supervisor_message_sender: tx,
|
||||
watchdog_timeout: watchdog_duration,
|
||||
systemd_watchdog_task: watchdog_task,
|
||||
status_notifier_task,
|
||||
})
|
||||
}
|
||||
|
||||
fn stop_receiving_new_connections(&self) -> anyhow::Result<()> {
|
||||
self.handler_task_tracker.close();
|
||||
self.supervisor_message_sender
|
||||
.send(SupervisorMessage::StopAcceptingNewConnections)
|
||||
.context("Failed to send stop accepting new connections message to listener task")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resume_receiving_new_connections(&self) -> anyhow::Result<()> {
|
||||
self.handler_task_tracker.reopen();
|
||||
self.supervisor_message_sender
|
||||
.send(SupervisorMessage::ResumeAcceptingNewConnections)
|
||||
.context("Failed to send resume accepting new connections message to listener task")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_existing_connections_to_finish(&self) -> anyhow::Result<()> {
|
||||
self.handler_task_tracker.wait().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reload_config(&self) -> anyhow::Result<()> {
|
||||
let new_config = ServerConfig::read_config_from_path(&self.config_path)
|
||||
.context("Failed to read server configuration")?;
|
||||
let mut config = self.config.clone().lock_owned().await;
|
||||
*config = new_config;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restart_db_connection_pool(&self) -> anyhow::Result<()> {
|
||||
let config = self.config.lock().await;
|
||||
let mut connection_pool = self.db_connection_pool.clone().write_owned().await;
|
||||
let mut db_is_mariadb_lock = self.db_is_mariadb.write().await;
|
||||
|
||||
let new_db_pool = create_db_connection_pool(&config.mysql).await?;
|
||||
let db_is_mariadb = {
|
||||
let version: String = sqlx::query_scalar("SELECT VERSION()")
|
||||
.fetch_one(&new_db_pool)
|
||||
.await
|
||||
.context("Failed to query database version")?;
|
||||
|
||||
let result = version.to_lowercase().contains("mariadb");
|
||||
tracing::debug!(
|
||||
"Connected to {} database server",
|
||||
if result { "MariaDB" } else { "MySQL" }
|
||||
);
|
||||
|
||||
result
|
||||
};
|
||||
|
||||
*connection_pool = new_db_pool;
|
||||
*db_is_mariadb_lock = db_is_mariadb;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// NOTE: the listener task will block the write lock unless the task is cancelled
|
||||
// first. Make sure to handle that appropriately to avoid a deadlock.
|
||||
async fn reload_listener(&self) -> anyhow::Result<()> {
|
||||
let config = self.config.lock().await;
|
||||
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?,
|
||||
};
|
||||
let mut listener = self.listener.write().await;
|
||||
*listener = new_listener;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reload(&self) -> anyhow::Result<()> {
|
||||
sd_notify::notify(false, &[sd_notify::NotifyState::Reloading])?;
|
||||
|
||||
let previous_config = self.config.lock().await.clone();
|
||||
self.reload_config().await?;
|
||||
|
||||
let mut listener_task_was_stopped = false;
|
||||
|
||||
// NOTE: despite closing the existing db pool, any already acquired connections will remain valid until dropped,
|
||||
// so we don't need to close existing connections here.
|
||||
if self.config.lock().await.mysql != previous_config.mysql {
|
||||
tracing::debug!("MySQL configuration has changed");
|
||||
|
||||
tracing::debug!("Restarting database connection pool with new configuration");
|
||||
self.restart_db_connection_pool().await?;
|
||||
}
|
||||
|
||||
if self.config.lock().await.socket_path != previous_config.socket_path {
|
||||
tracing::debug!("Socket path configuration has changed, reloading listener");
|
||||
if !listener_task_was_stopped {
|
||||
listener_task_was_stopped = true;
|
||||
tracing::debug!("Stop accepting new connections");
|
||||
self.stop_receiving_new_connections()?;
|
||||
|
||||
tracing::debug!("Waiting for existing connections to finish");
|
||||
self.wait_for_existing_connections_to_finish().await?;
|
||||
}
|
||||
|
||||
tracing::debug!("Reloading listener with new socket path");
|
||||
self.reload_listener().await?;
|
||||
}
|
||||
|
||||
if listener_task_was_stopped {
|
||||
tracing::debug!("Resuming listener task");
|
||||
self.resume_receiving_new_connections()?;
|
||||
}
|
||||
|
||||
sd_notify::notify(false, &[sd_notify::NotifyState::Ready])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) -> anyhow::Result<()> {
|
||||
sd_notify::notify(false, &[sd_notify::NotifyState::Stopping])?;
|
||||
|
||||
tracing::debug!("Stop accepting new connections");
|
||||
self.stop_receiving_new_connections()?;
|
||||
|
||||
let connection_count = self.handler_task_tracker.len();
|
||||
tracing::debug!(
|
||||
"Waiting for {} existing connections to finish",
|
||||
connection_count
|
||||
);
|
||||
self.wait_for_existing_connections_to_finish().await?;
|
||||
|
||||
tracing::debug!("Shutting down listener task");
|
||||
self.supervisor_message_sender
|
||||
.send(SupervisorMessage::Shutdown)
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!("Failed to send shutdown message to listener task: {}", e);
|
||||
0
|
||||
});
|
||||
|
||||
tracing::debug!("Shutting down database connection pool");
|
||||
self.db_connection_pool.read().await.close().await;
|
||||
|
||||
tracing::debug!("Server shutdown complete");
|
||||
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
pub async fn run(&self) -> anyhow::Result<()> {
|
||||
loop {
|
||||
select! {
|
||||
biased;
|
||||
|
||||
_ = async {
|
||||
let mut rx = self.reload_message_receiver.resubscribe();
|
||||
rx.recv().await
|
||||
} => {
|
||||
tracing::info!("Reloading configuration");
|
||||
match self.reload().await {
|
||||
Ok(()) => {
|
||||
tracing::info!("Configuration reloaded successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to reload configuration: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = self.shutdown_cancel_token.cancelled() => {
|
||||
tracing::info!("Shutting down server");
|
||||
self.shutdown().await?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_watchdog_task(duration: Duration) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = interval(duration.div_f32(2.0));
|
||||
tracing::debug!(
|
||||
"Starting systemd watchdog task, pinging every {} milliseconds",
|
||||
duration.div_f32(2.0).as_millis()
|
||||
);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Err(err) = sd_notify::notify(false, &[sd_notify::NotifyState::Watchdog]) {
|
||||
tracing::warn!("Failed to notify systemd watchdog: {}", err);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_status_notifier_task(task_tracker: TaskTracker) -> JoinHandle<()> {
|
||||
const STATUS_UPDATE_INTERVAL_SECS: Duration = Duration::from_secs(1);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut interval = interval(STATUS_UPDATE_INTERVAL_SECS);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let count = task_tracker.len();
|
||||
|
||||
let message = if count > 0 {
|
||||
format!("Handling {} connections", count)
|
||||
} else {
|
||||
"Waiting for connections".to_string()
|
||||
};
|
||||
|
||||
if let Err(e) =
|
||||
sd_notify::notify(false, &[sd_notify::NotifyState::Status(message.as_str())])
|
||||
{
|
||||
tracing::warn!("Failed to send systemd status notification: {}", e);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_unix_listener_with_socket_path(
|
||||
socket_path: PathBuf,
|
||||
) -> anyhow::Result<TokioUnixListener> {
|
||||
let parent_directory = socket_path.parent().unwrap();
|
||||
if !parent_directory.exists() {
|
||||
tracing::debug!("Creating directory {:?}", parent_directory);
|
||||
fs::create_dir_all(parent_directory)?;
|
||||
}
|
||||
|
||||
tracing::info!("Listening on socket {:?}", socket_path);
|
||||
|
||||
match fs::remove_file(socket_path.as_path()) {
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
|
||||
let listener = TokioUnixListener::bind(socket_path)?;
|
||||
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
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")?
|
||||
.next()
|
||||
.context("No file descriptors received from systemd")?;
|
||||
|
||||
debug_assert!(fd == 3, "Unexpected file descriptor from systemd: {}", fd);
|
||||
|
||||
tracing::debug!(
|
||||
"Received file descriptor from systemd with id: '{}', assuming socket",
|
||||
fd
|
||||
);
|
||||
|
||||
let std_unix_listener = unsafe { StdUnixListener::from_raw_fd(fd) };
|
||||
std_unix_listener
|
||||
.set_nonblocking(true)
|
||||
.context("Failed to set non-blocking mode on systemd socket")?;
|
||||
let listener = TokioUnixListener::from_std(std_unix_listener)?;
|
||||
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
async fn create_db_connection_pool(config: &MysqlConfig) -> anyhow::Result<MySqlPool> {
|
||||
let mysql_config = config.as_mysql_connect_options()?;
|
||||
|
||||
config.log_connection_notice();
|
||||
|
||||
let pool = match tokio::time::timeout(
|
||||
Duration::from_secs(config.timeout),
|
||||
MySqlPool::connect_with(mysql_config),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(connection) => connection.context("Failed to connect to the database"),
|
||||
Err(_) => Err(anyhow!("Timed out after {} seconds", config.timeout))
|
||||
.context("Failed to connect to the database"),
|
||||
}?;
|
||||
|
||||
let pool_opts = pool.options();
|
||||
tracing::debug!(
|
||||
"Successfully opened database connection pool with options (max_connections: {}, min_connections: {})",
|
||||
pool_opts.get_max_connections(),
|
||||
pool_opts.get_min_connections(),
|
||||
);
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
fn spawn_signal_handler_task(
|
||||
reload_sender: broadcast::Sender<ReloadEvent>,
|
||||
shutdown_token: CancellationToken,
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let mut sighup_stream =
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())
|
||||
.expect("Failed to set up SIGHUP handler");
|
||||
let mut sigterm_stream =
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("Failed to set up SIGTERM handler");
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = sighup_stream.recv() => {
|
||||
tracing::info!("Received SIGHUP signal");
|
||||
reload_sender.send(ReloadEvent).ok();
|
||||
}
|
||||
_ = sigterm_stream.recv() => {
|
||||
tracing::info!("Received SIGTERM signal");
|
||||
shutdown_token.cancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn listener_task(
|
||||
listener: Arc<RwLock<TokioUnixListener>>,
|
||||
task_tracker: TaskTracker,
|
||||
db_pool: Arc<RwLock<MySqlPool>>,
|
||||
mut supervisor_message_receiver: broadcast::Receiver<SupervisorMessage>,
|
||||
db_is_mariadb: Arc<RwLock<bool>>,
|
||||
) -> anyhow::Result<()> {
|
||||
sd_notify::notify(false, &[sd_notify::NotifyState::Ready])?;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
|
||||
Ok(message) = supervisor_message_receiver.recv() => {
|
||||
match message {
|
||||
SupervisorMessage::StopAcceptingNewConnections => {
|
||||
tracing::info!("Listener task received stop accepting new connections message, stopping listener");
|
||||
while let Ok(msg) = supervisor_message_receiver.try_recv() {
|
||||
if let SupervisorMessage::ResumeAcceptingNewConnections = msg {
|
||||
tracing::info!("Listener task received resume accepting new connections message, resuming listener");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
SupervisorMessage::Shutdown => {
|
||||
tracing::info!("Listener task received shutdown message, exiting listener task");
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
accept_result = async {
|
||||
let listener = listener.read().await;
|
||||
listener.accept().await
|
||||
} => {
|
||||
match accept_result {
|
||||
Ok((conn, _addr)) => {
|
||||
tracing::debug!("Got new connection");
|
||||
|
||||
let db_pool_clone = db_pool.clone();
|
||||
let db_is_mariadb_clone = *db_is_mariadb.read().await;
|
||||
task_tracker.spawn(async move {
|
||||
match session_handler(conn, db_pool_clone, db_is_mariadb_clone).await {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to run server: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to accept new connection: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
Reference in New Issue
Block a user