1 Commits

Author SHA1 Message Date
c6bce54859 WIP: flake.nix: create debian vm test 2025-12-15 15:19:03 +09:00
84 changed files with 1582 additions and 3208 deletions

View File

@@ -6,9 +6,6 @@ on:
- main
pull_request:
env:
BINSTALL_DISABLE_TELEMETRY: 'true'
jobs:
build:
runs-on: debian-latest
@@ -16,7 +13,10 @@ jobs:
- uses: actions/checkout@v6
- name: Install rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Build
run: cargo build --all-features --verbose --release
@@ -27,8 +27,10 @@ jobs:
- uses: actions/checkout@v6
- name: Install rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
components: rustfmt, clippy
- name: Check code format
@@ -41,13 +43,15 @@ jobs:
runs-on: debian-latest
steps:
- uses: actions/checkout@v6
- uses: cargo-bins/cargo-binstall@main
- name: Install rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Install cargo-deny
run: cargo binstall -y cargo-deny
run: cargo install cargo-deny
- name: Check licenses
run: |
@@ -68,7 +72,8 @@ jobs:
run: cargo binstall -y cargo-nextest --secure
- name: Run tests
run: cargo nextest run --release --no-fail-fast
run: |
cargo nextest run --release --no-fail-fast
env:
RUST_LOG: "trace"
RUSTFLAGS: "-Cinstrument-coverage"
@@ -111,7 +116,10 @@ jobs:
- uses: actions/checkout@v6
- name: Install rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Build docs
run: cargo doc --all-features --document-private-items --release

View File

@@ -22,9 +22,6 @@ on:
- beta
default: stable
env:
BINSTALL_DISABLE_TELEMETRY: 'true'
# TODO: dynamic matrix builds when...
# https://github.com/go-gitea/gitea/issues/25179
jobs:
@@ -36,15 +33,15 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: cargo-bins/cargo-binstall@main
- name: Install rust toolchain
uses: dtolnay/rust-toolchain@master
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ inputs.rust_toolchain }}
override: true
- name: Install cargo-deb
run: cargo binstall -y cargo-deb
run: cargo install cargo-deb
- name: Build deb package
env:
@@ -58,12 +55,12 @@ jobs:
CREATE_DEB_ARGS+=("--deb-version" "${{ inputs.deb_version }}")
fi
./scripts/create-deb.sh "${CREATE_DEB_ARGS[@]}"
./create-deb.sh "${CREATE_DEB_ARGS[@]}"
- name: Upload deb package artifact
uses: actions/upload-artifact@v3
with:
name: muscl-deb-${{ matrix.os }}-${{ gitea.sha }}.zip
name: muscl-deb-${{ matrix.os }}.zip
path: target/debian/*.deb
if-no-files-found: error
retention-days: 30

1
.gitignore vendored
View File

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

View File

@@ -49,17 +49,16 @@ This is the initial release of `muscl`.
### 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. The formatting that
was used in `mysql-admutils` is no longer present. However, since the editor is purely an
interactive tool, there shouldn't have been any scripts relying on the old formatting.
- The configuration file is shared for all variants of the program, and `muscl` will use
its new logic to look for and parse this file. See the example config and
[installation instructions][installation-instructions] for more information about how to
configure the software.
- The order in which input is validated might be differ from the original
(e.g. database ownership checks, invalid character checks, existence checks, ...).
This means that running the exact same command might lead to different error messages.
- Command-line arguments are de-duplicated. For example, if the user runs
- `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 have attempted to create it twice,
failing the second attempt.
the `user_db1` once. The old program would attempt to create it twice, failing the second time.

375
Cargo.lock generated
View File

@@ -17,15 +17,6 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "0.6.21"
@@ -171,43 +162,11 @@ dependencies = [
"generic-array",
]
[[package]]
name = "build-info-build"
version = "0.0.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b090e1d116997848529faaf849e1efd592cbe6e9eb44623c0588f017c63bbc4"
dependencies = [
"anyhow",
"base64",
"bincode 2.0.1",
"build-info-common",
"cargo_metadata",
"chrono",
"git2",
"glob",
"pretty_assertions",
"rustc_version",
"serde_json",
"zstd",
]
[[package]]
name = "build-info-common"
version = "0.0.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7a436965b6554ae18aba994745234bf2ed98d2c984e96b58aeb0f845c666969"
dependencies = [
"chrono",
"derive_more",
"semver",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.19.1"
version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]]
name = "byteorder"
@@ -221,44 +180,11 @@ version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]]
name = "camino"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48"
dependencies = [
"serde_core",
]
[[package]]
name = "cargo-platform"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87a0c0e6148f11f01f32650a2ea02d532b2ad4e81d8bd41e6e565b5adc5e6082"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "cargo_metadata"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
dependencies = [
"camino",
"cargo-platform",
"semver",
"serde",
"serde_json",
"thiserror 2.0.17",
]
[[package]]
name = "cc"
version = "1.2.50"
version = "1.2.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c"
checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -278,20 +204,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "clap"
version = "4.5.53"
@@ -327,9 +239,9 @@ dependencies = [
[[package]]
name = "clap_complete"
version = "4.5.62"
version = "4.5.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "004eef6b14ce34759aa7de4aea3217e368f463f46a3ed3764ca4b5a4404003b4"
checksum = "39615915e2ece2550c0149addac32fb5bd312c657f43845bb9088cb9c8a7c992"
dependencies = [
"clap",
"clap_lex",
@@ -355,27 +267,6 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]]
name = "color-print"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3aa954171903797d5623e047d9ab69d91b493657917bdfb8c2c80ecaf9cdb6f4"
dependencies = [
"color-print-proc-macro",
]
[[package]]
name = "color-print-proc-macro"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "692186b5ebe54007e45a59aea47ece9eb4108e141326c304cdc91699a7118a22"
dependencies = [
"nom",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "colorchoice"
version = "1.0.4"
@@ -393,9 +284,9 @@ dependencies = [
[[package]]
name = "console"
version = "0.16.2"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4"
checksum = "b430743a6eb14e9764d4260d4c0d8123087d504eeb9c48f2b2a5e810dd369df4"
dependencies = [
"encode_unicode",
"libc",
@@ -439,12 +330,6 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -528,18 +413,18 @@ dependencies = [
[[package]]
name = "derive_more"
version = "2.1.1"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b"
dependencies = [
"convert_case",
"proc-macro2",
@@ -561,12 +446,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "diff"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "digest"
version = "0.10.7"
@@ -697,7 +576,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -890,12 +769,6 @@ dependencies = [
"url",
]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -967,39 +840,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "humansize"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
dependencies = [
"libm",
]
[[package]]
name = "iana-time-zone"
version = "0.1.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "icu_collections"
version = "2.1.1"
@@ -1129,7 +969,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1158,9 +998,9 @@ dependencies = [
[[package]]
name = "itoa"
version = "1.0.16"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "jobserver"
@@ -1228,13 +1068,13 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
[[package]]
name = "libredox"
version = "0.1.11"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50"
checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
dependencies = [
"bitflags",
"libc",
"redox_syscall 0.6.0",
"redox_syscall",
]
[[package]]
@@ -1311,12 +1151,6 @@ dependencies = [
"autocfg",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "mio"
version = "1.1.1"
@@ -1335,24 +1169,19 @@ dependencies = [
"anyhow",
"async-bincode",
"bincode 2.0.1",
"build-info-build",
"chrono",
"clap",
"clap-verbosity-flag",
"clap_complete",
"color-print",
"const_format",
"derive_more",
"dialoguer",
"futures-util",
"git2",
"humansize",
"indoc",
"itertools",
"landlock",
"nix",
"num_cpus",
"pretty_assertions",
"prettytable",
"rand 0.9.2",
"regex",
@@ -1385,16 +1214,6 @@ dependencies = [
"memoffset",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -1496,7 +1315,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall 0.5.18",
"redox_syscall",
"smallvec",
"windows-link",
]
@@ -1593,16 +1412,6 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "pretty_assertions"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
dependencies = [
"diff",
"yansi",
]
[[package]]
name = "prettytable"
version = "0.10.0"
@@ -1709,15 +1518,6 @@ dependencies = [
"bitflags",
]
[[package]]
name = "redox_syscall"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec96166dafa0886eb81fe1c0a388bece180fbef2135f97c1e2cf8302e74b43b5"
dependencies = [
"bitflags",
]
[[package]]
name = "redox_users"
version = "0.4.6"
@@ -1811,7 +1611,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1830,9 +1630,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
version = "1.13.2"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282"
checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c"
dependencies = [
"zeroize",
]
@@ -1856,9 +1656,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.21"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "scopeguard"
@@ -1880,10 +1680,6 @@ name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde"
@@ -1917,9 +1713,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.146"
version = "1.0.145"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217ca874ae0207aac254aa02c957ded05585a90892cc8d87f9e5fa49669dadd8"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
dependencies = [
"indexmap",
"itoa",
@@ -1931,9 +1727,9 @@ dependencies = [
[[package]]
name = "serde_spanned"
version = "1.0.4"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392"
dependencies = [
"serde_core",
]
@@ -2307,7 +2103,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2464,9 +2260,9 @@ dependencies = [
[[package]]
name = "toml"
version = "0.9.10+spec-1.1.0"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48"
checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8"
dependencies = [
"indexmap",
"serde_core",
@@ -2479,33 +2275,33 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.0.6+spec-1.1.0"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.0.6+spec-1.1.0"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2"
[[package]]
name = "tracing"
version = "0.1.44"
version = "0.1.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647"
dependencies = [
"log",
"pin-project-lite",
@@ -2526,9 +2322,9 @@ dependencies = [
[[package]]
name = "tracing-core"
version = "0.1.36"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c"
dependencies = [
"once_cell",
"valuable",
@@ -2814,65 +2610,12 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
@@ -3113,12 +2856,6 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
name = "yansi"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "yoke"
version = "0.8.1"
@@ -3221,31 +2958,3 @@ dependencies = [
"quote",
"syn",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
]

View File

@@ -2,7 +2,6 @@
name = "muscl"
version = "0.1.0"
edition = "2024"
resolver = "2"
license = "BSD-3-Clause"
authors = [
"oysteikt@pvv.ntnu.no",
@@ -22,47 +21,42 @@ autolib = false
anyhow = "1.0.100"
async-bincode = "0.8.0"
bincode = "2.0.1"
chrono = { version = "0.4.42", features = ["serde"] }
clap = { version = "4.5.53", features = ["cargo", "derive"] }
clap-verbosity-flag = { version = "3.0.4", features = [ "tracing" ] }
clap_complete = { version = "4.5.62", features = ["unstable-dynamic"] }
color-print = "0.3.7"
clap_complete = { version = "4.5.61", features = ["unstable-dynamic"] }
const_format = "0.2.35"
derive_more = { version = "2.1.1", features = ["display", "error"] }
derive_more = { version = "2.1.0", features = ["display", "error"] }
dialoguer = "0.12.0"
futures-util = "0.3.31"
humansize = "2.1.3"
indoc = "2.0.7"
itertools = "0.14.0"
nix = { version = "0.30.1", features = ["fs", "process", "socket", "user"] }
num_cpus = "1.17.0"
prettytable = "0.10.0"
rand = "0.9.2"
sd-notify = "0.4.5"
serde = "1.0.228"
serde_json = { version = "1.0.146", features = ["preserve_order"] }
serde_json = { version = "1.0.145", features = ["preserve_order"] }
sqlx = { version = "0.8.6", features = ["runtime-tokio", "mysql", "tls-rustls"] }
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", "rt"] }
toml = "0.9.10"
tracing = { version = "0.1.44", features = ["log"] }
toml = "0.9.8"
tracing = { version = "0.1.43", features = ["log"] }
tracing-journald = "0.3.2"
tracing-subscriber = "0.3.22"
uuid = { version = "1.19.0", features = ["v4"] }
[target.'cfg(target_os = "linux")'.dependencies]
landlock = "0.4.4"
sd-notify = "0.4.5"
tracing-journald = "0.3.2"
[build-dependencies]
anyhow = "1.0.100"
build-info-build = "0.0.42"
git2 = { version = "0.20.3", default-features = false }
[dev-dependencies]
pretty_assertions = "1.4.1"
regex = "1.12.2"
[features]
@@ -70,19 +64,10 @@ default = ["mysql-admutils-compatibility"]
mysql-admutils-compatibility = []
suid-sgid-mode = []
[lib]
name = "muscl_lib"
path = "src/lib.rs"
[[bin]]
name = "muscl"
bench = false
path = "src/entrypoints/muscl.rs"
[[bin]]
name = "muscl-server"
bench = false
path = "src/entrypoints/muscl_server.rs"
path = "src/main.rs"
[profile.release-lto]
inherits = "release"
@@ -132,11 +117,6 @@ assets = [
"usr/bin/",
"755",
],
[
"target/release/muscl-server",
"usr/bin/",
"755",
],
[
"target/release/mysql-useradm",
"usr/bin/",
@@ -152,11 +132,6 @@ assets = [
"etc/muscl/config.toml",
"644",
],
[
"assets/debian/group_denylist.txt",
"etc/muscl/group_denylist.txt",
"644",
],
[
"assets/completions/_*",
"usr/share/zsh/site-functions/completions/",
@@ -172,16 +147,6 @@ assets = [
"usr/share/fish/vendor_completions.d/",
"644",
],
[
"README.md",
"usr/share/doc/muscl/",
"644",
],
[
"docs/*.md",
"usr/share/doc/muscl/docs/",
"644",
],
]
preserve-symlinks = true
maintainer-scripts = "debian/"

View File

@@ -3,53 +3,31 @@
# muscl 💪
Dropping DBs (dumbbells) and having MySQL spasms since 2024
Dropping DBs (dumbbells) and having mysql spasms since 2024
## What is this?
`muscl is a secure MySQL administration tool for multi-user systems.
It allows unprivileged users to manage their own databases and database users without granting them direct access to the MySQL server.
Authorization is handled by a prefix-based model tied to Unix users and groups, making it ideal for shared hosting environments, like university servers, tilde servers, or similar.
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`.
When a user requests an administrative operation, the `muscl` daemon verifies authenticates the user through unix socket peer credentials,
and then checks the requested item name against the user's username and group list for authorization.
The default authorization mechanism only allows the user to manage items prefixed with either their username or a group name.
For example, a user would be allowed to manage items like `<user>_mydb`, `<user>_mydbuser`, or `<group>_myotherdb`.
The available administrative actions include:
The available administrative operations 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
```bash
# Creating, listing, modifying, and deleting databases and database users
muscl create-db user_testdb
muscl create-user user_testuser --password strongpassword
muscl show-db
muscl drop-db group_projectdb
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.
# Modifying privileges for a database user on a database
muscl edit-privs user_testdb user_testuser +suid
muscl edit-privs -p user_testdb:user_testuser:A -p group_projectdb:otheruser:-d
muscl show-privs --json
# Changing the passwords of the database users
muscl passwd-user user_testuser
muscl passwd-user user_otheruser --stdin <<<"hunter2"
# Locking and unlocking database users
muscl lock-user user_testuser
muscl unlock-user user_testuser
# And more...
```
The software is designed to be run as a client and a server. The clients are run by the unprivileged users,
and does not have direct access to the MySQL server. Instead, they communicate with the muscl server
over a IPC, which then performs the requested operations on behalf of the clients.
This software is designed for multi-user servers, like tilde servers, university servers, etc.
## Documentation
- [Installation and configuration](docs/installation.md)
- [Development and testing](docs/development.md)
- [Compiling and packaging](docs/compiling.md)
- [Compatibility mode with mysql-admutils](docs/mysql-admutils-compatibility.md)
- [Use with NixOS](docs/nixos.md)
- [SUID/SGID mode](docs/suid-sgid-mode.md)

View File

@@ -1,6 +1,3 @@
[authorization]
group_denylist_file = "/etc/muscl/group_denylist.txt"
[mysql]
# Hostname and port of the database.
host = "localhost"
@@ -19,8 +16,7 @@ port = 3306
# systemd unit.
username = "muscl"
# This file gets created by systemd automatically, given you have set
# the password with `systemd-creds`. See /usr/share/doc/muscl/docs/installation.md
# for more information.
# the password with `systemd-creds`.
password_file = "/run/credentials/muscl.service/muscl_mysql_password"
# Database connection timeout in seconds

View File

@@ -1,56 +0,0 @@
# These are the default system groups on debian.
# You can alos add groups by gid by prefixing the line with 'gid:'.
group:adm
group:audio
group:avahi
group:backup
group:bin
group:cdrom
group:crontab
group:daemon
group:dialout
group:dip
group:disk
group:fax
group:floppy
group:games
group:gnats
group:input
group:irc
group:kmem
group:kvm
group:list
group:lp
group:mail
group:man
group:mlocate
group:netdev
group:news
group:nogroup
group:openldap
group:operator
group:plocate
group:plugdev
group:polkitd
group:proxy
group:render
group:root
group:sasl
group:shadow
group:src
group:staff
group:sudo
group:sys
group:systemd-journal
group:systemd-network
group:systemd-resolve
group:systemd-timesync
group:tape
group:tty
group:users
group:utmp
group:uucp
group:video
group:voice
group:www-data

View File

@@ -1,11 +1,10 @@
[Unit]
Description=Muscl MySQL admin tool
Requires=muscl.socket
After=mysql.service mariadb.service
[Service]
Type=notify
ExecStart=/usr/bin/muscl-server --systemd --disable-landlock socket-activate
ExecStart=/usr/bin/muscl server --systemd --disable-landlock socket-activate
ExecReload=/usr/bin/kill -HUP $MAINPID
WatchdogSec=15

View File

@@ -23,19 +23,8 @@ fn embed_build_time_info() {
.unwrap_or("unknown")
.to_string();
let dependencies = build_info_build::build_script()
.collect_runtime_dependencies(build_info_build::DependencyDepth::Depth(1))
.build()
.crate_info
.dependencies
.into_iter()
.map(|dep| format!("{}: {}", dep.name, dep.version))
.collect::<Vec<_>>()
.join(";");
println!("cargo:rustc-env=GIT_COMMIT={}", commit);
println!("cargo:rustc-env=BUILD_PROFILE={}", build_profile);
println!("cargo:rustc-env=DEPENDENCY_LIST={}", dependencies);
}
fn generate_mysql_admutils_symlinks() -> anyhow::Result<()> {

View File

@@ -1,76 +0,0 @@
# Compiling and packaging
This document describes how to compile `muscl` from source code, along with other related tasks.
## Build
To just compile `muscl`, there is not many special steps needed.
You need to have a working [Rust toolchain](https://www.rust-lang.org/tools/install) installed.
```bash
# Compile in debug mode
cargo build
ls target/debug # muscl, mysql-dbadm, mysql-useradm, ...
# Compile in release mode
cargo build --release
ls target/release # muscl, mysql-dbadm, mysql-useradm, ...
# Compile in release mode with link time optimization (only used for distribution builds)
cargo build --profile release-lto
ls target/release-lto # muscl, mysql-dbadm, mysql-useradm, ...
```
## Generating completions
> [!NOTE]
> This happens automatically when building the deb package, so you can skip this step if that's the goal.
In order to generate shell completions that work correctly, you need to put `muscl` (or alias symlinks) in your `$PATH`.
```bash
cargo build --release
(
PATH="$(pwd)/target/release:$PATH"
mkdir -p completions/bash
mkdir -p completions/zsh
mkdir -p completions/fish
muscl completions --shell bash > completions/bash/muscl.bash
muscl completions --shell zsh > completions/zsh/_muscl
muscl completions --shell fish > completions/fish/muscl.fish
)
```
Due to a [bug in clap](https://github.com/clap-rs/clap/issues/1764), you will also need to edit the completion files for the aliases.
```bash
sed -i 's/muscl/mysql-dbadm/g' assets/completions/{mysql-dbadm.bash,mysql-dbadm.fish,_mysql-dbadm}
sed -i 's/muscl/mysql-useradm/g' assets/completions/{mysql-useradm.bash,mysql-useradm.fish,_mysql-useradm}
```
## Bundling into a deb package
We have a script that automates the process of building a deb package for Debian-based systems.
Before running this, you will need to install `cargo-deb` and make sure you have `dpkg-deb` available on your system.
```bash
# Install cargo-deb if you don't have it already
cargo install cargo-deb
# Run the script to create the deb package
./scripts/create-deb.sh
# Inspect the resulting deb package
dpkg --contents target/debian/muscl_*.deb
dpkg --info target/debian/muscl_*.deb
```
The program will be built with the `release-lto` profile, so it can be a bit slower to build than a normal build.
## Compiling with CI
We have a pipeline that builds the deb package for a set of different distributions.
If you have access, you can trigger a build manually here: https://git.pvv.ntnu.no/Projects/muscl/actions?workflow=publish-deb.yml

View File

@@ -1,25 +1,25 @@
# Development and testing
Ensure you have a [Rust toolchain](https://www.rust-lang.org/tools/install) installed.
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:
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.
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 ./assets/example-config.toml ./config.toml
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`.
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.
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>
@@ -42,8 +42,7 @@ docker stop mariadb
If you have nix installed, you can easily test your changes in a NixOS vm by running:
```bash
nix run .#vm # Start a NixOS VM in QEMU with muscl and MariaDB installed
nix run .#vm-mysql # Start a NixOS VM in QEMU with muscl and MySQL installed
nix run .#vm
```
You can configure the vm in `flake.nix`

View File

@@ -13,14 +13,14 @@ You can install muscl by adding the [PVV apt repository][pvv-apt-repository] and
sudo -i
# Check the version of your Debian installation
VERSION_CODENAME=$(. /etc/os-release && echo "$VERSION_CODENAME")
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
# Add the repository
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/pvvgit-projects.asc] https://git.pvv.ntnu.no/api/packages/Projects/debian $VERSION_CODENAME main" | tee -a /etc/apt/sources.list.d/pvv-git.list
# Update package lists
apt update
@@ -28,38 +28,24 @@ apt update
apt install muscl
```
> [!NOTE]
> This has been tested on Debian 12 (bookworm) and Debian 13 (trixie) at the time of writing.
## Creating a database user
In order for the daemon to be able to do anything interesting on the MySQL server, it needs
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):
on the mysql server as the admin user (or another user with sufficient privileges):
```sql
CREATE USER `muscl`@`localhost` IDENTIFIED BY '<strong_password_here>';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mysql`.* TO `muscl`@`localhost`;
GRANT GRANT OPTION, CREATE, DROP ON *.* TO `muscl`@`localhost`;
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;
```
Make sure to remember the username and password, as we will now need to add them to the muscl configuration.
Now you should add the login credentials to the muscl configuration file, typically located at `/etc/muscl/config.toml`.
If your MySQL server is not running on the same host as the muscl server, you will need to replace `localhost` with the appropriate hostname or IP address in the different commands above. Alternatively, you can use `'%`' to allow connections from any host, but this is not recommended.
## Setting the myscl password with `systemd-creds`
The configuration already comes preconfigured expecting the database user to be named `muscl`.
If you named it differently, please edit `/etc/muscl/muscl.conf` accordingly.
muscl will use the `mysql` database to manage users and databases, and the `*.*` privileges to be able to create, drop and grant privileges on arbitrary databases (restricted by the prefix system).
For systemd-based setups, we recommend using `systemd-creds` to provide the database password, see the section below.
## Setting the MySQL password ...
### ... with `systemd-creds`
The Debian package assumes that you will provide the password for `muscl`'s database user 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:
@@ -71,16 +57,12 @@ sudo -i
mkdir -p /etc/credstore.encrypted
systemd-creds setup
# Prompt for the muscl MySQL password
read -s MUSCL_MYSQL_PASSWORD
<... enter strong password here>
# 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
# Now set the muscl mysql password
systemd-creds encrypt --name=muscl_mysql_password <(echo "$MUSCL_MYSQL_PASSWORD") /etc/credstore.encrypted/muscl_mysql_password
# Restart the muscl service to pick up the new credential
systemctl daemon-reload
systemctl restart muscl.service
```
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:
@@ -90,45 +72,10 @@ If you are running systemd older than version 254 (see `systemctl --version`), y
LoadCredentialEncrypted=muscl_mysql_password:/etc/credstore.encrypted/muscl_mysql_password
```
### ... without `systemd-creds`
If you do not have systemd, or if you do not want to use `systemd-creds`, you can also set the password in any other file on the system.
Be careful to ensure that the file is not readable by unprivileged users, as it would yield them too much access to the MySQL server.
Edit `/etc/muscl/muscl.conf` and set the `mysql_password_file` option below `[database]` to point to the file containing the password.
If you are using systemd, you should also create an override to unset the `ImportCredential=` line. Run `systemctl edit muscl.service` and add the following lines:
```ini
[Service]
ImportCredential=
```
## Configuring group denylists
In `/etc/muscl/muscl.conf`, you will find an option below `[authorization]` named `group_denylist_file`,
which points to `/etc/muscl/group_denylist.txt` by default.
In this file, you can add unix group names or GIDs to disallow the groups from being used as prefixes.
The deb package comes with a default denylist that disallows some common system groups.
The format of the file is one group name or GID per line. Lines starting with `#` and empty lines are ignored.
```
# Disallow using the 'root' group as a prefix
gid:0
# Disallow using the 'adm' group as a prefix
group:adm
```
> [!NOTE]
> If a user is named the same as a disallowed group, that user will still be able to use their username as a prefix.
## A note on minimum version requirements
The muscl server will work with older versions of systemd, but the recommended version is 254 or newer.
For full landlock support (disabled by default), you need a Linux kernel version 6.7 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

View File

@@ -1,6 +1,6 @@
# Compatibility mode with [mysql-admutils](https://git.pvv.ntnu.no/Projects/mysql-admutils)
If you enable the `mysql-admutils-compatibility` feature flag when [compiling][compiling] (enabled by default for now), the output directory will contain two symlinks to the `muscl` binary: `mysql-dbadm` and `mysql-useradm`. When you run either of the symlinks, the program will enter a compatibility mode that mimics the behaviour of the corresponding program from the `mysql-admutils` package. These tools try to replicate the behaviour of the original programs as closely as possible.
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
@@ -8,32 +8,21 @@ cargo build
./target/debug/mysql-useradm --help
```
These symlinks are also included in the deb packages by default.
These symlinks are also included in the deb packages.
### Known deviations from `mysql-admutils`' behaviour
There are some differences between the original programs and the compatibility mode in `muscl`.
The known ones are:
- `--help` output is formatted by clap in a different style.
- `mysql-dbadm edit-perm` uses the new privilege editor implementation. The formatting that
was used in `mysql-admutils` is no longer present. However, since the editor is purely an
interactive tool, there shouldn't have been any scripts relying on the old formatting.
- The configuration file is shared for all variants of the program, and `muscl` will use
its new logic to look for and parse this file. See the example config and
[installation instructions][installation-instructions] for more information about how to
configure the software.
- The order in which input is validated might be differ from the original
(e.g. database ownership checks, invalid character checks, existence checks, ...).
This means that running the exact same command might lead to different error messages.
- Command-line arguments are de-duplicated. For example, if the user runs
- `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 have attempted to create it twice,
failing the second attempt.
One detail that might be considered a difference but, is that the compatibility mode supports
command line completions when the user presses tab. This is not a feature of the original programs,
but it does not change any of the previous behaviour either.
[compiling]: ./compiling.md
[installation-instructions]: ./installation.md
the `user_db1` once. The old program would attempt to create it twice, failing the second time.

View File

@@ -1,6 +1,6 @@
# Use with NixOS
For NixOS, there is a NixOS module available in the nix flake. You can include it in your configuration like this:
For NixOS, there is a module available via the nix flake. You can include it in your configuration like this:
```nix
{

View File

@@ -7,9 +7,6 @@
# (see `systemctl status muscl.socket`)
socket_path = "/run/muscl/muscl.sock"
[authorization]
group_denylist_file = "/etc/muscl/group_denylist.txt"
[mysql]
# Hostname and port of the database.

39
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
"lastModified": 1766194365,
"narHash": "sha256-4AFsUZ0kl6MXSm4BaQgItD0VGlEKR3iq7gIaL7TjBvc=",
"lastModified": 1765739568,
"narHash": "sha256-gQYx35Of4UDKUjAYvmxjUEh/DdszYeTtT6MDin4loGE=",
"owner": "ipetkov",
"repo": "crane",
"rev": "7d8ec2c71771937ab99790b45e6d9b93d15d9379",
"rev": "67d2baff0f9f677af35db61b32b5df6863bcc075",
"type": "github"
},
"original": {
@@ -15,13 +15,33 @@
"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": 1766309749,
"narHash": "sha256-3xY8CZ4rSnQ0NqGhMKAy5vgC+2IVK0NoVEzDoOh4DA4=",
"lastModified": 1765472234,
"narHash": "sha256-9VvC20PJPsleGMewwcWYKGzDIyjckEz8uWmT0vCDYK0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a6531044f6d0bef691ea18d4d4ce44d0daa6e816",
"rev": "2fbfb1d73d239d2402a8fe03963e37aab15abe8b",
"type": "github"
},
"original": {
@@ -34,6 +54,7 @@
"root": {
"inputs": {
"crane": "crane",
"nix-vm-test": "nix-vm-test",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
@@ -45,11 +66,11 @@
]
},
"locked": {
"lastModified": 1766457837,
"narHash": "sha256-aeBbkQ0HPFNOIsUeEsXmZHXbYq4bG8ipT9JRlCcKHgU=",
"lastModified": 1765680428,
"narHash": "sha256-fyPmRof9SZeI14ChPk5rVPOm7ISiiGkwGCunkhM+eUg=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "2c7510a559416d07242621d036847152d970612b",
"rev": "eb3898d8ef143d4bf0f7f2229105fc51c7731b2f",
"type": "github"
},
"original": {

View File

@@ -6,9 +6,12 @@
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
crane.url = "github:ipetkov/crane";
nix-vm-test.url = "github:numtide/nix-vm-test";
nix-vm-test.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, rust-overlay, crane }:
outputs = { self, nixpkgs, rust-overlay, crane, nix-vm-test }:
let
inherit (nixpkgs) lib;
@@ -95,6 +98,8 @@
muscl = import ./nix/module.nix;
};
# vmlib = forAllSystems(system: _: _: nix-vm-test.lib.${system});
packages = forAllSystems (system: pkgs: _:
let
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
@@ -130,6 +135,8 @@
filteredSource = pkgs.runCommandLocal "filtered-source" { } ''
ln -s ${src} $out
'';
debianVm = import ./nix/debian-vm-configuration.nix { inherit nix-vm-test nixpkgs system pkgs; };
});
checks = forAllSystems (system: pkgs: _: {

View File

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

View File

@@ -84,7 +84,7 @@ buildFunction ({
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-server' "$out/bin/muscl-server"
--replace-fail '/usr/bin/muscl' "$out/bin/muscl"
'';
meta = with lib; {

View File

@@ -40,14 +40,6 @@ in
};
};
authorization = {
group_denylist = lib.mkOption {
type = with lib.types; nullOr (listOf str);
default = [ "wheel" ];
description = "List of groups that are denied access";
};
};
mysql = {
socket_path = lib.mkOption {
type = with lib.types; nullOr path;
@@ -89,12 +81,6 @@ in
environment.systemPackages = [ cfg.package ];
environment.etc."muscl/config.toml".source = lib.pipe cfg.settings [
# Handle group_denylist_file
(conf: lib.recursiveUpdate conf {
authorization.group_denylist_file = if (conf.authorization.group_denylist != [ ]) then "/etc/muscl/group-denylist" else null;
authorization.group_denylist = null;
})
# Remove nulls
(lib.filterAttrsRecursive (_: v: v != null))
@@ -109,10 +95,6 @@ in
(format.generate "muscl.conf")
];
environment.etc."muscl/group-denylist" = lib.mkIf (cfg.settings.authorization.group_denylist != [ ]) {
text = lib.concatMapStringsSep "\n" (group: "group:${group}") cfg.settings.authorization.group_denylist;
};
services.mysql.ensureUsers = lib.mkIf cfg.createLocalDatabaseUser [
{
name = cfg.settings.mysql.username;
@@ -132,7 +114,7 @@ in
serviceConfig = {
ExecStart = [
""
"${lib.getExe' cfg.package "muscl-server"} ${cfg.logLevel} --systemd --disable-landlock socket-activate"
"${lib.getExe cfg.package} ${cfg.logLevel} server --systemd --disable-landlock socket-activate"
];
ExecReload = [

View File

@@ -1,69 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "${CREATE_DEB_DEBUG:-}" == "1" ]]; then
set -x
fi
declare -a COMMANDS=(
curl
unzip
mktemp
find
)
for cmd in "${COMMANDS[@]}"; do
if ! command -v "$cmd" &> /dev/null; then
echo "$cmd could not be found" >&2
exit 1
fi
done
if [ "$#" -ne 2 ]; then
echo "Usage: $0 <gitea-run-number> <git-sha>" >&2
echo "Example:" >&2
echo " GITEA_USER=me GITEA_TOKEN=secret ./scripts/download-and-upload-debs.sh 123 \$(git rev-parse HEAD)" >&2
exit 1
fi
if [ -z "${GITEA_USER:-}" ]; then
echo "GITEA_USER is not set" >&2
exit 1
fi
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "GITEA_TOKEN is not set" >&2
exit 1
fi
declare -r RUN_NUMBER="$1"
declare -r GIT_SHA="$2"
TMPDIR="$(mktemp -d)"
for variant in debian-bookworm debian-trixie ubuntu-jammy ubuntu-noble; do
echo "Downloading and uploading debs for variant: $variant"
curl "https://git.pvv.ntnu.no/Projects/muscl/actions/runs/$RUN_NUMBER/artifacts/muscl-deb-$variant-$GIT_SHA.zip" --output "$TMPDIR/muscl-deb-$variant-$GIT_SHA.zip"
unzip "$TMPDIR/muscl-deb-$variant-$GIT_SHA.zip" -d "$TMPDIR/muscl-deb-$variant-$GIT_SHA"
DISTRO_VERSION_NAME="$(echo "$variant" | cut -d'-' -f2)"
DEB_NAME=$(find "$TMPDIR/muscl-deb-$variant-$GIT_SHA"/*.deb -print0 | xargs -0 -n1 basename | cut -d'_' -f1 | head -n1)
DEB_VERSION=$(find "$TMPDIR/muscl-deb-$variant-$GIT_SHA"/*.deb -print0 | xargs -0 -n1 basename | cut -d'_' -f2 | head -n1)
DEB_ARCH=$(find "$TMPDIR/muscl-deb-$variant-$GIT_SHA"/*.deb -print0 | xargs -0 -n1 basename | cut -d'_' -f3 | cut -d'.' -f1 | head -n1)
curl \
-X DELETE \
--user "$GITEA_USER:$GITEA_TOKEN" \
"https://git.pvv.ntnu.no/api/packages/Projects/debian/pool/$DISTRO_VERSION_NAME/main/$DEB_NAME/$DEB_VERSION/$DEB_ARCH"
curl \
-X PUT \
--user "$GITEA_USER:$GITEA_TOKEN" \
--upload-file "$TMPDIR/muscl-deb-$variant-$GIT_SHA/${DEB_NAME}_${DEB_VERSION}_${DEB_ARCH}.deb" \
"https://git.pvv.ntnu.no/api/packages/Projects/debian/pool/$DISTRO_VERSION_NAME/main/upload"
done
rm -rf "$TMPDIR"

View File

@@ -24,56 +24,149 @@ pub use show_privs::*;
pub use show_user::*;
pub use unlock_user::*;
use futures_util::SinkExt;
use itertools::Itertools;
use tokio_stream::StreamExt;
use clap::Subcommand;
use crate::core::protocol::{ClientToServerMessageStream, Request, Response};
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,
}
}
/// Handle an unexpected or erroneous response from the server.
///
/// This function checks the provided response and returns an appropriate error message.
/// It is typically used in `match` branches for expecting a specific response type from the server.
pub fn erroneous_server_response(
response: Option<Result<Response, std::io::Error>>,
) -> anyhow::Result<()> {
match response {
Some(Ok(Response::Error(e))) => {
anyhow::bail!("Server returned error: {e}");
anyhow::bail!("Server returned error: {}", e);
}
Some(Err(e)) => {
anyhow::bail!(e);
}
Some(response) => {
anyhow::bail!("Unexpected response from server: {response:?}");
anyhow::bail!("Unexpected response from server: {:?}", response);
}
None => {
anyhow::bail!("No response from server");
}
}
}
/// Print a hint about which name prefixes the user is authorized to manage
/// by querying the server for valid name prefixes.
///
/// This function should be used when an authorization error occurs,
/// to help the user understand which databases or users they are allowed to manage.
async fn print_authorization_owner_hint(
server_connection: &mut ClientToServerMessageStream,
) -> anyhow::Result<()> {
server_connection
.send(Request::ListValidNamePrefixes)
.await?;
let response = match server_connection.next().await {
Some(Ok(Response::ListValidNamePrefixes(prefixes))) => prefixes,
response => return erroneous_server_response(response),
};
eprintln!(
"Note: You are allowed to manage databases and users with the following prefixes:\n{}",
response.into_iter().map(|p| format!(" - {p}")).join("\n")
);
Ok(())
}

View File

@@ -14,7 +14,7 @@ use tokio_stream::StreamExt;
#[derive(Parser, Debug, Clone)]
pub struct CheckAuthArgs {
/// The `MySQL` database(s) or user(s) to check authorization for
/// The MySQL database(s) or user(s) to check authorization for
#[arg(num_args = 1.., value_name = "NAME")]
name: Vec<String>,
@@ -63,9 +63,5 @@ pub async fn check_authorization(
print_check_authorization_output_status(&result);
}
if result.values().any(std::result::Result::is_err) {
std::process::exit(1);
}
Ok(())
}

View File

@@ -1,16 +1,13 @@
use clap::Parser;
use clap_complete::ArgValueCompleter;
use futures_util::SinkExt;
use tokio_stream::StreamExt;
use crate::{
client::commands::{erroneous_server_response, print_authorization_owner_hint},
client::commands::erroneous_server_response,
core::{
completion::prefix_completer,
protocol::{
ClientToServerMessageStream, CreateDatabaseError, Request, Response,
print_create_databases_output_status, print_create_databases_output_status_json,
request_validation::ValidationError,
ClientToServerMessageStream, Request, Response, print_create_databases_output_status,
print_create_databases_output_status_json,
},
types::MySQLDatabase,
},
@@ -18,9 +15,8 @@ use crate::{
#[derive(Parser, Debug, Clone)]
pub struct CreateDbArgs {
/// The `MySQL` database(s) to create
/// The MySQL database(s) to create
#[arg(num_args = 1.., value_name = "DB_NAME")]
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(prefix_completer)))]
name: Vec<MySQLDatabase>,
/// Print the information as JSON
@@ -36,7 +32,7 @@ pub async fn create_databases(
anyhow::bail!("No database names provided");
}
let message = Request::CreateDatabases(args.name.clone());
let message = Request::CreateDatabases(args.name.to_owned());
server_connection.send(message).await?;
let result = match server_connection.next().await {
@@ -44,27 +40,12 @@ pub async fn create_databases(
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
if args.json {
print_create_databases_output_status_json(&result);
} else {
print_create_databases_output_status(&result);
if result.iter().any(|(_, res)| {
matches!(
res,
Err(CreateDatabaseError::ValidationError(
ValidationError::AuthorizationError(_)
))
)
}) {
print_authorization_owner_hint(&mut server_connection).await?;
}
}
server_connection.send(Request::Exit).await?;
if result.values().any(std::result::Result::is_err) {
std::process::exit(1);
}
Ok(())

View File

@@ -1,21 +1,14 @@
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, interactive_password_dialogue_with_double_check,
interactive_password_expiry_dialogue, print_authorization_owner_hint,
},
client::commands::{erroneous_server_response, read_password_from_stdin_with_double_check},
core::{
completion::prefix_completer,
protocol::{
ClientToServerMessageStream, CreateUserError, Request, Response,
SetUserPasswordRequest, print_create_users_output_status,
ClientToServerMessageStream, Request, Response, print_create_users_output_status,
print_create_users_output_status_json, print_set_password_output_status,
request_validation::ValidationError,
},
types::MySQLUser,
},
@@ -23,9 +16,8 @@ use crate::{
#[derive(Parser, Debug, Clone)]
pub struct CreateUserArgs {
/// The `MySQL` user(s) to create
/// The MySQL user(s) to create
#[arg(num_args = 1.., value_name = "USER_NAME")]
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(prefix_completer)))]
username: Vec<MySQLUser>,
/// Do not ask for a password, leave it unset
@@ -47,7 +39,7 @@ pub async fn create_users(
anyhow::bail!("No usernames provided");
}
let message = Request::CreateUsers(args.username.clone());
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"));
@@ -63,39 +55,23 @@ pub async fn create_users(
} else {
print_create_users_output_status(&result);
if result.iter().any(|(_, res)| {
matches!(
res,
Err(CreateUserError::ValidationError(
ValidationError::AuthorizationError(_)
))
)
}) {
print_authorization_owner_hint(&mut server_connection).await?;
}
let successfully_created_users = result
.iter()
.filter_map(|(username, result)| result.as_ref().ok().map(|()| username))
.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}'?"
"Do you want to set a password for user '{}'?",
username
))
.default(false)
.interact()?
{
let password = interactive_password_dialogue_with_double_check(username)?;
let expiry = interactive_password_expiry_dialogue(username)?;
let message = Request::PasswdUser(SetUserPasswordRequest {
user: username.clone(),
new_password: Some(password),
expiry: expiry,
});
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();
@@ -104,7 +80,7 @@ pub async fn create_users(
match server_connection.next().await {
Some(Ok(Response::SetUserPassword(result))) => {
print_set_password_output_status(&result, username);
print_set_password_output_status(&result, username)
}
response => return erroneous_server_response(response),
}
@@ -116,9 +92,5 @@ pub async fn create_users(
server_connection.send(Request::Exit).await?;
if result.values().any(std::result::Result::is_err) {
std::process::exit(1);
}
Ok(())
}

View File

@@ -5,13 +5,12 @@ use futures_util::SinkExt;
use tokio_stream::StreamExt;
use crate::{
client::commands::{erroneous_server_response, print_authorization_owner_hint},
client::commands::erroneous_server_response,
core::{
completion::mysql_database_completer,
protocol::{
ClientToServerMessageStream, DropDatabaseError, Request, Response,
print_drop_databases_output_status, print_drop_databases_output_status_json,
request_validation::ValidationError,
ClientToServerMessageStream, Request, Response, print_drop_databases_output_status,
print_drop_databases_output_status_json,
},
types::MySQLDatabase,
},
@@ -19,7 +18,7 @@ use crate::{
#[derive(Parser, Debug, Clone)]
pub struct DropDbArgs {
/// The `MySQL` database(s) to drop
/// 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>,
@@ -47,22 +46,19 @@ pub async fn drop_databases(
"Are you sure you want to drop the databases?\n\n{}\n\nThis action cannot be undone",
args.name
.iter()
.map(|d| format!("- {d}"))
.map(|d| format!("- {}", d))
.collect::<Vec<_>>()
.join("\n")
))
.interact()?;
//
if !confirmation {
// TODO: should we return with an error code here?
println!("Aborting drop operation.");
server_connection.send(Request::Exit).await?;
return Ok(());
}
}
let message = Request::DropDatabases(args.name.clone());
let message = Request::DropDatabases(args.name.to_owned());
server_connection.send(message).await?;
let result = match server_connection.next().await {
@@ -70,28 +66,13 @@ pub async fn drop_databases(
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
if args.json {
print_drop_databases_output_status_json(&result);
} else {
print_drop_databases_output_status(&result);
if result.iter().any(|(_, res)| {
matches!(
res,
Err(DropDatabaseError::ValidationError(
ValidationError::AuthorizationError(_)
))
)
}) {
print_authorization_owner_hint(&mut server_connection).await?;
}
}
server_connection.send(Request::Exit).await?;
if result.values().any(std::result::Result::is_err) {
std::process::exit(1);
}
};
Ok(())
}

View File

@@ -5,13 +5,12 @@ use futures_util::SinkExt;
use tokio_stream::StreamExt;
use crate::{
client::commands::{erroneous_server_response, print_authorization_owner_hint},
client::commands::erroneous_server_response,
core::{
completion::mysql_user_completer,
protocol::{
ClientToServerMessageStream, DropUserError, Request, Response,
print_drop_users_output_status, print_drop_users_output_status_json,
request_validation::ValidationError,
ClientToServerMessageStream, Request, Response, print_drop_users_output_status,
print_drop_users_output_status_json,
},
types::MySQLUser,
},
@@ -19,7 +18,7 @@ use crate::{
#[derive(Parser, Debug, Clone)]
pub struct DropUserArgs {
/// The `MySQL` user(s) to drop
/// 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>,
@@ -47,21 +46,19 @@ pub async fn drop_users(
"Are you sure you want to drop the users?\n\n{}\n\nThis action cannot be undone",
args.username
.iter()
.map(|d| format!("- {d}"))
.map(|d| format!("- {}", d))
.collect::<Vec<_>>()
.join("\n")
))
.interact()?;
if !confirmation {
// TODO: should we return with an error code here?
println!("Aborting drop operation.");
server_connection.send(Request::Exit).await?;
return Ok(());
}
}
let message = Request::DropUsers(args.username.clone());
let message = Request::DropUsers(args.username.to_owned());
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
@@ -73,27 +70,12 @@ pub async fn drop_users(
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
if args.json {
print_drop_users_output_status_json(&result);
} else {
print_drop_users_output_status(&result);
if result.iter().any(|(_, res)| {
matches!(
res,
Err(DropUserError::ValidationError(
ValidationError::AuthorizationError(_)
))
)
}) {
print_authorization_owner_hint(&mut server_connection).await?;
}
}
server_connection.send(Request::Exit).await?;
if result.values().any(std::result::Result::is_err) {
std::process::exit(1);
}
Ok(())

View File

@@ -1,7 +1,7 @@
use std::collections::{BTreeMap, BTreeSet};
use anyhow::Context;
use clap::{Args, Parser};
use clap::Parser;
use clap_complete::ArgValueCompleter;
use dialoguer::{Confirm, Editor};
use futures_util::SinkExt;
@@ -9,19 +9,18 @@ use nix::unistd::{User, getuid};
use tokio_stream::StreamExt;
use crate::{
client::commands::{erroneous_server_response, print_authorization_owner_hint},
client::commands::erroneous_server_response,
core::{
completion::{mysql_database_completer, mysql_user_completer},
completion::mysql_database_completer,
database_privileges::{
DatabasePrivilegeEdit, DatabasePrivilegeEditEntry, DatabasePrivilegeRow,
DatabasePrivilegeRowDiff, DatabasePrivilegesDiff, create_or_modify_privilege_rows,
diff_privileges, display_privilege_diffs, generate_editor_content_from_privilege_data,
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, ListDatabasesError, ListUsersError,
ModifyDatabasePrivilegesError, Request, Response,
print_modify_database_privileges_output_status, request_validation::ValidationError,
ClientToServerMessageStream, Request, Response,
print_modify_database_privileges_output_status,
},
types::{MySQLDatabase, MySQLUser},
},
@@ -29,24 +28,20 @@ use crate::{
#[derive(Parser, Debug, Clone)]
pub struct EditPrivsArgs {
/// The privileges to set, grant or revoke, in the format `DATABASE:USER:[+-]PRIVILEGES`
///
/// This option allows for changing privileges for multiple databases and users in batch.
///
/// This can not be used together with the positional `DB_NAME`, `USER_NAME` and `PRIVILEGES` arguments.
/// 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 = "DB_NAME:USER_NAME:[+-]PRIVILEGES",
value_name = "[DATABASE:]USER:[+-]PRIVILEGES",
num_args = 0..,
value_parser = DatabasePrivilegeEditEntry::parse_from_str,
conflicts_with("single_priv"),
)]
pub privs: Vec<DatabasePrivilegeEditEntry>,
#[command(flatten)]
pub single_priv: Option<SinglePrivilegeEditArgs>,
/// Print the information as JSON
#[arg(short, long)]
pub json: bool,
@@ -65,35 +60,10 @@ pub struct EditPrivsArgs {
pub yes: bool,
}
#[derive(Args, Debug, Clone)]
pub struct SinglePrivilegeEditArgs {
/// The `MySQL` database to edit privileges for
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_database_completer)))]
#[arg(
value_name = "DB_NAME",
requires = "user_name",
requires = "single_priv"
)]
pub db_name: Option<MySQLDatabase>,
/// The `MySQL` database to edit privileges for
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_user_completer)))]
#[arg(value_name = "USER_NAME")]
pub user_name: Option<MySQLUser>,
/// The privileges to set, grant or revoke
#[arg(
allow_hyphen_values = true,
value_name = "[+-]PRIVILEGES",
value_parser = DatabasePrivilegeEdit::parse_from_str,
)]
pub single_priv: Option<DatabasePrivilegeEdit>,
}
async fn users_exist(
server_connection: &mut ClientToServerMessageStream,
privilege_diff: &BTreeSet<DatabasePrivilegesDiff>,
) -> anyhow::Result<BTreeMap<MySQLUser, Result<(), ListUsersError>>> {
) -> anyhow::Result<BTreeMap<MySQLUser, bool>> {
let user_list = privilege_diff
.iter()
.map(|diff| diff.get_user_name().clone())
@@ -113,7 +83,7 @@ async fn users_exist(
let result = result
.into_iter()
.map(|(user, user_result)| (user, user_result.map(|_| ())))
.map(|(user, user_result)| (user, user_result.is_ok()))
.collect();
Ok(result)
@@ -122,7 +92,7 @@ async fn users_exist(
async fn databases_exist(
server_connection: &mut ClientToServerMessageStream,
privilege_diff: &BTreeSet<DatabasePrivilegesDiff>,
) -> anyhow::Result<BTreeMap<MySQLDatabase, Result<(), ListDatabasesError>>> {
) -> anyhow::Result<BTreeMap<MySQLDatabase, bool>> {
let database_list = privilege_diff
.iter()
.map(|diff| diff.get_database_name().clone())
@@ -142,51 +112,20 @@ async fn databases_exist(
let result = result
.into_iter()
.map(|(database, db_result)| (database, db_result.map(|_| ())))
.map(|(database, db_result)| (database, db_result.is_ok()))
.collect();
Ok(result)
}
// TODO: reduce the complexity of this function
pub async fn edit_database_privileges(
args: EditPrivsArgs,
// NOTE: this is only used for backwards compat with mysql-admutils
use_database: Option<MySQLDatabase>,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
let message = Request::ListPrivileges(use_database.clone().map(|db| vec![db]));
let message = Request::ListPrivileges(args.name.to_owned().map(|name| vec![name]));
server_connection.send(message).await?;
debug_assert!(args.privs.is_empty() ^ args.single_priv.is_none());
let privs = if let Some(single_priv_entry) = &args.single_priv {
let database = single_priv_entry.db_name.clone().ok_or_else(|| {
anyhow::anyhow!(
"DB_NAME must be specified when editing privileges in single privilege mode"
)
})?;
let user = single_priv_entry.user_name.clone().ok_or_else(|| {
anyhow::anyhow!(
"USER_NAME must be specified when DB_NAME is specified in single privilege mode"
)
})?;
let privilege_edit = single_priv_entry.single_priv.clone().ok_or_else(|| {
anyhow::anyhow!(
"PRIVILEGES must be specified when DB_NAME is specified in single privilege mode"
)
})?;
vec![DatabasePrivilegeEditEntry {
database,
user,
privilege_edit,
}]
} else {
args.privs.clone()
};
let existing_privilege_rows = match server_connection.next().await {
Some(Ok(Response::ListPrivileges(databases))) => databases
.into_iter()
@@ -212,17 +151,17 @@ pub async fn edit_database_privileges(
response => return erroneous_server_response(response),
};
let diffs: BTreeSet<DatabasePrivilegesDiff> = if privs.is_empty() {
let privileges_to_change =
edit_privileges_with_editor(&existing_privilege_rows, use_database.as_ref())?;
diff_privileges(&existing_privilege_rows, &privileges_to_change)
} else {
let privileges_to_change = parse_privilege_tables(&privs)?;
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 database_existence_map = databases_exist(&mut server_connection, &diffs).await?;
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()
@@ -230,14 +169,14 @@ pub async fn edit_database_privileges(
let database_name = diff.get_database_name();
let username = diff.get_user_name();
if let Some(Err(err)) = database_existence_map.get(database_name) {
println!("{}", err.to_error_message(database_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(Err(err)) = user_existence_map.get(username) {
println!("{}", err.to_error_message(username));
if let Some(false) = user_existence_map.get(username) {
println!("User '{}' does not exist.", username);
println!("Skipping...");
return false;
}
@@ -246,26 +185,6 @@ pub async fn edit_database_privileges(
})
.collect::<BTreeSet<_>>();
if database_existence_map.values().any(|res| {
matches!(
res,
Err(ListDatabasesError::ValidationError(
ValidationError::AuthorizationError(_)
))
)
}) || user_existence_map.values().any(|res| {
matches!(
res,
Err(ListUsersError::ValidationError(
ValidationError::AuthorizationError(_)
))
)
}) {
println!();
print_authorization_owner_hint(&mut server_connection).await?;
println!();
}
if diffs.is_empty() {
println!("No changes to make.");
server_connection.send(Request::Exit).await?;
@@ -296,39 +215,23 @@ pub async fn edit_database_privileges(
print_modify_database_privileges_output_status(&result);
if result.iter().any(|(_, res)| {
matches!(
res,
Err(ModifyDatabasePrivilegesError::UserValidationError(
ValidationError::AuthorizationError(_)
) | ModifyDatabasePrivilegesError::DatabaseValidationError(
ValidationError::AuthorizationError(_)
))
)
}) {
print_authorization_owner_hint(&mut server_connection).await?;
}
server_connection.send(Request::Exit).await?;
if result.values().any(std::result::Result::is_err) {
std::process::exit(1);
}
Ok(())
}
fn parse_privilege_tables(
privs: &[DatabasePrivilegeEditEntry],
fn parse_privilege_tables_from_args(
args: &EditPrivsArgs,
) -> anyhow::Result<BTreeSet<DatabasePrivilegeRowDiff>> {
debug_assert!(!privs.is_empty());
privs
debug_assert!(!args.privs.is_empty());
args.privs
.iter()
.map(|priv_edit_entry| {
priv_edit_entry
.as_database_privileges_diff()
.as_database_privileges_diff(args.name.as_ref())
.context(format!(
"Failed parsing database privileges: `{priv_edit_entry}`"
"Failed parsing database privileges: `{}`",
priv_edit_entry
))
})
.collect::<anyhow::Result<BTreeSet<DatabasePrivilegeRowDiff>>>()
@@ -336,7 +239,6 @@ fn parse_privilege_tables(
fn edit_privileges_with_editor(
privilege_data: &[DatabasePrivilegeRow],
// NOTE: this is only used for backwards compat with mysql-admtools
database_name: Option<&MySQLDatabase>,
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
let unix_user = User::from_uid(getuid())
@@ -351,7 +253,7 @@ fn edit_privileges_with_editor(
match result {
None => Ok(privilege_data.to_vec()),
Some(result) => parse_privilege_data_from_editor_content(&result)
Some(result) => parse_privilege_data_from_editor_content(result)
.context("Could not parse privilege data from editor"),
}
}

View File

@@ -4,13 +4,12 @@ use futures_util::SinkExt;
use tokio_stream::StreamExt;
use crate::{
client::commands::{erroneous_server_response, print_authorization_owner_hint},
client::commands::erroneous_server_response,
core::{
completion::mysql_user_completer,
protocol::{
ClientToServerMessageStream, LockUserError, Request, Response,
print_lock_users_output_status, print_lock_users_output_status_json,
request_validation::ValidationError,
ClientToServerMessageStream, Request, Response, print_lock_users_output_status,
print_lock_users_output_status_json,
},
types::MySQLUser,
},
@@ -18,7 +17,7 @@ use crate::{
#[derive(Parser, Debug, Clone)]
pub struct LockUserArgs {
/// The `MySQL` user(s) to loc
/// 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>,
@@ -36,7 +35,7 @@ pub async fn lock_users(
anyhow::bail!("No usernames provided");
}
let message = Request::LockUsers(args.username.clone());
let message = Request::LockUsers(args.username.to_owned());
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
@@ -48,27 +47,12 @@ pub async fn lock_users(
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
if args.json {
print_lock_users_output_status_json(&result);
} else {
print_lock_users_output_status(&result);
if result.iter().any(|(_, res)| {
matches!(
res,
Err(LockUserError::ValidationError(
ValidationError::AuthorizationError(_)
))
)
}) {
print_authorization_owner_hint(&mut server_connection).await?;
}
}
server_connection.send(Request::Exit).await?;
if result.values().any(std::result::Result::is_err) {
std::process::exit(1);
}
Ok(())

View File

@@ -8,13 +8,12 @@ use futures_util::SinkExt;
use tokio_stream::StreamExt;
use crate::{
client::commands::{erroneous_server_response, print_authorization_owner_hint},
client::commands::erroneous_server_response,
core::{
completion::mysql_user_completer,
protocol::{
ClientToServerMessageStream, ListUsersError, Request, Response, SetPasswordError,
SetUserPasswordRequest, print_set_password_output_status,
request_validation::ValidationError,
ClientToServerMessageStream, ListUsersError, Request, Response,
print_set_password_output_status,
},
types::MySQLUser,
},
@@ -22,7 +21,7 @@ use crate::{
#[derive(Parser, Debug, Clone)]
pub struct PasswdUserArgs {
/// The `MySQL` user whose password is to be changed
/// 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,
@@ -38,60 +37,25 @@ pub struct PasswdUserArgs {
/// Print the information as JSON
#[arg(short, long)]
json: bool,
/// Set the password to expire on the given date (YYYY-MM-DD)
#[arg(short, long, value_name = "DATE", conflicts_with = "no-expire")]
expire_on: Option<chrono::NaiveDate>,
/// Set the password to never expire
#[arg(short, long, conflicts_with = "expire_on")]
no_expire: bool,
/// Clear the password for the user instead of setting a new one
#[arg(short, long, conflicts_with_all = &["password_file", "stdin", "expire_on", "no-expire"])]
clear: bool,
}
pub fn interactive_password_dialogue_with_double_check(username: &MySQLUser) -> anyhow::Result<String> {
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_prompt(format!("New MySQL password for user '{}'", username))
.with_confirmation(
format!("Retype new MySQL password for user '{username}'"),
format!("Retype new MySQL password for user '{}'", username),
"Passwords do not match",
)
.interact()
.map_err(Into::into)
}
pub fn interactive_password_expiry_dialogue(username: &MySQLUser) -> anyhow::Result<Option<chrono::NaiveDate>> {
let input = dialoguer::Input::<String>::new()
.with_prompt(format!(
"Enter the password expiry date for user '{username}' (YYYY-MM-DD)"
))
.allow_empty(true)
.validate_with(|input: &String| {
chrono::NaiveDate::parse_from_str(input, "%Y-%m-%d")
.map(|_| ())
.map_err(|_| "Invalid date format. Please use YYYY-MM-DD".to_string())
})
.interact_text()?;
if input.trim().is_empty() {
return Ok(None);
}
let date = chrono::NaiveDate::parse_from_str(&input, "%Y-%m-%d")
.map_err(|e| anyhow::anyhow!("Failed to parse date: {}", e))?;
Ok(Some(date))
}
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.clone()]));
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);
@@ -112,38 +76,22 @@ pub async fn passwd_user(
}
}
let password: Option<String> = if let Some(password_file) = args.password_file {
Some(
std::fs::read_to_string(password_file)
.context("Failed to read password file")?
.trim()
.to_string(),
)
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")?;
Some(buffer.trim().to_string())
} else if args.clear {
None
buffer.trim().to_string()
} else {
Some(interactive_password_dialogue_with_double_check(&args.username)?)
read_password_from_stdin_with_double_check(&args.username)?
};
let expiry_date = if args.no_expire {
None
} else if let Some(date) = args.expire_on {
Some(date)
} else {
interactive_password_expiry_dialogue(&args.username)?
};
let message = Request::PasswdUser(SetUserPasswordRequest {
user: args.username.clone(),
new_password: password,
expiry: expiry_date,
});
let message = Request::PasswdUser((args.username.to_owned(), password));
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
@@ -155,22 +103,9 @@ pub async fn passwd_user(
response => return erroneous_server_response(response),
};
print_set_password_output_status(&result, &args.username);
if matches!(
result,
Err(SetPasswordError::ValidationError(
ValidationError::AuthorizationError(_)
))
) {
print_authorization_owner_hint(&mut server_connection).await?;
}
server_connection.send(Request::Exit).await?;
if result.is_err() {
std::process::exit(1);
}
print_set_password_output_status(&result, &args.username);
Ok(())
}

View File

@@ -4,13 +4,12 @@ use futures_util::SinkExt;
use tokio_stream::StreamExt;
use crate::{
client::commands::{erroneous_server_response, print_authorization_owner_hint},
client::commands::erroneous_server_response,
core::{
completion::mysql_database_completer,
protocol::{
ClientToServerMessageStream, ListDatabasesError, Request, Response,
print_list_databases_output_status, print_list_databases_output_status_json,
request_validation::ValidationError,
ClientToServerMessageStream, Request, Response, print_list_databases_output_status,
print_list_databases_output_status_json,
},
types::MySQLDatabase,
},
@@ -18,7 +17,7 @@ use crate::{
#[derive(Parser, Debug, Clone)]
pub struct ShowDbArgs {
/// The `MySQL` database(s) to show
/// 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>,
@@ -27,9 +26,9 @@ pub struct ShowDbArgs {
#[arg(short, long)]
json: bool,
/// Show sizes in bytes instead of human-readable format
/// Return a non-zero exit code if any of the results were erroneous
#[arg(short, long)]
bytes: bool,
fail: bool,
}
pub async fn show_databases(
@@ -39,7 +38,7 @@ pub async fn show_databases(
let message = if args.name.is_empty() {
Request::ListDatabases(None)
} else {
Request::ListDatabases(Some(args.name.clone()))
Request::ListDatabases(Some(args.name.to_owned()))
};
server_connection.send(message).await?;
@@ -61,26 +60,15 @@ pub async fn show_databases(
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
if args.json {
print_list_databases_output_status_json(&databases);
} else {
print_list_databases_output_status(&databases, args.bytes);
if databases.iter().any(|(_, res)| {
matches!(
res,
Err(ListDatabasesError::ValidationError(
ValidationError::AuthorizationError(_)
))
)
}) {
print_authorization_owner_hint(&mut server_connection).await?;
}
print_list_databases_output_status(&databases);
}
server_connection.send(Request::Exit).await?;
if databases.values().any(std::result::Result::is_err) {
if args.fail && databases.values().any(|res| res.is_err()) {
std::process::exit(1);
}

View File

@@ -5,13 +5,12 @@ use itertools::Itertools;
use tokio_stream::StreamExt;
use crate::{
client::commands::{erroneous_server_response, print_authorization_owner_hint},
client::commands::erroneous_server_response,
core::{
completion::mysql_database_completer,
protocol::{
ClientToServerMessageStream, ListPrivilegesError, Request, Response,
print_list_privileges_output_status, print_list_privileges_output_status_json,
request_validation::ValidationError,
ClientToServerMessageStream, Request, Response, print_list_privileges_output_status,
print_list_privileges_output_status_json,
},
types::MySQLDatabase,
},
@@ -19,7 +18,7 @@ use crate::{
#[derive(Parser, Debug, Clone)]
pub struct ShowPrivsArgs {
/// The `MySQL` database(s) to show privileges for
/// 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>,
@@ -33,6 +32,10 @@ pub struct ShowPrivsArgs {
/// This flag has no effect when used with --json
#[arg(short, long)]
long: bool,
/// Return a non-zero exit code if any of the results were erroneous
#[arg(short, long)]
fail: bool,
}
pub async fn show_database_privileges(
@@ -42,7 +45,7 @@ pub async fn show_database_privileges(
let message = if args.name.is_empty() {
Request::ListPrivileges(None)
} else {
Request::ListPrivileges(Some(args.name.clone()))
Request::ListPrivileges(Some(args.name.to_owned()))
};
server_connection.send(message).await?;
@@ -65,26 +68,15 @@ pub async fn show_database_privileges(
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
if args.json {
print_list_privileges_output_status_json(&privilege_data);
} else {
print_list_privileges_output_status(&privilege_data, args.long);
if privilege_data.iter().any(|(_, res)| {
matches!(
res,
Err(ListPrivilegesError::ValidationError(
ValidationError::AuthorizationError(_)
))
)
}) {
print_authorization_owner_hint(&mut server_connection).await?;
}
}
server_connection.send(Request::Exit).await?;
if privilege_data.values().any(std::result::Result::is_err) {
if args.fail && privilege_data.values().any(|res| res.is_err()) {
std::process::exit(1);
}

View File

@@ -4,13 +4,12 @@ use futures_util::SinkExt;
use tokio_stream::StreamExt;
use crate::{
client::commands::{erroneous_server_response, print_authorization_owner_hint},
client::commands::erroneous_server_response,
core::{
completion::mysql_user_completer,
protocol::{
ClientToServerMessageStream, ListUsersError, Request, Response,
print_list_users_output_status, print_list_users_output_status_json,
request_validation::ValidationError,
ClientToServerMessageStream, Request, Response, print_list_users_output_status,
print_list_users_output_status_json,
},
types::MySQLUser,
},
@@ -18,7 +17,7 @@ use crate::{
#[derive(Parser, Debug, Clone)]
pub struct ShowUserArgs {
/// The `MySQL` user(s) to show
/// 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>,
@@ -26,6 +25,10 @@ pub struct ShowUserArgs {
/// Print the information as JSON
#[arg(short, long)]
json: bool,
/// Return a non-zero exit code if any of the results were erroneous
#[arg(short, long)]
fail: bool,
}
pub async fn show_users(
@@ -35,7 +38,7 @@ pub async fn show_users(
let message = if args.username.is_empty() {
Request::ListUsers(None)
} else {
Request::ListUsers(Some(args.username.clone()))
Request::ListUsers(Some(args.username.to_owned()))
};
if let Err(err) = server_connection.send(message).await {
@@ -60,26 +63,15 @@ pub async fn show_users(
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
if args.json {
print_list_users_output_status_json(&users);
} else {
print_list_users_output_status(&users);
if users.iter().any(|(_, res)| {
matches!(
res,
Err(ListUsersError::ValidationError(
ValidationError::AuthorizationError(_)
))
)
}) {
print_authorization_owner_hint(&mut server_connection).await?;
}
}
server_connection.send(Request::Exit).await?;
if users.values().any(std::result::Result::is_err) {
if args.fail && users.values().any(|result| result.is_err()) {
std::process::exit(1);
}

View File

@@ -4,13 +4,12 @@ use futures_util::SinkExt;
use tokio_stream::StreamExt;
use crate::{
client::commands::{erroneous_server_response, print_authorization_owner_hint},
client::commands::erroneous_server_response,
core::{
completion::mysql_user_completer,
protocol::{
ClientToServerMessageStream, Request, Response, UnlockUserError,
print_unlock_users_output_status, print_unlock_users_output_status_json,
request_validation::ValidationError,
ClientToServerMessageStream, Request, Response, print_unlock_users_output_status,
print_unlock_users_output_status_json,
},
types::MySQLUser,
},
@@ -18,7 +17,7 @@ use crate::{
#[derive(Parser, Debug, Clone)]
pub struct UnlockUserArgs {
/// The `MySQL` user(s) to unlock
/// 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>,
@@ -36,7 +35,7 @@ pub async fn unlock_users(
anyhow::bail!("No usernames provided");
}
let message = Request::UnlockUsers(args.username.clone());
let message = Request::UnlockUsers(args.username.to_owned());
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
@@ -48,27 +47,12 @@ pub async fn unlock_users(
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
if args.json {
print_unlock_users_output_status_json(&result);
} else {
print_unlock_users_output_status(&result);
if result.iter().any(|(_, res)| {
matches!(
res,
Err(UnlockUserError::ValidationError(
ValidationError::AuthorizationError(_)
))
)
}) {
print_authorization_owner_hint(&mut server_connection).await?;
}
}
server_connection.send(Request::Exit).await?;
if result.values().any(std::result::Result::is_err) {
std::process::exit(1);
}
Ok(())

View File

@@ -1,13 +1,11 @@
use crate::core::types::{MySQLDatabase, MySQLUser};
#[inline]
#[must_use]
pub fn trim_db_name_to_32_chars(db_name: &MySQLDatabase) -> MySQLDatabase {
db_name.chars().take(32).collect::<String>().into()
}
#[inline]
#[must_use]
pub fn trim_user_name_to_32_chars(user_name: &MySQLUser) -> MySQLUser {
user_name.chars().take(32).collect::<String>().into()
}

View File

@@ -1,12 +1,12 @@
use crate::core::{
protocol::{
CreateDatabaseError, CreateUserError, DropDatabaseError, DropUserError,
ListPrivilegesError, ListUsersError, request_validation::ValidationError,
GetDatabasesPrivilegeDataError, ListUsersError, request_validation::ValidationError,
},
types::DbOrUser,
};
pub fn name_validation_error_to_error_message(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(),
@@ -23,7 +23,7 @@ pub fn name_validation_error_to_error_message(db_or_user: &DbOrUser) -> String {
)
}
pub fn authorization_error_message(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_noun(),
@@ -31,7 +31,7 @@ pub fn authorization_error_message(db_or_user: &DbOrUser) -> String {
)
}
pub fn handle_create_user_error(error: &CreateUserError, name: &str) {
pub fn handle_create_user_error(error: CreateUserError, name: &str) {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-useradm".to_string());
@@ -39,22 +39,22 @@ pub fn handle_create_user_error(error: &CreateUserError, name: &str) {
CreateUserError::ValidationError(ValidationError::NameValidationError(_)) => {
eprintln!(
"{}",
name_validation_error_to_error_message(&DbOrUser::User(name.into()))
name_validation_error_to_error_message(DbOrUser::User(name.into()))
);
}
CreateUserError::ValidationError(ValidationError::AuthorizationError(_)) => {
eprintln!(
"{}",
authorization_error_message(&DbOrUser::User(name.into()))
authorization_error_message(DbOrUser::User(name.into()))
);
}
CreateUserError::MySqlError(_) | CreateUserError::UserAlreadyExists => {
eprintln!("{argv0}: Failed to create user '{name}'.");
eprintln!("{}: Failed to create user '{}'.", argv0, name);
}
}
}
pub fn handle_drop_user_error(error: &DropUserError, name: &str) {
pub fn handle_drop_user_error(error: DropUserError, name: &str) {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-useradm".to_string());
@@ -62,22 +62,22 @@ pub fn handle_drop_user_error(error: &DropUserError, name: &str) {
DropUserError::ValidationError(ValidationError::NameValidationError(_)) => {
eprintln!(
"{}",
name_validation_error_to_error_message(&DbOrUser::User(name.into()))
name_validation_error_to_error_message(DbOrUser::User(name.into()))
);
}
DropUserError::ValidationError(ValidationError::AuthorizationError(_)) => {
eprintln!(
"{}",
authorization_error_message(&DbOrUser::User(name.into()))
authorization_error_message(DbOrUser::User(name.into()))
);
}
DropUserError::MySqlError(_) | DropUserError::UserDoesNotExist => {
eprintln!("{argv0}: Failed to delete user '{name}'.");
eprintln!("{}: Failed to delete user '{}'.", argv0, name);
}
}
}
pub fn handle_list_users_error(error: &ListUsersError, name: &str) {
pub fn handle_list_users_error(error: ListUsersError, name: &str) {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-useradm".to_string());
@@ -85,27 +85,30 @@ pub fn handle_list_users_error(error: &ListUsersError, name: &str) {
ListUsersError::ValidationError(ValidationError::NameValidationError(_)) => {
eprintln!(
"{}",
name_validation_error_to_error_message(&DbOrUser::User(name.into()))
name_validation_error_to_error_message(DbOrUser::User(name.into()))
);
}
ListUsersError::ValidationError(ValidationError::AuthorizationError(_)) => {
eprintln!(
"{}",
authorization_error_message(&DbOrUser::User(name.into()))
authorization_error_message(DbOrUser::User(name.into()))
);
}
ListUsersError::UserDoesNotExist => {
eprintln!("{argv0}: User '{name}' does not exist. You must create it first.",);
eprintln!(
"{}: User '{}' does not exist. You must create it first.",
argv0, name,
);
}
ListUsersError::MySqlError(_) => {
eprintln!("{argv0}: Failed to look up password for user '{name}'");
eprintln!("{}: Failed to look up password for user '{}'", argv0, name);
}
}
}
// ----------------------------------------------------------------------------
pub fn handle_create_database_error(error: &CreateDatabaseError, name: &str) {
pub fn handle_create_database_error(error: CreateDatabaseError, name: &str) {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-dbadm".to_string());
@@ -113,26 +116,26 @@ pub fn handle_create_database_error(error: &CreateDatabaseError, name: &str) {
CreateDatabaseError::ValidationError(ValidationError::NameValidationError(_)) => {
eprintln!(
"{}",
name_validation_error_to_error_message(&DbOrUser::Database(name.into()))
name_validation_error_to_error_message(DbOrUser::Database(name.into()))
);
}
CreateDatabaseError::ValidationError(ValidationError::AuthorizationError(_)) => {
eprintln!(
"{}",
authorization_error_message(&DbOrUser::Database(name.into()))
authorization_error_message(DbOrUser::Database(name.into()))
);
}
CreateDatabaseError::MySqlError(_) => {
eprintln!("{argv0}: Cannot create database '{name}'.");
eprintln!("{}: Cannot create database '{}'.", argv0, name);
}
CreateDatabaseError::DatabaseAlreadyExists => {
eprintln!("{argv0}: Database '{name}' already exists.");
eprintln!("{}: Database '{}' already exists.", argv0, name);
}
}
}
pub fn handle_drop_database_error(error: &DropDatabaseError, name: &str) {
pub fn handle_drop_database_error(error: DropDatabaseError, name: &str) {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-dbadm".to_string());
@@ -140,41 +143,47 @@ pub fn handle_drop_database_error(error: &DropDatabaseError, name: &str) {
DropDatabaseError::ValidationError(ValidationError::NameValidationError(_)) => {
eprintln!(
"{}",
name_validation_error_to_error_message(&DbOrUser::Database(name.into()))
name_validation_error_to_error_message(DbOrUser::Database(name.into()))
);
}
DropDatabaseError::ValidationError(ValidationError::AuthorizationError(_)) => {
eprintln!(
"{}",
authorization_error_message(&DbOrUser::Database(name.into()))
authorization_error_message(DbOrUser::Database(name.into()))
);
}
DropDatabaseError::MySqlError(_) => {
eprintln!("{argv0}: Cannot drop database '{name}'.");
eprintln!("{}: Cannot drop database '{}'.", argv0, name);
}
DropDatabaseError::DatabaseDoesNotExist => {
eprintln!("{argv0}: Database '{name}' doesn't exist.");
eprintln!("{}: Database '{}' doesn't exist.", argv0, name);
}
}
}
pub fn format_show_database_error_message(error: &ListPrivilegesError, name: &str) -> String {
pub fn format_show_database_error_message(
error: GetDatabasesPrivilegeDataError,
name: &str,
) -> String {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-dbadm".to_string());
match error {
ListPrivilegesError::ValidationError(ValidationError::NameValidationError(_)) => {
name_validation_error_to_error_message(&DbOrUser::Database(name.into()))
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()))
}
ListPrivilegesError::ValidationError(ValidationError::AuthorizationError(_)) => {
authorization_error_message(&DbOrUser::Database(name.into()))
GetDatabasesPrivilegeDataError::MySqlError(err) => {
format!(
"{}: Failed to look up privileges for database '{}': {}",
argv0, name, err
)
}
ListPrivilegesError::MySqlError(err) => {
format!("{argv0}: Failed to look up privileges for database '{name}': {err}")
}
ListPrivilegesError::DatabaseDoesNotExist => {
format!("{argv0}: Database '{name}' doesn't exist.")
GetDatabasesPrivilegeDataError::DatabaseDoesNotExist => {
format!("{}: Database '{}' doesn't exist.", argv0, name)
}
}
}

View File

@@ -1,6 +1,5 @@
use clap::{Parser, Subcommand};
use clap_complete::ArgValueCompleter;
use clap_verbosity_flag::Verbosity;
use futures_util::{SinkExt, StreamExt};
use std::os::unix::net::UnixStream as StdUnixStream;
use std::path::PathBuf;
@@ -19,17 +18,17 @@ use crate::{
},
core::{
bootstrap::bootstrap_server_connection_and_drop_privileges,
completion::{mysql_database_completer, prefix_completer},
completion::mysql_database_completer,
database_privileges::DatabasePrivilegeRow,
protocol::{
ClientToServerMessageStream, ListPrivilegesError, Request, Response,
ClientToServerMessageStream, GetDatabasesPrivilegeDataError, Request, Response,
create_client_to_server_message_stream,
},
types::MySQLDatabase,
},
};
const HELP_DB_PERM: &str = r"
const HELP_DB_PERM: &str = r#"
Edit permissions for the DATABASE(s). Running this command will
spawn the editor stored in the $EDITOR environment variable.
(pico will be used if the variable is unset)
@@ -50,7 +49,7 @@ The Y/N-values corresponds to the following mysql privileges:
Temp - Enables use of CREATE TEMPORARY TABLE
Lock - Enables use of LOCK TABLE
References - Enables use of REFERENCES
";
"#;
/// Create, drop or edit permissions for the DATABASE(s),
/// as determined by the COMMAND.
@@ -125,7 +124,6 @@ pub enum Command {
pub struct CreateArgs {
/// The name of the DATABASE(s) to create.
#[arg(num_args = 1..)]
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(prefix_completer)))]
name: Vec<MySQLDatabase>,
}
@@ -157,22 +155,25 @@ pub fn main() -> anyhow::Result<()> {
let args: Args = Args::parse();
if args.help_editperm {
println!("{HELP_DB_PERM}");
println!("{}", HELP_DB_PERM);
return Ok(());
}
let server_connection = bootstrap_server_connection_and_drop_privileges(
args.server_socket_path,
args.config,
Verbosity::default(),
Default::default(),
)?;
let Some(command) = args.command else {
println!(
"Try `{} --help' for more information.",
std::env::args().next().unwrap_or("mysql-dbadm".to_string())
);
return Ok(());
let command = match args.command {
Some(command) => command,
None => {
println!(
"Try `{} --help' for more information.",
std::env::args().next().unwrap_or("mysql-dbadm".to_string())
);
return Ok(());
}
};
tokio_run_command(command, server_connection)?;
@@ -192,11 +193,11 @@ fn tokio_run_command(command: Command, server_connection: StdUnixStream) -> anyh
while let Some(Ok(message)) = message_stream.next().await {
match message {
Response::Error(err) => {
anyhow::bail!("{err}");
anyhow::bail!("{}", err);
}
Response::Ready => break,
message => {
eprintln!("Unexpected message from server: {message:?}");
eprintln!("Unexpected message from server: {:?}", message);
}
}
}
@@ -207,19 +208,14 @@ fn tokio_run_command(command: Command, server_connection: StdUnixStream) -> anyh
Command::Show(args) => show_databases(args, message_stream).await,
Command::Editperm(args) => {
let edit_privileges_args = EditPrivsArgs {
single_priv: None,
name: Some(args.database),
privs: vec![],
json: false,
editor: None,
yes: false,
};
edit_database_privileges(
edit_privileges_args,
Some(args.database),
message_stream,
)
.await
edit_database_privileges(edit_privileges_args, message_stream).await
}
}
})
@@ -243,8 +239,8 @@ async fn create_databases(
for (name, result) in result {
match result {
Ok(()) => println!("Database {name} created."),
Err(err) => handle_create_database_error(&err, &name),
Ok(()) => println!("Database {} created.", name),
Err(err) => handle_create_database_error(err, &name),
}
}
@@ -269,8 +265,8 @@ async fn drop_databases(
for (name, result) in result {
match result {
Ok(()) => println!("Database {name} dropped."),
Err(err) => handle_drop_database_error(&err, &name),
Ok(()) => println!("Database {} dropped.", name),
Err(err) => handle_drop_database_error(err, &name),
}
}
@@ -310,21 +306,24 @@ async fn show_databases(
let results: Vec<Result<(MySQLDatabase, Vec<DatabasePrivilegeRow>), String>> = match response {
Some(Ok(Response::ListPrivileges(result))) => result
.into_iter()
.map(|(name, rows)| match rows.map(|rows| (name.clone(), rows)) {
Ok(rows) => Ok(rows),
Err(ListPrivilegesError::DatabaseDoesNotExist) => Ok((name, vec![])),
Err(err) => Err(format_show_database_error_message(&err, &name)),
})
.map(
|(name, rows)| match rows.map(|rows| (name.to_owned(), rows)) {
Ok(rows) => Ok(rows),
Err(GetDatabasesPrivilegeDataError::DatabaseDoesNotExist) => Ok((name, vec![])),
Err(err) => Err(format_show_database_error_message(err, &name)),
},
)
.collect(),
response => return erroneous_server_response(response),
};
for result in results {
match result {
Ok((name, rows)) => print_db_privs(&name, rows),
Err(err) => eprintln!("{err}"),
results.into_iter().try_for_each(|result| match result {
Ok((name, rows)) => print_db_privs(&name, rows),
Err(err) => {
eprintln!("{}", err);
Ok(())
}
}
})?;
Ok(())
}
@@ -334,7 +333,7 @@ fn yn(value: bool) -> &'static str {
if value { "Y" } else { "N" }
}
fn print_db_privs(name: &str, rows: Vec<DatabasePrivilegeRow>) {
fn print_db_privs(name: &str, rows: Vec<DatabasePrivilegeRow>) -> anyhow::Result<()> {
println!(
concat!(
"Database '{}':\n",
@@ -364,4 +363,6 @@ fn print_db_privs(name: &str, rows: Vec<DatabasePrivilegeRow>) {
);
}
}
Ok(())
}

View File

@@ -8,7 +8,7 @@ use tokio::net::UnixStream as TokioUnixStream;
use crate::{
client::{
commands::{erroneous_server_response, interactive_password_dialogue_with_double_check},
commands::{erroneous_server_response, read_password_from_stdin_with_double_check},
mysql_admutils_compatibility::{
common::trim_user_name_to_32_chars,
error_messages::{
@@ -18,9 +18,9 @@ use crate::{
},
core::{
bootstrap::bootstrap_server_connection_and_drop_privileges,
completion::{mysql_user_completer, prefix_completer},
completion::mysql_user_completer,
protocol::{
ClientToServerMessageStream, Request, Response, SetUserPasswordRequest, create_client_to_server_message_stream
ClientToServerMessageStream, Request, Response, create_client_to_server_message_stream,
},
types::MySQLUser,
},
@@ -75,7 +75,7 @@ pub enum Command {
/// delete the USER(s).
Delete(DeleteArgs),
/// change the `MySQL` password for the USER(s).
/// change the MySQL password for the USER(s).
Passwd(PasswdArgs),
/// give information about the USERS(s), or, if
@@ -87,7 +87,6 @@ pub enum Command {
pub struct CreateArgs {
/// The name of the USER(s) to create.
#[arg(num_args = 1..)]
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(prefix_completer)))]
name: Vec<MySQLUser>,
}
@@ -119,14 +118,17 @@ pub struct ShowArgs {
pub fn main() -> anyhow::Result<()> {
let args: Args = Args::parse();
let Some(command) = args.command else {
println!(
"Try `{} --help' for more information.",
std::env::args()
.next()
.unwrap_or("mysql-useradm".to_string())
);
return Ok(());
let command = match args.command {
Some(command) => command,
None => {
println!(
"Try `{} --help' for more information.",
std::env::args()
.next()
.unwrap_or("mysql-useradm".to_string())
);
return Ok(());
}
};
let server_connection = bootstrap_server_connection_and_drop_privileges(
@@ -152,11 +154,11 @@ fn tokio_run_command(command: Command, server_connection: StdUnixStream) -> anyh
while let Some(Ok(message)) = message_stream.next().await {
match message {
Response::Error(err) => {
anyhow::bail!("{err}");
anyhow::bail!("{}", err);
}
Response::Ready => break,
message => {
eprintln!("Unexpected message from server: {message:?}");
eprintln!("Unexpected message from server: {:?}", message);
}
}
}
@@ -188,8 +190,8 @@ async fn create_user(
for (name, result) in result {
match result {
Ok(()) => println!("User '{name}' created."),
Err(err) => handle_create_user_error(&err, &name),
Ok(()) => println!("User '{}' created.", name),
Err(err) => handle_create_user_error(err, &name),
}
}
@@ -214,8 +216,8 @@ async fn drop_users(
for (name, result) in result {
match result {
Ok(()) => println!("User '{name}' deleted."),
Err(err) => handle_drop_user_error(&err, &name),
Ok(()) => println!("User '{}' deleted.", name),
Err(err) => handle_drop_user_error(err, &name),
}
}
@@ -245,19 +247,15 @@ async fn passwd_users(
.filter_map(|(name, result)| match result {
Ok(user) => Some(user),
Err(err) => {
handle_list_users_error(&err, &name);
handle_list_users_error(err, &name);
None
}
})
.collect::<Vec<_>>();
for user in users {
let password = interactive_password_dialogue_with_double_check(&user.user)?;
let message = Request::PasswdUser(SetUserPasswordRequest {
user: user.user.clone(),
new_password: Some(password),
expiry: None,
});
let password = read_password_from_stdin_with_double_check(&user.user)?;
let message = Request::PasswdUser((user.user.to_owned(), password));
server_connection.send(message).await?;
match server_connection.next().await {
Some(Ok(Response::SetUserPassword(result))) => match result {
@@ -293,7 +291,7 @@ async fn show_users(
Some(Ok(Response::ListAllUsers(result))) => match result {
Ok(users) => users,
Err(err) => {
eprintln!("Failed to list users: {err:?}");
println!("Failed to list users: {:?}", err);
return Ok(());
}
},
@@ -302,7 +300,7 @@ async fn show_users(
.filter_map(|(name, result)| match result {
Ok(user) => Some(user),
Err(err) => {
handle_list_users_error(&err, &name);
handle_list_users_error(err, &name);
None
}
})

View File

@@ -1,9 +1,4 @@
use std::{
fs,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use std::{fs, path::PathBuf, sync::Arc, time::Duration};
use anyhow::{Context, anyhow};
use clap_verbosity_flag::{InfoLevel, Verbosity};
@@ -14,12 +9,10 @@ use tokio::{net::UnixStream as TokioUnixStream, sync::RwLock};
use tracing_subscriber::prelude::*;
use crate::{
core::{
common::{DEFAULT_CONFIG_PATH, DEFAULT_SOCKET_PATH, UnixUser, executing_in_suid_sgid_mode},
protocol::request_validation::GroupDenylist,
core::common::{
DEFAULT_CONFIG_PATH, DEFAULT_SOCKET_PATH, UnixUser, executing_in_suid_sgid_mode,
},
server::{
authorization::read_and_parse_group_denylist,
config::{MysqlConfig, ServerConfig},
landlock::landlock_restrict_server,
session_handler,
@@ -141,7 +134,7 @@ fn connect_to_external_server(
Err(e) => match e.kind() {
std::io::ErrorKind::NotFound => Err(anyhow::anyhow!("Socket not found")),
std::io::ErrorKind::PermissionDenied => Err(anyhow::anyhow!("Permission denied")),
_ => Err(anyhow::anyhow!("Failed to connect to socket: {e}")),
_ => Err(anyhow::anyhow!("Failed to connect to socket: {}", e)),
},
};
}
@@ -153,7 +146,7 @@ fn connect_to_external_server(
Err(e) => match e.kind() {
std::io::ErrorKind::NotFound => Err(anyhow::anyhow!("Socket not found")),
std::io::ErrorKind::PermissionDenied => Err(anyhow::anyhow!("Permission denied")),
_ => Err(anyhow::anyhow!("Failed to connect to socket: {e}")),
_ => Err(anyhow::anyhow!("Failed to connect to socket: {}", e)),
},
};
}
@@ -181,8 +174,6 @@ pub fn drop_privs() -> anyhow::Result<()> {
Ok(())
}
/// Bootstrap an internal server by forking a child process to run the server, giving it
/// the other half of a Unix socket pair to communicate with the client process.
fn bootstrap_internal_server_and_drop_privs(
config_path: Option<PathBuf>,
) -> anyhow::Result<StdUnixStream> {
@@ -197,10 +188,10 @@ fn bootstrap_internal_server_and_drop_privs(
}
tracing::debug!("Starting server with config at {:?}", config_path);
let socket = invoke_server_with_config(&config_path)?;
let socket = invoke_server_with_config(config_path)?;
drop_privs()?;
return Ok(socket);
}
};
let config_path = PathBuf::from(DEFAULT_CONFIG_PATH);
if fs::metadata(&config_path).is_ok() {
@@ -208,10 +199,10 @@ fn bootstrap_internal_server_and_drop_privs(
anyhow::bail!("Executable is not SUID/SGID - refusing to start internal sever");
}
tracing::debug!("Starting server with default config at {:?}", config_path);
let socket = invoke_server_with_config(&config_path)?;
let socket = invoke_server_with_config(config_path)?;
drop_privs()?;
return Ok(socket);
}
};
anyhow::bail!("No config path provided, and no default config found");
}
@@ -221,7 +212,7 @@ fn bootstrap_internal_server_and_drop_privs(
/// Fork a child process to run the server with the provided config.
/// The server will exit silently by itself when it is done, and this function
/// will only return for the client with the socket for the server.
fn invoke_server_with_config(config_path: &Path) -> anyhow::Result<StdUnixStream> {
fn invoke_server_with_config(config_path: PathBuf) -> anyhow::Result<StdUnixStream> {
let (server_socket, client_socket) = StdUnixStream::pair()?;
let unix_user = UnixUser::from_uid(nix::unistd::getuid().as_raw())?;
@@ -233,21 +224,17 @@ fn invoke_server_with_config(config_path: &Path) -> anyhow::Result<StdUnixStream
nix::unistd::ForkResult::Child => {
tracing::debug!("Running server in child process");
landlock_restrict_server(Some(config_path))
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) {
match run_forked_server(config_path, server_socket, unix_user) {
Err(e) => Err(e),
Ok(()) => unreachable!(),
Ok(_) => unreachable!(),
}
}
}
}
/// Construct a `MySQL` connection pool that consists of exactly one connection.
///
/// This is used for the internal server in SUID/SGID mode, where the server session
/// only ever will get a single client.
async fn construct_single_connection_mysql_pool(
config: &MysqlConfig,
) -> anyhow::Result<sqlx::MySqlPool> {
@@ -273,29 +260,20 @@ async fn construct_single_connection_mysql_pool(
Ok(pool)
}
/// Run a single server session in the forked process.
///
/// Run the server in the forked child process.
/// This function will not return, but will exit the process with a success code.
/// The function assumes that it's caller has already forked the process.
fn run_forked_server(
config_path: &Path,
config_path: PathBuf,
server_socket: StdUnixStream,
unix_user: &UnixUser,
unix_user: UnixUser,
) -> anyhow::Result<()> {
let config = ServerConfig::read_config_from_path(config_path)
let config = ServerConfig::read_config_from_path(&config_path)
.context("Failed to read server config in forked process")?;
let group_denylist = if let Some(denylist_path) = &config.authorization.group_denylist_file {
read_and_parse_group_denylist(denylist_path)
.context("Failed to read and parse group denylist")?
} else {
GroupDenylist::new()
};
let result: anyhow::Result<()> = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("Failed to start Tokio runtime")?
.unwrap()
.block_on(async {
let socket = TokioUnixStream::from_std(server_socket)?;
let db_pool = construct_single_connection_mysql_pool(&config.mysql).await?;
@@ -311,10 +289,9 @@ fn run_forked_server(
let db_pool = Arc::new(RwLock::new(db_pool));
session_handler::session_handler_with_unix_user(
socket,
unix_user,
&unix_user,
db_pool,
db_is_mariadb,
&group_denylist,
)
.await?;
Ok(())

View File

@@ -10,13 +10,13 @@ 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"
r#"
__
____ ___ __ ____________/ /
/ __ `__ \/ / / / ___/ ___/ /
/ / / / / / /_/ (__ ) /__/ /
/_/ /_/ /_/\__,_/____/\___/_/
"
"#
};
pub const KIND_REGARDS: &str = concat!(
@@ -95,14 +95,14 @@ impl UnixUser {
Ok(UnixUser {
username: libc_user.name,
groups: groups.iter().map(|g| g.name.clone()).collect(),
groups: groups.iter().map(|g| g.name.to_owned()).collect(),
})
}
// pub fn from_enviroment() -> anyhow::Result<Self> {
// let libc_uid = nix::unistd::getuid();
// UnixUser::from_uid(libc_uid.as_raw())
// }
pub fn from_enviroment() -> anyhow::Result<Self> {
let libc_uid = nix::unistd::getuid();
UnixUser::from_uid(libc_uid.as_raw())
}
}
#[inline]

View File

@@ -1,7 +1,5 @@
mod mysql_database_completer;
mod mysql_user_completer;
mod prefix_completer;
pub use mysql_database_completer::*;
pub use mysql_user_completer::*;
pub use prefix_completer::*;

View File

@@ -12,7 +12,6 @@ use crate::{
},
};
#[must_use]
pub fn mysql_database_completer(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
match tokio::runtime::Builder::new_current_thread()
.enable_all()
@@ -21,18 +20,18 @@ pub fn mysql_database_completer(current: &std::ffi::OsStr) -> Vec<CompletionCand
Ok(runtime) => match runtime.block_on(mysql_database_completer_(current)) {
Ok(completions) => completions,
Err(err) => {
eprintln!("Error getting MySQL database completions: {err}");
eprintln!("Error getting MySQL database completions: {}", err);
Vec::new()
}
},
Err(err) => {
eprintln!("Error starting Tokio runtime: {err}");
eprintln!("Error starting Tokio runtime: {}", err);
Vec::new()
}
}
}
/// Connect to the server to get `MySQL` database completions.
/// Connect to the server to get MySQL database completions.
async fn mysql_database_completer_(
current: &std::ffi::OsStr,
) -> anyhow::Result<Vec<CompletionCandidate>> {
@@ -45,11 +44,11 @@ async fn mysql_database_completer_(
while let Some(Ok(message)) = server_connection.next().await {
match message {
Response::Error(err) => {
anyhow::bail!("{err}");
anyhow::bail!("{}", err);
}
Response::Ready => break,
message => {
eprintln!("Unexpected message from server: {message:?}");
eprintln!("Unexpected message from server: {:?}", message);
}
}
}
@@ -63,7 +62,7 @@ async fn mysql_database_completer_(
let result = match server_connection.next().await {
Some(Ok(Response::CompleteDatabaseName(suggestions))) => suggestions,
response => return erroneous_server_response(response).map(|()| vec![]),
response => return erroneous_server_response(response).map(|_| vec![]),
};
server_connection.send(Request::Exit).await?;

View File

@@ -12,7 +12,6 @@ use crate::{
},
};
#[must_use]
pub fn mysql_user_completer(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
match tokio::runtime::Builder::new_current_thread()
.enable_all()
@@ -21,18 +20,18 @@ pub fn mysql_user_completer(current: &std::ffi::OsStr) -> Vec<CompletionCandidat
Ok(runtime) => match runtime.block_on(mysql_user_completer_(current)) {
Ok(completions) => completions,
Err(err) => {
eprintln!("Error getting MySQL user completions: {err}");
eprintln!("Error getting MySQL user completions: {}", err);
Vec::new()
}
},
Err(err) => {
eprintln!("Error starting Tokio runtime: {err}");
eprintln!("Error starting Tokio runtime: {}", err);
Vec::new()
}
}
}
/// Connect to the server to get `MySQL` user completions.
/// Connect to the server to get MySQL user completions.
async fn mysql_user_completer_(
current: &std::ffi::OsStr,
) -> anyhow::Result<Vec<CompletionCandidate>> {
@@ -45,11 +44,11 @@ async fn mysql_user_completer_(
while let Some(Ok(message)) = server_connection.next().await {
match message {
Response::Error(err) => {
anyhow::bail!("{err}");
anyhow::bail!("{}", err);
}
Response::Ready => break,
message => {
eprintln!("Unexpected message from server: {message:?}");
eprintln!("Unexpected message from server: {:?}", message);
}
}
}
@@ -63,7 +62,7 @@ async fn mysql_user_completer_(
let result = match server_connection.next().await {
Some(Ok(Response::CompleteUserName(suggestions))) => suggestions,
response => return erroneous_server_response(response).map(|()| vec![]),
response => return erroneous_server_response(response).map(|_| vec![]),
};
server_connection.send(Request::Exit).await?;

View File

@@ -1,76 +0,0 @@
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},
},
};
#[must_use]
pub fn prefix_completer(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => match runtime.block_on(prefix_completer_(current)) {
Ok(completions) => completions,
Err(err) => {
eprintln!("Error getting prefix completions: {err}");
Vec::new()
}
},
Err(err) => {
eprintln!("Error starting Tokio runtime: {err}");
Vec::new()
}
}
}
/// Connect to the server to get `MySQL` user completions.
async fn prefix_completer_(_current: &std::ffi::OsStr) -> anyhow::Result<Vec<CompletionCandidate>> {
let server_connection =
bootstrap_server_connection_and_drop_privileges(None, None, Verbosity::new(0, 1))?;
let tokio_socket = TokioUnixStream::from_std(server_connection)?;
let mut server_connection = create_client_to_server_message_stream(tokio_socket);
while let Some(Ok(message)) = server_connection.next().await {
match message {
Response::Error(err) => {
anyhow::bail!("{err}");
}
Response::Ready => break,
message => {
eprintln!("Unexpected message from server: {message:?}");
}
}
}
let message = Request::ListValidNamePrefixes;
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
anyhow::bail!(anyhow::Error::from(err).context("Failed to communicate with server"));
}
let result = match server_connection.next().await {
Some(Ok(Response::ListValidNamePrefixes(prefixes))) => prefixes,
response => return erroneous_server_response(response).map(|()| vec![]),
};
server_connection.send(Request::Exit).await?;
let result = result
.into_iter()
.map(|prefix| prefix + "_")
.map(CompletionCandidate::new)
.collect();
Ok(result)
}

View File

@@ -1,5 +1,5 @@
//! This module contains some base datastructures and functionality for dealing with
//! database privileges in `MySQL`.
//! database privileges in MySQL.
use std::fmt;
@@ -49,7 +49,6 @@ pub struct DatabasePrivilegeRow {
impl DatabasePrivilegeRow {
/// Gets the value of a privilege by its name as a &str.
#[must_use]
pub fn get_privilege_by_name(&self, name: &str) -> Option<bool> {
match name {
"select_priv" => Some(self.select_priv),
@@ -84,7 +83,6 @@ impl fmt::Display for DatabasePrivilegeRow {
}
/// Converts a database privilege field name to a human-readable name.
#[must_use]
pub fn db_priv_field_human_readable_name(name: &str) -> String {
match name {
"Db" => "Database".to_owned(),
@@ -100,13 +98,12 @@ pub fn db_priv_field_human_readable_name(name: &str) -> String {
"create_tmp_table_priv" => "Temp".to_owned(),
"lock_tables_priv" => "Lock".to_owned(),
"references_priv" => "References".to_owned(),
_ => format!("Unknown({name})"),
_ => format!("Unknown({})", name),
}
}
/// Converts a database privilege field name to a single-character name.
/// (the characters from the cli privilege editor)
#[must_use]
pub fn db_priv_field_single_character_name(name: &str) -> &str {
match name {
"select_priv" => "s",

View File

@@ -1,15 +1,9 @@
//! This module contains serialization and deserialization logic for
//! database privileges related CLI commands.
use itertools::Itertools;
use super::diff::{DatabasePrivilegeChange, DatabasePrivilegeRowDiff};
use crate::core::types::{MySQLDatabase, MySQLUser};
const VALID_PRIVILEGE_EDIT_CHARS: &[char] = &[
's', 'i', 'u', 'd', 'c', 'D', 'a', 'A', 'I', 't', 'l', 'r', 'A',
];
/// This enum represents a part of a CLI argument for editing database privileges,
/// indicating whether privileges are to be added, set, or removed.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -19,74 +13,17 @@ pub enum DatabasePrivilegeEditEntryType {
Remove,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DatabasePrivilegeEdit {
pub type_: DatabasePrivilegeEditEntryType,
pub privileges: Vec<char>,
}
impl DatabasePrivilegeEdit {
pub fn parse_from_str(input: &str) -> anyhow::Result<Self> {
let (edit_type, privs_str) = if let Some(privs_str) = input.strip_prefix('+') {
(DatabasePrivilegeEditEntryType::Add, privs_str)
} else if let Some(privs_str) = input.strip_prefix('-') {
(DatabasePrivilegeEditEntryType::Remove, privs_str)
} else {
(DatabasePrivilegeEditEntryType::Set, input)
};
let privileges: Vec<char> = privs_str.chars().collect();
if privileges
.iter()
.any(|c| !VALID_PRIVILEGE_EDIT_CHARS.contains(c))
{
let invalid_chars: String = privileges
.iter()
.filter(|c| !VALID_PRIVILEGE_EDIT_CHARS.contains(c))
.map(|c| format!("'{c}'"))
.join(", ");
let valid_characters: String = VALID_PRIVILEGE_EDIT_CHARS
.iter()
.map(|c| format!("'{c}'"))
.join(", ");
anyhow::bail!(
"Invalid character(s) in privilege edit entry: {invalid_chars}\n\nValid characters are: {valid_characters}",
);
}
Ok(DatabasePrivilegeEdit {
type_: edit_type,
privileges,
})
}
}
impl std::fmt::Display for DatabasePrivilegeEdit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.type_ {
DatabasePrivilegeEditEntryType::Add => write!(f, "+")?,
DatabasePrivilegeEditEntryType::Set => {}
DatabasePrivilegeEditEntryType::Remove => write!(f, "-")?,
}
for priv_char in &self.privileges {
write!(f, "{priv_char}")?;
}
Ok(())
}
}
/// This struct represents a single CLI argument for editing database privileges.
///
/// This is typically parsed from a string looking like:
///
/// `database_name:username:[+|-]privileges`
/// `[database_name:]username:[+|-]privileges`
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DatabasePrivilegeEditEntry {
pub database: MySQLDatabase,
pub database: Option<MySQLDatabase>,
pub user: MySQLUser,
pub privilege_edit: DatabasePrivilegeEdit,
pub type_: DatabasePrivilegeEditEntryType,
pub privileges: Vec<String>,
}
impl DatabasePrivilegeEditEntry {
@@ -94,41 +31,80 @@ impl DatabasePrivilegeEditEntry {
///
/// The expected format is:
///
/// `database_name:username:[+|-]privileges`
/// `[database_name:]username:[+|-]privileges`
///
/// where:
/// - `database_name` is the name of the database to edit privileges for
/// - 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<Self> {
pub fn parse_from_str(arg: &str) -> anyhow::Result<DatabasePrivilegeEditEntry> {
let parts: Vec<&str> = arg.split(':').collect();
if parts.len() != 3 {
anyhow::bail!("Invalid privilege edit entry format: {arg}");
if parts.len() < 2 || parts.len() > 3 {
anyhow::bail!("Invalid privilege edit entry format: {}", arg);
}
let (database, user, user_privs) = (parts[0].to_string(), parts[1].to_string(), parts[2]);
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}");
anyhow::bail!("Username cannot be empty in privilege edit entry: {}", arg);
}
let privilege_edit = DatabasePrivilegeEdit::parse_from_str(user_privs)?;
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: MySQLDatabase::from(database),
database: database.map(MySQLDatabase::from),
user: MySQLUser::from(user),
privilege_edit,
type_: edit_type,
privileges,
})
}
pub fn as_database_privileges_diff(&self) -> anyhow::Result<DatabasePrivilegeRowDiff> {
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.privilege_edit.type_ {
match self.type_ {
DatabasePrivilegeEditEntryType::Set => {
diff = DatabasePrivilegeRowDiff {
db: self.database.clone(),
db: database,
user: self.user.clone(),
select_priv: Some(DatabasePrivilegeChange::YesToNo),
insert_priv: Some(DatabasePrivilegeChange::YesToNo),
@@ -142,20 +118,20 @@ impl DatabasePrivilegeEditEntry {
lock_tables_priv: Some(DatabasePrivilegeChange::YesToNo),
references_priv: Some(DatabasePrivilegeChange::YesToNo),
};
for priv_char in &self.privilege_edit.privileges {
match priv_char {
's' => diff.select_priv = Some(DatabasePrivilegeChange::NoToYes),
'i' => diff.insert_priv = Some(DatabasePrivilegeChange::NoToYes),
'u' => diff.update_priv = Some(DatabasePrivilegeChange::NoToYes),
'd' => diff.delete_priv = Some(DatabasePrivilegeChange::NoToYes),
'c' => diff.create_priv = Some(DatabasePrivilegeChange::NoToYes),
'D' => diff.drop_priv = Some(DatabasePrivilegeChange::NoToYes),
'a' => diff.alter_priv = Some(DatabasePrivilegeChange::NoToYes),
'I' => diff.index_priv = Some(DatabasePrivilegeChange::NoToYes),
't' => diff.create_tmp_table_priv = Some(DatabasePrivilegeChange::NoToYes),
'l' => diff.lock_tables_priv = Some(DatabasePrivilegeChange::NoToYes),
'r' => diff.references_priv = Some(DatabasePrivilegeChange::NoToYes),
'A' => {
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);
@@ -174,7 +150,7 @@ impl DatabasePrivilegeEditEntry {
}
DatabasePrivilegeEditEntryType::Add | DatabasePrivilegeEditEntryType::Remove => {
diff = DatabasePrivilegeRowDiff {
db: self.database.clone(),
db: database,
user: self.user.clone(),
select_priv: None,
insert_priv: None,
@@ -188,25 +164,25 @@ impl DatabasePrivilegeEditEntry {
lock_tables_priv: None,
references_priv: None,
};
let value = match self.privilege_edit.type_ {
let value = match self.type_ {
DatabasePrivilegeEditEntryType::Add => DatabasePrivilegeChange::NoToYes,
DatabasePrivilegeEditEntryType::Remove => DatabasePrivilegeChange::YesToNo,
_ => unreachable!(),
};
for priv_char in &self.privilege_edit.privileges {
match priv_char {
's' => diff.select_priv = Some(value),
'i' => diff.insert_priv = Some(value),
'u' => diff.update_priv = Some(value),
'd' => diff.delete_priv = Some(value),
'c' => diff.create_priv = Some(value),
'D' => diff.drop_priv = Some(value),
'a' => diff.alter_priv = Some(value),
'I' => diff.index_priv = Some(value),
't' => diff.create_tmp_table_priv = Some(value),
'l' => diff.lock_tables_priv = Some(value),
'r' => diff.references_priv = Some(value),
'A' => {
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);
@@ -231,9 +207,19 @@ impl DatabasePrivilegeEditEntry {
impl std::fmt::Display for DatabasePrivilegeEditEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:, ", self.database)?;
if let Some(db) = &self.database {
write!(f, "{}:, ", db)?;
}
write!(f, "{}: ", self.user)?;
write!(f, "{}", self.privilege_edit)?;
match self.type_ {
DatabasePrivilegeEditEntryType::Add => write!(f, "+")?,
DatabasePrivilegeEditEntryType::Set => {}
DatabasePrivilegeEditEntryType::Remove => write!(f, "-")?,
}
for priv_char in &self.privileges {
write!(f, "{}", priv_char)?;
}
Ok(())
}
}
@@ -248,12 +234,10 @@ mod tests {
assert_eq!(
result.ok(),
Some(DatabasePrivilegeEditEntry {
database: "db".into(),
database: Some("db".into()),
user: "user".into(),
privilege_edit: DatabasePrivilegeEdit {
type_: DatabasePrivilegeEditEntryType::Set,
privileges: vec!['A'],
},
type_: DatabasePrivilegeEditEntryType::Set,
privileges: vec!["A".into()],
})
);
}
@@ -264,12 +248,10 @@ mod tests {
assert_eq!(
result.ok(),
Some(DatabasePrivilegeEditEntry {
database: "db".into(),
database: Some("db".into()),
user: "user".into(),
privilege_edit: DatabasePrivilegeEdit {
type_: DatabasePrivilegeEditEntryType::Set,
privileges: vec![],
},
type_: DatabasePrivilegeEditEntryType::Set,
privileges: vec![],
})
);
}
@@ -280,16 +262,28 @@ mod tests {
assert_eq!(
result.ok(),
Some(DatabasePrivilegeEditEntry {
database: "db".into(),
database: Some("db".into()),
user: "user".into(),
privilege_edit: DatabasePrivilegeEdit {
type_: DatabasePrivilegeEditEntryType::Set,
privileges: vec!['s', 'i', 'u', 'd'],
},
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");
@@ -314,12 +308,10 @@ mod tests {
assert_eq!(
result.ok(),
Some(DatabasePrivilegeEditEntry {
database: "db".into(),
database: Some("db".into()),
user: "user".into(),
privilege_edit: DatabasePrivilegeEdit {
type_: DatabasePrivilegeEditEntryType::Add,
privileges: vec!['s', 'i', 'u', 'd'],
},
type_: DatabasePrivilegeEditEntryType::Add,
privileges: vec!["s".into(), "i".into(), "u".into(), "d".into()],
})
);
}
@@ -330,12 +322,10 @@ mod tests {
assert_eq!(
result.ok(),
Some(DatabasePrivilegeEditEntry {
database: "db".into(),
database: Some("db".into()),
user: "user".into(),
privilege_edit: DatabasePrivilegeEdit {
type_: DatabasePrivilegeEditEntryType::Remove,
privileges: vec!['s', 'i', 'u', 'd'],
},
type_: DatabasePrivilegeEditEntryType::Remove,
privileges: vec!["s".into(), "i".into(), "u".into(), "d".into()],
}),
);
}

View File

@@ -18,7 +18,6 @@ pub enum DatabasePrivilegeChange {
}
impl DatabasePrivilegeChange {
#[must_use]
pub fn new(p1: bool, p2: bool) -> Option<DatabasePrivilegeChange> {
match (p1, p2) {
(true, false) => Some(DatabasePrivilegeChange::YesToNo),
@@ -50,7 +49,6 @@ pub struct DatabasePrivilegeRowDiff {
impl DatabasePrivilegeRowDiff {
/// Calculates the difference between two [`DatabasePrivilegeRow`] instances.
#[must_use]
pub fn from_rows(
row1: &DatabasePrivilegeRow,
row2: &DatabasePrivilegeRow,
@@ -58,8 +56,8 @@ impl DatabasePrivilegeRowDiff {
debug_assert!(row1.db == row2.db && row1.user == row2.user);
DatabasePrivilegeRowDiff {
db: row1.db.clone(),
user: row1.user.clone(),
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),
@@ -84,7 +82,6 @@ impl DatabasePrivilegeRowDiff {
}
/// Returns true if there are no changes in this diff.
#[must_use]
pub fn is_empty(&self) -> bool {
self.select_priv.is_none()
&& self.insert_priv.is_none()
@@ -116,7 +113,7 @@ impl DatabasePrivilegeRowDiff {
"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}"),
_ => anyhow::bail!("Unknown privilege name: {}", privilege_name),
}
}
@@ -162,7 +159,7 @@ impl DatabasePrivilegeRowDiff {
/// 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>,
change: &Option<DatabasePrivilegeChange>,
from_value: bool,
) -> Option<DatabasePrivilegeChange> {
change.as_ref().and_then(|c| match c {
@@ -176,24 +173,22 @@ impl DatabasePrivilegeRowDiff {
})
}
self.select_priv = new_value(self.select_priv.as_ref(), from.select_priv);
self.insert_priv = new_value(self.insert_priv.as_ref(), from.insert_priv);
self.update_priv = new_value(self.update_priv.as_ref(), from.update_priv);
self.delete_priv = new_value(self.delete_priv.as_ref(), from.delete_priv);
self.create_priv = new_value(self.create_priv.as_ref(), from.create_priv);
self.drop_priv = new_value(self.drop_priv.as_ref(), from.drop_priv);
self.alter_priv = new_value(self.alter_priv.as_ref(), from.alter_priv);
self.index_priv = new_value(self.index_priv.as_ref(), from.index_priv);
self.create_tmp_table_priv = new_value(
self.create_tmp_table_priv.as_ref(),
from.create_tmp_table_priv,
);
self.lock_tables_priv = new_value(self.lock_tables_priv.as_ref(), from.lock_tables_priv);
self.references_priv = new_value(self.references_priv.as_ref(), from.references_priv);
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) {
fn apply_change(change: &Option<DatabasePrivilegeChange>, target: &mut bool) {
match change {
Some(DatabasePrivilegeChange::YesToNo) => *target = false,
Some(DatabasePrivilegeChange::NoToYes) => *target = true,
@@ -201,20 +196,17 @@ impl DatabasePrivilegeRowDiff {
}
}
apply_change(self.select_priv.as_ref(), &mut base.select_priv);
apply_change(self.insert_priv.as_ref(), &mut base.insert_priv);
apply_change(self.update_priv.as_ref(), &mut base.update_priv);
apply_change(self.delete_priv.as_ref(), &mut base.delete_priv);
apply_change(self.create_priv.as_ref(), &mut base.create_priv);
apply_change(self.drop_priv.as_ref(), &mut base.drop_priv);
apply_change(self.alter_priv.as_ref(), &mut base.alter_priv);
apply_change(self.index_priv.as_ref(), &mut base.index_priv);
apply_change(
self.create_tmp_table_priv.as_ref(),
&mut base.create_tmp_table_priv,
);
apply_change(self.lock_tables_priv.as_ref(), &mut base.lock_tables_priv);
apply_change(self.references_priv.as_ref(), &mut base.references_priv);
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);
}
}
@@ -222,7 +214,7 @@ impl fmt::Display for DatabasePrivilegeRowDiff {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fn format_change(
f: &mut fmt::Formatter<'_>,
change: Option<DatabasePrivilegeChange>,
change: &Option<DatabasePrivilegeChange>,
field_name: &str,
) -> fmt::Result {
if let Some(change) = change {
@@ -241,17 +233,17 @@ impl fmt::Display for DatabasePrivilegeRowDiff {
}
}
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")?;
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(())
}
@@ -267,7 +259,6 @@ pub enum DatabasePrivilegesDiff {
}
impl DatabasePrivilegesDiff {
#[must_use]
pub fn get_database_name(&self) -> &MySQLDatabase {
match self {
DatabasePrivilegesDiff::New(p) => &p.db,
@@ -277,7 +268,6 @@ impl DatabasePrivilegesDiff {
}
}
#[must_use]
pub fn get_user_name(&self) -> &MySQLUser {
match self {
DatabasePrivilegesDiff::New(p) => &p.user,
@@ -315,7 +305,7 @@ impl DatabasePrivilegesDiff {
}
if matches!(self, DatabasePrivilegesDiff::Noop { .. }) {
other.clone_into(self);
*self = other.to_owned();
return Ok(());
} else if matches!(other, DatabasePrivilegesDiff::Noop { .. }) {
return Ok(());
@@ -337,8 +327,8 @@ impl DatabasePrivilegesDiff {
inner_diff.mappend(modified);
if inner_diff.is_empty() {
let db = inner_diff.db.clone();
let user = inner_diff.user.clone();
let db = inner_diff.db.to_owned();
let user = inner_diff.user.to_owned();
*self = DatabasePrivilegesDiff::Noop { db, user };
}
}
@@ -362,27 +352,28 @@ 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.
#[must_use]
pub fn diff_privileges(
from: DatabasePrivilegeState<'_>,
to: &[DatabasePrivilegeRow],
) -> BTreeSet<DatabasePrivilegesDiff> {
let from_lookup_table: HashMap<(MySQLDatabase, MySQLUser), DatabasePrivilegeRow> = from
.iter()
.cloned()
.map(|p| ((p.db.clone(), p.user.clone()), p))
.collect();
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> = to
.iter()
.cloned()
.map(|p| ((p.db.clone(), p.user.clone()), p))
.collect();
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.clone(), p.user.clone())) {
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));
@@ -393,7 +384,7 @@ pub fn diff_privileges(
}
for p in from {
if !to_lookup_table.contains_key(&(p.db.clone(), p.user.clone())) {
if !to_lookup_table.contains_key(&(p.db.to_owned(), p.user.to_owned())) {
result.insert(DatabasePrivilegesDiff::Deleted(p.to_owned()));
}
}
@@ -409,16 +400,17 @@ pub fn create_or_modify_privilege_rows(
from: DatabasePrivilegeState<'_>,
to: &BTreeSet<DatabasePrivilegeRowDiff>,
) -> anyhow::Result<BTreeSet<DatabasePrivilegesDiff>> {
let from_lookup_table: HashMap<(MySQLDatabase, MySQLUser), DatabasePrivilegeRow> = from
.iter()
.cloned()
.map(|p| ((p.db.clone(), p.user.clone()), p))
.collect();
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.clone(), diff.user.clone())) {
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() {
@@ -426,8 +418,8 @@ pub fn create_or_modify_privilege_rows(
}
} else {
let mut new_row = DatabasePrivilegeRow {
db: diff.db.clone(),
user: diff.user.clone(),
db: diff.db.to_owned(),
user: diff.user.to_owned(),
select_priv: false,
insert_priv: false,
update_priv: false,
@@ -458,11 +450,12 @@ pub fn reduce_privilege_diffs(
from: DatabasePrivilegeState<'_>,
to: BTreeSet<DatabasePrivilegesDiff>,
) -> anyhow::Result<BTreeSet<DatabasePrivilegesDiff>> {
let from_lookup_table: HashMap<(MySQLDatabase, MySQLUser), DatabasePrivilegeRow> = from
.iter()
.cloned()
.map(|p| ((p.db.clone(), p.user.clone()), p))
.collect();
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()
@@ -488,19 +481,19 @@ pub fn reduce_privilege_diffs(
existing_diff.mappend(&diff)?;
}
Entry::Vacant(vacant_entry) => {
vacant_entry.insert(diff.clone());
vacant_entry.insert(diff.to_owned());
}
}
}
for (key, diff) in &mut result {
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.clone();
let user = modified_diff.user.clone();
let db = modified_diff.db.to_owned();
let user = modified_diff.user.to_owned();
*diff = DatabasePrivilegesDiff::Noop { db, user };
}
}
@@ -513,7 +506,6 @@ pub fn reduce_privilege_diffs(
}
/// Renders a set of [`DatabasePrivilegesDiff`] into a human-readable formatted table.
#[must_use]
pub fn display_privilege_diffs(diffs: &BTreeSet<DatabasePrivilegesDiff>) -> String {
let mut table = Table::new();
table.set_titles(row!["Database", "User", "Privilege diff",]);

View File

@@ -13,11 +13,10 @@ use itertools::Itertools;
use std::cmp::max;
/// Generates a single row of the privileges table for the editor.
#[must_use]
pub fn format_privileges_line_for_editor(
privs: &DatabasePrivilegeRow,
database_name_len: usize,
username_len: usize,
database_name_len: usize,
) -> String {
DATABASE_PRIVILEGE_FIELDS
.into_iter()
@@ -26,7 +25,6 @@ pub fn format_privileges_line_for_editor(
"User" => format!("{:width$}", privs.user, width = username_len),
privilege => format!(
"{:width$}",
// SAFETY: unwrap is safe here because the field names are static
yn(privs.get_privilege_by_name(privilege).unwrap()),
width = db_priv_field_human_readable_name(privilege).len()
),
@@ -36,14 +34,14 @@ pub fn format_privileges_line_for_editor(
.to_string()
}
const EDITOR_COMMENT: &str = r"
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.
///
@@ -54,9 +52,9 @@ pub fn generate_editor_content_from_privilege_data(
unix_user: &str,
database_name: Option<&MySQLDatabase>,
) -> String {
let example_user = format!("{unix_user}_user");
let example_user = format!("{}_user", unix_user);
let example_db = database_name
.unwrap_or(&format!("{unix_user}_db").into())
.unwrap_or(&format!("{}_db", unix_user).into())
.to_string();
// NOTE: `.max()`` fails when the iterator is empty.
@@ -107,8 +105,8 @@ pub fn generate_editor_content_from_privilege_data(
lock_tables_priv: false,
references_priv: false,
},
longest_database_name,
longest_username,
longest_database_name,
);
format!(
@@ -116,15 +114,15 @@ pub fn generate_editor_content_from_privilege_data(
EDITOR_COMMENT,
header.join(" "),
if privilege_data.is_empty() {
format!("# {example_line}")
format!("# {}", example_line)
} else {
privilege_data
.iter()
.map(|privs| {
format_privileges_line_for_editor(
privs,
longest_database_name,
longest_username,
longest_database_name,
)
})
.join("\n")
@@ -147,8 +145,11 @@ enum PrivilegeRowParseResult {
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 '{human_readable_name}' privilege"))
.ok_or_else(|| anyhow!("Expected Y or N, found {}", yn))
.context(format!(
"Could not parse '{}' privilege",
human_readable_name
))
}
#[inline]
@@ -271,12 +272,12 @@ fn parse_privilege_row_from_editor(row: &str) -> PrivilegeRowParseResult {
}
pub fn parse_privilege_data_from_editor_content(
content: &str,
content: String,
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
content
.trim()
.lines()
.map(str::trim)
.split('\n')
.map(|line| line.trim())
.enumerate()
.map(|(i, line)| {
let mut header: Vec<_> = DATABASE_PRIVILEGE_FIELDS
@@ -313,7 +314,7 @@ pub fn parse_privilege_data_from_editor_content(
PrivilegeRowParseResult::Empty => Ok(None),
}
})
.filter_map(std::result::Result::transpose)
.filter_map(|result| result.transpose())
.collect::<anyhow::Result<Vec<DatabasePrivilegeRow>>>()
}
@@ -321,64 +322,6 @@ pub fn parse_privilege_data_from_editor_content(
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_generate_editor_content_from_privilege_data() {
let permissions = vec![
DatabasePrivilegeRow {
db: "test_abcdef".into(),
user: "test_abcdef".into(),
select_priv: true,
insert_priv: false,
update_priv: true,
delete_priv: false,
create_priv: true,
drop_priv: false,
alter_priv: true,
index_priv: false,
create_tmp_table_priv: true,
lock_tables_priv: false,
references_priv: true,
},
DatabasePrivilegeRow {
db: "test_abcdefghijlkmno".into(),
user: "test_abcdef".into(),
select_priv: true,
insert_priv: false,
update_priv: true,
delete_priv: false,
create_priv: true,
drop_priv: false,
alter_priv: true,
index_priv: false,
create_tmp_table_priv: true,
lock_tables_priv: false,
references_priv: true,
},
];
let content = generate_editor_content_from_privilege_data(&permissions, "test", None);
let expected_lines = vec![
"",
"# 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.",
"",
"Database User Select Insert Update Delete Create Drop Alter Index Temp Lock References",
"test_abcdef test_abcdef Y N Y N Y N Y N Y N Y",
"test_abcdefghijlkmno test_abcdef Y N Y N Y N Y N Y N Y",
];
let generated_lines: Vec<&str> = content.lines().collect();
assert_eq!(generated_lines, expected_lines);
}
#[test]
fn ensure_generated_and_parsed_editor_content_is_equal() {
let permissions = vec![
@@ -416,7 +359,7 @@ mod tests {
let content = generate_editor_content_from_privilege_data(&permissions, "user", None);
let parsed_permissions = parse_privilege_data_from_editor_content(&content).unwrap();
let parsed_permissions = parse_privilege_data_from_editor_content(content).unwrap();
assert_eq!(permissions, parsed_permissions);
}

View File

@@ -11,7 +11,6 @@ mod list_all_users;
mod list_databases;
mod list_privileges;
mod list_users;
mod list_valid_name_prefixes;
mod lock_users;
mod modify_privileges;
mod passwd_user;
@@ -30,7 +29,6 @@ pub use list_all_users::*;
pub use list_databases::*;
pub use list_privileges::*;
pub use list_users::*;
pub use list_valid_name_prefixes::*;
pub use lock_users::*;
pub use modify_privileges::*;
pub use passwd_user::*;
@@ -55,26 +53,13 @@ pub type ClientToServerMessageStream = SerdeFramed<
Bincode<Response, Request>,
>;
const MAX_REQUEST_FRAME_LENGTH: usize = 100 * 1024; // 100 KB
const MAX_RESPONSE_FRAME_LENGTH: usize = 1024 * 1024; // 1 MB
pub fn create_client_to_server_message_stream(socket: UnixStream) -> ClientToServerMessageStream {
let codec = {
let mut codec = LengthDelimitedCodec::new();
codec.set_max_frame_length(MAX_REQUEST_FRAME_LENGTH);
codec
};
let length_delimited = Framed::new(socket, codec);
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_server_to_client_message_stream(socket: UnixStream) -> ServerToClientMessageStream {
let codec = {
let mut codec = LengthDelimitedCodec::new();
codec.set_max_frame_length(MAX_RESPONSE_FRAME_LENGTH);
codec
};
let length_delimited = Framed::new(socket, codec);
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())
}
@@ -83,7 +68,6 @@ pub fn create_server_to_client_message_stream(socket: UnixStream) -> ServerToCli
pub enum Request {
CheckAuthorization(CheckAuthorizationRequest),
ListValidNamePrefixes,
CompleteDatabaseName(CompleteDatabaseNameRequest),
CompleteUserName(CompleteUserNameRequest),
@@ -111,7 +95,6 @@ pub enum Request {
pub enum Response {
CheckAuthorization(CheckAuthorizationResponse),
ListValidNamePrefixes(ListValidNamePrefixesResponse),
CompleteDatabaseName(CompleteDatabaseNameResponse),
CompleteUserName(CompleteUserNameResponse),

View File

@@ -21,7 +21,7 @@ pub fn print_check_authorization_output_status(output: &CheckAuthorizationRespon
println!("'{}': OK", db_or_user.name());
}
Err(err) => {
eprintln!(
println!(
"'{}': {}",
db_or_user.name(),
err.to_error_message(db_or_user)
@@ -57,12 +57,10 @@ pub fn print_check_authorization_output_status_json(output: &CheckAuthorizationR
}
impl CheckAuthorizationError {
#[must_use]
pub fn to_error_message(&self, db_or_user: &DbOrUser) -> String {
self.0.to_error_message(db_or_user)
self.0.to_error_message(db_or_user.clone())
}
#[must_use]
pub fn error_type(&self) -> String {
self.0.error_type()
}

View File

@@ -29,11 +29,11 @@ pub fn print_create_databases_output_status(output: &CreateDatabasesResponse) {
for (database_name, result) in output {
match result {
Ok(()) => {
println!("Database '{database_name}' created successfully.");
println!("Database '{}' created successfully.", database_name);
}
Err(err) => {
eprintln!("{}", err.to_error_message(database_name));
eprintln!("Skipping...");
println!("{}", err.to_error_message(database_name));
println!("Skipping...");
}
}
println!();
@@ -63,22 +63,20 @@ pub fn print_create_databases_output_status_json(output: &CreateDatabasesRespons
}
impl CreateDatabaseError {
#[must_use]
pub fn to_error_message(&self, database_name: &MySQLDatabase) -> String {
match self {
CreateDatabaseError::ValidationError(err) => {
err.to_error_message(&DbOrUser::Database(database_name.clone()))
err.to_error_message(DbOrUser::Database(database_name.clone()))
}
CreateDatabaseError::DatabaseAlreadyExists => {
format!("Database {database_name} already exists.")
format!("Database {} already exists.", database_name)
}
CreateDatabaseError::MySqlError(err) => {
format!("MySQL error: {err}")
format!("MySQL error: {}", err)
}
}
}
#[must_use]
pub fn error_type(&self) -> String {
match self {
CreateDatabaseError::ValidationError(err) => err.error_type(),

View File

@@ -29,11 +29,11 @@ pub fn print_create_users_output_status(output: &CreateUsersResponse) {
for (username, result) in output {
match result {
Ok(()) => {
println!("User '{username}' created successfully.");
println!("User '{}' created successfully.", username);
}
Err(err) => {
eprintln!("{}", err.to_error_message(username));
eprintln!("Skipping...");
println!("{}", err.to_error_message(username));
println!("Skipping...");
}
}
println!();
@@ -63,22 +63,20 @@ pub fn print_create_users_output_status_json(output: &CreateUsersResponse) {
}
impl CreateUserError {
#[must_use]
pub fn to_error_message(&self, username: &MySQLUser) -> String {
match self {
CreateUserError::ValidationError(err) => {
err.to_error_message(&DbOrUser::User(username.clone()))
err.to_error_message(DbOrUser::User(username.clone()))
}
CreateUserError::UserAlreadyExists => {
format!("User '{username}' already exists.")
format!("User '{}' already exists.", username)
}
CreateUserError::MySqlError(err) => {
format!("MySQL error: {err}")
format!("MySQL error: {}", err)
}
}
}
#[must_use]
pub fn error_type(&self) -> String {
match self {
CreateUserError::ValidationError(err) => err.error_type(),

View File

@@ -35,8 +35,8 @@ pub fn print_drop_databases_output_status(output: &DropDatabasesResponse) {
);
}
Err(err) => {
eprintln!("{}", err.to_error_message(database_name));
eprintln!("Skipping...");
println!("{}", err.to_error_message(database_name));
println!("Skipping...");
}
}
println!();
@@ -66,22 +66,20 @@ pub fn print_drop_databases_output_status_json(output: &DropDatabasesResponse) {
}
impl DropDatabaseError {
#[must_use]
pub fn to_error_message(&self, database_name: &MySQLDatabase) -> String {
match self {
DropDatabaseError::ValidationError(err) => {
err.to_error_message(&DbOrUser::Database(database_name.clone()))
err.to_error_message(DbOrUser::Database(database_name.clone()))
}
DropDatabaseError::DatabaseDoesNotExist => {
format!("Database {database_name} does not exist.")
format!("Database {} does not exist.", database_name)
}
DropDatabaseError::MySqlError(err) => {
format!("MySQL error: {err}")
format!("MySQL error: {}", err)
}
}
}
#[must_use]
pub fn error_type(&self) -> String {
match self {
DropDatabaseError::ValidationError(err) => err.error_type(),

View File

@@ -29,11 +29,11 @@ pub fn print_drop_users_output_status(output: &DropUsersResponse) {
for (username, result) in output {
match result {
Ok(()) => {
println!("User '{username}' dropped successfully.");
println!("User '{}' dropped successfully.", username);
}
Err(err) => {
eprintln!("{}", err.to_error_message(username));
eprintln!("Skipping...");
println!("{}", err.to_error_message(username));
println!("Skipping...");
}
}
println!();
@@ -63,22 +63,20 @@ pub fn print_drop_users_output_status_json(output: &DropUsersResponse) {
}
impl DropUserError {
#[must_use]
pub fn to_error_message(&self, username: &MySQLUser) -> String {
match self {
DropUserError::ValidationError(err) => {
err.to_error_message(&DbOrUser::User(username.clone()))
err.to_error_message(DbOrUser::User(username.clone()))
}
DropUserError::UserDoesNotExist => {
format!("User '{username}' does not exist.")
format!("User '{}' does not exist.", username)
}
DropUserError::MySqlError(err) => {
format!("MySQL error: {err}")
format!("MySQL error: {}", err)
}
}
}
#[must_use]
pub fn error_type(&self) -> String {
match self {
DropUserError::ValidationError(err) => err.error_type(),

View File

@@ -12,15 +12,13 @@ pub enum ListAllDatabasesError {
}
impl ListAllDatabasesError {
#[must_use]
pub fn to_error_message(&self) -> String {
match self {
ListAllDatabasesError::MySqlError(err) => format!("MySQL error: {err}"),
ListAllDatabasesError::MySqlError(err) => format!("MySQL error: {}", err),
}
}
#[allow(dead_code)]
#[must_use]
pub fn error_type(&self) -> String {
match self {
ListAllDatabasesError::MySqlError(_) => "mysql-error".to_string(),

View File

@@ -3,27 +3,26 @@ use thiserror::Error;
use crate::core::database_privileges::DatabasePrivilegeRow;
pub type ListAllPrivilegesResponse = Result<Vec<DatabasePrivilegeRow>, ListAllPrivilegesError>;
pub type ListAllPrivilegesResponse =
Result<Vec<DatabasePrivilegeRow>, GetAllDatabasesPrivilegeDataError>;
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ListAllPrivilegesError {
pub enum GetAllDatabasesPrivilegeDataError {
#[error("MySQL error: {0}")]
MySqlError(String),
}
impl ListAllPrivilegesError {
#[must_use]
impl GetAllDatabasesPrivilegeDataError {
pub fn to_error_message(&self) -> String {
match self {
ListAllPrivilegesError::MySqlError(err) => format!("MySQL error: {err}"),
GetAllDatabasesPrivilegeDataError::MySqlError(err) => format!("MySQL error: {}", err),
}
}
#[allow(dead_code)]
#[must_use]
pub fn error_type(&self) -> String {
match self {
ListAllPrivilegesError::MySqlError(_) => "mysql-error".to_string(),
GetAllDatabasesPrivilegeDataError::MySqlError(_) => "mysql-error".to_string(),
}
}
}

View File

@@ -12,15 +12,13 @@ pub enum ListAllUsersError {
}
impl ListAllUsersError {
#[must_use]
pub fn to_error_message(&self) -> String {
match self {
ListAllUsersError::MySqlError(err) => format!("MySQL error: {err}"),
ListAllUsersError::MySqlError(err) => format!("MySQL error: {}", err),
}
}
#[allow(dead_code)]
#[must_use]
pub fn error_type(&self) -> String {
match self {
ListAllUsersError::MySqlError(_) => "mysql-error".to_string(),

View File

@@ -30,10 +30,7 @@ pub enum ListDatabasesError {
MySqlError(String),
}
pub fn print_list_databases_output_status(
output: &ListDatabasesResponse,
display_size_as_bytes: bool,
) {
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 {
@@ -55,11 +52,7 @@ pub fn print_list_databases_output_status(
"Users",
"Collation",
"Character Set",
if display_size_as_bytes {
"Size (Bytes)"
} else {
"Size"
}
"Size (Bytes)"
]);
for db in final_database_list {
table.add_row(row![
@@ -68,11 +61,7 @@ pub fn print_list_databases_output_status(
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"),
if display_size_as_bytes {
db.size_bytes.to_string()
} else {
humansize::format_size(db.size_bytes, humansize::DECIMAL)
}
db.size_bytes,
]);
}
@@ -113,22 +102,20 @@ pub fn print_list_databases_output_status_json(output: &ListDatabasesResponse) {
}
impl ListDatabasesError {
#[must_use]
pub fn to_error_message(&self, database_name: &MySQLDatabase) -> String {
match self {
ListDatabasesError::ValidationError(err) => {
err.to_error_message(&DbOrUser::Database(database_name.clone()))
err.to_error_message(DbOrUser::Database(database_name.clone()))
}
ListDatabasesError::DatabaseDoesNotExist => {
format!("Database '{database_name}' does not exist.")
format!("Database '{}' does not exist.", database_name)
}
ListDatabasesError::MySqlError(err) => {
format!("MySQL error: {err}")
format!("MySQL error: {}", err)
}
}
}
#[must_use]
pub fn error_type(&self) -> String {
match self {
ListDatabasesError::ValidationError(err) => err.error_type(),

View File

@@ -23,7 +23,7 @@ use crate::core::{
pub type ListPrivilegesRequest = Option<Vec<MySQLDatabase>>;
pub type ListPrivilegesResponse =
BTreeMap<MySQLDatabase, Result<Vec<DatabasePrivilegeRow>, ListPrivilegesError>>;
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();
@@ -65,7 +65,7 @@ pub fn print_list_privileges_output_status(output: &ListPrivilegesResponse, long
));
for (_database, rows) in final_privs_map {
for row in &rows {
for row in rows.iter() {
table.add_row(row![
row.db,
row.user,
@@ -117,7 +117,7 @@ pub fn print_list_privileges_output_status_json(output: &ListPrivilegesResponse)
}
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ListPrivilegesError {
pub enum GetDatabasesPrivilegeDataError {
#[error("Validation error: {0}")]
ValidationError(#[from] ValidationError),
@@ -128,28 +128,28 @@ pub enum ListPrivilegesError {
MySqlError(String),
}
impl ListPrivilegesError {
#[must_use]
impl GetDatabasesPrivilegeDataError {
pub fn to_error_message(&self, database_name: &MySQLDatabase) -> String {
match self {
ListPrivilegesError::ValidationError(err) => {
err.to_error_message(&DbOrUser::Database(database_name.clone()))
GetDatabasesPrivilegeDataError::ValidationError(err) => {
err.to_error_message(DbOrUser::Database(database_name.clone()))
}
ListPrivilegesError::DatabaseDoesNotExist => {
format!("Database '{database_name}' does not exist.")
GetDatabasesPrivilegeDataError::DatabaseDoesNotExist => {
format!("Database '{}' does not exist.", database_name)
}
ListPrivilegesError::MySqlError(err) => {
format!("MySQL error: {err}")
GetDatabasesPrivilegeDataError::MySqlError(err) => {
format!("MySQL error: {}", err)
}
}
}
#[must_use]
pub fn error_type(&self) -> String {
match self {
ListPrivilegesError::ValidationError(err) => err.error_type(),
ListPrivilegesError::DatabaseDoesNotExist => "database-does-not-exist".to_string(),
ListPrivilegesError::MySqlError(_) => "mysql-error".to_string(),
GetDatabasesPrivilegeDataError::ValidationError(err) => err.error_type(),
GetDatabasesPrivilegeDataError::DatabaseDoesNotExist => {
"database-does-not-exist".to_string()
}
GetDatabasesPrivilegeDataError::MySqlError(_) => "mysql-error".to_string(),
}
}
}

View File

@@ -97,22 +97,20 @@ pub fn print_list_users_output_status_json(output: &ListUsersResponse) {
}
impl ListUsersError {
#[must_use]
pub fn to_error_message(&self, username: &MySQLUser) -> String {
match self {
ListUsersError::ValidationError(err) => {
err.to_error_message(&DbOrUser::User(username.clone()))
err.to_error_message(DbOrUser::User(username.clone()))
}
ListUsersError::UserDoesNotExist => {
format!("User '{username}' does not exist.")
format!("User '{}' does not exist.", username)
}
ListUsersError::MySqlError(err) => {
format!("MySQL error: {err}")
format!("MySQL error: {}", err)
}
}
}
#[must_use]
pub fn error_type(&self) -> String {
match self {
ListUsersError::ValidationError(err) => err.error_type(),

View File

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

View File

@@ -32,11 +32,11 @@ pub fn print_lock_users_output_status(output: &LockUsersResponse) {
for (username, result) in output {
match result {
Ok(()) => {
println!("User '{username}' locked successfully.");
println!("User '{}' locked successfully.", username);
}
Err(err) => {
eprintln!("{}", err.to_error_message(username));
eprintln!("Skipping...");
println!("{}", err.to_error_message(username));
println!("Skipping...");
}
}
println!();
@@ -66,25 +66,23 @@ pub fn print_lock_users_output_status_json(output: &LockUsersResponse) {
}
impl LockUserError {
#[must_use]
pub fn to_error_message(&self, username: &MySQLUser) -> String {
match self {
LockUserError::ValidationError(err) => {
err.to_error_message(&DbOrUser::User(username.clone()))
err.to_error_message(DbOrUser::User(username.clone()))
}
LockUserError::UserDoesNotExist => {
format!("User '{username}' does not exist.")
format!("User '{}' does not exist.", username)
}
LockUserError::UserIsAlreadyLocked => {
format!("User '{username}' is already locked.")
format!("User '{}' is already locked.", username)
}
LockUserError::MySqlError(err) => {
format!("MySQL error: {err}")
format!("MySQL error: {}", err)
}
}
}
#[must_use]
pub fn error_type(&self) -> String {
match self {
LockUserError::ValidationError(err) => err.error_type(),

View File

@@ -53,12 +53,13 @@ pub fn print_modify_database_privileges_output_status(output: &ModifyPrivilegesR
match result {
Ok(()) => {
println!(
"Privileges for user '{username}' on database '{database_name}' modified successfully."
"Privileges for user '{}' on database '{}' modified successfully.",
username, database_name
);
}
Err(err) => {
eprintln!("{}", err.to_error_message(database_name, username));
eprintln!("Skipping...");
println!("{}", err.to_error_message(database_name, username));
println!("Skipping...");
}
}
println!();
@@ -66,20 +67,19 @@ pub fn print_modify_database_privileges_output_status(output: &ModifyPrivilegesR
}
impl ModifyDatabasePrivilegesError {
#[must_use]
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()))
err.to_error_message(DbOrUser::Database(database_name.clone()))
}
ModifyDatabasePrivilegesError::UserValidationError(err) => {
err.to_error_message(&DbOrUser::User(username.clone()))
err.to_error_message(DbOrUser::User(username.clone()))
}
ModifyDatabasePrivilegesError::DatabaseDoesNotExist => {
format!("Database '{database_name}' does not exist.")
format!("Database '{}' does not exist.", database_name)
}
ModifyDatabasePrivilegesError::UserDoesNotExist => {
format!("User '{username}' does not exist.")
format!("User '{}' does not exist.", username)
}
ModifyDatabasePrivilegesError::DiffDoesNotApply(diff) => {
format!(
@@ -88,19 +88,17 @@ impl ModifyDatabasePrivilegesError {
)
}
ModifyDatabasePrivilegesError::MySqlError(err) => {
format!("MySQL error: {err}")
format!("MySQL error: {}", err)
}
}
}
#[allow(dead_code)]
#[must_use]
pub fn error_type(&self) -> String {
match self {
ModifyDatabasePrivilegesError::DatabaseValidationError(err) => {
err.error_type() + "/database"
}
ModifyDatabasePrivilegesError::UserValidationError(err) => err.error_type() + "/user",
// 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()
}
@@ -114,26 +112,29 @@ impl ModifyDatabasePrivilegesError {
}
impl DiffDoesNotApplyError {
#[must_use]
pub fn to_error_message(&self) -> String {
match self {
DiffDoesNotApplyError::RowAlreadyExists(database_name, username) => {
format!(
"Privileges for user '{username}' on database '{database_name}' already exist."
"Privileges for user '{}' on database '{}' already exist.",
username, database_name
)
}
DiffDoesNotApplyError::RowDoesNotExist(database_name, username) => {
format!(
"Privileges for user '{username}' on database '{database_name}' do not exist."
"Privileges for user '{}' on database '{}' do not exist.",
username, database_name
)
}
DiffDoesNotApplyError::RowPrivilegeChangeDoesNotApply(diff, row) => {
format!("Could not apply privilege change {diff:?} to row {row:?}")
format!(
"Could not apply privilege change {:?} to row {:?}",
diff, row
)
}
}
}
#[must_use]
pub fn error_type(&self) -> String {
match self {
DiffDoesNotApplyError::RowAlreadyExists(_, _) => "row-already-exists".to_string(),

View File

@@ -6,12 +6,7 @@ use crate::core::{
types::{DbOrUser, MySQLUser},
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SetUserPasswordRequest {
pub user: MySQLUser,
pub new_password: Option<String>,
pub expiry: Option<chrono::NaiveDate>,
}
pub type SetUserPasswordRequest = (MySQLUser, String);
pub type SetUserPasswordResponse = Result<(), SetPasswordError>;
@@ -23,9 +18,6 @@ pub enum SetPasswordError {
#[error("User does not exist")]
UserDoesNotExist,
#[error("Cannot clear password with an expiry date set")]
ClearPasswordWithExpiry,
#[error("MySQL error: {0}")]
MySqlError(String),
}
@@ -33,41 +25,35 @@ pub enum SetPasswordError {
pub fn print_set_password_output_status(output: &SetUserPasswordResponse, username: &MySQLUser) {
match output {
Ok(()) => {
println!("Password for user '{username}' set successfully.");
println!("Password for user '{}' set successfully.", username);
}
Err(err) => {
eprintln!("{}", err.to_error_message(username));
eprintln!("Skipping...");
println!("{}", err.to_error_message(username));
println!("Skipping...");
}
}
}
impl SetPasswordError {
#[must_use]
pub fn to_error_message(&self, username: &MySQLUser) -> String {
match self {
SetPasswordError::ValidationError(err) => {
err.to_error_message(&DbOrUser::User(username.clone()))
err.to_error_message(DbOrUser::User(username.clone()))
}
SetPasswordError::UserDoesNotExist => {
format!("User '{username}' does not exist.")
}
SetPasswordError::ClearPasswordWithExpiry => {
format!("Cannot clear password for user '{username}' when an expiry date is set.")
format!("User '{}' does not exist.", username)
}
SetPasswordError::MySqlError(err) => {
format!("MySQL error: {err}")
format!("MySQL error: {}", err)
}
}
}
#[allow(dead_code)]
#[must_use]
pub fn error_type(&self) -> String {
match self {
SetPasswordError::ValidationError(err) => err.error_type(),
SetPasswordError::UserDoesNotExist => "user-does-not-exist".to_string(),
SetPasswordError::ClearPasswordWithExpiry => "clear-password-with-expiry".to_string(),
SetPasswordError::MySqlError(_) => "mysql-error".to_string(),
}
}

View File

@@ -32,11 +32,11 @@ pub fn print_unlock_users_output_status(output: &UnlockUsersResponse) {
for (username, result) in output {
match result {
Ok(()) => {
println!("User '{username}' unlocked successfully.");
println!("User '{}' unlocked successfully.", username);
}
Err(err) => {
eprintln!("{}", err.to_error_message(username));
eprintln!("Skipping...");
println!("{}", err.to_error_message(username));
println!("Skipping...");
}
}
println!();
@@ -66,25 +66,23 @@ pub fn print_unlock_users_output_status_json(output: &UnlockUsersResponse) {
}
impl UnlockUserError {
#[must_use]
pub fn to_error_message(&self, username: &MySQLUser) -> String {
match self {
UnlockUserError::ValidationError(err) => {
err.to_error_message(&DbOrUser::User(username.clone()))
err.to_error_message(DbOrUser::User(username.clone()))
}
UnlockUserError::UserDoesNotExist => {
format!("User '{username}' does not exist.")
format!("User '{}' does not exist.", username)
}
UnlockUserError::UserIsAlreadyUnlocked => {
format!("User '{username}' is already unlocked.")
format!("User '{}' is already unlocked.", username)
}
UnlockUserError::MySqlError(err) => {
format!("MySQL error: {err}")
format!("MySQL error: {}", err)
}
}
}
#[must_use]
pub fn error_type(&self) -> String {
match self {
UnlockUserError::ValidationError(err) => err.error_type(),

View File

@@ -1,7 +1,5 @@
use std::collections::HashSet;
use indoc::indoc;
use nix::{libc::gid_t, unistd::Group};
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use thiserror::Error;
@@ -22,27 +20,29 @@ pub enum NameValidationError {
}
impl NameValidationError {
#[must_use]
pub fn to_error_message(self, db_or_user: &DbOrUser) -> String {
pub fn to_error_message(self, db_or_user: DbOrUser) -> String {
match self {
NameValidationError::EmptyString => {
format!("{} name can not be empty.", db_or_user.capitalized_noun())
format!("{} name cannot be empty.", db_or_user.capitalized_noun()).to_owned()
}
NameValidationError::TooLong => format!(
"{} is too long, maximum length is 64 characters.",
"{} is too long. Maximum length is 64 characters.",
db_or_user.capitalized_noun()
),
)
.to_owned(),
NameValidationError::InvalidCharacters => format!(
indoc! {r"
Invalid characters in {} name: '{}', only A-Z, a-z, 0-9, _ (underscore) and - (dash) are permitted.
"},
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(),
}
}
#[must_use]
pub fn error_type(&self) -> &'static str {
match self {
NameValidationError::EmptyString => "empty-string",
@@ -54,43 +54,64 @@ impl NameValidationError {
#[derive(Error, Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
pub enum AuthorizationError {
#[error("Illegal prefix, user is not authorized to manage this resource")]
IllegalPrefix,
#[error("No matching owner prefix found")]
NoMatch,
// TODO: I don't think this should ever happen?
#[error("Name cannot be empty")]
StringEmpty,
#[error("Group was found in denylist")]
DenylistError,
}
impl AuthorizationError {
#[must_use]
pub fn to_error_message(self, db_or_user: &DbOrUser) -> String {
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::IllegalPrefix => format!(
"Illegal {} name prefix: you are not allowed to manage databases or users prefixed with '{}'",
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.prefix(),
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(),
// TODO: This error message could be clearer
AuthorizationError::StringEmpty => {
format!("{} name can not be empty.", db_or_user.capitalized_noun())
}
AuthorizationError::DenylistError => {
format!("'{}' is denied by the group denylist", db_or_user.name())
}
AuthorizationError::StringEmpty => format!(
"'{}' is not a valid {} name.",
db_or_user.name(),
db_or_user.lowercased_noun()
)
.to_string(),
}
}
#[must_use]
pub fn error_type(&self) -> &'static str {
match self {
AuthorizationError::IllegalPrefix => "illegal-prefix",
AuthorizationError::NoMatch => "no-match",
AuthorizationError::StringEmpty => "string-empty",
AuthorizationError::DenylistError => "denylist-error",
}
}
}
@@ -106,8 +127,7 @@ pub enum ValidationError {
}
impl ValidationError {
#[must_use]
pub fn to_error_message(&self, db_or_user: &DbOrUser) -> String {
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),
@@ -121,7 +141,6 @@ impl ValidationError {
}
}
#[must_use]
pub fn error_type(&self) -> String {
match self {
ValidationError::NameValidationError(err) => {
@@ -136,8 +155,6 @@ impl ValidationError {
}
}
pub type GroupDenylist = HashSet<gid_t>;
const MAX_NAME_LENGTH: usize = 64;
pub fn validate_name(name: &str) -> Result<(), NameValidationError> {
@@ -159,7 +176,7 @@ pub fn validate_authorization_by_unix_user(
name: &str,
user: &UnixUser,
) -> Result<(), AuthorizationError> {
let prefixes = std::iter::once(user.username.clone())
let prefixes = std::iter::once(user.username.to_owned())
.chain(user.groups.iter().cloned())
.collect::<Vec<String>>();
@@ -180,53 +197,25 @@ pub fn validate_authorization_by_prefixes(
if prefixes
.iter()
.filter(|p| name.starts_with(&((*p).clone() + "_")))
.filter(|p| name.starts_with(&(p.to_string() + "_")))
.collect::<Vec<_>>()
.is_empty()
{
return Err(AuthorizationError::IllegalPrefix);
}
return Err(AuthorizationError::NoMatch);
};
Ok(())
}
pub fn validate_authorization_by_group_denylist(
name: &str,
user: &UnixUser,
group_denylist: &GroupDenylist,
) -> Result<(), AuthorizationError> {
// NOTE: if the username matches, we allow it regardless of denylist
if user.username == name {
return Ok(());
}
let user_group = Group::from_name(name)
.ok()
.flatten()
.map(|g| g.gid.as_raw());
if let Some(gid) = user_group
&& group_denylist.contains(&gid)
{
Err(AuthorizationError::DenylistError)
} else {
Ok(())
}
}
pub fn validate_db_or_user_request(
db_or_user: &DbOrUser,
unix_user: &UnixUser,
group_denylist: &GroupDenylist,
) -> Result<(), ValidationError> {
validate_name(db_or_user.name()).map_err(ValidationError::NameValidationError)?;
validate_authorization_by_unix_user(db_or_user.name(), unix_user)
.map_err(ValidationError::AuthorizationError)?;
validate_authorization_by_group_denylist(db_or_user.name(), unix_user, group_denylist)
.map_err(ValidationError::AuthorizationError)?;
Ok(())
}
@@ -284,7 +273,7 @@ mod tests {
assert_eq!(
validate_authorization_by_prefixes("nonexistent_testdb", &prefixes),
Err(AuthorizationError::IllegalPrefix)
Err(AuthorizationError::NoMatch)
);
}
}

View File

@@ -34,7 +34,7 @@ impl DerefMut for MySQLUser {
impl fmt::Display for MySQLUser {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:<width$}", self.0, width = f.width().unwrap_or(0))
write!(f, "{}", self.0)
}
}
@@ -83,7 +83,7 @@ impl DerefMut for MySQLDatabase {
impl fmt::Display for MySQLDatabase {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:<width$}", self.0, width = f.width().unwrap_or(0))
write!(f, "{}", self.0)
}
}
@@ -112,7 +112,6 @@ pub enum DbOrUser {
}
impl DbOrUser {
#[must_use]
pub fn lowercased_noun(&self) -> &'static str {
match self {
DbOrUser::Database(_) => "database",
@@ -120,7 +119,6 @@ impl DbOrUser {
}
}
#[must_use]
pub fn capitalized_noun(&self) -> &'static str {
match self {
DbOrUser::Database(_) => "Database",
@@ -128,19 +126,10 @@ impl DbOrUser {
}
}
#[must_use]
pub fn name(&self) -> &str {
match self {
DbOrUser::Database(db) => db.as_str(),
DbOrUser::User(user) => user.as_str(),
}
}
#[must_use]
pub fn prefix(&self) -> &str {
match self {
DbOrUser::Database(db) => db.split('_').next().unwrap_or("?"),
DbOrUser::User(user) => user.split('_').next().unwrap_or("?"),
}
}
}

View File

@@ -1,392 +0,0 @@
use std::os::unix::net::UnixStream as StdUnixStream;
use std::path::PathBuf;
use anyhow::Context;
use clap::{CommandFactory, Parser, Subcommand, crate_version};
use clap_complete::CompleteEnv;
use clap_verbosity_flag::{InfoLevel, Verbosity};
use tokio::net::UnixStream as TokioUnixStream;
use tokio_stream::StreamExt;
use muscl_lib::{
client::{
commands::{
CheckAuthArgs, CreateDbArgs, CreateUserArgs, DropDbArgs, DropUserArgs, EditPrivsArgs,
LockUserArgs, PasswdUserArgs, ShowDbArgs, ShowPrivsArgs, ShowUserArgs, UnlockUserArgs,
check_authorization, create_databases, create_users, drop_databases, drop_users,
edit_database_privileges, lock_users, passwd_user, show_database_privileges,
show_databases, show_users, unlock_users,
},
mysql_admutils_compatibility::{mysql_dbadm, mysql_useradm},
},
core::{
bootstrap::bootstrap_server_connection_and_drop_privileges,
common::{ASCII_BANNER, KIND_REGARDS},
protocol::{ClientToServerMessageStream, Response, create_client_to_server_message_stream},
},
};
#[cfg(feature = "suid-sgid-mode")]
use muscl_lib::core::common::executing_in_suid_sgid_mode;
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",
"\n",
"[dependencies]\n",
const_format::str_replace!(env!("DEPENDENCY_LIST"), ";", "\n")
)
}
const LONG_VERSION: &str = long_version();
const EXAMPLES: &str = const_format::concatcp!(
color_print::cstr!("<bold><underline>Examples:</underline></bold>"),
r#"
# Display help information for any specific command
muscl <command> --help
# Create two users 'alice_user1' and 'alice_user2'
muscl create-user alice_user1 alice_user2
# Create two databases 'alice_db1' and 'alice_db2'
muscl create-db alice_db1 alice_db2
# Grant Select, Update, Insert and Delete privileges on 'alice_db1' to 'alice_user1'
muscl edit-privs alice_db1 alice_user1 +suid
# Show all databases
muscl show-db
# Show which users have privileges on which databases
muscl show-privs
"#,
);
const BEFORE_LONG_HELP: &str = const_format::concatcp!("\x1b[1m", ASCII_BANNER, "\x1b[0m");
const AFTER_LONG_HELP: &str = const_format::concatcp!(EXAMPLES, "\n", KIND_REGARDS,);
/// Database administration tool for non-admin users to manage their own MySQL databases and users.
///
/// This tool allows you to manage users and databases in MySQL.
///
/// 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 = "muscl",
author = "Programvareverkstedet <projects@pvv.ntnu.no>",
version,
about,
disable_help_subcommand = true,
propagate_version = true,
before_long_help = BEFORE_LONG_HELP,
after_long_help = AFTER_LONG_HELP,
long_version = LONG_VERSION,
// NOTE: All non-registered "subcommands" are processed before Arg::parse() is called.
subcommand_required = true,
)]
struct Args {
#[command(subcommand)]
command: ClientCommand,
// NOTE: be careful not to add short options that collide with the `edit-privs` privilege
// characters. It should in theory be possible for `edit-privs` to ignore any options
// specified here, but in practice clap is being difficult to work with.
/// Path to the socket of the server.
#[arg(
long = "server-socket",
value_name = "PATH",
value_hint = clap::ValueHint::FilePath,
global = true,
hide_short_help = true
)]
server_socket_path: Option<PathBuf>,
/// Config file to use for the server.
///
/// This is only useful when running in SUID/SGID mode.
#[cfg(feature = "suid-sgid-mode")]
#[arg(
long = "config",
value_name = "PATH",
value_hint = clap::ValueHint::FilePath,
global = true,
hide_short_help = true
)]
config_path: Option<PathBuf>,
#[command(flatten)]
verbose: Verbosity<InfoLevel>,
}
const EDIT_PRIVS_EXAMPLES: &str = color_print::cstr!(
r#"
<bold><underline>Examples:</underline></bold>
# Open interactive editor to edit privileges
muscl edit-privs
# Set privileges `SELECT`, `INSERT`, and `UPDATE` for user `my_user` on database `my_db`
muscl edit-privs my_db my_user siu
# Set all privileges for user `my_other_user` on database `my_other_db`
muscl edit-privs my_other_db my_other_user A
# Add the `DELETE` privilege for user `my_user` on database `my_db`
muscl edit-privs my_db my_user +d
# Set miscellaneous privileges for multiple users on database `my_db`
muscl edit-privs -p my_db:my_user:siu -p my_db:my_other_user:+ct -p my_db:yet_another_user:-d
"#
);
#[derive(Subcommand, Debug, Clone)]
#[command(subcommand_required = true)]
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 three modes of operation:
///
/// 1. Interactive mode:
///
/// If no arguments are provided, the user will be prompted to edit the privileges using a text editor.
///
/// You can configure your preferred text editor by setting the `VISUAL` or `EDITOR` environment variables.
///
/// Follow the instructions inside the editor for more information.
///
/// 2. Non-interactive human-friendly mode:
///
/// You can provide the command with three positional arguments:
///
/// - `<DB_NAME>`: The name of the database for which you want to edit privileges.
/// - `<USER_NAME>`: The name of the user whose privileges you want to edit.
/// - `<[+-]PRIVILEGES>`: A string representing the privileges to set for the user.
///
/// The `<[+-]PRIVILEGES>` argument is a string of characters, each representing a single privilege.
/// The character `A` is an exception - it represents all privileges.
/// The optional leading character can be either `+` to grant additional privileges or `-` to revoke privileges.
/// If omitted, the privileges will be set exactly as specified, removing any privileges not listed, and adding any that are.
///
/// The character-to-privilege mapping is defined as follows:
///
/// - `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
///
/// 3. Non-interactive batch mode:
///
/// By using the `-p` flag, you can provide multiple privilege edits in a single command.
///
/// The flag value should be formatted as `DB_NAME:USER_NAME:[+-]PRIVILEGES`
/// where the privileges are a string of characters, each representing a single privilege.
/// (See the character-to-privilege mapping above.)
///
#[command(
verbatim_doc_comment,
override_usage = "muscl edit-privs [OPTIONS] [ -p <DB_NAME:USER_NAME:[+-]PRIVILEGES>... | <DB_NAME> <USER_NAME> <[+-]PRIVILEGES> ]",
after_long_help = EDIT_PRIVS_EXAMPLES,
)]
EditPrivs(EditPrivsArgs),
/// Create one or more users
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, None, server_connection).await
}
ClientCommand::CreateUser(args) => create_users(args, server_connection).await,
ClientCommand::DropUser(args) => drop_users(args, server_connection).await,
ClientCommand::PasswdUser(args) => passwd_user(args, server_connection).await,
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,
}
}
/// **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(());
}
let args: Args = Args::parse();
let connection = bootstrap_server_connection_and_drop_privileges(
args.server_socket_path,
#[cfg(feature = "suid-sgid-mode")]
args.config_path,
#[cfg(not(feature = "suid-sgid-mode"))]
None,
args.verbose,
)?;
tokio_run_command(args.command, connection)?;
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 muscl_lib::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| {
PathBuf::from(s)
.file_name()
.map(|s| s.to_string_lossy().to_string())
});
match argv0.as_deref() {
Some("mysql-dbadm") => mysql_dbadm::main().map(Some),
Some("mysql-useradm") => mysql_useradm::main().map(Some),
_ => Ok(None),
}
}
/// Run the given commmand (from the client side) using Tokio.
fn tokio_run_command(
command: ClientCommand,
server_connection: StdUnixStream,
) -> anyhow::Result<()> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("Failed to start Tokio runtime")?
.block_on(async {
let tokio_socket = TokioUnixStream::from_std(server_connection)?;
let mut message_stream = create_client_to_server_message_stream(tokio_socket);
while let Some(Ok(message)) = message_stream.next().await {
match message {
Response::Error(err) => {
anyhow::bail!("{}", err);
}
Response::Ready => break,
message => {
eprintln!("Unexpected message from server: {:?}", message);
}
}
}
handle_command(command, message_stream).await
})
}

View File

@@ -1,6 +0,0 @@
#[macro_use]
extern crate prettytable;
pub mod client;
pub mod core;
pub mod server;

281
src/main.rs Normal file
View File

@@ -0,0 +1,281 @@
#[macro_use]
extern crate prettytable;
use anyhow::Context;
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_util::StreamExt;
use crate::{
core::{
bootstrap::bootstrap_server_connection_and_drop_privileges,
common::{ASCII_BANNER, KIND_REGARDS, executing_in_suid_sgid_mode},
protocol::{Response, create_client_to_server_message_stream},
},
server::{command::ServerArgs, landlock::landlock_restrict_server},
};
#[cfg(feature = "mysql-admutils-compatibility")]
use crate::client::mysql_admutils_compatibility::{mysql_dbadm, mysql_useradm};
mod server;
mod client;
mod core;
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.
///
/// This tool allows you to manage users and databases in MySQL.
///
/// You are only allowed to manage databases and users that are prefixed with
/// either your username, or a group that you are a member of.
#[derive(Parser, Debug)]
#[command(
bin_name = "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,
/// Path to the socket of the server, if it already exists.
#[arg(
short,
long,
value_name = "PATH",
value_hint = clap::ValueHint::FilePath,
global = true,
hide_short_help = true
)]
server_socket_path: Option<PathBuf>,
/// Config file to use for the server.
#[arg(
short,
long,
value_name = "PATH",
value_hint = clap::ValueHint::FilePath,
global = true,
hide_short_help = true
)]
config: Option<PathBuf>,
#[command(flatten)]
verbose: Verbosity<InfoLevel>,
}
#[derive(Parser, Debug, Clone)]
enum Command {
#[command(flatten)]
Client(client::commands::ClientCommand),
/// Run the server
#[command(hide = true)]
Server(server::command::ServerArgs),
}
/// **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(());
}
let args: Args = Args::parse();
if handle_server_command(&args)?.is_some() {
return Ok(());
}
let connection = bootstrap_server_connection_and_drop_privileges(
args.server_socket_path,
args.config,
args.verbose,
)?;
tokio_run_command(args.command, connection)?;
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| {
PathBuf::from(s)
.file_name()
.map(|s| s.to_string_lossy().to_string())
});
match argv0.as_deref() {
Some("mysql-dbadm") => mysql_dbadm::main().map(Some),
Some("mysql-useradm") => mysql_useradm::main().map(Some),
_ => Ok(None),
}
}
/// **WARNING:** This function may be run with elevated privileges.
fn handle_server_command(args: &Args) -> anyhow::Result<Option<()>> {
match args.command {
Command::Server(ref command) => {
assert!(
!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.config.to_owned(),
args.verbose.to_owned(),
command.to_owned(),
)?;
Ok(Some(()))
}
_ => Ok(None),
}
}
const MIN_TOKIO_WORKER_THREADS: usize = 4;
/// Start a long-lived server using Tokio.
fn tokio_start_server(
config_path: Option<PathBuf>,
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(server::command::handle_command(
config_path,
verbosity,
args,
))
}
/// Run the given commmand (from the client side) using Tokio.
///
/// **WARNING:** This function may be run with elevated privileges.
fn tokio_run_command(command: Command, server_connection: StdUnixStream) -> anyhow::Result<()> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("Failed to start Tokio runtime")?
.block_on(async {
let tokio_socket = TokioUnixStream::from_std(server_connection)?;
let mut message_stream = create_client_to_server_message_stream(tokio_socket);
while let Some(Ok(message)) = message_stream.next().await {
match message {
Response::Error(err) => {
anyhow::bail!("{}", err);
}
Response::Ready => break,
message => {
eprintln!("Unexpected message from server: {:?}", message);
}
}
}
match command {
Command::Client(client_args) => {
client::commands::handle_command(client_args, message_stream).await
}
Command::Server(_) => unreachable!(),
}
})
}

View File

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

View File

@@ -1,145 +1,25 @@
use std::{collections::HashSet, path::Path};
use anyhow::Context;
use nix::unistd::Group;
use crate::core::{
common::UnixUser,
protocol::{
CheckAuthorizationError,
request_validation::{GroupDenylist, validate_db_or_user_request},
},
protocol::{CheckAuthorizationError, request_validation::validate_db_or_user_request},
types::DbOrUser,
};
pub async fn check_authorization(
dbs_or_users: Vec<DbOrUser>,
unix_user: &UnixUser,
group_denylist: &GroupDenylist,
) -> std::collections::BTreeMap<DbOrUser, Result<(), CheckAuthorizationError>> {
let mut results = std::collections::BTreeMap::new();
for db_or_user in dbs_or_users {
if let Err(err) = validate_db_or_user_request(&db_or_user, unix_user, group_denylist)
.map_err(CheckAuthorizationError)
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
}
/// Reads and parses a group denylist file, returning a set of GUIDs
///
/// The format of the denylist file is expected to be one group name or GID per line.
/// Lines starting with '#' are treated as comments and ignored.
/// Empty lines are also ignored.
///
/// Each line looks like one of the following:
/// - `gid:1001`
/// - `group:admins`
pub fn read_and_parse_group_denylist(denylist_path: &Path) -> anyhow::Result<GroupDenylist> {
let content = std::fs::read_to_string(denylist_path)
.context(format!("Failed to read denylist file at {denylist_path:?}"))?;
let mut groups = HashSet::with_capacity(content.lines().count());
for (line_number, line) in content.lines().enumerate() {
let trimmed_line = line.trim();
if trimmed_line.is_empty() || trimmed_line.starts_with('#') {
continue;
}
let parts: Vec<&str> = trimmed_line.splitn(2, ':').collect();
if parts.len() != 2 {
tracing::warn!(
"Invalid format in denylist file at {:?} on line {}: {}",
denylist_path,
line_number + 1,
line
);
continue;
}
match parts[0] {
"gid" => {
let gid: u32 = match parts[1].parse() {
Ok(gid) => gid,
Err(err) => {
tracing::warn!(
"Invalid GID '{}' in denylist file at {:?} on line {}: {}",
parts[1],
denylist_path,
line_number + 1,
err
);
continue;
}
};
let group = match Group::from_gid(nix::unistd::Gid::from_raw(gid)) {
Ok(Some(g)) => g,
Ok(None) => {
tracing::warn!(
"No group found for GID {} in denylist file at {:?} on line {}",
gid,
denylist_path,
line_number + 1
);
continue;
}
Err(err) => {
tracing::warn!(
"Failed to get group for GID {} in denylist file at {:?} on line {}: {}",
gid,
denylist_path,
line_number + 1,
err
);
continue;
}
};
groups.insert(group.gid.as_raw());
}
"group" => match Group::from_name(parts[1]) {
Ok(Some(group)) => {
groups.insert(group.gid.as_raw());
}
Ok(None) => {
tracing::warn!(
"No group found for name '{}' in denylist file at {:?} on line {}",
parts[1],
denylist_path,
line_number + 1
);
continue;
}
Err(err) => {
tracing::warn!(
"Failed to get group for name '{}' in denylist file at {:?} on line {}: {}",
parts[1],
denylist_path,
line_number + 1,
err
);
}
},
_ => {
tracing::warn!(
"Invalid prefix '{}' in denylist file at {:?} on line {}: {}",
parts[0],
denylist_path,
line_number + 1,
line
);
continue;
}
}
}
Ok(groups)
}

View File

@@ -3,11 +3,11 @@ use std::path::PathBuf;
use anyhow::Context;
use clap::{Parser, Subcommand};
use clap_verbosity_flag::{InfoLevel, Verbosity};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::prelude::*;
use muscl_lib::{
use crate::{
core::common::{ASCII_BANNER, DEFAULT_CONFIG_PATH, KIND_REGARDS},
server::{landlock::landlock_restrict_server, supervisor::Supervisor},
server::supervisor::Supervisor,
};
#[derive(Parser, Debug, Clone)]
@@ -16,7 +16,6 @@ pub struct ServerArgs {
pub subcmd: ServerCommand,
/// Enable systemd mode
#[cfg(target_os = "linux")]
#[arg(long)]
pub systemd: bool,
@@ -25,29 +24,6 @@ pub struct ServerArgs {
/// This is useful if you are planning to reload the server's configuration.
#[arg(long)]
pub disable_landlock: bool,
// NOTE: be careful not to add short options that collide with the `edit-privs` privilege
// characters. It should in theory be possible for `edit-privs` to ignore any options
// specified here, but in practice clap is being difficult to work with.
/// Path to where the server's unix socket should be created. This is only relevant when
/// not using systemd socket activation.
#[arg(
long = "socket",
value_name = "PATH",
value_hint = clap::ValueHint::FilePath,
)]
socket_path: Option<PathBuf>,
/// Config file to use for the server.
#[arg(
long = "config",
value_name = "PATH",
value_hint = clap::ValueHint::FilePath,
)]
config_path: Option<PathBuf>,
#[command(flatten)]
verbosity: Verbosity<InfoLevel>,
}
#[derive(Subcommand, Debug, Clone)]
@@ -71,37 +47,17 @@ const LOG_LEVEL_WARNING: &str = r#"
===================================================
"#;
const MIN_TOKIO_WORKER_THREADS: usize = 4;
fn main() -> anyhow::Result<()> {
let args = ServerArgs::parse();
if !args.disable_landlock {
landlock_restrict_server(args.config_path.as_deref())
.context("Failed to apply Landlock restrictions to the server process")?;
}
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(handle_command(args))?;
Ok(())
}
fn trace_server_prelude() {
pub fn trace_server_prelude() {
let message = [ASCII_BANNER, "", KIND_REGARDS, ""].join("\n");
tracing::info!(message);
}
async fn handle_command(args: ServerArgs) -> anyhow::Result<()> {
pub async fn handle_command(
config_path: Option<PathBuf>,
verbosity: Verbosity<InfoLevel>,
args: ServerArgs,
) -> anyhow::Result<()> {
let mut auto_detected_systemd_mode = false;
#[cfg(target_os = "linux")]
let systemd_mode = args.systemd || {
if let Ok(true) = sd_notify::booted() {
auto_detected_systemd_mode = true;
@@ -111,34 +67,28 @@ async fn handle_command(args: ServerArgs) -> anyhow::Result<()> {
}
};
#[cfg(not(target_os = "linux"))]
let systemd_mode = false;
if systemd_mode {
#[cfg(target_os = "linux")]
{
let subscriber = tracing_subscriber::Registry::default()
.with(args.verbosity.tracing_level_filter())
.with(tracing_journald::layer()?);
let subscriber = tracing_subscriber::Registry::default()
.with(verbosity.tracing_level_filter())
.with(tracing_journald::layer()?);
tracing::subscriber::set_global_default(subscriber)
.context("Failed to set global default tracing subscriber")?;
tracing::subscriber::set_global_default(subscriber)
.context("Failed to set global default tracing subscriber")?;
trace_server_prelude();
trace_server_prelude();
if args.verbosity.tracing_level_filter() >= tracing::Level::TRACE {
tracing::warn!("{}", LOG_LEVEL_WARNING.trim());
}
if verbosity.tracing_level_filter() >= tracing::Level::TRACE {
tracing::warn!("{}", LOG_LEVEL_WARNING.trim());
}
if auto_detected_systemd_mode {
tracing::debug!("Running in systemd mode, auto-detected");
} else {
tracing::debug!("Running in systemd mode");
}
if auto_detected_systemd_mode {
tracing::debug!("Running in systemd mode, auto-detected");
} else {
tracing::debug!("Running in systemd mode");
}
} else {
let subscriber = tracing_subscriber::Registry::default()
.with(args.verbosity.tracing_level_filter())
.with(verbosity.tracing_level_filter())
.with(
tracing_subscriber::fmt::layer()
.with_line_number(cfg!(debug_assertions))
@@ -155,9 +105,7 @@ async fn handle_command(args: ServerArgs) -> anyhow::Result<()> {
tracing::debug!("Running in standalone mode");
}
let config_path = args
.config_path
.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH));
let config_path = config_path.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH));
match args.subcmd {
ServerCommand::Listen => {

View File

@@ -1,43 +1,19 @@
use crate::core::{common::UnixUser, protocol::request_validation::GroupDenylist};
use nix::unistd::Group;
use crate::core::common::UnixUser;
use sqlx::prelude::*;
/// This function retrieves the groups of a user, filtering out any groups
/// that are present in the provided denylist.
pub fn get_user_filtered_groups(user: &UnixUser, group_denylist: &GroupDenylist) -> Vec<String> {
user.groups
.iter()
.cloned()
.filter_map(|group_name| {
match Group::from_name(&group_name) {
Ok(Some(group)) => {
if group_denylist.contains(&group.gid.as_raw()) {
None
} else {
Some(group.name)
}
}
// NOTE: allow non-existing groups to pass through the filter
_ => Some(group_name),
}
})
.collect()
}
/// This function creates a regex that matches items (users, databases)
/// that belong to the user or any of the user's groups.
pub fn create_user_group_matching_regex(user: &UnixUser, group_denylist: &GroupDenylist) -> String {
let filtered_groups = get_user_filtered_groups(user, group_denylist);
if filtered_groups.is_empty() {
pub fn create_user_group_matching_regex(user: &UnixUser) -> String {
if user.groups.is_empty() {
format!("{}_.+", user.username)
} else {
format!("({}|{})_.+", user.username, filtered_groups.join("|"))
format!("({}|{})_.+", user.username, user.groups.join("|"))
}
}
/// Some mysql versions with some collations mark some columns as binary fields,
/// which in the current version of sqlx is not parsable as string.
/// See: <https://github.com/launchbadge/sqlx/issues/3387>
/// See: https://github.com/launchbadge/sqlx/issues/3387
#[inline]
pub fn try_get_with_binary_fallback(
row: &sqlx::mysql::MySqlRow,
@@ -61,8 +37,7 @@ mod tests {
groups: vec!["group1".to_owned(), "group2".to_owned()],
};
let regex = create_user_group_matching_regex(&user, &GroupDenylist::new());
println!("Generated regex: {}", regex);
let regex = create_user_group_matching_regex(&user);
let re = Regex::new(&regex).unwrap();
assert!(re.is_match("user_something"));

View File

@@ -44,7 +44,7 @@ impl MysqlConfig {
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:?}")
format!("Failed to read MySQL password file at {:?}", password_file)
})?
.trim()
.to_owned();
@@ -78,15 +78,9 @@ impl MysqlConfig {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct AuthorizationConfig {
pub group_denylist_file: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct ServerConfig {
pub socket_path: Option<PathBuf>,
pub authorization: AuthorizationConfig,
pub mysql: MysqlConfig,
}
@@ -96,8 +90,8 @@ impl ServerConfig {
tracing::debug!("Reading config file at {:?}", config_path);
fs::read_to_string(config_path)
.context(format!("Failed to read config file at {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:?}"))
.context(format!("Failed to parse config file at {:?}", config_path))
}
}

View File

@@ -11,13 +11,11 @@ use crate::{
common::UnixUser,
protocol::{
Request, Response, ServerToClientMessageStream, SetPasswordError,
SetUserPasswordRequest, create_server_to_client_message_stream,
request_validation::GroupDenylist,
create_server_to_client_message_stream,
},
},
server::{
authorization::check_authorization,
common::get_user_filtered_groups,
sql::{
database_operations::{
complete_database_name, create_databases, drop_databases,
@@ -41,7 +39,6 @@ pub async fn session_handler(
socket: UnixStream,
db_pool: Arc<RwLock<MySqlPool>>,
db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> anyhow::Result<()> {
let uid = match socket.peer_cred() {
Ok(cred) => cred.uid(),
@@ -79,7 +76,7 @@ pub async fn session_handler(
))
.await
.ok();
anyhow::bail!("Failed to get username from uid: {e}");
anyhow::bail!("Failed to get username from uid: {}", e);
}
};
@@ -88,14 +85,8 @@ pub async fn session_handler(
(async move {
tracing::info!("Accepted connection from user: {}", unix_user);
let result = session_handler_with_unix_user(
socket,
&unix_user,
db_pool,
db_is_mariadb,
group_denylist,
)
.await;
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: {}",
@@ -113,7 +104,6 @@ pub async fn session_handler_with_unix_user(
unix_user: &UnixUser,
db_pool: Arc<RwLock<MySqlPool>>,
db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> anyhow::Result<()> {
let mut message_stream = create_server_to_client_message_stream(socket);
@@ -141,7 +131,6 @@ pub async fn session_handler_with_unix_user(
unix_user,
&mut db_connection,
db_is_mariadb,
group_denylist,
)
.await;
@@ -158,7 +147,6 @@ async fn session_handler_with_db_connection(
unix_user: &UnixUser,
db_connection: &mut MySqlConnection,
db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> anyhow::Result<()> {
stream.send(Response::Ready).await?;
loop {
@@ -176,235 +164,156 @@ async fn session_handler_with_db_connection(
// TODO: don't clone the request
let request_to_display = match &request {
Request::PasswdUser(SetUserPasswordRequest {
user,
new_password,
expiry,
}) => Request::PasswdUser(SetUserPasswordRequest {
user: user.clone(),
new_password: new_password.as_ref().map(|_| "<REDACTED>".to_string()),
expiry: *expiry,
}),
Request::PasswdUser((db_user, _)) => {
Request::PasswdUser((db_user.to_owned(), "<REDACTED>".to_string()))
}
request => request.to_owned(),
};
if request_to_display == Request::Exit {
tracing::debug!("Received request: {:#?}", request_to_display);
} else {
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, group_denylist).await;
let result = check_authorization(dbs_or_users, unix_user).await;
Response::CheckAuthorization(result)
}
Request::ListValidNamePrefixes => {
let mut result = Vec::with_capacity(unix_user.groups.len() + 1);
result.push(unix_user.username.clone());
for group in get_user_filtered_groups(unix_user, group_denylist) {
result.push(group.clone());
}
Response::ListValidNamePrefixes(result)
}
Request::CompleteDatabaseName(partial_database_name) => {
// TODO: more correct validation here
if partial_database_name
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,
group_denylist,
)
.await;
Response::CompleteDatabaseName(result)
} else {
Response::CompleteDatabaseName(vec![])
}
}
Request::CompleteUserName(partial_user_name) => {
// TODO: more correct validation here
if partial_user_name
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,
group_denylist,
)
.await;
Response::CompleteUserName(result)
} else {
Response::CompleteUserName(vec![])
}
}
Request::CreateDatabases(databases_names) => {
let result = create_databases(
databases_names,
unix_user,
db_connection,
db_is_mariadb,
group_denylist,
)
.await;
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,
group_denylist,
)
.await;
let result =
drop_databases(databases_names, unix_user, db_connection, db_is_mariadb).await;
Response::DropDatabases(result)
}
Request::ListDatabases(database_names) => {
if let Some(database_names) = database_names {
let result = list_databases(
database_names,
unix_user,
db_connection,
db_is_mariadb,
group_denylist,
)
.await;
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)
} else {
let result = list_all_databases_for_user(
unix_user,
db_connection,
db_is_mariadb,
group_denylist,
)
.await;
}
None => {
let result =
list_all_databases_for_user(unix_user, db_connection, db_is_mariadb).await;
Response::ListAllDatabases(result)
}
}
Request::ListPrivileges(database_names) => {
if let Some(database_names) = database_names {
},
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,
group_denylist,
)
.await;
Response::ListPrivileges(privilege_data)
} else {
let privilege_data = get_all_database_privileges(
unix_user,
db_connection,
db_is_mariadb,
group_denylist,
)
.await;
}
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,
group_denylist,
)
.await;
Response::ModifyPrivileges(result)
}
Request::CreateUsers(db_users) => {
let result = create_database_users(
db_users,
unix_user,
db_connection,
db_is_mariadb,
group_denylist,
)
.await;
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,
group_denylist,
)
.await;
let result =
drop_database_users(db_users, unix_user, db_connection, db_is_mariadb).await;
Response::DropUsers(result)
}
Request::PasswdUser(SetUserPasswordRequest {
user,
new_password,
expiry,
}) => {
Request::PasswdUser((db_user, password)) => {
let result = set_password_for_database_user(
&user,
new_password.as_deref(),
expiry,
&db_user,
&password,
unix_user,
db_connection,
db_is_mariadb,
group_denylist,
)
.await;
Response::SetUserPassword(result)
}
Request::ListUsers(db_users) => {
if let Some(db_users) = db_users {
let result = list_database_users(
db_users,
unix_user,
db_connection,
db_is_mariadb,
group_denylist,
)
.await;
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)
} else {
}
None => {
let result = list_all_database_users_for_unix_user(
unix_user,
db_connection,
db_is_mariadb,
group_denylist,
)
.await;
Response::ListAllUsers(result)
}
}
},
Request::LockUsers(db_users) => {
let result = lock_database_users(
db_users,
unix_user,
db_connection,
db_is_mariadb,
group_denylist,
)
.await;
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,
group_denylist,
)
.await;
let result =
unlock_database_users(db_users, unix_user, db_connection, db_is_mariadb).await;
Response::UnlockUsers(result)
}
Request::Exit => {
@@ -412,13 +321,14 @@ async fn session_handler_with_db_connection(
}
};
// TODO: don't clone the response
let response_to_display = match &response {
Response::SetUserPassword(Err(SetPasswordError::MySqlError(_))) => {
&Response::SetUserPassword(Err(SetPasswordError::MySqlError(
Response::SetUserPassword(Err(SetPasswordError::MySqlError(
"<REDACTED>".to_string(),
)))
}
response => response,
response => response.to_owned(),
};
tracing::debug!("Response: {:#?}", response_to_display);

View File

@@ -3,13 +3,11 @@ pub mod database_privilege_operations;
pub mod user_operations;
#[inline]
#[must_use]
pub fn quote_literal(s: &str) -> String {
format!("'{}'", s.replace('\'', r"\'"))
}
#[inline]
#[must_use]
pub fn quote_identifier(s: &str) -> String {
format!("`{}`", s.replace('`', r"\`"))
}

View File

@@ -6,7 +6,6 @@ use sqlx::prelude::*;
use serde::{Deserialize, Serialize};
use crate::core::protocol::CompleteDatabaseNameResponse;
use crate::core::protocol::request_validation::GroupDenylist;
use crate::core::protocol::request_validation::validate_db_or_user_request;
use crate::core::types::DbOrUser;
use crate::core::types::MySQLDatabase;
@@ -50,19 +49,18 @@ pub async fn complete_database_name(
unix_user: &UnixUser,
connection: &mut MySqlConnection,
_db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> CompleteDatabaseNameResponse {
let result = sqlx::query(
r"
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, group_denylist))
.bind(format!("{database_prefix}%"))
.bind(create_user_group_matching_regex(unix_user))
.bind(format!("{}%", database_prefix))
.fetch_all(connection)
.await;
@@ -91,33 +89,29 @@ pub async fn create_databases(
unix_user: &UnixUser,
connection: &mut MySqlConnection,
_db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> CreateDatabasesResponse {
let mut results = BTreeMap::new();
for database_name in database_names {
if let Err(err) = validate_db_or_user_request(
&DbOrUser::Database(database_name.clone()),
unix_user,
group_denylist,
)
.map_err(CreateDatabaseError::ValidationError)
if let Err(err) =
validate_db_or_user_request(&DbOrUser::Database(database_name.clone()), unix_user)
.map_err(CreateDatabaseError::ValidationError)
{
results.insert(database_name.clone(), Err(err));
results.insert(database_name.to_owned(), Err(err));
continue;
}
match unsafe_database_exists(&database_name, &mut *connection).await {
Ok(true) => {
results.insert(
database_name.clone(),
database_name.to_owned(),
Err(CreateDatabaseError::DatabaseAlreadyExists),
);
continue;
}
Err(err) => {
results.insert(
database_name.clone(),
database_name.to_owned(),
Err(CreateDatabaseError::MySqlError(err.to_string())),
);
continue;
@@ -147,33 +141,29 @@ pub async fn drop_databases(
unix_user: &UnixUser,
connection: &mut MySqlConnection,
_db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> DropDatabasesResponse {
let mut results = BTreeMap::new();
for database_name in database_names {
if let Err(err) = validate_db_or_user_request(
&DbOrUser::Database(database_name.clone()),
unix_user,
group_denylist,
)
.map_err(DropDatabaseError::ValidationError)
if let Err(err) =
validate_db_or_user_request(&DbOrUser::Database(database_name.clone()), unix_user)
.map_err(DropDatabaseError::ValidationError)
{
results.insert(database_name.clone(), Err(err));
results.insert(database_name.to_owned(), Err(err));
continue;
}
match unsafe_database_exists(&database_name, &mut *connection).await {
Ok(false) => {
results.insert(
database_name.clone(),
database_name.to_owned(),
Err(DropDatabaseError::DatabaseDoesNotExist),
);
continue;
}
Err(err) => {
results.insert(
database_name.clone(),
database_name.to_owned(),
Err(DropDatabaseError::MySqlError(err.to_string())),
);
continue;
@@ -218,7 +208,7 @@ impl FromRow<'_, sqlx::mysql::MySqlRow> for DatabaseRow {
if s.is_empty() {
None
} else {
Some(s.split(',').map(std::borrow::ToOwned::to_owned).collect())
Some(s.split(',').map(|s| s.to_owned()).collect())
}
})
.unwrap_or_default()
@@ -246,24 +236,20 @@ pub async fn list_databases(
unix_user: &UnixUser,
connection: &mut MySqlConnection,
_db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> ListDatabasesResponse {
let mut results = BTreeMap::new();
for database_name in database_names {
if let Err(err) = validate_db_or_user_request(
&DbOrUser::Database(database_name.clone()),
unix_user,
group_denylist,
)
.map_err(ListDatabasesError::ValidationError)
if let Err(err) =
validate_db_or_user_request(&DbOrUser::Database(database_name.clone()), unix_user)
.map_err(ListDatabasesError::ValidationError)
{
results.insert(database_name.clone(), Err(err));
results.insert(database_name.to_owned(), Err(err));
continue;
}
let result = sqlx::query_as::<_, DatabaseRow>(
r"
r#"
SELECT
CAST(`information_schema`.`SCHEMATA`.`SCHEMA_NAME` AS CHAR(64)) AS `database`,
GROUP_CONCAT(DISTINCT CAST(`information_schema`.`TABLES`.`TABLE_NAME` AS CHAR(64)) SEPARATOR ',') AS `tables`,
@@ -281,7 +267,7 @@ pub async fn list_databases(
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())
@@ -289,7 +275,9 @@ pub async fn list_databases(
.await
.map_err(|err| ListDatabasesError::MySqlError(err.to_string()))
.and_then(|database| {
database.map_or_else(|| Err(ListDatabasesError::DatabaseDoesNotExist), Ok)
database
.map(Ok)
.unwrap_or_else(|| Err(ListDatabasesError::DatabaseDoesNotExist))
});
if let Err(err) = &result {
@@ -308,10 +296,9 @@ pub async fn list_all_databases_for_user(
unix_user: &UnixUser,
connection: &mut MySqlConnection,
_db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> ListAllDatabasesResponse {
let result = sqlx::query_as::<_, DatabaseRow>(
r"
r#"
SELECT
CAST(`information_schema`.`SCHEMATA`.`SCHEMA_NAME` AS CHAR(64)) AS `database`,
GROUP_CONCAT(DISTINCT CAST(`information_schema`.`TABLES`.`TABLE_NAME` AS CHAR(64)) SEPARATOR ',') AS `tables`,
@@ -330,9 +317,9 @@ pub async fn list_all_databases_for_user(
WHERE `information_schema`.`SCHEMATA`.`SCHEMA_NAME` NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')
AND `information_schema`.`SCHEMATA`.`SCHEMA_NAME` REGEXP ?
GROUP BY `information_schema`.`SCHEMATA`.`SCHEMA_NAME`
",
"#,
)
.bind(create_user_group_matching_regex(unix_user, group_denylist))
.bind(create_user_group_matching_regex(unix_user))
.fetch_all(connection)
.await
.map_err(|err| ListAllDatabasesError::MySqlError(err.to_string()));

View File

@@ -28,10 +28,10 @@ use crate::{
DatabasePrivilegesDiff,
},
protocol::{
DiffDoesNotApplyError, ListAllPrivilegesError, ListAllPrivilegesResponse,
ListPrivilegesError, ListPrivilegesResponse, ModifyDatabasePrivilegesError,
ModifyPrivilegesResponse,
request_validation::{GroupDenylist, validate_db_or_user_request},
DiffDoesNotApplyError, GetAllDatabasesPrivilegeDataError,
GetDatabasesPrivilegeDataError, ListAllPrivilegesResponse, ListPrivilegesResponse,
ModifyDatabasePrivilegesError, ModifyPrivilegesResponse,
request_validation::validate_db_or_user_request,
},
types::{DbOrUser, MySQLDatabase, MySQLUser},
},
@@ -50,11 +50,12 @@ use crate::{
fn get_mysql_row_priv_field(row: &MySqlRow, position: usize) -> Result<bool, sqlx::Error> {
let field = DATABASE_PRIVILEGE_FIELDS[position];
let value = row.try_get(position)?;
if let Some(val) = rev_yn(value) {
Ok(val)
} else {
tracing::warn!(r#"Invalid value for privilege "{}": '{}'"#, field, value);
Ok(false)
match rev_yn(value) {
Some(val) => Ok(val),
_ => {
tracing::warn!(r#"Invalid value for privilege "{}": '{}'"#, field, value);
Ok(false)
}
}
}
@@ -142,43 +143,32 @@ pub async fn get_databases_privilege_data(
unix_user: &UnixUser,
connection: &mut MySqlConnection,
_db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> ListPrivilegesResponse {
let mut results = BTreeMap::new();
for database_name in &database_names {
if let Err(err) = validate_db_or_user_request(
&DbOrUser::Database(database_name.clone()),
unix_user,
group_denylist,
)
.map_err(ListPrivilegesError::ValidationError)
for database_name in database_names.iter() {
if let Err(err) =
validate_db_or_user_request(&DbOrUser::Database(database_name.clone()), unix_user)
.map_err(GetDatabasesPrivilegeDataError::ValidationError)
{
results.insert(database_name.to_owned(), Err(err));
continue;
}
match unsafe_database_exists(database_name, connection).await {
Ok(false) => {
results.insert(
database_name.to_owned(),
Err(ListPrivilegesError::DatabaseDoesNotExist),
);
continue;
}
Err(e) => {
results.insert(
database_name.to_owned(),
Err(ListPrivilegesError::MySqlError(e.to_string())),
);
continue;
}
Ok(true) => {}
if !unsafe_database_exists(database_name, connection)
.await
.unwrap()
{
results.insert(
database_name.to_owned(),
Err(GetDatabasesPrivilegeDataError::DatabaseDoesNotExist),
);
continue;
}
let result = unsafe_get_database_privileges(database_name, connection)
.await
.map_err(|e| ListPrivilegesError::MySqlError(e.to_string()));
.map_err(|e| GetDatabasesPrivilegeDataError::MySqlError(e.to_string()));
results.insert(database_name.to_owned(), result);
}
@@ -191,13 +181,13 @@ pub async fn get_databases_privilege_data(
/// TODO: make this constant
fn get_all_db_privs_query() -> String {
format!(
indoc! {r"
indoc! {r#"
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))
@@ -210,13 +200,12 @@ pub async fn get_all_database_privileges(
unix_user: &UnixUser,
connection: &mut MySqlConnection,
_db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> ListAllPrivilegesResponse {
let result = sqlx::query_as::<_, DatabasePrivilegeRow>(&get_all_db_privs_query())
.bind(create_user_group_matching_regex(unix_user, group_denylist))
.bind(create_user_group_matching_regex(unix_user))
.fetch_all(connection)
.await
.map_err(|e| ListAllPrivilegesError::MySqlError(e.to_string()));
.map_err(|e| GetAllDatabasesPrivilegeDataError::MySqlError(e.to_string()));
if let Err(e) = &result {
tracing::error!("Failed to get all database privileges: {:?}", e);
@@ -240,23 +229,25 @@ async fn unsafe_apply_privilege_diff(
let question_marks =
std::iter::repeat_n("?", DATABASE_PRIVILEGE_FIELDS.len()).join(",");
sqlx::query(format!("INSERT INTO `db` ({tables}) VALUES ({question_marks})").as_str())
.bind(p.db.to_string())
.bind(p.user.to_string())
.bind(yn(p.select_priv))
.bind(yn(p.insert_priv))
.bind(yn(p.update_priv))
.bind(yn(p.delete_priv))
.bind(yn(p.create_priv))
.bind(yn(p.drop_priv))
.bind(yn(p.alter_priv))
.bind(yn(p.index_priv))
.bind(yn(p.create_tmp_table_priv))
.bind(yn(p.lock_tables_priv))
.bind(yn(p.references_priv))
.execute(connection)
.await
.map(|_| ())
sqlx::query(
format!("INSERT INTO `db` ({}) VALUES ({})", tables, question_marks).as_str(),
)
.bind(p.db.to_string())
.bind(p.user.to_string())
.bind(yn(p.select_priv))
.bind(yn(p.insert_priv))
.bind(yn(p.update_priv))
.bind(yn(p.delete_priv))
.bind(yn(p.create_priv))
.bind(yn(p.drop_priv))
.bind(yn(p.alter_priv))
.bind(yn(p.index_priv))
.bind(yn(p.create_tmp_table_priv))
.bind(yn(p.lock_tables_priv))
.bind(yn(p.references_priv))
.execute(connection)
.await
.map(|_| ())
}
DatabasePrivilegesDiff::Modified(p) => {
let changes = DATABASE_PRIVILEGE_FIELDS
@@ -278,23 +269,25 @@ async fn unsafe_apply_privilege_diff(
}
}
sqlx::query(format!("UPDATE `db` SET {changes} WHERE `Db` = ? AND `User` = ?").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)
.await
.map(|_| ())
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)
.await
.map(|_| ())
}
DatabasePrivilegesDiff::Deleted(p) => {
sqlx::query("DELETE FROM `db` WHERE `Db` = ? AND `User` = ?")
@@ -404,7 +397,6 @@ pub async fn apply_privilege_diffs(
unix_user: &UnixUser,
connection: &mut MySqlConnection,
_db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> ModifyPrivilegesResponse {
let mut results: BTreeMap<(MySQLDatabase, MySQLUser), _> = BTreeMap::new();
@@ -416,7 +408,6 @@ pub async fn apply_privilege_diffs(
if let Err(err) = validate_db_or_user_request(
&DbOrUser::Database(diff.get_database_name().to_owned()),
unix_user,
group_denylist,
)
.map_err(ModifyDatabasePrivilegesError::UserValidationError)
{
@@ -424,48 +415,31 @@ pub async fn apply_privilege_diffs(
continue;
}
if let Err(err) = validate_db_or_user_request(
&DbOrUser::User(diff.get_user_name().to_owned()),
unix_user,
group_denylist,
)
.map_err(ModifyDatabasePrivilegesError::UserValidationError)
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;
}
match unsafe_database_exists(diff.get_database_name(), connection).await {
Ok(false) => {
results.insert(
key,
Err(ModifyDatabasePrivilegesError::DatabaseDoesNotExist),
);
continue;
}
Err(e) => {
results.insert(
key,
Err(ModifyDatabasePrivilegesError::MySqlError(e.to_string())),
);
continue;
}
Ok(true) => {}
if !unsafe_database_exists(diff.get_database_name(), connection)
.await
.unwrap()
{
results.insert(
key,
Err(ModifyDatabasePrivilegesError::DatabaseDoesNotExist),
);
continue;
}
match unsafe_user_exists(diff.get_user_name(), connection).await {
Ok(false) => {
results.insert(key, Err(ModifyDatabasePrivilegesError::UserDoesNotExist));
continue;
}
Err(e) => {
results.insert(
key,
Err(ModifyDatabasePrivilegesError::MySqlError(e.to_string())),
);
continue;
}
Ok(true) => {}
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 {

View File

@@ -7,7 +7,6 @@ use serde::{Deserialize, Serialize};
use sqlx::MySqlConnection;
use sqlx::prelude::*;
use crate::core::protocol::request_validation::GroupDenylist;
use crate::core::protocol::request_validation::validate_db_or_user_request;
use crate::core::types::DbOrUser;
use crate::{
@@ -34,13 +33,13 @@ pub(super) async fn unsafe_user_exists(
connection: &mut MySqlConnection,
) -> Result<bool, sqlx::Error> {
let result = sqlx::query(
r"
r#"
SELECT EXISTS(
SELECT 1
FROM `mysql`.`user`
WHERE `User` = ?
)
",
"#,
)
.bind(db_user)
.fetch_one(connection)
@@ -59,18 +58,17 @@ pub async fn complete_user_name(
unix_user: &UnixUser,
connection: &mut MySqlConnection,
_db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> Vec<MySQLUser> {
let result = sqlx::query(
r"
r#"
SELECT `User` AS `user`
FROM `mysql`.`user`
WHERE `User` REGEXP ?
AND `User` LIKE ?
",
"#,
)
.bind(create_user_group_matching_regex(unix_user, group_denylist))
.bind(format!("{user_prefix}%"))
.bind(create_user_group_matching_regex(unix_user))
.bind(format!("{}%", user_prefix))
.fetch_all(connection)
.await;
@@ -99,14 +97,12 @@ pub async fn create_database_users(
unix_user: &UnixUser,
connection: &mut MySqlConnection,
_db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> CreateUsersResponse {
let mut results = BTreeMap::new();
for db_user in db_users {
if let Err(err) =
validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist)
.map_err(CreateUserError::ValidationError)
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;
@@ -145,14 +141,12 @@ pub async fn drop_database_users(
unix_user: &UnixUser,
connection: &mut MySqlConnection,
_db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> DropUsersResponse {
let mut results = BTreeMap::new();
for db_user in db_users {
if let Err(err) =
validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist)
.map_err(DropUserError::ValidationError)
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;
@@ -188,54 +182,32 @@ pub async fn drop_database_users(
pub async fn set_password_for_database_user(
db_user: &MySQLUser,
password: Option<&str>,
expiry: Option<chrono::NaiveDate>,
password: &str,
unix_user: &UnixUser,
connection: &mut MySqlConnection,
_db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> SetUserPasswordResponse {
validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist)
validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user)
.map_err(SetPasswordError::ValidationError)?;
if password.is_none() && expiry.is_some() {
return Err(SetPasswordError::ClearPasswordWithExpiry);
}
match unsafe_user_exists(db_user, &mut *connection).await {
Ok(false) => return Err(SetPasswordError::UserDoesNotExist),
Err(err) => return Err(SetPasswordError::MySqlError(err.to_string())),
_ => {}
}
let result = if let Some(password) = password {
let mut query = format!(
let result = sqlx::query(
format!(
"ALTER USER {}@'%' IDENTIFIED BY {}",
quote_literal(db_user),
quote_literal(password).as_str(),
);
if let Some(expiry_date) = expiry {
query.push_str(&format!(" PASSWORD EXPIRE DATE '{}'", expiry_date));
}
sqlx::query(query.as_str())
.execute(&mut *connection)
.await
.map(|_| ())
.map_err(|err| SetPasswordError::MySqlError(err.to_string()))
} else {
let query = format!(
"ALTER USER {}@'%' IDENTIFIED WITH mysql_native_password AS ''",
quote_literal(db_user),
);
sqlx::query(query.as_str())
.execute(&mut *connection)
.await
.map(|_| ())
.map_err(|err| SetPasswordError::MySqlError(err.to_string()))
};
)
.as_str(),
)
.execute(&mut *connection)
.await
.map(|_| ())
.map_err(|err| SetPasswordError::MySqlError(err.to_string()));
if result.is_err() {
tracing::error!(
@@ -257,12 +229,12 @@ const DATABASE_USER_LOCK_STATUS_QUERY_MARIADB: &str = r#"
AND `Host` = '%'
"#;
const DATABASE_USER_LOCK_STATUS_QUERY_MYSQL: &str = r"
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(
@@ -297,14 +269,12 @@ pub async fn lock_database_users(
unix_user: &UnixUser,
connection: &mut MySqlConnection,
db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> LockUsersResponse {
let mut results = BTreeMap::new();
for db_user in db_users {
if let Err(err) =
validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist)
.map_err(LockUserError::ValidationError)
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;
@@ -357,14 +327,12 @@ pub async fn unlock_database_users(
unix_user: &UnixUser,
connection: &mut MySqlConnection,
db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> UnlockUsersResponse {
let mut results = BTreeMap::new();
for db_user in db_users {
if let Err(err) =
validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist)
.map_err(UnlockUserError::ValidationError)
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;
@@ -451,28 +419,26 @@ JOIN `global_priv` ON
AND `user`.`Host` = `global_priv`.`Host`
"#;
const DB_USER_SELECT_STATEMENT_MYSQL: &str = r"
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,
db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> ListUsersResponse {
let mut results = BTreeMap::new();
for db_user in db_users {
if let Err(err) =
validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist)
.map_err(ListUsersError::ValidationError)
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;
@@ -493,10 +459,8 @@ pub async fn list_database_users(
tracing::error!("Failed to list database user '{}': {:?}", &db_user, err);
}
if let Ok(Some(user)) = result.as_mut()
&& let Err(err) = set_databases_where_user_has_privileges(user, &mut *connection).await
{
result = Err(err);
if let Ok(Some(user)) = result.as_mut() {
append_databases_where_user_has_privileges(user, &mut *connection).await;
}
match result {
@@ -513,7 +477,6 @@ pub async fn list_all_database_users_for_unix_user(
unix_user: &UnixUser,
connection: &mut MySqlConnection,
db_is_mariadb: bool,
group_denylist: &GroupDenylist,
) -> ListAllUsersResponse {
let mut result = sqlx::query_as::<_, DatabaseUser>(
&(if db_is_mariadb {
@@ -522,7 +485,7 @@ pub async fn list_all_database_users_for_unix_user(
DB_USER_SELECT_STATEMENT_MYSQL.to_string()
} + "WHERE `user`.`User` REGEXP ?"),
)
.bind(create_user_group_matching_regex(unix_user, group_denylist))
.bind(create_user_group_matching_regex(unix_user))
.fetch_all(&mut *connection)
.await
.map_err(|err| ListAllUsersError::MySqlError(err.to_string()));
@@ -533,33 +496,27 @@ pub async fn list_all_database_users_for_unix_user(
if let Ok(users) = result.as_mut() {
for user in users {
if let Err(mysql_error) =
set_databases_where_user_has_privileges(user, &mut *connection).await
{
return Err(ListAllUsersError::MySqlError(mysql_error.to_string()));
}
append_databases_where_user_has_privileges(user, &mut *connection).await;
}
}
result
}
/// This function sets the `databases` field of the given `DatabaseUser`
/// where the user has any privileges.
pub async fn set_databases_where_user_has_privileges(
pub async fn append_databases_where_user_has_privileges(
db_user: &mut DatabaseUser,
connection: &mut MySqlConnection,
) -> Result<(), sqlx::Error> {
) {
let database_list = sqlx::query(
formatdoc!(
r"
r#"
SELECT `Db` AS `database`
FROM `db`
WHERE `User` = ? AND ({})
",
"#,
DATABASE_PRIVILEGE_FIELDS
.iter()
.map(|field| format!("`{field}` = 'Y'"))
.map(|field| format!("`{}` = 'Y'", field))
.join(" OR "),
)
.as_str(),
@@ -576,11 +533,11 @@ pub async fn set_databases_where_user_has_privileges(
);
}
db_user.databases = database_list.and_then(|rows| {
rows.into_iter()
.map(|row| try_get_with_binary_fallback(&row, "database"))
.collect::<Result<Vec<String>, sqlx::Error>>()
})?;
Ok(())
db_user.databases = database_list
.map(|rows| {
rows.into_iter()
.map(|row| try_get_with_binary_fallback(&row, "database").unwrap())
.collect()
})
.unwrap_or_default();
}

View File

@@ -17,13 +17,9 @@ use tokio::{
};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use crate::{
core::protocol::request_validation::GroupDenylist,
server::{
authorization::read_and_parse_group_denylist,
config::{MysqlConfig, ServerConfig},
session_handler::session_handler,
},
use crate::server::{
config::{MysqlConfig, ServerConfig},
session_handler::session_handler,
};
#[derive(Clone, Debug)]
@@ -40,7 +36,6 @@ pub struct ReloadEvent;
pub struct Supervisor {
config_path: PathBuf,
config: Arc<Mutex<ServerConfig>>,
group_deny_list: Arc<RwLock<GroupDenylist>>,
systemd_mode: bool,
shutdown_cancel_token: CancellationToken,
@@ -71,39 +66,20 @@ impl Supervisor {
let config = ServerConfig::read_config_from_path(&config_path)
.context("Failed to read server configuration")?;
let group_deny_list = if let Some(denylist_path) = &config.authorization.group_denylist_file
{
let denylist = read_and_parse_group_denylist(denylist_path)
.context("Failed to read group denylist file")?;
tracing::debug!(
"Loaded group denylist with {} entries from {:?}",
denylist.len(),
denylist_path
);
Arc::new(RwLock::new(denylist))
} else {
tracing::debug!("No group denylist file specified, proceeding without a denylist");
Arc::new(RwLock::new(GroupDenylist::new()))
};
let mut watchdog_duration = None;
let mut watchdog_micro_seconds = 0;
#[cfg(target_os = "linux")]
let watchdog_task =
if systemd_mode && sd_notify::watchdog_enabled(true, &mut watchdog_micro_seconds) {
let watchdog_duration_ = Duration::from_micros(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),
);
watchdog_duration = Some(watchdog_duration_);
Some(spawn_watchdog_task(watchdog_duration_))
Some(spawn_watchdog_task(watchdog_duration.unwrap()))
} else {
tracing::debug!("Systemd watchdog not enabled, skipping watchdog thread");
None
};
#[cfg(not(target_os = "linux"))]
let watchdog_task = None;
let db_connection_pool =
Arc::new(RwLock::new(create_db_connection_pool(&config.mysql).await?));
@@ -126,34 +102,19 @@ impl Supervisor {
let task_tracker = TaskTracker::new();
#[cfg(target_os = "linux")]
let status_notifier_task = if systemd_mode {
Some(spawn_status_notifier_task(task_tracker.clone()))
} else {
None
};
#[cfg(not(target_os = "linux"))]
let status_notifier_task = None;
let (tx, rx) = broadcast::channel(1);
// TODO: try to detech systemd socket before using the provided socket path
#[cfg(target_os = "linux")]
let listener = Arc::new(RwLock::new(match config.socket_path {
Some(ref path) => create_unix_listener_with_socket_path(path.clone()).await?,
None => create_unix_listener_with_systemd_socket().await?,
}));
#[cfg(not(target_os = "linux"))]
let listener = Arc::new(RwLock::new(
create_unix_listener_with_socket_path(
config
.socket_path
.as_ref()
.ok_or(anyhow!("Socket path must be set"))?
.clone(),
)
.await?,
));
let (reload_tx, reload_rx) = broadcast::channel(1);
let shutdown_cancel_token = CancellationToken::new();
@@ -169,14 +130,12 @@ impl Supervisor {
db_connection_pool.clone(),
rx,
db_is_mariadb.clone(),
group_deny_list.clone(),
))
};
Ok(Self {
config_path,
config: Arc::new(Mutex::new(config)),
group_deny_list,
systemd_mode,
reload_message_receiver: reload_rx,
shutdown_cancel_token,
@@ -219,24 +178,6 @@ impl Supervisor {
.context("Failed to read server configuration")?;
let mut config = self.config.clone().lock_owned().await;
*config = new_config;
let group_deny_list = if let Some(denylist_path) = &config.authorization.group_denylist_file
{
let denylist = read_and_parse_group_denylist(denylist_path)
.context("Failed to read group denylist file")?;
tracing::debug!(
"Loaded group denylist with {} entries from {:?}",
denylist.len(),
denylist_path
);
denylist
} else {
tracing::debug!("No group denylist file specified, proceeding without a denylist");
GroupDenylist::new()
};
let mut group_deny_list_lock = self.group_deny_list.write().await;
*group_deny_list_lock = group_deny_list;
Ok(())
}
@@ -270,28 +211,16 @@ impl Supervisor {
// first. Make sure to handle that appropriately to avoid a deadlock.
async fn reload_listener(&self) -> anyhow::Result<()> {
let config = self.config.lock().await;
#[cfg(target_os = "linux")]
let new_listener = match config.socket_path {
Some(ref path) => create_unix_listener_with_socket_path(path.clone()).await?,
None => create_unix_listener_with_systemd_socket().await?,
};
#[cfg(not(target_os = "linux"))]
let new_listener = create_unix_listener_with_socket_path(
config
.socket_path
.as_ref()
.ok_or(anyhow!("Socket path must be set"))?
.clone(),
)
.await?;
let mut listener = self.listener.write().await;
*listener = new_listener;
Ok(())
}
pub async fn reload(&self) -> anyhow::Result<()> {
#[cfg(target_os = "linux")]
sd_notify::notify(false, &[sd_notify::NotifyState::Reloading])?;
let previous_config = self.config.lock().await.clone();
@@ -328,14 +257,12 @@ impl Supervisor {
self.resume_receiving_new_connections()?;
}
#[cfg(target_os = "linux")]
sd_notify::notify(false, &[sd_notify::NotifyState::Ready])?;
Ok(())
}
pub async fn shutdown(&self) -> anyhow::Result<()> {
#[cfg(target_os = "linux")]
sd_notify::notify(false, &[sd_notify::NotifyState::Stopping])?;
tracing::debug!("Stop accepting new connections");
@@ -384,7 +311,7 @@ impl Supervisor {
}
}
() = self.shutdown_cancel_token.cancelled() => {
_ = self.shutdown_cancel_token.cancelled() => {
tracing::info!("Shutting down server");
self.shutdown().await?;
break;
@@ -396,7 +323,6 @@ impl Supervisor {
}
}
#[cfg(target_os = "linux")]
fn spawn_watchdog_task(duration: Duration) -> JoinHandle<()> {
tokio::spawn(async move {
let mut interval = interval(duration.div_f32(2.0));
@@ -413,7 +339,6 @@ fn spawn_watchdog_task(duration: Duration) -> JoinHandle<()> {
})
}
#[cfg(target_os = "linux")]
fn spawn_status_notifier_task(task_tracker: TaskTracker) -> JoinHandle<()> {
const STATUS_UPDATE_INTERVAL_SECS: Duration = Duration::from_secs(1);
@@ -424,7 +349,7 @@ fn spawn_status_notifier_task(task_tracker: TaskTracker) -> JoinHandle<()> {
let count = task_tracker.len();
let message = if count > 0 {
format!("Handling {count} connections")
format!("Handling {} connections", count)
} else {
"Waiting for connections".to_string()
};
@@ -450,7 +375,7 @@ async fn create_unix_listener_with_socket_path(
tracing::info!("Listening on socket {:?}", socket_path);
match fs::remove_file(socket_path.as_path()) {
Ok(()) => {}
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
@@ -460,14 +385,13 @@ async fn create_unix_listener_with_socket_path(
Ok(listener)
}
#[cfg(target_os = "linux")]
async fn create_unix_listener_with_systemd_socket() -> anyhow::Result<TokioUnixListener> {
let fd = sd_notify::listen_fds()
.context("Failed to get file descriptors from systemd")?
.next()
.context("No file descriptors received from systemd")?;
debug_assert!(fd == 3, "Unexpected file descriptor from systemd: {fd}");
debug_assert!(fd == 3, "Unexpected file descriptor from systemd: {}", fd);
tracing::debug!(
"Received file descriptor from systemd with id: '{}', assuming socket",
@@ -543,9 +467,7 @@ async fn listener_task(
db_pool: Arc<RwLock<MySqlPool>>,
mut supervisor_message_receiver: broadcast::Receiver<SupervisorMessage>,
db_is_mariadb: Arc<RwLock<bool>>,
group_denylist: Arc<RwLock<GroupDenylist>>,
) -> anyhow::Result<()> {
#[cfg(target_os = "linux")]
sd_notify::notify(false, &[sd_notify::NotifyState::Ready])?;
loop {
@@ -581,14 +503,8 @@ async fn listener_task(
let db_pool_clone = db_pool.clone();
let db_is_mariadb_clone = *db_is_mariadb.read().await;
let group_denylist_arc_clone = group_denylist.clone();
task_tracker.spawn(async move {
match session_handler(
conn,
db_pool_clone,
db_is_mariadb_clone,
&*group_denylist_arc_clone.read().await,
).await {
match session_handler(conn, db_pool_clone, db_is_mariadb_clone).await {
Ok(()) => {}
Err(e) => {
tracing::error!("Failed to run server: {}", e);