167 Commits

Author SHA1 Message Date
77ad080f83 passwd-user: allow clearing, allow setting expiry 2025-12-23 15:14:13 +09:00
6cbf719cfb flake.lock: bump, Cargo.{toml,lock}: update inputs
All checks were successful
Build and test / check-license (push) Successful in 1m46s
Build and test / test (push) Successful in 3m40s
Build and test / docs (push) Successful in 6m40s
Build and test / check (push) Successful in 2m38s
Build and test / build (push) Successful in 3m14s
2025-12-23 14:25:28 +09:00
4c3677d6d3 clippy pedantic fix + get rid of a few unwraps
All checks were successful
Build and test / docs (push) Successful in 7m1s
Build and test / check-license (push) Successful in 57s
Build and test / check (push) Successful in 2m46s
Build and test / build (push) Successful in 3m12s
Build and test / test (push) Successful in 3m25s
2025-12-23 14:12:39 +09:00
c866400b4a server/session_handler: remove response clone for tracing
All checks were successful
Build and test / check (push) Successful in 1m51s
Build and test / build (push) Successful in 3m41s
Build and test / test (push) Successful in 3m49s
Build and test / docs (push) Successful in 5m42s
Build and test / check-license (push) Successful in 1m14s
2025-12-23 12:35:09 +09:00
c75fbebd31 core/protocol: subtype ModifyDatabasePrivilegesError validation errors 2025-12-23 12:32:56 +09:00
b03338bdf6 server: fix doccomment link
Some checks failed
Build and test / docs (push) Has been cancelled
Build and test / check (push) Successful in 1m52s
Build and test / check-license (push) Successful in 1m2s
Build and test / build (push) Successful in 3m8s
Build and test / test (push) Successful in 3m28s
2025-12-23 12:28:07 +09:00
42bd28fe36 client: fix compile-time conditional imports 2025-12-23 12:28:06 +09:00
ba8ffd0e78 client: embed dependency list in long --version output
Some checks failed
Build and test / check-license (push) Successful in 1m16s
Build and test / check (push) Successful in 2m47s
Build and test / build (push) Failing after 2m48s
Build and test / test (push) Successful in 4m6s
Build and test / docs (push) Failing after 7m11s
2025-12-23 11:59:26 +09:00
e28da90e00 client: remove --config flag when not compile with suid/sgid support 2025-12-23 11:20:05 +09:00
82bfead593 Split client and server into separate binaries 2025-12-23 11:14:24 +09:00
a15eac607d core/privileges/editor: fix table formatting
All checks were successful
Build and test / check (push) Successful in 1m45s
Build and test / check-license (push) Successful in 2m15s
Build and test / build (push) Successful in 2m55s
Build and test / test (push) Successful in 3m28s
Build and test / docs (push) Successful in 7m7s
2025-12-23 04:51:54 +09:00
eefd9e1a98 Cargo.toml: add pretty assertions 2025-12-23 04:51:37 +09:00
6de83d122e README: replace list of admin actions with examples
All checks were successful
Build and test / check (push) Successful in 2m5s
Build and test / check-license (push) Successful in 1m13s
Build and test / build (push) Successful in 3m42s
Build and test / test (push) Successful in 3m26s
Build and test / docs (push) Successful in 7m18s
2025-12-18 18:33:41 +09:00
186adefc22 Improve documentation
All checks were successful
Build and test / build (push) Successful in 2m41s
Build and test / check (push) Successful in 1m46s
Build and test / check-license (push) Successful in 58s
Build and test / test (push) Successful in 3m7s
Build and test / docs (push) Successful in 5m40s
2025-12-18 02:08:20 +09:00
1f78ca0e25 Don't unwrap without message on failure to start SUID/SGID tokio
All checks were successful
Build and test / check-license (push) Successful in 55s
Build and test / check (push) Successful in 2m39s
Build and test / build (push) Successful in 2m54s
Build and test / test (push) Successful in 3m5s
Build and test / docs (push) Successful in 6m29s
2025-12-17 05:46:05 +09:00
340428c158 client: bold --help ascii banner
All checks were successful
Build and test / check (push) Successful in 1m43s
Build and test / check-license (push) Successful in 1m47s
Build and test / build (push) Successful in 2m53s
Build and test / test (push) Successful in 3m5s
Build and test / docs (push) Successful in 6m43s
2025-12-16 20:28:24 +09:00
dfe20826c1 Add misc. doccomments
All checks were successful
Build and test / check-license (push) Successful in 1m42s
Build and test / check (push) Successful in 1m50s
Build and test / build (push) Successful in 2m38s
Build and test / test (push) Successful in 4m44s
Build and test / docs (push) Successful in 6m1s
2025-12-16 19:36:17 +09:00
97908ce887 core/protocol: set request/response max size 2025-12-16 19:36:17 +09:00
10ef171c91 client: print errors and warnings to stderr
All checks were successful
Build and test / check (push) Successful in 1m55s
Build and test / build (push) Successful in 2m37s
Build and test / check-license (push) Successful in 1m3s
Build and test / test (push) Successful in 3m6s
Build and test / docs (push) Successful in 5m56s
2025-12-16 17:21:58 +09:00
043a1a7a7a move example-config.toml to assets, sync with debian variant
All checks were successful
Build and test / check-license (push) Successful in 59s
Build and test / check (push) Successful in 1m42s
Build and test / build (push) Successful in 3m10s
Build and test / test (push) Successful in 3m26s
Build and test / docs (push) Successful in 5m16s
2025-12-16 16:54:55 +09:00
072bf6a090 assets/debian/group_denylist: remove groups postgres,sync
Some checks failed
Build and test / check (push) Successful in 1m55s
Build and test / test (push) Has been cancelled
Build and test / docs (push) Has been cancelled
Build and test / check-license (push) Has been cancelled
Build and test / build (push) Has been cancelled
2025-12-16 16:52:44 +09:00
21bb5b62ff create-deb.sh: move to scripts, add download-and-upload-debs.sh
Some checks failed
Build and test / check (push) Successful in 1m42s
Build and test / build (push) Successful in 2m55s
Build and test / check-license (push) Successful in 1m30s
Build and test / test (push) Successful in 3m5s
Build and test / docs (push) Has been cancelled
2025-12-16 16:41:59 +09:00
62b2b30f94 server: don't fail on invalid entries in denylist
Some checks failed
Build and test / check-license (push) Successful in 1m7s
Build and test / check (push) Successful in 2m37s
Build and test / build (push) Successful in 2m38s
Build and test / docs (push) Has been cancelled
Build and test / test (push) Has been cancelled
2025-12-16 16:37:27 +09:00
05b5b5dac0 client: add prefix completer for create-{db,user}
All checks were successful
Build and test / check-license (push) Successful in 54s
Build and test / check (push) Successful in 2m23s
Build and test / build (push) Successful in 2m55s
Build and test / test (push) Successful in 3m7s
Build and test / docs (push) Successful in 6m31s
2025-12-16 15:27:36 +09:00
d814008006 docs/installation: document systemd-less creds
All checks were successful
Build and test / check-license (push) Successful in 1m25s
Build and test / check (push) Successful in 1m40s
Build and test / build (push) Successful in 2m54s
Build and test / test (push) Successful in 4m6s
Build and test / docs (push) Successful in 5m20s
2025-12-16 15:03:10 +09:00
67c8e3330c docs/compiling: init
Some checks failed
Build and test / check-license (push) Successful in 1m30s
Build and test / check (push) Successful in 1m40s
Build and test / build (push) Successful in 2m51s
Build and test / docs (push) Has been cancelled
Build and test / test (push) Has been cancelled
2025-12-16 14:58:19 +09:00
57ac26b120 client: display show-db output with human readable sizes
All checks were successful
Build and test / check-license (push) Successful in 55s
Build and test / check (push) Successful in 1m55s
Build and test / build (push) Successful in 3m25s
Build and test / test (push) Successful in 3m4s
Build and test / docs (push) Successful in 5m54s
2025-12-16 14:36:53 +09:00
256c1d1176 client: add example for display subcommand help
All checks were successful
Build and test / check-license (push) Successful in 59s
Build and test / build (push) Successful in 2m37s
Build and test / check (push) Successful in 2m46s
Build and test / test (push) Successful in 3m31s
Build and test / docs (push) Successful in 5m15s
2025-12-16 14:28:30 +09:00
15c8d82373 docs/installation: document denylists
All checks were successful
Build and test / check-license (push) Successful in 53s
Build and test / check (push) Successful in 2m21s
Build and test / build (push) Successful in 2m52s
Build and test / test (push) Successful in 3m5s
Build and test / docs (push) Successful in 6m38s
2025-12-16 14:16:45 +09:00
e6bcac8079 docs/installation: reword not to suggest adding plaintext password to config file
Some checks failed
Build and test / check-license (push) Successful in 1m5s
Build and test / check (push) Successful in 1m48s
Build and test / build (push) Successful in 3m16s
Build and test / test (push) Successful in 3m26s
Build and test / docs (push) Has been cancelled
2025-12-16 14:10:57 +09:00
146421dd79 client/edit-privs: report actual errors instead of non-existence
Some checks failed
Build and test / check-license (push) Successful in 54s
Build and test / check (push) Successful in 1m55s
Build and test / build (push) Successful in 3m26s
Build and test / docs (push) Has been cancelled
Build and test / test (push) Has been cancelled
2025-12-16 14:07:28 +09:00
795c6d3c9d client: exit with error on errors
All checks were successful
Build and test / check (push) Successful in 1m44s
Build and test / check-license (push) Successful in 2m0s
Build and test / build (push) Successful in 2m55s
Build and test / test (push) Successful in 3m4s
Build and test / docs (push) Successful in 6m54s
2025-12-16 13:46:17 +09:00
40ce292083 edit-privs: move examples to different section
Some checks failed
Build and test / check (push) Successful in 1m53s
Build and test / check-license (push) Successful in 2m13s
Build and test / build (push) Successful in 2m37s
Build and test / test (push) Successful in 3m25s
Build and test / docs (push) Has been cancelled
2025-12-16 13:39:12 +09:00
ca6ae43bbc Add a few usage examples to --help 2025-12-16 13:32:14 +09:00
996c9e50c7 docs/installation: limit apt repo architecture 2025-12-16 13:21:04 +09:00
d0e226bff3 assets/systemd: add service dependency on mysql/mariadb
All checks were successful
Build and test / check-license (push) Successful in 1m0s
Build and test / check (push) Successful in 1m43s
Build and test / build (push) Successful in 4m20s
Build and test / test (push) Successful in 3m27s
Build and test / docs (push) Successful in 5m11s
2025-12-16 13:04:49 +09:00
cc1d8b0cf1 Fix protocol error struct name for List(All)PrivilegesError
Some checks failed
Build and test / check (push) Successful in 2m42s
Build and test / check-license (push) Successful in 56s
Build and test / docs (push) Successful in 5m19s
Build and test / test (push) Failing after 11m43s
Build and test / build (push) Failing after 20m23s
2025-12-16 12:41:19 +09:00
8b4d549e18 Implement denylists
All checks were successful
Build and test / check-license (push) Successful in 1m38s
Build and test / check (push) Successful in 1m51s
Build and test / build (push) Successful in 2m40s
Build and test / test (push) Successful in 4m25s
Build and test / docs (push) Successful in 6m1s
2025-12-16 12:21:35 +09:00
45cefb8af4 client/edit-privs: use a more human-friendly interface
All checks were successful
Build and test / check-license (push) Successful in 1m39s
Build and test / check (push) Successful in 1m53s
Build and test / build (push) Successful in 2m43s
Build and test / test (push) Successful in 4m23s
Build and test / docs (push) Successful in 6m3s
2025-12-16 11:00:59 +09:00
891963f4bc Add ListValidNamePrefixes command to protocol
Some checks failed
Build and test / check (push) Has been cancelled
Build and test / test (push) Has been cancelled
Build and test / docs (push) Has been cancelled
Build and test / build (push) Has been cancelled
Build and test / check-license (push) Has been cancelled
2025-12-16 10:13:28 +09:00
912f0e8971 server: hide systemd stuff behind compiletime cond
All checks were successful
Build and test / check (push) Successful in 1m49s
Build and test / check-license (push) Successful in 1m49s
Build and test / build (push) Successful in 2m36s
Build and test / test (push) Successful in 3m25s
Build and test / docs (push) Successful in 6m35s
2025-12-15 17:02:53 +09:00
73f5cd9fd4 .gitea/workflows: actions-rs/toolchain -> dtolnay/rust-toolchain
All checks were successful
Build and test / check-license (push) Successful in 59s
Build and test / check (push) Successful in 1m42s
Build and test / build (push) Successful in 3m2s
Build and test / test (push) Successful in 3m27s
Build and test / docs (push) Successful in 5m7s
2025-12-15 16:22:15 +09:00
caf16c7a21 .gitea/workflows: use cargo-binstall everywhere, disable telemetry
Some checks failed
Build and test / check (push) Successful in 1m44s
Build and test / check-license (push) Successful in 1m48s
Build and test / build (push) Successful in 2m52s
Build and test / test (push) Successful in 3m1s
Build and test / docs (push) Has been cancelled
2025-12-15 16:15:06 +09:00
aac7315fd9 .gitea/workflows: name artifact zips with commit hash
All checks were successful
Build and test / check (push) Successful in 1m40s
Build and test / build (push) Successful in 2m52s
Build and test / test (push) Successful in 3m4s
Build and test / check-license (push) Successful in 6m18s
Build and test / docs (push) Successful in 5m59s
2025-12-15 16:06:22 +09:00
aa96587a35 assets/debian/config.toml: leave link to installation instructions
Some checks failed
Build and test / build (push) Successful in 2m37s
Build and test / check (push) Successful in 2m51s
Build and test / docs (push) Has been cancelled
Build and test / test (push) Has been cancelled
Build and test / check-license (push) Has been cancelled
2025-12-15 16:00:52 +09:00
15ebc5df5b Cargo.toml: (deb) install documentation 2025-12-15 16:00:27 +09:00
3f014f073e Rename AuthorizationError to ValidationError, rename suberrors
All checks were successful
Build and test / build (push) Successful in 2m55s
Build and test / check (push) Successful in 3m4s
Build and test / test (push) Successful in 4m33s
Build and test / check-license (push) Successful in 5m35s
Build and test / docs (push) Successful in 8m23s
2025-12-15 14:56:53 +09:00
5f03b55eb5 Move name validation code to core 2025-12-15 14:45:45 +09:00
bf6027f507 core/protocol: use thiserror, use common authorization error struct 2025-12-15 14:25:22 +09:00
1991e7bfd8 Show more data on show-db
All checks were successful
Build and test / build (push) Successful in 2m42s
Build and test / check (push) Successful in 2m42s
Build and test / check-license (push) Successful in 5m31s
Build and test / test (push) Successful in 3m6s
Build and test / docs (push) Successful in 6m58s
2025-12-15 11:44:18 +09:00
1cf9273fcd Add rust profile release-lto
All checks were successful
Build and test / build (push) Successful in 2m39s
Build and test / check (push) Successful in 2m45s
Build and test / check-license (push) Successful in 5m32s
Build and test / test (push) Successful in 3m8s
Build and test / docs (push) Successful in 7m5s
2025-12-15 10:08:58 +09:00
47a4bccd2c flake.lock: bump, Cargo.{toml,lock}: update inputs
All checks were successful
Build and test / check (push) Successful in 1m41s
Build and test / build (push) Successful in 3m27s
Build and test / test (push) Successful in 3m5s
Build and test / check-license (push) Successful in 6m7s
Build and test / docs (push) Successful in 6m15s
2025-12-15 09:07:23 +09:00
8811a41980 docs: split SUID/SGID installation section into its own document
Some checks failed
Build and test / test (push) Has been cancelled
Build and test / docs (push) Has been cancelled
Build and test / build (push) Has been cancelled
Build and test / check (push) Has been cancelled
Build and test / check-license (push) Has been cancelled
2025-12-15 09:05:18 +09:00
6e914dec34 Misc. clap improvements, , more accurate value names, ... 2025-12-15 00:46:56 +09:00
7b79f7b163 client/show-privs: allow showing single char hints in table output
All checks were successful
Build and test / check (push) Successful in 1m44s
Build and test / build (push) Successful in 3m24s
Build and test / test (push) Successful in 3m28s
Build and test / check-license (push) Successful in 4m54s
Build and test / docs (push) Successful in 6m43s
2025-12-14 16:01:51 +09:00
56596835fa docs/installation: fix wording for apt repo, reduce number of sudo commands
All checks were successful
Build and test / check (push) Successful in 1m52s
Build and test / build (push) Successful in 3m5s
Build and test / test (push) Successful in 3m26s
Build and test / check-license (push) Successful in 6m9s
Build and test / docs (push) Successful in 5m22s
2025-12-14 15:42:10 +09:00
3bc3f35294 docs/installation: move password into an envvar
Some checks failed
Build and test / check (push) Successful in 2m29s
Build and test / build (push) Successful in 3m31s
Build and test / test (push) Successful in 3m27s
Build and test / docs (push) Has been cancelled
Build and test / check-license (push) Has been cancelled
2025-12-14 15:34:21 +09:00
919fd326ba server: fix remaining broken mysql queries
All checks were successful
Build and test / check (push) Successful in 2m48s
Build and test / build (push) Successful in 3m30s
Build and test / check-license (push) Successful in 4m52s
Build and test / test (push) Successful in 4m46s
Build and test / docs (push) Successful in 6m7s
2025-12-14 15:22:37 +09:00
920544ef3a client/edit-privs: return better parsing errors
All checks were successful
Build and test / check (push) Successful in 1m43s
Build and test / build (push) Successful in 3m27s
Build and test / check-license (push) Successful in 4m54s
Build and test / test (push) Successful in 3m28s
Build and test / docs (push) Successful in 5m11s
2025-12-14 04:01:48 +09:00
4c82da390f server: determine sql server variant, fix lock-user,unlock-user
All checks were successful
Build and test / check (push) Successful in 1m54s
Build and test / build (push) Successful in 3m10s
Build and test / test (push) Successful in 3m30s
Build and test / check-license (push) Successful in 7m25s
Build and test / docs (push) Successful in 5m26s
2025-12-14 03:30:40 +09:00
dc7b72efe5 flake.nix: add vm for testing non-mariadb mysql 2025-12-14 03:07:14 +09:00
e56c41cee6 {client,server}/edit-privs: check for user existence 2025-12-14 01:58:48 +09:00
bd23cf693d .gitea/workflows: update actions/checkout: v3 -> v6
All checks were successful
Build and test / check (push) Successful in 1m54s
Build and test / build (push) Successful in 4m12s
Build and test / check-license (push) Successful in 4m50s
Build and test / test (push) Successful in 3m27s
Build and test / docs (push) Successful in 6m44s
2025-12-08 18:41:21 +09:00
6c1ae5479e CHANGELOG.md: fix inaccurate description
All checks were successful
Build and test / check (push) Successful in 2m49s
Build and test / build (push) Successful in 3m28s
Build and test / test (push) Successful in 3m24s
Build and test / docs (push) Successful in 4m59s
Build and test / check-license (push) Successful in 6m9s
2025-12-08 18:28:17 +09:00
222941509d core: check suid/sgid dynamically instead of checking file
All checks were successful
Build and test / check (push) Successful in 1m41s
Build and test / test (push) Successful in 3m5s
Build and test / check-license (push) Successful in 5m56s
Build and test / docs (push) Successful in 5m29s
Build and test / build (push) Successful in 3m9s
2025-12-04 20:29:44 +09:00
eeef8bd546 docs/installation: add a note on minimum required versions
All checks were successful
Build and test / check (push) Successful in 1m54s
Build and test / build (push) Successful in 3m54s
Build and test / check-license (push) Successful in 4m50s
Build and test / test (push) Successful in 3m39s
Build and test / docs (push) Successful in 6m45s
2025-12-04 19:50:26 +09:00
a036fd03c9 Cargo.toml: (deb) generate maintainer scripts
All checks were successful
Build and test / check (push) Successful in 1m43s
Build and test / build (push) Successful in 3m58s
Build and test / test (push) Successful in 3m5s
Build and test / check-license (push) Successful in 5m29s
Build and test / docs (push) Successful in 6m43s
2025-12-04 17:10:36 +09:00
bf66055f7f .gitea/workflows: matrix builds, build on ubuntu
All checks were successful
Build and test / check (push) Successful in 2m39s
Build and test / build (push) Successful in 3m33s
Build and test / test (push) Successful in 3m10s
Build and test / check-license (push) Successful in 6m3s
Build and test / docs (push) Successful in 5m33s
2025-12-04 16:31:18 +09:00
94619edf73 docs/installation: add instructions for installing apt repo
All checks were successful
Build and test / check (push) Successful in 1m41s
Build and test / build (push) Successful in 3m28s
Build and test / test (push) Successful in 3m7s
Build and test / check-license (push) Successful in 6m10s
Build and test / docs (push) Successful in 5m28s
2025-12-04 15:24:35 +09:00
bfa50b4d7e .gitea/workflows: run main pipeline on debian
Some checks failed
Build and test / build (push) Successful in 3m29s
Build and test / check (push) Successful in 2m48s
Build and test / check-license (push) Successful in 4m51s
Build and test / test (push) Successful in 3m41s
Build and test / docs (push) Has been cancelled
2025-12-04 15:09:41 +09:00
9408096391 .gitea/workflows: build for both trixie and bookworm
Some checks failed
Build and test / test (push) Has been cancelled
Build and test / docs (push) Has been cancelled
Build and test / check-license (push) Has been cancelled
Build and test / build (push) Has been cancelled
Build and test / check (push) Has been cancelled
2025-12-04 15:05:58 +09:00
69cb96014b .gitea/workflows: set run-name for all workflows
All checks were successful
Build and test / check (push) Successful in 1m57s
Build and test / build (push) Successful in 3m56s
Build and test / test (push) Successful in 3m30s
Build and test / check-license (push) Successful in 4m50s
Build and test / docs (push) Successful in 6m36s
2025-12-04 14:24:41 +09:00
67ff31f405 .gitea/workflows: fix deb building args
Some checks failed
Build and test / test (push) Has been cancelled
Build and test / docs (push) Has been cancelled
Build and test / build (push) Has been cancelled
Build and test / check-license (push) Has been cancelled
Build and test / check (push) Has been cancelled
2025-12-04 14:22:20 +09:00
a4084e2ecc Cargo.{toml,lock}: bump deps 2025-12-04 14:05:27 +09:00
a6804e01df .gitea/workflows: don't clippy check all features
Some checks failed
Build and test / check (push) Successful in 1m44s
Build and test / docs (push) Has been cancelled
Build and test / test (push) Has been cancelled
Build and test / build (push) Has been cancelled
Build and test / check-license (push) Has been cancelled
2025-12-04 13:51:33 +09:00
162c8cd422 Cargo.toml: (deb) fix metadata
Some checks failed
Build and test / check (push) Failing after 2m39s
Build and test / build (push) Successful in 3m5s
Build and test / check-license (push) Successful in 5m31s
Build and test / test (push) Successful in 4m5s
Build and test / docs (push) Successful in 4m58s
2025-12-04 13:39:31 +09:00
44fde9f780 .gitea/workflows: allow configuring version + revision 2025-12-04 13:39:31 +09:00
7911985410 .gitea/workflows: add commented inputs for package publishing 2025-12-04 13:39:31 +09:00
1e7911023e client: add error subtypes for name and owner validation in json output 2025-12-04 13:39:30 +09:00
f5d3c46e60 client: disable dynamic completions when in suid/sgid mode
Some checks failed
Build and test / check (push) Failing after 1m47s
Build and test / build (push) Successful in 3m48s
Build and test / test (push) Successful in 3m7s
Build and test / check-license (push) Successful in 5m39s
Build and test / docs (push) Successful in 8m9s
2025-12-04 12:06:49 +09:00
b0ae6e563d Add nix support for suid/sgid testing
All checks were successful
Build and test / check (push) Successful in 1m55s
Build and test / build (push) Successful in 4m1s
Build and test / check-license (push) Successful in 4m55s
Build and test / test (push) Successful in 3m26s
Build and test / docs (push) Successful in 6m18s
2025-12-04 11:42:49 +09:00
4c21d083df Cargo.toml: (deb) mark /etc/muscl/config.toml as config file 2025-12-04 09:27:41 +09:00
c5c6236e50 Cargo.toml: (deb) preserve symlinks 2025-12-04 09:27:03 +09:00
a5a5522ad0 Cargo.toml: (deb) install changelog 2025-12-04 09:26:55 +09:00
6194fcef26 Cargo.toml: (deb) let systemd-units install units instead of assets 2025-12-04 09:26:33 +09:00
51a6390aa6 build.rs: use relative symlinks for mysql-admutils aliases 2025-12-04 09:25:33 +09:00
614a756aa7 .gitignore: reorder 2025-12-04 09:24:32 +09:00
f2d404e864 create-deb.sh: don't reinvoke cargo build during cargo deb 2025-12-04 09:24:17 +09:00
271ce66022 CHANGELOG.md: init
All checks were successful
Build and test / check (push) Successful in 1m42s
Build and test / build (push) Successful in 3m27s
Build and test / test (push) Successful in 3m5s
Build and test / check-license (push) Successful in 5m52s
Build and test / docs (push) Successful in 5m19s
2025-12-03 18:38:12 +09:00
acde3a9d5d Cargo.toml: add mysql-admutils symlinks to deb
All checks were successful
Build and test / check (push) Successful in 1m55s
Build and test / build (push) Successful in 3m5s
Build and test / test (push) Successful in 3m29s
Build and test / check-license (push) Successful in 5m47s
Build and test / docs (push) Successful in 4m43s
2025-12-03 17:31:58 +09:00
fbf90a456a mysql-admutils: fix generated completions
All checks were successful
Build and test / check (push) Successful in 2m35s
Build and test / build (push) Successful in 3m29s
Build and test / check-license (push) Successful in 4m51s
Build and test / test (push) Successful in 3m58s
Build and test / docs (push) Successful in 5m31s
There is a bug in clap dynamic completions, where it does not account
for alternative binary names. This hack should fix it for now
2025-12-03 16:53:35 +09:00
0df19654d6 mysql-admutils: expect Ready from server before continuing 2025-12-03 16:53:35 +09:00
5faf0c2f0a nix: ensure argv0 is correct when generating completions 2025-12-03 16:53:35 +09:00
9297afec2f client: add dynamic completions for mysql-admutils commands 2025-12-03 16:53:35 +09:00
829a91705b flake.nix: add jq to test vm packages
All checks were successful
Build and test / check (push) Successful in 1m53s
Build and test / build (push) Successful in 3m4s
Build and test / test (push) Successful in 3m29s
Build and test / check-license (push) Successful in 5m47s
Build and test / docs (push) Successful in 4m45s
2025-12-03 15:49:50 +09:00
afbba78e39 client: add error type field for --json outputs
Some checks failed
Build and test / test (push) Has been cancelled
Build and test / check (push) Has been cancelled
Build and test / docs (push) Has been cancelled
Build and test / build (push) Has been cancelled
Build and test / check-license (push) Has been cancelled
2025-12-03 15:44:18 +09:00
32b70c44c6 client: print only json for show-db/show-user/show-privs --json
All checks were successful
Build and test / check (push) Successful in 1m52s
Build and test / build (push) Successful in 3m4s
Build and test / test (push) Successful in 3m28s
Build and test / check-license (push) Successful in 5m47s
Build and test / docs (push) Successful in 4m45s
The earlier version would print out human readable errors before
printing the json, which was not ideal. This version prints out the
errors inside the json.
2025-12-03 15:28:15 +09:00
6a4a83367e Add --yes flag for drop operations
All checks were successful
Build and test / check (push) Successful in 2m23s
Build and test / build (push) Successful in 3m5s
Build and test / check-license (push) Successful in 5m31s
Build and test / test (push) Successful in 4m0s
Build and test / docs (push) Successful in 4m46s
2025-12-03 14:21:09 +09:00
6aceda6f3d Remove empty file
All checks were successful
Build and test / check (push) Successful in 1m43s
Build and test / build (push) Successful in 3m54s
Build and test / test (push) Successful in 3m5s
Build and test / check-license (push) Successful in 5m29s
Build and test / docs (push) Successful in 6m13s
2025-12-03 13:52:28 +09:00
7bdecf78ff Remove old completion generator logic
All checks were successful
Build and test / check (push) Successful in 1m44s
Build and test / build (push) Successful in 3m23s
Build and test / test (push) Successful in 3m6s
Build and test / check-license (push) Successful in 6m8s
Build and test / docs (push) Successful in 5m32s
2025-12-03 13:07:26 +09:00
3ac90dcb26 Embed extra build time information in --version
All checks were successful
Build and test / check (push) Successful in 2m38s
Build and test / build (push) Successful in 3m30s
Build and test / check-license (push) Successful in 4m49s
Build and test / test (push) Successful in 4m20s
Build and test / docs (push) Successful in 5m40s
2025-12-03 12:32:09 +09:00
7df04ec413 Fix verbosity flag and default logging level
Some checks failed
Build and test / check (push) Failing after 40s
Build and test / build (push) Successful in 3m9s
Build and test / test (push) Successful in 3m24s
Build and test / check-license (push) Successful in 5m48s
Build and test / docs (push) Successful in 5m18s
2025-12-03 11:39:39 +09:00
ed71524e85 create-deb.sh: fix completion generation (again)
All checks were successful
Build and test / check (push) Successful in 1m38s
Build and test / build (push) Successful in 2m47s
Build and test / test (push) Successful in 3m7s
Build and test / check-license (push) Successful in 5m44s
Build and test / docs (push) Successful in 4m42s
2025-12-02 17:25:42 +09:00
54f794acb6 README: split up into docs
Some checks failed
Build and test / check (push) Successful in 1m25s
Build and test / build (push) Successful in 3m38s
Build and test / test (push) Successful in 2m52s
Build and test / check-license (push) Successful in 5m33s
Build and test / docs (push) Has been cancelled
2025-12-02 17:18:54 +09:00
32cbf215a8 .gitea/workflows: rename artifact archive to avoid confusion
All checks were successful
Build and test / check (push) Successful in 1m24s
Build and test / build (push) Successful in 3m36s
Build and test / test (push) Successful in 2m49s
Build and test / check-license (push) Successful in 5m35s
Build and test / docs (push) Successful in 5m56s
2025-12-02 15:48:57 +09:00
25c4c6f3e9 server: read mysql password from file
All checks were successful
Build and test / check (push) Successful in 1m33s
Build and test / build (push) Successful in 2m46s
Build and test / test (push) Successful in 3m11s
Build and test / check-license (push) Successful in 5m50s
Build and test / docs (push) Successful in 4m39s
2025-12-02 15:40:54 +09:00
7e1383609d README: fix section about systemd creds 2025-12-02 15:40:24 +09:00
da4a256124 create-deb.sh: fix completion generation
All checks were successful
Build and test / check (push) Successful in 1m26s
Build and test / build (push) Successful in 3m31s
Build and test / test (push) Successful in 2m50s
Build and test / check-license (push) Successful in 5m29s
Build and test / docs (push) Successful in 5m59s
2025-12-02 15:19:35 +09:00
fae1c2c1c8 Add default config for debian, use systemd-creds by default 2025-12-02 15:19:35 +09:00
999d6cbc71 example-config.toml: fix typo 2025-12-02 15:05:30 +09:00
cd58d4507e README: add instructions for creating mysql admin user 2025-12-02 13:50:04 +09:00
9f9e1ce504 assets/systemd: remove landlock instructions from seccomp filter by default 2025-12-02 13:49:36 +09:00
3e46d6f541 Add ASCII banner + regards trailer to long help
All checks were successful
Build and test / check (push) Successful in 1m37s
Build and test / build (push) Successful in 2m45s
Build and test / test (push) Successful in 3m8s
Build and test / check-license (push) Successful in 5m56s
Build and test / docs (push) Successful in 4m35s
2025-12-01 20:31:44 +09:00
526819d374 .gitea/workflows: fix deb name
All checks were successful
Build and test / check (push) Successful in 1m36s
Build and test / build (push) Successful in 2m46s
Build and test / test (push) Successful in 3m7s
Build and test / check-license (push) Successful in 5m58s
Build and test / docs (push) Successful in 4m39s
2025-12-01 17:46:31 +09:00
f348e67622 Add dynamic completion for users and databases
All checks were successful
Build and test / check (push) Successful in 1m35s
Build and test / build (push) Successful in 2m46s
Build and test / test (push) Successful in 3m10s
Build and test / check-license (push) Successful in 6m12s
Build and test / docs (push) Successful in 4m39s
2025-12-01 17:26:17 +09:00
cb3f3f3e1d Add value hints for args
All checks were successful
Build and test / check (push) Successful in 1m33s
Build and test / build (push) Successful in 2m44s
Build and test / test (push) Successful in 2m50s
Build and test / check-license (push) Successful in 5m28s
Build and test / docs (push) Successful in 4m40s
2025-12-01 15:46:38 +09:00
1af9748530 client: add --fail flag for show-* commands 2025-12-01 15:19:42 +09:00
e05a72894f client: take password from stdin for passwd-user 2025-12-01 14:32:35 +09:00
16db753f3f client: add missing doccomments to command args 2025-12-01 14:26:47 +09:00
d7b8167fd3 README: misc updates 2025-12-01 14:09:44 +09:00
67b820c1ad README: add note about log filtering 2025-12-01 13:47:07 +09:00
e5627b2649 server: add tracing span to user session 2025-12-01 13:45:39 +09:00
ff858de178 server: misc changes for traces 2025-12-01 13:26:44 +09:00
025df3490c server: add prelude print 2025-12-01 12:59:53 +09:00
79f2a2b497 Add misc command help messages 2025-12-01 12:59:53 +09:00
a6db254c20 server: disable landlock in systemd daemon
This ensures that reloads work correctly
2025-12-01 12:59:53 +09:00
152c3ddbcc Add landlock rulesets 2025-12-01 12:59:53 +09:00
2472936857 Switch from log to tracing 2025-11-30 20:42:10 +09:00
7f5c3310db flake.nix: fix coverage app 2025-11-30 15:30:00 +09:00
fd3fd30df9 module.nix: load mysql.passwordFile via LoadCredential 2025-11-30 04:08:02 +09:00
0e10e6dde9 assets/system: use shorter description 2025-11-30 04:08:02 +09:00
de57860395 module.nix: apply chroot 2025-11-30 04:08:01 +09:00
1fe08b59a3 server: implement graceful shutdown and reloads 2025-11-30 04:08:01 +09:00
4a6e49110a server: remove config args, store config path in supervisor 2025-11-29 20:38:32 +09:00
b4db2daac7 server: note implementation overview for graceful restarts/shutdown 2025-11-29 19:58:23 +09:00
865b24884e Add command check-auth 2025-11-29 19:37:54 +09:00
03ddf0ac8a core: move DbOrUser to types, wrap item name in struct 2025-11-29 19:34:14 +09:00
877f45c103 nix: vendor systemd units 2025-11-29 15:20:15 +09:00
fe87f72b00 core: derive Debug + Clone for UnixUser 2025-11-29 00:56:43 +09:00
dac1c147dd server: fix connection counter 2025-11-29 00:56:43 +09:00
bc4f2bc71c server: move peer cred checking to connection task
This commit also gets rid of the database connection closing helper
2025-11-29 00:56:43 +09:00
7ce81ddc55 server: log connection pool options 2025-11-29 00:54:31 +09:00
898a5e6ab0 server: set nonblocking option on systemd socket 2025-11-29 00:54:31 +09:00
9138613267 server: remove dead (moved) code 2025-11-29 00:54:30 +09:00
3eac8ffd94 server: set minimum number of tokio worker threads 2025-11-29 00:54:30 +09:00
e51e8fe408 flake.nix: fix loglevel setting, module.nix: use default loglevel 'info'
All checks were successful
Build and test / check (push) Successful in 2m6s
Build and test / build (push) Successful in 2m45s
Build and test / check-license (push) Successful in 5m30s
Build and test / test (push) Successful in 3m39s
Build and test / docs (push) Successful in 5m2s
2025-11-26 19:11:06 +09:00
fa1d27e09c server: make use of database connection pool 2025-11-26 19:11:06 +09:00
20331a4429 server: refactor server logic into supervisor + session handler 2025-11-26 19:11:05 +09:00
f5ff50365f client: fix doccomment for edit-privs
All checks were successful
Build and test / check (push) Successful in 1m25s
Build and test / build (push) Successful in 3m0s
Build and test / test (push) Successful in 2m47s
Build and test / check-license (push) Successful in 5m44s
Build and test / docs (push) Successful in 5m54s
2025-11-26 14:10:42 +09:00
7fa6f6aafe server: add connection counter
All checks were successful
Build and test / check (push) Successful in 2m16s
Build and test / build (push) Successful in 2m43s
Build and test / check-license (push) Successful in 5m41s
Build and test / test (push) Successful in 3m37s
Build and test / docs (push) Successful in 5m4s
2025-11-26 13:51:07 +09:00
77667e546c README: improve joke
It somehow got worse
2025-11-26 13:51:03 +09:00
f9c5f1347e client: rename <verb>-db-privs -> <verb>-privs 2025-11-26 13:50:43 +09:00
a4acfe91af Rename project to muscl 2025-11-26 13:50:06 +09:00
805c2d11ff core/protocol: split commands into separate files
All checks were successful
Build and test / check (push) Successful in 2m8s
Build and test / build (push) Successful in 3m3s
Build and test / check-license (push) Successful in 4m58s
Build and test / test (push) Successful in 3m47s
Build and test / docs (push) Successful in 5m47s
2025-11-26 02:51:23 +09:00
c9815fe7de Remove tui stub
This should just be added later when (if) it is ever implemented
2025-11-26 02:08:31 +09:00
1571f6e2c7 core: split mysql user/db into separate types module 2025-11-26 02:03:18 +09:00
9e39401049 client: split commands into separate files 2025-11-26 01:51:08 +09:00
4fb60f8563 client: rename and merge user/db command modules 2025-11-26 01:25:47 +09:00
39fa228d1c flake.nix: build with crane for vm
All checks were successful
Build and test / check (push) Successful in 1m30s
Build and test / build (push) Successful in 3m56s
Build and test / test (push) Successful in 2m55s
Build and test / check-license (push) Successful in 5m32s
Build and test / docs (push) Successful in 6m47s
2025-11-26 01:10:01 +09:00
412e5c1604 Cargo.toml: bump deps
All checks were successful
Build and test / check (push) Successful in 1m42s
Build and test / build (push) Successful in 2m47s
Build and test / test (push) Successful in 3m17s
Build and test / check-license (push) Successful in 6m4s
Build and test / docs (push) Successful in 5m36s
2025-11-25 19:38:05 +09:00
d350438176 cargo-deny: fix license list 2025-11-25 19:38:05 +09:00
d1de7b71bb .gitea/workflows: check licenses 2025-11-25 19:38:04 +09:00
8b893db898 .gitea/workflows: test and push coverage 2025-11-25 19:38:04 +09:00
03a761a0ff Refactor privilege handling
All checks were successful
Build / check (push) Successful in 2m41s
Build / build (push) Successful in 3m5s
Build / docs (push) Successful in 5m37s
2025-11-25 19:20:46 +09:00
7760b001d8 Get rid of dependency on 'futures'
All checks were successful
Build / check (push) Successful in 1m32s
Build / build (push) Successful in 4m11s
Build / docs (push) Successful in 7m2s
2025-11-20 19:34:24 +09:00
9d3b543998 Add pipeline for publishing debs
All checks were successful
Build / check (push) Successful in 1m32s
Build / build (push) Successful in 3m8s
Build / docs (push) Successful in 5m44s
2025-11-11 02:21:57 +09:00
6a7e8db162 Add script to create deb package
All checks were successful
Build / check (push) Successful in 1m38s
Build / build (push) Successful in 3m47s
Build / docs (push) Successful in 5m41s
2025-11-11 01:29:34 +09:00
108 changed files with 10479 additions and 5288 deletions

View File

@@ -0,0 +1,127 @@
name: "Build and test"
run-name: "Build and test"
on:
push:
branches:
- main
pull_request:
env:
BINSTALL_DISABLE_TELEMETRY: 'true'
jobs:
build:
runs-on: debian-latest
steps:
- uses: actions/checkout@v6
- name: Install rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Build
run: cargo build --all-features --verbose --release
check:
runs-on: debian-latest
steps:
- uses: actions/checkout@v6
- name: Install rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Check code format
run: cargo fmt -- --check
- name: Check clippy
run: cargo clippy -- --deny warnings
check-license:
runs-on: debian-latest
steps:
- uses: actions/checkout@v6
- uses: cargo-bins/cargo-binstall@main
- name: Install rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-deny
run: cargo binstall -y cargo-deny
- name: Check licenses
run: |
cargo deny check bans licenses sources
test:
runs-on: debian-latest
steps:
- uses: actions/checkout@v6
- uses: cargo-bins/cargo-binstall@main
- name: Install rust toolchain
uses: dtolnay/rust-toolchain@nightly
with:
components: llvm-tools-preview
- name: Install nextest
run: cargo binstall -y cargo-nextest --secure
- name: Run tests
run: cargo nextest run --release --no-fail-fast
env:
RUST_LOG: "trace"
RUSTFLAGS: "-Cinstrument-coverage"
LLVM_PROFILE_FILE: "target/coverage/%p-%m.profraw"
- name: Install grcov
run: cargo binstall -y grcov
- name: Generate coverage report
run: |
grcov \
--source-dir . \
--binary-path ./target/release/deps/ \
--excl-start 'mod test* \{' \
--ignore 'tests/*' \
--ignore "*test.rs" \
--ignore "*tests.rs" \
--ignore "*github.com*" \
--ignore "*libcore*" \
--ignore "*rustc*" \
--ignore "*liballoc*" \
--ignore "*cargo*" \
-t html \
-o ./target/coverage/html \
target/coverage/
- name: Upload test report
uses: https://git.pvv.ntnu.no/Projects/rsync-action@v1
with:
source: target/coverage/html/
target: ${{ gitea.ref_name }}/coverage/
username: gitea-web
ssh-key: ${{ secrets.WEB_SYNC_SSH_KEY }}
host: pages.pvv.ntnu.no
known-hosts: "pages.pvv.ntnu.no ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2QjfFB+city1SYqltkVqWACfo1j37k+oQQfj13mtgg"
docs:
runs-on: debian-latest
steps:
- uses: actions/checkout@v6
- name: Install rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Build docs
run: cargo doc --all-features --document-private-items --release
- name: Transfer files
uses: https://git.pvv.ntnu.no/Projects/rsync-action@main
with:
source: target/doc/
target: ${{ gitea.ref_name }}/docs/
username: gitea-web
ssh-key: ${{ secrets.WEB_SYNC_SSH_KEY }}
host: pages.pvv.ntnu.no
known-hosts: "pages.pvv.ntnu.no ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2QjfFB+city1SYqltkVqWACfo1j37k+oQQfj13mtgg"

View File

@@ -1,64 +0,0 @@
name: "Build"
on:
push:
branches:
- main
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install rust toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Build
run: cargo build --all-features --verbose --release
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install rust toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
components: rustfmt, clippy
- name: Check code format
run: cargo fmt -- --check
- name: Check clippy
run: cargo clippy --all-features -- --deny warnings
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install rust toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Build docs
run: cargo doc --all-features --document-private-items --release
- name: Transfer files
uses: https://git.pvv.ntnu.no/Projects/rsync-action@main
with:
source: target/doc/
target: ${{ gitea.ref_name }}/docs/
username: gitea-web
ssh-key: ${{ secrets.WEB_SYNC_SSH_KEY }}
host: pages.pvv.ntnu.no
known-hosts: "pages.pvv.ntnu.no ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2QjfFB+city1SYqltkVqWACfo1j37k+oQQfj13mtgg"

View File

@@ -0,0 +1,83 @@
name: "Publish Debian package"
run-name: "Publish Debian package"
on:
workflow_dispatch:
inputs:
deb_version:
description: "Version to publish"
type: string
deb_revision:
description: "Debian package revision"
type: string
default: "1"
required: true
rust_toolchain:
description: "Whether to build the package with stable rust"
type: choice
options:
- stable
- nightly
- beta
default: stable
env:
BINSTALL_DISABLE_TELEMETRY: 'true'
# TODO: dynamic matrix builds when...
# https://github.com/go-gitea/gitea/issues/25179
jobs:
build-deb:
strategy:
matrix:
os: [debian-trixie, debian-bookworm, ubuntu-noble, ubuntu-jammy]
name: Build and publish for ${{ matrix.os }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: cargo-bins/cargo-binstall@main
- name: Install rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ inputs.rust_toolchain }}
- name: Install cargo-deb
run: cargo binstall -y cargo-deb
- name: Build deb package
env:
CREATE_DEB_DEBUG: "1"
run: |
CREATE_DEB_ARGS=(
--deb-revision "${{ inputs.deb_revision }}"
)
if [ "${{ inputs.deb_version }}" != "" ]; then
CREATE_DEB_ARGS+=("--deb-version" "${{ inputs.deb_version }}")
fi
./scripts/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
path: target/debian/*.deb
if-no-files-found: error
retention-days: 30
# Already compressed
compression: 0
# This is not safely doable without either:
# - tokens scoped to the repository: https://github.com/go-gitea/gitea/issues/25900
# - automatically provisioned tokens: https://github.com/go-gitea/gitea/issues/24635
#
# - name: Publish deb package
# run: |
# # TODO: remove ubuntu-/debian- prefix from os.matrix
# curl \
# --user your_username:your_password_or_token \
# --upload-file target/debian/*.deb \
# https://git.pvv.ntnu.no/api/packages/${{ github.repository_owner }}/debian/pool/${{ os.matrix }}/${{ inputs.repository_component }}/upload

4
.gitignore vendored
View File

@@ -9,3 +9,7 @@ result-*
# Nix VM
*.qcow2
# Packaging
!/assets/debian/config.toml
/assets/completions/

65
CHANGELOG.md Normal file
View File

@@ -0,0 +1,65 @@
# Changelog
## v1.0.0 - Initial Release
This is the initial release of `muscl`.
### Features ported from [`mysql-admutils`](https://git.pvv.ntnu.no/Projects/mysql-admutils)
- All commands
- Support for starting internal server with SUID/SGID
- Best-effort CLI interface backwards compatibility (see deviation notes for details)
- Best-effort stdout/stderr output backwards compatibility (see line above)
- Privilege editor
### New features and changes from `mysql-admutils`
- Changed programming language from `C` to `Rust`, for better or for worse
- Combined the functionality of both `mysql-dbadm` and `mysql-useradm` into a single executable.
- Switched to a server+client architecture. With this change comes:
- Added security against SUID/SGID-related vulnerabilities.
- Logging and debug information for system administrators.
- A limitation on the maximum number of connections to the database.
- A lot of sandboxing and hardening for the server-side, limiting the amount
of damage that can be done if compromised, and further increasing security.
- Added `--json` flag for several commands
- Added `check-auth` command, for testing whether you are allowed to manage certain databases or users
- Added `lock-user`/`unlock-user` which let's you temporarily disable a database user.
- Added dynamic shell completions, aware of which databases and users exist.
- Changed the name length limit from `32` characters to `64` characters.
- Added `-p`/`--privs` flag for editing privileges using only commandline flags.
The flag acts similarly to `chmod` with `+` and `-` variants for adding and removing privileges.
See `muscl edit-privs --help` for more information.
- Changed handling of database user passwords:
- Prompting for passwords will now hide what you write
- Allow providing passwords through files and stdin
- Respect `$VISUAL` in addition to `$EDITOR` when launching the privilege editor.
- Use a commented example line in the template for the privilege editor on first use.
- Display the diff before committing privilege changes.
- Generally more detailed error reporting:
- On entering database or user names you do not own, suggest valid names
- Instead of silently trimming database/user names when too long, report as error
- When there are other name validation errors, report exactly what went wrong instead of a generic message
- Add new errors related to failures inbetween the client and the server
- Package and distribute software:
- Provide `.deb` packages
- Provide systemd units
- Provide nix-flake with packages, overlays and NixOS modules.
### Known deviations from `mysql-admutils`' behaviour
- `--help` output is formatted by clap in a different style.
- `mysql-dbadm edit-perm` uses the new privilege editor implementation. 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 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.

1047
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,16 @@
[package]
name = "mysqladm-rs"
name = "muscl"
version = "0.1.0"
edition = "2021"
license = "BSD3"
edition = "2024"
resolver = "2"
license = "BSD-3-Clause"
authors = [
"oysteikt@pvv.ntnu.no",
"felixalb@pvv.ntnu.no",
]
repository = "https://git.pvv.ntnu.no/Projects/mysqladm-rs"
documentation = "https://pages.pvv.ntnu.no/Projects/mysqladm-rs/main/docs/mysqladm/"
homepage = "https://git.pvv.ntnu.no/Projects/muscl"
repository = "https://git.pvv.ntnu.no/Projects/muscl"
documentation = "https://pages.pvv.ntnu.no/Projects/muscl/main/docs/muscl"
description = "A command-line utility for MySQL administration for non-admin users"
categories = ["command-line-interface", "command-line-utilities"]
keywords = ["mysql", "cli", "administration"]
@@ -20,51 +22,169 @@ autolib = false
anyhow = "1.0.100"
async-bincode = "0.8.0"
bincode = "2.0.1"
clap = { version = "4.5.51", features = ["derive"] }
clap-verbosity-flag = "3.0.4"
clap_complete = "4.5.60"
derive_more = { version = "2.0.1", features = ["display", "error"] }
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"
const_format = "0.2.35"
derive_more = { version = "2.1.1", features = ["display", "error"] }
dialoguer = "0.12.0"
env_logger = "0.11.8"
futures = "0.3.31"
futures-util = "0.3.31"
humansize = "2.1.3"
indoc = "2.0.7"
itertools = "0.14.0"
log = "0.4.28"
nix = { version = "0.30.1", features = ["fs", "process", "socket", "user"] }
num_cpus = "1.17.0"
prettytable = "0.10.0"
rand = "0.9.2"
ratatui = { version = "0.29.0", optional = true }
sd-notify = "0.4.5"
serde = "1.0.228"
serde_json = { version = "1.0.145", features = ["preserve_order"] }
serde_json = { version = "1.0.146", features = ["preserve_order"] }
sqlx = { version = "0.8.6", features = ["runtime-tokio", "mysql", "tls-rustls"] }
systemd-journal-logger = "2.2.2"
tokio = { version = "1.48.0", features = ["rt-multi-thread", "macros"] }
thiserror = "2.0.17"
tokio = { version = "1.48.0", features = ["rt-multi-thread", "macros", "signal"] }
tokio-serde = { version = "0.9.0", features = ["bincode"] }
tokio-stream = "0.1.17"
tokio-util = { version = "0.7.17", features = ["codec"] }
toml = "0.9.8"
uuid = { version = "1.18.1", features = ["v4"] }
tokio-util = { version = "0.7.17", features = ["codec", "rt"] }
toml = "0.9.10"
tracing = { version = "0.1.44", features = ["log"] }
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]
default = ["mysql-admutils-compatibility"]
tui = ["dep:ratatui"]
mysql-admutils-compatibility = []
suid-sgid-mode = []
[[bin]]
name = "mysqladm"
bench = false
path = "src/main.rs"
[lib]
name = "muscl_lib"
path = "src/lib.rs"
[profile.release]
[[bin]]
name = "muscl"
bench = false
path = "src/entrypoints/muscl.rs"
[[bin]]
name = "muscl-server"
bench = false
path = "src/entrypoints/muscl_server.rs"
[profile.release-lto]
inherits = "release"
strip = true
lto = true
codegen-units = 1
[build-dependencies]
anyhow = "1.0.100"
[package.metadata.deb]
name = "muscl"
priority = "optional"
section = "databases"
depends = "$auto"
license-file = ["LICENSE", "0"]
maintainer = "Programvareverkstedet <projects@pvv.ntnu.no>"
copyright = "Copyright (c) 2025, Programvareverkstedet"
# NOTE: try to keep this in sync with README, and keep an 80 char limit.
extended-description = """
[dev-dependencies]
regex = "1.12.2"
Muscl is a CLI tool that let's unprivileged users perform administrative
operations on a MySQL DBMS, given the are authorized to perform the action
on the database or database user in question. The default authorization
mechanism is to only let the user perform these actions on databases and
database users that are prefixed with their username, or with the name of any
unix group that the user is a part of. i.e. `<user>_mydb`, `<user>_mydbuser`,
or `<group>_myotherdb`.
The available administrative actions include:
- creating/listing/modifying/deleting databases and database users
- modifying privileges for a database user on a database
- changing the passwords of the database users
- locking and unlocking database users
- ... more to come
The software is designed to be run as a client and a server. The server has
administrative access to the mysql server, and is responsible for authorizing
any requests from the clients.
This software is designed for multi-user servers, like tilde servers,
university servers, etc.\
"""
changelog = "CHANGELOG.md"
assets = [
[
"target/release/muscl",
"usr/bin/",
"755",
],
[
"target/release/muscl-server",
"usr/bin/",
"755",
],
[
"target/release/mysql-useradm",
"usr/bin/",
"755",
],
[
"target/release/mysql-dbadm",
"usr/bin/",
"755",
],
[
"assets/debian/config.toml",
"etc/muscl/config.toml",
"644",
],
[
"assets/debian/group_denylist.txt",
"etc/muscl/group_denylist.txt",
"644",
],
[
"assets/completions/_*",
"usr/share/zsh/site-functions/completions/",
"644",
],
[
"assets/completions/*.bash",
"usr/share/bash-completion/completions/",
"644",
],
[
"assets/completions/*.fish",
"usr/share/fish/vendor_completions.d/",
"644",
],
[
"README.md",
"usr/share/doc/muscl/",
"644",
],
[
"docs/*.md",
"usr/share/doc/muscl/docs/",
"644",
],
]
preserve-symlinks = true
maintainer-scripts = "debian/"
systemd-units = [
{ unit-name = "muscl", unit-scripts = "assets/systemd", enable = true },
]

134
README.md
View File

@@ -1,105 +1,55 @@
[![Docs](https://img.shields.io/badge/rust_docs-blue?style=for-the-badge&logo=rust)](https://pages.pvv.ntnu.no/Projects/mysqladm-rs/main/docs/mysqladm/)
[![Coverage](https://pages.pvv.ntnu.no/Projects/muscl/main/coverage/badges/for_the_badge.svg)](https://pages.pvv.ntnu.no/Projects/muscl/main/coverage/)
[![Docs](https://img.shields.io/badge/rust_docs-blue?style=for-the-badge&logo=rust)](https://pages.pvv.ntnu.no/Projects/muscl/main/docs/muscl/)
# mysqladm-rs
# muscl 💪
Healing mysql spasms since 2024
Dropping DBs (dumbbells) and having MySQL spasms since 2024
## What is this?
This is a CLI tool that let's normal users perform administrative operations on a MySQL DBMS, with some restrictions.
The default restriction is to only let the user perform these actions on databases and database users that are prefixed with their username,
or with the name of any unix group that the user is a part of. i.e. `<user>_mydb`, `<user>_mydbuser`, or `<group>_myotherdb`.
`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.
The administrative actions available to the user includes:
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`.
- creating/listing/modifying/deleting databases and database users
- modifying database user privileges
- changing the passwords of the database users
- locking and unlocking database user accounts
- ... more to come
The software is split into a client and a server. The server has administrative access to the mysql server,
and is responsible for checking client authorization for the different types of actions the client might request.
This is designed for (and is only really useful for) multi-user servers, like tilde servers, university unix servers, etc.
## Installation
The resulting binary will probably need to be marked as either SUID or SGID to work in a multi-user environment.
The UID/GID of the binary should have access to the config file, which contains secrets to log in to an admin-like MySQL user.
Preferrably, this UID/GID should not be root, in order to minimize the potential damage that can be done in case of security vulnerabilities in the program.
## Development and testing
### Nix
If you have nix installed, you can test your changes in a NixOS vm by running:
The available administrative operations include:
```bash
nix run .#vm
# 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
# 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...
```
### General setup
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.
Ensure you have a [rust toolchain](https://www.rust-lang.org/tools/install) installed.
## Documentation
In order to set up a test instance of mariadb in a docker container, run the following command:
```bash
docker run --rm --name mariadb -e MYSQL_ROOT_PASSWORD=secret -p 3306:3306 -d mariadb:latest
```
This will start a mariadb instance with the root password `secret`, and expose the port 3306 on the host machine.
Run the following command to create a configuration file with the default settings:
```bash
cp ./example-config.toml ./config.toml
```
If you used the docker command above, you can use these settings as is, but if you are running mariadb/mysql on another host, port or with another password, adjust the corresponding fields in `config.toml`.
This file will contain your database password, but is ignored by git, so it will not be committed to the repository.
You should now be able to connect to the mariadb instance, after building the program and using arguments to specify the config file.
```bash
cargo run -- --config-file ./config.toml <args>
# example usage
cargo run -- --config-file ./config.toml create-db "${USER}_testdb"
cargo run -- --config-file ./config.toml create-user "${USER}_testuser"
cargo run -- --config-file ./config.toml edit-db-privs -p "${USER}_testdb:${USER}_testuser:A"
cargo run -- --config-file ./config.toml show-db-privs
```
To stop and remove the container, run the following command:
```bash
docker stop mariadb
```
## Compatibility mode with [mysql-admutils](https://git.pvv.ntnu.no/Projects/mysql-admutils)
If you enable the feature flag `mysql-admutils-compatibility` (enabled by default), the output directory will contain two symlinks to the binary, `mysql-dbadm` and `mysql-useradm`. In the same fashion as busybox, the binary will react to its `argv[0]` and behave as if it was called with the corresponding name. While the internal functionality is written in rust, these modes strive to behave as similar as possible to the original programs.
```bash
cargo build
./target/debug/mysql-dbadm --help
./target/debug/mysql-useradm --help
```
### Known deviations from the original programs
- Added flags for database configuration, not present in the original programs
- `--help` output is formatted by clap in a modern style.
- `mysql-dbadm edit-perm` uses the new implementation. The idea was that the parsing
logic was too complex to be worth porting, and there wouldn't be any scripts depending
on this command anyway. As such, the new implementation is more user-friendly and only
brings positive changes.
- The new tools use the modern implementation to find it's configuration. If you compiled
the old programs with `--sysconfdir=<somewhere>`, you might have to provide `--config-file`
where the old program would just work by itself.
- The order in which some things are validated (e.g. whether you own a user, whether the
contains illegal characters, whether the user does or does not exist) might be different
from the original program, leading to the same command giving the errors in a different order.
- [Installation and configuration](docs/installation.md)
- [Development and testing](docs/development.md)
- [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)

27
assets/debian/config.toml Normal file
View File

@@ -0,0 +1,27 @@
[authorization]
group_denylist_file = "/etc/muscl/group_denylist.txt"
[mysql]
# Hostname and port of the database.
host = "localhost"
port = 3306
# The path to the unix socket of the database.
# If you uncomment this line, the host and port will be ignored
# socket_path = "/run/mysql/mysql.sock"
# The username and password for the database connection.
# The username and password can be omitted if you are connecting
# to the database using socket based authentication.
# However, the vendored systemd service is running as DynamicUser,
# so these need to be specified by default unless you override the
# systemd unit.
username = "muscl"
# This file gets created by systemd automatically, given you have set
# the password with `systemd-creds`. See /usr/share/doc/muscl/docs/installation.md
# for more information.
password_file = "/run/credentials/muscl.service/muscl_mysql_password"
# Database connection timeout in seconds
timeout = 2

View File

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

View File

@@ -1,11 +1,14 @@
# This should go to `/etc/mysqladm/config.toml`
# This should go to `/etc/muscl/config.toml`
[server]
# The path to the socket where users can connect to the daemon.
#
# Note that this options gets ignored if you are using systemd socket activation
# (see `systemctl status mysqladm.socket`)
socket_path = "/run/mysqladm/mysqladm.sock"
# (see `systemctl status muscl.socket`)
socket_path = "/run/muscl/muscl.sock"
[authorization]
group_denylist_file = "/etc/muscl/group_denylist.txt"
[mysql]
@@ -13,7 +16,7 @@ socket_path = "/run/mysqladm/mysqladm.sock"
host = "localhost"
port = 3306
# The path to the unix socket of the databse.
# The path to the unix socket of the database.
# If you uncomment this line, the host and port will be ignored
# socket_path = "/run/mysql/mysql.sock"

View File

@@ -0,0 +1,63 @@
[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
ExecReload=/usr/bin/kill -HUP $MAINPID
WatchdogSec=15
# Although this is a multi-instance unit, the constant `User` field is needed
# for authentication via mysql's auth_socket plugin to work.
User=muscl
Group=muscl
DynamicUser=yes
ConfigurationDirectory=muscl
ImportCredential=muscl_mysql_password
# This is required to read unix user/group details.
PrivateUsers=false
# Needed to communicate with MySQL.
PrivateNetwork=false
PrivateIPC=false
AmbientCapabilities=
CapabilityBoundingSet=
DeviceAllow=
DevicePolicy=closed
LockPersonality=true
MemoryDenyWriteExecute=true
NoNewPrivileges=true
PrivateDevices=true
PrivateMounts=true
PrivateTmp=yes
ProcSubset=pid
ProtectClock=true
ProtectControlGroups=strict
ProtectHome=true
ProtectHostname=true
ProtectKernelLogs=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectProc=invisible
ProtectSystem=strict
RemoveIPC=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
SocketBindDeny=any
SystemCallArchitectures=native
SystemCallFilter=@system-service
# This is needed for landlock
# SystemCallFilter=@sandbox
SystemCallFilter=~@privileged @resources
UMask=0777

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Muscl MySQL admin tool
[Socket]
ListenStream=/run/muscl/muscl.sock
Accept=no
PassCredentials=true
[Install]
WantedBy=sockets.target

View File

@@ -3,6 +3,41 @@ use anyhow::anyhow;
#[cfg(feature = "mysql-admutils-compatibility")]
use std::{env, os::unix::fs::symlink, path::PathBuf};
fn get_git_commit() -> Option<String> {
let repo = git2::Repository::discover(".").ok()?;
let head = repo.head().ok()?;
let commit = head.peel_to_commit().ok()?;
Some(commit.id().to_string())
}
fn embed_build_time_info() {
let commit = option_env!("GIT_COMMIT")
.map(|s| s.to_string())
.or_else(get_git_commit)
.unwrap_or_else(|| "unknown".to_string());
let build_profile = std::env::var("OUT_DIR")
.unwrap_or_else(|_| "unknown".to_string())
.split(std::path::MAIN_SEPARATOR)
.nth_back(3)
.unwrap_or("unknown")
.to_string();
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<()> {
// NOTE: This is slightly illegal, and depends on implementation details.
// But it is only here for ease of testing the compatibility layer,
@@ -21,7 +56,7 @@ fn generate_mysql_admutils_symlinks() -> anyhow::Result<()> {
if !target_profile_dir.join("mysql-useradm").exists() {
symlink(
target_profile_dir.join("mysqladm"),
PathBuf::from("./muscl"),
target_profile_dir.join("mysql-useradm"),
)
.ok();
@@ -29,7 +64,7 @@ fn generate_mysql_admutils_symlinks() -> anyhow::Result<()> {
if !target_profile_dir.join("mysql-dbadm").exists() {
symlink(
target_profile_dir.join("mysqladm"),
PathBuf::from("./muscl"),
target_profile_dir.join("mysql-dbadm"),
)
.ok();
@@ -42,5 +77,7 @@ fn main() -> anyhow::Result<()> {
#[cfg(feature = "mysql-admutils-compatibility")]
generate_mysql_admutils_symlinks()?;
embed_build_time_info();
Ok(())
}

View File

@@ -27,14 +27,13 @@ ignore = []
[licenses]
allow = [
"GPL-2.0",
"MIT",
"Apache-2.0",
"ISC",
"MPL-2.0",
"Unicode-DFS-2016",
"BSD-3-Clause",
"OpenSSL",
"CDLA-Permissive-2.0",
"ISC",
"MIT",
"Unicode-3.0",
"Zlib"
]
confidence-threshold = 0.8
exceptions = []
@@ -75,4 +74,3 @@ allow-registry = ["https://github.com/rust-lang/crates.io-index"]
allow-git = []
[sources.allow-org]

76
docs/compiling.md Normal file
View File

@@ -0,0 +1,76 @@
# 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

57
docs/development.md Normal file
View File

@@ -0,0 +1,57 @@
# Development and testing
Ensure you have a [Rust toolchain](https://www.rust-lang.org/tools/install) installed.
In order to set up a test instance of MariaDB in a docker container, run the following command:
```bash
docker run --rm --name mariadb -e MYSQL_ROOT_PASSWORD=secret -p 3306:3306 -d mariadb:latest
```
This will start a MariaDB instance with the root password `secret`, and expose the port 3306 on the host machine.
Run the following command to create a configuration file with the default settings:
```bash
cp ./assets/example-config.toml ./config.toml
```
If you used the docker command above, you can use these settings as is, but if you are running MariaDB/MySQL on another host, port or with another password, adjust the corresponding fields in `config.toml`.
This file will contain your database password, but is ignored by git, so it will not be committed to the repository.
You should now be able to connect to the MariaDB instance, after building the program and using arguments to specify the config file.
```bash
cargo run -- --config-file ./config.toml <args>
# example usage
cargo run -- --config-file ./config.toml create-db "${USER}_testdb"
cargo run -- --config-file ./config.toml create-user "${USER}_testuser"
cargo run -- --config-file ./config.toml edit-privs -p "${USER}_testdb:${USER}_testuser:A"
cargo run -- --config-file ./config.toml show-privs
```
To stop and remove the container, run the following command:
```bash
docker stop mariadb
```
## Development using Nix
If you have nix installed, you can easily test your changes in a NixOS vm by running:
```bash
nix run .#vm # 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
```
You can configure the vm in `flake.nix`
## Filter logs by user with journalctl
If you want to filter the server logs by user, you can use journalctl's built-in filtering capabilities.
```bash
journalctl -eu muscl F_USER=<username>
```

134
docs/installation.md Normal file
View File

@@ -0,0 +1,134 @@
# Installation and configuration
This document contains instructions for the recommended way of installing and configuring muscl.
Note that there are separate instructions for [installing on NixOS](nixos.md) and [installing with SUID/SGID mode](suid-sgid-mode.md).
## Installing with deb on Debian
You can install muscl by adding the [PVV apt repository][pvv-apt-repository] and installing the package:
```bash
# Become root (if not already)
sudo -i
# Check the version of your Debian installation
VERSION_CODENAME=$(. /etc/os-release && echo "$VERSION_CODENAME")
# 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
# Install muscl
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
a database user with sufficient privileges. You can create such a user by running the following commands
on the MySQL server as the admin user (or another user with sufficient privileges):
```sql
CREATE USER `muscl`@`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`;
FLUSH PRIVILEGES;
```
Make sure to remember the username and password, as we will now need to add them to the muscl configuration.
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.
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`.
You can add the password like this:
```bash
# Become root (if not already)
sudo -i
# Unless you already have a working credential store, you need to set it up first
mkdir -p /etc/credstore.encrypted
systemd-creds setup
# Prompt for the muscl MySQL password
read -s MUSCL_MYSQL_PASSWORD
<... enter strong password here>
# Now set the muscl MySQL password
systemd-creds encrypt --name=muscl_mysql_password <(echo "$MUSCL_MYSQL_PASSWORD") /etc/credstore.encrypted/muscl_mysql_password
# 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:
```ini
[Service]
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.
[pvv-apt-repository]: https://git.pvv.ntnu.no/Projects/-/packages/debian/muscl

View File

@@ -0,0 +1,39 @@
# 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.
```bash
cargo build
./target/debug/mysql-dbadm --help
./target/debug/mysql-useradm --help
```
These symlinks are also included in the deb packages by default.
### 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 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

16
docs/nixos.md Normal file
View File

@@ -0,0 +1,16 @@
# Use with NixOS
For NixOS, there is a NixOS module available in the nix flake. You can include it in your configuration like this:
```nix
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-XX.YY";
inputs.muscl.url = "git+https://git.pvv.ntnu.no/Projects/muscle.git";
inputs.muscl.inputs.nixpkgs.follows = "nixpkgs";
...
}
```
The module allows for easy setup on a local machine by enabling `services.muscl.createLocalDatabaseUser`.

17
docs/suid-sgid-mode.md Normal file
View File

@@ -0,0 +1,17 @@
# SUID/SGID mode
> [!WARNING]
> This will be deprecated in a future release, see https://git.pvv.ntnu.no/Projects/muscl/issues/101
>
> We do not recommend you use this mode unless you absolutely have to. The biggest reason why `muscl` was rewritten from scratch
> was to fix an architectural issue that easily caused vulnerabilites due to reliance on SUID/SGID. Althought the architecture now
> is more resistant against such vulnerabilites, it is not failsafe.
For backwards compatibility reasons, it is possible to run the program without a daemon by utilizing SUID/SGID.
In order to do this, you should set either the SUID/SGID bit and preferably make the executable owned by a non-privileged user.
If the database is running on the same machine, the user/group will need access to write and read from the database socket.
Otherwise, the only requirement is that the user/group is able to read the config file (typically `/etc/muscl/config.toml`).
Note that the feature flag for SUID/SGID mode is not enabled by default, and is not included in the default deb package.
You will need to compile the program yourself with `--features suid-sgid-mode`.

28
flake.lock generated
View File

@@ -1,12 +1,27 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1766194365,
"narHash": "sha256-4AFsUZ0kl6MXSm4BaQgItD0VGlEKR3iq7gIaL7TjBvc=",
"owner": "ipetkov",
"repo": "crane",
"rev": "7d8ec2c71771937ab99790b45e6d9b93d15d9379",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1762363567,
"narHash": "sha256-YRqMDEtSMbitIMj+JLpheSz0pwEr0Rmy5mC7myl17xs=",
"lastModified": 1766309749,
"narHash": "sha256-3xY8CZ4rSnQ0NqGhMKAy5vgC+2IVK0NoVEzDoOh4DA4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "ae814fd3904b621d8ab97418f1d0f2eb0d3716f4",
"rev": "a6531044f6d0bef691ea18d4d4ce44d0daa6e816",
"type": "github"
},
"original": {
@@ -18,6 +33,7 @@
},
"root": {
"inputs": {
"crane": "crane",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
@@ -29,11 +45,11 @@
]
},
"locked": {
"lastModified": 1762655942,
"narHash": "sha256-hOM12KcQNQALrhB9w6KJmV5hPpm3GA763HRe9o7JUiI=",
"lastModified": 1766457837,
"narHash": "sha256-aeBbkQ0HPFNOIsUeEsXmZHXbYq4bG8ipT9JRlCcKHgU=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "6ac961b02d4235572692241e333d0470637f5492",
"rev": "2c7510a559416d07242621d036847152d970612b",
"type": "github"
},
"original": {

165
flake.nix
View File

@@ -4,9 +4,11 @@
rust-overlay.url = "github:oxalica/rust-overlay";
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
crane.url = "github:ipetkov/crane";
};
outputs = { self, nixpkgs, rust-overlay }:
outputs = { self, nixpkgs, rust-overlay, crane }:
let
inherit (nixpkgs) lib;
@@ -33,15 +35,31 @@
in f system pkgs toolchain);
in {
apps = let
mkApp = program: { type = "app"; program = toString program; };
mkApp = program: description: {
type = "app";
program = toString program;
meta = {
inherit description;
};
};
mkVm = name: mkApp "${self.nixosConfigurations.${name}.config.system.build.vm}/bin/run-nixos-vm";
in forAllSystems (system: pkgs: _: {
mysqladm-rs = mkApp (lib.getExe self.packages.${system}.mysqladm-rs);
coverage = mkApp (pkgs.writeScript "mysqladm-rs-coverage" ''
${lib.getExe pkgs.python3} -m http.server -d "${self.packages.${system}.coverage}/html/src"
'');
vm = mkApp "${self.nixosConfigurations.vm.config.system.build.vm}/bin/run-nixos-vm";
muscl = mkApp (lib.getExe self.packages.${system}.muscl) "Run muscl without any setup";
coverage = mkApp (pkgs.writeShellScript "muscl-coverage" ''
${lib.getExe pkgs.python3} -m http.server -d "${self.packages.${system}.coverage}/html"
'') "Serve code coverage report at http://localhost:8000";
vm = mkVm "vm" "Start a NixOS VM with muscl and mariadb installed";
vm-mysql = mkVm "vm-mysql" "Start a NixOS VM with muscl and mysql installed";
vm-suid = mkVm "vm-suid" "Start a NixOS VM with muscl as SUID/SGID installed";
});
nixosConfigurations = {
vm = import ./nix/nixos-configurations/vm.nix { inherit self nixpkgs; useMariadb = true; };
vm-mysql = import ./nix/nixos-configurations/vm.nix { inherit self nixpkgs; useMariadb = false; };
vm-suid = import ./nix/nixos-configurations/vm-suid.nix { inherit self nixpkgs; };
};
devShell = forAllSystems (system: pkgs: toolchain: pkgs.mkShell {
nativeBuildInputs = with pkgs; [
toolchain
@@ -49,109 +67,74 @@
cargo-nextest
cargo-edit
cargo-deny
cargo-deb
dpkg
];
RUST_SRC_PATH = "${toolchain}/lib/rustlib/src/rust/library";
});
overlays = {
default = self.overlays.mysqladm-rs;
mysqladm-rs = final: prev: {
inherit (self.packages.${prev.stdenv.hostPlatform.system}) mysqladm-rs;
default = self.overlays.muscl;
muscl = final: prev: {
inherit (self.packages.${prev.stdenv.hostPlatform.system}) muscl;
};
muscl-crane = final: prev: {
muscl = self.packages.${prev.stdenv.hostPlatform.system}.muscl-crane;
};
muscl-suid = final: prev: {
muscl = self.packages.${prev.stdenv.hostPlatform.system}.muscl-suid;
};
muscl-suid-crane = final: prev: {
muscl = self.packages.${prev.stdenv.hostPlatform.system}.muscl-suid-crane;
};
};
nixosModules = {
default = self.nixosModules.mysqladm-rs;
mysqladm-rs = import ./nix/module.nix;
default = self.nixosModules.muscl;
muscl = import ./nix/module.nix;
};
packages = let
packages = forAllSystems (system: pkgs: _:
let
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
cargoLock = ./Cargo.lock;
src = builtins.filterSource (path: type: let
baseName = baseNameOf (toString path);
in !(lib.any (b: b) [
(!(lib.cleanSourceFilter path type))
(baseName == "target" && type == "directory")
(baseName == "nix" && type == "directory")
(baseName == "flake.nix" && type == "regular")
(baseName == "flake.lock" && type == "regular")
])) ./.;
in forAllSystems (system: pkgs: _: {
default = self.packages.${system}.mysqladm-rs;
mysqladm-rs = pkgs.callPackage ./nix/default.nix { inherit cargoToml cargoLock src; };
craneLib = crane.mkLib pkgs;
src = lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.unions [
(craneLib.fileset.commonCargoSources ./.)
./assets
];
};
in {
default = self.packages.${system}.muscl-crane;
muscl = pkgs.callPackage ./nix/default.nix { inherit cargoToml cargoLock src; };
muscl-crane = pkgs.callPackage ./nix/default.nix {
useCrane = true;
inherit cargoToml cargoLock src craneLib;
};
muscl-suid = pkgs.callPackage ./nix/default.nix {
suidSgidSupport = true;
inherit cargoToml cargoLock src;
};
muscl-suid-crane = pkgs.callPackage ./nix/default.nix {
useCrane = true;
suidSgidSupport = true;
inherit cargoToml cargoLock src craneLib;
};
coverage = pkgs.callPackage ./nix/coverage.nix { inherit cargoToml cargoLock src; };
filteredSource = pkgs.runCommandLocal "filtered-source" { } ''
ln -s ${src} $out
'';
});
nixosConfigurations.vm = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
pkgs = import nixpkgs {
system = "x86_64-linux";
overlays = [
self.overlays.default
];
};
modules = [
"${nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix"
"${nixpkgs}/nixos/tests/common/user-account.nix"
self.nixosModules.default
({ config, pkgs, ... }: {
system.stateVersion = config.system.nixos.release;
virtualisation.graphics = false;
users = {
groups = {
a = { };
b = { };
};
users.alice.extraGroups = [
"a"
"b"
"wheel"
"systemd-journal"
];
extraUsers.root.password = "root";
};
services.getty.autologinUser = "alice";
users.motd = ''
=================================
Welcome to the mysqladm-rs vm!
Try running:
${config.services.mysqladm-rs.package.meta.mainProgram}
Password for alice is 'foobar'
Password for root is 'root'
To exit, press Ctrl+A, then X
=================================
'';
services.mysql = {
enable = true;
package = pkgs.mariadb;
};
services.mysqladm-rs = {
enable = true;
createLocalDatabaseUser = true;
};
systemd.services."mysqladm@".environment.RUST_LOG = "debug";
programs.vim = {
enable = true;
defaultEditor = true;
};
})
];
};
checks = forAllSystems (system: pkgs: _: {
# NOTE: the non-crane build runs tests during checkPhase
inherit (self.packages.${system}) muscl muscl-suid;
});
};
}

View File

@@ -1,31 +1,91 @@
{
lib
, rustPlatform
, stdenv
, installShellFiles
, versionCheckHook
, cargoToml
, cargoLock
, src
, installShellFiles
, useCrane ? false
, craneLib ? null
, suidSgidSupport ? false
}:
let
mainProgram = (lib.head cargoToml.bin).name;
in
rustPlatform.buildRustPackage {
pname = cargoToml.package.name;
version = cargoToml.package.version;
inherit src;
buildFunction = if useCrane then craneLib.buildPackage else rustPlatform.buildRustPackage;
cargoLock.lockFile = cargoLock;
pnameCraneSuffix = lib.optionalString useCrane "-crane";
pnameSuidSuffix = lib.optionalString suidSgidSupport "-suid";
pname = "${cargoToml.package.name}${pnameSuidSuffix}${pnameCraneSuffix}";
rustPlatformArgs = {
buildType = "release-lto";
buildFeatures = lib.optional suidSgidSupport "suid-sgid-mode";
cargoLock.lockFile = cargoLock;
doCheck = true;
useNextest = true;
nativeCheckInputs = [
versionCheckHook
];
cargoCheckFeatures = lib.optional suidSgidSupport "suid-sgid-mode";
postCheck = lib.optionalString (stdenv.buildPlatform.system == stdenv.hostPlatform.system && suidSgidSupport) ''
./target/${stdenv.hostPlatform.rust.rustcTarget}/release/muscl --version | grep "SUID/SGID mode: enabled"
'';
};
craneArgs = {
cargoLock = cargoLock;
cargoExtraArgs = lib.escapeShellArgs [ "--features" (lib.concatStringsSep "," (lib.optional suidSgidSupport "suid-sgid-mode")) ];
cargoArtifacts = craneLib.buildDepsOnly {
inherit pname;
inherit (cargoToml.package) version;
src = lib.fileset.toSource {
root = ../.;
fileset = lib.fileset.unions [
(craneLib.fileset.cargoTomlAndLock ../.)
];
};
cargoLock = cargoLock;
};
};
in
buildFunction ({
inherit pname;
inherit (cargoToml.package) version;
inherit src;
nativeBuildInputs = [ installShellFiles ];
postInstall = let
commands = lib.mapCartesianProduct ({ shell, command }: ''
"$out/bin/${mainProgram}" generate-completions --shell "${shell}" --command "${command}" > "$TMP/mysqladm.${shell}"
installShellCompletion "--${shell}" --cmd "${command}" "$TMP/mysqladm.${shell}"
installShellCompletions = lib.mapCartesianProduct ({ shell, command }: ''
(
export PATH="$out/bin:$PATH"
export COMPLETE="${shell}"
"${command}" > "$TMP/${command}.${shell}"
)
# See https://github.com/clap-rs/clap/issues/1764
sed -i 's/muscl/${command}/g' "$TMP/${command}.${shell}"
installShellCompletion "--${shell}" --cmd "${command}" "$TMP/${command}.${shell}"
'') {
shell = [ "bash" "zsh" "fish" ];
command = [ "mysqladm" "mysql-dbadm" "mysql-useradm" ];
command = [ "muscl" "mysql-dbadm" "mysql-useradm" ];
};
in lib.concatStringsSep "\n" commands;
in ''
ln -sr "$out/bin/muscl" "$out/bin/mysql-dbadm"
ln -sr "$out/bin/muscl" "$out/bin/mysql-useradm"
${lib.concatStringsSep "\n" installShellCompletions}
install -Dm444 assets/systemd/muscl.socket -t "$out/lib/systemd/system"
install -Dm644 assets/systemd/muscl.service -t "$out/lib/systemd/system"
substituteInPlace "$out/lib/systemd/system/muscl.service" \
--replace-fail '/usr/bin/muscl-server' "$out/bin/muscl-server"
'';
meta = with lib; {
license = licenses.mit;
@@ -33,3 +93,6 @@ rustPlatform.buildRustPackage {
inherit mainProgram;
};
}
//
(if useCrane then craneArgs else rustPlatformArgs)
)

View File

@@ -1,31 +1,29 @@
{ config, pkgs, lib, ... }:
let
cfg = config.services.mysqladm-rs;
cfg = config.services.muscl;
format = pkgs.formats.toml { };
in
{
options.services.mysqladm-rs = {
enable = lib.mkEnableOption "Enable mysqladm-rs";
options.services.muscl = {
enable = lib.mkEnableOption "Enable muscl";
package = lib.mkPackageOption pkgs "mysqladm-rs" { };
package = lib.mkPackageOption pkgs "muscl" { };
createLocalDatabaseUser = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Create a local database user for mysqladm-rs";
description = "Create a local database user for muscl";
};
logLevel = lib.mkOption {
type = lib.types.enum [ "quiet" "error" "warn" "info" "debug" "trace" ];
default = "debug";
description = "Log level for mysqladm-rs";
type = lib.types.enum [ "quiet" "info" "debug" "trace" ];
default = "info";
description = "Log level for muscl";
apply = level: {
"quiet" = "-q";
"error" = "";
"warn" = "-v";
"info" = "-vv";
"debug" = "-vvv";
"trace" = "-vvvv";
"info" = "";
"debug" = "-v";
"trace" = "-vv";
}.${level};
};
@@ -37,11 +35,19 @@ in
server = {
socket_path = lib.mkOption {
type = lib.types.path;
default = "/run/mysqladm/mysqladm.sock";
description = "Path to the mysqladm socket";
default = "/run/muscl/muscl.sock";
description = "Path to the muscl socket";
};
};
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;
@@ -60,7 +66,7 @@ in
};
username = lib.mkOption {
type = lib.types.str;
default = "mysqladm";
default = "muscl";
description = "MySQL username";
};
passwordFile = lib.mkOption {
@@ -79,12 +85,33 @@ in
};
};
config = lib.mkIf config.services.mysqladm-rs.enable {
config = lib.mkIf config.services.muscl.enable {
environment.systemPackages = [ cfg.package ];
environment.etc."mysqladm/config.toml".source = let
nullStrippedConfig = lib.filterAttrsRecursive (_: v: v != null) cfg.settings;
in format.generate "mysqladm-rs.conf" nullStrippedConfig;
environment.etc."muscl/config.toml".source = lib.pipe cfg.settings [
# 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))
# Load mysql.passwordFile via LoadCredentials
(conf:
if conf.mysql.passwordFile or null != null
then lib.recursiveUpdate conf { mysql.passwordFile = "/run/credentials/muscl.service/mysql-password"; }
else conf
)
# Render file
(format.generate "muscl.conf")
];
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 [
{
@@ -96,31 +123,38 @@ in
}
];
systemd.services."mysqladm" = {
description = "MySQL administration tool for non-admin users";
restartTriggers = [ config.environment.etc."mysqladm/config.toml".source ];
requires = [ "mysqladm.socket" ];
systemd.packages = [ cfg.package ];
systemd.sockets."muscl".wantedBy = [ "sockets.target" ];
systemd.services."muscl" = {
reloadTriggers = [ config.environment.etc."muscl/config.toml".source ];
serviceConfig = {
Type = "notify";
ExecStart = "${lib.getExe cfg.package} ${cfg.logLevel} server --systemd socket-activate";
ExecStart = [
""
"${lib.getExe' cfg.package "muscl-server"} ${cfg.logLevel} --systemd --disable-landlock socket-activate"
];
WatchdogSec = 15;
ExecReload = [
""
"${lib.getExe' pkgs.coreutils "kill"} -HUP $MAINPID"
];
# Although this is a multi-instance unit, the constant `User` field is needed
# for authentication via mysql's auth_socket plugin to work.
User = "mysqladm";
Group = "mysqladm";
DynamicUser = true;
RuntimeDirectory = "muscl/root-mnt";
RuntimeDirectoryMode = "0700";
RootDirectory = "/run/muscl/root-mnt";
BindReadOnlyPaths = [
builtins.storeDir
"/etc"
]
++ lib.optionals (cfg.settings.mysql.socket_path != null) [
cfg.settings.mysql.socket_path
];
ConfigurationDirectory = "mysqladm";
RuntimeDirectory = "mysqladm";
# This is required to read unix user/group details.
PrivateUsers = false;
# Needed to communicate with MySQL.
PrivateNetwork = false;
PrivateIPC = false;
ImportCredential = "";
LoadCredential = lib.mkIf (cfg.settings.mysql.passwordFile != null) [
"mysql-password:${cfg.settings.mysql.passwordFile}"
];
IPAddressDeny = "any";
IPAddressAllow = [
@@ -131,48 +165,6 @@ in
RestrictAddressFamilies = [ "AF_UNIX" ]
++ (lib.optionals (cfg.settings.mysql.host != null) [ "AF_INET" "AF_INET6" ]);
AmbientCapabilities = [ "" ];
CapabilityBoundingSet = [ "" ];
DeviceAllow = [ "" ];
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateMounts = true;
PrivateTmp = "yes";
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = "strict";
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RemoveIPC = true;
UMask = "0777";
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SocketBindDeny = [ "any" ];
SystemCallFilter = [
"@system-service"
"~@privileged"
"~@resources"
];
};
};
systemd.sockets."mysqladm" = {
description = "MySQL administration tool for non-admin users";
wantedBy = [ "sockets.target" ];
socketConfig = {
ListenStream = cfg.settings.server.socket_path;
Accept = "no";
PassCredentials = true;
};
};
};

View File

@@ -0,0 +1,96 @@
{ self, nixpkgs, ... }:
let
inherit (nixpkgs) lib;
in
nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
pkgs = import nixpkgs {
system = "x86_64-linux";
overlays = [
self.overlays.muscl-suid-crane
];
};
modules = [
"${nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix"
"${nixpkgs}/nixos/tests/common/user-account.nix"
({ config, pkgs, ... }: {
system.stateVersion = config.system.nixos.release;
virtualisation.graphics = false;
users = {
groups = {
a = { };
b = { };
muscl = { };
};
users.muscl = {
isSystemUser = true;
group = "muscl";
};
users.alice.extraGroups = [
"a"
"b"
"wheel"
"systemd-journal"
];
extraUsers.root.password = "root";
};
services.getty.autologinUser = "alice";
users.motd = ''
=================================
Welcome to the muscl SUID/SGID vm!
Try running:
${pkgs.muscl.meta.mainProgram}
Password for alice is 'foobar'
Password for root is 'root'
To exit, press Ctrl+A, then X
=================================
'';
services.mysql = {
enable = true;
package = pkgs.mariadb;
ensureUsers = [
{
name = "muscl";
ensurePermissions = {
"mysql.*" = "SELECT, INSERT, UPDATE, DELETE";
"*.*" = "GRANT OPTION, CREATE, DROP";
};
}
];
};
security.wrappers.muscl = {
owner = "muscl";
group = "muscl";
setuid = true;
source = lib.getExe pkgs.muscl;
};
environment.etc."muscl/config.toml".source = (pkgs.formats.toml { }).generate "muscl-config.toml" {
mysql = {
username = "muscl";
password = "snakeoil";
socket_path = "/run/mysqld/mysqld.sock";
};
};
# TODO: extra setup commands:
# set password for mysql user
programs.vim = {
enable = true;
defaultEditor = true;
};
environment.systemPackages = with pkgs; [ jq pkgs.muscl ];
})
];
}

View File

@@ -0,0 +1,69 @@
{ self, nixpkgs, useMariadb ? true, ... }:
nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
pkgs = import nixpkgs {
system = "x86_64-linux";
overlays = [
self.overlays.muscl-crane
];
};
modules = [
"${nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix"
"${nixpkgs}/nixos/tests/common/user-account.nix"
self.nixosModules.default
({ config, pkgs, ... }: {
system.stateVersion = config.system.nixos.release;
virtualisation.graphics = false;
users = {
groups = {
a = { };
b = { };
};
users.alice.extraGroups = [
"a"
"b"
"wheel"
"systemd-journal"
];
extraUsers.root.password = "root";
};
services.getty.autologinUser = "alice";
users.motd = ''
=================================
Welcome to the muscl vm!
Try running:
${config.services.muscl.package.meta.mainProgram}
Password for alice is 'foobar'
Password for root is 'root'
To exit, press Ctrl+A, then X
=================================
'';
services.mysql = {
enable = true;
package = if useMariadb then pkgs.mariadb else pkgs.mysql84;
dataDir = if useMariadb then "/var/lib/mariadb" else "/var/lib/mysql";
};
services.muscl = {
enable = true;
logLevel = "trace";
createLocalDatabaseUser = true;
};
programs.vim = {
enable = true;
defaultEditor = true;
};
environment.systemPackages = with pkgs; [ jq ];
})
];
}

50
scripts/create-deb.sh Executable file
View File

@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
declare -r RUST_PROFILE="release-lto"
if [[ "${CREATE_DEB_DEBUG:-}" == "1" ]]; then
set -x
fi
if ! command -v cargo &> /dev/null; then
echo "cargo could not be found" >&2
exit 1
fi
if ! command -v cargo-deb &> /dev/null; then
echo "cargo-deb could not be found" >&2
exit 1
fi
cargo build --profile "$RUST_PROFILE"
mkdir -p assets/completions
(
PATH="./target/$RUST_PROFILE:$PATH"
COMPLETE=bash muscl > assets/completions/muscl.bash
COMPLETE=zsh muscl > assets/completions/_muscl
COMPLETE=fish muscl > assets/completions/muscl.fish
COMPLETE=bash mysql-dbadm > assets/completions/mysql-dbadm.bash
COMPLETE=zsh mysql-dbadm > assets/completions/_mysql-dbadm
COMPLETE=fish mysql-dbadm > assets/completions/mysql-dbadm.fish
COMPLETE=bash mysql-useradm > assets/completions/mysql-useradm.bash
COMPLETE=zsh mysql-useradm > assets/completions/_mysql-useradm
COMPLETE=fish mysql-useradm > assets/completions/mysql-useradm.fish
)
# See https://github.com/clap-rs/clap/issues/1764
sed -i 's/muscl/mysql-dbadm/g' assets/completions/{mysql-dbadm.bash,mysql-dbadm.fish,_mysql-dbadm}
sed -i 's/muscl/mysql-useradm/g' assets/completions/{mysql-useradm.bash,mysql-useradm.fish,_mysql-useradm}
DEFAULT_CARGO_DEB_ARGS=(
--profile "$RUST_PROFILE"
--no-build
)
cargo deb "${DEFAULT_CARGO_DEB_ARGS[@]}" "$@"

View File

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

View File

@@ -1,20 +0,0 @@
use crate::core::protocol::Response;
pub fn erroneous_server_response(
response: Option<Result<Response, std::io::Error>>,
) -> anyhow::Result<()> {
match response {
Some(Ok(Response::Error(e))) => {
anyhow::bail!("Server returned error: {}", e);
}
Some(Err(e)) => {
anyhow::bail!(e);
}
Some(response) => {
anyhow::bail!("Unexpected response from server: {:?}", response);
}
None => {
anyhow::bail!("No response from server");
}
}
}

View File

@@ -1,491 +0,0 @@
use anyhow::Context;
use clap::Parser;
use dialoguer::{Confirm, Editor};
use futures_util::{SinkExt, StreamExt};
use nix::unistd::{User, getuid};
use prettytable::{Cell, Row, Table};
use crate::{
cli::common::erroneous_server_response,
core::{
common::yn,
database_privileges::{
db_priv_field_human_readable_name, diff_privileges, display_privilege_diffs,
generate_editor_content_from_privilege_data, parse_privilege_data_from_editor_content,
parse_privilege_table_cli_arg,
},
protocol::{
ClientToServerMessageStream, MySQLDatabase, Request, Response,
print_create_databases_output_status, print_create_databases_output_status_json,
print_drop_databases_output_status, print_drop_databases_output_status_json,
print_modify_database_privileges_output_status,
},
},
server::sql::database_privilege_operations::{DATABASE_PRIVILEGE_FIELDS, DatabasePrivilegeRow},
};
#[derive(Parser, Debug, Clone)]
// #[command(next_help_heading = Some(DATABASE_COMMAND_HEADER))]
pub enum DatabaseCommand {
/// Create one or more databases
#[command()]
CreateDb(DatabaseCreateArgs),
/// Delete one or more databases
#[command()]
DropDb(DatabaseDropArgs),
/// Print information about one or more databases
///
/// If no database name is provided, all databases you have access will be shown.
#[command()]
ShowDb(DatabaseShowArgs),
/// Print user privileges for one or more databases
///
/// If no database names are provided, all databases you have access to will be shown.
#[command()]
ShowDbPrivs(DatabaseShowPrivsArgs),
/// Change user privileges for one or more databases. See `edit-db-privs --help` for details.
///
/// This command has two modes of operation:
///
/// 1. Interactive mode: If nothing else is specified, the user will be prompted to edit the privileges using a text editor.
///
/// You can configure your preferred text editor by setting the `VISUAL` or `EDITOR` environment variables.
///
/// Follow the instructions inside the editor for more information.
///
/// 2. Non-interactive mode: If the `-p` flag is specified, the user can write privileges using arguments.
///
/// The privilege arguments should be formatted as `<db>:<user>:<privileges>`
/// where the privileges are a string of characters, each representing a single privilege.
/// The character `A` is an exception - it represents all privileges.
///
/// The character-to-privilege mapping is defined as follows:
///
/// - `s` - SELECT
/// - `i` - INSERT
/// - `u` - UPDATE
/// - `d` - DELETE
/// - `c` - CREATE
/// - `D` - DROP
/// - `a` - ALTER
/// - `I` - INDEX
/// - `t` - CREATE TEMPORARY TABLES
/// - `l` - LOCK TABLES
/// - `r` - REFERENCES
/// - `A` - ALL PRIVILEGES
///
/// If you provide a database name, you can omit it from the privilege string,
/// e.g. `edit-db-privs my_db -p my_user:siu` is equivalent to `edit-db-privs -p my_db:my_user:siu`.
/// While it doesn't make much of a difference for a single edit, it can be useful for editing multiple users
/// on the same database at once.
///
/// Example usage of non-interactive mode:
///
/// Enable privileges `SELECT`, `INSERT`, and `UPDATE` for user `my_user` on database `my_db`:
///
/// `mysqladm edit-db-privs -p my_db:my_user:siu`
///
/// Enable all privileges for user `my_other_user` on database `my_other_db`:
///
/// `mysqladm edit-db-privs -p my_other_db:my_other_user:A`
///
/// Set miscellaneous privileges for multiple users on database `my_db`:
///
/// `mysqladm edit-db-privs my_db -p my_user:siu my_other_user:ct``
///
#[command(verbatim_doc_comment)]
EditDbPrivs(DatabaseEditPrivsArgs),
}
#[derive(Parser, Debug, Clone)]
pub struct DatabaseCreateArgs {
/// The name of the database(s) to create
#[arg(num_args = 1..)]
name: Vec<MySQLDatabase>,
/// Print the information as JSON
#[arg(short, long)]
json: bool,
}
#[derive(Parser, Debug, Clone)]
pub struct DatabaseDropArgs {
/// The name of the database(s) to drop
#[arg(num_args = 1..)]
name: Vec<MySQLDatabase>,
/// Print the information as JSON
#[arg(short, long)]
json: bool,
}
#[derive(Parser, Debug, Clone)]
pub struct DatabaseShowArgs {
/// The name of the database(s) to show
#[arg(num_args = 0..)]
name: Vec<MySQLDatabase>,
/// Print the information as JSON
#[arg(short, long)]
json: bool,
}
#[derive(Parser, Debug, Clone)]
pub struct DatabaseShowPrivsArgs {
/// The name of the database(s) to show
#[arg(num_args = 0..)]
name: Vec<MySQLDatabase>,
/// Print the information as JSON
#[arg(short, long)]
json: bool,
}
#[derive(Parser, Debug, Clone)]
pub struct DatabaseEditPrivsArgs {
/// The name of the database to edit privileges for
pub name: Option<MySQLDatabase>,
#[arg(short, long, value_name = "[DATABASE:]USER:PRIVILEGES", num_args = 0..)]
pub privs: Vec<String>,
/// Print the information as JSON
#[arg(short, long)]
pub json: bool,
/// Specify the text editor to use for editing privileges
#[arg(short, long)]
pub editor: Option<String>,
/// Disable interactive confirmation before saving changes
#[arg(short, long)]
pub yes: bool,
}
pub async fn handle_command(
command: DatabaseCommand,
server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
match command {
DatabaseCommand::CreateDb(args) => create_databases(args, server_connection).await,
DatabaseCommand::DropDb(args) => drop_databases(args, server_connection).await,
DatabaseCommand::ShowDb(args) => show_databases(args, server_connection).await,
DatabaseCommand::ShowDbPrivs(args) => {
show_database_privileges(args, server_connection).await
}
DatabaseCommand::EditDbPrivs(args) => {
edit_database_privileges(args, server_connection).await
}
}
}
async fn create_databases(
args: DatabaseCreateArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
if args.name.is_empty() {
anyhow::bail!("No database names provided");
}
let message = Request::CreateDatabases(args.name.to_owned());
server_connection.send(message).await?;
let result = match server_connection.next().await {
Some(Ok(Response::CreateDatabases(result))) => result,
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
if args.json {
print_create_databases_output_status_json(&result);
} else {
print_create_databases_output_status(&result);
}
Ok(())
}
async fn drop_databases(
args: DatabaseDropArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
if args.name.is_empty() {
anyhow::bail!("No database names provided");
}
let message = Request::DropDatabases(args.name.to_owned());
server_connection.send(message).await?;
let result = match server_connection.next().await {
Some(Ok(Response::DropDatabases(result))) => result,
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
if args.json {
print_drop_databases_output_status_json(&result);
} else {
print_drop_databases_output_status(&result);
};
Ok(())
}
async fn show_databases(
args: DatabaseShowArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
let message = if args.name.is_empty() {
Request::ListDatabases(None)
} else {
Request::ListDatabases(Some(args.name.to_owned()))
};
server_connection.send(message).await?;
// TODO: collect errors for json output.
let database_list = match server_connection.next().await {
Some(Ok(Response::ListDatabases(databases))) => databases
.into_iter()
.filter_map(|(database_name, result)| match result {
Ok(database_row) => Some(database_row),
Err(err) => {
eprintln!("{}", err.to_error_message(&database_name));
eprintln!("Skipping...");
println!();
None
}
})
.collect::<Vec<_>>(),
Some(Ok(Response::ListAllDatabases(database_list))) => match database_list {
Ok(list) => list,
Err(err) => {
server_connection.send(Request::Exit).await?;
return Err(
anyhow::anyhow!(err.to_error_message()).context("Failed to list databases")
);
}
},
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
if args.json {
println!("{}", serde_json::to_string_pretty(&database_list)?);
} else if database_list.is_empty() {
println!("No databases to show.");
} else {
let mut table = Table::new();
table.add_row(Row::new(vec![Cell::new("Database")]));
for db in database_list {
table.add_row(row![db.database]);
}
table.printstd();
}
Ok(())
}
async fn show_database_privileges(
args: DatabaseShowPrivsArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
let message = if args.name.is_empty() {
Request::ListPrivileges(None)
} else {
Request::ListPrivileges(Some(args.name.to_owned()))
};
server_connection.send(message).await?;
let privilege_data = match server_connection.next().await {
Some(Ok(Response::ListPrivileges(databases))) => databases
.into_iter()
.filter_map(|(database_name, result)| match result {
Ok(privileges) => Some(privileges),
Err(err) => {
eprintln!("{}", err.to_error_message(&database_name));
eprintln!("Skipping...");
println!();
None
}
})
.flatten()
.collect::<Vec<_>>(),
Some(Ok(Response::ListAllPrivileges(privilege_rows))) => match privilege_rows {
Ok(list) => list,
Err(err) => {
server_connection.send(Request::Exit).await?;
return Err(anyhow::anyhow!(err.to_error_message())
.context("Failed to list database privileges"));
}
},
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
if args.json {
println!("{}", serde_json::to_string_pretty(&privilege_data)?);
} else if privilege_data.is_empty() {
println!("No database privileges to show.");
} else {
let mut table = Table::new();
table.add_row(Row::new(
DATABASE_PRIVILEGE_FIELDS
.into_iter()
.map(db_priv_field_human_readable_name)
.map(|name| Cell::new(&name))
.collect(),
));
for row in privilege_data {
table.add_row(row![
row.db,
row.user,
c->yn(row.select_priv),
c->yn(row.insert_priv),
c->yn(row.update_priv),
c->yn(row.delete_priv),
c->yn(row.create_priv),
c->yn(row.drop_priv),
c->yn(row.alter_priv),
c->yn(row.index_priv),
c->yn(row.create_tmp_table_priv),
c->yn(row.lock_tables_priv),
c->yn(row.references_priv),
]);
}
table.printstd();
}
Ok(())
}
pub async fn edit_database_privileges(
args: DatabaseEditPrivsArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
let message = Request::ListPrivileges(args.name.to_owned().map(|name| vec![name]));
server_connection.send(message).await?;
let privilege_data = match server_connection.next().await {
Some(Ok(Response::ListPrivileges(databases))) => databases
.into_iter()
.filter_map(|(database_name, result)| match result {
Ok(privileges) => Some(privileges),
Err(err) => {
eprintln!("{}", err.to_error_message(&database_name));
eprintln!("Skipping...");
println!();
None
}
})
.flatten()
.collect::<Vec<_>>(),
Some(Ok(Response::ListAllPrivileges(privilege_rows))) => match privilege_rows {
Ok(list) => list,
Err(err) => {
server_connection.send(Request::Exit).await?;
return Err(anyhow::anyhow!(err.to_error_message())
.context("Failed to list database privileges"));
}
},
response => return erroneous_server_response(response),
};
let privileges_to_change = if !args.privs.is_empty() {
parse_privilege_tables_from_args(&args)?
} else {
edit_privileges_with_editor(&privilege_data, args.name.as_ref())?
};
let diffs = diff_privileges(&privilege_data, &privileges_to_change);
if diffs.is_empty() {
println!("No changes to make.");
server_connection.send(Request::Exit).await?;
return Ok(());
}
println!("The following changes will be made:\n");
println!("{}", display_privilege_diffs(&diffs));
if !args.yes
&& !Confirm::new()
.with_prompt("Do you want to apply these changes?")
.default(false)
.show_default(true)
.interact()?
{
server_connection.send(Request::Exit).await?;
return Ok(());
}
let message = Request::ModifyPrivileges(diffs);
server_connection.send(message).await?;
let result = match server_connection.next().await {
Some(Ok(Response::ModifyPrivileges(result))) => result,
response => return erroneous_server_response(response),
};
print_modify_database_privileges_output_status(&result);
server_connection.send(Request::Exit).await?;
Ok(())
}
fn parse_privilege_tables_from_args(
args: &DatabaseEditPrivsArgs,
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
debug_assert!(!args.privs.is_empty());
let result = if let Some(name) = &args.name {
args.privs
.iter()
.map(|p| {
parse_privilege_table_cli_arg(&format!("{}:{}", name, &p))
.context(format!("Failed parsing database privileges: `{}`", &p))
})
.collect::<anyhow::Result<Vec<DatabasePrivilegeRow>>>()?
} else {
args.privs
.iter()
.map(|p| {
parse_privilege_table_cli_arg(p)
.context(format!("Failed parsing database privileges: `{}`", &p))
})
.collect::<anyhow::Result<Vec<DatabasePrivilegeRow>>>()?
};
Ok(result)
}
fn edit_privileges_with_editor(
privilege_data: &[DatabasePrivilegeRow],
database_name: Option<&MySQLDatabase>,
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
let unix_user = User::from_uid(getuid())
.context("Failed to look up your UNIX username")
.and_then(|u| u.ok_or(anyhow::anyhow!("Failed to look up your UNIX username")))?;
let editor_content =
generate_editor_content_from_privilege_data(privilege_data, &unix_user.name, database_name);
// TODO: handle errors better here
let result = Editor::new().extension("tsv").edit(&editor_content)?;
match result {
None => Ok(privilege_data.to_vec()),
Some(result) => parse_privilege_data_from_editor_content(result)
.context("Could not parse privilege data from editor"),
}
}

View File

@@ -1,176 +0,0 @@
use crate::core::protocol::{
CreateDatabaseError, CreateUserError, DbOrUser, DropDatabaseError, DropUserError,
GetDatabasesPrivilegeDataError, ListUsersError,
};
pub fn name_validation_error_to_error_message(name: &str, db_or_user: DbOrUser) -> String {
let argv0 = std::env::args().next().unwrap_or_else(|| match db_or_user {
DbOrUser::Database => "mysql-dbadm".to_string(),
DbOrUser::User => "mysql-useradm".to_string(),
});
format!(
concat!(
"{}: {} name '{}' contains invalid characters.\n",
"Only A-Z, a-z, 0-9, _ (underscore) and - (dash) permitted. Skipping.",
),
argv0,
db_or_user.capitalized(),
name,
)
}
pub fn owner_validation_error_message(name: &str, db_or_user: DbOrUser) -> String {
format!(
"You are not in charge of mysql-{}: '{}'. Skipping.",
db_or_user.lowercased(),
name
)
}
pub fn handle_create_user_error(error: CreateUserError, name: &str) {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-useradm".to_string());
match error {
CreateUserError::SanitizationError(_) => {
eprintln!(
"{}",
name_validation_error_to_error_message(name, DbOrUser::User)
);
}
CreateUserError::OwnershipError(_) => {
eprintln!("{}", owner_validation_error_message(name, DbOrUser::User));
}
CreateUserError::MySqlError(_) | CreateUserError::UserAlreadyExists => {
eprintln!("{}: Failed to create user '{}'.", argv0, name);
}
}
}
pub fn handle_drop_user_error(error: DropUserError, name: &str) {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-useradm".to_string());
match error {
DropUserError::SanitizationError(_) => {
eprintln!(
"{}",
name_validation_error_to_error_message(name, DbOrUser::User)
);
}
DropUserError::OwnershipError(_) => {
eprintln!("{}", owner_validation_error_message(name, DbOrUser::User));
}
DropUserError::MySqlError(_) | DropUserError::UserDoesNotExist => {
eprintln!("{}: Failed to delete user '{}'.", argv0, name);
}
}
}
pub fn handle_list_users_error(error: ListUsersError, name: &str) {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-useradm".to_string());
match error {
ListUsersError::SanitizationError(_) => {
eprintln!(
"{}",
name_validation_error_to_error_message(name, DbOrUser::User)
);
}
ListUsersError::OwnershipError(_) => {
eprintln!("{}", owner_validation_error_message(name, DbOrUser::User));
}
ListUsersError::UserDoesNotExist => {
eprintln!(
"{}: User '{}' does not exist. You must create it first.",
argv0, name,
);
}
ListUsersError::MySqlError(_) => {
eprintln!("{}: Failed to look up password for user '{}'", argv0, name);
}
}
}
// ----------------------------------------------------------------------------
pub fn handle_create_database_error(error: CreateDatabaseError, name: &str) {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-dbadm".to_string());
match error {
CreateDatabaseError::SanitizationError(_) => {
eprintln!(
"{}",
name_validation_error_to_error_message(name, DbOrUser::Database)
);
}
CreateDatabaseError::OwnershipError(_) => {
eprintln!(
"{}",
owner_validation_error_message(name, DbOrUser::Database)
);
}
CreateDatabaseError::MySqlError(_) => {
eprintln!("{}: Cannot create database '{}'.", argv0, name);
}
CreateDatabaseError::DatabaseAlreadyExists => {
eprintln!("{}: Database '{}' already exists.", argv0, name);
}
}
}
pub fn handle_drop_database_error(error: DropDatabaseError, name: &str) {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-dbadm".to_string());
match error {
DropDatabaseError::SanitizationError(_) => {
eprintln!(
"{}",
name_validation_error_to_error_message(name, DbOrUser::Database)
);
}
DropDatabaseError::OwnershipError(_) => {
eprintln!(
"{}",
owner_validation_error_message(name, DbOrUser::Database)
);
}
DropDatabaseError::MySqlError(_) => {
eprintln!("{}: Cannot drop database '{}'.", argv0, name);
}
DropDatabaseError::DatabaseDoesNotExist => {
eprintln!("{}: Database '{}' doesn't exist.", argv0, name);
}
}
}
pub fn format_show_database_error_message(
error: GetDatabasesPrivilegeDataError,
name: &str,
) -> String {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-dbadm".to_string());
match error {
GetDatabasesPrivilegeDataError::SanitizationError(_) => {
name_validation_error_to_error_message(name, DbOrUser::Database)
}
GetDatabasesPrivilegeDataError::OwnershipError(_) => {
owner_validation_error_message(name, DbOrUser::Database)
}
GetDatabasesPrivilegeDataError::MySqlError(err) => {
format!(
"{}: Failed to look up privileges for database '{}': {}",
argv0, name, err
)
}
GetDatabasesPrivilegeDataError::DatabaseDoesNotExist => {
format!("{}: Database '{}' doesn't exist.", argv0, name)
}
}
}

View File

@@ -1,59 +0,0 @@
use clap::Parser;
use futures_util::{SinkExt, StreamExt};
use crate::core::protocol::{
ClientToServerMessageStream, Request, Response
};
use super::common::erroneous_server_response;
#[allow(clippy::enum_variant_names)]
#[derive(Parser, Debug, Clone)]
pub enum OtherCommand {
/// Check if the tool is set up correctly, and the server is running.
#[command()]
Status(StatusArgs),
}
#[derive(Parser, Debug, Clone)]
pub struct StatusArgs {
/// Print the information as JSON
#[arg(short, long)]
json: bool,
}
pub async fn handle_command(
command: OtherCommand,
server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
match command {
OtherCommand::Status(args) => status(args, server_connection).await,
}
}
/// TODO: this should be moved all the way out to the main function, so that
/// we can teste the server connection before it fails to be established.
async fn status(
args: StatusArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
if let Err(err) = server_connection.send(Request::Ping).await {
server_connection.close().await.ok();
anyhow::bail!(err);
}
match server_connection.next().await {
Some(Ok(Response::Pong)) => (),
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
if args.json {
// print_drop_users_output_status_json(&result);
} else {
// print_drop_users_output_status(&result);
}
Ok(())
}

View File

@@ -1,425 +0,0 @@
use anyhow::Context;
use clap::Parser;
use dialoguer::{Confirm, Password};
use futures_util::{SinkExt, StreamExt};
use crate::core::protocol::{
ClientToServerMessageStream, ListUsersError, MySQLUser, Request, Response,
print_create_users_output_status, print_create_users_output_status_json,
print_drop_users_output_status, print_drop_users_output_status_json,
print_lock_users_output_status, print_lock_users_output_status_json,
print_set_password_output_status, print_unlock_users_output_status,
print_unlock_users_output_status_json,
};
use super::common::erroneous_server_response;
#[derive(Parser, Debug, Clone)]
pub struct UserArgs {
#[clap(subcommand)]
subcmd: UserCommand,
}
#[allow(clippy::enum_variant_names)]
#[derive(Parser, Debug, Clone)]
pub enum UserCommand {
/// Create one or more users
#[command()]
CreateUser(UserCreateArgs),
/// Delete one or more users
#[command()]
DropUser(UserDeleteArgs),
/// Change the MySQL password for a user
#[command()]
PasswdUser(UserPasswdArgs),
/// Print information about one or more users
///
/// If no username is provided, all users you have access will be shown.
#[command()]
ShowUser(UserShowArgs),
/// Lock account for one or more users
#[command()]
LockUser(UserLockArgs),
/// Unlock account for one or more users
#[command()]
UnlockUser(UserUnlockArgs),
}
#[derive(Parser, Debug, Clone)]
pub struct UserCreateArgs {
#[arg(num_args = 1..)]
username: Vec<MySQLUser>,
/// Do not ask for a password, leave it unset
#[clap(long)]
no_password: bool,
/// Print the information as JSON
///
/// Note that this implies `--no-password`, since the command will become non-interactive.
#[arg(short, long)]
json: bool,
}
#[derive(Parser, Debug, Clone)]
pub struct UserDeleteArgs {
#[arg(num_args = 1..)]
username: Vec<MySQLUser>,
/// Print the information as JSON
#[arg(short, long)]
json: bool,
}
#[derive(Parser, Debug, Clone)]
pub struct UserPasswdArgs {
username: MySQLUser,
#[clap(short, long)]
password_file: Option<String>,
/// Print the information as JSON
#[arg(short, long)]
json: bool,
}
#[derive(Parser, Debug, Clone)]
pub struct UserShowArgs {
#[arg(num_args = 0..)]
username: Vec<MySQLUser>,
/// Print the information as JSON
#[arg(short, long)]
json: bool,
}
#[derive(Parser, Debug, Clone)]
pub struct UserLockArgs {
#[arg(num_args = 1..)]
username: Vec<MySQLUser>,
/// Print the information as JSON
#[arg(short, long)]
json: bool,
}
#[derive(Parser, Debug, Clone)]
pub struct UserUnlockArgs {
#[arg(num_args = 1..)]
username: Vec<MySQLUser>,
/// Print the information as JSON
#[arg(short, long)]
json: bool,
}
pub async fn handle_command(
command: UserCommand,
server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
match command {
UserCommand::CreateUser(args) => create_users(args, server_connection).await,
UserCommand::DropUser(args) => drop_users(args, server_connection).await,
UserCommand::PasswdUser(args) => passwd_user(args, server_connection).await,
UserCommand::ShowUser(args) => show_users(args, server_connection).await,
UserCommand::LockUser(args) => lock_users(args, server_connection).await,
UserCommand::UnlockUser(args) => unlock_users(args, server_connection).await,
}
}
async fn create_users(
args: UserCreateArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
if args.username.is_empty() {
anyhow::bail!("No usernames provided");
}
let message = Request::CreateUsers(args.username.to_owned());
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
anyhow::bail!(anyhow::Error::from(err).context("Failed to communicate with server"));
}
let result = match server_connection.next().await {
Some(Ok(Response::CreateUsers(result))) => result,
response => return erroneous_server_response(response),
};
if args.json {
print_create_users_output_status_json(&result);
} else {
print_create_users_output_status(&result);
let successfully_created_users = result
.iter()
.filter_map(|(username, result)| result.as_ref().ok().map(|_| username))
.collect::<Vec<_>>();
for username in successfully_created_users {
if !args.no_password
&& Confirm::new()
.with_prompt(format!(
"Do you want to set a password for user '{}'?",
username
))
.default(false)
.interact()?
{
let password = read_password_from_stdin_with_double_check(username)?;
let message = Request::PasswdUser(username.to_owned(), password);
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
anyhow::bail!(err);
}
match server_connection.next().await {
Some(Ok(Response::PasswdUser(result))) => {
print_set_password_output_status(&result, username)
}
response => return erroneous_server_response(response),
}
println!();
}
}
}
server_connection.send(Request::Exit).await?;
Ok(())
}
async fn drop_users(
args: UserDeleteArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
if args.username.is_empty() {
anyhow::bail!("No usernames provided");
}
let message = Request::DropUsers(args.username.to_owned());
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
anyhow::bail!(err);
}
let result = match server_connection.next().await {
Some(Ok(Response::DropUsers(result))) => result,
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
if args.json {
print_drop_users_output_status_json(&result);
} else {
print_drop_users_output_status(&result);
}
Ok(())
}
pub fn read_password_from_stdin_with_double_check(username: &MySQLUser) -> anyhow::Result<String> {
Password::new()
.with_prompt(format!("New MySQL password for user '{}'", username))
.with_confirmation(
format!("Retype new MySQL password for user '{}'", username),
"Passwords do not match",
)
.interact()
.map_err(Into::into)
}
async fn passwd_user(
args: UserPasswdArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
// TODO: create a "user" exists check" command
let message = Request::ListUsers(Some(vec![args.username.to_owned()]));
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
anyhow::bail!(err);
}
let response = match server_connection.next().await {
Some(Ok(Response::ListUsers(users))) => users,
response => return erroneous_server_response(response),
};
match response
.get(&args.username)
.unwrap_or(&Err(ListUsersError::UserDoesNotExist))
{
Ok(_) => {}
Err(err) => {
server_connection.send(Request::Exit).await?;
server_connection.close().await.ok();
anyhow::bail!("{}", err.to_error_message(&args.username));
}
}
let password = if let Some(password_file) = args.password_file {
std::fs::read_to_string(password_file)
.context("Failed to read password file")?
.trim()
.to_string()
} else {
read_password_from_stdin_with_double_check(&args.username)?
};
let message = Request::PasswdUser(args.username.to_owned(), password);
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
anyhow::bail!(err);
}
let result = match server_connection.next().await {
Some(Ok(Response::PasswdUser(result))) => result,
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
print_set_password_output_status(&result, &args.username);
Ok(())
}
async fn show_users(
args: UserShowArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
let message = if args.username.is_empty() {
Request::ListUsers(None)
} else {
Request::ListUsers(Some(args.username.to_owned()))
};
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
anyhow::bail!(err);
}
let users = match server_connection.next().await {
Some(Ok(Response::ListUsers(users))) => users
.into_iter()
.filter_map(|(username, result)| match result {
Ok(user) => Some(user),
Err(err) => {
eprintln!("{}", err.to_error_message(&username));
eprintln!("Skipping...");
None
}
})
.collect::<Vec<_>>(),
Some(Ok(Response::ListAllUsers(users))) => match users {
Ok(users) => users,
Err(err) => {
server_connection.send(Request::Exit).await?;
return Err(
anyhow::anyhow!(err.to_error_message()).context("Failed to list all users")
);
}
},
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&users).context("Failed to serialize users to JSON")?
);
} else if users.is_empty() {
println!("No users to show.");
} else {
let mut table = prettytable::Table::new();
table.add_row(row![
"User",
"Password is set",
"Locked",
"Databases where user has privileges"
]);
for user in users {
table.add_row(row![
user.user,
user.has_password,
user.is_locked,
user.databases.join("\n")
]);
}
table.printstd();
}
Ok(())
}
async fn lock_users(
args: UserLockArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
if args.username.is_empty() {
anyhow::bail!("No usernames provided");
}
let message = Request::LockUsers(args.username.to_owned());
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
anyhow::bail!(err);
}
let result = match server_connection.next().await {
Some(Ok(Response::LockUsers(result))) => result,
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
if args.json {
print_lock_users_output_status_json(&result);
} else {
print_lock_users_output_status(&result);
}
Ok(())
}
async fn unlock_users(
args: UserUnlockArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
if args.username.is_empty() {
anyhow::bail!("No usernames provided");
}
let message = Request::UnlockUsers(args.username.to_owned());
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
anyhow::bail!(err);
}
let result = match server_connection.next().await {
Some(Ok(Response::UnlockUsers(result))) => result,
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
if args.json {
print_unlock_users_output_status_json(&result);
} else {
print_unlock_users_output_status(&result);
}
Ok(())
}

View File

@@ -1,7 +1,4 @@
mod common;
pub mod database_command;
pub mod other_command;
pub mod user_command;
pub mod commands;
#[cfg(feature = "mysql-admutils-compatibility")]
pub mod mysql_admutils_compatibility;

79
src/client/commands.rs Normal file
View File

@@ -0,0 +1,79 @@
mod check_auth;
mod create_db;
mod create_user;
mod drop_db;
mod drop_user;
mod edit_privs;
mod lock_user;
mod passwd_user;
mod show_db;
mod show_privs;
mod show_user;
mod unlock_user;
pub use check_auth::*;
pub use create_db::*;
pub use create_user::*;
pub use drop_db::*;
pub use drop_user::*;
pub use edit_privs::*;
pub use lock_user::*;
pub use passwd_user::*;
pub use show_db::*;
pub use show_privs::*;
pub use show_user::*;
pub use unlock_user::*;
use futures_util::SinkExt;
use itertools::Itertools;
use tokio_stream::StreamExt;
use crate::core::protocol::{ClientToServerMessageStream, Request, Response};
/// 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}");
}
Some(Err(e)) => {
anyhow::bail!(e);
}
Some(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

@@ -0,0 +1,71 @@
use crate::{
client::commands::erroneous_server_response,
core::{
protocol::{
ClientToServerMessageStream, Request, Response,
print_check_authorization_output_status, print_check_authorization_output_status_json,
},
types::DbOrUser,
},
};
use clap::Parser;
use futures_util::SinkExt;
use tokio_stream::StreamExt;
#[derive(Parser, Debug, Clone)]
pub struct CheckAuthArgs {
/// The `MySQL` database(s) or user(s) to check authorization for
#[arg(num_args = 1.., value_name = "NAME")]
name: Vec<String>,
/// Treat the provided names as users instead of databases
#[arg(short, long)]
users: bool,
/// Print the information as JSON
#[arg(short, long)]
json: bool,
}
pub async fn check_authorization(
args: CheckAuthArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
if args.name.is_empty() {
anyhow::bail!("No database/user names provided");
}
let payload = args
.name
.into_iter()
.map(|name| {
if args.users {
DbOrUser::User(name.into())
} else {
DbOrUser::Database(name.into())
}
})
.collect::<Vec<_>>();
let message = Request::CheckAuthorization(payload);
server_connection.send(message).await?;
let result = match server_connection.next().await {
Some(Ok(Response::CheckAuthorization(response))) => response,
response => return erroneous_server_response(response),
};
server_connection.send(Request::Exit).await?;
if args.json {
print_check_authorization_output_status_json(&result);
} else {
print_check_authorization_output_status(&result);
}
if result.values().any(std::result::Result::is_err) {
std::process::exit(1);
}
Ok(())
}

View File

@@ -0,0 +1,71 @@
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},
core::{
completion::prefix_completer,
protocol::{
ClientToServerMessageStream, CreateDatabaseError, Request, Response,
print_create_databases_output_status, print_create_databases_output_status_json,
request_validation::ValidationError,
},
types::MySQLDatabase,
},
};
#[derive(Parser, Debug, Clone)]
pub struct CreateDbArgs {
/// The `MySQL` database(s) to create
#[arg(num_args = 1.., value_name = "DB_NAME")]
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(prefix_completer)))]
name: Vec<MySQLDatabase>,
/// Print the information as JSON
#[arg(short, long)]
json: bool,
}
pub async fn create_databases(
args: CreateDbArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
if args.name.is_empty() {
anyhow::bail!("No database names provided");
}
let message = Request::CreateDatabases(args.name.clone());
server_connection.send(message).await?;
let result = match server_connection.next().await {
Some(Ok(Response::CreateDatabases(result))) => result,
response => return erroneous_server_response(response),
};
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

@@ -0,0 +1,124 @@
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,
},
core::{
completion::prefix_completer,
protocol::{
ClientToServerMessageStream, CreateUserError, Request, Response,
SetUserPasswordRequest, print_create_users_output_status,
print_create_users_output_status_json, print_set_password_output_status,
request_validation::ValidationError,
},
types::MySQLUser,
},
};
#[derive(Parser, Debug, Clone)]
pub struct CreateUserArgs {
/// The `MySQL` user(s) to create
#[arg(num_args = 1.., value_name = "USER_NAME")]
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(prefix_completer)))]
username: Vec<MySQLUser>,
/// Do not ask for a password, leave it unset
#[clap(long)]
no_password: bool,
/// Print the information as JSON
///
/// Note that this implies `--no-password`, since the command will become non-interactive.
#[arg(short, long)]
json: bool,
}
pub async fn create_users(
args: CreateUserArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
if args.username.is_empty() {
anyhow::bail!("No usernames provided");
}
let message = Request::CreateUsers(args.username.clone());
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
anyhow::bail!(anyhow::Error::from(err).context("Failed to communicate with server"));
}
let result = match server_connection.next().await {
Some(Ok(Response::CreateUsers(result))) => result,
response => return erroneous_server_response(response),
};
if args.json {
print_create_users_output_status_json(&result);
} else {
print_create_users_output_status(&result);
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))
.collect::<Vec<_>>();
for username in successfully_created_users {
if !args.no_password
&& Confirm::new()
.with_prompt(format!(
"Do you want to set a password for user '{username}'?"
))
.default(false)
.interact()?
{
let password = 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,
});
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
anyhow::bail!(err);
}
match server_connection.next().await {
Some(Ok(Response::SetUserPassword(result))) => {
print_set_password_output_status(&result, username);
}
response => return erroneous_server_response(response),
}
println!();
}
}
}
server_connection.send(Request::Exit).await?;
if result.values().any(std::result::Result::is_err) {
std::process::exit(1);
}
Ok(())
}

View File

@@ -0,0 +1,97 @@
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, print_authorization_owner_hint},
core::{
completion::mysql_database_completer,
protocol::{
ClientToServerMessageStream, DropDatabaseError, Request, Response,
print_drop_databases_output_status, print_drop_databases_output_status_json,
request_validation::ValidationError,
},
types::MySQLDatabase,
},
};
#[derive(Parser, Debug, Clone)]
pub struct DropDbArgs {
/// The `MySQL` database(s) to drop
#[arg(num_args = 1.., value_name = "DB_NAME")]
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_database_completer)))]
name: Vec<MySQLDatabase>,
/// Print the information as JSON
#[arg(short, long)]
json: bool,
/// Automatically confirm action without prompting
#[arg(short, long)]
yes: bool,
}
pub async fn drop_databases(
args: DropDbArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
if args.name.is_empty() {
anyhow::bail!("No database names provided");
}
if !args.yes {
let confirmation = Confirm::new()
.with_prompt(format!(
"Are you sure you want to drop the databases?\n\n{}\n\nThis action cannot be undone",
args.name
.iter()
.map(|d| format!("- {d}"))
.collect::<Vec<_>>()
.join("\n")
))
.interact()?;
//
if !confirmation {
// 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());
server_connection.send(message).await?;
let result = match server_connection.next().await {
Some(Ok(Response::DropDatabases(result))) => result,
response => return erroneous_server_response(response),
};
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

@@ -0,0 +1,100 @@
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, print_authorization_owner_hint},
core::{
completion::mysql_user_completer,
protocol::{
ClientToServerMessageStream, DropUserError, Request, Response,
print_drop_users_output_status, print_drop_users_output_status_json,
request_validation::ValidationError,
},
types::MySQLUser,
},
};
#[derive(Parser, Debug, Clone)]
pub struct DropUserArgs {
/// The `MySQL` user(s) to drop
#[arg(num_args = 1.., value_name = "USER_NAME")]
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_user_completer)))]
username: Vec<MySQLUser>,
/// Print the information as JSON
#[arg(short, long)]
json: bool,
/// Automatically confirm action without prompting
#[arg(short, long)]
yes: bool,
}
pub async fn drop_users(
args: DropUserArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
if args.username.is_empty() {
anyhow::bail!("No usernames provided");
}
if !args.yes {
let confirmation = Confirm::new()
.with_prompt(format!(
"Are you sure you want to drop the users?\n\n{}\n\nThis action cannot be undone",
args.username
.iter()
.map(|d| format!("- {d}"))
.collect::<Vec<_>>()
.join("\n")
))
.interact()?;
if !confirmation {
// 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());
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
anyhow::bail!(err);
}
let result = match server_connection.next().await {
Some(Ok(Response::DropUsers(result))) => result,
response => return erroneous_server_response(response),
};
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

@@ -0,0 +1,357 @@
use std::collections::{BTreeMap, BTreeSet};
use anyhow::Context;
use clap::{Args, Parser};
use clap_complete::ArgValueCompleter;
use dialoguer::{Confirm, Editor};
use futures_util::SinkExt;
use nix::unistd::{User, getuid};
use tokio_stream::StreamExt;
use crate::{
client::commands::{erroneous_server_response, print_authorization_owner_hint},
core::{
completion::{mysql_database_completer, mysql_user_completer},
database_privileges::{
DatabasePrivilegeEdit, DatabasePrivilegeEditEntry, DatabasePrivilegeRow,
DatabasePrivilegeRowDiff, DatabasePrivilegesDiff, create_or_modify_privilege_rows,
diff_privileges, display_privilege_diffs, generate_editor_content_from_privilege_data,
parse_privilege_data_from_editor_content, reduce_privilege_diffs,
},
protocol::{
ClientToServerMessageStream, ListDatabasesError, ListUsersError,
ModifyDatabasePrivilegesError, Request, Response,
print_modify_database_privileges_output_status, request_validation::ValidationError,
},
types::{MySQLDatabase, MySQLUser},
},
};
#[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.
#[arg(
short,
long,
value_name = "DB_NAME:USER_NAME:[+-]PRIVILEGES",
num_args = 0..,
value_parser = DatabasePrivilegeEditEntry::parse_from_str,
conflicts_with("single_priv"),
)]
pub privs: Vec<DatabasePrivilegeEditEntry>,
#[command(flatten)]
pub single_priv: Option<SinglePrivilegeEditArgs>,
/// Print the information as JSON
#[arg(short, long)]
pub json: bool,
/// Specify the text editor to use for editing privileges
#[arg(
short,
long,
value_name = "COMMAND",
value_hint = clap::ValueHint::CommandString,
)]
pub editor: Option<String>,
/// Disable interactive confirmation before saving changes
#[arg(short, long)]
pub yes: bool,
}
#[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>>> {
let user_list = privilege_diff
.iter()
.map(|diff| diff.get_user_name().clone())
.collect();
let message = Request::ListUsers(Some(user_list));
server_connection.send(message).await?;
let result = match server_connection.next().await {
Some(Ok(Response::ListUsers(user_map))) => user_map,
response => {
erroneous_server_response(response)?;
// Unreachable, but needed to satisfy the type checker
BTreeMap::new()
}
};
let result = result
.into_iter()
.map(|(user, user_result)| (user, user_result.map(|_| ())))
.collect();
Ok(result)
}
async fn databases_exist(
server_connection: &mut ClientToServerMessageStream,
privilege_diff: &BTreeSet<DatabasePrivilegesDiff>,
) -> anyhow::Result<BTreeMap<MySQLDatabase, Result<(), ListDatabasesError>>> {
let database_list = privilege_diff
.iter()
.map(|diff| diff.get_database_name().clone())
.collect();
let message = Request::ListDatabases(Some(database_list));
server_connection.send(message).await?;
let result = match server_connection.next().await {
Some(Ok(Response::ListDatabases(database_map))) => database_map,
response => {
erroneous_server_response(response)?;
// Unreachable, but needed to satisfy the type checker
BTreeMap::new()
}
};
let result = result
.into_iter()
.map(|(database, db_result)| (database, db_result.map(|_| ())))
.collect();
Ok(result)
}
// TODO: reduce the complexity of this function
pub async fn edit_database_privileges(
args: EditPrivsArgs,
// NOTE: this is only used for backwards compat with mysql-admutils
use_database: Option<MySQLDatabase>,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
let message = Request::ListPrivileges(use_database.clone().map(|db| vec![db]));
server_connection.send(message).await?;
debug_assert!(args.privs.is_empty() ^ args.single_priv.is_none());
let privs = if let Some(single_priv_entry) = &args.single_priv {
let database = single_priv_entry.db_name.clone().ok_or_else(|| {
anyhow::anyhow!(
"DB_NAME must be specified when editing privileges in single privilege mode"
)
})?;
let user = single_priv_entry.user_name.clone().ok_or_else(|| {
anyhow::anyhow!(
"USER_NAME must be specified when DB_NAME is specified in single privilege mode"
)
})?;
let privilege_edit = single_priv_entry.single_priv.clone().ok_or_else(|| {
anyhow::anyhow!(
"PRIVILEGES must be specified when DB_NAME is specified in single privilege mode"
)
})?;
vec![DatabasePrivilegeEditEntry {
database,
user,
privilege_edit,
}]
} else {
args.privs.clone()
};
let existing_privilege_rows = match server_connection.next().await {
Some(Ok(Response::ListPrivileges(databases))) => databases
.into_iter()
.filter_map(|(database_name, result)| match result {
Ok(privileges) => Some(privileges),
Err(err) => {
eprintln!("{}", err.to_error_message(&database_name));
eprintln!("Skipping...");
println!();
None
}
})
.flatten()
.collect::<Vec<_>>(),
Some(Ok(Response::ListAllPrivileges(privilege_rows))) => match privilege_rows {
Ok(list) => list,
Err(err) => {
server_connection.send(Request::Exit).await?;
return Err(anyhow::anyhow!(err.to_error_message())
.context("Failed to list database privileges"));
}
},
response => return erroneous_server_response(response),
};
let diffs: BTreeSet<DatabasePrivilegesDiff> = if 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)?;
create_or_modify_privilege_rows(&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 diffs = reduce_privilege_diffs(&existing_privilege_rows, diffs)?
.into_iter()
.filter(|diff| {
let database_name = diff.get_database_name();
let username = diff.get_user_name();
if let Some(Err(err)) = database_existence_map.get(database_name) {
println!("{}", err.to_error_message(database_name));
println!("Skipping...");
return false;
}
if let Some(Err(err)) = user_existence_map.get(username) {
println!("{}", err.to_error_message(username));
println!("Skipping...");
return false;
}
true
})
.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?;
return Ok(());
}
println!("The following changes will be made:\n");
println!("{}", display_privilege_diffs(&diffs));
if !args.yes
&& !Confirm::new()
.with_prompt("Do you want to apply these changes?")
.default(false)
.show_default(true)
.interact()?
{
server_connection.send(Request::Exit).await?;
return Ok(());
}
let message = Request::ModifyPrivileges(diffs);
server_connection.send(message).await?;
let result = match server_connection.next().await {
Some(Ok(Response::ModifyPrivileges(result))) => result,
response => return erroneous_server_response(response),
};
print_modify_database_privileges_output_status(&result);
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],
) -> anyhow::Result<BTreeSet<DatabasePrivilegeRowDiff>> {
debug_assert!(!privs.is_empty());
privs
.iter()
.map(|priv_edit_entry| {
priv_edit_entry
.as_database_privileges_diff()
.context(format!(
"Failed parsing database privileges: `{priv_edit_entry}`"
))
})
.collect::<anyhow::Result<BTreeSet<DatabasePrivilegeRowDiff>>>()
}
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())
.context("Failed to look up your UNIX username")
.and_then(|u| u.ok_or(anyhow::anyhow!("Failed to look up your UNIX username")))?;
let editor_content =
generate_editor_content_from_privilege_data(privilege_data, &unix_user.name, database_name);
// TODO: handle errors better here
let result = Editor::new().extension("tsv").edit(&editor_content)?;
match result {
None => Ok(privilege_data.to_vec()),
Some(result) => parse_privilege_data_from_editor_content(&result)
.context("Could not parse privilege data from editor"),
}
}

View File

@@ -0,0 +1,75 @@
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},
core::{
completion::mysql_user_completer,
protocol::{
ClientToServerMessageStream, LockUserError, Request, Response,
print_lock_users_output_status, print_lock_users_output_status_json,
request_validation::ValidationError,
},
types::MySQLUser,
},
};
#[derive(Parser, Debug, Clone)]
pub struct LockUserArgs {
/// The `MySQL` user(s) to loc
#[arg(num_args = 1.., value_name = "USER_NAME")]
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_user_completer)))]
username: Vec<MySQLUser>,
/// Print the information as JSON
#[arg(short, long)]
json: bool,
}
pub async fn lock_users(
args: LockUserArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
if args.username.is_empty() {
anyhow::bail!("No usernames provided");
}
let message = Request::LockUsers(args.username.clone());
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
anyhow::bail!(err);
}
let result = match server_connection.next().await {
Some(Ok(Response::LockUsers(result))) => result,
response => return erroneous_server_response(response),
};
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

@@ -0,0 +1,176 @@
use std::path::PathBuf;
use anyhow::Context;
use clap::Parser;
use clap_complete::ArgValueCompleter;
use dialoguer::Password;
use futures_util::SinkExt;
use tokio_stream::StreamExt;
use crate::{
client::commands::{erroneous_server_response, print_authorization_owner_hint},
core::{
completion::mysql_user_completer,
protocol::{
ClientToServerMessageStream, ListUsersError, Request, Response, SetPasswordError,
SetUserPasswordRequest, print_set_password_output_status,
request_validation::ValidationError,
},
types::MySQLUser,
},
};
#[derive(Parser, Debug, Clone)]
pub struct PasswdUserArgs {
/// The `MySQL` user whose password is to be changed
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_user_completer)))]
#[arg(value_name = "USER_NAME")]
username: MySQLUser,
/// Read the new password from a file instead of prompting for it
#[clap(short, long, value_name = "PATH", conflicts_with = "stdin")]
password_file: Option<PathBuf>,
/// Read the new password from stdin instead of prompting for it
#[clap(short = 'i', long, conflicts_with = "password_file")]
stdin: bool,
/// Print the information as JSON
#[arg(short, long)]
json: bool,
/// 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> {
Password::new()
.with_prompt(format!("New MySQL password for user '{username}'"))
.with_confirmation(
format!("Retype new MySQL password for user '{username}'"),
"Passwords do not match",
)
.interact()
.map_err(Into::into)
}
pub 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()]));
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
anyhow::bail!(err);
}
let response = match server_connection.next().await {
Some(Ok(Response::ListUsers(users))) => users,
response => return erroneous_server_response(response),
};
match response
.get(&args.username)
.unwrap_or(&Err(ListUsersError::UserDoesNotExist))
{
Ok(_) => {}
Err(err) => {
server_connection.send(Request::Exit).await?;
server_connection.close().await.ok();
anyhow::bail!("{}", err.to_error_message(&args.username));
}
}
let password: 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(),
)
} 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
} else {
Some(interactive_password_dialogue_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,
});
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
anyhow::bail!(err);
}
let result = match server_connection.next().await {
Some(Ok(Response::SetUserPassword(result))) => result,
response => return erroneous_server_response(response),
};
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);
}
Ok(())
}

View File

@@ -0,0 +1,88 @@
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},
core::{
completion::mysql_database_completer,
protocol::{
ClientToServerMessageStream, ListDatabasesError, Request, Response,
print_list_databases_output_status, print_list_databases_output_status_json,
request_validation::ValidationError,
},
types::MySQLDatabase,
},
};
#[derive(Parser, Debug, Clone)]
pub struct ShowDbArgs {
/// The `MySQL` database(s) to show
#[arg(num_args = 0.., value_name = "DB_NAME")]
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_database_completer)))]
name: Vec<MySQLDatabase>,
/// Print the information as JSON
#[arg(short, long)]
json: bool,
/// Show sizes in bytes instead of human-readable format
#[arg(short, long)]
bytes: bool,
}
pub async fn show_databases(
args: ShowDbArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
let message = if args.name.is_empty() {
Request::ListDatabases(None)
} else {
Request::ListDatabases(Some(args.name.clone()))
};
server_connection.send(message).await?;
let databases = match server_connection.next().await {
Some(Ok(Response::ListDatabases(databases))) => databases,
Some(Ok(Response::ListAllDatabases(database_list))) => match database_list {
Ok(list) => list
.into_iter()
.map(|db| (db.database.clone(), Ok(db)))
.collect(),
Err(err) => {
server_connection.send(Request::Exit).await?;
return Err(
anyhow::anyhow!(err.to_error_message()).context("Failed to list databases")
);
}
},
response => return erroneous_server_response(response),
};
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?;
}
}
server_connection.send(Request::Exit).await?;
if databases.values().any(std::result::Result::is_err) {
std::process::exit(1);
}
Ok(())
}

View File

@@ -0,0 +1,92 @@
use clap::Parser;
use clap_complete::ArgValueCompleter;
use futures_util::SinkExt;
use itertools::Itertools;
use tokio_stream::StreamExt;
use crate::{
client::commands::{erroneous_server_response, print_authorization_owner_hint},
core::{
completion::mysql_database_completer,
protocol::{
ClientToServerMessageStream, ListPrivilegesError, Request, Response,
print_list_privileges_output_status, print_list_privileges_output_status_json,
request_validation::ValidationError,
},
types::MySQLDatabase,
},
};
#[derive(Parser, Debug, Clone)]
pub struct ShowPrivsArgs {
/// The `MySQL` database(s) to show privileges for
#[arg(num_args = 0.., value_name = "DB_NAME")]
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_database_completer)))]
name: Vec<MySQLDatabase>,
/// Print the information as JSON
#[arg(short, long)]
json: bool,
/// Show single-character privilege names in addition to human-readable names
///
/// This flag has no effect when used with --json
#[arg(short, long)]
long: bool,
}
pub async fn show_database_privileges(
args: ShowPrivsArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
let message = if args.name.is_empty() {
Request::ListPrivileges(None)
} else {
Request::ListPrivileges(Some(args.name.clone()))
};
server_connection.send(message).await?;
let privilege_data = match server_connection.next().await {
Some(Ok(Response::ListPrivileges(databases))) => databases,
Some(Ok(Response::ListAllPrivileges(privilege_rows))) => match privilege_rows {
Ok(list) => list
.into_iter()
.map(|row| (row.db.clone(), row))
.into_group_map()
.into_iter()
.map(|(db, rows)| (db, Ok(rows)))
.collect(),
Err(err) => {
server_connection.send(Request::Exit).await?;
return Err(anyhow::anyhow!(err.to_error_message())
.context("Failed to list database privileges"));
}
},
response => return erroneous_server_response(response),
};
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) {
std::process::exit(1);
}
Ok(())
}

View File

@@ -0,0 +1,87 @@
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},
core::{
completion::mysql_user_completer,
protocol::{
ClientToServerMessageStream, ListUsersError, Request, Response,
print_list_users_output_status, print_list_users_output_status_json,
request_validation::ValidationError,
},
types::MySQLUser,
},
};
#[derive(Parser, Debug, Clone)]
pub struct ShowUserArgs {
/// The `MySQL` user(s) to show
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_user_completer)))]
#[arg(num_args = 0.., value_name = "USER_NAME")]
username: Vec<MySQLUser>,
/// Print the information as JSON
#[arg(short, long)]
json: bool,
}
pub async fn show_users(
args: ShowUserArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
let message = if args.username.is_empty() {
Request::ListUsers(None)
} else {
Request::ListUsers(Some(args.username.clone()))
};
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
anyhow::bail!(err);
}
let users = match server_connection.next().await {
Some(Ok(Response::ListUsers(users))) => users,
Some(Ok(Response::ListAllUsers(users))) => match users {
Ok(users) => users
.into_iter()
.map(|user| (user.user.clone(), Ok(user)))
.collect(),
Err(err) => {
server_connection.send(Request::Exit).await?;
return Err(
anyhow::anyhow!(err.to_error_message()).context("Failed to list all users")
);
}
},
response => return erroneous_server_response(response),
};
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) {
std::process::exit(1);
}
Ok(())
}

View File

@@ -0,0 +1,75 @@
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},
core::{
completion::mysql_user_completer,
protocol::{
ClientToServerMessageStream, Request, Response, UnlockUserError,
print_unlock_users_output_status, print_unlock_users_output_status_json,
request_validation::ValidationError,
},
types::MySQLUser,
},
};
#[derive(Parser, Debug, Clone)]
pub struct UnlockUserArgs {
/// The `MySQL` user(s) to unlock
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_user_completer)))]
#[arg(num_args = 1.., value_name = "USER_NAME")]
username: Vec<MySQLUser>,
/// Print the information as JSON
#[arg(short, long)]
json: bool,
}
pub async fn unlock_users(
args: UnlockUserArgs,
mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> {
if args.username.is_empty() {
anyhow::bail!("No usernames provided");
}
let message = Request::UnlockUsers(args.username.clone());
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();
anyhow::bail!(err);
}
let result = match server_connection.next().await {
Some(Ok(Response::UnlockUsers(result))) => result,
response => return erroneous_server_response(response),
};
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,11 +1,13 @@
use crate::core::protocol::{MySQLDatabase, MySQLUser};
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

@@ -0,0 +1,180 @@
use crate::core::{
protocol::{
CreateDatabaseError, CreateUserError, DropDatabaseError, DropUserError,
ListPrivilegesError, ListUsersError, request_validation::ValidationError,
},
types::DbOrUser,
};
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(),
});
format!(
concat!(
"{}: {} name '{}' contains invalid characters.\n",
"Only A-Z, a-z, 0-9, _ (underscore) and - (dash) permitted. Skipping.",
),
argv0,
db_or_user.capitalized_noun(),
db_or_user.name(),
)
}
pub fn authorization_error_message(db_or_user: &DbOrUser) -> String {
format!(
"You are not in charge of mysql-{}: '{}'. Skipping.",
db_or_user.lowercased_noun(),
db_or_user.name(),
)
}
pub fn handle_create_user_error(error: &CreateUserError, name: &str) {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-useradm".to_string());
match error {
CreateUserError::ValidationError(ValidationError::NameValidationError(_)) => {
eprintln!(
"{}",
name_validation_error_to_error_message(&DbOrUser::User(name.into()))
);
}
CreateUserError::ValidationError(ValidationError::AuthorizationError(_)) => {
eprintln!(
"{}",
authorization_error_message(&DbOrUser::User(name.into()))
);
}
CreateUserError::MySqlError(_) | CreateUserError::UserAlreadyExists => {
eprintln!("{argv0}: Failed to create user '{name}'.");
}
}
}
pub fn handle_drop_user_error(error: &DropUserError, name: &str) {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-useradm".to_string());
match error {
DropUserError::ValidationError(ValidationError::NameValidationError(_)) => {
eprintln!(
"{}",
name_validation_error_to_error_message(&DbOrUser::User(name.into()))
);
}
DropUserError::ValidationError(ValidationError::AuthorizationError(_)) => {
eprintln!(
"{}",
authorization_error_message(&DbOrUser::User(name.into()))
);
}
DropUserError::MySqlError(_) | DropUserError::UserDoesNotExist => {
eprintln!("{argv0}: Failed to delete user '{name}'.");
}
}
}
pub fn handle_list_users_error(error: &ListUsersError, name: &str) {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-useradm".to_string());
match error {
ListUsersError::ValidationError(ValidationError::NameValidationError(_)) => {
eprintln!(
"{}",
name_validation_error_to_error_message(&DbOrUser::User(name.into()))
);
}
ListUsersError::ValidationError(ValidationError::AuthorizationError(_)) => {
eprintln!(
"{}",
authorization_error_message(&DbOrUser::User(name.into()))
);
}
ListUsersError::UserDoesNotExist => {
eprintln!("{argv0}: User '{name}' does not exist. You must create it first.",);
}
ListUsersError::MySqlError(_) => {
eprintln!("{argv0}: Failed to look up password for user '{name}'");
}
}
}
// ----------------------------------------------------------------------------
pub fn handle_create_database_error(error: &CreateDatabaseError, name: &str) {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-dbadm".to_string());
match error {
CreateDatabaseError::ValidationError(ValidationError::NameValidationError(_)) => {
eprintln!(
"{}",
name_validation_error_to_error_message(&DbOrUser::Database(name.into()))
);
}
CreateDatabaseError::ValidationError(ValidationError::AuthorizationError(_)) => {
eprintln!(
"{}",
authorization_error_message(&DbOrUser::Database(name.into()))
);
}
CreateDatabaseError::MySqlError(_) => {
eprintln!("{argv0}: Cannot create database '{name}'.");
}
CreateDatabaseError::DatabaseAlreadyExists => {
eprintln!("{argv0}: Database '{name}' already exists.");
}
}
}
pub fn handle_drop_database_error(error: &DropDatabaseError, name: &str) {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-dbadm".to_string());
match error {
DropDatabaseError::ValidationError(ValidationError::NameValidationError(_)) => {
eprintln!(
"{}",
name_validation_error_to_error_message(&DbOrUser::Database(name.into()))
);
}
DropDatabaseError::ValidationError(ValidationError::AuthorizationError(_)) => {
eprintln!(
"{}",
authorization_error_message(&DbOrUser::Database(name.into()))
);
}
DropDatabaseError::MySqlError(_) => {
eprintln!("{argv0}: Cannot drop database '{name}'.");
}
DropDatabaseError::DatabaseDoesNotExist => {
eprintln!("{argv0}: Database '{name}' doesn't exist.");
}
}
}
pub fn format_show_database_error_message(error: &ListPrivilegesError, name: &str) -> String {
let argv0 = std::env::args()
.next()
.unwrap_or_else(|| "mysql-dbadm".to_string());
match error {
ListPrivilegesError::ValidationError(ValidationError::NameValidationError(_)) => {
name_validation_error_to_error_message(&DbOrUser::Database(name.into()))
}
ListPrivilegesError::ValidationError(ValidationError::AuthorizationError(_)) => {
authorization_error_message(&DbOrUser::Database(name.into()))
}
ListPrivilegesError::MySqlError(err) => {
format!("{argv0}: Failed to look up privileges for database '{name}': {err}")
}
ListPrivilegesError::DatabaseDoesNotExist => {
format!("{argv0}: Database '{name}' doesn't exist.")
}
}
}

View File

@@ -1,13 +1,14 @@
use clap::Parser;
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;
use tokio::net::UnixStream as TokioUnixStream;
use crate::{
cli::{
common::erroneous_server_response,
database_command,
client::{
commands::{EditPrivsArgs, edit_database_privileges, erroneous_server_response},
mysql_admutils_compatibility::{
common::trim_db_name_to_32_chars,
error_messages::{
@@ -18,15 +19,17 @@ use crate::{
},
core::{
bootstrap::bootstrap_server_connection_and_drop_privileges,
completion::{mysql_database_completer, prefix_completer},
database_privileges::DatabasePrivilegeRow,
protocol::{
ClientToServerMessageStream, GetDatabasesPrivilegeDataError, MySQLDatabase, Request,
Response, create_client_to_server_message_stream,
ClientToServerMessageStream, ListPrivilegesError, Request, Response,
create_client_to_server_message_stream,
},
types::MySQLDatabase,
},
server::sql::database_privilege_operations::DatabasePrivilegeRow,
};
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)
@@ -47,13 +50,13 @@ 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.
///
/// This is a compatibility layer for the mysql-dbadm command.
/// Please consider using the newer mysqladm command instead.
/// This is a compatibility layer for the 'mysql-dbadm' command.
/// Please consider using the newer 'muscl' command instead.
#[derive(Parser)]
#[command(
bin_name = "mysql-dbadm",
@@ -71,6 +74,7 @@ pub struct Args {
short,
long,
value_name = "PATH",
value_hint = clap::ValueHint::FilePath,
global = true,
hide_short_help = true
)]
@@ -81,6 +85,7 @@ pub struct Args {
short,
long,
value_name = "PATH",
value_hint = clap::ValueHint::FilePath,
global = true,
hide_short_help = true
)]
@@ -93,8 +98,8 @@ pub struct Args {
// NOTE: mysql-dbadm explicitly calls privileges "permissions".
// This is something we're trying to move away from.
// See https://git.pvv.ntnu.no/Projects/mysqladm-rs/issues/29
#[derive(Parser)]
// See https://git.pvv.ntnu.no/Projects/muscl/issues/29
#[derive(Subcommand)]
pub enum Command {
/// create the DATABASE(s).
Create(CreateArgs),
@@ -120,6 +125,7 @@ pub enum Command {
pub struct CreateArgs {
/// The name of the DATABASE(s) to create.
#[arg(num_args = 1..)]
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(prefix_completer)))]
name: Vec<MySQLDatabase>,
}
@@ -127,6 +133,7 @@ pub struct CreateArgs {
pub struct DatabaseDropArgs {
/// The name of the DATABASE(s) to drop.
#[arg(num_args = 1..)]
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_database_completer)))]
name: Vec<MySQLDatabase>,
}
@@ -134,12 +141,14 @@ pub struct DatabaseDropArgs {
pub struct DatabaseShowArgs {
/// The name of the DATABASE(s) to show.
#[arg(num_args = 0..)]
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_database_completer)))]
name: Vec<MySQLDatabase>,
}
#[derive(Parser)]
pub struct EditPermArgs {
/// The name of the DATABASE to edit permissions for.
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_database_completer)))]
pub database: MySQLDatabase,
}
@@ -148,25 +157,22 @@ 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,
Default::default(),
Verbosity::default(),
)?;
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(());
}
let Some(command) = args.command else {
println!(
"Try `{} --help' for more information.",
std::env::args().next().unwrap_or("mysql-dbadm".to_string())
);
return Ok(());
};
tokio_run_command(command, server_connection)?;
@@ -181,22 +187,39 @@ fn tokio_run_command(command: Command, server_connection: StdUnixStream) -> anyh
.unwrap()
.block_on(async {
let tokio_socket = TokioUnixStream::from_std(server_connection)?;
let message_stream = create_client_to_server_message_stream(tokio_socket);
let mut message_stream = create_client_to_server_message_stream(tokio_socket);
while let Some(Ok(message)) = message_stream.next().await {
match message {
Response::Error(err) => {
anyhow::bail!("{err}");
}
Response::Ready => break,
message => {
eprintln!("Unexpected message from server: {message:?}");
}
}
}
match command {
Command::Create(args) => create_databases(args, message_stream).await,
Command::Drop(args) => drop_databases(args, message_stream).await,
Command::Show(args) => show_databases(args, message_stream).await,
Command::Editperm(args) => {
let edit_privileges_args = database_command::DatabaseEditPrivsArgs {
name: Some(args.database),
let edit_privileges_args = EditPrivsArgs {
single_priv: None,
privs: vec![],
json: false,
editor: None,
yes: false,
};
database_command::edit_database_privileges(edit_privileges_args, message_stream)
.await
edit_database_privileges(
edit_privileges_args,
Some(args.database),
message_stream,
)
.await
}
}
})
@@ -220,8 +243,8 @@ async fn create_databases(
for (name, result) in result {
match result {
Ok(()) => println!("Database {} created.", name),
Err(err) => handle_create_database_error(err, &name),
Ok(()) => println!("Database {name} created."),
Err(err) => handle_create_database_error(&err, &name),
}
}
@@ -246,8 +269,8 @@ async fn drop_databases(
for (name, result) in result {
match result {
Ok(()) => println!("Database {} dropped.", name),
Err(err) => handle_drop_database_error(err, &name),
Ok(()) => println!("Database {name} dropped."),
Err(err) => handle_drop_database_error(&err, &name),
}
}
@@ -287,24 +310,21 @@ 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.to_owned(), rows)) {
Ok(rows) => Ok(rows),
Err(GetDatabasesPrivilegeDataError::DatabaseDoesNotExist) => Ok((name, vec![])),
Err(err) => Err(format_show_database_error_message(err, &name)),
},
)
.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)),
})
.collect(),
response => return erroneous_server_response(response),
};
results.into_iter().try_for_each(|result| match result {
Ok((name, rows)) => print_db_privs(&name, rows),
Err(err) => {
eprintln!("{}", err);
Ok(())
for result in results {
match result {
Ok((name, rows)) => print_db_privs(&name, rows),
Err(err) => eprintln!("{err}"),
}
})?;
}
Ok(())
}
@@ -314,7 +334,7 @@ fn yn(value: bool) -> &'static str {
if value { "Y" } else { "N" }
}
fn print_db_privs(name: &str, rows: Vec<DatabasePrivilegeRow>) -> anyhow::Result<()> {
fn print_db_privs(name: &str, rows: Vec<DatabasePrivilegeRow>) {
println!(
concat!(
"Database '{}':\n",
@@ -344,6 +364,4 @@ fn print_db_privs(name: &str, rows: Vec<DatabasePrivilegeRow>) -> anyhow::Result
);
}
}
Ok(())
}

View File

@@ -1,4 +1,5 @@
use clap::Parser;
use clap::{Parser, Subcommand};
use clap_complete::ArgValueCompleter;
use futures_util::{SinkExt, StreamExt};
use std::path::PathBuf;
@@ -6,22 +7,22 @@ use std::os::unix::net::UnixStream as StdUnixStream;
use tokio::net::UnixStream as TokioUnixStream;
use crate::{
cli::{
common::erroneous_server_response,
client::{
commands::{erroneous_server_response, interactive_password_dialogue_with_double_check},
mysql_admutils_compatibility::{
common::trim_user_name_to_32_chars,
error_messages::{
handle_create_user_error, handle_drop_user_error, handle_list_users_error,
},
},
user_command::read_password_from_stdin_with_double_check,
},
core::{
bootstrap::bootstrap_server_connection_and_drop_privileges,
completion::{mysql_user_completer, prefix_completer},
protocol::{
ClientToServerMessageStream, MySQLUser, Request, Response,
create_client_to_server_message_stream,
ClientToServerMessageStream, Request, Response, SetUserPasswordRequest, create_client_to_server_message_stream
},
types::MySQLUser,
},
server::sql::user_operations::DatabaseUser,
};
@@ -29,8 +30,8 @@ use crate::{
/// Create, delete or change password for the USER(s),
/// as determined by the COMMAND.
///
/// This is a compatibility layer for the mysql-useradm command.
/// Please consider using the newer mysqladm command instead.
/// This is a compatibility layer for the 'mysql-useradm' command.
/// Please consider using the newer 'muscl' command instead.
#[derive(Parser)]
#[command(
bin_name = "mysql-useradm",
@@ -48,6 +49,7 @@ pub struct Args {
short,
long,
value_name = "PATH",
value_hint = clap::ValueHint::FilePath,
global = true,
hide_short_help = true
)]
@@ -58,13 +60,14 @@ pub struct Args {
short,
long,
value_name = "PATH",
value_hint = clap::ValueHint::FilePath,
global = true,
hide_short_help = true
)]
config: Option<PathBuf>,
}
#[derive(Parser)]
#[derive(Subcommand)]
pub enum Command {
/// create the USER(s).
Create(CreateArgs),
@@ -72,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
@@ -84,6 +87,7 @@ pub enum Command {
pub struct CreateArgs {
/// The name of the USER(s) to create.
#[arg(num_args = 1..)]
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(prefix_completer)))]
name: Vec<MySQLUser>,
}
@@ -91,6 +95,7 @@ pub struct CreateArgs {
pub struct DeleteArgs {
/// The name of the USER(s) to delete.
#[arg(num_args = 1..)]
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_user_completer)))]
name: Vec<MySQLUser>,
}
@@ -98,6 +103,7 @@ pub struct DeleteArgs {
pub struct PasswdArgs {
/// The name of the USER(s) to change the password for.
#[arg(num_args = 1..)]
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_user_completer)))]
name: Vec<MySQLUser>,
}
@@ -105,6 +111,7 @@ pub struct PasswdArgs {
pub struct ShowArgs {
/// The name of the USER(s) to show.
#[arg(num_args = 0..)]
#[cfg_attr(not(feature = "suid-sgid-mode"), arg(add = ArgValueCompleter::new(mysql_user_completer)))]
name: Vec<MySQLUser>,
}
@@ -112,17 +119,14 @@ pub struct ShowArgs {
pub fn main() -> anyhow::Result<()> {
let args: Args = Args::parse();
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 Some(command) = args.command else {
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(
@@ -143,7 +147,20 @@ fn tokio_run_command(command: Command, server_connection: StdUnixStream) -> anyh
.unwrap()
.block_on(async {
let tokio_socket = TokioUnixStream::from_std(server_connection)?;
let message_stream = create_client_to_server_message_stream(tokio_socket);
let mut message_stream = create_client_to_server_message_stream(tokio_socket);
while let Some(Ok(message)) = message_stream.next().await {
match message {
Response::Error(err) => {
anyhow::bail!("{err}");
}
Response::Ready => break,
message => {
eprintln!("Unexpected message from server: {message:?}");
}
}
}
match command {
Command::Create(args) => create_user(args, message_stream).await,
Command::Delete(args) => drop_users(args, message_stream).await,
@@ -171,8 +188,8 @@ async fn create_user(
for (name, result) in result {
match result {
Ok(()) => println!("User '{}' created.", name),
Err(err) => handle_create_user_error(err, &name),
Ok(()) => println!("User '{name}' created."),
Err(err) => handle_create_user_error(&err, &name),
}
}
@@ -197,8 +214,8 @@ async fn drop_users(
for (name, result) in result {
match result {
Ok(()) => println!("User '{}' deleted.", name),
Err(err) => handle_drop_user_error(err, &name),
Ok(()) => println!("User '{name}' deleted."),
Err(err) => handle_drop_user_error(&err, &name),
}
}
@@ -228,18 +245,22 @@ 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 = read_password_from_stdin_with_double_check(&user.user)?;
let message = Request::PasswdUser(user.user.to_owned(), password);
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,
});
server_connection.send(message).await?;
match server_connection.next().await {
Some(Ok(Response::PasswdUser(result))) => match result {
Some(Ok(Response::SetUserPassword(result))) => match result {
Ok(()) => println!("Password updated for user '{}'.", &user.user),
Err(_) => eprintln!(
"{}: Failed to update password for user '{}'.",
@@ -272,7 +293,7 @@ async fn show_users(
Some(Ok(Response::ListAllUsers(result))) => match result {
Ok(users) => users,
Err(err) => {
println!("Failed to list users: {:?}", err);
eprintln!("Failed to list users: {err:?}");
return Ok(());
}
},
@@ -281,7 +302,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,4 +1,6 @@
pub mod bootstrap;
pub mod common;
pub mod completion;
pub mod database_privileges;
pub mod protocol;
pub mod types;

View File

@@ -1,16 +1,29 @@
use std::{fs, path::PathBuf};
use std::{
fs,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use anyhow::Context;
use clap_verbosity_flag::Verbosity;
use anyhow::{Context, anyhow};
use clap_verbosity_flag::{InfoLevel, Verbosity};
use nix::libc::{EXIT_SUCCESS, exit};
use sqlx::mysql::MySqlPoolOptions;
use std::os::unix::net::UnixStream as StdUnixStream;
use tokio::net::UnixStream as TokioUnixStream;
use tokio::{net::UnixStream as TokioUnixStream, sync::RwLock};
use tracing_subscriber::prelude::*;
use crate::{
core::common::{
DEFAULT_CONFIG_PATH, DEFAULT_SOCKET_PATH, UnixUser, executable_is_suid_or_sgid,
core::{
common::{DEFAULT_CONFIG_PATH, DEFAULT_SOCKET_PATH, UnixUser, executing_in_suid_sgid_mode},
protocol::request_validation::GroupDenylist,
},
server::{
authorization::read_and_parse_group_denylist,
config::{MysqlConfig, ServerConfig},
landlock::landlock_restrict_server,
session_handler,
},
server::{config::read_config_from_path, server_loop::handle_requests_for_single_session},
};
/// Determine whether we will make a connection to an external server
@@ -71,17 +84,26 @@ fn will_connect_to_external_server(
pub fn bootstrap_server_connection_and_drop_privileges(
server_socket_path: Option<PathBuf>,
config: Option<PathBuf>,
verbose: Verbosity,
verbose: Verbosity<InfoLevel>,
) -> anyhow::Result<StdUnixStream> {
if will_connect_to_external_server(server_socket_path.as_ref(), config.as_ref())? {
assert!(
!executable_is_suid_or_sgid()?,
!executing_in_suid_sgid_mode()?,
"The executable should not be SUID or SGID when connecting to an external server"
);
env_logger::Builder::new()
.filter_level(verbose.log_level_filter())
.init();
let subscriber = tracing_subscriber::Registry::default()
.with(verbose.tracing_level_filter())
.with(
tracing_subscriber::fmt::layer()
.with_line_number(cfg!(debug_assertions))
.with_target(cfg!(debug_assertions))
.with_thread_ids(false)
.with_thread_names(false),
);
tracing::subscriber::set_global_default(subscriber)
.context("Failed to set global default tracing subscriber")?;
connect_to_external_server(server_socket_path)
} else if cfg!(feature = "suid-sgid-mode") {
@@ -89,9 +111,18 @@ pub fn bootstrap_server_connection_and_drop_privileges(
// as we might be running with elevated privileges.
let server_connection = bootstrap_internal_server_and_drop_privs(config)?;
env_logger::Builder::new()
.filter_level(verbose.log_level_filter())
.init();
let subscriber = tracing_subscriber::Registry::default()
.with(verbose.tracing_level_filter())
.with(
tracing_subscriber::fmt::layer()
.with_line_number(cfg!(debug_assertions))
.with_target(cfg!(debug_assertions))
.with_thread_ids(false)
.with_thread_names(false),
);
tracing::subscriber::set_global_default(subscriber)
.context("Failed to set global default tracing subscriber")?;
Ok(server_connection)
} else {
@@ -104,25 +135,25 @@ fn connect_to_external_server(
) -> anyhow::Result<StdUnixStream> {
// TODO: ensure this is both readable and writable
if let Some(socket_path) = server_socket_path {
log::debug!("Connecting to socket at {:?}", socket_path);
tracing::debug!("Connecting to socket at {:?}", socket_path);
return match StdUnixStream::connect(socket_path) {
Ok(socket) => Ok(socket),
Err(e) => match e.kind() {
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}")),
},
};
}
if fs::metadata(DEFAULT_SOCKET_PATH).is_ok() {
log::debug!("Connecting to default socket at {:?}", DEFAULT_SOCKET_PATH);
tracing::debug!("Connecting to default socket at {:?}", DEFAULT_SOCKET_PATH);
return match StdUnixStream::connect(DEFAULT_SOCKET_PATH) {
Ok(socket) => Ok(socket),
Err(e) => match e.kind() {
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}")),
},
};
}
@@ -135,8 +166,8 @@ fn connect_to_external_server(
/// Drop privileges to the real user and group of the process.
/// If the process is not running with elevated privileges, this function
/// is a no-op.
fn drop_privs() -> anyhow::Result<()> {
log::debug!("Dropping privileges");
pub fn drop_privs() -> anyhow::Result<()> {
tracing::debug!("Dropping privileges");
let real_uid = nix::unistd::getuid();
let real_gid = nix::unistd::getgid();
@@ -146,15 +177,17 @@ fn drop_privs() -> anyhow::Result<()> {
debug_assert_eq!(nix::unistd::getuid(), real_uid);
debug_assert_eq!(nix::unistd::getgid(), real_gid);
log::debug!("Privileges dropped successfully");
tracing::debug!("Privileges dropped successfully");
Ok(())
}
/// 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> {
if let Some(config_path) = config_path {
if !executable_is_suid_or_sgid()? {
if !executing_in_suid_sgid_mode()? {
anyhow::bail!("Executable is not SUID/SGID - refusing to start internal sever");
}
@@ -163,22 +196,22 @@ fn bootstrap_internal_server_and_drop_privs(
return Err(anyhow::anyhow!("Config file not found or not readable"));
}
log::debug!("Starting server with config at {:?}", config_path);
let socket = invoke_server_with_config(config_path)?;
tracing::debug!("Starting server with config at {:?}", 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() {
if !executable_is_suid_or_sgid()? {
if !executing_in_suid_sgid_mode()? {
anyhow::bail!("Executable is not SUID/SGID - refusing to start internal sever");
}
log::debug!("Starting server with default config at {:?}", config_path);
let socket = invoke_server_with_config(config_path)?;
tracing::debug!("Starting server with default config at {:?}", config_path);
let socket = invoke_server_with_config(&config_path)?;
drop_privs()?;
return Ok(socket);
};
}
anyhow::bail!("No config path provided, and no default config found");
}
@@ -188,42 +221,102 @@ 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: PathBuf) -> anyhow::Result<StdUnixStream> {
fn invoke_server_with_config(config_path: &Path) -> anyhow::Result<StdUnixStream> {
let (server_socket, client_socket) = StdUnixStream::pair()?;
let unix_user = UnixUser::from_uid(nix::unistd::getuid().as_raw())?;
match (unsafe { nix::unistd::fork() }).context("Failed to fork")? {
nix::unistd::ForkResult::Parent { child } => {
log::debug!("Forked child process with PID {}", child);
tracing::debug!("Forked child process with PID {}", child);
Ok(client_socket)
}
nix::unistd::ForkResult::Child => {
log::debug!("Running server in child process");
tracing::debug!("Running server in child process");
match run_forked_server(config_path, server_socket, unix_user) {
landlock_restrict_server(Some(config_path))
.context("Failed to apply Landlock restrictions to the server process")?;
match run_forked_server(config_path, server_socket, &unix_user) {
Err(e) => Err(e),
Ok(_) => unreachable!(),
Ok(()) => unreachable!(),
}
}
}
}
/// Run the server in the forked child process.
/// 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> {
let mysql_config = config.as_mysql_connect_options()?;
let pool_opts = MySqlPoolOptions::new()
.max_connections(1)
.min_connections(1);
config.log_connection_notice();
let pool = match tokio::time::timeout(
Duration::from_secs(config.timeout),
pool_opts.connect_with(mysql_config),
)
.await
{
Ok(connection) => connection.context("Failed to connect to the database"),
Err(_) => Err(anyhow!("Timed out after {} seconds", config.timeout))
.context("Failed to connect to the database"),
}?;
Ok(pool)
}
/// Run a single server session in the forked process.
///
/// This function will not return, but will exit the process with a success code.
/// The function assumes that it's caller has already forked the process.
fn run_forked_server(
config_path: PathBuf,
config_path: &Path,
server_socket: StdUnixStream,
unix_user: UnixUser,
unix_user: &UnixUser,
) -> anyhow::Result<()> {
let config = read_config_from_path(Some(config_path))?;
let config = ServerConfig::read_config_from_path(config_path)
.context("Failed to read server config in forked process")?;
let 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()
.unwrap()
.context("Failed to start Tokio runtime")?
.block_on(async {
let socket = TokioUnixStream::from_std(server_socket)?;
handle_requests_for_single_session(socket, &unix_user, &config).await?;
let db_pool = construct_single_connection_mysql_pool(&config.mysql).await?;
let db_is_mariadb = {
let mut conn = db_pool.acquire().await?;
let version_row: String = sqlx::query_scalar("SELECT VERSION()")
.fetch_one(&mut *conn)
.await
.context("Failed to query MySQL version")?;
version_row.to_lowercase().contains("mariadb")
};
let db_pool = Arc::new(RwLock::new(db_pool));
session_handler::session_handler_with_unix_user(
socket,
unix_user,
db_pool,
db_is_mariadb,
&group_denylist,
)
.await?;
Ok(())
});

View File

@@ -1,17 +1,41 @@
use anyhow::Context;
use indoc::indoc;
use nix::unistd::{Group as LibcGroup, User as LibcUser};
#[cfg(not(target_os = "macos"))]
use std::ffi::CString;
use std::fmt;
pub const DEFAULT_CONFIG_PATH: &str = "/etc/mysqladm/config.toml";
pub const DEFAULT_SOCKET_PATH: &str = "/run/mysqladm/mysqladm.sock";
pub const DEFAULT_CONFIG_PATH: &str = "/etc/muscl/config.toml";
pub const DEFAULT_SOCKET_PATH: &str = "/run/muscl/muscl.sock";
pub const ASCII_BANNER: &str = indoc! {
r"
__
____ ___ __ ____________/ /
/ __ `__ \/ / / / ___/ ___/ /
/ / / / / / /_/ (__ ) /__/ /
/_/ /_/ /_/\__,_/____/\___/_/
"
};
pub const KIND_REGARDS: &str = concat!(
"Hacked together by yours truly, Programvareverkstedet <projects@pvv.ntnu.no>\n",
"If you experience any bugs or turbulence, please give us a heads up :)",
);
#[derive(Debug, Clone)]
pub struct UnixUser {
pub username: String,
pub groups: Vec<String>,
}
impl fmt::Display for UnixUser {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.username)
}
}
// TODO: these functions are somewhat critical, and should have integration tests
#[cfg(target_os = "macos")]
@@ -30,7 +54,7 @@ fn get_unix_groups(user: &LibcUser) -> anyhow::Result<Vec<LibcGroup>> {
Ok(Some(group)) => Some(group),
Ok(None) => None,
Err(e) => {
log::warn!(
tracing::warn!(
"Failed to look up group with GID {}: {}\nIgnoring...",
gid,
e
@@ -43,28 +67,20 @@ fn get_unix_groups(user: &LibcUser) -> anyhow::Result<Vec<LibcGroup>> {
Ok(groups)
}
/// Check if the current executable is SUID or SGID.
///
/// If the check fails, an error is returned.
/// Check if the current executable is running in SUID/SGID mode
#[cfg(feature = "suid-sgid-mode")]
pub fn executable_is_suid_or_sgid() -> anyhow::Result<bool> {
use std::{fs, os::unix::fs::PermissionsExt};
let result = std::env::current_exe()
.context("Failed to get current executable path")
.and_then(|executable| {
fs::metadata(executable).context("Failed to get executable metadata")
})
.context("Failed to check SUID/SGID bits on executable")
.map(|metadata| {
let mode = metadata.permissions().mode();
mode & 0o4000 != 0 || mode & 0o2000 != 0
})?;
Ok(result)
pub fn executing_in_suid_sgid_mode() -> anyhow::Result<bool> {
let euid = nix::unistd::geteuid();
let uid = nix::unistd::getuid();
let egid = nix::unistd::getegid();
let gid = nix::unistd::getgid();
Ok(euid != uid || egid != gid)
}
#[cfg(not(feature = "suid-sgid-mode"))]
#[inline]
pub fn executable_is_suid_or_sgid() -> anyhow::Result<bool> {
pub fn executing_in_suid_sgid_mode() -> anyhow::Result<bool> {
Ok(false)
}
@@ -79,14 +95,14 @@ impl UnixUser {
Ok(UnixUser {
username: libc_user.name,
groups: groups.iter().map(|g| g.name.to_owned()).collect(),
groups: groups.iter().map(|g| g.name.clone()).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]

7
src/core/completion.rs Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,76 @@
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,767 +1,9 @@
use anyhow::{Context, anyhow};
use itertools::Itertools;
use prettytable::Table;
use serde::{Deserialize, Serialize};
use std::{
cmp::max,
collections::{BTreeSet, HashMap},
};
use super::{
common::{rev_yn, yn},
protocol::{MySQLDatabase, MySQLUser},
};
use crate::server::sql::database_privilege_operations::{
DATABASE_PRIVILEGE_FIELDS, DatabasePrivilegeRow,
};
pub fn db_priv_field_human_readable_name(name: &str) -> String {
match name {
"Db" => "Database".to_owned(),
"User" => "User".to_owned(),
"select_priv" => "Select".to_owned(),
"insert_priv" => "Insert".to_owned(),
"update_priv" => "Update".to_owned(),
"delete_priv" => "Delete".to_owned(),
"create_priv" => "Create".to_owned(),
"drop_priv" => "Drop".to_owned(),
"alter_priv" => "Alter".to_owned(),
"index_priv" => "Index".to_owned(),
"create_tmp_table_priv" => "Temp".to_owned(),
"lock_tables_priv" => "Lock".to_owned(),
"references_priv" => "References".to_owned(),
_ => format!("Unknown({})", name),
}
}
pub fn diff(row1: &DatabasePrivilegeRow, row2: &DatabasePrivilegeRow) -> DatabasePrivilegeRowDiff {
debug_assert!(row1.db == row2.db && row1.user == row2.user);
DatabasePrivilegeRowDiff {
db: row1.db.to_owned(),
user: row1.user.to_owned(),
diff: DATABASE_PRIVILEGE_FIELDS
.into_iter()
.skip(2)
.filter_map(|field| {
DatabasePrivilegeChange::new(
row1.get_privilege_by_name(field),
row2.get_privilege_by_name(field),
field,
)
})
.collect(),
}
}
/*************************/
/* CLI INTERFACE PARSING */
/*************************/
/// See documentation for [`DatabaseCommand::EditDbPrivs`].
pub fn parse_privilege_table_cli_arg(arg: &str) -> anyhow::Result<DatabasePrivilegeRow> {
let parts: Vec<&str> = arg.split(':').collect();
if parts.len() != 3 {
anyhow::bail!("Invalid argument format. See `edit-db-privs --help` for more information.");
}
if parts[0].is_empty() {
anyhow::bail!("Database name cannot be empty.");
}
if parts[1].is_empty() {
anyhow::bail!("Username cannot be empty.");
}
let db = parts[0].into();
let user = parts[1].into();
let privs = parts[2].to_string();
let mut result = DatabasePrivilegeRow {
db,
user,
select_priv: false,
insert_priv: false,
update_priv: false,
delete_priv: false,
create_priv: false,
drop_priv: false,
alter_priv: false,
index_priv: false,
create_tmp_table_priv: false,
lock_tables_priv: false,
references_priv: false,
};
for char in privs.chars() {
match char {
's' => result.select_priv = true,
'i' => result.insert_priv = true,
'u' => result.update_priv = true,
'd' => result.delete_priv = true,
'c' => result.create_priv = true,
'D' => result.drop_priv = true,
'a' => result.alter_priv = true,
'I' => result.index_priv = true,
't' => result.create_tmp_table_priv = true,
'l' => result.lock_tables_priv = true,
'r' => result.references_priv = true,
'A' => {
result.select_priv = true;
result.insert_priv = true;
result.update_priv = true;
result.delete_priv = true;
result.create_priv = true;
result.drop_priv = true;
result.alter_priv = true;
result.index_priv = true;
result.create_tmp_table_priv = true;
result.lock_tables_priv = true;
result.references_priv = true;
}
_ => anyhow::bail!("Invalid privilege character: {}", char),
}
}
Ok(result)
}
/**********************************/
/* EDITOR CONTENT DISPLAY/DISPLAY */
/**********************************/
/// Generates a single row of the privileges table for the editor.
pub fn format_privileges_line_for_editor(
privs: &DatabasePrivilegeRow,
username_len: usize,
database_name_len: usize,
) -> String {
DATABASE_PRIVILEGE_FIELDS
.into_iter()
.map(|field| match field {
"Db" => format!("{:width$}", privs.db, width = database_name_len),
"User" => format!("{:width$}", privs.user, width = username_len),
privilege => format!(
"{:width$}",
yn(privs.get_privilege_by_name(privilege)),
width = db_priv_field_human_readable_name(privilege).len()
),
})
.join(" ")
.trim()
.to_string()
}
const EDITOR_COMMENT: &str = r#"
# Welcome to the privilege editor.
# Each line defines what privileges a single user has on a single database.
# The first two columns respectively represent the database name and the user, and the remaining columns are the privileges.
# If the user should have a certain privilege, write 'Y', otherwise write 'N'.
#
# Lines starting with '#' are comments and will be ignored.
"#;
/// Generates the content for the privilege editor.
///
/// The unix user is used in case there are no privileges to edit,
/// so that the user can see an example line based on their username.
pub fn generate_editor_content_from_privilege_data(
privilege_data: &[DatabasePrivilegeRow],
unix_user: &str,
database_name: Option<&MySQLDatabase>,
) -> String {
let example_user = format!("{}_user", unix_user);
let example_db = database_name
.unwrap_or(&format!("{}_db", unix_user).into())
.to_string();
// NOTE: `.max()`` fails when the iterator is empty.
// In this case, we know that the only fields in the
// editor will be the example user and example db name.
// Hence, it's put as the fallback value, despite not really
// being a "fallback" in the normal sense.
let longest_username = max(
privilege_data
.iter()
.map(|p| p.user.len())
.max()
.unwrap_or(example_user.len()),
"User".len(),
);
let longest_database_name = max(
privilege_data
.iter()
.map(|p| p.db.len())
.max()
.unwrap_or(example_db.len()),
"Database".len(),
);
let mut header: Vec<_> = DATABASE_PRIVILEGE_FIELDS
.into_iter()
.map(db_priv_field_human_readable_name)
.collect();
// Pad the first two columns with spaces to align the privileges.
header[0] = format!("{:width$}", header[0], width = longest_database_name);
header[1] = format!("{:width$}", header[1], width = longest_username);
let example_line = format_privileges_line_for_editor(
&DatabasePrivilegeRow {
db: example_db.into(),
user: example_user.into(),
select_priv: true,
insert_priv: true,
update_priv: true,
delete_priv: true,
create_priv: false,
drop_priv: false,
alter_priv: false,
index_priv: false,
create_tmp_table_priv: false,
lock_tables_priv: false,
references_priv: false,
},
longest_username,
longest_database_name,
);
format!(
"{}\n{}\n{}",
EDITOR_COMMENT,
header.join(" "),
if privilege_data.is_empty() {
format!("# {}", example_line)
} else {
privilege_data
.iter()
.map(|privs| {
format_privileges_line_for_editor(
privs,
longest_username,
longest_database_name,
)
})
.join("\n")
}
)
}
#[derive(Debug)]
enum PrivilegeRowParseResult {
PrivilegeRow(DatabasePrivilegeRow),
ParserError(anyhow::Error),
TooFewFields(usize),
TooManyFields(usize),
Header,
Comment,
Empty,
}
#[inline]
fn parse_privilege_cell_from_editor(yn: &str, name: &str) -> anyhow::Result<bool> {
rev_yn(yn)
.ok_or_else(|| anyhow!("Expected Y or N, found {}", yn))
.context(format!("Could not parse {} privilege", name))
}
#[inline]
fn editor_row_is_header(row: &str) -> bool {
row.split_ascii_whitespace()
.zip(DATABASE_PRIVILEGE_FIELDS.iter())
.map(|(field, priv_name)| (field, db_priv_field_human_readable_name(priv_name)))
.all(|(field, header_field)| field == header_field)
}
/// Parse a single row of the privileges table from the editor.
fn parse_privilege_row_from_editor(row: &str) -> PrivilegeRowParseResult {
if row.starts_with('#') || row.starts_with("//") {
return PrivilegeRowParseResult::Comment;
}
if row.trim().is_empty() {
return PrivilegeRowParseResult::Empty;
}
let parts: Vec<&str> = row.trim().split_ascii_whitespace().collect();
match parts.len() {
n if (n < DATABASE_PRIVILEGE_FIELDS.len()) => {
return PrivilegeRowParseResult::TooFewFields(n);
}
n if (n > DATABASE_PRIVILEGE_FIELDS.len()) => {
return PrivilegeRowParseResult::TooManyFields(n);
}
_ => {}
}
if editor_row_is_header(row) {
return PrivilegeRowParseResult::Header;
}
let row = DatabasePrivilegeRow {
db: (*parts.first().unwrap()).into(),
user: (*parts.get(1).unwrap()).into(),
select_priv: match parse_privilege_cell_from_editor(
parts.get(2).unwrap(),
DATABASE_PRIVILEGE_FIELDS[2],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
insert_priv: match parse_privilege_cell_from_editor(
parts.get(3).unwrap(),
DATABASE_PRIVILEGE_FIELDS[3],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
update_priv: match parse_privilege_cell_from_editor(
parts.get(4).unwrap(),
DATABASE_PRIVILEGE_FIELDS[4],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
delete_priv: match parse_privilege_cell_from_editor(
parts.get(5).unwrap(),
DATABASE_PRIVILEGE_FIELDS[5],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
create_priv: match parse_privilege_cell_from_editor(
parts.get(6).unwrap(),
DATABASE_PRIVILEGE_FIELDS[6],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
drop_priv: match parse_privilege_cell_from_editor(
parts.get(7).unwrap(),
DATABASE_PRIVILEGE_FIELDS[7],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
alter_priv: match parse_privilege_cell_from_editor(
parts.get(8).unwrap(),
DATABASE_PRIVILEGE_FIELDS[8],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
index_priv: match parse_privilege_cell_from_editor(
parts.get(9).unwrap(),
DATABASE_PRIVILEGE_FIELDS[9],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
create_tmp_table_priv: match parse_privilege_cell_from_editor(
parts.get(10).unwrap(),
DATABASE_PRIVILEGE_FIELDS[10],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
lock_tables_priv: match parse_privilege_cell_from_editor(
parts.get(11).unwrap(),
DATABASE_PRIVILEGE_FIELDS[11],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
references_priv: match parse_privilege_cell_from_editor(
parts.get(12).unwrap(),
DATABASE_PRIVILEGE_FIELDS[12],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
};
PrivilegeRowParseResult::PrivilegeRow(row)
}
// TODO: return better errors
pub fn parse_privilege_data_from_editor_content(
content: String,
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
content
.trim()
.split('\n')
.map(|line| line.trim())
.map(parse_privilege_row_from_editor)
.map(|result| match result {
PrivilegeRowParseResult::PrivilegeRow(row) => Ok(Some(row)),
PrivilegeRowParseResult::ParserError(e) => Err(e),
PrivilegeRowParseResult::TooFewFields(n) => Err(anyhow!(
"Too few fields in line. Expected to find {} fields, found {}",
DATABASE_PRIVILEGE_FIELDS.len(),
n
)),
PrivilegeRowParseResult::TooManyFields(n) => Err(anyhow!(
"Too many fields in line. Expected to find {} fields, found {}",
DATABASE_PRIVILEGE_FIELDS.len(),
n
)),
PrivilegeRowParseResult::Header => Ok(None),
PrivilegeRowParseResult::Comment => Ok(None),
PrivilegeRowParseResult::Empty => Ok(None),
})
.filter_map(|result| result.transpose())
.collect::<anyhow::Result<Vec<DatabasePrivilegeRow>>>()
}
/*****************************/
/* CALCULATE PRIVILEGE DIFFS */
/*****************************/
/// This struct represents encapsulates the differences between two
/// instances of privilege sets for a single user on a single database.
///
/// The `User` and `Database` are the same for both instances.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
pub struct DatabasePrivilegeRowDiff {
pub db: MySQLDatabase,
pub user: MySQLUser,
pub diff: BTreeSet<DatabasePrivilegeChange>,
}
/// This enum represents a change for a single privilege.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
pub enum DatabasePrivilegeChange {
YesToNo(String),
NoToYes(String),
}
impl DatabasePrivilegeChange {
pub fn new(p1: bool, p2: bool, name: &str) -> Option<DatabasePrivilegeChange> {
match (p1, p2) {
(true, false) => Some(DatabasePrivilegeChange::YesToNo(name.to_owned())),
(false, true) => Some(DatabasePrivilegeChange::NoToYes(name.to_owned())),
_ => None,
}
}
}
/// This enum encapsulates whether a [`DatabasePrivilegeRow`] was intrduced, modified or deleted.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
pub enum DatabasePrivilegesDiff {
New(DatabasePrivilegeRow),
Modified(DatabasePrivilegeRowDiff),
Deleted(DatabasePrivilegeRow),
}
impl DatabasePrivilegesDiff {
pub fn get_database_name(&self) -> &MySQLDatabase {
match self {
DatabasePrivilegesDiff::New(p) => &p.db,
DatabasePrivilegesDiff::Modified(p) => &p.db,
DatabasePrivilegesDiff::Deleted(p) => &p.db,
}
}
pub fn get_user_name(&self) -> &MySQLUser {
match self {
DatabasePrivilegesDiff::New(p) => &p.user,
DatabasePrivilegesDiff::Modified(p) => &p.user,
DatabasePrivilegesDiff::Deleted(p) => &p.user,
}
}
}
/// This function calculates the differences between two sets of database privileges.
/// It returns a set of [`DatabasePrivilegesDiff`] that can be used to display or
/// apply a set of privilege modifications to the database.
pub fn diff_privileges(
from: &[DatabasePrivilegeRow],
to: &[DatabasePrivilegeRow],
) -> BTreeSet<DatabasePrivilegesDiff> {
let from_lookup_table: HashMap<(MySQLDatabase, MySQLUser), DatabasePrivilegeRow> =
HashMap::from_iter(
from.iter()
.cloned()
.map(|p| ((p.db.to_owned(), p.user.to_owned()), p)),
);
let to_lookup_table: HashMap<(MySQLDatabase, MySQLUser), DatabasePrivilegeRow> =
HashMap::from_iter(
to.iter()
.cloned()
.map(|p| ((p.db.to_owned(), p.user.to_owned()), p)),
);
let mut result = BTreeSet::new();
for p in to {
if let Some(old_p) = from_lookup_table.get(&(p.db.to_owned(), p.user.to_owned())) {
let diff = diff(old_p, p);
if !diff.diff.is_empty() {
result.insert(DatabasePrivilegesDiff::Modified(diff));
}
} else {
result.insert(DatabasePrivilegesDiff::New(p.to_owned()));
}
}
for p in from {
if !to_lookup_table.contains_key(&(p.db.to_owned(), p.user.to_owned())) {
result.insert(DatabasePrivilegesDiff::Deleted(p.to_owned()));
}
}
result
}
fn display_privilege_cell(diff: &DatabasePrivilegeRowDiff) -> String {
diff.diff
.iter()
.map(|change| match change {
DatabasePrivilegeChange::YesToNo(name) => {
format!("{}: Y -> N", db_priv_field_human_readable_name(name))
}
DatabasePrivilegeChange::NoToYes(name) => {
format!("{}: N -> Y", db_priv_field_human_readable_name(name))
}
})
.join("\n")
}
fn display_new_privileges_list(row: &DatabasePrivilegeRow) -> String {
DATABASE_PRIVILEGE_FIELDS
.into_iter()
.skip(2)
.map(|field| {
if row.get_privilege_by_name(field) {
format!("{}: Y", db_priv_field_human_readable_name(field))
} else {
format!("{}: N", db_priv_field_human_readable_name(field))
}
})
.join("\n")
}
/// Displays the difference between two sets of database privileges.
pub fn display_privilege_diffs(diffs: &BTreeSet<DatabasePrivilegesDiff>) -> String {
let mut table = Table::new();
table.set_titles(row!["Database", "User", "Privilege diff",]);
for row in diffs {
match row {
DatabasePrivilegesDiff::New(p) => {
table.add_row(row![
p.db,
p.user,
"(Previously unprivileged)\n".to_string() + &display_new_privileges_list(p)
]);
}
DatabasePrivilegesDiff::Modified(p) => {
table.add_row(row![p.db, p.user, display_privilege_cell(p),]);
}
DatabasePrivilegesDiff::Deleted(p) => {
table.add_row(row![p.db, p.user, "Removed".to_string()]);
}
}
}
table.to_string()
}
/*********/
/* TESTS */
/*********/
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_database_privilege_change_creation() {
assert_eq!(
DatabasePrivilegeChange::new(true, false, "test"),
Some(DatabasePrivilegeChange::YesToNo("test".to_owned()))
);
assert_eq!(
DatabasePrivilegeChange::new(false, true, "test"),
Some(DatabasePrivilegeChange::NoToYes("test".to_owned()))
);
assert_eq!(DatabasePrivilegeChange::new(true, true, "test"), None);
assert_eq!(DatabasePrivilegeChange::new(false, false, "test"), None);
}
#[test]
fn test_parse_privilege_table_cli_arg() {
let result = parse_privilege_table_cli_arg("db:user:A");
assert_eq!(
result.ok(),
Some(DatabasePrivilegeRow {
db: "db".into(),
user: "user".into(),
select_priv: true,
insert_priv: true,
update_priv: true,
delete_priv: true,
create_priv: true,
drop_priv: true,
alter_priv: true,
index_priv: true,
create_tmp_table_priv: true,
lock_tables_priv: true,
references_priv: true,
})
);
let result = parse_privilege_table_cli_arg("db:user:");
assert_eq!(
result.ok(),
Some(DatabasePrivilegeRow {
db: "db".into(),
user: "user".into(),
select_priv: false,
insert_priv: false,
update_priv: false,
delete_priv: false,
create_priv: false,
drop_priv: false,
alter_priv: false,
index_priv: false,
create_tmp_table_priv: false,
lock_tables_priv: false,
references_priv: false,
})
);
let result = parse_privilege_table_cli_arg("db:user:siud");
assert_eq!(
result.ok(),
Some(DatabasePrivilegeRow {
db: "db".into(),
user: "user".into(),
select_priv: true,
insert_priv: true,
update_priv: true,
delete_priv: true,
create_priv: false,
drop_priv: false,
alter_priv: false,
index_priv: false,
create_tmp_table_priv: false,
lock_tables_priv: false,
references_priv: false,
})
);
let result = parse_privilege_table_cli_arg("db:user:F");
assert!(result.is_err());
let result = parse_privilege_table_cli_arg("db:s");
assert!(result.is_err());
let result = parse_privilege_table_cli_arg("::");
assert!(result.is_err());
let result = parse_privilege_table_cli_arg("db::");
assert!(result.is_err());
let result = parse_privilege_table_cli_arg(":user:");
assert!(result.is_err());
}
#[test]
fn test_diff_privileges() {
let row_to_be_modified = DatabasePrivilegeRow {
db: "db".into(),
user: "user".into(),
select_priv: true,
insert_priv: true,
update_priv: true,
delete_priv: true,
create_priv: true,
drop_priv: true,
alter_priv: true,
index_priv: false,
create_tmp_table_priv: true,
lock_tables_priv: true,
references_priv: false,
};
let mut row_to_be_deleted = row_to_be_modified.to_owned();
"user2".clone_into(&mut row_to_be_deleted.user);
let from = vec![row_to_be_modified.to_owned(), row_to_be_deleted.to_owned()];
let mut modified_row = row_to_be_modified.to_owned();
modified_row.select_priv = false;
modified_row.insert_priv = false;
modified_row.index_priv = true;
let mut new_row = row_to_be_modified.to_owned();
"user3".clone_into(&mut new_row.user);
let to = vec![modified_row.to_owned(), new_row.to_owned()];
let diffs = diff_privileges(&from, &to);
assert_eq!(
diffs,
BTreeSet::from_iter(vec![
DatabasePrivilegesDiff::Deleted(row_to_be_deleted),
DatabasePrivilegesDiff::Modified(DatabasePrivilegeRowDiff {
db: "db".into(),
user: "user".into(),
diff: BTreeSet::from_iter(vec![
DatabasePrivilegeChange::YesToNo("select_priv".to_owned()),
DatabasePrivilegeChange::YesToNo("insert_priv".to_owned()),
DatabasePrivilegeChange::NoToYes("index_priv".to_owned()),
]),
}),
DatabasePrivilegesDiff::New(new_row),
])
);
}
#[test]
fn ensure_generated_and_parsed_editor_content_is_equal() {
let permissions = vec![
DatabasePrivilegeRow {
db: "db".into(),
user: "user".into(),
select_priv: true,
insert_priv: true,
update_priv: true,
delete_priv: true,
create_priv: true,
drop_priv: true,
alter_priv: true,
index_priv: true,
create_tmp_table_priv: true,
lock_tables_priv: true,
references_priv: true,
},
DatabasePrivilegeRow {
db: "db".into(),
user: "user".into(),
select_priv: false,
insert_priv: false,
update_priv: false,
delete_priv: false,
create_priv: false,
drop_priv: false,
alter_priv: false,
index_priv: false,
create_tmp_table_priv: false,
lock_tables_priv: false,
references_priv: false,
},
];
let content = generate_editor_content_from_privilege_data(&permissions, "user", None);
let parsed_permissions = parse_privilege_data_from_editor_content(content).unwrap();
assert_eq!(permissions, parsed_permissions);
}
}
mod base;
mod cli;
mod diff;
mod editor;
pub use base::*;
pub use cli::*;
pub use diff::*;
pub use editor::*;

View File

@@ -0,0 +1,125 @@
//! This module contains some base datastructures and functionality for dealing with
//! database privileges in `MySQL`.
use std::fmt;
use crate::core::types::{MySQLDatabase, MySQLUser};
use serde::{Deserialize, Serialize};
/// This is the list of fields that are used to fetch the db + user + privileges
/// from the `db` table in the database. If you need to add or remove privilege
/// fields, this is a good place to start.
pub const DATABASE_PRIVILEGE_FIELDS: [&str; 13] = [
"Db",
"User",
"select_priv",
"insert_priv",
"update_priv",
"delete_priv",
"create_priv",
"drop_priv",
"alter_priv",
"index_priv",
"create_tmp_table_priv",
"lock_tables_priv",
"references_priv",
];
// NOTE: ord is needed for BTreeSet to accept the type, but it
// doesn't have any natural implementation semantics.
/// Representation of the set of privileges for a single user on a single database.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
pub struct DatabasePrivilegeRow {
// TODO: don't store the db and user here, let the type be stored in a mapping
pub db: MySQLDatabase,
pub user: MySQLUser,
pub select_priv: bool,
pub insert_priv: bool,
pub update_priv: bool,
pub delete_priv: bool,
pub create_priv: bool,
pub drop_priv: bool,
pub alter_priv: bool,
pub index_priv: bool,
pub create_tmp_table_priv: bool,
pub lock_tables_priv: bool,
pub references_priv: bool,
}
impl DatabasePrivilegeRow {
/// Gets the value of a privilege by its name as a &str.
#[must_use]
pub fn get_privilege_by_name(&self, name: &str) -> Option<bool> {
match name {
"select_priv" => Some(self.select_priv),
"insert_priv" => Some(self.insert_priv),
"update_priv" => Some(self.update_priv),
"delete_priv" => Some(self.delete_priv),
"create_priv" => Some(self.create_priv),
"drop_priv" => Some(self.drop_priv),
"alter_priv" => Some(self.alter_priv),
"index_priv" => Some(self.index_priv),
"create_tmp_table_priv" => Some(self.create_tmp_table_priv),
"lock_tables_priv" => Some(self.lock_tables_priv),
"references_priv" => Some(self.references_priv),
_ => None,
}
}
}
impl fmt::Display for DatabasePrivilegeRow {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for field in DATABASE_PRIVILEGE_FIELDS.into_iter().skip(2) {
if self.get_privilege_by_name(field).unwrap() {
f.write_str(db_priv_field_human_readable_name(field).as_str())?;
f.write_str(": Y\n")?;
} else {
f.write_str(db_priv_field_human_readable_name(field).as_str())?;
f.write_str(": N\n")?;
}
}
Ok(())
}
}
/// Converts a database privilege field name to a human-readable name.
#[must_use]
pub fn db_priv_field_human_readable_name(name: &str) -> String {
match name {
"Db" => "Database".to_owned(),
"User" => "User".to_owned(),
"select_priv" => "Select".to_owned(),
"insert_priv" => "Insert".to_owned(),
"update_priv" => "Update".to_owned(),
"delete_priv" => "Delete".to_owned(),
"create_priv" => "Create".to_owned(),
"drop_priv" => "Drop".to_owned(),
"alter_priv" => "Alter".to_owned(),
"index_priv" => "Index".to_owned(),
"create_tmp_table_priv" => "Temp".to_owned(),
"lock_tables_priv" => "Lock".to_owned(),
"references_priv" => "References".to_owned(),
_ => format!("Unknown({name})"),
}
}
/// Converts a database privilege field name to a single-character name.
/// (the characters from the cli privilege editor)
#[must_use]
pub fn db_priv_field_single_character_name(name: &str) -> &str {
match name {
"select_priv" => "s",
"insert_priv" => "i",
"update_priv" => "u",
"delete_priv" => "d",
"create_priv" => "c",
"drop_priv" => "D",
"alter_priv" => "a",
"index_priv" => "I",
"create_tmp_table_priv" => "t",
"lock_tables_priv" => "l",
"references_priv" => "r",
_ => "?",
}
}

View File

@@ -0,0 +1,342 @@
//! 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)]
pub enum DatabasePrivilegeEditEntryType {
Add,
Set,
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`
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DatabasePrivilegeEditEntry {
pub database: MySQLDatabase,
pub user: MySQLUser,
pub privilege_edit: DatabasePrivilegeEdit,
}
impl DatabasePrivilegeEditEntry {
/// Parses a privilege edit entry from a string.
///
/// The expected format is:
///
/// `database_name:username:[+|-]privileges`
///
/// where:
/// - `database_name` is the name of the database to edit privileges for
/// - username is the name of the user to edit privileges for
/// - privileges is a string of characters representing the privileges to add, set or remove
/// - the `+` or `-` prefix indicates whether to add or remove the privileges, if omitted the privileges are set directly
/// - privileges characters are: siudcDaAItlrA
pub fn parse_from_str(arg: &str) -> anyhow::Result<Self> {
let parts: Vec<&str> = arg.split(':').collect();
if 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]);
if user.is_empty() {
anyhow::bail!("Username cannot be empty in privilege edit entry: {arg}");
}
let privilege_edit = DatabasePrivilegeEdit::parse_from_str(user_privs)?;
Ok(DatabasePrivilegeEditEntry {
database: MySQLDatabase::from(database),
user: MySQLUser::from(user),
privilege_edit,
})
}
pub fn as_database_privileges_diff(&self) -> anyhow::Result<DatabasePrivilegeRowDiff> {
let mut diff;
match self.privilege_edit.type_ {
DatabasePrivilegeEditEntryType::Set => {
diff = DatabasePrivilegeRowDiff {
db: self.database.clone(),
user: self.user.clone(),
select_priv: Some(DatabasePrivilegeChange::YesToNo),
insert_priv: Some(DatabasePrivilegeChange::YesToNo),
update_priv: Some(DatabasePrivilegeChange::YesToNo),
delete_priv: Some(DatabasePrivilegeChange::YesToNo),
create_priv: Some(DatabasePrivilegeChange::YesToNo),
drop_priv: Some(DatabasePrivilegeChange::YesToNo),
alter_priv: Some(DatabasePrivilegeChange::YesToNo),
index_priv: Some(DatabasePrivilegeChange::YesToNo),
create_tmp_table_priv: Some(DatabasePrivilegeChange::YesToNo),
lock_tables_priv: Some(DatabasePrivilegeChange::YesToNo),
references_priv: Some(DatabasePrivilegeChange::YesToNo),
};
for priv_char in &self.privilege_edit.privileges {
match priv_char {
's' => diff.select_priv = Some(DatabasePrivilegeChange::NoToYes),
'i' => diff.insert_priv = Some(DatabasePrivilegeChange::NoToYes),
'u' => diff.update_priv = Some(DatabasePrivilegeChange::NoToYes),
'd' => diff.delete_priv = Some(DatabasePrivilegeChange::NoToYes),
'c' => diff.create_priv = Some(DatabasePrivilegeChange::NoToYes),
'D' => diff.drop_priv = Some(DatabasePrivilegeChange::NoToYes),
'a' => diff.alter_priv = Some(DatabasePrivilegeChange::NoToYes),
'I' => diff.index_priv = Some(DatabasePrivilegeChange::NoToYes),
't' => diff.create_tmp_table_priv = Some(DatabasePrivilegeChange::NoToYes),
'l' => diff.lock_tables_priv = Some(DatabasePrivilegeChange::NoToYes),
'r' => diff.references_priv = Some(DatabasePrivilegeChange::NoToYes),
'A' => {
diff.select_priv = Some(DatabasePrivilegeChange::NoToYes);
diff.insert_priv = Some(DatabasePrivilegeChange::NoToYes);
diff.update_priv = Some(DatabasePrivilegeChange::NoToYes);
diff.delete_priv = Some(DatabasePrivilegeChange::NoToYes);
diff.create_priv = Some(DatabasePrivilegeChange::NoToYes);
diff.drop_priv = Some(DatabasePrivilegeChange::NoToYes);
diff.alter_priv = Some(DatabasePrivilegeChange::NoToYes);
diff.index_priv = Some(DatabasePrivilegeChange::NoToYes);
diff.create_tmp_table_priv = Some(DatabasePrivilegeChange::NoToYes);
diff.lock_tables_priv = Some(DatabasePrivilegeChange::NoToYes);
diff.references_priv = Some(DatabasePrivilegeChange::NoToYes);
}
_ => unreachable!(),
}
}
}
DatabasePrivilegeEditEntryType::Add | DatabasePrivilegeEditEntryType::Remove => {
diff = DatabasePrivilegeRowDiff {
db: self.database.clone(),
user: self.user.clone(),
select_priv: None,
insert_priv: None,
update_priv: None,
delete_priv: None,
create_priv: None,
drop_priv: None,
alter_priv: None,
index_priv: None,
create_tmp_table_priv: None,
lock_tables_priv: None,
references_priv: None,
};
let value = match self.privilege_edit.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' => {
diff.select_priv = Some(value);
diff.insert_priv = Some(value);
diff.update_priv = Some(value);
diff.delete_priv = Some(value);
diff.create_priv = Some(value);
diff.drop_priv = Some(value);
diff.alter_priv = Some(value);
diff.index_priv = Some(value);
diff.create_tmp_table_priv = Some(value);
diff.lock_tables_priv = Some(value);
diff.references_priv = Some(value);
}
_ => unreachable!(),
}
}
}
}
Ok(diff)
}
}
impl std::fmt::Display for DatabasePrivilegeEditEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:, ", self.database)?;
write!(f, "{}: ", self.user)?;
write!(f, "{}", self.privilege_edit)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cli_arg_parse_set_db_user_all() {
let result = DatabasePrivilegeEditEntry::parse_from_str("db:user:A");
assert_eq!(
result.ok(),
Some(DatabasePrivilegeEditEntry {
database: "db".into(),
user: "user".into(),
privilege_edit: DatabasePrivilegeEdit {
type_: DatabasePrivilegeEditEntryType::Set,
privileges: vec!['A'],
},
})
);
}
#[test]
fn test_cli_arg_parse_set_db_user_none() {
let result = DatabasePrivilegeEditEntry::parse_from_str("db:user:");
assert_eq!(
result.ok(),
Some(DatabasePrivilegeEditEntry {
database: "db".into(),
user: "user".into(),
privilege_edit: DatabasePrivilegeEdit {
type_: DatabasePrivilegeEditEntryType::Set,
privileges: vec![],
},
})
);
}
#[test]
fn test_cli_arg_parse_set_db_user_misc() {
let result = DatabasePrivilegeEditEntry::parse_from_str("db:user:siud");
assert_eq!(
result.ok(),
Some(DatabasePrivilegeEditEntry {
database: "db".into(),
user: "user".into(),
privilege_edit: DatabasePrivilegeEdit {
type_: DatabasePrivilegeEditEntryType::Set,
privileges: vec!['s', 'i', 'u', 'd'],
},
})
);
}
#[test]
fn test_cli_arg_parse_set_db_user_nonexistent_privilege() {
let result = DatabasePrivilegeEditEntry::parse_from_str("db:user:F");
assert!(result.is_err());
}
#[test]
fn test_cli_arg_parse_set_user_empty_string() {
let result = DatabasePrivilegeEditEntry::parse_from_str("::");
assert!(result.is_err());
}
#[test]
fn test_cli_arg_parse_set_db_user_empty_string() {
let result = DatabasePrivilegeEditEntry::parse_from_str("db::");
assert!(result.is_err());
}
#[test]
fn test_cli_arg_parse_add_db_user_misc() {
let result = DatabasePrivilegeEditEntry::parse_from_str("db:user:+siud");
assert_eq!(
result.ok(),
Some(DatabasePrivilegeEditEntry {
database: "db".into(),
user: "user".into(),
privilege_edit: DatabasePrivilegeEdit {
type_: DatabasePrivilegeEditEntryType::Add,
privileges: vec!['s', 'i', 'u', 'd'],
},
})
);
}
#[test]
fn test_cli_arg_parse_remove_db_user_misc() {
let result = DatabasePrivilegeEditEntry::parse_from_str("db:user:-siud");
assert_eq!(
result.ok(),
Some(DatabasePrivilegeEditEntry {
database: "db".into(),
user: "user".into(),
privilege_edit: DatabasePrivilegeEdit {
type_: DatabasePrivilegeEditEntryType::Remove,
privileges: vec!['s', 'i', 'u', 'd'],
},
}),
);
}
}

View File

@@ -0,0 +1,694 @@
//! This module contains datastructures and logic for comparing database privileges,
//! generating, validating and reducing diffs between two sets of database privileges.
use super::base::{DatabasePrivilegeRow, db_priv_field_human_readable_name};
use crate::core::types::{MySQLDatabase, MySQLUser};
use prettytable::Table;
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeSet, HashMap, hash_map::Entry},
fmt,
};
/// This enum represents a change for a single privilege.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
pub enum DatabasePrivilegeChange {
YesToNo,
NoToYes,
}
impl DatabasePrivilegeChange {
#[must_use]
pub fn new(p1: bool, p2: bool) -> Option<DatabasePrivilegeChange> {
match (p1, p2) {
(true, false) => Some(DatabasePrivilegeChange::YesToNo),
(false, true) => Some(DatabasePrivilegeChange::NoToYes),
_ => None,
}
}
}
/// This struct encapsulates the before and after states of the
/// access privileges for a single user on a single database.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord, Default)]
pub struct DatabasePrivilegeRowDiff {
// TODO: don't store the db and user here, let the type be stored in a mapping
pub db: MySQLDatabase,
pub user: MySQLUser,
pub select_priv: Option<DatabasePrivilegeChange>,
pub insert_priv: Option<DatabasePrivilegeChange>,
pub update_priv: Option<DatabasePrivilegeChange>,
pub delete_priv: Option<DatabasePrivilegeChange>,
pub create_priv: Option<DatabasePrivilegeChange>,
pub drop_priv: Option<DatabasePrivilegeChange>,
pub alter_priv: Option<DatabasePrivilegeChange>,
pub index_priv: Option<DatabasePrivilegeChange>,
pub create_tmp_table_priv: Option<DatabasePrivilegeChange>,
pub lock_tables_priv: Option<DatabasePrivilegeChange>,
pub references_priv: Option<DatabasePrivilegeChange>,
}
impl DatabasePrivilegeRowDiff {
/// Calculates the difference between two [`DatabasePrivilegeRow`] instances.
#[must_use]
pub fn from_rows(
row1: &DatabasePrivilegeRow,
row2: &DatabasePrivilegeRow,
) -> DatabasePrivilegeRowDiff {
debug_assert!(row1.db == row2.db && row1.user == row2.user);
DatabasePrivilegeRowDiff {
db: row1.db.clone(),
user: row1.user.clone(),
select_priv: DatabasePrivilegeChange::new(row1.select_priv, row2.select_priv),
insert_priv: DatabasePrivilegeChange::new(row1.insert_priv, row2.insert_priv),
update_priv: DatabasePrivilegeChange::new(row1.update_priv, row2.update_priv),
delete_priv: DatabasePrivilegeChange::new(row1.delete_priv, row2.delete_priv),
create_priv: DatabasePrivilegeChange::new(row1.create_priv, row2.create_priv),
drop_priv: DatabasePrivilegeChange::new(row1.drop_priv, row2.drop_priv),
alter_priv: DatabasePrivilegeChange::new(row1.alter_priv, row2.alter_priv),
index_priv: DatabasePrivilegeChange::new(row1.index_priv, row2.index_priv),
create_tmp_table_priv: DatabasePrivilegeChange::new(
row1.create_tmp_table_priv,
row2.create_tmp_table_priv,
),
lock_tables_priv: DatabasePrivilegeChange::new(
row1.lock_tables_priv,
row2.lock_tables_priv,
),
references_priv: DatabasePrivilegeChange::new(
row1.references_priv,
row2.references_priv,
),
}
}
/// Returns true if there are no changes in this diff.
#[must_use]
pub fn is_empty(&self) -> bool {
self.select_priv.is_none()
&& self.insert_priv.is_none()
&& self.update_priv.is_none()
&& self.delete_priv.is_none()
&& self.create_priv.is_none()
&& self.drop_priv.is_none()
&& self.alter_priv.is_none()
&& self.index_priv.is_none()
&& self.create_tmp_table_priv.is_none()
&& self.lock_tables_priv.is_none()
&& self.references_priv.is_none()
}
/// Retrieves the privilege change for a given privilege name.
pub fn get_privilege_change_by_name(
&self,
privilege_name: &str,
) -> anyhow::Result<Option<DatabasePrivilegeChange>> {
match privilege_name {
"select_priv" => Ok(self.select_priv),
"insert_priv" => Ok(self.insert_priv),
"update_priv" => Ok(self.update_priv),
"delete_priv" => Ok(self.delete_priv),
"create_priv" => Ok(self.create_priv),
"drop_priv" => Ok(self.drop_priv),
"alter_priv" => Ok(self.alter_priv),
"index_priv" => Ok(self.index_priv),
"create_tmp_table_priv" => Ok(self.create_tmp_table_priv),
"lock_tables_priv" => Ok(self.lock_tables_priv),
"references_priv" => Ok(self.references_priv),
_ => anyhow::bail!("Unknown privilege name: {privilege_name}"),
}
}
/// Merges another diff into this one, combining them in a sequential manner.
fn mappend(&mut self, other: &DatabasePrivilegeRowDiff) {
debug_assert!(self.db == other.db && self.user == other.user);
if other.select_priv.is_some() {
self.select_priv = other.select_priv;
}
if other.insert_priv.is_some() {
self.insert_priv = other.insert_priv;
}
if other.update_priv.is_some() {
self.update_priv = other.update_priv;
}
if other.delete_priv.is_some() {
self.delete_priv = other.delete_priv;
}
if other.create_priv.is_some() {
self.create_priv = other.create_priv;
}
if other.drop_priv.is_some() {
self.drop_priv = other.drop_priv;
}
if other.alter_priv.is_some() {
self.alter_priv = other.alter_priv;
}
if other.index_priv.is_some() {
self.index_priv = other.index_priv;
}
if other.create_tmp_table_priv.is_some() {
self.create_tmp_table_priv = other.create_tmp_table_priv;
}
if other.lock_tables_priv.is_some() {
self.lock_tables_priv = other.lock_tables_priv;
}
if other.references_priv.is_some() {
self.references_priv = other.references_priv;
}
}
/// Removes any no-op changes from the diff, based on the original privilege row.
fn remove_noops(&mut self, from: &DatabasePrivilegeRow) {
fn new_value(
change: Option<&DatabasePrivilegeChange>,
from_value: bool,
) -> Option<DatabasePrivilegeChange> {
change.as_ref().and_then(|c| match c {
DatabasePrivilegeChange::YesToNo if from_value => {
Some(DatabasePrivilegeChange::YesToNo)
}
DatabasePrivilegeChange::NoToYes if !from_value => {
Some(DatabasePrivilegeChange::NoToYes)
}
_ => None,
})
}
self.select_priv = new_value(self.select_priv.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);
}
fn apply(&self, base: &mut DatabasePrivilegeRow) {
fn apply_change(change: Option<&DatabasePrivilegeChange>, target: &mut bool) {
match change {
Some(DatabasePrivilegeChange::YesToNo) => *target = false,
Some(DatabasePrivilegeChange::NoToYes) => *target = true,
None => {}
}
}
apply_change(self.select_priv.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);
}
}
impl fmt::Display for DatabasePrivilegeRowDiff {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fn format_change(
f: &mut fmt::Formatter<'_>,
change: Option<DatabasePrivilegeChange>,
field_name: &str,
) -> fmt::Result {
if let Some(change) = change {
match change {
DatabasePrivilegeChange::YesToNo => f.write_fmt(format_args!(
"{}: Y -> N\n",
db_priv_field_human_readable_name(field_name)
)),
DatabasePrivilegeChange::NoToYes => f.write_fmt(format_args!(
"{}: N -> Y\n",
db_priv_field_human_readable_name(field_name)
)),
}
} else {
Ok(())
}
}
format_change(f, self.select_priv, "select_priv")?;
format_change(f, self.insert_priv, "insert_priv")?;
format_change(f, self.update_priv, "update_priv")?;
format_change(f, self.delete_priv, "delete_priv")?;
format_change(f, self.create_priv, "create_priv")?;
format_change(f, self.drop_priv, "drop_priv")?;
format_change(f, self.alter_priv, "alter_priv")?;
format_change(f, self.index_priv, "index_priv")?;
format_change(f, self.create_tmp_table_priv, "create_tmp_table_priv")?;
format_change(f, self.lock_tables_priv, "lock_tables_priv")?;
format_change(f, self.references_priv, "references_priv")?;
Ok(())
}
}
/// This enum encapsulates whether a [`DatabasePrivilegeRow`] was introduced, modified or deleted.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
pub enum DatabasePrivilegesDiff {
New(DatabasePrivilegeRow),
Modified(DatabasePrivilegeRowDiff),
Deleted(DatabasePrivilegeRow),
Noop { db: MySQLDatabase, user: MySQLUser },
}
impl DatabasePrivilegesDiff {
#[must_use]
pub fn get_database_name(&self) -> &MySQLDatabase {
match self {
DatabasePrivilegesDiff::New(p) => &p.db,
DatabasePrivilegesDiff::Modified(p) => &p.db,
DatabasePrivilegesDiff::Deleted(p) => &p.db,
DatabasePrivilegesDiff::Noop { db, .. } => db,
}
}
#[must_use]
pub fn get_user_name(&self) -> &MySQLUser {
match self {
DatabasePrivilegesDiff::New(p) => &p.user,
DatabasePrivilegesDiff::Modified(p) => &p.user,
DatabasePrivilegesDiff::Deleted(p) => &p.user,
DatabasePrivilegesDiff::Noop { user, .. } => user,
}
}
/// Merges another [`DatabasePrivilegesDiff`] into this one, combining them in a sequential manner.
/// For example, if this diff represents a creation and the other represents a modification,
/// the result will be a creation with the modifications applied.
pub fn mappend(&mut self, other: &DatabasePrivilegesDiff) -> anyhow::Result<()> {
debug_assert!(
self.get_database_name() == other.get_database_name()
&& self.get_user_name() == other.get_user_name()
);
if matches!(self, DatabasePrivilegesDiff::Deleted(_))
&& (matches!(other, DatabasePrivilegesDiff::Modified(_)))
{
anyhow::bail!("Cannot modify a deleted database privilege row");
}
if matches!(self, DatabasePrivilegesDiff::New(_))
&& (matches!(other, DatabasePrivilegesDiff::New(_)))
{
anyhow::bail!("Cannot create an already existing database privilege row");
}
if matches!(self, DatabasePrivilegesDiff::Modified(_))
&& (matches!(other, DatabasePrivilegesDiff::New(_)))
{
anyhow::bail!("Cannot create an already existing database privilege row");
}
if matches!(self, DatabasePrivilegesDiff::Noop { .. }) {
other.clone_into(self);
return Ok(());
} else if matches!(other, DatabasePrivilegesDiff::Noop { .. }) {
return Ok(());
}
match (&self, other) {
(DatabasePrivilegesDiff::New(_), DatabasePrivilegesDiff::Modified(modified)) => {
let inner_row = match self {
DatabasePrivilegesDiff::New(r) => r,
_ => unreachable!(),
};
modified.apply(inner_row);
}
(DatabasePrivilegesDiff::Modified(_), DatabasePrivilegesDiff::Modified(modified)) => {
let inner_diff = match self {
DatabasePrivilegesDiff::Modified(r) => r,
_ => unreachable!(),
};
inner_diff.mappend(modified);
if inner_diff.is_empty() {
let db = inner_diff.db.clone();
let user = inner_diff.user.clone();
*self = DatabasePrivilegesDiff::Noop { db, user };
}
}
(DatabasePrivilegesDiff::Modified(_), DatabasePrivilegesDiff::Deleted(deleted)) => {
*self = DatabasePrivilegesDiff::Deleted(deleted.to_owned());
}
(DatabasePrivilegesDiff::New(_), DatabasePrivilegesDiff::Deleted(_)) => {
let db = self.get_database_name().to_owned();
let user = self.get_user_name().to_owned();
*self = DatabasePrivilegesDiff::Noop { db, user };
}
_ => {}
}
Ok(())
}
}
pub type DatabasePrivilegeState<'a> = &'a [DatabasePrivilegeRow];
/// This function calculates the differences between two sets of database privileges.
/// It returns a set of [`DatabasePrivilegesDiff`] that can be used to display or
/// apply a set of privilege modifications to the database.
#[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 to_lookup_table: HashMap<(MySQLDatabase, MySQLUser), DatabasePrivilegeRow> = to
.iter()
.cloned()
.map(|p| ((p.db.clone(), p.user.clone()), p))
.collect();
let mut result = BTreeSet::new();
for p in to {
if let Some(old_p) = from_lookup_table.get(&(p.db.clone(), p.user.clone())) {
let diff = DatabasePrivilegeRowDiff::from_rows(old_p, p);
if !diff.is_empty() {
result.insert(DatabasePrivilegesDiff::Modified(diff));
}
} else {
result.insert(DatabasePrivilegesDiff::New(p.to_owned()));
}
}
for p in from {
if !to_lookup_table.contains_key(&(p.db.clone(), p.user.clone())) {
result.insert(DatabasePrivilegesDiff::Deleted(p.to_owned()));
}
}
result
}
/// Converts a set of [`DatabasePrivilegeRowDiff`] into a set of [`DatabasePrivilegesDiff`],
/// representing either creating new privilege rows, or modifying the existing ones.
///
/// This is particularly useful for processing CLI arguments.
pub fn create_or_modify_privilege_rows(
from: DatabasePrivilegeState<'_>,
to: &BTreeSet<DatabasePrivilegeRowDiff>,
) -> anyhow::Result<BTreeSet<DatabasePrivilegesDiff>> {
let from_lookup_table: HashMap<(MySQLDatabase, MySQLUser), DatabasePrivilegeRow> = from
.iter()
.cloned()
.map(|p| ((p.db.clone(), p.user.clone()), p))
.collect();
let mut result = BTreeSet::new();
for diff in to {
if let Some(old_p) = from_lookup_table.get(&(diff.db.clone(), diff.user.clone())) {
let mut modified_diff = diff.to_owned();
modified_diff.remove_noops(old_p);
if !modified_diff.is_empty() {
result.insert(DatabasePrivilegesDiff::Modified(modified_diff));
}
} else {
let mut new_row = DatabasePrivilegeRow {
db: diff.db.clone(),
user: diff.user.clone(),
select_priv: false,
insert_priv: false,
update_priv: false,
delete_priv: false,
create_priv: false,
drop_priv: false,
alter_priv: false,
index_priv: false,
create_tmp_table_priv: false,
lock_tables_priv: false,
references_priv: false,
};
diff.apply(&mut new_row);
result.insert(DatabasePrivilegesDiff::New(new_row));
}
}
Ok(result)
}
/// Reduces a set of [`DatabasePrivilegesDiff`] by removing any modifications that would be no-ops.
/// For example, if a privilege is changed from Yes to No, but it was already No, that change
/// is removed from the diff.
///
/// The `from` parameter is used to determine the current state of the privileges.
/// The `to` parameter is the set of diffs to be reduced.
pub fn reduce_privilege_diffs(
from: DatabasePrivilegeState<'_>,
to: BTreeSet<DatabasePrivilegesDiff>,
) -> anyhow::Result<BTreeSet<DatabasePrivilegesDiff>> {
let from_lookup_table: HashMap<(MySQLDatabase, MySQLUser), DatabasePrivilegeRow> = from
.iter()
.cloned()
.map(|p| ((p.db.clone(), p.user.clone()), p))
.collect();
let mut result: HashMap<(MySQLDatabase, MySQLUser), DatabasePrivilegesDiff> = from_lookup_table
.iter()
.map(|((db, user), _)| {
(
(db.to_owned(), user.to_owned()),
DatabasePrivilegesDiff::Noop {
db: db.to_owned(),
user: user.to_owned(),
},
)
})
.collect();
for diff in to {
let entry = result.entry((
diff.get_database_name().to_owned(),
diff.get_user_name().to_owned(),
));
match entry {
Entry::Occupied(mut occupied_entry) => {
let existing_diff = occupied_entry.get_mut();
existing_diff.mappend(&diff)?;
}
Entry::Vacant(vacant_entry) => {
vacant_entry.insert(diff.clone());
}
}
}
for (key, diff) in &mut result {
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();
*diff = DatabasePrivilegesDiff::Noop { db, user };
}
}
}
Ok(result
.into_values()
.filter(|diff| !matches!(diff, DatabasePrivilegesDiff::Noop { .. }))
.collect::<BTreeSet<DatabasePrivilegesDiff>>())
}
/// Renders a set of [`DatabasePrivilegesDiff`] into a human-readable formatted table.
#[must_use]
pub fn display_privilege_diffs(diffs: &BTreeSet<DatabasePrivilegesDiff>) -> String {
let mut table = Table::new();
table.set_titles(row!["Database", "User", "Privilege diff",]);
for row in diffs {
match row {
DatabasePrivilegesDiff::New(p) => {
table.add_row(row![
p.db,
p.user,
"(Previously unprivileged)\n".to_string() + &p.to_string()
]);
}
DatabasePrivilegesDiff::Modified(p) => {
table.add_row(row![p.db, p.user, p.to_string(),]);
}
DatabasePrivilegesDiff::Deleted(p) => {
table.add_row(row![p.db, p.user, "Removed".to_string()]);
}
DatabasePrivilegesDiff::Noop { db, user } => {
table.add_row(row![db, user, "No changes".to_string()]);
}
}
}
table.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_database_privilege_change_creation() {
assert_eq!(
DatabasePrivilegeChange::new(true, false),
Some(DatabasePrivilegeChange::YesToNo),
);
assert_eq!(
DatabasePrivilegeChange::new(false, true),
Some(DatabasePrivilegeChange::NoToYes),
);
assert_eq!(DatabasePrivilegeChange::new(true, true), None);
assert_eq!(DatabasePrivilegeChange::new(false, false), None);
}
#[test]
fn test_database_privilege_row_diff_from_rows() {
let row1 = DatabasePrivilegeRow {
db: "db".into(),
user: "user".into(),
select_priv: true,
insert_priv: false,
update_priv: true,
delete_priv: false,
create_priv: false,
drop_priv: false,
alter_priv: false,
index_priv: false,
create_tmp_table_priv: false,
lock_tables_priv: false,
references_priv: false,
};
let row2 = DatabasePrivilegeRow {
db: "db".into(),
user: "user".into(),
select_priv: true,
insert_priv: true,
update_priv: false,
delete_priv: false,
create_priv: false,
drop_priv: false,
alter_priv: false,
index_priv: false,
create_tmp_table_priv: false,
lock_tables_priv: false,
references_priv: false,
};
let diff = DatabasePrivilegeRowDiff::from_rows(&row1, &row2);
assert_eq!(
diff,
DatabasePrivilegeRowDiff {
db: "db".into(),
user: "user".into(),
select_priv: None,
insert_priv: Some(DatabasePrivilegeChange::NoToYes),
update_priv: Some(DatabasePrivilegeChange::YesToNo),
delete_priv: None,
..Default::default()
},
);
}
#[test]
fn test_database_privilege_row_diff_is_empty() {
let empty_diff = DatabasePrivilegeRowDiff {
db: "db".into(),
user: "user".into(),
..Default::default()
};
assert!(empty_diff.is_empty());
let non_empty_diff = DatabasePrivilegeRowDiff {
db: "db".into(),
user: "user".into(),
select_priv: Some(DatabasePrivilegeChange::YesToNo),
..Default::default()
};
assert!(!non_empty_diff.is_empty());
}
// TODO: test in isolation:
// DatabasePrivilegeRowDiff::mappend
// DatabasePrivilegeRowDiff::remove_noops
// DatabasePrivilegeRowDiff::apply
//
// DatabasePrivilegesDiff::mappend
//
// reduce_privilege_diffs
#[test]
fn test_diff_privileges() {
let row_to_be_modified = DatabasePrivilegeRow {
db: "db".into(),
user: "user".into(),
select_priv: true,
insert_priv: true,
update_priv: true,
delete_priv: true,
create_priv: true,
drop_priv: true,
alter_priv: true,
index_priv: false,
create_tmp_table_priv: true,
lock_tables_priv: true,
references_priv: false,
};
let mut row_to_be_deleted = row_to_be_modified.to_owned();
"user2".clone_into(&mut row_to_be_deleted.user);
let from = vec![row_to_be_modified.to_owned(), row_to_be_deleted.to_owned()];
let mut modified_row = row_to_be_modified.to_owned();
modified_row.select_priv = false;
modified_row.insert_priv = false;
modified_row.index_priv = true;
let mut new_row = row_to_be_modified.to_owned();
"user3".clone_into(&mut new_row.user);
let to = vec![modified_row.to_owned(), new_row.to_owned()];
let diffs = diff_privileges(&from, &to);
assert_eq!(
diffs,
BTreeSet::from_iter(vec![
DatabasePrivilegesDiff::Deleted(row_to_be_deleted),
DatabasePrivilegesDiff::Modified(DatabasePrivilegeRowDiff {
db: "db".into(),
user: "user".into(),
select_priv: Some(DatabasePrivilegeChange::YesToNo),
insert_priv: Some(DatabasePrivilegeChange::YesToNo),
index_priv: Some(DatabasePrivilegeChange::NoToYes),
..Default::default()
}),
DatabasePrivilegesDiff::New(new_row),
])
);
}
}

View File

@@ -0,0 +1,423 @@
//! This module contains serialization and deserialization logic for
//! editing database privileges in a text editor.
use super::base::{
DATABASE_PRIVILEGE_FIELDS, DatabasePrivilegeRow, db_priv_field_human_readable_name,
};
use crate::core::{
common::{rev_yn, yn},
types::MySQLDatabase,
};
use anyhow::{Context, anyhow};
use itertools::Itertools;
use std::cmp::max;
/// Generates a single row of the privileges table for the editor.
#[must_use]
pub fn format_privileges_line_for_editor(
privs: &DatabasePrivilegeRow,
database_name_len: usize,
username_len: usize,
) -> String {
DATABASE_PRIVILEGE_FIELDS
.into_iter()
.map(|field| match field {
"Db" => format!("{:width$}", privs.db, width = database_name_len),
"User" => format!("{:width$}", privs.user, width = username_len),
privilege => format!(
"{:width$}",
// 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()
),
})
.join(" ")
.trim()
.to_string()
}
const EDITOR_COMMENT: &str = r"
# Welcome to the privilege editor.
# Each line defines what privileges a single user has on a single database.
# The first two columns respectively represent the database name and the user, and the remaining columns are the privileges.
# If the user should have a certain privilege, write 'Y', otherwise write 'N'.
#
# Lines starting with '#' are comments and will be ignored.
";
/// Generates the content for the privilege editor.
///
/// The unix user is used in case there are no privileges to edit,
/// so that the user can see an example line based on their username.
pub fn generate_editor_content_from_privilege_data(
privilege_data: &[DatabasePrivilegeRow],
unix_user: &str,
database_name: Option<&MySQLDatabase>,
) -> String {
let example_user = format!("{unix_user}_user");
let example_db = database_name
.unwrap_or(&format!("{unix_user}_db").into())
.to_string();
// NOTE: `.max()`` fails when the iterator is empty.
// In this case, we know that the only fields in the
// editor will be the example user and example db name.
// Hence, it's put as the fallback value, despite not really
// being a "fallback" in the normal sense.
let longest_username = max(
privilege_data
.iter()
.map(|p| p.user.len())
.max()
.unwrap_or(example_user.len()),
"User".len(),
);
let longest_database_name = max(
privilege_data
.iter()
.map(|p| p.db.len())
.max()
.unwrap_or(example_db.len()),
"Database".len(),
);
let mut header: Vec<_> = DATABASE_PRIVILEGE_FIELDS
.into_iter()
.map(db_priv_field_human_readable_name)
.collect();
// Pad the first two columns with spaces to align the privileges.
header[0] = format!("{:width$}", header[0], width = longest_database_name);
header[1] = format!("{:width$}", header[1], width = longest_username);
let example_line = format_privileges_line_for_editor(
&DatabasePrivilegeRow {
db: example_db.into(),
user: example_user.into(),
select_priv: true,
insert_priv: true,
update_priv: true,
delete_priv: true,
create_priv: false,
drop_priv: false,
alter_priv: false,
index_priv: false,
create_tmp_table_priv: false,
lock_tables_priv: false,
references_priv: false,
},
longest_database_name,
longest_username,
);
format!(
"{}\n{}\n{}",
EDITOR_COMMENT,
header.join(" "),
if privilege_data.is_empty() {
format!("# {example_line}")
} else {
privilege_data
.iter()
.map(|privs| {
format_privileges_line_for_editor(
privs,
longest_database_name,
longest_username,
)
})
.join("\n")
}
)
}
#[derive(Debug)]
enum PrivilegeRowParseResult {
PrivilegeRow(DatabasePrivilegeRow),
ParserError(anyhow::Error),
TooFewFields(usize),
TooManyFields(usize),
Header,
Comment,
Empty,
}
#[inline]
fn parse_privilege_cell_from_editor(yn: &str, name: &str) -> anyhow::Result<bool> {
let human_readable_name = db_priv_field_human_readable_name(name);
rev_yn(yn)
.ok_or_else(|| anyhow!("Expected Y or N, found {yn}"))
.context(format!("Could not parse '{human_readable_name}' privilege"))
}
#[inline]
fn editor_row_is_header(row: &str) -> bool {
row.split_ascii_whitespace()
.zip(DATABASE_PRIVILEGE_FIELDS.iter())
.map(|(field, priv_name)| (field, db_priv_field_human_readable_name(priv_name)))
.all(|(field, header_field)| field == header_field)
}
/// Parse a single row of the privileges table from the editor.
fn parse_privilege_row_from_editor(row: &str) -> PrivilegeRowParseResult {
if row.starts_with('#') || row.starts_with("//") {
return PrivilegeRowParseResult::Comment;
}
if row.trim().is_empty() {
return PrivilegeRowParseResult::Empty;
}
let parts: Vec<&str> = row.trim().split_ascii_whitespace().collect();
match parts.len() {
n if (n < DATABASE_PRIVILEGE_FIELDS.len()) => {
return PrivilegeRowParseResult::TooFewFields(n);
}
n if (n > DATABASE_PRIVILEGE_FIELDS.len()) => {
return PrivilegeRowParseResult::TooManyFields(n);
}
_ => {}
}
if editor_row_is_header(row) {
return PrivilegeRowParseResult::Header;
}
let row = DatabasePrivilegeRow {
db: (*parts.first().unwrap()).into(),
user: (*parts.get(1).unwrap()).into(),
select_priv: match parse_privilege_cell_from_editor(
parts.get(2).unwrap(),
DATABASE_PRIVILEGE_FIELDS[2],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
insert_priv: match parse_privilege_cell_from_editor(
parts.get(3).unwrap(),
DATABASE_PRIVILEGE_FIELDS[3],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
update_priv: match parse_privilege_cell_from_editor(
parts.get(4).unwrap(),
DATABASE_PRIVILEGE_FIELDS[4],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
delete_priv: match parse_privilege_cell_from_editor(
parts.get(5).unwrap(),
DATABASE_PRIVILEGE_FIELDS[5],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
create_priv: match parse_privilege_cell_from_editor(
parts.get(6).unwrap(),
DATABASE_PRIVILEGE_FIELDS[6],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
drop_priv: match parse_privilege_cell_from_editor(
parts.get(7).unwrap(),
DATABASE_PRIVILEGE_FIELDS[7],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
alter_priv: match parse_privilege_cell_from_editor(
parts.get(8).unwrap(),
DATABASE_PRIVILEGE_FIELDS[8],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
index_priv: match parse_privilege_cell_from_editor(
parts.get(9).unwrap(),
DATABASE_PRIVILEGE_FIELDS[9],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
create_tmp_table_priv: match parse_privilege_cell_from_editor(
parts.get(10).unwrap(),
DATABASE_PRIVILEGE_FIELDS[10],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
lock_tables_priv: match parse_privilege_cell_from_editor(
parts.get(11).unwrap(),
DATABASE_PRIVILEGE_FIELDS[11],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
references_priv: match parse_privilege_cell_from_editor(
parts.get(12).unwrap(),
DATABASE_PRIVILEGE_FIELDS[12],
) {
Ok(p) => p,
Err(e) => return PrivilegeRowParseResult::ParserError(e),
},
};
PrivilegeRowParseResult::PrivilegeRow(row)
}
pub fn parse_privilege_data_from_editor_content(
content: &str,
) -> anyhow::Result<Vec<DatabasePrivilegeRow>> {
content
.trim()
.lines()
.map(str::trim)
.enumerate()
.map(|(i, line)| {
let mut header: Vec<_> = DATABASE_PRIVILEGE_FIELDS
.into_iter()
.map(db_priv_field_human_readable_name)
.collect();
let splitline = line.split_ascii_whitespace().collect::<Vec<&str>>();
let dbname = splitline.first().unwrap_or(&"");
let username = splitline.get(1).unwrap_or(&"");
// Pad the first two columns with spaces to align the privileges.
header[0] = format!("{:width$}", header[0], width = dbname.len());
header[1] = format!("{:width$}", header[1], width = username.len());
let header: String = header.join(" ");
match parse_privilege_row_from_editor(line) {
PrivilegeRowParseResult::PrivilegeRow(row) => Ok(Some(row)),
PrivilegeRowParseResult::ParserError(e) => Err(anyhow!(
"Could not parse privilege row from line {i}:\n {header}\n {line}\n {e}",
)),
PrivilegeRowParseResult::TooFewFields(n) => Err(anyhow!(
"Too few fields in line {i}:\n {header}\n {line}\n Expected to find {} fields, found {n}",
DATABASE_PRIVILEGE_FIELDS.len(),
)),
PrivilegeRowParseResult::TooManyFields(n) => Err(anyhow!(
"Too many fields in line {i}:\n {header}\n {line}\n Expected to find {} fields, found {n}",
DATABASE_PRIVILEGE_FIELDS.len(),
)),
PrivilegeRowParseResult::Header => Ok(None),
PrivilegeRowParseResult::Comment => Ok(None),
PrivilegeRowParseResult::Empty => Ok(None),
}
})
.filter_map(std::result::Result::transpose)
.collect::<anyhow::Result<Vec<DatabasePrivilegeRow>>>()
}
#[cfg(test)]
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![
DatabasePrivilegeRow {
db: "db".into(),
user: "user".into(),
select_priv: true,
insert_priv: true,
update_priv: true,
delete_priv: true,
create_priv: true,
drop_priv: true,
alter_priv: true,
index_priv: true,
create_tmp_table_priv: true,
lock_tables_priv: true,
references_priv: true,
},
DatabasePrivilegeRow {
db: "db".into(),
user: "user".into(),
select_priv: false,
insert_priv: false,
update_priv: false,
delete_priv: false,
create_priv: false,
drop_priv: false,
alter_priv: false,
index_priv: false,
create_tmp_table_priv: false,
lock_tables_priv: false,
references_priv: false,
},
];
let content = generate_editor_content_from_privilege_data(&permissions, "user", None);
let parsed_permissions = parse_privilege_data_from_editor_content(&content).unwrap();
assert_eq!(permissions, parsed_permissions);
}
}

View File

@@ -1,5 +1,4 @@
pub mod request_response;
pub mod server_responses;
mod commands;
pub mod request_validation;
pub use request_response::*;
pub use server_responses::*;
pub use commands::*;

View File

@@ -0,0 +1,138 @@
mod check_authorization;
mod complete_database_name;
mod complete_user_name;
mod create_databases;
mod create_users;
mod drop_databases;
mod drop_users;
mod list_all_databases;
mod list_all_privileges;
mod list_all_users;
mod list_databases;
mod list_privileges;
mod list_users;
mod list_valid_name_prefixes;
mod lock_users;
mod modify_privileges;
mod passwd_user;
mod unlock_users;
pub use check_authorization::*;
pub use complete_database_name::*;
pub use complete_user_name::*;
pub use create_databases::*;
pub use create_users::*;
pub use drop_databases::*;
pub use drop_users::*;
pub use list_all_databases::*;
pub use list_all_privileges::*;
pub use list_all_users::*;
pub use list_databases::*;
pub use list_privileges::*;
pub use list_users::*;
pub use list_valid_name_prefixes::*;
pub use lock_users::*;
pub use modify_privileges::*;
pub use passwd_user::*;
pub use unlock_users::*;
use serde::{Deserialize, Serialize};
use tokio::net::UnixStream;
use tokio_serde::{Framed as SerdeFramed, formats::Bincode};
use tokio_util::codec::{Framed, LengthDelimitedCodec};
pub type ServerToClientMessageStream = SerdeFramed<
Framed<UnixStream, LengthDelimitedCodec>,
Request,
Response,
Bincode<Request, Response>,
>;
pub type ClientToServerMessageStream = SerdeFramed<
Framed<UnixStream, LengthDelimitedCodec>,
Response,
Request,
Bincode<Response, Request>,
>;
const MAX_REQUEST_FRAME_LENGTH: usize = 100 * 1024; // 100 KB
const MAX_RESPONSE_FRAME_LENGTH: usize = 1024 * 1024; // 1 MB
pub fn create_client_to_server_message_stream(socket: UnixStream) -> ClientToServerMessageStream {
let codec = {
let mut codec = LengthDelimitedCodec::new();
codec.set_max_frame_length(MAX_REQUEST_FRAME_LENGTH);
codec
};
let length_delimited = Framed::new(socket, codec);
tokio_serde::Framed::new(length_delimited, Bincode::default())
}
pub fn create_server_to_client_message_stream(socket: UnixStream) -> ServerToClientMessageStream {
let codec = {
let mut codec = LengthDelimitedCodec::new();
codec.set_max_frame_length(MAX_RESPONSE_FRAME_LENGTH);
codec
};
let length_delimited = Framed::new(socket, codec);
tokio_serde::Framed::new(length_delimited, Bincode::default())
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Request {
CheckAuthorization(CheckAuthorizationRequest),
ListValidNamePrefixes,
CompleteDatabaseName(CompleteDatabaseNameRequest),
CompleteUserName(CompleteUserNameRequest),
CreateDatabases(CreateDatabasesRequest),
DropDatabases(DropDatabasesRequest),
ListDatabases(ListDatabasesRequest),
ListPrivileges(ListPrivilegesRequest),
ModifyPrivileges(ModifyPrivilegesRequest),
CreateUsers(CreateUsersRequest),
DropUsers(DropUsersRequest),
PasswdUser(SetUserPasswordRequest),
ListUsers(ListUsersRequest),
LockUsers(LockUsersRequest),
UnlockUsers(UnlockUsersRequest),
// Commit,
Exit,
}
// TODO: include a generic "message" that will display a message to the user?
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Response {
CheckAuthorization(CheckAuthorizationResponse),
ListValidNamePrefixes(ListValidNamePrefixesResponse),
CompleteDatabaseName(CompleteDatabaseNameResponse),
CompleteUserName(CompleteUserNameResponse),
// Specific data for specific commands
CreateDatabases(CreateDatabasesResponse),
DropDatabases(DropDatabasesResponse),
ListDatabases(ListDatabasesResponse),
ListAllDatabases(ListAllDatabasesResponse),
ListPrivileges(ListPrivilegesResponse),
ListAllPrivileges(ListAllPrivilegesResponse),
ModifyPrivileges(ModifyPrivilegesResponse),
CreateUsers(CreateUsersResponse),
DropUsers(DropUsersResponse),
SetUserPassword(SetUserPasswordResponse),
ListUsers(ListUsersResponse),
ListAllUsers(ListAllUsersResponse),
LockUsers(LockUsersResponse),
UnlockUsers(UnlockUsersResponse),
// Generic responses
Ready,
Error(String),
}

View File

@@ -0,0 +1,69 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::json;
use thiserror::Error;
use crate::core::{protocol::request_validation::ValidationError, types::DbOrUser};
pub type CheckAuthorizationRequest = Vec<DbOrUser>;
pub type CheckAuthorizationResponse = BTreeMap<DbOrUser, Result<(), CheckAuthorizationError>>;
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[error("Validation error: {0}")]
pub struct CheckAuthorizationError(#[from] pub ValidationError);
pub fn print_check_authorization_output_status(output: &CheckAuthorizationResponse) {
for (db_or_user, result) in output {
match result {
Ok(()) => {
println!("'{}': OK", db_or_user.name());
}
Err(err) => {
eprintln!(
"'{}': {}",
db_or_user.name(),
err.to_error_message(db_or_user)
);
}
}
}
}
pub fn print_check_authorization_output_status_json(output: &CheckAuthorizationResponse) {
let value = output
.iter()
.map(|(db_or_user, result)| match result {
Ok(()) => (
db_or_user.name().to_string(),
json!({ "status": "success" }),
),
Err(err) => (
db_or_user.name().to_string(),
json!({
"status": "error",
"type": err.error_type(),
"error": err.to_error_message(db_or_user),
}),
),
})
.collect::<serde_json::Map<_, _>>();
println!(
"{}",
serde_json::to_string_pretty(&value)
.unwrap_or("Failed to serialize result to JSON".to_string())
);
}
impl CheckAuthorizationError {
#[must_use]
pub fn to_error_message(&self, db_or_user: &DbOrUser) -> String {
self.0.to_error_message(db_or_user)
}
#[must_use]
pub fn error_type(&self) -> String {
self.0.error_type()
}
}

View File

@@ -0,0 +1,5 @@
use crate::core::types::MySQLDatabase;
pub type CompleteDatabaseNameRequest = String;
pub type CompleteDatabaseNameResponse = Vec<MySQLDatabase>;

View File

@@ -0,0 +1,5 @@
use crate::core::types::MySQLUser;
pub type CompleteUserNameRequest = String;
pub type CompleteUserNameResponse = Vec<MySQLUser>;

View File

@@ -0,0 +1,89 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::json;
use thiserror::Error;
use crate::core::{
protocol::request_validation::ValidationError,
types::{DbOrUser, MySQLDatabase},
};
pub type CreateDatabasesRequest = Vec<MySQLDatabase>;
pub type CreateDatabasesResponse = BTreeMap<MySQLDatabase, Result<(), CreateDatabaseError>>;
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CreateDatabaseError {
#[error("Validation error: {0}")]
ValidationError(#[from] ValidationError),
#[error("Database already exists")]
DatabaseAlreadyExists,
#[error("MySQL error: {0}")]
MySqlError(String),
}
pub fn print_create_databases_output_status(output: &CreateDatabasesResponse) {
for (database_name, result) in output {
match result {
Ok(()) => {
println!("Database '{database_name}' created successfully.");
}
Err(err) => {
eprintln!("{}", err.to_error_message(database_name));
eprintln!("Skipping...");
}
}
println!();
}
}
pub fn print_create_databases_output_status_json(output: &CreateDatabasesResponse) {
let value = output
.iter()
.map(|(name, result)| match result {
Ok(()) => (name.to_string(), json!({ "status": "success" })),
Err(err) => (
name.to_string(),
json!({
"status": "error",
"type": err.error_type(),
"error": err.to_error_message(name),
}),
),
})
.collect::<serde_json::Map<_, _>>();
println!(
"{}",
serde_json::to_string_pretty(&value)
.unwrap_or("Failed to serialize result to JSON".to_string())
);
}
impl CreateDatabaseError {
#[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()))
}
CreateDatabaseError::DatabaseAlreadyExists => {
format!("Database {database_name} already exists.")
}
CreateDatabaseError::MySqlError(err) => {
format!("MySQL error: {err}")
}
}
}
#[must_use]
pub fn error_type(&self) -> String {
match self {
CreateDatabaseError::ValidationError(err) => err.error_type(),
CreateDatabaseError::DatabaseAlreadyExists => "database-already-exists".to_string(),
CreateDatabaseError::MySqlError(_) => "mysql-error".to_string(),
}
}
}

View File

@@ -0,0 +1,89 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::json;
use thiserror::Error;
use crate::core::{
protocol::request_validation::ValidationError,
types::{DbOrUser, MySQLUser},
};
pub type CreateUsersRequest = Vec<MySQLUser>;
pub type CreateUsersResponse = BTreeMap<MySQLUser, Result<(), CreateUserError>>;
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CreateUserError {
#[error("Validation error: {0}")]
ValidationError(#[from] ValidationError),
#[error("User already exists")]
UserAlreadyExists,
#[error("MySQL error: {0}")]
MySqlError(String),
}
pub fn print_create_users_output_status(output: &CreateUsersResponse) {
for (username, result) in output {
match result {
Ok(()) => {
println!("User '{username}' created successfully.");
}
Err(err) => {
eprintln!("{}", err.to_error_message(username));
eprintln!("Skipping...");
}
}
println!();
}
}
pub fn print_create_users_output_status_json(output: &CreateUsersResponse) {
let value = output
.iter()
.map(|(name, result)| match result {
Ok(()) => (name.to_string(), json!({ "status": "success" })),
Err(err) => (
name.to_string(),
json!({
"status": "error",
"type": err.error_type(),
"error": err.to_error_message(name),
}),
),
})
.collect::<serde_json::Map<_, _>>();
println!(
"{}",
serde_json::to_string_pretty(&value)
.unwrap_or("Failed to serialize result to JSON".to_string())
);
}
impl CreateUserError {
#[must_use]
pub fn to_error_message(&self, username: &MySQLUser) -> String {
match self {
CreateUserError::ValidationError(err) => {
err.to_error_message(&DbOrUser::User(username.clone()))
}
CreateUserError::UserAlreadyExists => {
format!("User '{username}' already exists.")
}
CreateUserError::MySqlError(err) => {
format!("MySQL error: {err}")
}
}
}
#[must_use]
pub fn error_type(&self) -> String {
match self {
CreateUserError::ValidationError(err) => err.error_type(),
CreateUserError::UserAlreadyExists => "user-already-exists".to_string(),
CreateUserError::MySqlError(_) => "mysql-error".to_string(),
}
}
}

View File

@@ -0,0 +1,92 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::json;
use thiserror::Error;
use crate::core::{
protocol::request_validation::ValidationError,
types::{DbOrUser, MySQLDatabase},
};
pub type DropDatabasesRequest = Vec<MySQLDatabase>;
pub type DropDatabasesResponse = BTreeMap<MySQLDatabase, Result<(), DropDatabaseError>>;
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DropDatabaseError {
#[error("Validation error: {0}")]
ValidationError(#[from] ValidationError),
#[error("Database does not exist")]
DatabaseDoesNotExist,
#[error("MySQL error: {0}")]
MySqlError(String),
}
pub fn print_drop_databases_output_status(output: &DropDatabasesResponse) {
for (database_name, result) in output {
match result {
Ok(()) => {
println!(
"Database '{}' dropped successfully.",
database_name.as_str()
);
}
Err(err) => {
eprintln!("{}", err.to_error_message(database_name));
eprintln!("Skipping...");
}
}
println!();
}
}
pub fn print_drop_databases_output_status_json(output: &DropDatabasesResponse) {
let value = output
.iter()
.map(|(name, result)| match result {
Ok(()) => (name.to_string(), json!({ "status": "success" })),
Err(err) => (
name.to_string(),
json!({
"status": "error",
"type": err.error_type(),
"error": err.to_error_message(name),
}),
),
})
.collect::<serde_json::Map<_, _>>();
println!(
"{}",
serde_json::to_string_pretty(&value)
.unwrap_or("Failed to serialize result to JSON".to_string())
);
}
impl DropDatabaseError {
#[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()))
}
DropDatabaseError::DatabaseDoesNotExist => {
format!("Database {database_name} does not exist.")
}
DropDatabaseError::MySqlError(err) => {
format!("MySQL error: {err}")
}
}
}
#[must_use]
pub fn error_type(&self) -> String {
match self {
DropDatabaseError::ValidationError(err) => err.error_type(),
DropDatabaseError::DatabaseDoesNotExist => "database-does-not-exist".to_string(),
DropDatabaseError::MySqlError(_) => "mysql-error".to_string(),
}
}
}

View File

@@ -0,0 +1,89 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::json;
use thiserror::Error;
use crate::core::{
protocol::request_validation::ValidationError,
types::{DbOrUser, MySQLUser},
};
pub type DropUsersRequest = Vec<MySQLUser>;
pub type DropUsersResponse = BTreeMap<MySQLUser, Result<(), DropUserError>>;
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DropUserError {
#[error("Validation error: {0}")]
ValidationError(#[from] ValidationError),
#[error("User does not exist")]
UserDoesNotExist,
#[error("MySQL error: {0}")]
MySqlError(String),
}
pub fn print_drop_users_output_status(output: &DropUsersResponse) {
for (username, result) in output {
match result {
Ok(()) => {
println!("User '{username}' dropped successfully.");
}
Err(err) => {
eprintln!("{}", err.to_error_message(username));
eprintln!("Skipping...");
}
}
println!();
}
}
pub fn print_drop_users_output_status_json(output: &DropUsersResponse) {
let value = output
.iter()
.map(|(name, result)| match result {
Ok(()) => (name.to_string(), json!({ "status": "success" })),
Err(err) => (
name.to_string(),
json!({
"status": "error",
"type": err.error_type(),
"error": err.to_error_message(name),
}),
),
})
.collect::<serde_json::Map<_, _>>();
println!(
"{}",
serde_json::to_string_pretty(&value)
.unwrap_or("Failed to serialize result to JSON".to_string())
);
}
impl DropUserError {
#[must_use]
pub fn to_error_message(&self, username: &MySQLUser) -> String {
match self {
DropUserError::ValidationError(err) => {
err.to_error_message(&DbOrUser::User(username.clone()))
}
DropUserError::UserDoesNotExist => {
format!("User '{username}' does not exist.")
}
DropUserError::MySqlError(err) => {
format!("MySQL error: {err}")
}
}
}
#[must_use]
pub fn error_type(&self) -> String {
match self {
DropUserError::ValidationError(err) => err.error_type(),
DropUserError::UserDoesNotExist => "user-does-not-exist".to_string(),
DropUserError::MySqlError(_) => "mysql-error".to_string(),
}
}
}

View File

@@ -0,0 +1,29 @@
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::server::sql::database_operations::DatabaseRow;
pub type ListAllDatabasesResponse = Result<Vec<DatabaseRow>, ListAllDatabasesError>;
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ListAllDatabasesError {
#[error("MySQL error: {0}")]
MySqlError(String),
}
impl ListAllDatabasesError {
#[must_use]
pub fn to_error_message(&self) -> String {
match self {
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

@@ -0,0 +1,29 @@
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::core::database_privileges::DatabasePrivilegeRow;
pub type ListAllPrivilegesResponse = Result<Vec<DatabasePrivilegeRow>, ListAllPrivilegesError>;
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ListAllPrivilegesError {
#[error("MySQL error: {0}")]
MySqlError(String),
}
impl ListAllPrivilegesError {
#[must_use]
pub fn to_error_message(&self) -> String {
match self {
ListAllPrivilegesError::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(),
}
}
}

View File

@@ -0,0 +1,29 @@
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::server::sql::user_operations::DatabaseUser;
pub type ListAllUsersResponse = Result<Vec<DatabaseUser>, ListAllUsersError>;
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ListAllUsersError {
#[error("MySQL error: {0}")]
MySqlError(String),
}
impl ListAllUsersError {
#[must_use]
pub fn to_error_message(&self) -> String {
match self {
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

@@ -0,0 +1,139 @@
use std::collections::BTreeMap;
use itertools::Itertools;
use prettytable::Table;
use serde::{Deserialize, Serialize};
use serde_json::json;
use thiserror::Error;
use crate::{
core::{
protocol::request_validation::ValidationError,
types::{DbOrUser, MySQLDatabase},
},
server::sql::database_operations::DatabaseRow,
};
pub type ListDatabasesRequest = Option<Vec<MySQLDatabase>>;
pub type ListDatabasesResponse = BTreeMap<MySQLDatabase, Result<DatabaseRow, ListDatabasesError>>;
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ListDatabasesError {
#[error("Validation error: {0}")]
ValidationError(#[from] ValidationError),
#[error("Database does not exist")]
DatabaseDoesNotExist,
#[error("MySQL error: {0}")]
MySqlError(String),
}
pub fn print_list_databases_output_status(
output: &ListDatabasesResponse,
display_size_as_bytes: bool,
) {
let mut final_database_list: Vec<&DatabaseRow> = Vec::new();
for (db_name, db_result) in output {
match db_result {
Ok(db_row) => final_database_list.push(db_row),
Err(err) => {
eprintln!("{}", err.to_error_message(db_name));
eprintln!("Skipping...");
}
}
}
if final_database_list.is_empty() {
println!("No databases to show.");
} else {
let mut table = Table::new();
table.add_row(row![
"Database",
"Tables",
"Users",
"Collation",
"Character Set",
if display_size_as_bytes {
"Size (Bytes)"
} else {
"Size"
}
]);
for db in final_database_list {
table.add_row(row![
db.database,
db.tables.join("\n"),
db.users.iter().map(|user| user.as_str()).join("\n"),
db.collation.as_deref().unwrap_or("N/A"),
db.character_set.as_deref().unwrap_or("N/A"),
if display_size_as_bytes {
db.size_bytes.to_string()
} else {
humansize::format_size(db.size_bytes, humansize::DECIMAL)
}
]);
}
table.printstd();
}
}
pub fn print_list_databases_output_status_json(output: &ListDatabasesResponse) {
let value = output
.iter()
.map(|(name, result)| match result {
Ok(row) => (
name.to_string(),
json!({
"status": "success",
"tables": row.tables,
"users": row.users,
"collation": row.collation,
"character_set": row.character_set,
"size_bytes": row.size_bytes,
}),
),
Err(err) => (
name.to_string(),
json!({
"status": "error",
"type": err.error_type(),
"error": err.to_error_message(name),
}),
),
})
.collect::<serde_json::Map<_, _>>();
println!(
"{}",
serde_json::to_string_pretty(&value)
.unwrap_or("Failed to serialize result to JSON".to_string())
);
}
impl ListDatabasesError {
#[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()))
}
ListDatabasesError::DatabaseDoesNotExist => {
format!("Database '{database_name}' does not exist.")
}
ListDatabasesError::MySqlError(err) => {
format!("MySQL error: {err}")
}
}
}
#[must_use]
pub fn error_type(&self) -> String {
match self {
ListDatabasesError::ValidationError(err) => err.error_type(),
ListDatabasesError::DatabaseDoesNotExist => "database-does-not-exist".to_string(),
ListDatabasesError::MySqlError(_) => "mysql-error".to_string(),
}
}
}

View File

@@ -0,0 +1,155 @@
// TODO: merge all rows into a single collection.
// they already contain which database they belong to.
// no need to index by database name.
use std::collections::BTreeMap;
use itertools::Itertools;
use prettytable::{Cell, Row, Table};
use serde::{Deserialize, Serialize};
use serde_json::json;
use thiserror::Error;
use crate::core::{
common::yn,
database_privileges::{
DATABASE_PRIVILEGE_FIELDS, DatabasePrivilegeRow, db_priv_field_human_readable_name,
db_priv_field_single_character_name,
},
protocol::request_validation::ValidationError,
types::{DbOrUser, MySQLDatabase},
};
pub type ListPrivilegesRequest = Option<Vec<MySQLDatabase>>;
pub type ListPrivilegesResponse =
BTreeMap<MySQLDatabase, Result<Vec<DatabasePrivilegeRow>, ListPrivilegesError>>;
pub fn print_list_privileges_output_status(output: &ListPrivilegesResponse, long_names: bool) {
let mut final_privs_map: BTreeMap<MySQLDatabase, Vec<DatabasePrivilegeRow>> = BTreeMap::new();
for (db_name, db_result) in output {
match db_result {
Ok(db_rows) => {
final_privs_map.insert(db_name.clone(), db_rows.clone());
}
Err(err) => {
eprintln!("{}", err.to_error_message(db_name));
eprintln!("Skipping...");
}
}
}
if final_privs_map.is_empty() {
println!("No privileges to show.");
} else {
let mut table = Table::new();
table.add_row(Row::new(
DATABASE_PRIVILEGE_FIELDS
.into_iter()
.map(|field| {
if field == "Db" || field == "User" {
db_priv_field_human_readable_name(field)
} else if long_names {
format!(
"{} ({})",
db_priv_field_human_readable_name(field),
db_priv_field_single_character_name(field),
)
} else {
db_priv_field_human_readable_name(field)
}
})
.map(|name| Cell::new(&name))
.collect(),
));
for (_database, rows) in final_privs_map {
for row in &rows {
table.add_row(row![
row.db,
row.user,
c->yn(row.select_priv),
c->yn(row.insert_priv),
c->yn(row.update_priv),
c->yn(row.delete_priv),
c->yn(row.create_priv),
c->yn(row.drop_priv),
c->yn(row.alter_priv),
c->yn(row.index_priv),
c->yn(row.create_tmp_table_priv),
c->yn(row.lock_tables_priv),
c->yn(row.references_priv),
]);
}
}
table.printstd();
}
}
pub fn print_list_privileges_output_status_json(output: &ListPrivilegesResponse) {
let value = output
.iter()
.map(|(name, result)| match result {
Ok(row) => (
name.to_string(),
json!({
"status": "success",
"value": row.iter().into_group_map_by(|priv_row| priv_row.user.clone()),
}),
),
Err(err) => (
name.to_string(),
json!({
"status": "error",
"type": err.error_type(),
"error": err.to_error_message(name),
}),
),
})
.collect::<serde_json::Map<_, _>>();
println!(
"{}",
serde_json::to_string_pretty(&value)
.unwrap_or("Failed to serialize result to JSON".to_string())
);
}
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ListPrivilegesError {
#[error("Validation error: {0}")]
ValidationError(#[from] ValidationError),
#[error("Database does not exist")]
DatabaseDoesNotExist,
#[error("MySQL error: {0}")]
MySqlError(String),
}
impl ListPrivilegesError {
#[must_use]
pub fn to_error_message(&self, database_name: &MySQLDatabase) -> String {
match self {
ListPrivilegesError::ValidationError(err) => {
err.to_error_message(&DbOrUser::Database(database_name.clone()))
}
ListPrivilegesError::DatabaseDoesNotExist => {
format!("Database '{database_name}' does not exist.")
}
ListPrivilegesError::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(),
}
}
}

View File

@@ -0,0 +1,123 @@
use std::collections::BTreeMap;
use prettytable::Table;
use serde::{Deserialize, Serialize};
use serde_json::json;
use thiserror::Error;
use crate::{
core::{
protocol::request_validation::ValidationError,
types::{DbOrUser, MySQLUser},
},
server::sql::user_operations::DatabaseUser,
};
pub type ListUsersRequest = Option<Vec<MySQLUser>>;
pub type ListUsersResponse = BTreeMap<MySQLUser, Result<DatabaseUser, ListUsersError>>;
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ListUsersError {
#[error("Validation error: {0}")]
ValidationError(#[from] ValidationError),
#[error("User does not exist")]
UserDoesNotExist,
#[error("MySQL error: {0}")]
MySqlError(String),
}
pub fn print_list_users_output_status(output: &ListUsersResponse) {
let mut final_user_list: Vec<&DatabaseUser> = Vec::new();
for (db_name, db_result) in output {
match db_result {
Ok(db_row) => final_user_list.push(db_row),
Err(err) => {
eprintln!("{}", err.to_error_message(db_name));
eprintln!("Skipping...");
}
}
}
if final_user_list.is_empty() {
println!("No users to show.");
} else {
let mut table = Table::new();
table.add_row(row![
"User",
"Password is set",
"Locked",
"Databases where user has privileges"
]);
for user in final_user_list {
table.add_row(row![
user.user,
user.has_password,
user.is_locked,
user.databases.join("\n")
]);
}
table.printstd();
}
}
pub fn print_list_users_output_status_json(output: &ListUsersResponse) {
let value = output
.iter()
.map(|(name, result)| match result {
Ok(row) => (
name.to_string(),
json!({
"status": "success",
"value": {
"user": row.user,
"has_password": row.has_password,
"is_locked": row.is_locked,
"databases": row.databases,
}
}),
),
Err(err) => (
name.to_string(),
json!({
"status": "error",
"type": err.error_type(),
"error": err.to_error_message(name),
}),
),
})
.collect::<serde_json::Map<_, _>>();
println!(
"{}",
serde_json::to_string_pretty(&value)
.unwrap_or("Failed to serialize result to JSON".to_string())
);
}
impl ListUsersError {
#[must_use]
pub fn to_error_message(&self, username: &MySQLUser) -> String {
match self {
ListUsersError::ValidationError(err) => {
err.to_error_message(&DbOrUser::User(username.clone()))
}
ListUsersError::UserDoesNotExist => {
format!("User '{username}' does not exist.")
}
ListUsersError::MySqlError(err) => {
format!("MySQL error: {err}")
}
}
}
#[must_use]
pub fn error_type(&self) -> String {
match self {
ListUsersError::ValidationError(err) => err.error_type(),
ListUsersError::UserDoesNotExist => "user-does-not-exist".to_string(),
ListUsersError::MySqlError(_) => "mysql-error".to_string(),
}
}
}

View File

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

View File

@@ -0,0 +1,96 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::json;
use thiserror::Error;
use crate::core::{
protocol::request_validation::ValidationError,
types::{DbOrUser, MySQLUser},
};
pub type LockUsersRequest = Vec<MySQLUser>;
pub type LockUsersResponse = BTreeMap<MySQLUser, Result<(), LockUserError>>;
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum LockUserError {
#[error("Validation error: {0}")]
ValidationError(#[from] ValidationError),
#[error("User does not exist")]
UserDoesNotExist,
#[error("User is already locked")]
UserIsAlreadyLocked,
#[error("MySQL error: {0}")]
MySqlError(String),
}
pub fn print_lock_users_output_status(output: &LockUsersResponse) {
for (username, result) in output {
match result {
Ok(()) => {
println!("User '{username}' locked successfully.");
}
Err(err) => {
eprintln!("{}", err.to_error_message(username));
eprintln!("Skipping...");
}
}
println!();
}
}
pub fn print_lock_users_output_status_json(output: &LockUsersResponse) {
let value = output
.iter()
.map(|(name, result)| match result {
Ok(()) => (name.to_string(), json!({ "status": "success" })),
Err(err) => (
name.to_string(),
json!({
"status": "error",
"type": err.error_type(),
"error": err.to_error_message(name),
}),
),
})
.collect::<serde_json::Map<_, _>>();
println!(
"{}",
serde_json::to_string_pretty(&value)
.unwrap_or("Failed to serialize result to JSON".to_string())
);
}
impl LockUserError {
#[must_use]
pub fn to_error_message(&self, username: &MySQLUser) -> String {
match self {
LockUserError::ValidationError(err) => {
err.to_error_message(&DbOrUser::User(username.clone()))
}
LockUserError::UserDoesNotExist => {
format!("User '{username}' does not exist.")
}
LockUserError::UserIsAlreadyLocked => {
format!("User '{username}' is already locked.")
}
LockUserError::MySqlError(err) => {
format!("MySQL error: {err}")
}
}
}
#[must_use]
pub fn error_type(&self) -> String {
match self {
LockUserError::ValidationError(err) => err.error_type(),
LockUserError::UserDoesNotExist => "user-does-not-exist".to_string(),
LockUserError::UserIsAlreadyLocked => "user-is-already-locked".to_string(),
LockUserError::MySqlError(_) => "mysql-error".to_string(),
}
}
}

View File

@@ -0,0 +1,146 @@
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::core::{
database_privileges::{DatabasePrivilegeRow, DatabasePrivilegeRowDiff, DatabasePrivilegesDiff},
protocol::request_validation::ValidationError,
types::{DbOrUser, MySQLDatabase, MySQLUser},
};
pub type ModifyPrivilegesRequest = BTreeSet<DatabasePrivilegesDiff>;
pub type ModifyPrivilegesResponse =
BTreeMap<(MySQLDatabase, MySQLUser), Result<(), ModifyDatabasePrivilegesError>>;
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ModifyDatabasePrivilegesError {
#[error("Database validation error: {0}")]
DatabaseValidationError(ValidationError),
#[error("User validation error: {0}")]
UserValidationError(ValidationError),
#[error("Database does not exist")]
DatabaseDoesNotExist,
#[error("User does not exist")]
UserDoesNotExist,
#[error("Diff does not apply: {0}")]
DiffDoesNotApply(DiffDoesNotApplyError),
#[error("MySQL error: {0}")]
MySqlError(String),
}
#[allow(clippy::enum_variant_names)]
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DiffDoesNotApplyError {
#[error("Privileges row already exists for database '{0}' and user '{1}'")]
RowAlreadyExists(MySQLDatabase, MySQLUser),
#[error("Privileges row does not exist for database '{0}' and user '{1}'")]
RowDoesNotExist(MySQLDatabase, MySQLUser),
#[error("Privilege change '{0:?}' does not apply to row '{1:?}'")]
RowPrivilegeChangeDoesNotApply(DatabasePrivilegeRowDiff, DatabasePrivilegeRow),
}
pub fn print_modify_database_privileges_output_status(output: &ModifyPrivilegesResponse) {
for ((database_name, username), result) in output {
match result {
Ok(()) => {
println!(
"Privileges for user '{username}' on database '{database_name}' modified successfully."
);
}
Err(err) => {
eprintln!("{}", err.to_error_message(database_name, username));
eprintln!("Skipping...");
}
}
println!();
}
}
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()))
}
ModifyDatabasePrivilegesError::UserValidationError(err) => {
err.to_error_message(&DbOrUser::User(username.clone()))
}
ModifyDatabasePrivilegesError::DatabaseDoesNotExist => {
format!("Database '{database_name}' does not exist.")
}
ModifyDatabasePrivilegesError::UserDoesNotExist => {
format!("User '{username}' does not exist.")
}
ModifyDatabasePrivilegesError::DiffDoesNotApply(diff) => {
format!(
"Could not apply privilege change:\n{}",
diff.to_error_message()
)
}
ModifyDatabasePrivilegesError::MySqlError(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",
ModifyDatabasePrivilegesError::DatabaseDoesNotExist => {
"database-does-not-exist".to_string()
}
ModifyDatabasePrivilegesError::UserDoesNotExist => "user-does-not-exist".to_string(),
ModifyDatabasePrivilegesError::DiffDoesNotApply(err) => {
format!("diff-does-not-apply/{}", err.error_type())
}
ModifyDatabasePrivilegesError::MySqlError(_) => "mysql-error".to_string(),
}
}
}
impl DiffDoesNotApplyError {
#[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."
)
}
DiffDoesNotApplyError::RowDoesNotExist(database_name, username) => {
format!(
"Privileges for user '{username}' on database '{database_name}' do not exist."
)
}
DiffDoesNotApplyError::RowPrivilegeChangeDoesNotApply(diff, row) => {
format!("Could not apply privilege change {diff:?} to row {row:?}")
}
}
}
#[must_use]
pub fn error_type(&self) -> String {
match self {
DiffDoesNotApplyError::RowAlreadyExists(_, _) => "row-already-exists".to_string(),
DiffDoesNotApplyError::RowDoesNotExist(_, _) => "row-does-not-exist".to_string(),
DiffDoesNotApplyError::RowPrivilegeChangeDoesNotApply(_, _) => {
"row-privilege-change-does-not-apply".to_string()
}
}
}
}

View File

@@ -0,0 +1,74 @@
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::core::{
protocol::request_validation::ValidationError,
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 SetUserPasswordResponse = Result<(), SetPasswordError>;
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SetPasswordError {
#[error("Validation error: {0}")]
ValidationError(#[from] ValidationError),
#[error("User does not exist")]
UserDoesNotExist,
#[error("Cannot clear password with an expiry date set")]
ClearPasswordWithExpiry,
#[error("MySQL error: {0}")]
MySqlError(String),
}
pub fn print_set_password_output_status(output: &SetUserPasswordResponse, username: &MySQLUser) {
match output {
Ok(()) => {
println!("Password for user '{username}' set successfully.");
}
Err(err) => {
eprintln!("{}", err.to_error_message(username));
eprintln!("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()))
}
SetPasswordError::UserDoesNotExist => {
format!("User '{username}' does not exist.")
}
SetPasswordError::ClearPasswordWithExpiry => {
format!("Cannot clear password for user '{username}' when an expiry date is set.")
}
SetPasswordError::MySqlError(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

@@ -0,0 +1,96 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::json;
use thiserror::Error;
use crate::core::{
protocol::request_validation::ValidationError,
types::{DbOrUser, MySQLUser},
};
pub type UnlockUsersRequest = Vec<MySQLUser>;
pub type UnlockUsersResponse = BTreeMap<MySQLUser, Result<(), UnlockUserError>>;
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum UnlockUserError {
#[error("Validation error: {0}")]
ValidationError(#[from] ValidationError),
#[error("User does not exist")]
UserDoesNotExist,
#[error("User is already unlocked")]
UserIsAlreadyUnlocked,
#[error("MySQL error: {0}")]
MySqlError(String),
}
pub fn print_unlock_users_output_status(output: &UnlockUsersResponse) {
for (username, result) in output {
match result {
Ok(()) => {
println!("User '{username}' unlocked successfully.");
}
Err(err) => {
eprintln!("{}", err.to_error_message(username));
eprintln!("Skipping...");
}
}
println!();
}
}
pub fn print_unlock_users_output_status_json(output: &UnlockUsersResponse) {
let value = output
.iter()
.map(|(name, result)| match result {
Ok(()) => (name.to_string(), json!({ "status": "success" })),
Err(err) => (
name.to_string(),
json!({
"status": "error",
"type": err.error_type(),
"error": err.to_error_message(name),
}),
),
})
.collect::<serde_json::Map<_, _>>();
println!(
"{}",
serde_json::to_string_pretty(&value)
.unwrap_or("Failed to serialize result to JSON".to_string())
);
}
impl UnlockUserError {
#[must_use]
pub fn to_error_message(&self, username: &MySQLUser) -> String {
match self {
UnlockUserError::ValidationError(err) => {
err.to_error_message(&DbOrUser::User(username.clone()))
}
UnlockUserError::UserDoesNotExist => {
format!("User '{username}' does not exist.")
}
UnlockUserError::UserIsAlreadyUnlocked => {
format!("User '{username}' is already unlocked.")
}
UnlockUserError::MySqlError(err) => {
format!("MySQL error: {err}")
}
}
}
#[must_use]
pub fn error_type(&self) -> String {
match self {
UnlockUserError::ValidationError(err) => err.error_type(),
UnlockUserError::UserDoesNotExist => "user-does-not-exist".to_string(),
UnlockUserError::UserIsAlreadyUnlocked => "user-is-already-unlocked".to_string(),
UnlockUserError::MySqlError(_) => "mysql-error".to_string(),
}
}
}

View File

@@ -1,174 +0,0 @@
use std::{
collections::BTreeSet,
fmt::{Display, Formatter},
ops::{Deref, DerefMut},
str::FromStr,
};
use serde::{Deserialize, Serialize};
use tokio::net::UnixStream;
use tokio_serde::{Framed as SerdeFramed, formats::Bincode};
use tokio_util::codec::{Framed, LengthDelimitedCodec};
use crate::core::{database_privileges::DatabasePrivilegesDiff, protocol::*};
pub type ServerToClientMessageStream = SerdeFramed<
Framed<UnixStream, LengthDelimitedCodec>,
Request,
Response,
Bincode<Request, Response>,
>;
pub type ClientToServerMessageStream = SerdeFramed<
Framed<UnixStream, LengthDelimitedCodec>,
Response,
Request,
Bincode<Response, Request>,
>;
pub fn create_server_to_client_message_stream(socket: UnixStream) -> ServerToClientMessageStream {
let length_delimited = Framed::new(socket, LengthDelimitedCodec::new());
tokio_serde::Framed::new(length_delimited, Bincode::default())
}
pub fn create_client_to_server_message_stream(socket: UnixStream) -> ClientToServerMessageStream {
let length_delimited = Framed::new(socket, LengthDelimitedCodec::new());
tokio_serde::Framed::new(length_delimited, Bincode::default())
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct MySQLUser(String);
impl FromStr for MySQLUser {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(MySQLUser(s.to_string()))
}
}
impl Deref for MySQLUser {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for MySQLUser {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl Display for MySQLUser {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<&str> for MySQLUser {
fn from(s: &str) -> Self {
MySQLUser(s.to_string())
}
}
impl From<String> for MySQLUser {
fn from(s: String) -> Self {
MySQLUser(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct MySQLDatabase(String);
impl FromStr for MySQLDatabase {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(MySQLDatabase(s.to_string()))
}
}
impl Deref for MySQLDatabase {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for MySQLDatabase {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl Display for MySQLDatabase {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<&str> for MySQLDatabase {
fn from(s: &str) -> Self {
MySQLDatabase(s.to_string())
}
}
impl From<String> for MySQLDatabase {
fn from(s: String) -> Self {
MySQLDatabase(s)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Request {
Ping,
CreateDatabases(Vec<MySQLDatabase>),
DropDatabases(Vec<MySQLDatabase>),
ListDatabases(Option<Vec<MySQLDatabase>>),
ListPrivileges(Option<Vec<MySQLDatabase>>),
ModifyPrivileges(BTreeSet<DatabasePrivilegesDiff>),
CreateUsers(Vec<MySQLUser>),
DropUsers(Vec<MySQLUser>),
PasswdUser(MySQLUser, String),
ListUsers(Option<Vec<MySQLUser>>),
LockUsers(Vec<MySQLUser>),
UnlockUsers(Vec<MySQLUser>),
// Commit,
Exit,
}
// TODO: include a generic "message" that will display a message to the user?
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Response {
Pong,
// Specific data for specific commands
CreateDatabases(CreateDatabasesOutput),
DropDatabases(DropDatabasesOutput),
ListDatabases(ListDatabasesOutput),
ListAllDatabases(ListAllDatabasesOutput),
ListPrivileges(GetDatabasesPrivilegeData),
ListAllPrivileges(GetAllDatabasesPrivilegeData),
ModifyPrivileges(ModifyDatabasePrivilegesOutput),
CreateUsers(CreateUsersOutput),
DropUsers(DropUsersOutput),
PasswdUser(SetPasswordOutput),
ListUsers(ListUsersOutput),
ListAllUsers(ListAllUsersOutput),
LockUsers(LockUsersOutput),
UnlockUsers(UnlockUsersOutput),
// Generic responses
Ready,
Error(String),
}

View File

@@ -0,0 +1,290 @@
use std::collections::HashSet;
use indoc::indoc;
use nix::{libc::gid_t, unistd::Group};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::core::{common::UnixUser, types::DbOrUser};
#[derive(Error, Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
pub enum NameValidationError {
#[error("Name cannot be empty.")]
EmptyString,
#[error(
"Name contains invalid characters. Only A-Z, a-z, 0-9, _ (underscore) and - (dash) are permitted."
)]
InvalidCharacters,
#[error("Name is too long. Maximum length is 64 characters.")]
TooLong,
}
impl NameValidationError {
#[must_use]
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())
}
NameValidationError::TooLong => format!(
"{} is too long, maximum length is 64 characters.",
db_or_user.capitalized_noun()
),
NameValidationError::InvalidCharacters => format!(
indoc! {r"
Invalid characters in {} name: '{}', only A-Z, a-z, 0-9, _ (underscore) and - (dash) are permitted.
"},
db_or_user.lowercased_noun(),
db_or_user.name(),
),
}
}
#[must_use]
pub fn error_type(&self) -> &'static str {
match self {
NameValidationError::EmptyString => "empty-string",
NameValidationError::InvalidCharacters => "invalid-characters",
NameValidationError::TooLong => "too-long",
}
}
}
#[derive(Error, Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
pub enum AuthorizationError {
#[error("Illegal prefix, user is not authorized to manage this resource")]
IllegalPrefix,
// TODO: I don't think this should ever happen?
#[error("Name cannot be empty")]
StringEmpty,
#[error("Group was found in denylist")]
DenylistError,
}
impl AuthorizationError {
#[must_use]
pub fn to_error_message(self, db_or_user: &DbOrUser) -> String {
match self {
AuthorizationError::IllegalPrefix => format!(
"Illegal {} name prefix: you are not allowed to manage databases or users prefixed with '{}'",
db_or_user.lowercased_noun(),
db_or_user.prefix(),
)
.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())
}
}
}
#[must_use]
pub fn error_type(&self) -> &'static str {
match self {
AuthorizationError::IllegalPrefix => "illegal-prefix",
AuthorizationError::StringEmpty => "string-empty",
AuthorizationError::DenylistError => "denylist-error",
}
}
}
#[derive(Error, Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub enum ValidationError {
#[error("Name validation error: {0}")]
NameValidationError(NameValidationError),
#[error("Authorization error: {0}")]
AuthorizationError(AuthorizationError),
// AuthorizationHandlerError(String),
}
impl ValidationError {
#[must_use]
pub fn to_error_message(&self, db_or_user: &DbOrUser) -> String {
match self {
ValidationError::NameValidationError(err) => err.to_error_message(db_or_user),
ValidationError::AuthorizationError(err) => err.to_error_message(db_or_user),
// AuthorizationError::AuthorizationHandlerError(msg) => {
// format!(
// "Authorization handler error for '{}': {}",
// db_or_user.name(),
// msg
// )
// }
}
}
#[must_use]
pub fn error_type(&self) -> String {
match self {
ValidationError::NameValidationError(err) => {
format!("name-validation-error/{}", err.error_type())
}
ValidationError::AuthorizationError(err) => {
format!("authorization-error/{}", err.error_type())
} // AuthorizationError::AuthorizationHandlerError(_) => {
// "authorization-handler-error".to_string()
// }
}
}
}
pub type GroupDenylist = HashSet<gid_t>;
const MAX_NAME_LENGTH: usize = 64;
pub fn validate_name(name: &str) -> Result<(), NameValidationError> {
if name.is_empty() {
Err(NameValidationError::EmptyString)
} else if name.len() > MAX_NAME_LENGTH {
Err(NameValidationError::TooLong)
} else if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
Err(NameValidationError::InvalidCharacters)
} else {
Ok(())
}
}
pub fn validate_authorization_by_unix_user(
name: &str,
user: &UnixUser,
) -> Result<(), AuthorizationError> {
let prefixes = std::iter::once(user.username.clone())
.chain(user.groups.iter().cloned())
.collect::<Vec<String>>();
validate_authorization_by_prefixes(name, &prefixes)
}
/// Core logic for validating the ownership of a database name.
/// This function checks if the given name matches any of the given prefixes.
/// These prefixes will in most cases be the user's unix username and any
/// unix groups the user is a member of.
pub fn validate_authorization_by_prefixes(
name: &str,
prefixes: &[String],
) -> Result<(), AuthorizationError> {
if name.is_empty() {
return Err(AuthorizationError::StringEmpty);
}
if prefixes
.iter()
.filter(|p| name.starts_with(&((*p).clone() + "_")))
.collect::<Vec<_>>()
.is_empty()
{
return Err(AuthorizationError::IllegalPrefix);
}
Ok(())
}
pub fn validate_authorization_by_group_denylist(
name: &str,
user: &UnixUser,
group_denylist: &GroupDenylist,
) -> Result<(), AuthorizationError> {
// NOTE: if the username matches, we allow it regardless of denylist
if user.username == name {
return Ok(());
}
let user_group = Group::from_name(name)
.ok()
.flatten()
.map(|g| g.gid.as_raw());
if let Some(gid) = user_group
&& group_denylist.contains(&gid)
{
Err(AuthorizationError::DenylistError)
} else {
Ok(())
}
}
pub fn validate_db_or_user_request(
db_or_user: &DbOrUser,
unix_user: &UnixUser,
group_denylist: &GroupDenylist,
) -> Result<(), ValidationError> {
validate_name(db_or_user.name()).map_err(ValidationError::NameValidationError)?;
validate_authorization_by_unix_user(db_or_user.name(), unix_user)
.map_err(ValidationError::AuthorizationError)?;
validate_authorization_by_group_denylist(db_or_user.name(), unix_user, group_denylist)
.map_err(ValidationError::AuthorizationError)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_name() {
assert_eq!(validate_name(""), Err(NameValidationError::EmptyString));
assert_eq!(validate_name("abcdefghijklmnopqrstuvwxyz"), Ok(()));
assert_eq!(validate_name("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), Ok(()));
assert_eq!(validate_name("0123456789_-"), Ok(()));
for c in "\n\t\r !@#$%^&*()+=[]{}|;:,.<>?/".chars() {
assert_eq!(
validate_name(&c.to_string()),
Err(NameValidationError::InvalidCharacters)
);
}
assert_eq!(validate_name(&"a".repeat(MAX_NAME_LENGTH)), Ok(()));
assert_eq!(
validate_name(&"a".repeat(MAX_NAME_LENGTH + 1)),
Err(NameValidationError::TooLong)
);
}
#[test]
fn test_validate_authorization_by_prefixes() {
let prefixes = vec!["user".to_string(), "group".to_string()];
assert_eq!(
validate_authorization_by_prefixes("", &prefixes),
Err(AuthorizationError::StringEmpty)
);
assert_eq!(
validate_authorization_by_prefixes("user_testdb", &prefixes),
Ok(())
);
assert_eq!(
validate_authorization_by_prefixes("group_testdb", &prefixes),
Ok(())
);
assert_eq!(
validate_authorization_by_prefixes("group_test_db", &prefixes),
Ok(())
);
assert_eq!(
validate_authorization_by_prefixes("group_test-db", &prefixes),
Ok(())
);
assert_eq!(
validate_authorization_by_prefixes("nonexistent_testdb", &prefixes),
Err(AuthorizationError::IllegalPrefix)
);
}
}

View File

@@ -1,774 +0,0 @@
use std::collections::BTreeMap;
use indoc::indoc;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::{
core::{common::UnixUser, database_privileges::DatabasePrivilegeRowDiff},
server::sql::{
database_operations::DatabaseRow, database_privilege_operations::DatabasePrivilegeRow,
user_operations::DatabaseUser,
},
};
use super::{MySQLDatabase, MySQLUser};
/// This enum is used to differentiate between database and user operations.
/// Their output are very similar, but there are slight differences in the words used.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum DbOrUser {
Database,
User,
}
impl DbOrUser {
pub fn lowercased(&self) -> &'static str {
match self {
DbOrUser::Database => "database",
DbOrUser::User => "user",
}
}
pub fn capitalized(&self) -> &'static str {
match self {
DbOrUser::Database => "Database",
DbOrUser::User => "User",
}
}
}
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
pub enum NameValidationError {
EmptyString,
InvalidCharacters,
TooLong,
}
impl NameValidationError {
pub fn to_error_message(self, name: &str, db_or_user: DbOrUser) -> String {
match self {
NameValidationError::EmptyString => {
format!("{} name cannot be empty.", db_or_user.capitalized()).to_owned()
}
NameValidationError::TooLong => format!(
"{} is too long. Maximum length is 64 characters.",
db_or_user.capitalized()
)
.to_owned(),
NameValidationError::InvalidCharacters => format!(
indoc! {r#"
Invalid characters in {} name: '{}'
Only A-Z, a-z, 0-9, _ (underscore) and - (dash) are permitted.
"#},
db_or_user.lowercased(),
name
)
.to_owned(),
}
}
}
impl OwnerValidationError {
pub fn to_error_message(self, name: &str, db_or_user: DbOrUser) -> String {
let user = UnixUser::from_enviroment();
let UnixUser {
username,
mut groups,
} = user.unwrap_or(UnixUser {
username: "???".to_string(),
groups: vec![],
});
groups.sort();
match self {
OwnerValidationError::NoMatch => format!(
indoc! {r#"
Invalid {} name prefix: '{}' does not match your username or any of your groups.
Are you sure you are allowed to create {} names with this prefix?
The format should be: <prefix>_<{} name>
Allowed prefixes:
- {}
{}
"#},
db_or_user.lowercased(),
name,
db_or_user.lowercased(),
db_or_user.lowercased(),
username,
groups
.into_iter()
.filter(|g| g != &username)
.map(|g| format!(" - {}", g))
.join("\n"),
)
.to_owned(),
OwnerValidationError::StringEmpty => format!(
"'{}' is not a valid {} name.",
name,
db_or_user.lowercased()
)
.to_string(),
}
}
}
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
pub enum OwnerValidationError {
// The name is valid, but none of the given prefixes matched the name
NoMatch,
// The name is empty, which is invalid
StringEmpty,
}
pub type CreateDatabasesOutput = BTreeMap<MySQLDatabase, Result<(), CreateDatabaseError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CreateDatabaseError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
DatabaseAlreadyExists,
MySqlError(String),
}
pub fn print_create_databases_output_status(output: &CreateDatabasesOutput) {
for (database_name, result) in output {
match result {
Ok(()) => {
println!("Database '{}' created successfully.", database_name);
}
Err(err) => {
println!("{}", err.to_error_message(database_name));
println!("Skipping...");
}
}
println!();
}
}
pub fn print_create_databases_output_status_json(output: &CreateDatabasesOutput) {
let value = output
.iter()
.map(|(name, result)| match result {
Ok(()) => (name.to_string(), json!({ "status": "success" })),
Err(err) => (
name.to_string(),
json!({
"status": "error",
"error": err.to_error_message(name),
}),
),
})
.collect::<serde_json::Map<_, _>>();
println!(
"{}",
serde_json::to_string_pretty(&value)
.unwrap_or("Failed to serialize result to JSON".to_string())
);
}
impl CreateDatabaseError {
pub fn to_error_message(&self, database_name: &MySQLDatabase) -> String {
match self {
CreateDatabaseError::SanitizationError(err) => {
err.to_error_message(database_name, DbOrUser::Database)
}
CreateDatabaseError::OwnershipError(err) => {
err.to_error_message(database_name, DbOrUser::Database)
}
CreateDatabaseError::DatabaseAlreadyExists => {
format!("Database {} already exists.", database_name)
}
CreateDatabaseError::MySqlError(err) => {
format!("MySQL error: {}", err)
}
}
}
}
pub type DropDatabasesOutput = BTreeMap<MySQLDatabase, Result<(), DropDatabaseError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DropDatabaseError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
DatabaseDoesNotExist,
MySqlError(String),
}
pub fn print_drop_databases_output_status(output: &DropDatabasesOutput) {
for (database_name, result) in output {
match result {
Ok(()) => {
println!(
"Database '{}' dropped successfully.",
database_name.as_str()
);
}
Err(err) => {
println!("{}", err.to_error_message(database_name));
println!("Skipping...");
}
}
println!();
}
}
pub fn print_drop_databases_output_status_json(output: &DropDatabasesOutput) {
let value = output
.iter()
.map(|(name, result)| match result {
Ok(()) => (name.to_string(), json!({ "status": "success" })),
Err(err) => (
name.to_string(),
json!({
"status": "error",
"error": err.to_error_message(name),
}),
),
})
.collect::<serde_json::Map<_, _>>();
println!(
"{}",
serde_json::to_string_pretty(&value)
.unwrap_or("Failed to serialize result to JSON".to_string())
);
}
impl DropDatabaseError {
pub fn to_error_message(&self, database_name: &MySQLDatabase) -> String {
match self {
DropDatabaseError::SanitizationError(err) => {
err.to_error_message(database_name, DbOrUser::Database)
}
DropDatabaseError::OwnershipError(err) => {
err.to_error_message(database_name, DbOrUser::Database)
}
DropDatabaseError::DatabaseDoesNotExist => {
format!("Database {} does not exist.", database_name)
}
DropDatabaseError::MySqlError(err) => {
format!("MySQL error: {}", err)
}
}
}
}
pub type ListDatabasesOutput = BTreeMap<MySQLDatabase, Result<DatabaseRow, ListDatabasesError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ListDatabasesError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
DatabaseDoesNotExist,
MySqlError(String),
}
impl ListDatabasesError {
pub fn to_error_message(&self, database_name: &MySQLDatabase) -> String {
match self {
ListDatabasesError::SanitizationError(err) => {
err.to_error_message(database_name, DbOrUser::Database)
}
ListDatabasesError::OwnershipError(err) => {
err.to_error_message(database_name, DbOrUser::Database)
}
ListDatabasesError::DatabaseDoesNotExist => {
format!("Database '{}' does not exist.", database_name)
}
ListDatabasesError::MySqlError(err) => {
format!("MySQL error: {}", err)
}
}
}
}
pub type ListAllDatabasesOutput = Result<Vec<DatabaseRow>, ListAllDatabasesError>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ListAllDatabasesError {
MySqlError(String),
}
impl ListAllDatabasesError {
pub fn to_error_message(&self) -> String {
match self {
ListAllDatabasesError::MySqlError(err) => format!("MySQL error: {}", err),
}
}
}
// TODO: merge all rows into a single collection.
// they already contain which database they belong to.
// no need to index by database name.
pub type GetDatabasesPrivilegeData =
BTreeMap<MySQLDatabase, Result<Vec<DatabasePrivilegeRow>, GetDatabasesPrivilegeDataError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum GetDatabasesPrivilegeDataError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
DatabaseDoesNotExist,
MySqlError(String),
}
impl GetDatabasesPrivilegeDataError {
pub fn to_error_message(&self, database_name: &MySQLDatabase) -> String {
match self {
GetDatabasesPrivilegeDataError::SanitizationError(err) => {
err.to_error_message(database_name, DbOrUser::Database)
}
GetDatabasesPrivilegeDataError::OwnershipError(err) => {
err.to_error_message(database_name, DbOrUser::Database)
}
GetDatabasesPrivilegeDataError::DatabaseDoesNotExist => {
format!("Database '{}' does not exist.", database_name)
}
GetDatabasesPrivilegeDataError::MySqlError(err) => {
format!("MySQL error: {}", err)
}
}
}
}
pub type GetAllDatabasesPrivilegeData =
Result<Vec<DatabasePrivilegeRow>, GetAllDatabasesPrivilegeDataError>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum GetAllDatabasesPrivilegeDataError {
MySqlError(String),
}
impl GetAllDatabasesPrivilegeDataError {
pub fn to_error_message(&self) -> String {
match self {
GetAllDatabasesPrivilegeDataError::MySqlError(err) => format!("MySQL error: {}", err),
}
}
}
pub type ModifyDatabasePrivilegesOutput =
BTreeMap<(MySQLDatabase, MySQLUser), Result<(), ModifyDatabasePrivilegesError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ModifyDatabasePrivilegesError {
DatabaseSanitizationError(NameValidationError),
DatabaseOwnershipError(OwnerValidationError),
UserSanitizationError(NameValidationError),
UserOwnershipError(OwnerValidationError),
DatabaseDoesNotExist,
DiffDoesNotApply(DiffDoesNotApplyError),
MySqlError(String),
}
#[allow(clippy::enum_variant_names)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DiffDoesNotApplyError {
RowAlreadyExists(MySQLDatabase, MySQLUser),
RowDoesNotExist(MySQLDatabase, MySQLUser),
RowPrivilegeChangeDoesNotApply(DatabasePrivilegeRowDiff, DatabasePrivilegeRow),
}
pub fn print_modify_database_privileges_output_status(output: &ModifyDatabasePrivilegesOutput) {
for ((database_name, username), result) in output {
match result {
Ok(()) => {
println!(
"Privileges for user '{}' on database '{}' modified successfully.",
username, database_name
);
}
Err(err) => {
println!("{}", err.to_error_message(database_name, username));
println!("Skipping...");
}
}
println!();
}
}
impl ModifyDatabasePrivilegesError {
pub fn to_error_message(&self, database_name: &MySQLDatabase, username: &MySQLUser) -> String {
match self {
ModifyDatabasePrivilegesError::DatabaseSanitizationError(err) => {
err.to_error_message(database_name, DbOrUser::Database)
}
ModifyDatabasePrivilegesError::DatabaseOwnershipError(err) => {
err.to_error_message(database_name, DbOrUser::Database)
}
ModifyDatabasePrivilegesError::UserSanitizationError(err) => {
err.to_error_message(username, DbOrUser::User)
}
ModifyDatabasePrivilegesError::UserOwnershipError(err) => {
err.to_error_message(username, DbOrUser::User)
}
ModifyDatabasePrivilegesError::DatabaseDoesNotExist => {
format!("Database '{}' does not exist.", database_name)
}
ModifyDatabasePrivilegesError::DiffDoesNotApply(diff) => {
format!(
"Could not apply privilege change:\n{}",
diff.to_error_message()
)
}
ModifyDatabasePrivilegesError::MySqlError(err) => {
format!("MySQL error: {}", err)
}
}
}
}
impl DiffDoesNotApplyError {
pub fn to_error_message(&self) -> String {
match self {
DiffDoesNotApplyError::RowAlreadyExists(database_name, username) => {
format!(
"Privileges for user '{}' on database '{}' already exist.",
username, database_name
)
}
DiffDoesNotApplyError::RowDoesNotExist(database_name, username) => {
format!(
"Privileges for user '{}' on database '{}' do not exist.",
username, database_name
)
}
DiffDoesNotApplyError::RowPrivilegeChangeDoesNotApply(diff, row) => {
format!(
"Could not apply privilege change {:?} to row {:?}",
diff, row
)
}
}
}
}
pub type CreateUsersOutput = BTreeMap<MySQLUser, Result<(), CreateUserError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CreateUserError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
UserAlreadyExists,
MySqlError(String),
}
pub fn print_create_users_output_status(output: &CreateUsersOutput) {
for (username, result) in output {
match result {
Ok(()) => {
println!("User '{}' created successfully.", username);
}
Err(err) => {
println!("{}", err.to_error_message(username));
println!("Skipping...");
}
}
println!();
}
}
pub fn print_create_users_output_status_json(output: &CreateUsersOutput) {
let value = output
.iter()
.map(|(name, result)| match result {
Ok(()) => (name.to_string(), json!({ "status": "success" })),
Err(err) => (
name.to_string(),
json!({
"status": "error",
"error": err.to_error_message(name),
}),
),
})
.collect::<serde_json::Map<_, _>>();
println!(
"{}",
serde_json::to_string_pretty(&value)
.unwrap_or("Failed to serialize result to JSON".to_string())
);
}
impl CreateUserError {
pub fn to_error_message(&self, username: &MySQLUser) -> String {
match self {
CreateUserError::SanitizationError(err) => {
err.to_error_message(username, DbOrUser::User)
}
CreateUserError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
CreateUserError::UserAlreadyExists => {
format!("User '{}' already exists.", username)
}
CreateUserError::MySqlError(err) => {
format!("MySQL error: {}", err)
}
}
}
}
pub type DropUsersOutput = BTreeMap<MySQLUser, Result<(), DropUserError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DropUserError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
UserDoesNotExist,
MySqlError(String),
}
pub fn print_drop_users_output_status(output: &DropUsersOutput) {
for (username, result) in output {
match result {
Ok(()) => {
println!("User '{}' dropped successfully.", username);
}
Err(err) => {
println!("{}", err.to_error_message(username));
println!("Skipping...");
}
}
println!();
}
}
pub fn print_drop_users_output_status_json(output: &DropUsersOutput) {
let value = output
.iter()
.map(|(name, result)| match result {
Ok(()) => (name.to_string(), json!({ "status": "success" })),
Err(err) => (
name.to_string(),
json!({
"status": "error",
"error": err.to_error_message(name),
}),
),
})
.collect::<serde_json::Map<_, _>>();
println!(
"{}",
serde_json::to_string_pretty(&value)
.unwrap_or("Failed to serialize result to JSON".to_string())
);
}
impl DropUserError {
pub fn to_error_message(&self, username: &MySQLUser) -> String {
match self {
DropUserError::SanitizationError(err) => err.to_error_message(username, DbOrUser::User),
DropUserError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
DropUserError::UserDoesNotExist => {
format!("User '{}' does not exist.", username)
}
DropUserError::MySqlError(err) => {
format!("MySQL error: {}", err)
}
}
}
}
pub type SetPasswordOutput = Result<(), SetPasswordError>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SetPasswordError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
UserDoesNotExist,
MySqlError(String),
}
pub fn print_set_password_output_status(output: &SetPasswordOutput, username: &MySQLUser) {
match output {
Ok(()) => {
println!("Password for user '{}' set successfully.", username);
}
Err(err) => {
println!("{}", err.to_error_message(username));
println!("Skipping...");
}
}
}
impl SetPasswordError {
pub fn to_error_message(&self, username: &MySQLUser) -> String {
match self {
SetPasswordError::SanitizationError(err) => {
err.to_error_message(username, DbOrUser::User)
}
SetPasswordError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
SetPasswordError::UserDoesNotExist => {
format!("User '{}' does not exist.", username)
}
SetPasswordError::MySqlError(err) => {
format!("MySQL error: {}", err)
}
}
}
}
pub type LockUsersOutput = BTreeMap<MySQLUser, Result<(), LockUserError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum LockUserError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
UserDoesNotExist,
UserIsAlreadyLocked,
MySqlError(String),
}
pub fn print_lock_users_output_status(output: &LockUsersOutput) {
for (username, result) in output {
match result {
Ok(()) => {
println!("User '{}' locked successfully.", username);
}
Err(err) => {
println!("{}", err.to_error_message(username));
println!("Skipping...");
}
}
println!();
}
}
pub fn print_lock_users_output_status_json(output: &LockUsersOutput) {
let value = output
.iter()
.map(|(name, result)| match result {
Ok(()) => (name.to_string(), json!({ "status": "success" })),
Err(err) => (
name.to_string(),
json!({
"status": "error",
"error": err.to_error_message(name),
}),
),
})
.collect::<serde_json::Map<_, _>>();
println!(
"{}",
serde_json::to_string_pretty(&value)
.unwrap_or("Failed to serialize result to JSON".to_string())
);
}
impl LockUserError {
pub fn to_error_message(&self, username: &MySQLUser) -> String {
match self {
LockUserError::SanitizationError(err) => err.to_error_message(username, DbOrUser::User),
LockUserError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
LockUserError::UserDoesNotExist => {
format!("User '{}' does not exist.", username)
}
LockUserError::UserIsAlreadyLocked => {
format!("User '{}' is already locked.", username)
}
LockUserError::MySqlError(err) => {
format!("MySQL error: {}", err)
}
}
}
}
pub type UnlockUsersOutput = BTreeMap<MySQLUser, Result<(), UnlockUserError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum UnlockUserError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
UserDoesNotExist,
UserIsAlreadyUnlocked,
MySqlError(String),
}
pub fn print_unlock_users_output_status(output: &UnlockUsersOutput) {
for (username, result) in output {
match result {
Ok(()) => {
println!("User '{}' unlocked successfully.", username);
}
Err(err) => {
println!("{}", err.to_error_message(username));
println!("Skipping...");
}
}
println!();
}
}
pub fn print_unlock_users_output_status_json(output: &UnlockUsersOutput) {
let value = output
.iter()
.map(|(name, result)| match result {
Ok(()) => (name.to_string(), json!({ "status": "success" })),
Err(err) => (
name.to_string(),
json!({
"status": "error",
"error": err.to_error_message(name),
}),
),
})
.collect::<serde_json::Map<_, _>>();
println!(
"{}",
serde_json::to_string_pretty(&value)
.unwrap_or("Failed to serialize result to JSON".to_string())
);
}
impl UnlockUserError {
pub fn to_error_message(&self, username: &MySQLUser) -> String {
match self {
UnlockUserError::SanitizationError(err) => {
err.to_error_message(username, DbOrUser::User)
}
UnlockUserError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
UnlockUserError::UserDoesNotExist => {
format!("User '{}' does not exist.", username)
}
UnlockUserError::UserIsAlreadyUnlocked => {
format!("User '{}' is already unlocked.", username)
}
UnlockUserError::MySqlError(err) => {
format!("MySQL error: {}", err)
}
}
}
}
pub type ListUsersOutput = BTreeMap<MySQLUser, Result<DatabaseUser, ListUsersError>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ListUsersError {
SanitizationError(NameValidationError),
OwnershipError(OwnerValidationError),
UserDoesNotExist,
MySqlError(String),
}
impl ListUsersError {
pub fn to_error_message(&self, username: &MySQLUser) -> String {
match self {
ListUsersError::SanitizationError(err) => {
err.to_error_message(username, DbOrUser::User)
}
ListUsersError::OwnershipError(err) => err.to_error_message(username, DbOrUser::User),
ListUsersError::UserDoesNotExist => {
format!("User '{}' does not exist.", username)
}
ListUsersError::MySqlError(err) => {
format!("MySQL error: {}", err)
}
}
}
}
pub type ListAllUsersOutput = Result<Vec<DatabaseUser>, ListAllUsersError>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ListAllUsersError {
MySqlError(String),
}
impl ListAllUsersError {
pub fn to_error_message(&self) -> String {
match self {
ListAllUsersError::MySqlError(err) => format!("MySQL error: {}", err),
}
}
}

146
src/core/types.rs Normal file
View File

@@ -0,0 +1,146 @@
use std::{
ffi::OsString,
fmt,
ops::{Deref, DerefMut},
str::FromStr,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
pub struct MySQLUser(String);
impl FromStr for MySQLUser {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(MySQLUser(s.to_string()))
}
}
impl Deref for MySQLUser {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for MySQLUser {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl fmt::Display for MySQLUser {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:<width$}", self.0, width = f.width().unwrap_or(0))
}
}
impl From<&str> for MySQLUser {
fn from(s: &str) -> Self {
MySQLUser(s.to_string())
}
}
impl From<String> for MySQLUser {
fn from(s: String) -> Self {
MySQLUser(s)
}
}
impl From<MySQLUser> for OsString {
fn from(val: MySQLUser) -> Self {
val.0.into()
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
pub struct MySQLDatabase(String);
impl FromStr for MySQLDatabase {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(MySQLDatabase(s.to_string()))
}
}
impl Deref for MySQLDatabase {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for MySQLDatabase {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl fmt::Display for MySQLDatabase {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:<width$}", self.0, width = f.width().unwrap_or(0))
}
}
impl From<&str> for MySQLDatabase {
fn from(s: &str) -> Self {
MySQLDatabase(s.to_string())
}
}
impl From<String> for MySQLDatabase {
fn from(s: String) -> Self {
MySQLDatabase(s)
}
}
impl From<MySQLDatabase> for OsString {
fn from(val: MySQLDatabase) -> Self {
val.0.into()
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum DbOrUser {
Database(MySQLDatabase),
User(MySQLUser),
}
impl DbOrUser {
#[must_use]
pub fn lowercased_noun(&self) -> &'static str {
match self {
DbOrUser::Database(_) => "database",
DbOrUser::User(_) => "user",
}
}
#[must_use]
pub fn capitalized_noun(&self) -> &'static str {
match self {
DbOrUser::Database(_) => "Database",
DbOrUser::User(_) => "User",
}
}
#[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("?"),
}
}
}

392
src/entrypoints/muscl.rs Normal file
View File

@@ -0,0 +1,392 @@
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

@@ -0,0 +1,183 @@
use std::path::PathBuf;
use anyhow::Context;
use clap::{Parser, Subcommand};
use clap_verbosity_flag::{InfoLevel, Verbosity};
use tracing_subscriber::layer::SubscriberExt;
use muscl_lib::{
core::common::{ASCII_BANNER, DEFAULT_CONFIG_PATH, KIND_REGARDS},
server::{landlock::landlock_restrict_server, supervisor::Supervisor},
};
#[derive(Parser, Debug, Clone)]
pub struct ServerArgs {
#[command(subcommand)]
pub subcmd: ServerCommand,
/// Enable systemd mode
#[cfg(target_os = "linux")]
#[arg(long)]
pub systemd: bool,
/// Disable Landlock sandboxing.
///
/// This is useful if you are planning to reload the server's configuration.
#[arg(long)]
pub disable_landlock: bool,
// 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)]
pub enum ServerCommand {
/// Start the server and listen for incoming connections on the unix socket
/// specified in the configuration file.
Listen,
/// Start the server using systemd socket activation.
SocketActivate,
}
const LOG_LEVEL_WARNING: &str = r#"
===================================================
== WARNING: LOG LEVEL IS SET TO 'TRACE'! ==
== THIS WILL CAUSE THE SERVER TO LOG SQL QUERIES ==
== THAT MAY CONTAIN SENSITIVE INFORMATION LIKE ==
== PASSWORDS AND AUTHENTICATION TOKENS. ==
== THIS IS INTENDED FOR DEBUGGING PURPOSES ONLY ==
== AND SHOULD *NEVER* BE USED IN PRODUCTION. ==
===================================================
"#;
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() {
let message = [ASCII_BANNER, "", KIND_REGARDS, ""].join("\n");
tracing::info!(message);
}
async fn handle_command(args: ServerArgs) -> anyhow::Result<()> {
let mut auto_detected_systemd_mode = false;
#[cfg(target_os = "linux")]
let systemd_mode = args.systemd || {
if let Ok(true) = sd_notify::booted() {
auto_detected_systemd_mode = true;
true
} else {
false
}
};
#[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()?);
tracing::subscriber::set_global_default(subscriber)
.context("Failed to set global default tracing subscriber")?;
trace_server_prelude();
if args.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");
}
}
} else {
let subscriber = tracing_subscriber::Registry::default()
.with(args.verbosity.tracing_level_filter())
.with(
tracing_subscriber::fmt::layer()
.with_line_number(cfg!(debug_assertions))
.with_target(cfg!(debug_assertions))
.with_thread_ids(false)
.with_thread_names(false),
);
tracing::subscriber::set_global_default(subscriber)
.context("Failed to set global default tracing subscriber")?;
trace_server_prelude();
tracing::debug!("Running in standalone mode");
}
let config_path = args
.config_path
.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH));
match args.subcmd {
ServerCommand::Listen => {
Supervisor::new(config_path, systemd_mode)
.await?
.run()
.await
}
ServerCommand::SocketActivate => {
if !args.systemd {
anyhow::bail!(concat!(
"The `--systemd` flag must be used with the `socket-activate` command.\n",
"This command currently only supports socket activation under systemd."
));
}
Supervisor::new(config_path, systemd_mode)
.await?
.run()
.await
}
}
}

6
src/lib.rs Normal file
View File

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

View File

@@ -1,260 +0,0 @@
#[macro_use]
extern crate prettytable;
use anyhow::Context;
use clap::{CommandFactory, Parser, ValueEnum};
use clap_complete::{Shell, generate};
use clap_verbosity_flag::Verbosity;
use std::path::PathBuf;
use std::os::unix::net::UnixStream as StdUnixStream;
use tokio::net::UnixStream as TokioUnixStream;
use futures::StreamExt;
use crate::{
core::{
bootstrap::bootstrap_server_connection_and_drop_privileges,
common::executable_is_suid_or_sgid,
protocol::{Response, create_client_to_server_message_stream},
},
server::command::ServerArgs,
};
#[cfg(feature = "mysql-admutils-compatibility")]
use crate::cli::mysql_admutils_compatibility::{mysql_dbadm, mysql_useradm};
mod server;
mod cli;
mod core;
#[cfg(feature = "tui")]
mod tui;
/// 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 = "mysqladm", version, about, disable_help_subcommand = true)]
struct Args {
#[command(subcommand)]
command: Command,
/// Path to the socket of the server, if it already exists.
#[arg(
short,
long,
value_name = "PATH",
global = true,
hide_short_help = true
)]
server_socket_path: Option<PathBuf>,
/// Config file to use for the server.
#[arg(
short,
long,
value_name = "PATH",
global = true,
hide_short_help = true
)]
config: Option<PathBuf>,
#[command(flatten)]
verbose: Verbosity,
/// Run in TUI mode.
#[cfg(feature = "tui")]
#[arg(short, long, alias = "tui", global = true)]
interactive: bool,
}
#[derive(Parser, Debug, Clone)]
enum Command {
#[command(flatten)]
Db(cli::database_command::DatabaseCommand),
#[command(flatten)]
User(cli::user_command::UserCommand),
#[command(flatten)]
Other(cli::other_command::OtherCommand),
#[command(hide = true)]
Server(server::command::ServerArgs),
#[command(hide = true)]
GenerateCompletions(GenerateCompletionArgs),
}
#[derive(Parser, Debug, Clone)]
struct GenerateCompletionArgs {
#[arg(long, default_value = "bash")]
shell: Shell,
#[arg(long, default_value = "mysqladm")]
command: ToplevelCommands,
}
#[cfg(feature = "mysql-admutils-compatibility")]
#[derive(ValueEnum, Debug, Clone)]
enum ToplevelCommands {
Mysqladm,
MysqlDbadm,
MysqlUseradm,
}
/// **WARNING:** This function may be run with elevated privileges.
fn main() -> anyhow::Result<()> {
#[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(());
}
if handle_generate_completions_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_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!(
!executable_is_suid_or_sgid()?,
"The executable should not be SUID or SGID when running the server manually"
);
tokio_start_server(
args.server_socket_path.to_owned(),
args.config.to_owned(),
args.verbose.to_owned(),
command.to_owned(),
)?;
Ok(Some(()))
}
_ => Ok(None),
}
}
/// **WARNING:** This function may be run with elevated privileges.
fn handle_generate_completions_command(args: &Args) -> anyhow::Result<Option<()>> {
match args.command {
Command::GenerateCompletions(ref completion_args) => {
assert!(
!executable_is_suid_or_sgid()?,
"The executable should not be SUID or SGID when generating completions"
);
let mut cmd = match completion_args.command {
ToplevelCommands::Mysqladm => Args::command(),
#[cfg(feature = "mysql-admutils-compatibility")]
ToplevelCommands::MysqlDbadm => mysql_dbadm::Args::command(),
#[cfg(feature = "mysql-admutils-compatibility")]
ToplevelCommands::MysqlUseradm => mysql_useradm::Args::command(),
};
let binary_name = cmd.get_bin_name().unwrap().to_owned();
generate(
completion_args.shell,
&mut cmd,
binary_name,
&mut std::io::stdout(),
);
Ok(Some(()))
}
_ => Ok(None),
}
}
/// Start a long-lived server using Tokio.
fn tokio_start_server(
server_socket_path: Option<PathBuf>,
config_path: Option<PathBuf>,
verbosity: Verbosity,
args: ServerArgs,
) -> anyhow::Result<()> {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("Failed to start Tokio runtime")?
.block_on(async {
server::command::handle_command(server_socket_path, config_path, verbosity, args).await
})
}
/// 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::User(user_args) => {
cli::user_command::handle_command(user_args, message_stream).await
}
Command::Db(db_args) => {
cli::database_command::handle_command(db_args, message_stream).await
}
Command::Other(other_args) => {
cli::other_command::handle_command(other_args, message_stream).await
}
Command::Server(_) => unreachable!(),
Command::GenerateCompletions(_) => unreachable!(),
}
})
}

View File

@@ -1,6 +1,7 @@
pub mod command;
pub mod authorization;
mod common;
pub mod config;
pub mod input_sanitization;
pub mod server_loop;
pub mod landlock;
pub mod session_handler;
pub mod sql;
pub mod supervisor;

145
src/server/authorization.rs Normal file
View File

@@ -0,0 +1,145 @@
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},
},
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)
{
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

@@ -1,133 +0,0 @@
use std::path::PathBuf;
use anyhow::Context;
use clap::Parser;
use clap_verbosity_flag::Verbosity;
use systemd_journal_logger::JournalLog;
use crate::server::{
config::{ServerConfigArgs, read_config_from_path_with_arg_overrides},
server_loop::{
listen_for_incoming_connections_with_socket_path,
listen_for_incoming_connections_with_systemd_socket,
},
};
#[derive(Parser, Debug, Clone)]
pub struct ServerArgs {
#[command(subcommand)]
subcmd: ServerCommand,
#[command(flatten)]
config_overrides: ServerConfigArgs,
#[arg(long)]
systemd: bool,
}
#[derive(Parser, Debug, Clone)]
pub enum ServerCommand {
#[command()]
Listen,
#[command()]
SocketActivate,
}
const LOG_LEVEL_WARNING: &str = r#"
===================================================
== WARNING: LOG LEVEL IS SET TO 'TRACE'! ==
== THIS WILL CAUSE THE SERVER TO LOG SQL QUERIES ==
== THAT MAY CONTAIN SENSITIVE INFORMATION LIKE ==
== PASSWORDS AND AUTHENTICATION TOKENS. ==
== THIS IS INTENDED FOR DEBUGGING PURPOSES ONLY ==
== AND SHOULD *NEVER* BE USED IN PRODUCTION. ==
===================================================
"#;
pub async fn handle_command(
socket_path: Option<PathBuf>,
config_path: Option<PathBuf>,
verbosity: Verbosity,
args: ServerArgs,
) -> anyhow::Result<()> {
let mut auto_detected_systemd_mode = false;
let systemd_mode = args.systemd || {
if let Ok(true) = sd_notify::booted() {
auto_detected_systemd_mode = true;
true
} else {
false
}
};
if systemd_mode {
JournalLog::new()
.context("Failed to initialize journald logging")?
.install()
.context("Failed to install journald logger")?;
log::set_max_level(verbosity.log_level_filter());
if verbosity.log_level_filter() >= log::LevelFilter::Trace {
log::warn!("{}", LOG_LEVEL_WARNING.trim());
}
if auto_detected_systemd_mode {
log::info!("Running in systemd mode, auto-detected");
} else {
log::info!("Running in systemd mode");
}
start_watchdog_thread_if_enabled();
} else {
env_logger::Builder::new()
.filter_level(verbosity.log_level_filter())
.init();
log::info!("Running in standalone mode");
}
let config = read_config_from_path_with_arg_overrides(config_path, args.config_overrides)?;
match args.subcmd {
ServerCommand::Listen => {
listen_for_incoming_connections_with_socket_path(socket_path, config).await
}
ServerCommand::SocketActivate => {
if !args.systemd {
anyhow::bail!(concat!(
"The `--systemd` flag must be used with the `socket-activate` command.\n",
"This command currently only supports socket activation under systemd."
));
}
listen_for_incoming_connections_with_systemd_socket(config).await
}
}
}
fn start_watchdog_thread_if_enabled() {
let mut micro_seconds: u64 = 0;
let watchdog_enabled = sd_notify::watchdog_enabled(false, &mut micro_seconds);
if watchdog_enabled {
micro_seconds = micro_seconds.max(2_000_000).div_ceil(2);
tokio::spawn(async move {
log::debug!(
"Starting systemd watchdog thread with {} millisecond interval",
micro_seconds.div_ceil(1000)
);
loop {
tokio::time::sleep(tokio::time::Duration::from_micros(micro_seconds)).await;
if let Err(err) = sd_notify::notify(false, &[sd_notify::NotifyState::Watchdog]) {
log::warn!("Failed to notify systemd watchdog: {}", err);
} else {
log::trace!("Ping sent to systemd watchdog");
}
}
});
} else {
log::debug!("Systemd watchdog not enabled, skipping watchdog thread");
}
}

View File

@@ -1,19 +1,43 @@
use crate::core::common::UnixUser;
use crate::core::{common::UnixUser, protocol::request_validation::GroupDenylist};
use nix::unistd::Group;
use sqlx::prelude::*;
/// This function retrieves the groups of a user, filtering out any groups
/// that are present in the provided denylist.
pub fn get_user_filtered_groups(user: &UnixUser, group_denylist: &GroupDenylist) -> Vec<String> {
user.groups
.iter()
.cloned()
.filter_map(|group_name| {
match Group::from_name(&group_name) {
Ok(Some(group)) => {
if group_denylist.contains(&group.gid.as_raw()) {
None
} else {
Some(group.name)
}
}
// NOTE: allow non-existing groups to pass through the filter
_ => Some(group_name),
}
})
.collect()
}
/// This function creates a regex that matches items (users, databases)
/// that belong to the user or any of the user's groups.
pub fn create_user_group_matching_regex(user: &UnixUser) -> String {
if user.groups.is_empty() {
pub fn create_user_group_matching_regex(user: &UnixUser, group_denylist: &GroupDenylist) -> String {
let filtered_groups = get_user_filtered_groups(user, group_denylist);
if filtered_groups.is_empty() {
format!("{}_.+", user.username)
} else {
format!("({}|{})_.+", user.username, user.groups.join("|"))
format!("({}|{})_.+", user.username, filtered_groups.join("|"))
}
}
/// 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,
@@ -37,7 +61,8 @@ mod tests {
groups: vec!["group1".to_owned(), "group2".to_owned()],
};
let regex = create_user_group_matching_regex(&user);
let regex = create_user_group_matching_regex(&user, &GroupDenylist::new());
println!("Generated regex: {}", regex);
let re = Regex::new(&regex).unwrap();
assert!(re.is_match("user_something"));

View File

@@ -1,170 +1,103 @@
use std::{fs, path::PathBuf, time::Duration};
use std::{
fs,
path::{Path, PathBuf},
};
use anyhow::{Context, anyhow};
use clap::Parser;
use anyhow::Context;
use serde::{Deserialize, Serialize};
use sqlx::{ConnectOptions, MySqlConnection, mysql::MySqlConnectOptions};
use crate::core::common::DEFAULT_CONFIG_PATH;
use sqlx::{ConnectOptions, mysql::MySqlConnectOptions};
pub const DEFAULT_PORT: u16 = 3306;
pub const DEFAULT_TIMEOUT: u64 = 2;
// NOTE: this might look empty now, and the extra wrapping for the mysql
// config seems unnecessary, but it will be useful later when we
// add more configuration options.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ServerConfig {
pub mysql: MysqlConfig,
fn default_mysql_port() -> u16 {
DEFAULT_PORT
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub const DEFAULT_TIMEOUT: u64 = 2;
fn default_mysql_timeout() -> u64 {
DEFAULT_TIMEOUT
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename = "mysql")]
pub struct MysqlConfig {
pub socket_path: Option<PathBuf>,
pub host: Option<String>,
pub port: Option<u16>,
#[serde(default = "default_mysql_port")]
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
pub password_file: Option<PathBuf>,
pub timeout: Option<u64>,
#[serde(default = "default_mysql_timeout")]
pub timeout: u64,
}
#[derive(Parser, Debug, Clone)]
pub struct ServerConfigArgs {
/// Path to the socket of the MySQL server.
#[arg(long, value_name = "PATH", global = true)]
socket_path: Option<PathBuf>,
impl MysqlConfig {
pub fn as_mysql_connect_options(&self) -> anyhow::Result<MySqlConnectOptions> {
let mut options = MySqlConnectOptions::new()
.database("mysql")
.log_statements(tracing::log::LevelFilter::Trace);
/// Hostname of the MySQL server.
#[arg(
long,
value_name = "HOST",
global = true,
conflicts_with = "socket_path"
)]
mysql_host: Option<String>,
/// Port of the MySQL server.
#[arg(
long,
value_name = "PORT",
global = true,
conflicts_with = "socket_path"
)]
mysql_port: Option<u16>,
/// Username to use for the MySQL connection.
#[arg(long, value_name = "USER", global = true)]
mysql_user: Option<String>,
/// Path to a file containing the MySQL password.
#[arg(long, value_name = "PATH", global = true)]
mysql_password_file: Option<PathBuf>,
/// Seconds to wait for the MySQL connection to be established.
#[arg(long, value_name = "SECONDS", global = true)]
mysql_connect_timeout: Option<u64>,
}
/// Use the arguments and whichever configuration file which might or might not
/// be found and default values to determine the configuration for the program.
pub fn read_config_from_path_with_arg_overrides(
config_path: Option<PathBuf>,
args: ServerConfigArgs,
) -> anyhow::Result<ServerConfig> {
let config = read_config_from_path(config_path)?;
let mysql = config.mysql;
let password = if let Some(path) = &args.mysql_password_file {
Some(
fs::read_to_string(path)
.context("Failed to read MySQL password file")
.map(|s| s.trim().to_owned())?,
)
} else if let Some(path) = &mysql.password_file {
Some(
fs::read_to_string(path)
.context("Failed to read MySQL password file")
.map(|s| s.trim().to_owned())?,
)
} else {
mysql.password.to_owned()
};
Ok(ServerConfig {
mysql: MysqlConfig {
socket_path: args.socket_path.or(mysql.socket_path),
host: args.mysql_host.or(mysql.host),
port: args.mysql_port.or(mysql.port),
username: args.mysql_user.or(mysql.username.to_owned()),
password,
password_file: args.mysql_password_file.or(mysql.password_file),
timeout: args.mysql_connect_timeout.or(mysql.timeout),
},
})
}
pub fn read_config_from_path(config_path: Option<PathBuf>) -> anyhow::Result<ServerConfig> {
let config_path = config_path.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH));
log::debug!("Reading config file at {:?}", &config_path);
fs::read_to_string(&config_path)
.context(format!("Failed to read config file at {:?}", &config_path))
.and_then(|c| toml::from_str(&c).context("Failed to parse config file"))
.context(format!("Failed to parse config file at {:?}", &config_path))
}
fn log_config(config: &MysqlConfig) {
let mut display_config = config.to_owned();
display_config.password = display_config
.password
.as_ref()
.map(|_| "<REDACTED>".to_owned());
log::debug!(
"Connecting to MySQL server with parameters: {:#?}",
display_config
);
}
/// Use the provided configuration to establish a connection to a MySQL server.
pub async fn create_mysql_connection_from_config(
config: &MysqlConfig,
) -> anyhow::Result<MySqlConnection> {
log_config(config);
let mut mysql_options = MySqlConnectOptions::new()
.database("mysql")
.log_statements(log::LevelFilter::Trace);
if let Some(username) = &config.username {
mysql_options = mysql_options.username(username);
}
if let Some(password) = &config.password {
mysql_options = mysql_options.password(password);
}
if let Some(socket_path) = &config.socket_path {
mysql_options = mysql_options.socket(socket_path);
} else if let Some(host) = &config.host {
mysql_options = mysql_options.host(host);
mysql_options = mysql_options.port(config.port.unwrap_or(DEFAULT_PORT));
} else {
anyhow::bail!("No MySQL host or socket path provided");
}
match tokio::time::timeout(
Duration::from_secs(config.timeout.unwrap_or(DEFAULT_TIMEOUT)),
mysql_options.connect(),
)
.await
{
Ok(connection) => connection.context("Failed to connect to the database"),
Err(_) => {
Err(anyhow!("Timed out after 2 seconds")).context("Failed to connect to the database")
if let Some(username) = &self.username {
options = options.username(username);
}
if let Some(password_file) = &self.password_file {
let password = fs::read_to_string(password_file)
.with_context(|| {
format!("Failed to read MySQL password file at {password_file:?}")
})?
.trim()
.to_owned();
options = options.password(&password);
} else if let Some(password) = &self.password {
options = options.password(password);
}
if let Some(socket_path) = &self.socket_path {
options = options.socket(socket_path);
} else if let Some(host) = &self.host {
options = options.host(host);
options = options.port(self.port);
} else {
anyhow::bail!("No MySQL host or socket path provided");
}
Ok(options)
}
pub fn log_connection_notice(&self) {
let mut display_config = self.to_owned();
display_config.password = display_config
.password
.as_ref()
.map(|_| "<REDACTED>".to_owned());
tracing::debug!(
"Connecting to MySQL server with parameters: {:#?}",
display_config
);
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct 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,
}
impl ServerConfig {
/// Reads the server configuration from the specified path, or the default path if none is provided.
pub fn read_config_from_path(config_path: &Path) -> anyhow::Result<Self> {
tracing::debug!("Reading config file at {:?}", config_path);
fs::read_to_string(config_path)
.context(format!("Failed to read config file at {config_path:?}"))
.and_then(|c| toml::from_str(&c).context("Failed to parse config file"))
.context(format!("Failed to parse config file at {config_path:?}"))
}
}

View File

@@ -1,136 +0,0 @@
use crate::core::{
common::UnixUser,
protocol::server_responses::{NameValidationError, OwnerValidationError},
};
const MAX_NAME_LENGTH: usize = 64;
pub fn validate_name(name: &str) -> Result<(), NameValidationError> {
if name.is_empty() {
Err(NameValidationError::EmptyString)
} else if name.len() > MAX_NAME_LENGTH {
Err(NameValidationError::TooLong)
} else if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
Err(NameValidationError::InvalidCharacters)
} else {
Ok(())
}
}
pub fn validate_ownership_by_unix_user(
name: &str,
user: &UnixUser,
) -> Result<(), OwnerValidationError> {
let prefixes = std::iter::once(user.username.to_owned())
.chain(user.groups.iter().cloned())
.collect::<Vec<String>>();
validate_ownership_by_prefixes(name, &prefixes)
}
/// Core logic for validating the ownership of a database name.
/// This function checks if the given name matches any of the given prefixes.
/// These prefixes will in most cases be the user's unix username and any
/// unix groups the user is a member of.
pub fn validate_ownership_by_prefixes(
name: &str,
prefixes: &[String],
) -> Result<(), OwnerValidationError> {
if name.is_empty() {
return Err(OwnerValidationError::StringEmpty);
}
if prefixes
.iter()
.filter(|p| name.starts_with(&(p.to_string() + "_")))
.collect::<Vec<_>>()
.is_empty()
{
return Err(OwnerValidationError::NoMatch);
};
Ok(())
}
#[inline]
pub fn quote_literal(s: &str) -> String {
format!("'{}'", s.replace('\'', r"\'"))
}
#[inline]
pub fn quote_identifier(s: &str) -> String {
format!("`{}`", s.replace('`', r"\`"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quote_literal() {
let payload = "' OR 1=1 --";
assert_eq!(quote_literal(payload), r#"'\' OR 1=1 --'"#);
}
#[test]
fn test_quote_identifier() {
let payload = "` OR 1=1 --";
assert_eq!(quote_identifier(payload), r#"`\` OR 1=1 --`"#);
}
#[test]
fn test_validate_name() {
assert_eq!(validate_name(""), Err(NameValidationError::EmptyString));
assert_eq!(validate_name("abcdefghijklmnopqrstuvwxyz"), Ok(()));
assert_eq!(validate_name("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), Ok(()));
assert_eq!(validate_name("0123456789_-"), Ok(()));
for c in "\n\t\r !@#$%^&*()+=[]{}|;:,.<>?/".chars() {
assert_eq!(
validate_name(&c.to_string()),
Err(NameValidationError::InvalidCharacters)
);
}
assert_eq!(validate_name(&"a".repeat(MAX_NAME_LENGTH)), Ok(()));
assert_eq!(
validate_name(&"a".repeat(MAX_NAME_LENGTH + 1)),
Err(NameValidationError::TooLong)
);
}
#[test]
fn test_validate_owner_by_prefixes() {
let prefixes = vec!["user".to_string(), "group".to_string()];
assert_eq!(
validate_ownership_by_prefixes("", &prefixes),
Err(OwnerValidationError::StringEmpty)
);
assert_eq!(
validate_ownership_by_prefixes("user_testdb", &prefixes),
Ok(())
);
assert_eq!(
validate_ownership_by_prefixes("group_testdb", &prefixes),
Ok(())
);
assert_eq!(
validate_ownership_by_prefixes("group_test_db", &prefixes),
Ok(())
);
assert_eq!(
validate_ownership_by_prefixes("group_test-db", &prefixes),
Ok(())
);
assert_eq!(
validate_ownership_by_prefixes("nonexistent_testdb", &prefixes),
Err(OwnerValidationError::NoMatch)
);
}
}

89
src/server/landlock.rs Normal file
View File

@@ -0,0 +1,89 @@
#[cfg(target_os = "linux")]
use std::path::Path;
#[cfg(target_os = "linux")]
pub fn landlock_restrict_server(config_path: Option<&Path>) -> anyhow::Result<()> {
use crate::{core::common::DEFAULT_CONFIG_PATH, server::config::ServerConfig};
use anyhow::Context;
use landlock::{
ABI, Access, AccessFs, AccessNet, NetPort, Ruleset, RulesetAttr, RulesetCreatedAttr,
path_beneath_rules,
};
let config_path = config_path.unwrap_or(Path::new(DEFAULT_CONFIG_PATH));
let config = ServerConfig::read_config_from_path(config_path)?;
let abi = ABI::V4;
let mut ruleset = Ruleset::default()
.handle_access(AccessFs::from_all(abi))?
.handle_access(AccessNet::from_all(abi))?
.create()
.context("Failed to create Landlock ruleset")?
.add_rules(path_beneath_rules(
&["/run/muscl"],
AccessFs::from_read(abi),
))
.context("Failed to add Landlock rules for /run/muscl")?
// Needs read access to /etc to access unix user/group info
.add_rules(path_beneath_rules(&["/etc"], AccessFs::from_read(abi)))
.context("Failed to add Landlock rules for /etc")?
.add_rules(path_beneath_rules(&[config_path], AccessFs::from_read(abi)))
.context(format!(
"Failed to add Landlock rules for server config path at {}",
config_path.display()
))?;
if let Some(socket_path) = &config.socket_path {
ruleset = ruleset
.add_rules(path_beneath_rules(&[socket_path], AccessFs::from_all(abi)))
.context(format!(
"Failed to add Landlock rules for server socket path at {}",
socket_path.display()
))?;
}
if let Some(mysql_socket_path) = &config.mysql.socket_path {
ruleset = ruleset
.add_rules(path_beneath_rules(
&[mysql_socket_path],
AccessFs::from_all(abi),
))
.context(format!(
"Failed to add Landlock rules for MySQL socket path at {}",
mysql_socket_path.display()
))?;
}
if let Some(mysql_host) = &config.mysql.host {
ruleset = ruleset
.add_rule(NetPort::new(config.mysql.port, AccessNet::ConnectTcp))
.context(format!(
"Failed to add Landlock rules for MySQL host at {}:{}",
mysql_host, config.mysql.port
))?;
}
if let Some(mysql_passwd_file) = &config.mysql.password_file {
ruleset = ruleset
.add_rules(path_beneath_rules(
&[mysql_passwd_file],
AccessFs::from_read(abi),
))
.context(format!(
"Failed to add Landlock rules for MySQL password file at {}",
mysql_passwd_file.display()
))?;
}
ruleset
.restrict_self()
.context("Failed to apply Landlock restrictions to the server process")?;
Ok(())
}
#[cfg(not(target_os = "linux"))]
pub fn landlock_restrict_server() -> anyhow::Result<()> {
Ok(())
}

Some files were not shown because too many files have changed in this diff Show More